Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
671ee98ab1 | ||
|
|
f59c7db7bb | ||
|
|
15efbcc205 | ||
|
|
ff48a25186 | ||
|
|
e681e9e05c | ||
|
|
d5427926d5 | ||
|
|
bbaa5e8859 | ||
|
|
7510603b77 | ||
|
|
b9a6f5dd7d | ||
|
|
5833a0868e | ||
|
|
f534f76b23 | ||
|
|
e33adb8bbf | ||
|
|
60a35509d4 | ||
|
|
a1c0264d36 |
+85
-3
@@ -625,14 +625,17 @@ Invalid receipts return 502; delivery remains uncertain, so retry using the orig
|
||||
### `GET /v1/agent/read-state`
|
||||
### `PUT /v1/agent/read-state`
|
||||
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
|
||||
`{ "cursor": "43" }` and returns the same shape. The cursor is shared across this
|
||||
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z", "sourceId": "00000000-0000-4000-8000-000000000001" }`.
|
||||
Initially: `{ "cursor": "0", "updatedAt": null, "sourceId": null }` (after source discovery the zero row also has its source UUID).
|
||||
PUT takes `{ "cursor": "43", "sourceId": "00000000-0000-4000-8000-000000000001" }`; `sourceId` is optional for old clients.
|
||||
A supplied source must match the bound runtime or the server returns 409. Both operations return the same additive response shape. The cursor is shared across this
|
||||
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
|
||||
a stale browser from lowering a cursor saved by another browser. Advance it only
|
||||
after showing the corresponding history; poll it to reconcile unread indicators
|
||||
across clients. Agent REST responses use `Cache-Control: no-store`.
|
||||
|
||||
Source-aware clients bind both history and queued acknowledgments to the first accepted read source. A missing/null source is a legacy binding once read state or history has been accepted, not permission to adopt a later UUID. A source mismatch, HTTP 409 or invalid/non-advancing PUT acknowledgment stops old read writers and requires an explicit fresh history load; old cursors must never be relabeled. Known-source writes include `sourceId`; legacy writes omit it. Client reload does not reset server read state. See the source-binding rules in the streaming contract.
|
||||
|
||||
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
|
||||
|
||||
### Agent attachments (v1)
|
||||
@@ -686,3 +689,82 @@ Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT
|
||||
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
|
||||
|
||||
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
|
||||
|
||||
|
||||
## Agent push notifications (v1)
|
||||
|
||||
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
|
||||
|
||||
### `GET /v1/agent/push/config`
|
||||
|
||||
```json
|
||||
{"version":1,"enabled":false,"apnsAvailable":false,"webPushAvailable":false,"sourceId":null,"quarantined":false,"policy":{"promptGraceMs":5000,"debounceMs":2000,"interactionGraceMs":5000,"inactiveGraceMs":5000,"unknownGraceMs":5000,"presenceHeartbeatMs":15000,"presenceLeaseMs":60000}}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
### `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:
|
||||
|
||||
```json
|
||||
{"transport":"apns","enabled":true,"token":"hex-device-token","environment":"production","topic":"net.buzzert.sybil2"}
|
||||
```
|
||||
|
||||
Device tokens must be even-length hexadecimal, 32–512 characters; use the exact token returned by the OS. The server pins permitted topic and environment. TestFlight/App Store normally use production; local development must match the signed `aps-environment` entitlement. Tokens rotate: upsert the current token after registration callbacks and each opted-in launch. App Store upload passwords cannot authenticate APNs.
|
||||
|
||||
Web body (the keys and endpoint come from the browser's actual `PushSubscription`):
|
||||
|
||||
```json
|
||||
{"transport":"web","enabled":true,"subscription":{"endpoint":"https://web.push.apple.com/provider-path","expirationTime":null,"keys":{"p256dh":"base64url-P256-public-key","auth":"base64url-16-byte-auth-secret"}}}
|
||||
```
|
||||
|
||||
Subscription keys are curve/size validated. Endpoints must use HTTPS, port 443, no userinfo/fragment, and the standard allowlisted Apple (`web.push.apple.com` or one-label `*.push.apple.com`), Google (`fcm.googleapis.com`) or Mozilla (`updates.push.services.mozilla.com`) provider hosts. HTTP redirects are never followed; DNS addresses are checked at connection time to reject private/reserved addresses. Other providers require a reviewed server allowlist change. There is no arbitrary push URL fetch.
|
||||
|
||||
Response: `{ "installationId":"uuid", "transport":"apns|web", "enabled":true, "version":1 }`. No endpoint/token/key is reflected. Identical registrations retain the same version. Rotation increments the version and cancels old pending deliveries; a provider rejection of an old version cannot disable a newer one. A single endpoint is active under at most one installation ID; rebinding it irreversibly retires every prior non-retired UUID for that endpoint, including provider-disabled and prior credential-generation registrations. A provider-invalid UUID may refresh only until a newer installation claims its endpoint. A PUT to an explicitly retired UUID returns 409. At most 64 opted-in installations are allowed (429 when full).
|
||||
|
||||
### `DELETE /v1/agent/push/installations/:installationId`
|
||||
|
||||
Idempotent authenticated 204. Irreversibly retires the UUID, including an unknown UUID (durable tombstone), removes stored transport payload, increments its generation once and cancels pending deliveries. Late/delayed PUTs cannot re-enable a retired UUID, including after restart. A new explicit user opt-in must mint a **new UUID**; rotations may continue using the still-active UUID. Provider-invalid disabled endpoints are separate from explicit retirement and may re-register their active UUID. Clients unregister before discarding their bearer on logout and unsubscribe locally. Offline logout must persist a local revocation tombstone for a later authenticated retry and must never silently re-enable consent. Retry that tombstone only against its original backend and credential authority; a 204 from a different backend does not acknowledge the old revocation. Persist authority digests and installation IDs, never raw credentials. Changing source under the same authority can still revoke the old UUID. A transport request already crossing the provider boundary cannot be recalled reliably.
|
||||
|
||||
### `PUT /v1/agent/presence/:sessionId`
|
||||
|
||||
A UUID per browser document/tab, independent of notification permission and selected workspace tab:
|
||||
|
||||
```json
|
||||
{"sequence":12,"visibility":"visible","focused":true,"interacted":false}
|
||||
```
|
||||
|
||||
Response `{ "expiresAt":"2026-10-15T03:00:00.000Z" }`. Sequence is a nonnegative signed 32-bit integer; stale/out-of-order heartbeats neither overwrite state nor extend the lease. Report every 15 seconds and on focus, blur, visibility changes, pagehide/pageshow and trusted pointer/key/touch/wheel/input events. Observe `document.visibilityState`, `document.hidden` and `document.hasFocus()`; pagehide explicitly reports hidden/unfocused. Times are assigned by the server. Each authenticated document's explicit lease lasts 60 seconds. Visible **and focused** blocks even while idle. Interaction reported while visible blocks for 5 seconds, including a subsequent blur/hide; hidden reports cannot create new interaction evidence. A live focused-to-hidden/unfocused transition adds at most 5 seconds of grace. Repeated hidden/unfocused heartbeats do not renew that transition grace. All authenticated workspace sessions participate independently, even without a socket or notification opt-in; at most 512 unexpired sessions are accepted. An inactive tab cannot override another active tab. Associate the same UUID and authenticated credential generation with WS auth as described in the streaming contract.
|
||||
|
||||
Unobserved authenticated clients (including a declared web UUID with no matching observation) share **one persisted 5-second introduction window per credential generation**, starting at the first such connection. Socket pings/pongs, reconnects, new unobserved UUIDs and backend restarts do not renew it. With no stable anonymous identity, subsequent raw connections cannot earn a new window: this deliberately favors eventual notification eligibility over indefinite suppression by uninstrumented clients. Fresh explicit document observations always retain their own veto. Expiring an observed lease does not itself create unknown attention. Connection liveness and web attention are separate; neither a socket nor its absence proves foreground state.
|
||||
|
||||
`DELETE /v1/agent/presence/:sessionId` returns 204 and releases focus/visibility best effort with the same short transition/interaction grace. It does not revoke push consent. Backend restarts still impose a separate 60-second safety warm-up, with explicit leases and the spent anonymous introduction window preserved across restart. If a page disappears without a successful hidden/blur receipt, its last explicit focus can block for the remaining 60-second lease.
|
||||
|
||||
### Scheduling, read reconciliation and delivery identities
|
||||
|
||||
Only complete user-visible root assistant messages enter the durable outbox. Tool output, model reasoning, worker envelopes and raw events do not. A runtime external event or timer may result in a later qualifying assistant message. First source discovery bootstraps at the current high-water cursor without historical alerts. Checkpoint advancement and enqueue are atomic. Latest accepted human input, including queued input and runtime-native channels, delays alerts for at least five seconds; backend prompt admission also records a durable five-second guard before proxying, covering a concurrent source snapshot (a failed submission only delays briefly). Bursts debounce for two seconds, with a 15-second burst cap subject to the prompt grace. Timer/external-event results and replies arriving at least 30 seconds after a prompt receive higher queue priority; coalescing keeps the latest message identity and preserves the burst's higher priority.
|
||||
|
||||
All web sessions must be inactive at dispatch, including the runtime-native browser. Before each provider request, the backend refreshes runtime read/prompt/presence, merges native read into the canonical shared read row, and transactionally rechecks opt-in/version, owner generation, source freshness, read cursor and all attention leases. Unknown/malformed/unavailable source data suppresses sends. Messages expire after five minutes; provider TTL is at most 60 seconds. The active outbox coalesces per installation; completed receipts are capped at 10,000 and expire after seven days, while revocation tombstones remain durable. There are at most four attempts, only after definite retryable provider rejections, with bounded backoff. A persisted send intent protects crash recovery: interrupted/ambiguous network sends are never automatically replayed. Exactly-once provider/device delivery is not promised; a final focus/read race after dispatch remains possible.
|
||||
|
||||
Each outbox row retains nullable `lastSuppressionReason`, `lastSuppressionClientKind` and server `lastSuppressedAt` for operator diagnosis, even after read cancellation or a later send. These fields record one evaluated veto, not a full session census, and do not retroactively explain older rows. Reasons are fixed codes for foreground focus, recent interaction, inactive/unknown grace, runtime active/unknown, prompt grace, startup warm-up or source/coordinator unavailability. Client kinds are `web`, `unknown`, `runtime_web`, `runtime_unknown` or `system`; no token, session/connection/installation identifier, endpoint or transcript is logged. They are internal database diagnostics, not additions to client responses. Runtime unknown also covers its independent restart/clock/source safety checks; it is not proof that a browser was active.
|
||||
|
||||
Alert payload identity is top-level `{version:1,sourceId,kind:"message",messageId,cursor}`. APNs adds `aps.alert` with title **Sybil Agent** and a native string `body` containing the actual eligible public assistant message preview, default sound and `thread-id:sybil-agent-root`. Web Push adds the same title/preview body, fixed `url:"/?agent=root"` and `tag:"sybil-agent:"+sourceId`. Enabling notifications includes message text by default, including on the lock screen when OS notification settings allow it; there is no separate preview opt-in. Explicit public assistant text is authorized content. Reasoning, tool results/arguments, worker messages, internal events, metadata and credentials are never alternative preview sources.
|
||||
|
||||
Markdown is flattened to readable text: formatting and HTML tags are removed, links retain their visible labels, images retain alt text, code retains text, and whitespace is collapsed. Link/image destinations are not fetched or appended. The body is bounded to 3,000 UTF-8 bytes including JSON escaping, with `…` on truncation at a Unicode code point boundary. The complete serialized APNs JSON is at most 4,096 bytes; Web Push JSON is at most 3,993 bytes, leaving 103 bytes for `aes128gcm` overhead within the 4,096-byte encrypted request budget. Identity/escaping overhead can further shorten the body. A successfully resolved message with no displayable text uses **You have a new message.**. Missing, malformed or unavailable public text suppresses sending until a valid lookup or normal expiry. Normal APNs alerts do not request critical/time-sensitive entitlements, and existing native clients can display the string body without a notification extension or app update.
|
||||
|
||||
Monotonic canonical read advances cancel pending alerts and broadcast `read_state` on authenticated WS. New read-aware clients pass the response's exact source/cursor to their notification manager; receiving/tapping a push does not itself mark history read. Native APNs clear payload is `{version:1,sourceId,kind:"read_state",readCursor,aps:{"content-available":1}}`, push type background/priority 5, coalesced to at most one request per minute per installation that has an earlier delivered/ambiguous alert. App foreground/background handlers reconcile delivered OS notifications by source/cursor. Silent pushes are best effort and cannot guarantee removal when an app is terminated or force-quit; foreground read sync remains required.
|
||||
|
||||
**No silent Web Push read-clear is sent.** Safari requires visible notifications for Web Push. Authenticated foreground pages forward source-bound read state to their service worker; it closes only owned notifications at/below that read cursor. Closing while every browser client is stopped is best effort only. The service worker must never receive/store the general bearer token. Notification click uses the fixed same-origin Agent link and waits for actual rendered history before acknowledging read.
|
||||
|
||||
The web service worker accepts bounded nonempty preview strings, preserving the fixed title, destination, tag and identity validation. It retains the latest preview alongside its existing per-source identity so a late delivery cannot pair older text with a newer cursor, including after worker restart. Legacy stored identities without text use the generic fallback on late deliveries. Generic payloads from older servers remain supported. Deploy the web worker before the preview-producing backend; previously installed generic-only workers must update to display actual-text payloads. The worker never fetches message content or linked resources.
|
||||
|
||||
### Server configuration and runtime source contract
|
||||
|
||||
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 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.
|
||||
|
||||
The runtime browser uses the same 15-second explicit heartbeat, 60-second lease and 5-second interaction/hidden/blur grace. Unobserved direct runtime sockets, event streams and REST pollers share one persisted 5-second introduction window per runtime source, beginning at their first establishment/observation. Polling and reconnects cannot renew it, including with fresh unobserved UUIDs. Existing source/clock uncertainty and the separate 60-second runtime restart safety interval still suppress; the backend never ignores a returned `unknown:true`.
|
||||
|
||||
@@ -373,3 +373,24 @@ Merge queued and committed messages by stable message ID. A matching ID alone do
|
||||
Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract.
|
||||
|
||||
Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path.
|
||||
|
||||
|
||||
### Agent attention and shared read events (push v1)
|
||||
|
||||
Web auth frames always add `clientKind:"web"` to the existing `type`, `token`, and optional `after`. They also add `presenceSessionId:"per-document-uuid"` when secure UUID creation succeeds; omit only that field if unavailable. Report the same UUID to `PUT /v1/agent/presence/:sessionId` across the entire authenticated workspace. Native iOS clients add `clientKind:"native"`; no browser-attention lease is required for a native app. These declarations classify a client under the existing shared principal and do not grant new authority. Explicit document visibility/focus/interaction receipts, bound to that credential generation and session, determine attention independently of sockets. Every fresh visible/focused session suppresses, even while idle; hidden/blur transitions and recent visible interaction have 5-second grace, and unrenewed explicit observations expire after 60 seconds.
|
||||
|
||||
Old/unclassified authenticated sockets and web sockets with no matching observation share one persisted 5-second introduction grace per credential generation. Reconnects, missing/new UUIDs and transport heartbeat cannot extend or restart it. A spent anonymous window does not weaken any genuine document lease; an expired observed lease does not become unknown merely because its socket remains connected. Ping/pong updates only existing connection liveness, preserving the original kind/session and never recreating a removed row as unknown. The proxy classifies itself upstream using the authenticated `X-Sybil-Client: backend-proxy-v1` upgrade header.
|
||||
|
||||
The backend adds this optional server-to-client frame after a successful monotonic read advance, including a runtime-native browser acknowledgment merged into the shared row:
|
||||
|
||||
```json
|
||||
{"type":"read_state","cursor":"44","updatedAt":"2026-10-15T03:00:00.000Z","sourceId":"00000000-0000-4000-8000-000000000001"}
|
||||
```
|
||||
|
||||
`sourceId` is nullable before source discovery and is never silently rebound after runtime replacement. Source replacement quarantines push delivery; the backend does not reset old read cursors. Apply read cursors monotonically within their source, and only clear notifications whose payload source matches this frame and whose cursor is at/below it. Frames without a source remain usable by legacy unread indicators but cannot safely authorize notification clearing. Clients still poll `GET /v1/agent/read-state` on foreground/reconnect to recover missed frames; receiving history, activity, a push, or a notification tap does not acknowledge that history was viewed.
|
||||
|
||||
Distinguish an unbound room from one already bound to a legacy absent/null source or a source UUID. Bind on the first accepted read state; if history arrives first, pin the legacy state before publishing it. A different source in a later GET, stream frame or PUT acknowledgment, including null-to-UUID or UUID-to-null, must stop stale read writes and require an explicit fresh room load. Preserve the old snapshot and cursors until that reload, then fetch read state before fresh history; never attach a new UUID to an old cursor. Ordinary foreground/network recovery must not reset this stopped room. HTTP 409, an invalid acknowledgment cursor or an acknowledgment below the requested cursor also stops the writer. A transient transport failure can retry only within the same pinned source. None of these client reloads resets server read state.
|
||||
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SybilAPNSEnvironment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array><string>remote-notification</string></array>
|
||||
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
|
||||
<key>NSSupportsAutomaticTermination</key>
|
||||
<false/>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 791 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png",
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 877 KiB |
@@ -1,6 +1,7 @@
|
||||
import Sybil
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct SybilApp: App
|
||||
@@ -18,11 +19,13 @@ struct SybilApp: App
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
SybilPushNotifications.launchFixtureIfRequested()
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
@@ -30,6 +33,31 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
SybilPushNotifications.didRegister(deviceToken: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
SybilPushNotifications.didFailToRegister()
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
SybilPushNotifications.receiveBackground(userInfo: userInfo) { result in completionHandler(result) }
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
let event = SybilPushNotifications.Event(userInfo: notification.request.content.userInfo)
|
||||
return await SybilPushNotifications.presentationOptions(event: event)
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
let event = SybilPushNotifications.Event(userInfo: response.notification.request.content.userInfo)
|
||||
await SybilPushNotifications.didRespond(event: event, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
@@ -73,6 +101,9 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
if let response = connectionOptions.notificationResponse, response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
SybilPushNotifications.didRespond(userInfo: response.notification.request.content.userInfo, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,6 +5,12 @@ targets:
|
||||
- iOS
|
||||
- macCatalyst
|
||||
deploymentTarget: "18.0"
|
||||
attributes:
|
||||
SystemCapabilities:
|
||||
com.apple.Push:
|
||||
enabled: 1
|
||||
com.apple.BackgroundModes:
|
||||
enabled: 1
|
||||
sources:
|
||||
- Sources
|
||||
- Resources
|
||||
@@ -25,6 +31,9 @@ targets:
|
||||
PRODUCT_MODULE_NAME: SybilApp
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Apps/Sybil/Sybil.entitlements
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]": Apps/Sybil/Sybil.entitlements
|
||||
INFOPLIST_KEY_SybilAPNSEnvironment: "$(SYBIL_APNS_ENVIRONMENT)"
|
||||
SWIFT_VERSION: 6.0
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
@@ -41,7 +50,10 @@ targets:
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
configs:
|
||||
Debug:
|
||||
SYBIL_APNS_ENVIRONMENT: development
|
||||
Release:
|
||||
SYBIL_APNS_ENVIRONMENT: production
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
#if DEBUG
|
||||
/// Opt-in operation counters for the simulator presentation benchmark. These
|
||||
/// measure work, not frames; CADisplayLink sampling lives in the test target.
|
||||
@MainActor
|
||||
enum AgentRenderDiagnostics {
|
||||
static var enabled = false
|
||||
static var counts: [String: Int] = [:]
|
||||
|
||||
static func record(_ operation: String) {
|
||||
guard enabled else { return }
|
||||
counts[operation, default: 0] += 1
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -11,6 +11,7 @@ public struct SplitView: View {
|
||||
@State private var hasPendingQuickQuestionPresentation = false
|
||||
@State private var isQuickQuestionPresented = false
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
|
||||
@State private var notificationSceneID = UUID()
|
||||
|
||||
private var keyboardActions: SybilKeyboardActions? {
|
||||
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
|
||||
@@ -92,8 +93,14 @@ public struct SplitView: View {
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
await viewModel.bootstrap()
|
||||
SybilPushNotifications.foregroundFixtureIfRequested()
|
||||
viewModel.notifications.setScene(notificationSceneID, active: scenePhase == .active)
|
||||
routePendingNotification()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: viewModel.notifications.pendingRouteID) { _, _ in routePendingNotification() }
|
||||
.onChange(of: viewModel.notifications.scope) { _, _ in routePendingNotification() }
|
||||
.onDisappear { viewModel.notifications.setScene(notificationSceneID, active: false) }
|
||||
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
|
||||
guard request > 0 else {
|
||||
return
|
||||
@@ -101,6 +108,7 @@ public struct SplitView: View {
|
||||
queueQuickQuestionPresentation()
|
||||
}
|
||||
.onChange(of: viewModel.isCheckingSession) { _, _ in
|
||||
routePendingNotification()
|
||||
presentPendingQuickQuestionIfPossible()
|
||||
}
|
||||
.onChange(of: viewModel.isAuthenticated) { _, _ in
|
||||
@@ -110,11 +118,14 @@ public struct SplitView: View {
|
||||
viewModel.agent.isModalPresented = presented
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
viewModel.notifications.setScene(notificationSceneID, active: nextPhase == .active)
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
shouldRefreshOnForeground = true
|
||||
viewModel.markAppInactiveForNetwork()
|
||||
case .active:
|
||||
SybilPushNotifications.foregroundFixtureIfRequested()
|
||||
routePendingNotification()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.attach(viewModel)
|
||||
#endif
|
||||
@@ -138,6 +149,13 @@ public struct SplitView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func routePendingNotification() {
|
||||
guard scenePhase == .active, viewModel.consumeAgentNotificationRoute() else { return }
|
||||
hasPendingQuickQuestionPresentation = false
|
||||
isQuickQuestionPresented = false
|
||||
columnVisibility = .detailOnly
|
||||
}
|
||||
|
||||
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
|
||||
return size.width < size.height ? .showSidebar : .hidden
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -64,8 +64,51 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentPushConfig() async throws -> AgentPushConfig {
|
||||
let data = try await pushRequest(path: "/v1/agent/push/config", method: "GET")
|
||||
guard let config = try? JSONDecoder().decode(AgentPushConfig.self, from: data) else { throw APIError.invalidResponse }
|
||||
return config
|
||||
}
|
||||
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
|
||||
let data = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "PUT", body: AnyEncodable(registration))
|
||||
guard let receipt = try? JSONDecoder().decode(AgentPushReceipt.self, from: data) else { throw APIError.invalidResponse }
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentRevokePush(id: UUID) async throws {
|
||||
_ = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "DELETE")
|
||||
}
|
||||
|
||||
private func pushRequest(path: String, method: String, body: AnyEncodable? = nil) async throws -> Data {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
let allowLoopback = true // Local URLProtocol/UI fixtures; no device enrollment.
|
||||
#else
|
||||
let allowLoopback = false
|
||||
#endif
|
||||
guard AgentPushScope.canonicalBackend(configuration.baseURL, allowLoopback: allowLoopback) != nil,
|
||||
configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw APIError.invalidBaseURL }
|
||||
var request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
|
||||
request.timeoutInterval = 8
|
||||
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
|
||||
// Same bounded, redirect-rejecting authenticated Agent transport as attachments.
|
||||
// Never decode/log a server error body that might reflect an address or token.
|
||||
let (data, status, _) = try await attachmentBytes(request, maximum: 64 * 1024)
|
||||
guard method == "DELETE" ? status == 204 : (200...299).contains(status) else { throw AgentPushRequestError(status: status) }
|
||||
return data
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
|
||||
try await agentMarkRead(cursor: cursor, sourceID: nil)
|
||||
}
|
||||
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
var body = ["cursor": try AgentCursor.normalize(cursor)]
|
||||
if let sourceID {
|
||||
guard AgentPushPayload.validSource(sourceID) else { throw APIError.invalidResponse }
|
||||
body["sourceId"] = sourceID
|
||||
}
|
||||
return try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(body), responseType: AgentReadState.self)
|
||||
}
|
||||
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
|
||||
@@ -32,8 +32,9 @@ struct AgentPickedPhoto: Transferable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentComposer: View {
|
||||
struct AgentAttachmentComposer<Prompt: View>: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
@ViewBuilder var prompt: () -> Prompt
|
||||
@State private var modalID = UUID()
|
||||
@State private var pickerSession: UUID?
|
||||
@State private var showChoices = false
|
||||
@@ -78,10 +79,17 @@ struct AgentAttachmentComposer: View {
|
||||
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
||||
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
if room.attachmentDraft.supportsAttachments {
|
||||
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
|
||||
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
|
||||
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
if room.attachmentDraft.supportsAttachments {
|
||||
SybilPromptAttachmentButton(
|
||||
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
|
||||
) {
|
||||
pickerSession = room.attachmentSessionID
|
||||
showChoices = true
|
||||
}
|
||||
.accessibilityIdentifier("agent-attach")
|
||||
}
|
||||
prompt()
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Add attachment", isPresented: $showChoices) {
|
||||
|
||||
@@ -73,6 +73,7 @@ struct AgentPage: Codable, Sendable {
|
||||
struct AgentReadState: Codable, Sendable {
|
||||
var cursor: String
|
||||
var updatedAt: String?
|
||||
var sourceId: String? = nil
|
||||
}
|
||||
struct AgentReceipt: Codable, Sendable {
|
||||
var id: Int64
|
||||
@@ -95,6 +96,7 @@ enum AgentEvent: Sendable {
|
||||
case ready(String?)
|
||||
case messages(AgentPage)
|
||||
case activity(String, AgentActivity)
|
||||
case readState(AgentReadState)
|
||||
case heartbeat
|
||||
case error(String)
|
||||
|
||||
@@ -118,6 +120,13 @@ enum AgentEvent: Sendable {
|
||||
}
|
||||
return .activity(id, activity)
|
||||
case "pong", "heartbeat": return .heartbeat
|
||||
case "read_state":
|
||||
// Additive optional frame: malformed new metadata must not tear down
|
||||
// an otherwise compatible Agent connection.
|
||||
guard let read = try? AgentCoding.decoder().decode(AgentReadState.self, from: data),
|
||||
AgentPushPayload.validCursor(read.cursor) != nil,
|
||||
read.sourceId.map(AgentPushPayload.validSource) ?? true else { return nil }
|
||||
return .readState(read)
|
||||
case "error": return .error(envelope.error ?? "Agent connection error.")
|
||||
default: return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import Foundation
|
||||
import CoreFoundation
|
||||
import CryptoKit
|
||||
|
||||
struct AgentPushConfig: Codable, Sendable {
|
||||
var version: Int
|
||||
var enabled: Bool
|
||||
var apnsAvailable: Bool
|
||||
var webPushAvailable: Bool
|
||||
var sourceId: String?
|
||||
var quarantined: Bool
|
||||
var available: Bool { version == 1 && enabled && apnsAvailable && !quarantined && sourceId.map(AgentPushPayload.validSource) == true }
|
||||
}
|
||||
|
||||
enum AgentAPNSEnvironment: String, Codable, Sendable {
|
||||
case sandbox, production
|
||||
// The app's Info.plist and entitlement expand the SAME XcodeGen build setting.
|
||||
// Never guess from DEBUG, token length, simulator, or marketing version.
|
||||
init?(entitlementSetting: String?) {
|
||||
switch entitlementSetting {
|
||||
case "development": self = .sandbox
|
||||
case "production": self = .production
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPushRegistration: Encodable, Equatable, Sendable {
|
||||
let transport = "apns"
|
||||
let enabled = true
|
||||
var token: String
|
||||
var environment: AgentAPNSEnvironment
|
||||
let topic = "net.buzzert.sybil2"
|
||||
}
|
||||
|
||||
struct AgentPushReceipt: Codable, Sendable {
|
||||
var installationId: String
|
||||
var transport: String
|
||||
var enabled: Bool
|
||||
var version: Int
|
||||
}
|
||||
|
||||
protocol AgentPushAPIClienting: Sendable {
|
||||
func agentPushConfig() async throws -> AgentPushConfig
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt
|
||||
func agentRevokePush(id: UUID) async throws
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
}
|
||||
|
||||
struct AgentPushRequestError: LocalizedError, Sendable {
|
||||
var status: Int
|
||||
var retryable: Bool { status == 408 || status == 429 || status >= 500 }
|
||||
var errorDescription: String? { "Agent notification request failed (\(status))." }
|
||||
}
|
||||
|
||||
struct AgentPushScope: Codable, Equatable, Hashable, Sendable {
|
||||
var backend: String
|
||||
// ADMIN_TOKEN identifies one shared personal principal, not individual people.
|
||||
var owner = "shared-personal-admin"
|
||||
var credentialScope: String
|
||||
var sourceID: String
|
||||
|
||||
static func credentialScope(backend: String, token: String) -> String {
|
||||
// Nonsecret consent epoch, never logged. A credential replacement requires
|
||||
// new consent even when the server still represents one personal principal.
|
||||
SHA256.hash(data: Data((backend + "\n" + token.trimmingCharacters(in: .whitespacesAndNewlines)).utf8))
|
||||
.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
static func canonicalBackend(_ url: URL, allowLoopback: Bool = false) -> String? {
|
||||
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
let host = parts.host?.lowercased(), parts.user == nil, parts.password == nil,
|
||||
parts.query == nil, parts.fragment == nil,
|
||||
parts.scheme?.lowercased() == "https" || (allowLoopback && parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { return nil }
|
||||
parts.scheme = parts.scheme?.lowercased(); parts.host = host
|
||||
if (parts.scheme == "https" && parts.port == 443) || (parts.scheme == "http" && parts.port == 80) { parts.port = nil }
|
||||
let path = parts.path
|
||||
guard !path.contains("//"), !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil }
|
||||
parts.path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty ? "" : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
return parts.url?.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPushPayload: Equatable, Sendable {
|
||||
enum Kind: String { case message, readState = "read_state" }
|
||||
var sourceID: String
|
||||
var kind: Kind
|
||||
var cursor: String
|
||||
var messageID: String?
|
||||
|
||||
init?(_ info: [AnyHashable: Any]) {
|
||||
guard let version = info["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1,
|
||||
let source = info["sourceId"] as? String, Self.validSource(source),
|
||||
let name = info["kind"] as? String, let kind = Kind(rawValue: name),
|
||||
let raw = info[kind == .message ? "cursor" : "readCursor"] as? String,
|
||||
let cursor = Self.validCursor(raw) else { return nil }
|
||||
let message = info["messageId"] as? String
|
||||
if kind == .message && (message?.isEmpty != false || (message?.utf8.count ?? 0) > 256) { return nil }
|
||||
self.sourceID = source.lowercased(); self.kind = kind; self.cursor = cursor; self.messageID = message
|
||||
}
|
||||
|
||||
static func validSource(_ value: String) -> Bool {
|
||||
value.utf8.count == 36 && UUID(uuidString: value) != nil
|
||||
}
|
||||
static func validCursor(_ value: String) -> String? {
|
||||
guard value.utf8.count <= 16, let normalized = try? AgentCursor.normalize(value),
|
||||
!AgentCursor.less("9007199254740991", normalized) else { return nil }
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentDeliveredNotification: Sendable {
|
||||
var identifier: String // UNNotification.request.identifier, never messageId.
|
||||
var payload: AgentPushPayload?
|
||||
}
|
||||
|
||||
enum AgentNotificationPermission: Sendable { case notDetermined, denied, authorized, provisional }
|
||||
|
||||
@MainActor
|
||||
protocol AgentNotificationPlatform: AnyObject {
|
||||
var environment: AgentAPNSEnvironment? { get }
|
||||
var allowsLoopback: Bool { get }
|
||||
func permission() async -> AgentNotificationPermission
|
||||
func requestPermission() async throws -> Bool
|
||||
func register(generation: UUID)
|
||||
func unregister()
|
||||
func delivered() async -> [AgentDeliveredNotification]
|
||||
func removeDelivered(identifiers: [String])
|
||||
}
|
||||
|
||||
struct AgentPushRecord: Codable, Equatable {
|
||||
var scope: AgentPushScope
|
||||
var installationID = UUID()
|
||||
var optedIn = false
|
||||
var mayBeRegistered = false
|
||||
var version: Int?
|
||||
}
|
||||
|
||||
struct AgentPushPersistence: Codable {
|
||||
var records: [AgentPushRecord] = []
|
||||
var tombstones: [AgentPushRecord] = []
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct AgentPushStorage {
|
||||
var read: () throws -> AgentPushPersistence
|
||||
var write: (AgentPushPersistence) throws -> Void
|
||||
|
||||
static func defaults(_ defaults: UserDefaults = .standard) -> Self {
|
||||
let key = "sybil.ios.agentPush.v1"
|
||||
return Self(read: {
|
||||
guard let data = defaults.data(forKey: key) else { return .init() }
|
||||
return try JSONDecoder().decode(AgentPushPersistence.self, from: data)
|
||||
}, write: { value in
|
||||
// No authentication credential or device token is ever persisted here.
|
||||
defaults.set(try JSONEncoder().encode(value), forKey: key)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// One app-lifetime owner of push enrollment. OS addresses stay in memory; retired
|
||||
/// installation IDs are never reused. Every suspension checks its session generation.
|
||||
@MainActor @Observable
|
||||
final class SybilAgentNotifications {
|
||||
static let shared = SybilAgentNotifications(platform: SybilNotificationPlatform.shared, storage: .defaults())
|
||||
|
||||
private(set) var optedIn = false
|
||||
private(set) var available = false
|
||||
private(set) var permission: AgentNotificationPermission = .notDetermined
|
||||
private(set) var status = "Connect to a server to configure notifications."
|
||||
private(set) var pendingRouteID: UUID?
|
||||
private(set) var generation = UUID()
|
||||
private(set) var scope: AgentPushScope?
|
||||
private(set) var pendingRevocations = 0
|
||||
@ObservationIgnored private let platform: any AgentNotificationPlatform
|
||||
@ObservationIgnored private let storage: AgentPushStorage
|
||||
@ObservationIgnored private var saved = AgentPushPersistence()
|
||||
@ObservationIgnored private var storageHealthy = true
|
||||
@ObservationIgnored private var client: (any AgentPushAPIClienting)?
|
||||
@ObservationIgnored private var backend: String?
|
||||
@ObservationIgnored private var credentialScope: String?
|
||||
@ObservationIgnored private var token: String?
|
||||
@ObservationIgnored private var uploaded: AgentPushRegistration?
|
||||
@ObservationIgnored private var enrollment: Task<Void, Never>?
|
||||
@ObservationIgnored private var refreshing: UUID?
|
||||
@ObservationIgnored private var pendingPayload: AgentPushPayload?
|
||||
@ObservationIgnored private var consumedResponses: [String] = []
|
||||
@ObservationIgnored private var readCursor = "0"
|
||||
@ObservationIgnored private var foregroundScenes: Set<UUID> = []
|
||||
@ObservationIgnored private var sessionBackend: String?
|
||||
@ObservationIgnored private var sessionIdentity: String?
|
||||
|
||||
func transition(baseURL: URL?, token: String?) async {
|
||||
// Multiple windows and ordinary reconnects share one app enrollment. Compare
|
||||
// credentials only in memory; never derive/persist an owner from a token.
|
||||
let canonical = baseURL.flatMap { AgentPushScope.canonicalBackend($0, allowLoopback: platform.allowsLoopback) }
|
||||
let identity = canonical.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
|
||||
guard sessionBackend != canonical || sessionIdentity != identity else { return }
|
||||
let hadSession = sessionBackend != nil || sessionIdentity != nil
|
||||
sessionBackend = canonical; sessionIdentity = identity
|
||||
if hadSession { await deactivate() }
|
||||
}
|
||||
|
||||
init(platform: any AgentNotificationPlatform, storage: AgentPushStorage) {
|
||||
self.platform = platform; self.storage = storage
|
||||
do { saved = try storage.read(); pendingRevocations = saved.tombstones.count }
|
||||
catch { storageHealthy = false; status = "Notification settings could not be restored." }
|
||||
}
|
||||
|
||||
private func persist() -> Bool {
|
||||
do { try storage.write(saved); pendingRevocations = saved.tombstones.count; return true }
|
||||
catch { storageHealthy = false; available = false; status = "Notification settings could not be saved."; return false }
|
||||
}
|
||||
private var recordIndex: Int? { scope.flatMap { scope in saved.records.firstIndex { $0.scope == scope } } }
|
||||
|
||||
/// Call BEFORE overwriting the old session settings/client. This invalidates OS,
|
||||
/// config, read, permission and upload callbacks before attempting revocation.
|
||||
@discardableResult func deactivate() async -> UUID {
|
||||
let oldClient = client
|
||||
let oldScope = scope
|
||||
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
||||
let retiringGeneration = generation
|
||||
refreshing = nil; available = false; optedIn = false; readCursor = "0"
|
||||
scope = nil; client = nil; backend = nil; credentialScope = nil
|
||||
if oldScope != nil { pendingPayload = nil; pendingRouteID = nil }
|
||||
platform.unregister()
|
||||
if let oldScope { retire(oldScope, clearOptIn: true) }
|
||||
// Server DELETE is a terminal UUID tombstone, even if PUT is in flight.
|
||||
// Attempt it before settings are replaced. A stale PUT also retries revoke.
|
||||
if let oldClient, let oldScope {
|
||||
await revokePending(client: oldClient, scope: oldScope)
|
||||
}
|
||||
if generation == retiringGeneration {
|
||||
status = pendingRevocations > 0 ? "Revocation pending. Reconnect to the original server to retry." : "Notifications are off."
|
||||
}
|
||||
return retiringGeneration
|
||||
}
|
||||
|
||||
func bind(client: any AgentPushAPIClienting, baseURL: URL, authenticated: Bool, mode: String, token: String?) async {
|
||||
await transition(baseURL: baseURL, token: token)
|
||||
let requestedBackend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback)
|
||||
let requestedIdentity = requestedBackend.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
|
||||
guard sessionBackend == requestedBackend, sessionIdentity == requestedIdentity else { return }
|
||||
guard authenticated, mode == "token", let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storageHealthy,
|
||||
let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback) else {
|
||||
if self.client != nil { await deactivate() }
|
||||
available = false; status = "Notifications require a token-authenticated HTTPS server."; return
|
||||
}
|
||||
self.client = client; self.backend = backend
|
||||
self.credentialScope = AgentPushScope.credentialScope(backend: backend, token: token)
|
||||
await refresh()
|
||||
}
|
||||
|
||||
func refresh() async {
|
||||
guard refreshing == nil, let client, let backend, let credentialScope else { return }
|
||||
let refreshID = UUID()
|
||||
refreshing = refreshID
|
||||
let run = generation
|
||||
defer { if refreshing == refreshID { refreshing = nil } }
|
||||
do {
|
||||
let config = try await client.agentPushConfig()
|
||||
guard generation == run else { return }
|
||||
guard config.version == 1, let source = config.sourceId, AgentPushPayload.validSource(source) else {
|
||||
available = false; status = "This server does not support Agent notifications."; return
|
||||
}
|
||||
let nextScope = AgentPushScope(backend: backend, credentialScope: credentialScope, sourceID: source.lowercased())
|
||||
if let scope, scope != nextScope {
|
||||
let retired = await deactivate()
|
||||
guard generation == retired else { return }
|
||||
self.client = client; self.backend = backend; self.credentialScope = credentialScope
|
||||
await refresh()
|
||||
return
|
||||
}
|
||||
await revokePending(client: client, scope: nextScope)
|
||||
guard generation == run else { return }
|
||||
scope = nextScope
|
||||
if recordIndex == nil { saved.records.append(AgentPushRecord(scope: nextScope)); guard persist() else { return } }
|
||||
optedIn = recordIndex.map { saved.records[$0].optedIn } ?? false
|
||||
available = config.available && platform.environment != nil
|
||||
permission = await platform.permission()
|
||||
guard generation == run else { return }
|
||||
if permission == .denied {
|
||||
await disable()
|
||||
guard self.scope == nextScope else { return }
|
||||
status = "Notifications are denied in iOS Settings."
|
||||
return
|
||||
}
|
||||
if !available {
|
||||
if let index = recordIndex, saved.records[index].mayBeRegistered {
|
||||
// Invalidate both the cached success and any outstanding PUT
|
||||
// before replacing the UUID. A fresh callback must enroll the
|
||||
// replacement even when APNs returns the same token.
|
||||
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
||||
refreshing = nil; platform.unregister()
|
||||
let retirementRun = generation
|
||||
retire(nextScope); await revokePending(client: client, scope: nextScope)
|
||||
guard generation == retirementRun else { return }
|
||||
}
|
||||
status = "Agent notifications are unavailable on this server or build."
|
||||
} else if optedIn && (permission == .authorized || permission == .provisional) {
|
||||
status = "Registering notifications…"
|
||||
// A fresh OS callback is required on each launch/foreground refresh.
|
||||
platform.register(generation: run)
|
||||
} else { status = optedIn ? "Enable notifications to allow alerts." : "Notifications are off." }
|
||||
await syncRead()
|
||||
} catch {
|
||||
guard generation == run else { return }
|
||||
available = false
|
||||
status = "Agent notifications are unavailable. Retry after reconnecting."
|
||||
}
|
||||
}
|
||||
|
||||
/// The only code path that can display the system permission prompt.
|
||||
func enable() async {
|
||||
guard available, let index = recordIndex else { return }
|
||||
let run = generation
|
||||
do {
|
||||
let current = await platform.permission()
|
||||
guard generation == run else { return }
|
||||
let allowed: Bool
|
||||
if current == .notDetermined { allowed = try await platform.requestPermission() }
|
||||
else { allowed = current == .authorized || current == .provisional }
|
||||
guard generation == run else { return }
|
||||
permission = await platform.permission()
|
||||
guard generation == run else { return }
|
||||
guard allowed else { status = "Notifications are denied in iOS Settings."; return }
|
||||
saved.records[index].optedIn = true
|
||||
guard persist() else { return }
|
||||
optedIn = true; status = "Registering notifications…"
|
||||
platform.register(generation: run)
|
||||
} catch { if generation == run { status = "Could not request notification permission. Try again." } }
|
||||
}
|
||||
|
||||
func disable() async {
|
||||
guard let scope else { return }
|
||||
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
|
||||
let disablingGeneration = generation
|
||||
refreshing = nil
|
||||
optedIn = false; platform.unregister()
|
||||
retire(scope, clearOptIn: true)
|
||||
if let client { await revokePending(client: client, scope: scope) }
|
||||
if generation == disablingGeneration {
|
||||
status = pendingRevocations > 0 ? "Notifications off; revocation pending until this server reconnects." : "Notifications are off."
|
||||
}
|
||||
}
|
||||
|
||||
private func retire(_ scope: AgentPushScope, clearOptIn: Bool = false) {
|
||||
guard let index = saved.records.firstIndex(where: { $0.scope == scope }) else { return }
|
||||
let old = saved.records[index]
|
||||
if self.scope == scope { uploaded = nil; token = nil }
|
||||
if old.mayBeRegistered && !saved.tombstones.contains(where: { $0.installationID == old.installationID }) {
|
||||
saved.tombstones.append(old)
|
||||
}
|
||||
saved.records[index] = AgentPushRecord(scope: scope, optedIn: clearOptIn ? false : old.optedIn)
|
||||
_ = persist()
|
||||
}
|
||||
|
||||
private func revokePending(client: any AgentPushAPIClienting, scope: AgentPushScope) async {
|
||||
for record in saved.tombstones.filter({ $0.scope == scope }) {
|
||||
do {
|
||||
try await client.agentRevokePush(id: record.installationID)
|
||||
saved.tombstones.removeAll { $0.installationID == record.installationID }
|
||||
_ = persist()
|
||||
} catch { /* Durable nonsecret tombstone survives offline/logout. */ }
|
||||
}
|
||||
}
|
||||
|
||||
func receivedDeviceToken(_ data: Data, generation run: UUID) {
|
||||
guard generation == run, available, optedIn, !data.isEmpty, data.count <= 256 else { return }
|
||||
token = data.map { String(format: "%02x", $0) }.joined()
|
||||
startEnrollment()
|
||||
}
|
||||
func registrationFailed(generation run: UUID) {
|
||||
guard generation == run else { return }
|
||||
status = "iOS registration failed. Retry when connected."
|
||||
}
|
||||
|
||||
private func startEnrollment() {
|
||||
guard enrollment == nil, optedIn, available, let client, let token,
|
||||
let environment = platform.environment, let index = recordIndex else { return }
|
||||
let registration = AgentPushRegistration(token: token, environment: environment)
|
||||
guard uploaded != registration else { status = "Notifications are enabled."; return }
|
||||
let record = saved.records[index], run = generation
|
||||
// Persist revocation intent BEFORE the first request, even if its response is lost.
|
||||
saved.records[index].mayBeRegistered = true
|
||||
guard persist() else { return }
|
||||
enrollment = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer {
|
||||
if self.generation == run {
|
||||
self.enrollment = nil
|
||||
if self.token != token { self.startEnrollment() }
|
||||
}
|
||||
}
|
||||
for attempt in 0..<3 {
|
||||
do {
|
||||
let receipt = try await client.agentRegisterPush(id: record.installationID, registration: registration)
|
||||
guard self.generation == run else {
|
||||
await self.revokePending(client: client, scope: record.scope); return
|
||||
}
|
||||
guard UUID(uuidString: receipt.installationId) == record.installationID,
|
||||
receipt.transport == "apns", receipt.enabled, receipt.version > 0 else { throw APIError.invalidResponse }
|
||||
guard let index = self.recordIndex, self.saved.records[index].installationID == record.installationID else { return }
|
||||
self.saved.records[index].version = receipt.version
|
||||
guard self.persist() else { return }
|
||||
self.uploaded = registration; self.status = "Notifications are enabled."
|
||||
return
|
||||
} catch {
|
||||
guard self.generation == run else {
|
||||
await self.revokePending(client: client, scope: record.scope); return
|
||||
}
|
||||
if let failure = error as? AgentPushRequestError, !failure.retryable {
|
||||
self.status = "The server rejected this notification registration. Disable it and check the server configuration before enabling again."
|
||||
return
|
||||
}
|
||||
self.status = "Registration pending. Retry when connected."
|
||||
if attempt < 2 { try? await Task.sleep(for: .seconds(attempt + 1)) }
|
||||
guard self.generation == run else { return }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setScene(_ id: UUID, active: Bool) {
|
||||
if active { foregroundScenes.insert(id) } else { foregroundScenes.remove(id) }
|
||||
}
|
||||
|
||||
func shouldPresent(_ payload: AgentPushPayload) -> Bool {
|
||||
// Foreground updates already flow through the Agent transcript. Never mark
|
||||
// them read here; the visible, bottom-rendered, settled guard owns that.
|
||||
matches(payload) && payload.kind == .message && foregroundScenes.isEmpty && AgentCursor.less(readCursor, payload.cursor)
|
||||
}
|
||||
|
||||
private func matches(_ payload: AgentPushPayload) -> Bool {
|
||||
guard let scope, scope.sourceID == payload.sourceID else { return false }
|
||||
// The wire payload has no backend origin. If one source is known at two
|
||||
// origins, refuse to route/clear rather than guess which server sent it.
|
||||
// Credentials may rotate for the same shared personal owner and runtime.
|
||||
// Old revoke tombstones must not veto a newly consented installation there.
|
||||
return !(saved.records + saved.tombstones).contains {
|
||||
$0.scope.sourceID == payload.sourceID &&
|
||||
($0.scope.backend != scope.backend || $0.scope.owner != scope.owner)
|
||||
}
|
||||
}
|
||||
|
||||
func syncRead() async {
|
||||
guard let client else { return }
|
||||
let run = generation
|
||||
if let read = try? await client.agentReadState(), generation == run { await acknowledged(read, generation: run) }
|
||||
}
|
||||
|
||||
func acknowledged(_ read: AgentReadState, generation run: UUID) async {
|
||||
guard generation == run, let source = read.sourceId, source.lowercased() == scope?.sourceID,
|
||||
let cursor = AgentPushPayload.validCursor(read.cursor) else { return }
|
||||
readCursor = (try? AgentCursor.advance(readCursor, cursor)) ?? readCursor
|
||||
await clearDelivered(generation: run)
|
||||
}
|
||||
|
||||
private func clearDelivered(generation run: UUID) async {
|
||||
let delivered = await platform.delivered()
|
||||
guard generation == run else { return }
|
||||
let ids = delivered.compactMap { notification -> String? in
|
||||
guard let payload = notification.payload, matches(payload), payload.kind == .message,
|
||||
!AgentCursor.less(readCursor, payload.cursor) else { return nil }
|
||||
return notification.identifier
|
||||
}
|
||||
if !ids.isEmpty { platform.removeDelivered(identifiers: ids) }
|
||||
}
|
||||
|
||||
func receiveBackground(_ payload: AgentPushPayload) async -> Bool {
|
||||
guard payload.kind == .readState, matches(payload) else { return false }
|
||||
await acknowledged(AgentReadState(cursor: payload.cursor, sourceId: payload.sourceID), generation: generation)
|
||||
// No foreground socket, no PUT read receipt, no badge reset. APNs is the
|
||||
// source of this scoped read hint; foreground GET converges missed hints.
|
||||
return true
|
||||
}
|
||||
|
||||
/// Cold background delivery may run before SwiftUI/auth bootstrap. Only restore
|
||||
/// the opted-in record at the currently saved backend; never enroll from here.
|
||||
func restoreBackgroundScope(baseURL: URL, token: String?, payload: AgentPushPayload) {
|
||||
guard client == nil, let token, !token.isEmpty, let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback),
|
||||
let record = saved.records.first(where: { $0.scope.backend == backend && $0.scope.credentialScope == AgentPushScope.credentialScope(backend: backend, token: token) && $0.scope.sourceID == payload.sourceID && $0.optedIn && $0.mayBeRegistered }) else { return }
|
||||
scope = record.scope
|
||||
}
|
||||
|
||||
func notificationResponse(_ payload: AgentPushPayload, identifier: String) {
|
||||
guard payload.kind == .message else { return }
|
||||
let identity = payload.sourceID + "|" + (payload.messageID ?? "") + "|" + payload.cursor
|
||||
guard !consumedResponses.contains(identity) else { return }
|
||||
consumedResponses.append(identity)
|
||||
if consumedResponses.count > 64 { consumedResponses.removeFirst() }
|
||||
pendingPayload = payload; pendingRouteID = UUID()
|
||||
}
|
||||
|
||||
func consumeRoute() -> Bool {
|
||||
guard let payload = pendingPayload, client != nil, matches(payload) else { return false }
|
||||
pendingPayload = nil; pendingRouteID = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ final class SybilAgentRoom {
|
||||
private(set) var connection = "Offline"
|
||||
private(set) var error: String?
|
||||
private(set) var isLoading = false
|
||||
private(set) var requiresReload = false
|
||||
private(set) var outbox: [AgentOutboxRow] = []
|
||||
let attachmentDraft = AgentAttachmentDraft()
|
||||
private(set) var attachmentSessionID = UUID()
|
||||
@@ -23,6 +24,11 @@ final class SybilAgentRoom {
|
||||
func setAttachmentModal(_ id: UUID, presented: Bool) {
|
||||
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
|
||||
}
|
||||
func dismissAttachmentPresentations() {
|
||||
// Existing picker/share views observe this identity and dismiss themselves.
|
||||
// Keep draft contents and transcript/view identity intact during routing.
|
||||
attachmentSessionID = UUID()
|
||||
}
|
||||
var composer: String {
|
||||
get { attachmentDraft.caption }
|
||||
set { attachmentDraft.caption = newValue }
|
||||
@@ -31,6 +37,15 @@ final class SybilAgentRoom {
|
||||
var isModalPresented = false
|
||||
var isSelected = false
|
||||
private(set) var isActive = true
|
||||
@ObservationIgnored var onAcknowledgedRead: ((AgentReadState) -> Void)?
|
||||
private enum ReadSourceBinding: Equatable {
|
||||
case unbound, legacy, source(String)
|
||||
}
|
||||
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
|
||||
private var readSourceID: String? {
|
||||
if case let .source(id) = readBinding { return id }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@@ -59,6 +74,9 @@ final class SybilAgentRoom {
|
||||
var isBusy: Bool { snapshot.room.activity.isBusy }
|
||||
var unreadCount: Int { snapshot.room.unreadCount }
|
||||
var messages: [Message] {
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("messageProjection")
|
||||
#endif
|
||||
let committed = Set(snapshot.room.messages.map(\.id))
|
||||
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
|
||||
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
|
||||
@@ -89,8 +107,7 @@ final class SybilAgentRoom {
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
attachmentDraft.configure(client: client, storage: draftStorage)
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
||||
resetHistory()
|
||||
error = nil; storageReadable = true
|
||||
do {
|
||||
outbox = try storage.read().map { row in
|
||||
@@ -154,10 +171,32 @@ final class SybilAgentRoom {
|
||||
}
|
||||
}
|
||||
|
||||
func retryConnection() { flush(); stop(); if isActive { start() } }
|
||||
func retryConnection() {
|
||||
flush(); stop()
|
||||
// Explicit Reconnect starts a new history/source boundary. Foreground and
|
||||
// network recovery must not relabel the preserved old room automatically.
|
||||
if requiresReload { resetHistory(); error = nil }
|
||||
if isActive { start() }
|
||||
}
|
||||
|
||||
private func resetHistory() {
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
|
||||
readBinding = .unbound; requiresReload = false
|
||||
}
|
||||
|
||||
private func requireReload(_ reason: String) -> AgentFailure {
|
||||
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
|
||||
requiresReload = true
|
||||
// Preserve already accepted old cursors/history, cancel every old writer,
|
||||
// and invalidate callbacks before allowing any further source observation.
|
||||
flush(); stop()
|
||||
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
|
||||
return failure
|
||||
}
|
||||
|
||||
private func start() {
|
||||
guard isActive, let client, connectionTask == nil else { return }
|
||||
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
|
||||
let run = generation
|
||||
isLoading = !hasLoaded
|
||||
connection = "Connecting"
|
||||
@@ -168,7 +207,7 @@ final class SybilAgentRoom {
|
||||
guard let self, self.generation == run else { return }
|
||||
let recovered = self.wasNetworkOffline && satisfied
|
||||
self.wasNetworkOffline = !satisfied
|
||||
if recovered { self.retryConnection() }
|
||||
if recovered && !self.requiresReload { self.retryConnection() }
|
||||
}
|
||||
}
|
||||
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
|
||||
@@ -245,7 +284,7 @@ final class SybilAgentRoom {
|
||||
|
||||
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
|
||||
let run = run ?? generation
|
||||
guard generation == run, !Task.isCancelled, !syncing else { return }
|
||||
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
|
||||
syncing = true
|
||||
defer { if generation == run { syncing = false } }
|
||||
var cursor = pending.cursor
|
||||
@@ -264,15 +303,21 @@ final class SybilAgentRoom {
|
||||
}
|
||||
|
||||
func receive(_ event: AgentEvent, replay: Bool = false) throws {
|
||||
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
|
||||
let before = pending
|
||||
var next = pending
|
||||
switch event {
|
||||
case let .messages(page): try next.apply(page)
|
||||
case let .messages(page):
|
||||
try next.apply(page)
|
||||
// History has no source field. Pin even an empty first page before
|
||||
// publishing it so a later UUID cannot adopt legacy cursor space.
|
||||
if readBinding == .unbound { readBinding = .legacy }
|
||||
case let .activity(id, activity):
|
||||
try next.checkConversation(id)
|
||||
next.activity = activity
|
||||
case let .ready(id): if let id { try next.checkConversation(id) }
|
||||
case let .error(message): throw AgentFailure(message: message)
|
||||
case let .readState(read): try applyRead(read); return
|
||||
case .heartbeat: return
|
||||
}
|
||||
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
|
||||
@@ -295,6 +340,9 @@ final class SybilAgentRoom {
|
||||
}
|
||||
|
||||
func flush() {
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("snapshotPublish")
|
||||
#endif
|
||||
batchTask?.cancel(); batchTask = nil
|
||||
typingTask?.cancel(); typingTask = nil
|
||||
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
|
||||
@@ -315,32 +363,53 @@ final class SybilAgentRoom {
|
||||
try applyRead(read)
|
||||
}
|
||||
func applyRead(_ read: AgentReadState) throws {
|
||||
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
|
||||
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
|
||||
let cursor = try AgentCursor.normalize(read.cursor)
|
||||
let incoming: ReadSourceBinding
|
||||
if let source = read.sourceId {
|
||||
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
|
||||
incoming = .source(source.lowercased())
|
||||
} else { incoming = .legacy }
|
||||
guard readBinding == .unbound || readBinding == incoming else {
|
||||
throw requireReload("Agent read source changed.")
|
||||
}
|
||||
readBinding = incoming
|
||||
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
|
||||
pending.readCursor = acknowledgedRead
|
||||
schedulePublish()
|
||||
onAcknowledgedRead?(read)
|
||||
}
|
||||
func markRead(renderedCursor: String) {
|
||||
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
|
||||
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
|
||||
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
|
||||
catch { self.error = error.localizedDescription }
|
||||
}
|
||||
private func flushRead() {
|
||||
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
||||
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
|
||||
let run = generation
|
||||
readTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.readTask = nil } }
|
||||
do {
|
||||
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
||||
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
|
||||
let captured = self.desiredRead
|
||||
let read = try await client.agentMarkRead(cursor: captured)
|
||||
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
|
||||
throw AgentFailure(message: "Agent read state was not acknowledged.")
|
||||
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
|
||||
throw self.requireReload("Agent read state was not acknowledged.")
|
||||
}
|
||||
try self.applyRead(read)
|
||||
}
|
||||
} catch { if self.generation == run { self.error = error.localizedDescription } }
|
||||
} catch {
|
||||
if self.generation == run {
|
||||
let isHTTPConflict: Bool
|
||||
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
|
||||
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
|
||||
if isHTTPConflict {
|
||||
_ = self.requireReload("Agent read source changed.")
|
||||
} else { self.error = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +433,7 @@ final class SybilAgentRoom {
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
|
||||
guard !requiresReload else { return false }
|
||||
do {
|
||||
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
|
||||
if !attachments.isEmpty {
|
||||
@@ -398,6 +468,7 @@ final class SybilAgentRoom {
|
||||
try storage.write(outbox.filter { !$0.confirmed })
|
||||
}
|
||||
private func dispatch(_ id: String) {
|
||||
guard !requiresReload else { return }
|
||||
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
|
||||
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
|
||||
outbox[index].status = "sending"; outbox[index].error = nil
|
||||
|
||||
@@ -12,6 +12,7 @@ protocol AgentAPIClienting: Sendable {
|
||||
func agentMessages(after: String?, before: String?) async throws -> AgentPage
|
||||
func agentReadState() async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
|
||||
@@ -51,6 +52,7 @@ extension APIConfiguration {
|
||||
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value: JSONValue = .object([
|
||||
"type": .string("auth"),
|
||||
"clientKind": .string("native"),
|
||||
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
|
||||
"after": .string(try AgentCursor.normalize(after))
|
||||
])
|
||||
@@ -60,6 +62,9 @@ extension APIConfiguration {
|
||||
|
||||
// Text-only test/legacy clients fail closed for the new wire path.
|
||||
extension AgentAPIClienting {
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
try await agentMarkRead(cursor: cursor)
|
||||
}
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
|
||||
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
|
||||
|
||||
@@ -1,183 +1,176 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
struct SybilAgentView: View {
|
||||
@Bindable var room: SybilAgentRoom
|
||||
var room: SybilAgentRoom
|
||||
var topInset: CGFloat = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
@State private var scrollPosition = ScrollPosition()
|
||||
@State private var scrollMetrics = AgentScrollMetrics()
|
||||
@State private var rowFrames: [String: CGRect] = [:]
|
||||
@State private var restoreAnchor: AgentReadingAnchor?
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
||||
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
}
|
||||
@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
|
||||
@State private var atLatest = true
|
||||
@State private var scrollPhase: ScrollPhase = .idle
|
||||
@State private var historyLimit = 100
|
||||
@State private var historyStartID: String?
|
||||
private static let bottomID = "agent-bottom"
|
||||
@State private var scrollTracking = AgentScrollTracking()
|
||||
@State private var appeared = false
|
||||
private var isScrolling: Bool { scrollPhase.isScrolling }
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = AgentRenderDiagnostics.record("agentBody")
|
||||
#endif
|
||||
let rendered = room.snapshot
|
||||
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
|
||||
let allMessages = transcript.messages
|
||||
let startIndex = transcript.startIndex
|
||||
let displayed = transcript.displayed
|
||||
VStack(spacing: 0) {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 15) {
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
if var anchor = visibleReadingAnchor {
|
||||
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
||||
restoreAnchor = anchor
|
||||
}
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 0) {
|
||||
// The native scroll origin is the visual bottom. Keep model
|
||||
// and cursor order ascending; reverse only presentation.
|
||||
Color.clear.frame(height: 17).id(Self.bottomID)
|
||||
if rendered.isTyping {
|
||||
HStack(spacing: 9) {
|
||||
AgentAvatar()
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||
}
|
||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
||||
VStack(spacing: 14) {
|
||||
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
|
||||
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) })
|
||||
.background(GeometryReader { geometry in
|
||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
||||
})
|
||||
}
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.accessibilityIdentifier("agent-typing")
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
ForEach(Array(displayed.enumerated().reversed()), 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()
|
||||
// Include the gap in the occupied-space entrance.
|
||||
.padding(.bottom, 15)
|
||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
.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)
|
||||
}
|
||||
.coordinateSpace(name: "agent-scroll")
|
||||
.scrollPosition($scrollPosition)
|
||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
isScrolling = phase.isScrolling
|
||||
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
||||
}
|
||||
.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: { previous, next in
|
||||
scrollMetrics = next
|
||||
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)
|
||||
if room.isLoading {
|
||||
ProgressView("Loading messages…").frame(maxWidth: .infinity)
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
if allMessages.count > displayed.count {
|
||||
Button("Load earlier messages") {
|
||||
atLatest = false
|
||||
historyLimit += 100
|
||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
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 = scrollMetrics.offset + frame.minY - anchor.offset
|
||||
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
|
||||
let appliedShift = target - scrollMetrics.offset
|
||||
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 + scrollMetrics.topInset)
|
||||
}
|
||||
restoreAnchor = nil
|
||||
} else {
|
||||
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) }
|
||||
// Keep native lazy-row targets without binding scroll position.
|
||||
.scrollTargetLayout()
|
||||
.padding(.horizontal, 16)
|
||||
// Match normal chat: content clears the header, while the
|
||||
// viewport extends beneath the shared navigation fade.
|
||||
.padding(.bottom, 16 + topInset)
|
||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
.accessibilityIdentifier("agent-transcript")
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onScrollPhaseChange { _, phase in
|
||||
scrollPhase = phase
|
||||
}
|
||||
.onScrollGeometryChange(for: Bool.self) { geometry in
|
||||
geometry.containerSize.height > 0 &&
|
||||
geometry.contentOffset.y + geometry.contentInsets.top <= 24
|
||||
} action: { _, atBottom in
|
||||
#if DEBUG
|
||||
AgentRenderDiagnostics.record("scrollGeometry")
|
||||
#endif
|
||||
scrollTracking.atBottom = atBottom
|
||||
// Only user scrolling changes follow intent. Send navigation
|
||||
// and native keyboard resizing must not cancel it.
|
||||
if isScrolling && scrollPhase != .animating {
|
||||
atLatest = atBottom
|
||||
}
|
||||
}
|
||||
statusBar
|
||||
composer
|
||||
.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: latestRequest) { _, _ in
|
||||
if !atLatest {
|
||||
atLatest = true
|
||||
proxy.scrollTo(Self.bottomID, anchor: .top)
|
||||
}
|
||||
}
|
||||
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
|
||||
atLatest: atLatest, isScrolling: isScrolling))
|
||||
}
|
||||
.onAppear { room.isSelected = true }
|
||||
.onDisappear { room.isSelected = false }
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
||||
// the task, and cannot be acknowledged while still entering the layout.
|
||||
let cursor = rendered.room.cursor
|
||||
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
// Capture the rendered cursor before scheduling. New network input
|
||||
// cancels this task and must settle in layout before acknowledgement.
|
||||
guard atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
|
||||
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||
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
|
||||
if let error = room.error ?? activity.error {
|
||||
HStack(alignment: .top) {
|
||||
@@ -199,25 +192,28 @@ struct SybilAgentView: View {
|
||||
.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 {
|
||||
room.attachmentDraft.canSend
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
AgentAttachmentComposer(room: room)
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityIdentifier("agent-composer")
|
||||
var body: some View {
|
||||
AgentAttachmentComposer(room: room) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
submit: submitComposer
|
||||
)
|
||||
.accessibilityIdentifier("agent-composer")
|
||||
|
||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||
.accessibilityIdentifier("agent-send")
|
||||
}
|
||||
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
|
||||
.accessibilityIdentifier("agent-send")
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.bottom, 12)
|
||||
@@ -226,7 +222,7 @@ struct SybilAgentView: View {
|
||||
|
||||
private func submitComposer() {
|
||||
guard canSendComposer, room.sendComposer() else { return }
|
||||
atLatest = true
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,10 +235,37 @@ struct AgentAvatar: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Activity/read publications still update the transcript. Completed rows depend
|
||||
// only on message values and heading; scrolling needs no per-row geometry.
|
||||
private struct AgentTranscriptRow: View, Equatable {
|
||||
var message: Message
|
||||
var room: SybilAgentRoom
|
||||
var startsDay: Bool
|
||||
|
||||
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
|
||||
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = AgentRenderDiagnostics.record("transcriptRow")
|
||||
#endif
|
||||
VStack(spacing: 14) {
|
||||
if startsDay {
|
||||
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.sybil(.caption2))
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
AgentMessageRow(message: message, room: room)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentMessageRow: View {
|
||||
var message: Message
|
||||
var room: SybilAgentRoom
|
||||
var retry: () -> Void
|
||||
private var own: Bool { message.role == .user }
|
||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||
var body: some View {
|
||||
@@ -260,7 +283,7 @@ private struct AgentMessageRow: View {
|
||||
if own && status == "queued" { Label("Queued", systemImage: "clock") }
|
||||
if own && status == "failed" {
|
||||
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
|
||||
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
|
||||
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
|
||||
@@ -272,26 +295,35 @@ private struct AgentMessageRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Animate both occupied height and content, so incoming rows push older history up.
|
||||
private struct AgentGrowingLayout: Layout {
|
||||
// Grow the occupied space with the bubble's entrance so neighboring rows move
|
||||
// smoothly. Measure at natural height; no geometry state or scroll correction.
|
||||
private struct AgentEntranceLayout: Layout {
|
||||
var progress: CGFloat
|
||||
var animatableData: CGFloat { get { progress } set { progress = newValue } }
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
|
||||
var animatableData: CGFloat {
|
||||
get { progress }
|
||||
set { progress = newValue }
|
||||
}
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let size = subviews.first?.sizeThatFits(.init(width: proposal.width, height: nil)) ?? .zero
|
||||
return CGSize(width: size.width, height: size.height * progress)
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
|
||||
subviews.first?.place(at: bounds.origin, proposal: .init(width: bounds.width, height: nil))
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentEntrance: ViewModifier {
|
||||
var animate: Bool
|
||||
var reduceMotion: Bool
|
||||
@State private var entered = false
|
||||
func body(content: Content) -> some View {
|
||||
let preparing = animate && !entered && !reduceMotion
|
||||
AgentGrowingLayout(progress: preparing ? 0 : 1) {
|
||||
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
|
||||
AgentEntranceLayout(progress: preparing ? 0 : 1) {
|
||||
content
|
||||
.opacity(preparing ? 0 : 1)
|
||||
.offset(y: preparing ? 16 : 0)
|
||||
}
|
||||
.clipped()
|
||||
.onAppear {
|
||||
@@ -300,27 +332,7 @@ private struct AgentEntrance: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentScrollMetrics: Equatable {
|
||||
var offset: CGFloat = 0
|
||||
var height: CGFloat = 0
|
||||
var contentHeight: CGFloat = 0
|
||||
var bottomInset: CGFloat = 0
|
||||
var topInset: CGFloat = 0
|
||||
var visibleBottom: CGFloat = 0
|
||||
// visibleRect includes the viewport extended into the safe area, as well as
|
||||
// the alignment inset used when a short transcript is pinned to the bottom.
|
||||
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
|
||||
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
|
||||
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
|
||||
}
|
||||
private struct AgentReadingAnchor {
|
||||
var id: String
|
||||
var offset: CGFloat
|
||||
var expectedCount: Int? = nil
|
||||
}
|
||||
private struct AgentRowFrames: PreferenceKey {
|
||||
static let defaultValue: [String: CGRect] = [:]
|
||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
|
||||
@Observable private final class AgentScrollTracking {
|
||||
var atBottom = false
|
||||
}
|
||||
|
||||
@@ -230,15 +230,7 @@ struct MessageBubble: View {
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if isUser {
|
||||
Text(message.content)
|
||||
.font(.sybil(.body))
|
||||
} else {
|
||||
Markdown(message.content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
@@ -371,10 +363,7 @@ struct StreamingAssistantContentView: View {
|
||||
}
|
||||
|
||||
if hasAnswer {
|
||||
Markdown(content)
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
SybilMessageText(content: content, format: .markdown)
|
||||
.textSelection(.enabled)
|
||||
.id("streaming-assistant-answer")
|
||||
.transition(answerTransition)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
|
||||
/// Both roles and both workspaces use normal chat's readable, Dynamic Type-aware theme.
|
||||
/// Literal input is built from text nodes, never passed through the Markdown parser.
|
||||
struct SybilMessageText: View {
|
||||
enum Format { case literal, markdown }
|
||||
var content: String
|
||||
var format: Format
|
||||
|
||||
static func literalContent(_ text: String) -> MarkdownContent {
|
||||
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
|
||||
// paragraph boundaries as Markdown. Keep the newline count so deliberate
|
||||
// extra blank lines survive; nonblank lines and Markdown syntax stay literal.
|
||||
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
|
||||
.replacingOccurrences(of: "\r", with: "\n")
|
||||
.components(separatedBy: "\n")
|
||||
.map { line in line.allSatisfy { $0 == " " || $0 == "\t" } ? "" : line }
|
||||
.joined(separator: "\n")
|
||||
return MarkdownContent {
|
||||
for paragraph in normalized.components(separatedBy: "\n\n") {
|
||||
Paragraph {
|
||||
// Keep extra blank paragraphs visible, including leading/trailing ones.
|
||||
paragraph.isEmpty ? "\u{00a0}" : paragraph
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
#if DEBUG
|
||||
let _ = AgentRenderDiagnostics.record("messageTextBuild")
|
||||
#endif
|
||||
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@MainActor
|
||||
final class SybilNotificationPlatform: AgentNotificationPlatform {
|
||||
static let shared = SybilNotificationPlatform()
|
||||
private var center: UNUserNotificationCenter { .current() }
|
||||
// UIKit's callback has no request ID. Keep an outstanding request tied to its
|
||||
// original generation until it drains; queue the next session behind it.
|
||||
private var outstanding: UUID?
|
||||
private var queued: UUID?
|
||||
private var registeredGeneration: UUID?
|
||||
|
||||
var environment: AgentAPNSEnvironment? {
|
||||
#if targetEnvironment(simulator)
|
||||
return isFixture ? .sandbox : nil
|
||||
#elseif targetEnvironment(macCatalyst)
|
||||
return nil
|
||||
#else
|
||||
return AgentAPNSEnvironment(entitlementSetting: Bundle.main.object(forInfoDictionaryKey: "SybilAPNSEnvironment") as? String)
|
||||
#endif
|
||||
}
|
||||
var allowsLoopback: Bool { isFixture }
|
||||
var isFixture: Bool {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
return ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1"
|
||||
#else
|
||||
return false
|
||||
#endif
|
||||
}
|
||||
private var fixturePermission: AgentNotificationPermission = .notDetermined
|
||||
func permission() async -> AgentNotificationPermission {
|
||||
if isFixture {
|
||||
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "authorized" { return .authorized }
|
||||
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "denied" { return .denied }
|
||||
return fixturePermission
|
||||
}
|
||||
guard environment != nil else { return .notDetermined }
|
||||
switch await center.notificationSettings().authorizationStatus {
|
||||
case .authorized, .ephemeral: return .authorized
|
||||
case .provisional: return .provisional
|
||||
case .denied: return .denied
|
||||
default: return .notDetermined
|
||||
}
|
||||
}
|
||||
func requestPermission() async throws -> Bool {
|
||||
if isFixture {
|
||||
fixturePermission = ProcessInfo.processInfo.environment["SYBIL_PUSH_DENIED"] == "1" ? .denied : .authorized
|
||||
return fixturePermission == .authorized
|
||||
}
|
||||
guard environment != nil else { return false }
|
||||
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
|
||||
}
|
||||
func register(generation: UUID) {
|
||||
guard environment != nil else { return }
|
||||
if let outstanding {
|
||||
if outstanding != generation { queued = generation }
|
||||
return
|
||||
}
|
||||
outstanding = generation
|
||||
if isFixture {
|
||||
// Deliberately synthetic bytes; never call Apple's registrar in a simulator.
|
||||
Task { didRegister(Data([0x00, 0x01, 0x0a, 0xff] + Array(repeating: 0x42, count: 28))) }
|
||||
} else {
|
||||
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
#endif
|
||||
}
|
||||
}
|
||||
func unregister() {
|
||||
queued = nil; registeredGeneration = nil
|
||||
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
|
||||
UIApplication.shared.unregisterForRemoteNotifications()
|
||||
#endif
|
||||
// Do not reassign an old callback to a new session.
|
||||
}
|
||||
func didRegister(_ data: Data) {
|
||||
let run = outstanding ?? registeredGeneration
|
||||
outstanding = nil
|
||||
if let run {
|
||||
registeredGeneration = run
|
||||
SybilAgentNotifications.shared.receivedDeviceToken(data, generation: run)
|
||||
}
|
||||
drainQueued()
|
||||
}
|
||||
func didFail() {
|
||||
let run = outstanding ?? registeredGeneration
|
||||
outstanding = nil
|
||||
if let run { SybilAgentNotifications.shared.registrationFailed(generation: run) }
|
||||
drainQueued()
|
||||
}
|
||||
private func drainQueued() {
|
||||
if let next = queued { queued = nil; register(generation: next) }
|
||||
}
|
||||
func delivered() async -> [AgentDeliveredNotification] {
|
||||
if isFixture || environment == nil { return [] }
|
||||
return await center.deliveredNotifications().map {
|
||||
AgentDeliveredNotification(identifier: $0.request.identifier, payload: AgentPushPayload($0.request.content.userInfo))
|
||||
}
|
||||
}
|
||||
func removeDelivered(identifiers: [String]) {
|
||||
guard !isFixture, environment != nil else { return }
|
||||
center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
}
|
||||
|
||||
/// Public app-delegate bridge. The package coordinator remains injectable in tests.
|
||||
@MainActor
|
||||
public enum SybilPushNotifications {
|
||||
public struct Event: Sendable {
|
||||
let payload: AgentPushPayload?
|
||||
public init(userInfo: [AnyHashable: Any]) { payload = AgentPushPayload(userInfo) }
|
||||
}
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
private static var fixtureWasActive = false
|
||||
private static var fixtureForegroundSequence = 0
|
||||
#endif
|
||||
public static func didRegister(deviceToken: Data) { SybilNotificationPlatform.shared.didRegister(deviceToken) }
|
||||
public static func didFailToRegister() { SybilNotificationPlatform.shared.didFail() }
|
||||
|
||||
public static func presentationOptions(event: Event) -> UNNotificationPresentationOptions {
|
||||
guard let payload = event.payload else { return [] }
|
||||
let notifications = SybilAgentNotifications.shared
|
||||
Task { await notifications.syncRead() }
|
||||
// Covers foreground bootstrap before a SwiftUI scene has attached too.
|
||||
guard UIApplication.shared.applicationState != .active, notifications.shouldPresent(payload) else { return [] }
|
||||
return [.banner, .list, .sound]
|
||||
}
|
||||
public static func didRespond(userInfo: [AnyHashable: Any], identifier: String) {
|
||||
didRespond(event: Event(userInfo: userInfo), identifier: identifier)
|
||||
}
|
||||
public static func didRespond(event: Event, identifier: String) {
|
||||
guard let payload = event.payload else { return }
|
||||
SybilAgentNotifications.shared.notificationResponse(payload, identifier: identifier)
|
||||
}
|
||||
public static func receiveBackground(userInfo: [AnyHashable: Any], completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
|
||||
guard let payload = AgentPushPayload(userInfo), payload.kind == .readState else { completion(.noData); return }
|
||||
let notifications = SybilAgentNotifications.shared
|
||||
let settings = SybilSettingsStore()
|
||||
if let base = settings.normalizedAPIBaseURL {
|
||||
notifications.restoreBackgroundScope(baseURL: base, token: settings.trimmedTokenOrNil, payload: payload)
|
||||
}
|
||||
AgentBackgroundCompletion.run(timeout: .seconds(5), operation: {
|
||||
await notifications.receiveBackground(payload) ? .newData : .noData
|
||||
}, completion: completion)
|
||||
}
|
||||
|
||||
public static func launchFixtureIfRequested() {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
guard SybilNotificationPlatform.shared.isFixture,
|
||||
ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP"] == "1" else { return }
|
||||
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "outbound:3", "cursor": "3"], identifier: "fixture-system-request")
|
||||
#endif
|
||||
}
|
||||
|
||||
static func foregroundFixtureIfRequested() {
|
||||
#if DEBUG && targetEnvironment(simulator)
|
||||
guard SybilNotificationPlatform.shared.isFixture else { return }
|
||||
let wasActive = fixtureWasActive
|
||||
fixtureWasActive = true
|
||||
guard wasActive, ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP_ON_FOREGROUND"] == "1" else { return }
|
||||
fixtureForegroundSequence += 1
|
||||
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "fixture-warm-tap-\(fixtureForegroundSequence)", "cursor": "3"], identifier: "fixture-warm-system-request")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded callback gate: even an uncooperative notification-center fixture (or
|
||||
/// OS suspension) cannot delay the fetch completion waiting for a network/socket.
|
||||
@MainActor
|
||||
enum AgentBackgroundCompletion {
|
||||
static func run(timeout: Duration, operation: @escaping @MainActor () async -> UIBackgroundFetchResult,
|
||||
completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
|
||||
let gate = Gate(completion)
|
||||
let work = Task { gate.finish(await operation()) }
|
||||
Task {
|
||||
try? await Task.sleep(for: timeout)
|
||||
if !gate.finished { work.cancel(); gate.finish(.noData) }
|
||||
}
|
||||
}
|
||||
@MainActor private final class Gate {
|
||||
var finished = false
|
||||
let completion: @MainActor (UIBackgroundFetchResult) -> Void
|
||||
init(_ completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) { self.completion = completion }
|
||||
func finish(_ result: UIBackgroundFetchResult) {
|
||||
guard !finished else { return }
|
||||
finished = true; completion(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,11 @@ struct SybilPhoneShellView: View {
|
||||
@State private var sidebarHighlightClearTask: Task<Void, Never>?
|
||||
@State private var openingSelectionRequestID: UUID?
|
||||
|
||||
init(viewModel: SybilViewModel) {
|
||||
self.viewModel = viewModel
|
||||
_route = State(initialValue: viewModel.selectedItem == .agent ? .agent : .draftChat)
|
||||
}
|
||||
|
||||
private var canRecognizeSidebarSwipe: Bool {
|
||||
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
|
||||
}
|
||||
@@ -94,6 +99,10 @@ struct SybilPhoneShellView: View {
|
||||
viewModel.agent.isObscured = covered
|
||||
}
|
||||
.onDisappear { viewModel.agent.isObscured = false }
|
||||
.onChange(of: viewModel.agentNotificationRouteRequest) { _, _ in
|
||||
openingSelectionRequestID = nil
|
||||
showRouteAndClearSidebarHighlight(.agent)
|
||||
}
|
||||
.onChange(of: scenePhase) { _, nextPhase in
|
||||
switch nextPhase {
|
||||
case .background:
|
||||
|
||||
@@ -60,3 +60,26 @@ struct SybilPromptSendButton: View {
|
||||
.accessibilityLabel("Send message")
|
||||
}
|
||||
}
|
||||
|
||||
/// The normal chat attachment control, shared with Agent's composer.
|
||||
/// Picker ownership and availability stay with each composer.
|
||||
struct SybilPromptAttachmentButton: View {
|
||||
var isEnabled: Bool
|
||||
var action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(Circle().fill(SybilTheme.surface))
|
||||
.overlay(Circle().stroke(SybilTheme.border.opacity(0.82), lineWidth: 1))
|
||||
.foregroundStyle(isEnabled ? SybilTheme.text : SybilTheme.textMuted)
|
||||
.frame(width: 44, height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isEnabled)
|
||||
.accessibilityLabel("Attach file")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SybilSettingsView: View {
|
||||
@Bindable var viewModel: SybilViewModel
|
||||
@@ -86,6 +87,35 @@ struct SybilSettingsView: View {
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
|
||||
Text("Notifications include Agent message text and may appear on your lock screen. Reading elsewhere clears older alerts when iOS allows the app to sync.")
|
||||
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
|
||||
Text(viewModel.notifications.status)
|
||||
.font(.sybil(.footnote)).accessibilityIdentifier("agent-push-status")
|
||||
if viewModel.notifications.pendingRevocations > 0 {
|
||||
Text("A previous registration is awaiting revocation. It will retry when you reconnect with its original server credentials.")
|
||||
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
|
||||
}
|
||||
if viewModel.notifications.optedIn {
|
||||
Button("Disable Notifications") { Task { await viewModel.notifications.disable() } }
|
||||
.accessibilityIdentifier("agent-push-disable")
|
||||
} else {
|
||||
Button("Enable Notifications") { Task { await viewModel.notifications.enable() } }
|
||||
.disabled(!viewModel.notifications.available)
|
||||
.accessibilityIdentifier("agent-push-enable")
|
||||
}
|
||||
if viewModel.notifications.permission == .denied {
|
||||
Button("Open iOS Notification Settings") {
|
||||
if let url = URL(string: UIApplication.openNotificationSettingsURLString) { UIApplication.shared.open(url) }
|
||||
}
|
||||
}
|
||||
Button("Refresh Notification Status") { Task { await viewModel.notifications.refresh() } }
|
||||
.accessibilityIdentifier("agent-push-refresh")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.tint(SybilTheme.primarySoft)
|
||||
|
||||
if let authError = viewModel.authError {
|
||||
Text(authError)
|
||||
.font(.sybil(.footnote))
|
||||
|
||||
@@ -157,6 +157,9 @@ private actor SearchStreamStatus {
|
||||
final class SybilViewModel {
|
||||
let settings: SybilSettingsStore
|
||||
let agent = SybilAgentRoom()
|
||||
let notifications: SybilAgentNotifications
|
||||
private(set) var agentNotificationRouteRequest = 0
|
||||
@ObservationIgnored private var sessionGeneration = UUID()
|
||||
|
||||
var isCheckingSession = true
|
||||
var isAuthenticated = false
|
||||
@@ -245,11 +248,13 @@ final class SybilViewModel {
|
||||
|
||||
init(
|
||||
settings: SybilSettingsStore = SybilSettingsStore(),
|
||||
notifications: SybilAgentNotifications? = nil,
|
||||
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
|
||||
SybilAPIClient(configuration: configuration)
|
||||
}
|
||||
) {
|
||||
self.settings = settings
|
||||
self.notifications = notifications ?? .shared
|
||||
self.clientFactory = clientFactory
|
||||
let initialProvider = settings.preferredProvider
|
||||
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
|
||||
@@ -640,9 +645,13 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func reconnect() async {
|
||||
let run = UUID()
|
||||
sessionGeneration = run
|
||||
agent.disconnect()
|
||||
agent.onAcknowledgedRead = nil
|
||||
setSidebarSearchQuery("")
|
||||
isCheckingSession = true
|
||||
isAuthenticated = false
|
||||
authError = nil
|
||||
errorMessage = nil
|
||||
resetSelectionLoading()
|
||||
@@ -659,7 +668,13 @@ final class SybilViewModel {
|
||||
resetQuickQuestion()
|
||||
draftIdentity = UUID()
|
||||
composerAttachments = []
|
||||
// Keep the old authenticated push client alive until revoke has been
|
||||
// attempted; never use the edited URL/token to revoke an old registration.
|
||||
await notifications.transition(baseURL: settings.normalizedAPIBaseURL, token: settings.trimmedTokenOrNil)
|
||||
guard sessionGeneration == run else { return }
|
||||
settings.persist()
|
||||
let baseURL = settings.normalizedAPIBaseURL
|
||||
let pushToken = settings.trimmedTokenOrNil
|
||||
|
||||
SybilLog.info(
|
||||
SybilLog.app,
|
||||
@@ -669,6 +684,7 @@ final class SybilViewModel {
|
||||
do {
|
||||
let client = try client()
|
||||
let session = try await client.verifySession()
|
||||
guard sessionGeneration == run else { return }
|
||||
isAuthenticated = session.authenticated
|
||||
authMode = session.mode
|
||||
authError = nil
|
||||
@@ -679,12 +695,23 @@ final class SybilViewModel {
|
||||
)
|
||||
|
||||
await loadInitialData(using: client)
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
|
||||
guard sessionGeneration == run else { return }
|
||||
if let pushClient = client as? any AgentPushAPIClienting, let baseURL {
|
||||
await notifications.bind(client: pushClient, baseURL: baseURL, authenticated: session.authenticated, mode: session.mode, token: pushToken)
|
||||
}
|
||||
guard sessionGeneration == run else { return }
|
||||
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL {
|
||||
agent.onAcknowledgedRead = { [weak self] read in
|
||||
guard let self, self.sessionGeneration == run else { return }
|
||||
let pushRun = self.notifications.generation
|
||||
Task { await self.notifications.acknowledged(read, generation: pushRun) }
|
||||
}
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
|
||||
agent.setActive(isAppActive)
|
||||
}
|
||||
startActiveRunPolling()
|
||||
} catch {
|
||||
guard sessionGeneration == run else { return }
|
||||
stopActiveRunPolling()
|
||||
isAuthenticated = false
|
||||
authMode = nil
|
||||
@@ -1143,7 +1170,6 @@ final class SybilViewModel {
|
||||
|
||||
func refreshAfterSettingsChange() async {
|
||||
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
|
||||
settings.persist()
|
||||
await reconnect()
|
||||
}
|
||||
|
||||
@@ -1159,8 +1185,19 @@ final class SybilViewModel {
|
||||
}
|
||||
|
||||
func markAppActiveForNetwork() {
|
||||
let wasInactive = !isAppActive
|
||||
isAppActive = true
|
||||
agent.setActive(true)
|
||||
if wasInactive { Task { await notifications.refresh() } }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func consumeAgentNotificationRoute() -> Bool {
|
||||
guard isAuthenticated, !isCheckingSession, notifications.consumeRoute() else { return false }
|
||||
agent.dismissAttachmentPresentations()
|
||||
select(.agent)
|
||||
agentNotificationRouteRequest += 1
|
||||
return true
|
||||
}
|
||||
|
||||
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
|
||||
|
||||
@@ -592,25 +592,10 @@ struct SybilWorkspaceView: View {
|
||||
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
if !viewModel.isSearchMode {
|
||||
Button {
|
||||
SybilPromptAttachmentButton(isEnabled: !viewModel.isActiveSelectionSending) {
|
||||
isShowingAttachmentOptions = true
|
||||
} label: {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.frame(width: 40, height: 40)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(SybilTheme.surface)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
|
||||
)
|
||||
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(viewModel.isActiveSelectionSending)
|
||||
.accessibilityLabel("Attach file")
|
||||
.accessibilityIdentifier("chat-attach")
|
||||
}
|
||||
|
||||
SybilPromptField(
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Sybil
|
||||
|
||||
@MainActor private final class PushMemory {
|
||||
var value = AgentPushPersistence()
|
||||
var failWrite = false
|
||||
var storage: AgentPushStorage { .init(read: { self.value }, write: { value in
|
||||
if self.failWrite { throw URLError(.cannotWriteToFile) }
|
||||
self.value = value
|
||||
}) }
|
||||
}
|
||||
@MainActor private final class PushPlatform: AgentNotificationPlatform {
|
||||
var environment: AgentAPNSEnvironment? = .sandbox
|
||||
var allowsLoopback = true
|
||||
var authorization: AgentNotificationPermission = .notDetermined
|
||||
var allow = true
|
||||
var prompts = 0
|
||||
var registrations: [UUID] = []
|
||||
var unregisters = 0
|
||||
var notifications: [AgentDeliveredNotification] = []
|
||||
var removed: [String] = []
|
||||
var holdPermission = false
|
||||
var holdPermissionRead = false
|
||||
var permissionReadWaiter: CheckedContinuation<AgentNotificationPermission, Never>?
|
||||
var permissionWaiter: CheckedContinuation<Bool, Never>?
|
||||
func permission() async -> AgentNotificationPermission {
|
||||
if holdPermissionRead { return await withCheckedContinuation { permissionReadWaiter = $0 } }
|
||||
return authorization
|
||||
}
|
||||
func requestPermission() async -> Bool {
|
||||
prompts += 1
|
||||
if holdPermission { return await withCheckedContinuation { permissionWaiter = $0 } }
|
||||
authorization = allow ? .authorized : .denied
|
||||
return allow
|
||||
}
|
||||
func register(generation: UUID) { registrations.append(generation) }
|
||||
func unregister() { unregisters += 1 }
|
||||
func delivered() async -> [AgentDeliveredNotification] { notifications }
|
||||
func removeDelivered(identifiers: [String]) { removed += identifiers; notifications.removeAll { identifiers.contains($0.identifier) } }
|
||||
}
|
||||
private actor PushAPI: AgentPushAPIClienting {
|
||||
var config = AgentPushConfig(version: 1, enabled: true, apnsAvailable: true, webPushAvailable: false, sourceId: "11111111-1111-4111-8111-111111111111", quarantined: false)
|
||||
var read = AgentReadState(cursor: "0", sourceId: "11111111-1111-4111-8111-111111111111")
|
||||
var puts: [(UUID, AgentPushRegistration)] = []
|
||||
var deletes: [UUID] = []
|
||||
var failures = 0
|
||||
var offline = false
|
||||
var oldServer = false
|
||||
var revokeStatus: Int?
|
||||
var holdPut = false
|
||||
var putWaiter: CheckedContinuation<Void, Never>?
|
||||
var holdConfig = false
|
||||
var configWaiter: CheckedContinuation<Void, Never>?
|
||||
func agentPushConfig() async throws -> AgentPushConfig {
|
||||
if holdConfig { await withCheckedContinuation { configWaiter = $0 } }
|
||||
if oldServer { throw URLError(.badServerResponse) }
|
||||
return config
|
||||
}
|
||||
func agentReadState() -> AgentReadState { read }
|
||||
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
|
||||
puts.append((id, registration))
|
||||
if holdPut { await withCheckedContinuation { putWaiter = $0 } }
|
||||
if failures > 0 { failures -= 1; throw URLError(.networkConnectionLost) }
|
||||
return .init(installationId: id.uuidString, transport: "apns", enabled: true, version: puts.count)
|
||||
}
|
||||
func agentRevokePush(id: UUID) throws {
|
||||
deletes.append(id)
|
||||
if let revokeStatus { throw AgentPushRequestError(status: revokeStatus) }
|
||||
if offline { throw URLError(.notConnectedToInternet) }
|
||||
}
|
||||
func failRevoke(status: Int) { revokeStatus = status }
|
||||
func set(failures: Int = 0, offline: Bool = false, holdPut: Bool = false, oldServer: Bool = false, holdConfig: Bool = false) {
|
||||
self.failures = failures; self.offline = offline; self.holdPut = holdPut; self.oldServer = oldServer; self.holdConfig = holdConfig
|
||||
}
|
||||
func source(_ id: String) { config.sourceId = id; read.sourceId = id }
|
||||
func setAvailable(_ available: Bool) { config.enabled = available }
|
||||
func releasePut() { holdPut = false; putWaiter?.resume(); putWaiter = nil }
|
||||
func releaseConfig() { holdConfig = false; configWaiter?.resume(); configWaiter = nil }
|
||||
func requests() -> [(UUID, AgentPushRegistration)] { puts }
|
||||
func revokes() -> [UUID] { deletes }
|
||||
func waitingPut() -> Bool { putWaiter != nil }
|
||||
func waitingConfig() -> Bool { configWaiter != nil }
|
||||
}
|
||||
@MainActor private func pushEventually(_ predicate: () async -> Bool) async -> Bool {
|
||||
for _ in 0..<300 { if await predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
|
||||
return false
|
||||
}
|
||||
private func pushPayload(_ cursor: String = "4", source: String = "11111111-1111-4111-8111-111111111111", kind: String = "message") -> AgentPushPayload {
|
||||
AgentPushPayload(["version": 1, "sourceId": source, "kind": kind, "cursor": cursor, "readCursor": cursor, "messageId": "outbound:\(cursor)"])!
|
||||
}
|
||||
@MainActor private func bindPush(_ notifications: SybilAgentNotifications, _ api: PushAPI, url: String = "https://example.test/api", mode: String = "token") async {
|
||||
await notifications.bind(client: api, baseURL: URL(string: url)!, authenticated: true, mode: mode, token: mode == "token" ? "fixture-token" : nil)
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentNotificationTests {
|
||||
@Test func payloadValidationAndCanonicalScope() throws {
|
||||
#expect(AgentPushPayload(["version": true, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
|
||||
#expect(AgentPushPayload(["version": 2, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
|
||||
#expect(AgentPushPayload(["version": 1, "sourceId": "s", "kind": "message", "cursor": 1, "messageId": "m"]) == nil)
|
||||
for cursor in ["-1", "1e3", "9007199254740992", "1.0", ""] { #expect(AgentPushPayload.validCursor(cursor) == nil) }
|
||||
#expect(AgentPushPayload.validCursor("0004") == "4")
|
||||
#expect(AgentPushScope.canonicalBackend(URL(string: "https://EXAMPLE.test:443/api/")!) == "https://example.test/api")
|
||||
for url in ["https://user:secret@example.test/api", "https://example.test/api?token=bad", "https://example.test/#fragment", "http://example.test/api", "https://example.test/api/../other"] {
|
||||
#expect(AgentPushScope.canonicalBackend(URL(string: url)!) == nil)
|
||||
}
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "production") == .production)
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "development") == .sandbox)
|
||||
#expect(AgentAPNSEnvironment(entitlementSetting: "DEBUG") == nil)
|
||||
}
|
||||
|
||||
@Test @MainActor func explicitAllowFreshTokenRotationAndIdempotence() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
|
||||
await push.enable()
|
||||
#expect(platform.prompts == 1 && platform.registrations.count == 1)
|
||||
#expect(await api.requests().isEmpty)
|
||||
let run = push.generation
|
||||
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
|
||||
#expect(await pushEventually { await api.requests().count == 1 && memory.value.records.first?.version != nil })
|
||||
let first = try #require(await api.requests().first)
|
||||
#expect(first.1.token == "00010aff" && first.1.environment == .sandbox)
|
||||
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
|
||||
#expect(await api.requests().count == 1)
|
||||
push.receivedDeviceToken(Data([0x42, 0xab]), generation: run)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version == 2 })
|
||||
let next = try #require(await api.requests().last)
|
||||
#expect(first.0 == next.0 && next.1.token == "42ab")
|
||||
let encoded = String(decoding: try JSONEncoder().encode(memory.value), as: UTF8.self)
|
||||
#expect(!encoded.contains("00010aff") && !encoded.contains("42ab"))
|
||||
await push.refresh()
|
||||
#expect(platform.prompts == 1 && platform.registrations.count == 2)
|
||||
let relaunchedPlatform = PushPlatform(); relaunchedPlatform.authorization = .authorized
|
||||
let relaunched = SybilAgentNotifications(platform: relaunchedPlatform, storage: memory.storage)
|
||||
await bindPush(relaunched, api)
|
||||
#expect(relaunchedPlatform.registrations.count == 1 && relaunchedPlatform.prompts == 0)
|
||||
#expect(await api.requests().count == 2) // persisted token is never used.
|
||||
}
|
||||
|
||||
@Test @MainActor func deniedOpenOldServerAndPersistenceFailureNeverEnroll() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api, mode: "open"); await push.enable()
|
||||
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
|
||||
await api.set(oldServer: true); await bindPush(push, api)
|
||||
#expect(!push.available && platform.prompts == 0)
|
||||
await api.set(); await bindPush(push, api)
|
||||
platform.allow = false; await push.enable()
|
||||
#expect(platform.prompts == 1 && !push.optedIn && platform.registrations.isEmpty)
|
||||
platform.authorization = .authorized; memory.failWrite = true
|
||||
await push.enable()
|
||||
#expect(platform.registrations.isEmpty)
|
||||
#expect(await api.requests().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func retryThenRevokeUsesRetiredUUID() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable(); await api.set(failures: 1)
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
|
||||
let ids = await api.requests().map(\.0)
|
||||
#expect(Set(ids).count == 1)
|
||||
await push.disable()
|
||||
#expect(await api.revokes().contains(ids[0]))
|
||||
#expect(!push.optedIn && push.pendingRevocations == 0)
|
||||
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 3 })
|
||||
#expect(await api.requests().last?.0 != ids[0])
|
||||
}
|
||||
|
||||
@Test @MainActor func logoutAndServerSwitchRejectLateOSAndUploadCallbacks() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable(); await old.set(holdPut: true)
|
||||
let oldRun = push.generation
|
||||
push.receivedDeviceToken(Data([1]), generation: oldRun)
|
||||
#expect(await pushEventually { await old.waitingPut() })
|
||||
let oldID = try #require(await old.requests().first?.0)
|
||||
await push.deactivate()
|
||||
await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test/api")
|
||||
await push.enable()
|
||||
push.receivedDeviceToken(Data([0xff]), generation: oldRun)
|
||||
#expect(await next.requests().isEmpty)
|
||||
await old.releasePut()
|
||||
#expect(await pushEventually { await old.revokes().contains(oldID) })
|
||||
#expect(await next.revokes().isEmpty)
|
||||
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222" && push.optedIn)
|
||||
push.receivedDeviceToken(Data([2]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 })
|
||||
}
|
||||
|
||||
@Test @MainActor func oldConfigAndPermissionCannotBindNewSession() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await old.set(holdConfig: true)
|
||||
let binding = Task { await bindPush(push, old) }
|
||||
#expect(await pushEventually { await old.waitingConfig() })
|
||||
await push.deactivate(); await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test")
|
||||
await old.releaseConfig(); await binding.value
|
||||
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222")
|
||||
platform.holdPermission = true
|
||||
let enabling = Task { await push.enable() }
|
||||
#expect(await pushEventually { platform.permissionWaiter != nil })
|
||||
await push.deactivate()
|
||||
platform.permissionWaiter?.resume(returning: true); await enabling.value
|
||||
#expect(platform.registrations.isEmpty && !push.optedIn)
|
||||
}
|
||||
|
||||
@Test @MainActor func offlineTombstoneSurvivesRelaunchAndOnlyExactScopeRetries() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await api.set(offline: true); await push.deactivate()
|
||||
#expect(memory.value.tombstones.count == 1)
|
||||
let id = try #require(memory.value.tombstones.first?.installationID)
|
||||
let next = PushAPI(), relaunched = SybilAgentNotifications(platform: PushPlatform(), storage: memory.storage)
|
||||
await bindPush(relaunched, next, url: "https://other.test/api")
|
||||
#expect(await next.revokes().isEmpty)
|
||||
await relaunched.deactivate(); await next.source("33333333-3333-4333-8333-333333333333")
|
||||
await bindPush(relaunched, next)
|
||||
#expect(await next.revokes().isEmpty)
|
||||
await relaunched.deactivate(); await next.source("11111111-1111-4111-8111-111111111111"); await bindPush(relaunched, next)
|
||||
#expect(await next.revokes() == [id] && memory.value.tombstones.isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func deliveredClearUsesSystemIdentifiersAndRetainsNewerOrUnrelated() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
platform.notifications = [
|
||||
.init(identifier: "apns-system-a", payload: pushPayload("3")),
|
||||
.init(identifier: "apns-system-b", payload: pushPayload("4")),
|
||||
.init(identifier: "newer", payload: pushPayload("5")),
|
||||
.init(identifier: "foreign", payload: pushPayload("1", source: "44444444-4444-4444-8444-444444444444")),
|
||||
.init(identifier: "unrelated", payload: nil)]
|
||||
await push.acknowledged(.init(cursor: "99"), generation: push.generation)
|
||||
#expect(platform.removed.isEmpty) // legacy reads have no source authority.
|
||||
await push.acknowledged(.init(cursor: "4", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
|
||||
#expect(platform.removed == ["apns-system-a", "apns-system-b"])
|
||||
#expect(platform.notifications.map(\.identifier) == ["newer", "foreign", "unrelated"])
|
||||
await push.acknowledged(.init(cursor: "1", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
|
||||
#expect(!push.shouldPresent(pushPayload("4")))
|
||||
#expect(push.shouldPresent(pushPayload("5")))
|
||||
let scene = UUID(); push.setScene(scene, active: true)
|
||||
#expect(!push.shouldPresent(pushPayload("5")))
|
||||
push.setScene(scene, active: false)
|
||||
#expect(await push.receiveBackground(pushPayload("5", kind: "read_state")))
|
||||
#expect(platform.removed.last == "newer")
|
||||
#expect(await api.requests().isEmpty) // no read/enrollment side effect.
|
||||
}
|
||||
|
||||
@Test @MainActor func coldTapWaitsForAuthenticationDeduplicatesAndDoesNotRead() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
let payload = pushPayload()
|
||||
push.notificationResponse(payload, identifier: "system-1")
|
||||
#expect(!push.consumeRoute())
|
||||
let request = push.pendingRouteID
|
||||
push.notificationResponse(payload, identifier: "system-2")
|
||||
#expect(push.pendingRouteID == request)
|
||||
await bindPush(push, api, mode: "open"); #expect(!push.consumeRoute())
|
||||
await bindPush(push, api); #expect(push.consumeRoute()); #expect(!push.consumeRoute())
|
||||
#expect(platform.removed.isEmpty)
|
||||
#expect(await api.requests().isEmpty)
|
||||
#expect(push.shouldPresent(payload)) // tap alone did not advance read.
|
||||
push.notificationResponse(pushPayload(source: "44444444-4444-4444-8444-444444444444"), identifier: "wrong")
|
||||
#expect(!push.consumeRoute())
|
||||
}
|
||||
|
||||
@Test @MainActor func backgroundCompletionExactlyOnceIncludingTimeout() async {
|
||||
var outcomes: [UIBackgroundFetchResult] = []
|
||||
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { .newData }, completion: { outcomes.append($0) })
|
||||
#expect(await pushEventually { outcomes.count == 1 })
|
||||
try? await Task.sleep(for: .milliseconds(40)); #expect(outcomes == [.newData])
|
||||
var continuation: CheckedContinuation<UIBackgroundFetchResult, Never>?
|
||||
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { await withCheckedContinuation { continuation = $0 } }, completion: { outcomes.append($0) })
|
||||
#expect(await pushEventually { outcomes.count == 2 })
|
||||
continuation?.resume(returning: .newData)
|
||||
try? await Task.sleep(for: .milliseconds(20)); #expect(outcomes == [.newData, .noData])
|
||||
}
|
||||
|
||||
@Test @MainActor func changedCredentialEpochRequiresConsentAndRejectsColdReadHints() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let original = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(original, api); await original.enable()
|
||||
original.receivedDeviceToken(Data([1]), generation: original.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
let replacementPlatform = PushPlatform(); replacementPlatform.authorization = .authorized
|
||||
let replacement = SybilAgentNotifications(platform: replacementPlatform, storage: memory.storage)
|
||||
replacement.restoreBackgroundScope(baseURL: URL(string: "https://example.test/api")!, token: "replacement-fixture-token", payload: pushPayload(kind: "read_state"))
|
||||
#expect(replacement.scope == nil)
|
||||
await replacement.bind(client: api, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
|
||||
#expect(!replacement.optedIn && replacementPlatform.registrations.isEmpty)
|
||||
#expect(memory.value.records.count == 2)
|
||||
#expect(memory.value.records[0].scope.credentialScope != memory.value.records[1].scope.credentialScope)
|
||||
#expect(await api.revokes().isEmpty)
|
||||
#expect(replacement.scope?.credentialScope == memory.value.records.last?.scope.credentialScope)
|
||||
}
|
||||
|
||||
@Test @MainActor func deniedPermissionThenAuthorizedRefreshRecovers() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
platform.authorization = .denied
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
#expect(push.permission == .denied)
|
||||
platform.authorization = .authorized
|
||||
await push.refresh()
|
||||
#expect(push.permission == .authorized && push.available)
|
||||
await push.enable()
|
||||
#expect(platform.registrations.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func equivalentCanonicalBackendDoesNotRetireConsent() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
let run = push.generation
|
||||
await bindPush(push, api, url: "https://EXAMPLE.test:443/api/")
|
||||
#expect(push.generation == run && push.optedIn)
|
||||
#expect(memory.value.records.count == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func sameBackendCredentialRotationWith403TombstoneAllowsNewConsentRoutingAndClear() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await old.failRevoke(status: 403)
|
||||
await push.bind(client: next, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
|
||||
#expect(!push.optedIn && memory.value.tombstones.count == 1)
|
||||
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 && push.status == "Notifications are enabled." })
|
||||
push.notificationResponse(pushPayload(), identifier: "system-after-rotation")
|
||||
#expect(push.consumeRoute())
|
||||
platform.notifications = [.init(identifier: "actual-system-id", payload: pushPayload())]
|
||||
#expect(await push.receiveBackground(pushPayload(kind: "read_state")))
|
||||
#expect(platform.removed == ["actual-system-id"])
|
||||
#expect(memory.value.tombstones.count == 1)
|
||||
#expect(await next.revokes().isEmpty)
|
||||
}
|
||||
|
||||
@Test @MainActor func distinctBackendWithSameRuntimeSourceStillRefusesPayloads() async {
|
||||
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, old); await push.enable()
|
||||
push.receivedDeviceToken(Data([1]), generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
await old.failRevoke(status: 403)
|
||||
await bindPush(push, next, url: "https://other.test/api"); await push.enable()
|
||||
push.receivedDeviceToken(Data([2]), generation: push.generation)
|
||||
#expect(await pushEventually { await next.requests().count == 1 })
|
||||
push.notificationResponse(pushPayload(), identifier: "ambiguous-server")
|
||||
#expect(!push.consumeRoute())
|
||||
#expect(!(await push.receiveBackground(pushPayload(kind: "read_state"))))
|
||||
}
|
||||
|
||||
@Test @MainActor func disableDuringConfigOrPermissionRefreshDoesNotBlockNewRefresh() async {
|
||||
for holdConfig in [true, false] {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api)
|
||||
await api.set(holdConfig: holdConfig); platform.holdPermissionRead = !holdConfig
|
||||
let oldRefresh = Task { await push.refresh() }
|
||||
#expect(await pushEventually { holdConfig ? await api.waitingConfig() : platform.permissionReadWaiter != nil })
|
||||
await push.disable()
|
||||
if holdConfig { await api.releaseConfig() }
|
||||
else { platform.holdPermissionRead = false; platform.permissionReadWaiter?.resume(returning: .denied) }
|
||||
await oldRefresh.value
|
||||
platform.authorization = .authorized
|
||||
await push.refresh(); await push.enable()
|
||||
#expect(push.permission == .authorized && platform.registrations.count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func capabilityRecoveryReenrollsSameTokenWithNewInstallation() async throws {
|
||||
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
|
||||
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
|
||||
await bindPush(push, api); await push.enable()
|
||||
let bytes = Data([1, 2, 3])
|
||||
push.receivedDeviceToken(bytes, generation: push.generation)
|
||||
#expect(await pushEventually { memory.value.records.first?.version != nil })
|
||||
let oldID = try #require(await api.requests().first?.0)
|
||||
await api.setAvailable(false); await push.refresh()
|
||||
#expect(await api.revokes().contains(oldID))
|
||||
#expect(memory.value.records.first?.installationID != oldID)
|
||||
await api.setAvailable(true); await push.refresh()
|
||||
push.receivedDeviceToken(bytes, generation: push.generation)
|
||||
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
|
||||
#expect(await api.requests().last?.0 != oldID)
|
||||
#expect(push.status == "Notifications are enabled.")
|
||||
}
|
||||
|
||||
@Test @MainActor func optionalReadStateAndNativeAuthRemainCompatible() throws {
|
||||
let old = try JSONDecoder().decode(AgentReadState.self, from: Data(#"{"cursor":"4","updatedAt":null}"#.utf8))
|
||||
#expect(old.sourceId == nil)
|
||||
let data = Data(#"{"type":"read_state","sourceId":"11111111-1111-4111-8111-111111111111","cursor":"5","updatedAt":null}"#.utf8)
|
||||
guard case let .readState(read) = try AgentEvent.decode(data) else { Issue.record("Missing read frame"); return }
|
||||
let room = SybilAgentRoom(); try room.applyRead(read); room.flush()
|
||||
#expect(room.snapshot.room.readCursor == "5")
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "99", sourceId: "foreign")) }
|
||||
room.flush()
|
||||
#expect(room.snapshot.room.readCursor == "5")
|
||||
#expect(room.requiresReload)
|
||||
#expect(try AgentEvent.decode(Data(#"{"type":"read_state","cursor":"-1"}"#.utf8)) == nil)
|
||||
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(APIConfiguration(baseURL: URL(string: "https://example.test")!, authToken: "fixture").agentAuthFrame(after: "0").utf8))
|
||||
#expect(frame.objectValue?["clientKind"] == .string("native"))
|
||||
#expect(frame.objectValue?["presenceSessionId"] == nil)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Sybil
|
||||
|
||||
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
|
||||
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
|
||||
override class func canInit(with request: URLRequest) -> Bool { true }
|
||||
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
||||
override func startLoading() {
|
||||
do {
|
||||
let handler = Self.lock.withLock { Self.handler! }
|
||||
let (status, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
|
||||
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
|
||||
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
|
||||
}
|
||||
private func pushRequestBody(_ request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open(); defer { stream.close() }
|
||||
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
|
||||
return bytes
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentPushAPITests {
|
||||
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
|
||||
let id = UUID()
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.host == "example.test")
|
||||
#expect(request.url?.query == nil)
|
||||
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
|
||||
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
|
||||
#expect(request.timeoutInterval == 8)
|
||||
if request.httpMethod == "GET" {
|
||||
#expect(request.url?.path == "/api/v1/agent/push/config")
|
||||
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
|
||||
}
|
||||
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
|
||||
if request.httpMethod == "DELETE" { return (204, Data()) }
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
|
||||
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
|
||||
#expect(object["token"] as? String == "00010aff")
|
||||
#expect(object["environment"] as? String == "production")
|
||||
#expect(object["topic"] as? String == "net.buzzert.sybil2")
|
||||
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
|
||||
}
|
||||
let client = pushClient()
|
||||
#expect(try await client.agentPushConfig().available)
|
||||
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
|
||||
try await client.agentRevokePush(id: id)
|
||||
}
|
||||
|
||||
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
|
||||
for status in [302, 401, 404, 409, 503] {
|
||||
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
|
||||
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
|
||||
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
|
||||
}
|
||||
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
|
||||
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
|
||||
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
|
||||
let source = "11111111-1111-4111-8111-111111111111"
|
||||
for expectedSource: String? in [nil, source] {
|
||||
PushURLProtocol.install { request in
|
||||
#expect(request.url?.path == "/api/v1/agent/read-state")
|
||||
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
|
||||
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
|
||||
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
|
||||
}
|
||||
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,12 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
var requests: [String] = []
|
||||
var submissions: [(String, String)] = []
|
||||
var read = "0"
|
||||
var readSource: String?
|
||||
var readWrites: [(cursor: String, source: String?)] = []
|
||||
var writeReply: AgentReadState?
|
||||
var writeConflict: Bool?
|
||||
var holdWrite = false
|
||||
var heldWrite: CheckedContinuation<Void, Never>?
|
||||
var failRead = false
|
||||
var failSubmit = false
|
||||
var malformedReceipt = false
|
||||
@@ -56,16 +62,34 @@ private actor TestAgentClient: AgentAPIClienting {
|
||||
}
|
||||
func agentReadState() async -> AgentReadState {
|
||||
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
|
||||
return AgentReadState(cursor: read)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func pauseRead() { holdRead = true }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
|
||||
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); heldRead = nil }
|
||||
func hasHeldRead() -> Bool { heldRead != nil }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState {
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read)
|
||||
func agentMarkRead(cursor: String) async throws -> AgentReadState {
|
||||
try await agentMarkRead(cursor: cursor, sourceID: nil)
|
||||
}
|
||||
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
|
||||
readWrites.append((cursor, sourceID))
|
||||
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
|
||||
if let coded = writeConflict {
|
||||
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
|
||||
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
|
||||
}
|
||||
if failRead { throw AgentFailure(message: "Offline") }
|
||||
if let writeReply { return writeReply }
|
||||
read = try AgentCursor.advance(read, cursor)
|
||||
return AgentReadState(cursor: read, sourceId: readSource)
|
||||
}
|
||||
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
|
||||
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
|
||||
func setWriteConflict(coded: Bool) { writeConflict = coded }
|
||||
func pauseWrite() { holdWrite = true }
|
||||
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
|
||||
func hasHeldWrite() -> Bool { heldWrite != nil }
|
||||
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
|
||||
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
|
||||
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append((content, requestID))
|
||||
@@ -420,6 +444,89 @@ struct AgentTests {
|
||||
#expect(await eventually { room.connection == "Connecting" })
|
||||
#expect(room.messages.count == 2)
|
||||
}
|
||||
|
||||
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90"))
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
defer { room.stop() }
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
|
||||
for _ in 0..<100 {
|
||||
if await client.hasHeldWrite() { break }
|
||||
try? await Task.sleep(for: .milliseconds(10))
|
||||
}
|
||||
#expect(await client.hasHeldWrite())
|
||||
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
|
||||
#expect(room.requiresReload && room.connection == "Reload required")
|
||||
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
|
||||
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(50))
|
||||
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == nil)
|
||||
#expect(room.messages.map(\.id) == ["old"])
|
||||
|
||||
// Explicit reconnect resets cursor space before discovering B or loading
|
||||
// its history. The old queued100 can never be sent under sourceB.
|
||||
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
|
||||
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
|
||||
room.retryConnection()
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
|
||||
#expect(room.messages.map(\.id) == ["new"])
|
||||
room.markRead(renderedCursor: "1")
|
||||
#expect(await eventually { room.snapshot.room.readCursor == "1" })
|
||||
let writes = await client.writtenReads()
|
||||
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
|
||||
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
|
||||
}
|
||||
|
||||
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
|
||||
let room = SybilAgentRoom(); defer { room.stop() }
|
||||
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
|
||||
#expect(throws: AgentFailure.self) {
|
||||
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
|
||||
}
|
||||
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
|
||||
}
|
||||
|
||||
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
|
||||
let sourceA = "11111111-1111-4111-8111-111111111111"
|
||||
let sourceB = "22222222-2222-4222-8222-222222222222"
|
||||
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
#expect(await client.writtenReads().first?.source == sourceA)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
|
||||
for coded in [false, true] {
|
||||
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
|
||||
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
|
||||
await client.setWriteConflict(coded: coded)
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
|
||||
#expect(await eventually { room.connection == "Connected" })
|
||||
room.isSelected = true; room.markRead(renderedCursor: "100")
|
||||
#expect(await eventually { room.requiresReload })
|
||||
room.markRead(renderedCursor: "100")
|
||||
#expect(room.snapshot.room.readCursor == "90")
|
||||
#expect(await client.writtenReads().count == 1)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
|
||||
let client = TestAgentClient(), memory = MemoryOutbox()
|
||||
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Sybil
|
||||
|
||||
@Suite @MainActor
|
||||
struct MessagePresentationTests {
|
||||
@Test func userMarkupRemainsLiteral() {
|
||||
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
|
||||
let content = SybilMessageText.literalContent(source)
|
||||
#expect(content.renderPlainText() == source)
|
||||
#expect(!content.renderHTML().contains("<strong>"))
|
||||
#expect(!content.renderHTML().contains("<a "))
|
||||
#expect(!content.renderHTML().contains("<code>"))
|
||||
#expect(!content.renderHTML().contains("<ul>"))
|
||||
#expect(content.renderHTML().contains("<script>"))
|
||||
}
|
||||
|
||||
@Test func literalLineBreaksAndParagraphsStayDistinct() {
|
||||
let content = SybilMessageText.literalContent("First\nSecond\n\nThird")
|
||||
#expect(content.renderPlainText() == "First\nSecond\n\nThird")
|
||||
#expect(content.renderHTML() == "<p>First\nSecond</p>\n<p>Third</p>\n")
|
||||
// Empty paragraphs retain layout space instead of disappearing.
|
||||
#expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "<p>").count == 6)
|
||||
}
|
||||
|
||||
@Test func pastedParagraphBoundariesMatchMarkdown() {
|
||||
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
|
||||
#expect(SybilMessageText.literalContent(source).renderHTML() == "<p>First</p>\n<p>Second</p>\n")
|
||||
}
|
||||
#expect(SybilMessageText.literalContent("First\r\nSecond").renderPlainText() == "First\nSecond")
|
||||
#expect(SybilMessageText.literalContent(" **literal** \n\nLast").renderPlainText() == " **literal** \n\nLast")
|
||||
#expect(SybilMessageText.literalContent("First\r\n \r\n\t\r\n\r\nLast") == SybilMessageText.literalContent("First\n\n\n\nLast"))
|
||||
}
|
||||
|
||||
@Test func bothFormatsShareLineParagraphMetricsAndDynamicType() {
|
||||
func height(_ content: String, _ format: SybilMessageText.Format, _ size: DynamicTypeSize) -> CGFloat {
|
||||
let host = UIHostingController(rootView: SybilMessageText(content: content, format: format)
|
||||
.environment(\.dynamicTypeSize, size))
|
||||
return host.sizeThatFits(in: CGSize(width: 320, height: 10_000)).height
|
||||
}
|
||||
let literal = "First line\nSecond line\n\nAnother paragraph"
|
||||
let markdown = "First line \nSecond line\n\nAnother paragraph"
|
||||
for size in [DynamicTypeSize.large, .accessibility3] {
|
||||
#expect(abs(height(literal, .literal, size) - height(markdown, .markdown, size)) < 1)
|
||||
}
|
||||
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
|
||||
#expect(abs(height(source, .literal, .large) - height(source, .markdown, .large)) < 1)
|
||||
}
|
||||
#expect(height(literal, .literal, .accessibility3) > height(literal, .literal, .large) * 1.5)
|
||||
#expect(height(markdown, .markdown, .accessibility3) > height(markdown, .markdown, .large) * 1.5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# Agent interaction benchmarks
|
||||
|
||||
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
||||
|
||||
```sh
|
||||
just generate
|
||||
xcodebuild test -scheme SybilPresentation \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
||||
-only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
|
||||
-resultBundlePath build/agent-input-performance.xcresult
|
||||
```
|
||||
|
||||
`testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
|
||||
messages through the normal room reducer (100 initially displayed), then:
|
||||
|
||||
- Receives 12 more messages at 550 ms intervals through the production 100 ms
|
||||
publication batch and 240 ms entrance animation, checking bottom following.
|
||||
- Presents and dismisses the actual software keyboard six times, 600 ms per
|
||||
transition. It asserts that the transcript viewport shrinks by over 100 points
|
||||
and remains at the bottom; a hardware-keyboard-only run is invalid.
|
||||
- Calls UIKit `insertText` on the actual focused composer every 30 ms, including
|
||||
enough text to wrap and grow to its six-line limit. It verifies the draft,
|
||||
bottom following, and that keyboard/draft changes don't reproject the history.
|
||||
|
||||
The real composer, Markdown rows, avatars and entrance animations remain enabled.
|
||||
No server, production credentials, notification delivery or network images are
|
||||
needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
|
||||
JSON and screenshots of the actual hosted view before and after typing.
|
||||
Screenshots capture the hosting view, not the separate system keyboard window.
|
||||
|
||||
Use the same test source, simulator, build configuration and host load for
|
||||
before/after measurements. Don't run other builds or profilers during sampling.
|
||||
Functional assertions about observation boundaries should fail against the old
|
||||
implementation; use the same sampling procedure when collecting a baseline.
|
||||
|
||||
## Measurement limits
|
||||
|
||||
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.
|
||||
|
||||
`AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
|
||||
a drop in counts alone does not establish improved responsiveness.
|
||||
|
||||
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 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 12 message entrances | 40.8 | 33.2 | 16.8 |
|
||||
| Six keyboard cycles | 33.2 | 33.1 | 29.2 |
|
||||
| Multiline composer typing | 24.8 | 25.2 | 21.3 |
|
||||
|
||||
Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame
|
||||
preference callbacks in these pinned-bottom phases. Eager content height stayed
|
||||
43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
|
||||
typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
|
||||
50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
|
||||
and typing p95 did not improve. These single runs establish removed transcript
|
||||
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.
|
||||
@@ -26,6 +26,12 @@ attachment_capability = True
|
||||
next_upload_error = None
|
||||
blocked_proxy = False
|
||||
transcript_comparison = False
|
||||
PUSH_SOURCE = '11111111-1111-4111-8111-111111111111'
|
||||
push_installations = {}
|
||||
push_retired = set()
|
||||
push_requests = []
|
||||
push_available = True
|
||||
auth_required = False
|
||||
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
|
||||
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
|
||||
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
|
||||
@@ -88,7 +94,7 @@ async def reply(content):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
@@ -100,8 +106,16 @@ async def route(request):
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
|
||||
push_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
|
||||
push_available = True; auth_required = False
|
||||
for i in range(body.get('count', 3)):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
elif action == 'presentation-samples':
|
||||
# Same source messages in normal chat and Agent; loopback-only UI comparisons.
|
||||
messages.clear(); cursor = 0; read_cursor = 0
|
||||
transcript_comparison = True
|
||||
for sample in body['messages']:
|
||||
message(sample['content'], sample['role'])
|
||||
elif action == 'transcript-comparison':
|
||||
transcript_comparison = True
|
||||
elif action == 'capability':
|
||||
@@ -124,15 +138,35 @@ async def route(request):
|
||||
elif action == 'busy':
|
||||
busy = body.get('value', True)
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
elif action == 'progress':
|
||||
busy = True
|
||||
progress = dict(activity(), queued=body['queued'], backgroundWorkers=body['workers'],
|
||||
tools=[dict(id='local-review', name='review_transcript', status='running',
|
||||
output=f"Checked {body['step']} local entries")])
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=progress))
|
||||
elif action == 'edit-last':
|
||||
cursor += 1
|
||||
row = dict(messages[-1], content=body['content'],
|
||||
metadata=dict(messages[-1]['metadata'], entryId=cursor))
|
||||
messages[-1] = row
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'read':
|
||||
read_cursor = cursor
|
||||
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
|
||||
elif action == 'push-available':
|
||||
push_available = body.get('enabled', True)
|
||||
elif action == 'auth-required':
|
||||
auth_required = body.get('enabled', True)
|
||||
elif action == 'disconnect':
|
||||
for socket in list(sockets):
|
||||
await socket.close(code=1012)
|
||||
elif action == 'malformed':
|
||||
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log))
|
||||
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log,
|
||||
pushRequests=push_requests, pushActive=len(push_installations), pushRetired=len(push_retired)))
|
||||
if path == '/api/v1/auth/session':
|
||||
if auth_required and request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(message='Fixture authentication required'), status=401)
|
||||
return web.json_response(dict(authenticated=True, mode='token'))
|
||||
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
|
||||
if path == '/api/v1/workspace-items':
|
||||
@@ -157,6 +191,29 @@ async def route(request):
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
return web.json_response(descriptor)
|
||||
if path == '/api/v1/agent/push/config':
|
||||
if not push_available:
|
||||
return web.json_response(dict(message='Old server fixture'), status=404)
|
||||
return web.json_response(dict(version=1, enabled=True, apnsAvailable=True, webPushAvailable=False,
|
||||
sourceId=PUSH_SOURCE, quarantined=False, policy={}))
|
||||
if path.startswith('/api/v1/agent/push/installations/'):
|
||||
installation = str(uuid.UUID(path.rsplit('/', 1)[1]))
|
||||
if request.method == 'DELETE':
|
||||
push_installations.pop(installation, None); push_retired.add(installation)
|
||||
push_requests.append(dict(method='DELETE', installationId=installation))
|
||||
return web.Response(status=204)
|
||||
if request.method == 'PUT':
|
||||
body = await request.json()
|
||||
assert set(body) == {'transport', 'enabled', 'token', 'environment', 'topic'}
|
||||
assert body['transport'] == 'apns' and body['enabled'] is True
|
||||
assert body['environment'] == 'sandbox' and body['topic'] == 'net.buzzert.sybil2'
|
||||
assert body['token'] == '00010aff' + '42' * 28 # explicitly injected synthetic token
|
||||
if installation in push_retired:
|
||||
return web.json_response(dict(message='Retired installation'), status=409)
|
||||
version = push_installations.get(installation, 0) + 1
|
||||
push_installations[installation] = version
|
||||
push_requests.append(dict(method='PUT', installationId=installation, environment='sandbox'))
|
||||
return web.json_response(dict(installationId=installation, transport='apns', enabled=True, version=version))
|
||||
if path == '/api/v1/agent/attachments' and request.method == 'POST':
|
||||
if not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
@@ -217,7 +274,7 @@ async def route(request):
|
||||
if path == '/api/v1/agent/read-state':
|
||||
if request.method == 'PUT':
|
||||
read_cursor = max(read_cursor, int((await request.json())['cursor']))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp()))
|
||||
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp(), sourceId=PUSH_SOURCE))
|
||||
if path == '/api/v1/agent/messages':
|
||||
if request.method == 'POST':
|
||||
body = await request.json()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import XCTest
|
||||
import SwiftUI
|
||||
@testable import Sybil
|
||||
|
||||
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
|
||||
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
|
||||
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg"],
|
||||
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
|
||||
|
||||
/// In-memory upload-only fixture. Every other path fails; no socket/provider IO.
|
||||
private actor AttachmentTestClient: AgentAPIClienting {
|
||||
var fail = false
|
||||
var delay = Duration.milliseconds(1200)
|
||||
func failNextUpload(delay: Duration) { fail = true; self.delay = delay }
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
||||
try await Task.sleep(for: delay)
|
||||
if fail {
|
||||
fail = false
|
||||
throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507)
|
||||
}
|
||||
return AgentAttachment(id: "synthetic-" + uploadID, kind: .file, filename: file.filename,
|
||||
mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
|
||||
}
|
||||
func agentDescriptor() throws -> AgentDescriptor { throw AgentAttachmentIssue.unavailable }
|
||||
func agentMessages(after: String?, before: String?) throws -> AgentPage { throw AgentAttachmentIssue.unavailable }
|
||||
func agentReadState() throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
|
||||
func agentMarkRead(cursor: String) throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
|
||||
func agentSubmit(content: String, requestID: String) throws -> AgentReceipt { throw AgentAttachmentIssue.unavailable }
|
||||
func agentSocket(after: String) throws -> any AgentSocket { throw AgentAttachmentIssue.unavailable }
|
||||
}
|
||||
|
||||
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
|
||||
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
|
||||
return false
|
||||
}
|
||||
|
||||
// Rendering the production composer against the in-memory upload fixture
|
||||
// covers transient states without changing the shipping picker or contacting a provider.
|
||||
@MainActor
|
||||
final class AgentComposerPresentationTests: XCTestCase {
|
||||
func testLoadingUploadingFailureRetryCapacityAndCapabilitySnapshots() async throws {
|
||||
XCTAssertTrue(UserDefaults.standard.string(forKey: "sybil.ios.apiBaseURL") == "http://127.0.0.1:8799/api")
|
||||
XCTAssertTrue(ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1")
|
||||
let room = SybilAgentRoom(), client = AttachmentTestClient()
|
||||
let draft = room.attachmentDraft
|
||||
draft.configure(client: client, storage: nil)
|
||||
var limits = attachmentLimits
|
||||
limits.maxCount = 1
|
||||
draft.updateCapability(limits)
|
||||
await client.failNextUpload(delay: .milliseconds(1200))
|
||||
await capture(room, "composer-enabled-empty")
|
||||
let id = try XCTUnwrap(draft.add(filename: "synthetic-original.bin") {
|
||||
try await Task.sleep(for: .milliseconds(600))
|
||||
return .init(filename: "synthetic-original.bin", mimeType: "application/octet-stream", data: originalBytes)
|
||||
})
|
||||
XCTAssertFalse(draft.canSend)
|
||||
XCTAssertEqual(draft.items.first?.state, .loading)
|
||||
await capture(room, "composer-loading-at-capacity-disabled")
|
||||
let uploading = await attachmentEventually { draft.items.first?.state == .uploading }
|
||||
XCTAssertTrue(uploading)
|
||||
await capture(room, "composer-uploading-at-capacity-disabled")
|
||||
let failed = await attachmentEventually { draft.items.first?.state == .failed }
|
||||
XCTAssertTrue(failed); XCTAssertFalse(draft.canSend)
|
||||
await capture(room, "composer-error-retry-disabled-send")
|
||||
draft.retry(id)
|
||||
let ready = await attachmentEventually { draft.items.first?.state == .ready }
|
||||
XCTAssertTrue(ready); XCTAssertTrue(draft.canSend)
|
||||
await capture(room, "composer-ready-enabled-send")
|
||||
await capture(room, "composer-ready-accessibility3", size: .accessibility3)
|
||||
draft.updateCapability(nil)
|
||||
XCTAssertFalse(draft.canSend)
|
||||
await capture(room, "composer-unavailable-retains-attachment")
|
||||
draft.remove(id)
|
||||
draft.caption = "Text still works"
|
||||
XCTAssertTrue(draft.canSend)
|
||||
await capture(room, "composer-text-only-capability")
|
||||
draft.suspend()
|
||||
}
|
||||
|
||||
private func capture(_ room: SybilAgentRoom, _ name: String, size: DynamicTypeSize = .large) async {
|
||||
// ImageRenderer omits UIKit-backed fields/scroll views; host the real hierarchy.
|
||||
let host = UIHostingController(rootView: AgentComposerPreview(room: room)
|
||||
.padding(12).background(SybilTheme.background)
|
||||
.environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size))
|
||||
let fitting = host.sizeThatFits(in: CGSize(width: 414, height: 600))
|
||||
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 414, height: max(120, fitting.height)))
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true; window.rootViewController = nil }
|
||||
host.view.frame = window.bounds
|
||||
host.view.setNeedsLayout(); host.view.layoutIfNeeded()
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
|
||||
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
|
||||
}
|
||||
let attachment = XCTAttachment(image: image)
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentComposerPreview: View {
|
||||
var room: SybilAgentRoom
|
||||
@FocusState private var focused: Bool
|
||||
var body: some View {
|
||||
AgentAttachmentComposer(room: room) {
|
||||
SybilPromptField(text: .constant(room.composer), focused: $focused, submit: {})
|
||||
SybilPromptSendButton(canSend: room.attachmentDraft.canSend, submit: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import XCTest
|
||||
import SwiftUI
|
||||
@testable import Sybil
|
||||
|
||||
/// Run on an iPhone simulator, with the real Agent view and deterministic local
|
||||
/// messages. Display-link delivery gaps measure main-run-loop responsiveness,
|
||||
/// not GPU presentation, touch latency, or physical-device frame rate.
|
||||
@MainActor
|
||||
final class AgentPerformanceTests: XCTestCase {
|
||||
func testEntranceGrowsOccupiedHeight() async throws {
|
||||
XCTAssertFalse(UIAccessibility.isReduceMotionEnabled, "This check must exercise the entrance animation")
|
||||
let room = SybilAgentRoom()
|
||||
let rows = (1...3).map { index in
|
||||
Message(id: "entrance-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||
role: .assistant, content: "Existing neighbor \(index)")
|
||||
}
|
||||
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: rows,
|
||||
cursor: "3", beforeCursor: nil, hasMore: false)), replay: true)
|
||||
room.flush()
|
||||
let host = UIHostingController(rootView: SybilAgentView(room: room))
|
||||
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let window = UIWindow(windowScene: scene)
|
||||
window.rootViewController = host
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true; window.rootViewController = nil; room.stop() }
|
||||
host.view.frame = window.bounds
|
||||
host.view.layoutIfNeeded()
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
func transcript(in view: UIView) -> UIScrollView? {
|
||||
if let scroll = view as? UIScrollView, scroll.contentSize.height > 100 { return scroll }
|
||||
return view.subviews.lazy.compactMap { transcript(in: $0) }.first
|
||||
}
|
||||
let scroll = try XCTUnwrap(transcript(in: host.view))
|
||||
let before = scroll.contentSize.height
|
||||
let reply = Message(id: "entrance-4", createdAt: rows[0].createdAt, role: .assistant,
|
||||
content: "Incoming reply\n\nIts occupied space should grow with the bubble.")
|
||||
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: [reply],
|
||||
cursor: "4", beforeCursor: nil, hasMore: false)))
|
||||
room.flush()
|
||||
var heights: [CGFloat] = []
|
||||
for _ in 0..<32 {
|
||||
try await Task.sleep(for: .milliseconds(16))
|
||||
heights.append(scroll.contentSize.height)
|
||||
}
|
||||
let after = try XCTUnwrap(heights.last)
|
||||
// All four short rows fit onscreen, so these are measured heights, not
|
||||
// estimates for unmaterialized lazy history. No frame-rate claim.
|
||||
XCTAssertLessThan(after, scroll.bounds.height)
|
||||
XCTAssertGreaterThan(after - before, 50)
|
||||
let intermediate = Set(heights.filter { $0 > before + 2 && $0 < after - 2 }.map { Int($0.rounded()) })
|
||||
XCTAssertGreaterThanOrEqual(intermediate.count, 3, "Neighbors must get intermediate layout heights, not a full-height insertion")
|
||||
print("AGENT_ENTRANCE_HEIGHT before=\(before) samples=\(heights)")
|
||||
}
|
||||
|
||||
func testThinkingScrollResponsiveness() async throws {
|
||||
let room = SybilAgentRoom()
|
||||
var rows: [Message] = []
|
||||
for index in 1...160 { rows.append(Self.message(index)) }
|
||||
try room.receive(.messages(AgentPage(conversationId: "performance", messages: rows, 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 {
|
||||
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))
|
||||
XCTAssertGreaterThan(scroll.contentSize.height, 10_000)
|
||||
// Move away from the pinned bottom before the controlled sweeps.
|
||||
scroll.setContentOffset(CGPoint(x: 0, y: max(0, scroll.contentSize.height - scroll.bounds.height - 1200)), animated: false)
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
AgentRenderDiagnostics.enabled = true
|
||||
var reports: [[String: Any]] = []
|
||||
for phase in ["idle-scroll", "thinking-stationary", "thinking-scroll"] {
|
||||
if phase == "thinking-stationary" {
|
||||
try room.receive(.activity("performance", busyActivity(step: 0)))
|
||||
room.flush()
|
||||
scroll.setContentOffset(CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom), animated: false)
|
||||
try await Task.sleep(for: .seconds(1))
|
||||
XCTAssertTrue(room.snapshot.isTyping)
|
||||
}
|
||||
AgentRenderDiagnostics.counts = [:]
|
||||
let sampler = AgentFrameSampler(scroll: phase == "thinking-stationary" ? nil : scroll)
|
||||
sampler.start()
|
||||
// Changing queue/worker/tool progress every 500 ms exercises real
|
||||
// activity observation while the typing spinner remains active. No IO.
|
||||
for step in 1...24 {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
if phase != "idle-scroll" {
|
||||
try room.receive(.activity("performance", busyActivity(step: step)))
|
||||
}
|
||||
}
|
||||
var report = sampler.stop()
|
||||
report["phase"] = phase
|
||||
report["procedure"] = "mixed160-changing-activity-v1"
|
||||
report["operations"] = AgentRenderDiagnostics.counts
|
||||
report["contentHeight"] = scroll.contentSize.height
|
||||
// iPhone 16e / iOS 26.5 / default text size reference geometry from
|
||||
// the unoptimized view. A faster result must not shrink the content.
|
||||
XCTAssertEqual(scroll.contentSize.height,
|
||||
phase == "idle-scroll" ? 39_181.333333 : 39_223.333333,
|
||||
accuracy: 1)
|
||||
reports.append(report)
|
||||
}
|
||||
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
|
||||
print("AGENT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
|
||||
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
|
||||
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 {
|
||||
let own = index % 3 == 0
|
||||
let content = own
|
||||
? "Can you review step \(index)? Keep the scroll position and the **literal input** intact.\n\nInclude the next checks."
|
||||
: "### Investigation \(index)\n\nThe worker checked the local transcript and found a repeatable result. This paragraph contains **emphasis**, `inline code`, and a [reference](https://example.com).\n\n- Preserve the reading position while new messages arrive.\n- Keep attachments, queued messages, and status updates visible.\n- Verify the result on the simulator before publishing.\n\n```swift\nlet item = messages[\(index)]\nrender(item)\n```\n\nThe next step is to compare the measurements and report any remaining limitations."
|
||||
return Message(id: "perf-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 60)),
|
||||
role: own ? .user : .assistant, content: content,
|
||||
metadata: .object(["kind": .string("message"), "entryId": .string(String(index))]))
|
||||
}
|
||||
|
||||
private func busyActivity(step: Int) -> AgentActivity {
|
||||
AgentActivity(state: "thinking", isThinking: true, queued: 1 + step % 3,
|
||||
backgroundWorkers: 1 + step % 2,
|
||||
tools: [.init(id: "local-review", name: "review_transcript", status: "running",
|
||||
output: "Checked \(step * 7) of 200 local entries; preparing batch \(step + 1).")])
|
||||
}
|
||||
|
||||
private func findScroll(_ view: UIView) -> UIScrollView? {
|
||||
if let scroll = view as? UIScrollView, scroll.contentSize.height > 10_000 { return scroll }
|
||||
return view.subviews.lazy.compactMap { self.findScroll($0) }.first
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class AgentFrameSampler: NSObject {
|
||||
private weak var scroll: UIScrollView?
|
||||
private var link: CADisplayLink?
|
||||
private var previous: CFTimeInterval?
|
||||
private var started: CFTimeInterval = 0
|
||||
private var gaps: [Double] = []
|
||||
private var budgets: [Double] = []
|
||||
private var origin: CGFloat = 0
|
||||
private var scrollDistance: CGFloat = 0
|
||||
init(scroll: UIScrollView?) { self.scroll = scroll }
|
||||
func start() {
|
||||
origin = scroll?.contentOffset.y ?? 0
|
||||
started = CACurrentMediaTime()
|
||||
let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
|
||||
link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 60, preferred: 60)
|
||||
link.add(to: .main, forMode: .common)
|
||||
self.link = link
|
||||
}
|
||||
@objc private func frame(_ link: CADisplayLink) {
|
||||
let now = CACurrentMediaTime()
|
||||
if let previous { gaps.append((now - previous) * 1000); budgets.append((link.targetTimestamp - link.timestamp) * 1000) }
|
||||
previous = now
|
||||
if let scroll {
|
||||
// Constant-speed triangular sweep over 2400 points, one cycle / 8 s.
|
||||
let phase = (now - started).truncatingRemainder(dividingBy: 8) / 4
|
||||
let delta = CGFloat(phase <= 1 ? phase : 2 - phase) * 2400
|
||||
let target = max(-scroll.adjustedContentInset.top, origin - delta)
|
||||
scrollDistance += abs(target - scroll.contentOffset.y)
|
||||
scroll.setContentOffset(CGPoint(x: 0, y: target), animated: false)
|
||||
}
|
||||
}
|
||||
func stop() -> [String: Any] {
|
||||
link?.invalidate(); link = nil
|
||||
let sorted = gaps.sorted()
|
||||
let budget = budgets.sorted()[budgets.count / 2]
|
||||
func percentile(_ value: Double) -> Double { sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * value))] }
|
||||
return ["seconds": CACurrentMediaTime() - started, "callbacks": gaps.count,
|
||||
"medianBudgetMS": budget, "p50MS": percentile(0.5), "p95MS": percentile(0.95),
|
||||
"p99MS": percentile(0.99), "maxMS": sorted.last ?? 0,
|
||||
"over25MS": gaps.filter { $0 > 25 }.count, "over50MS": gaps.filter { $0 > 50 }.count,
|
||||
"missedBudgetIntervals": gaps.reduce(0) { $0 + max(0, Int(($1 / budget).rounded()) - 1) },
|
||||
"scrollDistance": scrollDistance]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
/// Actual normal chat and Agent views, using only the loopback contract fixture.
|
||||
@MainActor final class AgentPolishUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
try await control(["action": "reset", "count": 0])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
private func control(_ body: [String: Any]) async throws {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (_, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
|
||||
}
|
||||
private func sidebar() {
|
||||
if !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
}
|
||||
private func openChat() {
|
||||
sidebar()
|
||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
|
||||
XCTAssertTrue(app.buttons["chat-attach"].waitForExistence(timeout: 8))
|
||||
}
|
||||
private func openAgent() {
|
||||
sidebar(); app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
shot.name = name; shot.lifetime = .keepAlways; add(shot)
|
||||
}
|
||||
func testSharedAttachmentControlAndPickerFlows() async throws {
|
||||
try await control(["action": "transcript-comparison"])
|
||||
app.launch(); openChat()
|
||||
let normal = app.buttons["chat-attach"]
|
||||
XCTAssertEqual(normal.label, "Attach file"); XCTAssertTrue(normal.isEnabled)
|
||||
let normalSize = normal.frame.size
|
||||
let normalLabel = normal.label
|
||||
XCTAssertGreaterThanOrEqual(normalSize.width, 44); XCTAssertGreaterThanOrEqual(normalSize.height, 44)
|
||||
capture("polish-normal-attachment-control")
|
||||
normal.tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
normal.tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
openAgent()
|
||||
let agent = app.buttons["agent-attach"]
|
||||
XCTAssertEqual(agent.label, normalLabel); XCTAssertTrue(agent.isEnabled)
|
||||
XCTAssertEqual(agent.frame.size.width, normalSize.width, accuracy: 1)
|
||||
XCTAssertEqual(agent.frame.size.height, normalSize.height, accuracy: 1)
|
||||
// Same row as the prompt, including the minimum VoiceOver/touch target.
|
||||
XCTAssertEqual(agent.frame.maxY, app.buttons["agent-send"].frame.maxY, accuracy: 1)
|
||||
capture("polish-agent-attachment-control")
|
||||
agent.tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
agent.tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
|
||||
}
|
||||
|
||||
func testUserAssistantMultilineParagraphMetricsInBothWorkspaces() async throws {
|
||||
try await compareMetrics(user: "User first\nUser second\n\nUser paragraph",
|
||||
peer: "Peer first \nPeer second\n\nPeer paragraph", name: "multiline-paragraphs")
|
||||
}
|
||||
func testPastedParagraphMetricsInBothWorkspaces() async throws {
|
||||
try await compareMetrics(user: "User first\r\nUser second\r\n \r\nUser paragraph",
|
||||
peer: "Peer first \nPeer second\n\t\nPeer paragraph", name: "pasted-paragraphs")
|
||||
}
|
||||
private func compareMetrics(user: String, peer: String, name: String) async throws {
|
||||
try await control(["action": "presentation-samples", "messages": [
|
||||
["role": "user", "content": user], ["role": "assistant", "content": peer]]])
|
||||
app.launch(); openChat()
|
||||
let normal = try metrics()
|
||||
capture("polish-normal-" + name)
|
||||
openAgent()
|
||||
let agent = try metrics()
|
||||
XCTAssertEqual(normal.0, agent.0, accuracy: 1)
|
||||
XCTAssertEqual(normal.1, agent.1, accuracy: 1)
|
||||
capture("polish-agent-" + name)
|
||||
}
|
||||
private func metrics() throws -> (CGFloat, CGFloat) {
|
||||
let user = app.staticTexts["User first\nUser second"]
|
||||
let peer = app.staticTexts["Peer first\nPeer second"]
|
||||
XCTAssertTrue(user.waitForExistence(timeout: 8)); XCTAssertTrue(peer.waitForExistence(timeout: 8))
|
||||
let userParagraph = app.staticTexts["User paragraph"], peerParagraph = app.staticTexts["Peer paragraph"]
|
||||
XCTAssertTrue(userParagraph.exists); XCTAssertTrue(peerParagraph.exists)
|
||||
XCTAssertEqual(user.frame.height, peer.frame.height, accuracy: 1)
|
||||
let gap = userParagraph.frame.minY - user.frame.maxY
|
||||
XCTAssertEqual(gap, peerParagraph.frame.minY - peer.frame.maxY, accuracy: 1)
|
||||
XCTAssertGreaterThan(gap, 0)
|
||||
return (user.frame.height, gap)
|
||||
}
|
||||
func testLiteralUserAndSemanticAssistantMarkdown() async throws {
|
||||
try await control(["action": "presentation-samples", "messages": [
|
||||
["role": "user", "content": "**literal bold** `literal code`\n- literal item\n[not a link](https://example.invalid)"],
|
||||
["role": "assistant", "content": "Soft break\ncontinues here.\n\n**Real bold** and [Local link](http://127.0.0.1:8799).\n\n- First item\n- Second item\n\n```swift\nlet value = 42\n```"]]])
|
||||
app.launch(); openChat()
|
||||
assertMarkdownSemantics(); capture("polish-normal-literal-and-markdown")
|
||||
openAgent()
|
||||
assertMarkdownSemantics(); capture("polish-agent-literal-and-markdown")
|
||||
try await control(["action": "long-tool"])
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
}
|
||||
private func assertMarkdownSemantics() {
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "**literal bold**")).firstMatch.waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "[not a link](https://example.invalid)")).firstMatch.exists)
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "First item")).firstMatch.exists)
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "let value = 42")).firstMatch.exists)
|
||||
XCTAssertTrue(app.links["Local link"].exists)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import XCTest
|
||||
|
||||
/// Local fixture + fake notification center/registrar only. No APNs provider or
|
||||
/// system permission dialog is involved in these deterministic UI scenarios.
|
||||
@MainActor final class AgentPushUITests: XCTestCase {
|
||||
let app = XCUIApplication()
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
try await control(["action": "reset", "count": 3])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launchEnvironment = ["SYBIL_PUSH_FIXTURE": "1", "SYBIL_PUSH_PERMISSION": "authorized"]
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
@discardableResult private func control(_ body: [String: Any]) async throws -> [String: Any] {
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
|
||||
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
private func settings() {
|
||||
if !app.buttons["Settings"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["Settings"].waitForExistence(timeout: 5)); app.buttons["Settings"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-refresh"].waitForExistence(timeout: 5))
|
||||
if !app.buttons["agent-push-refresh"].isHittable { app.scrollViews.firstMatch.swipeUp() }
|
||||
}
|
||||
private func capture(_ name: String) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20)); app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5))
|
||||
}
|
||||
private func ensureDisabled() {
|
||||
if app.buttons["agent-push-disable"].exists { app.buttons["agent-push-disable"].tap() }
|
||||
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
func testExplicitEnableRefreshAndDisable() async throws {
|
||||
app.launch(); settings(); ensureDisabled()
|
||||
let before = try await control(["action": "state"])
|
||||
let count = (before["pushRequests"] as? [[String: Any]])?.count ?? 0
|
||||
app.buttons["agent-push-enable"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-disable"].waitForExistence(timeout: 5))
|
||||
let status = app.staticTexts["agent-push-status"]
|
||||
let enabled = NSPredicate(format: "label == %@", "Notifications are enabled.")
|
||||
let enabledExpectation = expectation(for: enabled, evaluatedWith: status)
|
||||
await fulfillment(of: [enabledExpectation], timeout: 8)
|
||||
capture("push-enabled-generic-preview")
|
||||
app.buttons["agent-push-refresh"].tap()
|
||||
let state = try await control(["action": "state"])
|
||||
let requests = try XCTUnwrap(state["pushRequests"] as? [[String: Any]])
|
||||
XCTAssertEqual(requests.count, count + 1)
|
||||
XCTAssertEqual(requests.last?["environment"] as? String, "sandbox")
|
||||
app.buttons["agent-push-disable"].tap()
|
||||
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
|
||||
let revoked = try await control(["action": "state"])
|
||||
XCTAssertEqual(revoked["pushActive"] as? Int, 0)
|
||||
capture("push-disabled-revoked")
|
||||
}
|
||||
|
||||
func testColdTapRoutesAgentAndPreservesAttachmentCaption() async throws {
|
||||
app.launch()
|
||||
openAgent()
|
||||
let composer = app.textFields["agent-composer"]
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 5))
|
||||
composer.tap(); composer.typeText("Push fixture saved caption")
|
||||
app.terminate()
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 20))
|
||||
XCTAssertTrue((composer.value as? String ?? "").contains("Push fixture saved caption"))
|
||||
XCTAssertTrue(app.buttons["agent-attach"].exists)
|
||||
capture("push-cold-tap-agent-draft-preserved")
|
||||
}
|
||||
|
||||
func testTapBeforeLoginWaitsThenRoutesAgent() async throws {
|
||||
try await control(["action": "auth-required", "enabled": true])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", ""]
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
|
||||
app.launch()
|
||||
XCTAssertTrue(app.buttons["Connect"].waitForExistence(timeout: 15))
|
||||
XCTAssertFalse(app.textFields["agent-composer"].exists)
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual(state["readCursor"] as? Int, 0)
|
||||
app.secureTextFields.firstMatch.tap(); app.secureTextFields.firstMatch.typeText("fixture-token")
|
||||
app.buttons["Connect"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 20))
|
||||
capture("push-pending-auth-routed-after-login")
|
||||
}
|
||||
|
||||
func testDeniedAndOldServerLeaveAgentUsable() async throws {
|
||||
app.launchEnvironment["SYBIL_PUSH_PERMISSION"] = "denied"
|
||||
app.launch(); settings()
|
||||
XCTAssertTrue(app.buttons["Open iOS Notification Settings"].waitForExistence(timeout: 5))
|
||||
capture("push-denied-settings")
|
||||
try await control(["action": "push-available", "enabled": false])
|
||||
app.buttons["agent-push-refresh"].tap()
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].exists); app.buttons["Show sidebar"].tap()
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 10))
|
||||
XCTAssertTrue(app.staticTexts["History message 3: local simulator fixture."].waitForExistence(timeout: 5))
|
||||
capture("push-old-server-agent-compatible")
|
||||
}
|
||||
|
||||
func testWarmTapDismissesAttachmentPickerPreservingDraft() async throws {
|
||||
app.launchEnvironment["SYBIL_PUSH_TAP_ON_FOREGROUND"] = "1"
|
||||
app.launch(); openAgent()
|
||||
let composer = app.textFields["agent-composer"]
|
||||
composer.tap(); composer.typeText(" Warm push preserved draft")
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
XCUIDevice.shared.press(.home); app.activate()
|
||||
XCTAssertTrue(composer.waitForExistence(timeout: 10))
|
||||
let visible = expectation(for: NSPredicate(format: "hittable == true"), evaluatedWith: composer)
|
||||
await fulfillment(of: [visible], timeout: 5)
|
||||
XCTAssertFalse(app.buttons["Cancel"].firstMatch.exists)
|
||||
XCTAssertTrue((composer.value as? String ?? "").contains("Warm push preserved draft"))
|
||||
capture("push-warm-tap-dismissed-picker-draft-preserved")
|
||||
}
|
||||
}
|
||||
@@ -241,6 +241,24 @@ final class AgentUITests: XCTestCase {
|
||||
let state = try await control(["action": "state"])
|
||||
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
|
||||
}
|
||||
func testBusyProgressAndSameIDMessageUpdate() async throws {
|
||||
try await control(["action": "progress", "queued": 2, "workers": 1, "step": 7])
|
||||
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts["2 queued"].waitForExistence(timeout: 5))
|
||||
try await control(["action": "progress", "queued": 3, "workers": 2, "step": 14])
|
||||
XCTAssertTrue(app.staticTexts["3 queued"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.staticTexts["2 working"].exists)
|
||||
// Same stable ID, changed content: the transcript boundary must update.
|
||||
try await control(["action": "edit-last", "content": "**Revised response**\n\n[Updated reference](http://127.0.0.1:8799)"])
|
||||
XCTAssertTrue(app.staticTexts["Revised response"].waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(app.links["Updated reference"].exists)
|
||||
XCTAssertFalse(app.staticTexts["History message 3: local simulator fixture."].exists)
|
||||
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].exists)
|
||||
try await control(["action": "busy", "value": false])
|
||||
let stopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.staticTexts["Sybil is thinking…"])
|
||||
await fulfillment(of: [stopped], timeout: 5)
|
||||
}
|
||||
|
||||
func testReduceMotionSend() async throws {
|
||||
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
|
||||
send("reduce motion fixture")
|
||||
@@ -295,7 +313,8 @@ final class AgentUITests: XCTestCase {
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown()
|
||||
scroll.swipeDown()
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
|
||||
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
||||
let before = anchor.frame.minY
|
||||
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
||||
@@ -303,20 +322,33 @@ final class AgentUITests: XCTestCase {
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
capture("agent-reading-anchor")
|
||||
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)
|
||||
}
|
||||
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()
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
||||
capture("agent-older-history-anchor")
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
// Loading older lazy history may move the viewport. It must still load
|
||||
// the remaining history and preserve scroll-to-latest/keyboard behavior below.
|
||||
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
|
||||
capture("agent-older-history-loaded")
|
||||
let latest = app.staticTexts["Reply while reading earlier history"]
|
||||
for _ in 0..<30 {
|
||||
if latest.isHittable { break }
|
||||
scroll.swipeUp(velocity: .fast)
|
||||
}
|
||||
scroll.swipeUp(velocity: .slow)
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||
capture("agent-multiline-keyboard")
|
||||
// Sending remains the explicit navigation path while reading history.
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertFalse(latest.isHittable)
|
||||
app.buttons["agent-send"].tap()
|
||||
let sent = app.staticTexts["A multiline draft\nwith a second line\nand a third line"]
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: sent)
|
||||
await fulfillment(of: [followed], timeout: 5)
|
||||
}
|
||||
|
||||
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
||||
|
||||
@@ -37,8 +37,19 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
}
|
||||
|
||||
private var transcript: XCUIElement {
|
||||
// The tablet sidebar is also a scroll view; select the detail by its content.
|
||||
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
|
||||
// Identify the transcript independently of any particular message row.
|
||||
app.scrollViews["agent-transcript"]
|
||||
}
|
||||
|
||||
private func scrollToLatest(_ latest: XCUIElement) {
|
||||
for _ in 0..<30 {
|
||||
if latest.isHittable { break }
|
||||
transcript.swipeUp(velocity: .fast)
|
||||
}
|
||||
// Finish at the physical end, including the reply metadata and spacing.
|
||||
transcript.swipeUp(velocity: .slow)
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||
}
|
||||
|
||||
private func capture(_ name: String) {
|
||||
@@ -55,6 +66,79 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
|
||||
private func reloadMixedHistory() async throws {
|
||||
app.terminate()
|
||||
let rows = (1...160).map { index in
|
||||
let short = "History message \(index): local simulator fixture."
|
||||
return ["role": index % 3 == 0 ? "user" : "assistant",
|
||||
"content": index == 160 || index % 3 == 0 ? short :
|
||||
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
|
||||
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
|
||||
}
|
||||
try await control(["action": "presentation-samples", "messages": rows])
|
||||
app.launch()
|
||||
}
|
||||
|
||||
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
|
||||
try await reloadMixedHistory()
|
||||
openAgent()
|
||||
let latest = app.staticTexts["History message 160: local simulator fixture."]
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
|
||||
capture("agent-initial-open")
|
||||
|
||||
showSidebar()
|
||||
app.buttons["Settings"].tap()
|
||||
XCTAssertFalse(composer.exists)
|
||||
openAgent()
|
||||
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
|
||||
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
|
||||
capture("agent-tab-return")
|
||||
}
|
||||
|
||||
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
|
||||
try await reloadMixedHistory()
|
||||
openAgent()
|
||||
composer.tap()
|
||||
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
|
||||
composer.press(forDuration: 1)
|
||||
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
|
||||
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
|
||||
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
|
||||
}
|
||||
let draft = "Keyboard send\nsecond line\nthird line"
|
||||
composer.typeText(draft)
|
||||
XCTAssertTrue(app.keyboards.firstMatch.exists)
|
||||
let multilineHeight = composer.frame.height
|
||||
app.buttons["agent-send"].tap()
|
||||
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
|
||||
await fulfillment(of: [cleared], timeout: 5)
|
||||
XCTAssertLessThan(composer.frame.height, multilineHeight)
|
||||
XCTAssertTrue(app.keyboards.firstMatch.exists)
|
||||
let sent = app.staticTexts[draft]
|
||||
XCTAssertTrue(sent.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(sent.isHittable)
|
||||
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
|
||||
await fulfillment(of: [followed], timeout: 10)
|
||||
capture("agent-send-cleared-composer-with-keyboard")
|
||||
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
|
||||
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
|
||||
// Finished: is only the first Markdown paragraph. Measure the final
|
||||
// reply content, leaving the same allowance for metadata and spacing.
|
||||
let replyEnd = app.staticTexts["The local fixture returned three matches."]
|
||||
XCTAssertTrue(replyEnd.isHittable)
|
||||
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
|
||||
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
|
||||
print("Agent send settled final-content gap: \(bottomGap)pt")
|
||||
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||
}
|
||||
|
||||
func testSharedHeaderFadeGeometry() async throws {
|
||||
showSidebar()
|
||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||
@@ -73,7 +157,7 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(scroll.exists)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
||||
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
capture("agent-content-crossing-shared-fade")
|
||||
XCTAssertTrue(composer.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
@@ -89,7 +173,8 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
||||
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||
// Stay below the header's content clearance, where text is readable.
|
||||
let visibleTop = scroll.frame.minY + 100
|
||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
||||
@@ -98,8 +183,9 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
let before = anchor.frame.minY
|
||||
let stateBefore = try await control(["action": "state"])
|
||||
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
|
||||
await fulfillment(of: [unread], timeout: 8)
|
||||
// The removed button no longer exposes unread state. Allow the entrance
|
||||
// and read debounce to settle, then inspect the fixture's actual cursor.
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||
let stateAfter = try await control(["action": "state"])
|
||||
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
||||
@@ -107,23 +193,23 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
capture("agent-fade-reading-position-and-unread")
|
||||
|
||||
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)
|
||||
}
|
||||
let load = app.buttons["Load earlier messages"]
|
||||
XCTAssertTrue(load.isHittable)
|
||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
||||
let beforeLoad = first.frame.minY
|
||||
load.tap()
|
||||
capture("agent-fade-prepend-reading-position")
|
||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
||||
"Prepending must preserve the readable row's position beneath the header")
|
||||
// Prepend position is intentionally not preserved with lazy history.
|
||||
// New-message reading anchors above and follow/read settling below remain strict.
|
||||
XCTAssertFalse(load.exists)
|
||||
capture("agent-fade-older-history-loaded")
|
||||
|
||||
app.buttons["agent-jump-latest"].tap()
|
||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||
scrollToLatest(latest)
|
||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||
await fulfillment(of: [followed], timeout: 5)
|
||||
composer.tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||
capture("agent-fade-latest-with-keyboard")
|
||||
@@ -134,5 +220,27 @@ final class TranscriptFadeUITests: XCTestCase {
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let read = try await control(["action": "state"])
|
||||
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; scrolling 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)
|
||||
scrollToLatest(keyboardReply)
|
||||
XCTAssertTrue(keyboardReply.isHittable)
|
||||
composer.tap()
|
||||
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||
XCTAssertTrue(keyboardReply.isHittable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ platform :ios do
|
||||
lane :beta do
|
||||
setup_ci
|
||||
|
||||
match(type: "appstore")
|
||||
match(type: "appstore", readonly: true)
|
||||
|
||||
tag = ENV.fetch("GITHUB_REF_NAME")
|
||||
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
|
||||
|
||||
+3
-3
@@ -10,13 +10,13 @@ generate:
|
||||
|
||||
build: generate
|
||||
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' | xcbeautify; \
|
||||
else \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
|
||||
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'; \
|
||||
fi
|
||||
|
||||
test:
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
|
||||
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' -parallel-testing-enabled NO
|
||||
|
||||
run: generate
|
||||
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
|
||||
|
||||
@@ -11,6 +11,22 @@ include:
|
||||
|
||||
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
|
||||
targets:
|
||||
SybilPresentationTests:
|
||||
type: bundle.unit-test
|
||||
platform: iOS
|
||||
deploymentTarget: "18.0"
|
||||
sources:
|
||||
- Tests/SybilPresentationTests
|
||||
dependencies:
|
||||
- target: SybilApp
|
||||
- package: Sybil
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.presentation-tests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
SWIFT_VERSION: 6.0
|
||||
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Sybil.app/Sybil"
|
||||
BUNDLE_LOADER: "$(TEST_HOST)"
|
||||
SybilAgentUITests:
|
||||
type: bundle.ui-testing
|
||||
platform: iOS
|
||||
@@ -27,6 +43,20 @@ targets:
|
||||
TEST_TARGET_NAME: SybilApp
|
||||
SWIFT_VERSION: 6.0
|
||||
schemes:
|
||||
SybilPresentation:
|
||||
run:
|
||||
commandLineArguments:
|
||||
"-sybil.ios.apiBaseURL http://127.0.0.1:8799/api -sybil.ios.adminToken fixture-token": true
|
||||
environmentVariables:
|
||||
SYBIL_PUSH_FIXTURE: "1"
|
||||
build:
|
||||
targets:
|
||||
SybilApp: all
|
||||
SybilPresentationTests: [test]
|
||||
test:
|
||||
config: Debug
|
||||
targets:
|
||||
- SybilPresentationTests
|
||||
SybilAgentUI:
|
||||
build:
|
||||
targets:
|
||||
|
||||
Generated
+161
@@ -19,14 +19,17 @@
|
||||
"exa-js": "^2.4.0",
|
||||
"fastify": "^5.7.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"marked": "17.0.2",
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
@@ -878,6 +881,16 @@
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -894,6 +907,15 @@
|
||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
@@ -927,6 +949,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
@@ -946,6 +980,18 @@
|
||||
"fastq": "^1.17.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
@@ -1116,6 +1162,15 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
@@ -1490,6 +1545,15 @@
|
||||
"entities": "^4.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
@@ -1510,6 +1574,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
@@ -1589,6 +1666,27 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
@@ -1644,6 +1742,18 @@
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz",
|
||||
"integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
@@ -1690,6 +1800,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
@@ -2006,6 +2122,26 @@
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
|
||||
@@ -2034,6 +2170,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/secure-json-parse": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
|
||||
@@ -2231,6 +2373,25 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
|
||||
@@ -28,14 +28,17 @@
|
||||
"exa-js": "^2.4.0",
|
||||
"fastify": "^5.7.2",
|
||||
"html-to-text": "^9.0.5",
|
||||
"marked": "17.0.2",
|
||||
"openai": "^6.16.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"prisma": "^6.6.0",
|
||||
"web-push": "^3.6.7",
|
||||
"ws": "8.22.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE "AgentReadState" ADD COLUMN "sourceId" TEXT;
|
||||
CREATE TABLE "AgentPushInstallation" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "transport" TEXT NOT NULL, "payload" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT false, "version" INTEGER NOT NULL DEFAULT 1, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, "lastClearAt" DATETIME, "revokedAt" DATETIME);
|
||||
CREATE INDEX "AgentPushInstallation_ownerEpoch_enabled_idx" ON "AgentPushInstallation"("ownerEpoch", "enabled");
|
||||
CREATE INDEX "AgentPushInstallation_fingerprint_idx" ON "AgentPushInstallation"("fingerprint");
|
||||
CREATE TABLE "AgentPushPresence" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sequence" INTEGER NOT NULL, "visible" BOOLEAN NOT NULL, "focused" BOOLEAN NOT NULL, "lastInteractionAt" DATETIME, "expiresAt" DATETIME NOT NULL);
|
||||
CREATE INDEX "AgentPushPresence_ownerEpoch_expiresAt_idx" ON "AgentPushPresence"("ownerEpoch", "expiresAt");
|
||||
CREATE TABLE "AgentPushConnection" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "kind" TEXT NOT NULL, "sessionId" TEXT, "expiresAt" DATETIME NOT NULL);
|
||||
CREATE INDEX "AgentPushConnection_ownerEpoch_expiresAt_idx" ON "AgentPushConnection"("ownerEpoch", "expiresAt");
|
||||
CREATE TABLE "AgentPushCoordinator" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sourceId" TEXT, "cursor" TEXT NOT NULL DEFAULT '0', "quarantined" BOOLEAN NOT NULL DEFAULT false, "contextObservedAt" DATETIME, "latestUserMessageAt" DATETIME, "localPromptAt" DATETIME, "nativeActive" BOOLEAN NOT NULL DEFAULT false, "nativeUnknown" BOOLEAN NOT NULL DEFAULT true, "uncertain" BOOLEAN NOT NULL DEFAULT true, "lockOwner" TEXT, "lockUntil" DATETIME);
|
||||
CREATE TABLE "AgentPushDelivery" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "installationId" TEXT NOT NULL, "installationVersion" INTEGER NOT NULL, "sourceId" TEXT NOT NULL, "messageId" TEXT NOT NULL, "cursor" TEXT NOT NULL, "kind" TEXT NOT NULL, "trigger" TEXT NOT NULL, "priority" INTEGER NOT NULL DEFAULT 0, "status" TEXT NOT NULL DEFAULT 'pending', "attempts" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "dueAt" DATETIME NOT NULL, "expiresAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL, "outcome" TEXT);
|
||||
CREATE INDEX "AgentPushDelivery_status_dueAt_idx" ON "AgentPushDelivery"("status", "dueAt");
|
||||
CREATE INDEX "AgentPushDelivery_installationId_status_idx" ON "AgentPushDelivery"("installationId", "status");
|
||||
CREATE INDEX "AgentPushDelivery_ownerEpoch_sourceId_cursor_idx" ON "AgentPushDelivery"("ownerEpoch", "sourceId", "cursor");
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE "AgentPushPresence" ADD COLUMN "inactiveUntil" DATETIME;
|
||||
ALTER TABLE "AgentPushCoordinator" ADD COLUMN "unknownSince" DATETIME;
|
||||
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressionReason" TEXT;
|
||||
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressionClientKind" TEXT;
|
||||
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressedAt" DATETIME;
|
||||
@@ -219,7 +219,89 @@ model ProjectItem {
|
||||
// Shared personal read position for the external persistent Sybil Agent room.
|
||||
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
|
||||
model AgentReadState {
|
||||
sourceId String?
|
||||
id String @id
|
||||
cursor String @default("0")
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// One server-derived shared principal. Transport payloads are secret DB data.
|
||||
model AgentPushInstallation {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
transport String
|
||||
payload String
|
||||
fingerprint String
|
||||
enabled Boolean @default(false)
|
||||
version Int @default(1)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
lastClearAt DateTime?
|
||||
revokedAt DateTime?
|
||||
@@index([ownerEpoch, enabled])
|
||||
@@index([fingerprint])
|
||||
}
|
||||
|
||||
model AgentPushPresence {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
sequence Int
|
||||
visible Boolean
|
||||
focused Boolean
|
||||
lastInteractionAt DateTime?
|
||||
inactiveUntil DateTime?
|
||||
expiresAt DateTime
|
||||
@@index([ownerEpoch, expiresAt])
|
||||
}
|
||||
|
||||
model AgentPushConnection {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
kind String
|
||||
sessionId String?
|
||||
expiresAt DateTime
|
||||
@@index([ownerEpoch, expiresAt])
|
||||
}
|
||||
|
||||
model AgentPushCoordinator {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
sourceId String?
|
||||
cursor String @default("0")
|
||||
quarantined Boolean @default(false)
|
||||
contextObservedAt DateTime?
|
||||
latestUserMessageAt DateTime?
|
||||
localPromptAt DateTime?
|
||||
nativeActive Boolean @default(false)
|
||||
nativeUnknown Boolean @default(true)
|
||||
unknownSince DateTime?
|
||||
uncertain Boolean @default(true)
|
||||
lockOwner String?
|
||||
lockUntil DateTime?
|
||||
}
|
||||
|
||||
model AgentPushDelivery {
|
||||
id String @id
|
||||
ownerEpoch String
|
||||
installationId String
|
||||
installationVersion Int
|
||||
sourceId String
|
||||
messageId String
|
||||
cursor String
|
||||
kind String
|
||||
trigger String
|
||||
priority Int @default(0)
|
||||
status String @default("pending")
|
||||
attempts Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
dueAt DateTime
|
||||
expiresAt DateTime
|
||||
updatedAt DateTime @updatedAt
|
||||
outcome String?
|
||||
lastSuppressionReason String?
|
||||
lastSuppressionClientKind String?
|
||||
lastSuppressedAt DateTime?
|
||||
@@index([status, dueAt])
|
||||
@@index([installationId, status])
|
||||
@@index([ownerEpoch, sourceId, cursor])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { createECDH } from 'node:crypto';
|
||||
import { env } from './env.js';
|
||||
import { validateAPNsKey } from './agent-push-contract.js';
|
||||
|
||||
export type PushConfig = { enabled: boolean; adminToken?: string; apns?: { key: string; keyId: string; teamId: string; topic: string; environment: 'sandbox' | 'production' };
|
||||
web?: { publicKey: string; privateKey: string; subject: string } };
|
||||
function secretFile(path: string): string {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error('Unsafe push secret file');
|
||||
return readFileSync(path, 'utf8').trim();
|
||||
}
|
||||
/** Never log config objects or secret-file read/crypto errors. Invalid credentials disable their adapter. */
|
||||
export function loadPushConfig(): PushConfig {
|
||||
const result: PushConfig = { enabled: env.AGENT_PUSH_ENABLED && Boolean(env.ADMIN_TOKEN), adminToken: env.ADMIN_TOKEN };
|
||||
if (!result.enabled) return result;
|
||||
try {
|
||||
if (env.AGENT_PUSH_APNS_KEY_FILE && env.AGENT_PUSH_APNS_KEY_ID && env.AGENT_PUSH_APNS_TEAM_ID) {
|
||||
const key = secretFile(env.AGENT_PUSH_APNS_KEY_FILE); validateAPNsKey(key);
|
||||
result.apns = { key, keyId: env.AGENT_PUSH_APNS_KEY_ID, teamId: env.AGENT_PUSH_APNS_TEAM_ID,
|
||||
topic: env.AGENT_PUSH_APNS_TOPIC, environment: env.AGENT_PUSH_APNS_ENVIRONMENT };
|
||||
}
|
||||
} catch { /* Unavailable, fail closed. Public config exposes only the availability bit. */ }
|
||||
try {
|
||||
if (env.AGENT_PUSH_VAPID_FILE) {
|
||||
const value = JSON.parse(secretFile(env.AGENT_PUSH_VAPID_FILE));
|
||||
const curve = createECDH('prime256v1'); curve.setPrivateKey(Buffer.from(value.privateKey, 'base64url'));
|
||||
if (curve.getPublicKey().toString('base64url') !== value.publicKey || typeof value.subject !== 'string'
|
||||
|| !/^(mailto:[^\s@]+@[^\s@]+|https:\/\/[^\s]+)$/.test(value.subject)) throw new Error();
|
||||
result.web = { publicKey: value.publicKey, privateKey: value.privateKey, subject: value.subject };
|
||||
}
|
||||
} catch { /* Unavailable; never emit credential parse errors. */ }
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash, createPublicKey, ECDH } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 5_000,
|
||||
inactiveGraceMs: 5_000, unknownGraceMs: 5_000, presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
|
||||
export const MAX_AGE_MS = 5 * 60_000;
|
||||
export const MAX_ATTEMPTS = 4;
|
||||
export const ROOT_OWNER = 'root';
|
||||
export const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)))
|
||||
.transform(value => String(Number(value)));
|
||||
export const InstallationId = z.string().uuid();
|
||||
export const PresenceBody = z.object({ sequence: z.number().int().min(0).max(2_147_483_647),
|
||||
visibility: z.enum(['visible', 'hidden']), focused: z.boolean(), interacted: z.boolean() }).strict();
|
||||
export type Presence = z.infer<typeof PresenceBody>;
|
||||
const Base64url = z.string().regex(/^[A-Za-z0-9_-]+={0,2}$/);
|
||||
export const WebSubscription = z.object({ endpoint: z.string().max(2048), expirationTime: z.number().nonnegative().nullable().optional(),
|
||||
keys: z.object({ p256dh: Base64url.max(90), auth: Base64url.max(24) }).strict() }).strict();
|
||||
export const InstallationBody = z.discriminatedUnion('transport', [
|
||||
z.object({ transport: z.literal('apns'), enabled: z.literal(true), token: z.string().regex(/^[a-fA-F0-9]{32,512}$/).refine(v => v.length % 2 === 0),
|
||||
environment: z.enum(['sandbox', 'production']), topic: z.string().min(1).max(128) }).strict(),
|
||||
z.object({ transport: z.literal('web'), enabled: z.literal(true), subscription: WebSubscription }).strict(),
|
||||
]);
|
||||
export type Registration = z.infer<typeof InstallationBody>;
|
||||
export type Subscription = z.infer<typeof WebSubscription>;
|
||||
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,
|
||||
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,
|
||||
createdAt: z.string().datetime(), trigger: z.enum(['user', 'external_event', 'timer', 'worker', 'unknown']) })).max(200),
|
||||
});
|
||||
export type Context = z.infer<typeof NotificationContext>;
|
||||
export type Identity = { version: 1; sourceId: string; kind: 'message'; messageId: string; cursor: string } |
|
||||
{ version: 1; sourceId: string; kind: 'read_state'; readCursor: string };
|
||||
export const digest = (value: string) => createHash('sha256').update(value).digest('hex');
|
||||
export const ownerEpoch = (token: string) => digest(`sybil-agent-push-owner-v1\0${token}`);
|
||||
export const fail = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode });
|
||||
export function parseSafe<T>(schema: z.ZodType<T>, value: unknown): T {
|
||||
const result = schema.safeParse(value);
|
||||
// Do not reflect supplied endpoints/tokens or validation objects into logs/errors.
|
||||
if (!result.success) throw fail(400, 'Invalid Agent push request');
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/** Exact provider origins only: no arbitrary-host/redirect/private-network egress. */
|
||||
export function validatePushEndpoint(endpoint: string): URL {
|
||||
let url: URL;
|
||||
try { url = new URL(endpoint); } catch { throw fail(400, 'Invalid Web Push subscription'); }
|
||||
const allowed = url.hostname === 'fcm.googleapis.com' || url.hostname === 'updates.push.services.mozilla.com'
|
||||
|| url.hostname === 'web.push.apple.com' || /^[a-z0-9-]+\.push\.apple\.com$/.test(url.hostname);
|
||||
if (!allowed || url.protocol !== 'https:' || url.username || url.password || url.hash || (url.port && url.port !== '443')
|
||||
|| endpoint.length > 2048 || !url.pathname || url.pathname === '/') throw fail(400, 'Unsupported Web Push provider');
|
||||
return url;
|
||||
}
|
||||
export function validateSubscription(subscription: Subscription, now: number) {
|
||||
validatePushEndpoint(subscription.endpoint);
|
||||
if (subscription.expirationTime != null && subscription.expirationTime <= now) throw fail(400, 'Expired Web Push subscription');
|
||||
try {
|
||||
const key = Buffer.from(subscription.keys.p256dh, 'base64url');
|
||||
if (key.length !== 65 || key[0] !== 4 || Buffer.from(subscription.keys.auth, 'base64url').length !== 16) throw new Error();
|
||||
// Validate the curve point as well as its length before persistence.
|
||||
ECDH.convertKey(key, 'prime256v1', undefined, undefined, 'uncompressed');
|
||||
} catch { throw fail(400, 'Invalid Web Push encryption keys'); }
|
||||
}
|
||||
export function validateAPNsKey(key: string) {
|
||||
const publicKey = createPublicKey(key);
|
||||
if (publicKey.asymmetricKeyType !== 'ec' || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new Error('Invalid APNs key');
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { convert } from 'html-to-text';
|
||||
import { marked } from 'marked';
|
||||
import { z } from 'zod';
|
||||
import { Cursor, parseSafe } from './agent-push-contract.js';
|
||||
|
||||
export const MAX_PREVIEW_BYTES = 3000;
|
||||
export const APNS_PAYLOAD_BYTES = 4096;
|
||||
// RFC 8291: 4096 bytes less the 86-byte header, delimiter and 16-byte tag.
|
||||
export const WEB_PUSH_PAYLOAD_BYTES = 3993;
|
||||
const FALLBACK = 'You have a new message.';
|
||||
export type PublicMessage = { conversationId: string; messageId: string; cursor: string };
|
||||
export type MessageText = (message: PublicMessage) => Promise<string>;
|
||||
|
||||
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
|
||||
messages: z.array(z.unknown()).max(1000) });
|
||||
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
|
||||
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe() }) });
|
||||
|
||||
/** Resolve only an already eligible candidate from one immutable root public entry. */
|
||||
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
|
||||
return async candidate => {
|
||||
if (!upstream.apiKey || !Cursor.safeParse(candidate.cursor).success || Number(candidate.cursor) < 1
|
||||
|| !(candidate.messageId === `outbound:${candidate.cursor}` || new RegExp(`^entry:${candidate.cursor}:\\d+$`).test(candidate.messageId))) {
|
||||
throw new Error('Public Agent message unavailable');
|
||||
}
|
||||
const url = new URL('/api/conversations/root/messages', upstream.baseUrl);
|
||||
url.searchParams.set('after', String(Number(candidate.cursor) - 1));
|
||||
url.searchParams.set('limit', '1'); url.searchParams.set('includeTools', 'false');
|
||||
const response = await fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' },
|
||||
signal: AbortSignal.timeout(10_000), redirect: 'error' });
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error('Public Agent message unavailable'); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('Public Agent message unavailable');
|
||||
const chunks: Uint8Array[] = []; let length = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
length += value.length;
|
||||
if (length > 512 * 1024) throw new Error('Public Agent message unavailable');
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { await reader.cancel(); }
|
||||
const page = parseSafe(Page, JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
||||
if (page.conversationId !== candidate.conversationId || page.cursor !== candidate.cursor || page.beforeCursor !== candidate.cursor) {
|
||||
throw new Error('Public Agent message unavailable');
|
||||
}
|
||||
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
|
||||
const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined;
|
||||
if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable');
|
||||
// Never inspect metadata, tool results, activity, reasoning blocks or linked resources for text.
|
||||
return message.data.content;
|
||||
};
|
||||
}
|
||||
|
||||
export function previewText(message: string): string {
|
||||
const html = marked.parse(message, { async: false });
|
||||
const text = convert(html, { wordwrap: false,
|
||||
selectors: [
|
||||
{ selector: 'a', options: { ignoreHref: true } },
|
||||
{ selector: 'img', format: 'imageAlt' },
|
||||
...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map(selector => ({ selector, options: { uppercase: false } })),
|
||||
{ selector: 'hr', format: 'skip' },
|
||||
],
|
||||
formatters: { imageAlt: (element: { attribs: { alt?: string } }, _walk: unknown, builder: { addInline: (text: string) => void }) => builder.addInline(element.attribs.alt || '') },
|
||||
});
|
||||
// A string body for native OS display; normalize whitespace/control characters and malformed UTF-16.
|
||||
return Buffer.from(text).toString('utf8').replace(/[\u0000-\u001f\u007f-\u009f\s]+/gu, ' ').trim();
|
||||
}
|
||||
|
||||
/** Budget the complete serialized UTF-8 payload, including JSON escaping and identity overhead. */
|
||||
export function previewPayload(message: string | undefined, payload: (body: string) => unknown, maxBytes: number): Buffer {
|
||||
const text = (message === undefined ? '' : previewText(message)) || FALLBACK;
|
||||
const budget = Math.min(MAX_PREVIEW_BYTES, maxBytes - Buffer.byteLength(JSON.stringify(payload(''))));
|
||||
if (budget < 3) throw new Error('Agent push identity exceeds payload budget');
|
||||
let body = text;
|
||||
if (Buffer.byteLength(JSON.stringify(text)) - 2 > budget) {
|
||||
body = ''; let bytes = 0;
|
||||
for (const character of text) {
|
||||
const size = Buffer.byteLength(JSON.stringify(character)) - 2;
|
||||
if (bytes + size > budget - 3) break;
|
||||
body += character; bytes += size;
|
||||
}
|
||||
body = body.trimEnd() + '…';
|
||||
}
|
||||
const result = Buffer.from(JSON.stringify(payload(body)));
|
||||
if (result.length > maxBytes) throw new Error('Agent push exceeds payload budget');
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createHash, sign } from 'node:crypto';
|
||||
import { lookup } from 'node:dns';
|
||||
import { connect } from 'node:http2';
|
||||
import { request } from 'node:https';
|
||||
import { isIP } from 'node:net';
|
||||
import webpush from 'web-push';
|
||||
import type { PushConfig } from './agent-push-config.js';
|
||||
import { validatePushEndpoint, type Identity, type Registration } from './agent-push-contract.js';
|
||||
import { APNS_PAYLOAD_BYTES, previewPayload, WEB_PUSH_PAYLOAD_BYTES } from './agent-push-preview.js';
|
||||
|
||||
export type SendResult = { outcome: 'accepted' | 'retry' | 'invalid' | 'failed' | 'ambiguous'; retryAfterMs?: number };
|
||||
export type ProviderRequest = { endpoint: string; headers: Record<string, string | number>; body: Buffer };
|
||||
export type ProviderResponse = { status: number; body?: string; retryAfter?: string };
|
||||
export type RequestTransport = (request: ProviderRequest) => Promise<ProviderResponse>;
|
||||
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number, messageText?: string) => Promise<SendResult>;
|
||||
|
||||
function boundedRetry(value?: string): number | undefined {
|
||||
if (!value || !/^\d+$/.test(value)) return undefined;
|
||||
return Math.min(60_000, Math.max(1_000, Number(value) * 1000));
|
||||
}
|
||||
function classify(response: ProviderResponse, apns: boolean): SendResult {
|
||||
if (response.status >= 200 && response.status < 300) return { outcome: 'accepted' };
|
||||
if (response.status === 410 || (!apns && response.status === 404)) return { outcome: 'invalid' };
|
||||
if (apns && response.status === 400) {
|
||||
try { if (['BadDeviceToken', 'DeviceTokenNotForTopic', 'Unregistered'].includes(JSON.parse(response.body || '{}').reason)) return { outcome: 'invalid' }; } catch { /* Never emit provider text. */ }
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) return { outcome: 'retry', retryAfterMs: boundedRetry(response.retryAfter) };
|
||||
return { outcome: 'failed' };
|
||||
}
|
||||
|
||||
/** Fixed Apple origin, strict ten-second boundary. Unknown network outcomes are never auto-retried. */
|
||||
export const createAPNsRequest = (open: typeof connect = connect, timeoutMs = 10_000): RequestTransport => async input => new Promise((resolve, reject) => {
|
||||
const url = new URL(input.endpoint);
|
||||
const session = open(url.origin);
|
||||
let settled = false;
|
||||
const finish = (error?: Error, value?: ProviderResponse) => {
|
||||
if (settled) return; settled = true; clearTimeout(timer); session.destroy();
|
||||
error ? reject(new Error('APNs transport unavailable')) : resolve(value!);
|
||||
};
|
||||
const timer = setTimeout(() => finish(new Error()), timeoutMs);
|
||||
session.on('error', () => finish(new Error()));
|
||||
session.on('goaway', () => finish(new Error()));
|
||||
session.on('close', () => finish(new Error()));
|
||||
session.on('connect', () => {
|
||||
if (settled) return;
|
||||
try {
|
||||
const stream = session.request({ ':method': 'POST', ':path': url.pathname, ...input.headers });
|
||||
let body = '', status = 0, retryAfter: string | undefined;
|
||||
stream.on('response', headers => { status = Number(headers[':status']); retryAfter = String(headers['retry-after'] || ''); });
|
||||
stream.on('data', data => { body += data.toString(); if (body.length > 4096) finish(new Error()); });
|
||||
stream.on('error', () => finish(new Error()));
|
||||
stream.on('end', () => finish(undefined, { status, body, retryAfter }));
|
||||
stream.end(input.body);
|
||||
} catch { finish(new Error()); }
|
||||
});
|
||||
});
|
||||
|
||||
export const apnsRequest = createAPNsRequest();
|
||||
|
||||
// Only global addresses are permitted even when an allowlisted provider's DNS is compromised.
|
||||
export function isPublicAddress(address: string): boolean {
|
||||
if (isIP(address) === 4) {
|
||||
const [a,b] = address.split('.').map(Number);
|
||||
return !(a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|
||||
|| (a === 192 && [0, 168].includes(b)) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && [18,19,51].includes(b)) || (a === 203 && b === 0));
|
||||
}
|
||||
if (isIP(address) === 6) return /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address) && !/^(2002|3fff):/i.test(address);
|
||||
return false;
|
||||
}
|
||||
export const createWebRequest = (open: typeof request = request, timeoutMs = 10_000): RequestTransport => input => new Promise((resolve, reject) => {
|
||||
const url = validatePushEndpoint(input.endpoint);
|
||||
// https.request never follows redirects. The DNS result passed to the connection is checked here (no resolve/use race).
|
||||
let settled = false;
|
||||
const finish = (error?: Error, response?: ProviderResponse) => {
|
||||
if (settled) return; settled = true; clearTimeout(deadline);
|
||||
if (error) { req?.destroy(); reject(new Error('Web Push transport unavailable')); } else resolve(response!);
|
||||
};
|
||||
const deadline = setTimeout(() => finish(new Error()), timeoutMs);
|
||||
let req: ReturnType<typeof request>;
|
||||
try { req = open(url, { method: 'POST', headers: input.headers, timeout: 10_000,
|
||||
lookup: (hostname, options, callback) => {
|
||||
lookup(hostname, { all: true, verbatim: true }, (error, addresses) => {
|
||||
if (error || !addresses.length || addresses.some(value => !isPublicAddress(value.address))) {
|
||||
callback(new Error('Unsafe push provider address'), '', 4); return;
|
||||
}
|
||||
const selected = addresses.find(value => !options.family || value.family === options.family) || addresses[0];
|
||||
// Node's automatic address selection requests all results.
|
||||
if (options.all) (callback as unknown as (error: Error | null, results: { address: string; family: number }[]) => void)(null, addresses);
|
||||
else callback(null, selected.address, selected.family);
|
||||
});
|
||||
},
|
||||
}, res => {
|
||||
res.resume(); // Provider bodies can contain endpoint/token values; never retain or log them.
|
||||
res.on('end', () => finish(undefined, { status: res.statusCode || 0, retryAfter: String(res.headers['retry-after'] || '') }));
|
||||
res.on('error', () => finish(new Error()));
|
||||
res.on('aborted', () => finish(new Error()));
|
||||
});
|
||||
req.on('timeout', () => req.destroy(new Error('Web Push timeout')));
|
||||
req.on('error', () => finish(new Error()));
|
||||
req.on('close', () => { if (!settled) finish(new Error()); });
|
||||
req.end(input.body);
|
||||
} catch { finish(new Error()); }
|
||||
});
|
||||
export const webRequest = createWebRequest();
|
||||
|
||||
export function createPushSender(config: PushConfig, adapters: { apns?: RequestTransport; web?: RequestTransport } = {}): PushSend {
|
||||
let jwt: { value: string; issuedAt: number } | undefined;
|
||||
return async (registration, identity, expiresAt, now, messageText) => {
|
||||
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
|
||||
try {
|
||||
if (registration.transport === 'apns') {
|
||||
const credential = config.apns;
|
||||
if (!credential || credential.topic !== registration.topic || credential.environment !== registration.environment) return { outcome: 'failed' };
|
||||
if (!jwt || now - jwt.issuedAt >= 50 * 60_000 || now < jwt.issuedAt) {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: credential.keyId })).toString('base64url');
|
||||
const claims = Buffer.from(JSON.stringify({ iss: credential.teamId, iat: Math.floor(now / 1000) })).toString('base64url');
|
||||
const value = `${header}.${claims}`;
|
||||
jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now };
|
||||
}
|
||||
const clear = identity.kind === 'read_state';
|
||||
const body = clear ? Buffer.from(JSON.stringify({ aps: { 'content-available': 1 }, ...identity }))
|
||||
: previewPayload(messageText, body => ({ aps: { alert: { title: 'Sybil Agent', body }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }), APNS_PAYLOAD_BYTES);
|
||||
const response = await (adapters.apns || apnsRequest)({
|
||||
endpoint: `https://${registration.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'}/3/device/${registration.token}`,
|
||||
headers: { authorization: `bearer ${jwt.value}`, 'apns-topic': credential.topic, 'apns-push-type': clear ? 'background' : 'alert',
|
||||
'apns-priority': clear ? '5' : '10', 'apns-expiration': String(Math.floor(Math.min(expiresAt, now + 60_000) / 1000)),
|
||||
'apns-collapse-id': `sybil-${clear ? 'read' : 'message'}-${createHash('sha256').update(identity.sourceId).digest('hex').slice(0,24)}`,
|
||||
'content-type': 'application/json' }, body,
|
||||
});
|
||||
return classify(response, true);
|
||||
}
|
||||
// Safari requires visible pushes; there is deliberately no Web Push read-clear transport.
|
||||
if (!config.web || identity.kind !== 'message') return { outcome: 'failed' };
|
||||
validatePushEndpoint(registration.subscription.endpoint);
|
||||
const payload = previewPayload(messageText, body => ({ ...identity, title: 'Sybil Agent', body, url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` }), WEB_PUSH_PAYLOAD_BYTES);
|
||||
const details = webpush.generateRequestDetails(registration.subscription, payload, { vapidDetails: config.web,
|
||||
contentEncoding: 'aes128gcm', TTL: Math.max(1, Math.min(60, Math.floor((expiresAt - now) / 1000))), urgency: 'normal',
|
||||
topic: createHash('sha256').update(identity.sourceId).digest('base64url').slice(0,32) });
|
||||
if (!details.body || details.body.length > 4096) return { outcome: 'failed' };
|
||||
const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! });
|
||||
return classify(response, false);
|
||||
} catch {
|
||||
// Encryption/config and I/O errors are intentionally indistinguishable externally. Never replay an uncertain send.
|
||||
return { outcome: 'ambiguous' };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Prisma, PrismaClient } from '@prisma/client';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { env } from './env.js';
|
||||
import type { PushConfig } from './agent-push-config.js';
|
||||
import { loadPushConfig } from './agent-push-config.js';
|
||||
import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, NotificationContext, PresenceBody, PUSH_POLICY,
|
||||
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 { createMessageText, type MessageText } from './agent-push-preview.js';
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
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;
|
||||
broadcast?: (state: ReadState) => void; warmupMs?: number };
|
||||
const date = (ms: number) => new Date(ms);
|
||||
const activeStatuses = ['pending', 'intent'];
|
||||
type Suppression = { reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
|
||||
|
||||
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
|
||||
export class AgentPushCoordinator {
|
||||
readonly instanceId = randomUUID();
|
||||
readonly epoch: string;
|
||||
private readonly now: () => number;
|
||||
private readonly warmUntil: number;
|
||||
private started = false;
|
||||
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
|
||||
private running = false;
|
||||
private timer?: ReturnType<typeof setInterval>;
|
||||
private stopped = false;
|
||||
private observedRoot?: { sourceId: string; conversationId: string };
|
||||
constructor(readonly options: Options) {
|
||||
this.epoch = ownerEpoch(options.config.adminToken || 'disabled');
|
||||
this.now = options.now || Date.now;
|
||||
this.warmUntil = this.now() + (options.warmupMs ?? PUSH_POLICY.presenceLeaseMs);
|
||||
}
|
||||
async initialize() {
|
||||
if (this.started) return;
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const current = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||
if (!current) await tx.agentPushCoordinator.create({ data: { id: 'root', ownerEpoch: this.epoch } });
|
||||
else if (current.ownerEpoch !== this.epoch) {
|
||||
// Credentials rotate ownership generation; old endpoint opt-ins do not cross it.
|
||||
await tx.agentPushInstallation.updateMany({ where: { enabled: true }, data: { enabled: false } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'owner_changed' } });
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { ownerEpoch: this.epoch, contextObservedAt: null,
|
||||
nativeUnknown: true, unknownSince: null, uncertain: true, lockOwner: null, lockUntil: null } });
|
||||
}
|
||||
});
|
||||
this.started = true;
|
||||
}
|
||||
start() {
|
||||
if (this.timer || !this.options.config.adminToken) return;
|
||||
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
|
||||
void this.tick();
|
||||
}
|
||||
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
|
||||
async config() {
|
||||
await this.initialize();
|
||||
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||
const config = this.options.config;
|
||||
return { version: 1, enabled: config.enabled, apnsAvailable: Boolean(config.enabled && config.apns),
|
||||
webPushAvailable: Boolean(config.enabled && config.web), ...(config.enabled && config.web ? { vapidPublicKey: config.web.publicKey } : {}),
|
||||
sourceId: state?.sourceId || null, quarantined: state?.quarantined || false, policy: PUSH_POLICY };
|
||||
}
|
||||
async register(id: string, value: Registration) {
|
||||
await this.initialize();
|
||||
const config = this.options.config;
|
||||
if (!config.enabled) throw fail(503, 'Agent push is disabled');
|
||||
if (value.transport === 'apns') {
|
||||
if (!config.apns || config.apns.topic !== value.topic || config.apns.environment !== value.environment) throw fail(400, 'APNs topic or environment unavailable');
|
||||
value = { ...value, token: value.token.toLowerCase() };
|
||||
} 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);
|
||||
return this.options.db.$transaction(async tx => {
|
||||
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?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
|
||||
if (await tx.agentPushInstallation.count({ where: { ownerEpoch: this.epoch, enabled: true } }) >= 64 && !old?.enabled) throw fail(429, 'Too many push installations');
|
||||
// A rotated/recreated local installation cannot retain another active delivery to the same endpoint.
|
||||
const duplicates = await tx.agentPushInstallation.findMany({ where: { fingerprint, revokedAt: null, id: { not: id } }, select: { id: true } });
|
||||
if (duplicates.length) {
|
||||
await tx.agentPushInstallation.updateMany({ where: { id: { in: duplicates.map(v => v.id) } }, data: { enabled: false, revokedAt: date(this.now()), payload: '', fingerprint: '' } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: { in: duplicates.map(v => v.id) }, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'endpoint_rebound' } });
|
||||
}
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'registration_changed' } });
|
||||
const record = await tx.agentPushInstallation.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true },
|
||||
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 };
|
||||
});
|
||||
}
|
||||
async revoke(id: string) {
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
|
||||
if (!old?.revokedAt) await tx.agentPushInstallation.upsert({ where: { id },
|
||||
create: { id, ownerEpoch: this.epoch, transport: 'revoked', payload: '', fingerprint: '', enabled: false, revokedAt: date(this.now()) },
|
||||
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' } });
|
||||
});
|
||||
}
|
||||
async presence(id: string, value: Presence) {
|
||||
const now = this.now();
|
||||
return this.options.db.$transaction(async tx => {
|
||||
const old = await tx.agentPushPresence.findUnique({ where: { id } });
|
||||
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');
|
||||
const previous = old?.ownerEpoch === this.epoch ? old : null;
|
||||
const visible = value.visibility === 'visible', focused = visible && value.focused;
|
||||
const data = { ownerEpoch: this.epoch, sequence: value.sequence, visible, focused,
|
||||
lastInteractionAt: value.interacted && visible ? date(now) : previous?.lastInteractionAt || null,
|
||||
// Only a transition out of proven focus starts grace. Inactive heartbeats cannot renew it.
|
||||
inactiveUntil: focused ? null : previous?.visible && previous.focused && previous.expiresAt.getTime() > now ? date(now + PUSH_POLICY.inactiveGraceMs) : previous?.inactiveUntil || null,
|
||||
expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
|
||||
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
|
||||
return { expiresAt: row.expiresAt.toISOString() };
|
||||
});
|
||||
}
|
||||
async retirePresence(id: string) {
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const old = await tx.agentPushPresence.findUnique({ where: { id } });
|
||||
if (old?.ownerEpoch !== this.epoch) return;
|
||||
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 } });
|
||||
});
|
||||
}
|
||||
async connect(id: string, kind?: string, sessionId?: string) {
|
||||
const now = this.now();
|
||||
if (kind !== 'native' && !this.untrackedConnections.has(id)) this.untrackedConnections.set(id, { at: now, kind: kind === 'web' ? 'web' : 'unknown' });
|
||||
await this.initialize();
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const connection = await tx.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
|
||||
expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) } });
|
||||
const observed = connection.kind === 'web' && connection.sessionId
|
||||
? await tx.agentPushPresence.findFirst({ where: { id: connection.sessionId, ownerEpoch: this.epoch } }) : null;
|
||||
if (connection.kind !== 'native' && !observed) {
|
||||
// Raw clients have no stable identity. One persisted introduction window per owner
|
||||
// prevents reconnects (including invented UUIDs) from manufacturing attention.
|
||||
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(now) } });
|
||||
}
|
||||
});
|
||||
this.untrackedConnections.delete(id);
|
||||
}
|
||||
async heartbeat(id: string) {
|
||||
// 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) } });
|
||||
}
|
||||
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
|
||||
async notePrompt() {
|
||||
if (!this.options.config.adminToken) return;
|
||||
await this.initialize();
|
||||
// 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()) } });
|
||||
}
|
||||
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
|
||||
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: row?.cursor || '0', updatedAt: row?.updatedAt.toISOString() || null, sourceId: row?.sourceId || null };
|
||||
}
|
||||
private async mergeRead(tx: Tx, cursor: string, sourceId: string | null): Promise<ReadState | undefined> {
|
||||
const old = await tx.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
if (old?.sourceId && sourceId && old.sourceId !== sourceId) return;
|
||||
if (Number(cursor) <= Number(old?.cursor || 0)) return;
|
||||
await tx.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt", "sourceId") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP, ${sourceId})
|
||||
ON CONFLICT("id") DO UPDATE SET "cursor" = excluded."cursor", "updatedAt" = CURRENT_TIMESTAMP,
|
||||
"sourceId" = COALESCE("AgentReadState"."sourceId", excluded."sourceId")
|
||||
WHERE CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER)`;
|
||||
if (sourceId) {
|
||||
await tx.$executeRaw`UPDATE "AgentPushDelivery" SET "status" = 'cancelled', "outcome" = 'read', "updatedAt" = CURRENT_TIMESTAMP
|
||||
WHERE "ownerEpoch" = ${this.epoch} AND "sourceId" = ${sourceId} AND "kind" = 'message' AND "status" = 'pending' AND CAST("cursor" AS INTEGER) <= ${Number(cursor)}`;
|
||||
if (this.options.config.enabled && this.options.config.apns) {
|
||||
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true, transport: 'apns' } });
|
||||
for (const installation of installations) {
|
||||
// Do not send background clears to installations that never received an alert.
|
||||
const delivered = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId, kind: 'message', status: { in: ['accepted', 'intent', 'ambiguous'] } } });
|
||||
if (!delivered) continue;
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, kind: 'read_state', status: 'pending' }, data: { status: 'cancelled', outcome: 'superseded' } });
|
||||
const id = digest(`clear:${installation.id}:${installation.version}:${sourceId}:${cursor}`);
|
||||
const now = this.now();
|
||||
await tx.agentPushDelivery.upsert({ where: { id }, update: {}, create: { id, ownerEpoch: this.epoch, installationId: installation.id,
|
||||
installationVersion: installation.version, sourceId, cursor, messageId: `read:${cursor}`, kind: 'read_state', trigger: 'read',
|
||||
dueAt: date(Math.max(now, (installation.lastClearAt?.getTime() || 0) + 60_000)), expiresAt: date(now + MAX_AGE_MS) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.readState(tx);
|
||||
}
|
||||
async advanceRead(cursor: string, suppliedSource?: string): Promise<ReadState> {
|
||||
await this.initialize();
|
||||
const result = await this.options.db.$transaction(async tx => {
|
||||
const coordinator = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
|
||||
if (suppliedSource && coordinator?.sourceId !== suppliedSource) throw fail(409, 'Agent source changed; refresh before acknowledging');
|
||||
// Quarantine never relabels old read positions as a different runtime.
|
||||
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
|
||||
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
|
||||
});
|
||||
if (result.changed) this.options.broadcast?.(result.state);
|
||||
return result.state;
|
||||
}
|
||||
private async acquire(): Promise<boolean> {
|
||||
const now = this.now();
|
||||
const result = await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch,
|
||||
OR: [{ lockOwner: this.instanceId }, { lockUntil: null }, { lockUntil: { lte: date(now) } }] },
|
||||
data: { lockOwner: this.instanceId, lockUntil: date(now + 30_000) } });
|
||||
return result.count === 1;
|
||||
}
|
||||
private async observe(): Promise<boolean> {
|
||||
const state = await this.options.db.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
const context = parseSafe(NotificationContext, await this.options.context(state.sourceId ? state.cursor : undefined, state.sourceId || undefined));
|
||||
const now = this.now(), observedAt = Date.parse(context.webPresence.observedAt);
|
||||
if ((!state.sourceId || (state.sourceId === context.sourceId && Number(context.cursor) >= Number(state.cursor))) && (observedAt > now + 5_000 || observedAt < now - 10_000 || (state.contextObservedAt && observedAt < state.contextObservedAt.getTime())
|
||||
|| (context.latestUserMessageAt && Date.parse(context.latestUserMessageAt) > now + 5_000)
|
||||
|| (state.latestUserMessageAt && (!context.latestUserMessageAt || Date.parse(context.latestUserMessageAt) < state.latestUserMessageAt.getTime())))) throw new Error('Uncertain runtime clock');
|
||||
const read = await this.options.db.$transaction(async tx => {
|
||||
const current = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
if (current.lockOwner !== this.instanceId || current.ownerEpoch !== this.epoch) throw new Error('Lost coordinator lease');
|
||||
if ((current.sourceId && current.sourceId !== context.sourceId) || (current.sourceId && Number(context.cursor) < Number(current.cursor))) {
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { quarantined: true, uncertain: true } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'cancelled', outcome: 'source_changed' } });
|
||||
return undefined;
|
||||
}
|
||||
if (current.quarantined) return undefined;
|
||||
const initial = !current.sourceId;
|
||||
const previousCursor = Number(current.cursor);
|
||||
let candidateCursor = previousCursor;
|
||||
for (const candidate of context.candidates) {
|
||||
if (Number(candidate.cursor) < candidateCursor || Number(candidate.cursor) > Number(context.cursor)) throw new Error('Invalid source order');
|
||||
candidateCursor = Number(candidate.cursor);
|
||||
}
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { sourceId: context.sourceId, cursor: context.cursor,
|
||||
latestUserMessageAt: context.latestUserMessageAt ? date(Date.parse(context.latestUserMessageAt)) : null,
|
||||
nativeActive: context.webPresence.active, nativeUnknown: context.webPresence.unknown, contextObservedAt: date(observedAt), uncertain: context.uncertain,
|
||||
lockUntil: date(now + 30_000) } });
|
||||
await tx.agentReadState.upsert({ where: { id: 'root' }, create: { id: 'root', cursor: '0', sourceId: context.sourceId }, update: {} });
|
||||
await tx.agentReadState.updateMany({ where: { id: 'root', sourceId: null }, data: { sourceId: context.sourceId } });
|
||||
const changedRead = await this.mergeRead(tx, context.nativeReadCursor, context.sourceId);
|
||||
const readState = await this.readState(tx);
|
||||
if (!initial && this.options.config.enabled && !context.uncertain) {
|
||||
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true } });
|
||||
for (const candidate of context.candidates) {
|
||||
const createdAt = Date.parse(candidate.createdAt);
|
||||
if (Number(candidate.cursor) <= previousCursor || Number(candidate.cursor) <= Number(readState.cursor) || candidate.trigger === 'unknown'
|
||||
|| createdAt > now + 5_000 || createdAt + MAX_AGE_MS <= now || (candidate.trigger === 'user' && !context.latestUserMessageAt)) continue;
|
||||
for (const installation of installations) {
|
||||
const id = digest(`${this.epoch}:${installation.id}:${installation.version}:${context.sourceId}:${candidate.messageId}`);
|
||||
if (await tx.agentPushDelivery.findUnique({ where: { id } })) continue;
|
||||
const older = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, orderBy: { createdAt: 'asc' } });
|
||||
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, data: { status: 'cancelled', outcome: 'coalesced' } });
|
||||
const burstStart = older?.createdAt.getTime() || now;
|
||||
await tx.agentPushDelivery.create({ data: { id, ownerEpoch: this.epoch, installationId: installation.id, installationVersion: installation.version,
|
||||
sourceId: context.sourceId, messageId: candidate.messageId, cursor: candidate.cursor, kind: 'message', trigger: candidate.trigger,
|
||||
priority: Math.max(older?.priority || 0, ['timer', 'external_event'].includes(candidate.trigger) ||
|
||||
(context.latestUserMessageAt && createdAt - Date.parse(context.latestUserMessageAt) >= 30_000) ? 2 : 1),
|
||||
createdAt: date(burstStart), dueAt: date(Math.max(Math.min(now + PUSH_POLICY.debounceMs, burstStart + 15_000),
|
||||
context.latestUserMessageAt ? Date.parse(context.latestUserMessageAt) + PUSH_POLICY.promptGraceMs : 0)), expiresAt: date(createdAt + MAX_AGE_MS) } });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep the durable active outbox plus at most 10,000 completed receipts. The source checkpoint,
|
||||
// not receipt retention, prevents historical replay after cleanup.
|
||||
await tx.$executeRaw`DELETE FROM "AgentPushDelivery" WHERE "id" IN (
|
||||
SELECT "id" FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent') ORDER BY "updatedAt" ASC
|
||||
LIMIT MAX(0, (SELECT COUNT(*) FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent')) - 10000))`;
|
||||
return changedRead;
|
||||
});
|
||||
if (read) this.options.broadcast?.(read);
|
||||
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
|
||||
return context.hasMore;
|
||||
}
|
||||
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
|
||||
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
|
||||
const presences = observations.filter(v => v.expiresAt.getTime() > now);
|
||||
if (presences.some(v => v.visible && v.focused)) return { reason: 'foreground_focused', clientKind: 'web' };
|
||||
if (presences.some(v => v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now)) return { reason: 'recent_interaction', clientKind: 'web' };
|
||||
if (presences.some(v => v.inactiveUntil && v.inactiveUntil.getTime() > now)) return { reason: 'inactive_grace', clientKind: 'web' };
|
||||
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)));
|
||||
if (!unobserved.length && !this.untrackedConnections.size) return;
|
||||
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) } });
|
||||
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
|
||||
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
|
||||
}
|
||||
private async claim(id: string, commit: boolean) {
|
||||
return this.options.db.$transaction(async tx => {
|
||||
const now = this.now();
|
||||
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
|
||||
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
|
||||
const suppress = async ({ reason, clientKind }: Suppression) => {
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs),
|
||||
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
|
||||
};
|
||||
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
|
||||
const read = await this.readState(tx);
|
||||
if (!installation?.enabled || installation.ownerEpoch !== this.epoch || installation.version !== row.installationVersion || row.expiresAt.getTime() <= now
|
||||
|| row.ownerEpoch !== this.epoch || row.sourceId !== state.sourceId || state.quarantined || (row.kind === 'message' && Number(row.cursor) <= Number(read.cursor))) {
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'obsolete' } }); return;
|
||||
}
|
||||
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) {
|
||||
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system' }); return;
|
||||
}
|
||||
if (row.kind === 'message') {
|
||||
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web' }
|
||||
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown' } : await this.browserSuppression(tx, now);
|
||||
if (attention) { await suppress(attention); return; }
|
||||
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|
||||
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
|
||||
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; }
|
||||
const registration = parseSafe(InstallationBody, JSON.parse(installation.payload));
|
||||
if (registration.transport === 'web' && registration.subscription.expirationTime != null && registration.subscription.expirationTime <= now) {
|
||||
await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { enabled: false } });
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'expired_subscription' } }); return;
|
||||
}
|
||||
const identity: Identity = row.kind === 'read_state' ? { version: 1, sourceId: row.sourceId, kind: 'read_state', readCursor: read.cursor }
|
||||
: { version: 1, sourceId: row.sourceId, kind: 'message', messageId: row.messageId, cursor: row.cursor };
|
||||
if (!commit) return { row, registration, identity, now };
|
||||
// Durable crossing intent is committed BEFORE invoking an external adapter. A crash here is ambiguous, never replayed.
|
||||
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'intent', attempts: { increment: 1 }, outcome: null } });
|
||||
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { lockUntil: date(now + 30_000) } });
|
||||
if (row.kind === 'read_state') await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { lastClearAt: date(now) } });
|
||||
return { row, registration, identity, now };
|
||||
});
|
||||
}
|
||||
private async dispatch(id: string) {
|
||||
// Only resolve text for an otherwise dispatchable candidate, outside the DB transaction.
|
||||
// Recheck source/read/presence and every claim condition after the bounded public lookup.
|
||||
const ready = await this.claim(id, false);
|
||||
if (!ready) return;
|
||||
let messageText: string | undefined;
|
||||
if (ready.identity.kind === 'message') {
|
||||
if (this.observedRoot?.sourceId !== ready.identity.sourceId) throw new Error('Public Agent source unavailable');
|
||||
messageText = await this.options.messageText({ ...ready.identity, conversationId: this.observedRoot.conversationId });
|
||||
if (await this.observe()) return;
|
||||
}
|
||||
const claim = await this.claim(id, true);
|
||||
if (!claim) return;
|
||||
let result;
|
||||
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now, messageText); }
|
||||
catch { result = { outcome: 'ambiguous' as const }; }
|
||||
await this.options.db.$transaction(async tx => {
|
||||
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,
|
||||
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 } });
|
||||
});
|
||||
}
|
||||
async tick() {
|
||||
if (this.running || this.stopped || !this.options.config.adminToken) return;
|
||||
this.running = true;
|
||||
try {
|
||||
await this.initialize(); if (!await this.acquire()) return;
|
||||
// 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' } });
|
||||
let more = true;
|
||||
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
|
||||
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
|
||||
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) {
|
||||
if (this.stopped || !await this.acquire()) break;
|
||||
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
|
||||
if (await this.observe()) break;
|
||||
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.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.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
|
||||
} catch {
|
||||
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
|
||||
try {
|
||||
await this.options.db.$transaction(async tx => {
|
||||
const held = await tx.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } });
|
||||
if (held.count) await tx.agentPushDelivery.updateMany({ where: { ownerEpoch: this.epoch, status: 'pending' }, data: {
|
||||
lastSuppressionReason: 'source_or_coordinator_unavailable', lastSuppressionClientKind: 'system', lastSuppressedAt: date(this.now()) } });
|
||||
});
|
||||
} catch { /* Retry on next loop. */ }
|
||||
} finally {
|
||||
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient, upstream: { baseUrl: string; apiKey?: string }, broadcast: (state: ReadState) => void,
|
||||
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
|
||||
const config = overrides?.config || loadPushConfig();
|
||||
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
|
||||
context: async (after, sourceId) => {
|
||||
if (!upstream.apiKey) throw new Error('Runtime unavailable');
|
||||
// A replaced/restored runtime can reject the old cursor before returning its identity.
|
||||
// Share one deadline across the incremental request and its single high-water probe.
|
||||
const signal = AbortSignal.timeout(10_000);
|
||||
const request = async (cursor?: string) => {
|
||||
const url = new URL('/api/notification-context', upstream.baseUrl);
|
||||
if (cursor !== undefined) url.searchParams.set('after', cursor); url.searchParams.set('limit', '200');
|
||||
return fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' }, signal, redirect: 'error' });
|
||||
};
|
||||
let response = await request(after);
|
||||
const probe = response.status === 409 && after !== undefined;
|
||||
if (probe) { await response.body?.cancel(); response = await request(); }
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error('Runtime unavailable'); }
|
||||
const reader = response.body?.getReader(); if (!reader) throw new Error('Invalid runtime response');
|
||||
const chunks: Uint8Array[] = []; let length = 0;
|
||||
try { for (;;) { const { done, value } = await reader.read(); if (done) break; length += value.length;
|
||||
if (length > 512 * 1024) throw new Error('Invalid runtime response'); chunks.push(value); }
|
||||
} finally { await reader.cancel(); }
|
||||
const context = parseSafe(NotificationContext, JSON.parse(Buffer.concat(chunks).toString()));
|
||||
// This probe may only establish quarantine. Never use it to skip a backlog, advance
|
||||
// the checkpoint, merge new reads, or replay historical candidates after a generic409.
|
||||
if (probe && (!sourceId || context.hasMore || context.candidates.length > 0
|
||||
|| (context.sourceId === sourceId && Number(context.cursor) >= Number(after)))) throw new Error('Runtime cursor unavailable');
|
||||
return context;
|
||||
}, messageText: createMessageText(upstream), send: createPushSender(config), ...overrides });
|
||||
const auth = (req: FastifyRequest) => { if (!env.ADMIN_TOKEN) throw fail(503, 'Agent push requires authenticated access'); requireAdmin(req); };
|
||||
const safe = <T>(fn: () => Promise<T>): Promise<T> => fn().catch(error => { if (error?.statusCode) throw error; throw fail(503, 'Agent push is unavailable'); });
|
||||
const routeOptions = { bodyLimit: 16 * 1024 };
|
||||
app.get('/v1/agent/push/config', async (req, reply) => { reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.config()); });
|
||||
app.put<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', routeOptions, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req);
|
||||
const id = parseSafe(InstallationId, req.params.installationId), value = parseSafe(InstallationBody, req.body);
|
||||
return safe(() => coordinator.register(id, value));
|
||||
});
|
||||
app.delete<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.revoke(parseSafe(InstallationId, req.params.installationId))); return reply.code(204).send();
|
||||
});
|
||||
app.put<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', routeOptions, async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.presence(parseSafe(InstallationId, req.params.sessionId), parseSafe(PresenceBody, req.body)));
|
||||
});
|
||||
app.delete<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.retirePresence(parseSafe(InstallationId, req.params.sessionId))); return reply.code(204).send();
|
||||
});
|
||||
app.addHook('onReady', async () => { if (config.adminToken && upstream.apiKey) coordinator.start(); });
|
||||
app.addHook('onClose', async () => coordinator.stop());
|
||||
return coordinator;
|
||||
}
|
||||
+23
-22
@@ -1,3 +1,5 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { registerPushRoutes, type AgentPushCoordinator, type ReadState } from './agent-push.js';
|
||||
import type { IncomingMessage } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
@@ -14,8 +16,8 @@ const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional()
|
||||
.refine(value => !(value.after && value.before), 'Use after or before, not both');
|
||||
const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
|
||||
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
|
||||
const ReadBody = z.object({ cursor: Cursor });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
|
||||
const ReadBody = z.object({ cursor: Cursor, sourceId: z.string().uuid().optional() });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional(), clientKind: z.enum(['web', 'native']).optional(), presenceSessionId: z.string().uuid().optional() });
|
||||
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
|
||||
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
|
||||
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
|
||||
@@ -27,22 +29,19 @@ const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
||||
return result.data;
|
||||
};
|
||||
|
||||
async function readState() {
|
||||
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
|
||||
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
|
||||
}
|
||||
|
||||
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
|
||||
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
|
||||
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
|
||||
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
|
||||
const readListeners = new Set<(state: ReadState) => void>();
|
||||
const push = await registerPushRoutes(app, prisma, { baseUrl, apiKey }, state => { for (const send of readListeners) send(state); });
|
||||
// An open development proxy must not expose a token-protected attachment runtime's
|
||||
// history/model context using its server-side credential.
|
||||
const guardOpenProxy = async (signal?: AbortSignal) => {
|
||||
if (env.ADMIN_TOKEN) return;
|
||||
try {
|
||||
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
|
||||
headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
||||
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
||||
});
|
||||
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
|
||||
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
|
||||
@@ -54,7 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, baseUrl), { method,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
||||
});
|
||||
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
|
||||
@@ -88,6 +87,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const body = parse(MessageBody, req.body);
|
||||
await push.notePrompt();
|
||||
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
|
||||
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
@@ -103,26 +103,20 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
app.get('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
return readState();
|
||||
return push.readState();
|
||||
});
|
||||
app.put('/v1/agent/read-state', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
requireAdmin(req);
|
||||
const { cursor } = parse(ReadBody, req.body);
|
||||
// One SQLite statement makes concurrent tabs monotonic. Never write an older
|
||||
// cursor over one saved by another connection between a read and an update.
|
||||
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT("id") DO UPDATE SET
|
||||
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
|
||||
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
|
||||
return readState();
|
||||
const { cursor, sourceId } = parse(ReadBody, req.body);
|
||||
return push.advanceRead(cursor, sourceId);
|
||||
});
|
||||
|
||||
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
|
||||
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy);
|
||||
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy, push, readListeners);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>) {
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>, push: AgentPushCoordinator, readListeners: Set<(state: ReadState) => void>) {
|
||||
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
|
||||
const cleanup = new Set<() => void>();
|
||||
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
||||
@@ -134,6 +128,8 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
|
||||
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
|
||||
sockets.handleUpgrade(request, socket, head, browser => {
|
||||
let upstream: WebSocket | undefined;
|
||||
const connectionId = randomUUID();
|
||||
const sendRead = (state: ReadState) => send({ type: 'read_state', ...state });
|
||||
let authenticated = false, authenticating = false, disposed = false, alive = true;
|
||||
const send = (value: unknown) => {
|
||||
if (browser.readyState !== WebSocket.OPEN) return;
|
||||
@@ -142,10 +138,12 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
|
||||
};
|
||||
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
|
||||
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
|
||||
browser.on('pong', () => { alive = true; });
|
||||
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.heartbeat(connectionId).catch(() => {}); });
|
||||
const dispose = () => {
|
||||
if (disposed) return;
|
||||
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
|
||||
readListeners.delete(sendRead);
|
||||
void push.disconnect(connectionId).catch(() => {});
|
||||
upstream?.terminate(); browser.terminate();
|
||||
};
|
||||
cleanup.add(dispose);
|
||||
@@ -163,8 +161,11 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
|
||||
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
|
||||
if (disposed || browser.readyState !== WebSocket.OPEN) return;
|
||||
authenticated = true; clearTimeout(authTimeout);
|
||||
readListeners.add(sendRead);
|
||||
if (env.ADMIN_TOKEN) await push.connect(connectionId, auth.clientKind, auth.presenceSessionId).catch(() => {});
|
||||
if (disposed) { void push.disconnect(connectionId).catch(() => {}); return; }
|
||||
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream = new WebSocket(target, { headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
|
||||
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
|
||||
upstream.on('message', (data, binary) => {
|
||||
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
|
||||
|
||||
+2
-2
@@ -8,9 +8,9 @@ export const prisma = new PrismaClient({
|
||||
});
|
||||
|
||||
prisma.$on("error", (e: any) => {
|
||||
console.error("[prisma:error]", e);
|
||||
console.error("[prisma:error] Database operation failed");
|
||||
});
|
||||
|
||||
prisma.$on("warn", (e: any) => {
|
||||
console.warn("[prisma:warn]", e);
|
||||
console.warn("[prisma:warn] Database warning");
|
||||
});
|
||||
|
||||
@@ -72,6 +72,14 @@ const EnvSchema = z.object({
|
||||
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
|
||||
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
|
||||
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
|
||||
// Off until a deliberate server opt-in plus per-installation user consent.
|
||||
AGENT_PUSH_ENABLED: BooleanFlagSchema,
|
||||
AGENT_PUSH_APNS_KEY_FILE: OptionalTrimmedStringSchema,
|
||||
AGENT_PUSH_APNS_KEY_ID: z.string().regex(/^[A-Z0-9]{10}$/).optional(),
|
||||
AGENT_PUSH_APNS_TEAM_ID: z.string().regex(/^[A-Z0-9]{10}$/).optional(),
|
||||
AGENT_PUSH_APNS_TOPIC: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/).default("net.buzzert.sybil2"),
|
||||
AGENT_PUSH_APNS_ENVIRONMENT: z.enum(["sandbox", "production"]).default("production"),
|
||||
AGENT_PUSH_VAPID_FILE: OptionalTrimmedStringSchema,
|
||||
EXA_API_KEY: z.string().optional(),
|
||||
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
|
||||
|
||||
|
||||
+10
-5
@@ -9,6 +9,9 @@ import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-cata
|
||||
import { registerAgentRoutes } from "./agent.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
|
||||
// Push URL parameters are identifiers, never credentials. Redact even malformed caller URLs.
|
||||
const logUrl = (url: string) => url.startsWith('/v1/agent/push/') ? '/v1/agent/push/[redacted]'
|
||||
: url.startsWith('/v1/agent/presence/') ? '/v1/agent/presence/[redacted]' : url;
|
||||
const app = Fastify({
|
||||
bodyLimit: 32 * 1024 * 1024,
|
||||
disableRequestLogging: true,
|
||||
@@ -44,7 +47,7 @@ await app.register(sensible);
|
||||
|
||||
app.addHook("onRequest", async (req) => {
|
||||
if (req.url.startsWith("/health")) return;
|
||||
req.log.info({ method: req.method, url: req.url }, "request received");
|
||||
req.log.info({ method: req.method, url: logUrl(req.url) }, "request received");
|
||||
});
|
||||
|
||||
app.addHook("onResponse", async (req, reply) => {
|
||||
@@ -53,7 +56,7 @@ app.addHook("onResponse", async (req, reply) => {
|
||||
req.log.info(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
url: logUrl(req.url),
|
||||
statusCode: reply.statusCode,
|
||||
responseTimeMs,
|
||||
},
|
||||
@@ -68,16 +71,18 @@ app.setErrorHandler((err, req, reply) => {
|
||||
req.log.error(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
url: logUrl(req.url),
|
||||
statusCode,
|
||||
err: e,
|
||||
err: req.url.startsWith('/v1/agent/push/') || req.url.startsWith('/v1/agent/presence/') ? { message: 'Agent push request failed' } : e,
|
||||
},
|
||||
"request failed"
|
||||
);
|
||||
}
|
||||
reply.status(statusCode).send({
|
||||
error: true,
|
||||
message: e.message ?? String(e),
|
||||
message: (req.url.startsWith('/v1/agent/push/') || req.url.startsWith('/v1/agent/presence/'))
|
||||
? (statusCode === 400 ? 'Invalid Agent push request' : statusCode >= 500 ? 'Agent push is unavailable' : e.message || 'Agent push request failed')
|
||||
: e.message ?? String(e),
|
||||
statusCode,
|
||||
...(typeof e.code === 'string' ? { code: e.code } : {}),
|
||||
});
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { once } from 'node:events';
|
||||
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
|
||||
import { Readable } from 'node:stream';
|
||||
import test from 'node:test';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
process.env.AGENT_PUSH_ENABLED = 'false';
|
||||
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
|
||||
process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite';
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-attachment-proxy-'));
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'test.db')}`;
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
|
||||
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
|
||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
|
||||
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
|
||||
@@ -28,7 +37,7 @@ const source=createServer(async(req,res)=>{
|
||||
source.listen(0,'127.0.0.1');await once(source,'listening');
|
||||
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
|
||||
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
|
||||
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));});
|
||||
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));const {prisma}=await import('../src/db.js');await prisma.$disconnect();rmSync(directory,{recursive:true,force:true});});
|
||||
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
|
||||
|
||||
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createECDH, generateKeyPairSync, randomUUID } from 'node:crypto';
|
||||
import { once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import { createRequire } from 'node:module';
|
||||
import test from 'node:test';
|
||||
import webpush from 'web-push';
|
||||
import { createMessageText, MAX_PREVIEW_BYTES, previewPayload, previewText, WEB_PUSH_PAYLOAD_BYTES } from '../src/agent-push-preview.js';
|
||||
import { createPushSender, type ProviderRequest } from '../src/agent-push-transports.js';
|
||||
|
||||
const { decrypt } = createRequire(import.meta.url)('http_ece');
|
||||
|
||||
test('preview lookup uses only the exact eligible completed root assistant content on the fixed public endpoint', async t => {
|
||||
const requests: string[] = [];
|
||||
const message = { id: 'outbound:11', role: 'assistant', content: 'Your **public answer** includes the requested passphrase: sky-blue.',
|
||||
metadata: { kind: 'message', status: 'complete', entryId: 11, reasoning: 'private reasoning', resultPreview: 'tool secret', output: 'worker output' } };
|
||||
const original = { conversationId: '1', cursor: '11', beforeCursor: '11', messages: [message] };
|
||||
let page: unknown = original, status = 200;
|
||||
const server = createServer((req, res) => {
|
||||
requests.push(req.url!);
|
||||
assert.equal(req.headers.authorization, 'Bearer fixture-key');
|
||||
assert.equal(req.headers['x-sybil-client'], 'backend-proxy-v1');
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', Location: 'http://never-fetch.invalid/' });
|
||||
res.end(JSON.stringify(page));
|
||||
});
|
||||
server.listen(0, '127.0.0.1'); await once(server, 'listening');
|
||||
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); });
|
||||
const load = createMessageText({ baseUrl: `http://127.0.0.1:${(server.address() as { port: number }).port}`, apiKey: 'fixture-key' });
|
||||
const candidate = { conversationId: '1', cursor: '11', messageId: 'outbound:11' };
|
||||
assert.equal(await load(candidate), message.content, 'explicit public assistant text is authorized without guessing/redacting secrets');
|
||||
assert.deepEqual(requests, ['/api/conversations/root/messages?after=10&limit=1&includeTools=false']);
|
||||
page = { ...original, messages: [{ ...message, id: 'entry:11:0' }] };
|
||||
assert.equal(await load({ ...candidate, messageId: 'entry:11:0' }), message.content);
|
||||
for (const invalid of [
|
||||
{ ...original, conversationId: 'worker-conversation' }, { ...original, cursor: '12' }, { ...original, beforeCursor: '10' },
|
||||
{ ...original, messages: [] }, { ...original, messages: [message, message] },
|
||||
...['tool', 'user', 'system', 'reasoning'].map(role => ({ ...original, messages: [{ ...message, role }] })),
|
||||
...[{ kind: 'tool_call' }, { kind: 'worker' }, { status: 'initiated' }, { entryId: 12 }].map(metadata => ({ ...original, messages: [{ ...message, metadata: { ...message.metadata, ...metadata } }] })),
|
||||
{ ...original, messages: [{ ...message, content: [{ type: 'reasoning', text: 'private reasoning' }] }] },
|
||||
{ ...original, messages: [{ ...message, id: 'tool:1:11' }] },
|
||||
]) { page = invalid; await assert.rejects(load(candidate)); }
|
||||
page = { ...original, messages: [{ ...message, content: '' }] };
|
||||
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
|
||||
const before = requests.length;
|
||||
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
|
||||
await assert.rejects(load({ ...candidate, messageId }));
|
||||
}
|
||||
assert.equal(requests.length, before);
|
||||
page = { ...original, messages: [{ ...message, content: 'x'.repeat(512 * 1024) }] };
|
||||
await assert.rejects(load(candidate));
|
||||
status = 302; page = original; await assert.rejects(load(candidate));
|
||||
status = 503; await assert.rejects(load(candidate));
|
||||
assert.ok(requests.every(url => url === requests[0]), 'no worker/raw-state/event/linked-resource requests or redirects');
|
||||
});
|
||||
|
||||
test('Markdown becomes readable message text without link destinations, image sources or HTML internals', () => {
|
||||
const markdown = '# Status\n\nThe **report** is _ready_ & checked.\n\n- Read [the summary](https://private.invalid/secret)\n- `keep_snake_case()`\n\n> Done.\n\n```ts\nconst answer = 42;\n```\n\n\n<!-- hidden comment -->\n<script>internal script</script>';
|
||||
const result = previewText(markdown);
|
||||
for (const text of ['Status', 'The report is ready & checked.', 'Read the summary', 'keep_snake_case()', 'Done.', 'const answer = 42;', 'Chart']) assert.ok(result.includes(text), result);
|
||||
for (const hidden of ['https://', 'private.invalid', 'secret', 'hidden comment', 'internal script', '**', '```', '# Status']) assert.ok(!result.includes(hidden), result);
|
||||
assert.equal(previewText('Plain text: 日本語 🌧️ — keep_this_path.txt'), 'Plain text: 日本語 🌧️ — keep_this_path.txt');
|
||||
assert.equal(previewText('A\u0000B\n\tC\ud800'), 'A B C�');
|
||||
});
|
||||
|
||||
test('actual APNs and decrypted Web Push bodies carry public text with UTF-8/JSON/encryption bounds', async () => {
|
||||
const apnsKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const receiver = createECDH('prime256v1'); receiver.generateKeys();
|
||||
const auth = Buffer.alloc(16, 1).toString('base64url');
|
||||
const native = { transport: 'apns' as const, enabled: true as const, token: 'a'.repeat(64), environment: 'sandbox' as const, topic: 'net.buzzert.sybil2' };
|
||||
const browser = { transport: 'web' as const, enabled: true as const, subscription: { endpoint: 'https://web.push.apple.com/fixture',
|
||||
keys: { p256dh: receiver.getPublicKey().toString('base64url'), auth } } };
|
||||
const apns: ProviderRequest[] = [], web: ProviderRequest[] = [];
|
||||
const sender = createPushSender({ enabled: true, adminToken: 'fixture',
|
||||
apns: { key: apnsKey.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), keyId: 'ABCDEFGHIJ', teamId: 'DQQH5H6GBD', environment: 'sandbox', topic: native.topic },
|
||||
web: { ...webpush.generateVAPIDKeys(), subject: 'mailto:tests@example.com' } }, {
|
||||
apns: async request => { apns.push(request); return { status: 200 }; }, web: async request => { web.push(request); return { status: 201 }; },
|
||||
});
|
||||
const identity = { version: 1 as const, kind: 'message' as const, sourceId: randomUUID(), messageId: 'outbound:11', cursor: '11' };
|
||||
const now = Date.now();
|
||||
for (const content of ['The **report** is ready. 日本語 🌧️', 'Requested public passphrase: sky-blue.', 'a'.repeat(3000),
|
||||
'a'.repeat(3001), '世界🌧️é'.repeat(2000), '"\\'.repeat(4000), '\ud800'.repeat(4000), ' ']) {
|
||||
assert.equal((await sender(native, identity, now + 60_000, now, content)).outcome, 'accepted');
|
||||
assert.equal((await sender(browser, identity, now + 60_000, now, content)).outcome, 'accepted');
|
||||
const apnsBytes = apns.at(-1)!.body, encrypted = web.at(-1)!.body;
|
||||
const plaintext: Buffer = decrypt(encrypted, { version: 'aes128gcm', privateKey: receiver, authSecret: auth });
|
||||
const apnsPayload = JSON.parse(apnsBytes.toString()), webPayload = JSON.parse(plaintext.toString());
|
||||
const body = apnsPayload.aps.alert.body;
|
||||
assert.equal(typeof body, 'string'); assert.equal(webPayload.body, body);
|
||||
assert.ok(Buffer.byteLength(body) <= MAX_PREVIEW_BYTES);
|
||||
assert.ok(apnsBytes.length <= 4096); assert.ok(plaintext.length <= 3993); assert.ok(encrypted.length <= 4096);
|
||||
assert.equal(encrypted.length, plaintext.length + 103);
|
||||
assert.equal(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(body)), body);
|
||||
for (const [key, value] of Object.entries(identity)) { assert.equal(apnsPayload[key], value); assert.equal(webPayload[key], value); }
|
||||
assert.equal(webPayload.url, '/?agent=root'); assert.equal(webPayload.tag, `sybil-agent:${identity.sourceId}`);
|
||||
const flattened = previewText(content) || 'You have a new message.';
|
||||
if (Buffer.byteLength(JSON.stringify(flattened)) - 2 <= MAX_PREVIEW_BYTES) assert.equal(body, flattened);
|
||||
else { assert.ok(body.endsWith('…')); assert.ok(flattened.startsWith(body.slice(0, -1))); }
|
||||
assert.ok(!encrypted.includes(Buffer.from(body)), 'Web Push plaintext is encrypted');
|
||||
}
|
||||
await sender(native, { version: 1, kind: 'read_state', sourceId: identity.sourceId, readCursor: '11' }, now + 60_000, now, 'never include text in a clear');
|
||||
assert.deepEqual(JSON.parse(apns.at(-1)!.body.toString()).aps, { 'content-available': 1 });
|
||||
assert.ok(!apns.at(-1)!.body.includes('never include'));
|
||||
});
|
||||
|
||||
test('payload budget includes identity overhead and JSON escaping before truncating on a Unicode boundary', () => {
|
||||
const result = previewPayload('界"\\🌧'.repeat(2000), body => ({ messageId: '"'.repeat(1000), body }), WEB_PUSH_PAYLOAD_BYTES);
|
||||
assert.ok(result.length <= WEB_PUSH_PAYLOAD_BYTES);
|
||||
const body = JSON.parse(result.toString()).body;
|
||||
assert.ok(body.endsWith('…')); assert.equal(body.includes('�'), false);
|
||||
assert.throws(() => previewPayload('hello', body => ({ extra: 'x'.repeat(5000), body }), 4096));
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createECDH, generateKeyPairSync, randomUUID, verify } from 'node:crypto';
|
||||
import { EventEmitter, once } from 'node:events';
|
||||
import { createServer } from 'node:http';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import Fastify from 'fastify';
|
||||
import sensible from '@fastify/sensible';
|
||||
import webpush from 'web-push';
|
||||
import type { Context, Registration } from '../src/agent-push-contract.js';
|
||||
import type { PushConfig } from '../src/agent-push-config.js';
|
||||
import type { SendResult } from '../src/agent-push-transports.js';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-push-test-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'push.db')}`;
|
||||
process.env.ADMIN_TOKEN = 'push-test-bearer-0123456789';
|
||||
process.env.AGENT_PUSH_ENABLED = 'false';
|
||||
for (const key of ['AGENT_PUSH_APNS_KEY_FILE','AGENT_PUSH_APNS_KEY_ID','AGENT_PUSH_APNS_TEAM_ID','AGENT_PUSH_VAPID_FILE']) delete process.env[key];
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
|
||||
const [{ prisma }, { AgentPushCoordinator, registerPushRoutes }, { createPushSender, isPublicAddress, createAPNsRequest, createWebRequest }, { validatePushEndpoint }, { env }] = await Promise.all([
|
||||
import('../src/db.js'), import('../src/agent-push.js'), import('../src/agent-push-transports.js'), import('../src/agent-push-contract.js'), import('../src/env.js'),
|
||||
]);
|
||||
const apns: Registration = { transport: 'apns', enabled: true, token: 'a'.repeat(64), environment: 'sandbox', topic: 'net.buzzert.sybil2' };
|
||||
const config: PushConfig = { enabled: true, adminToken: process.env.ADMIN_TOKEN,
|
||||
apns: { key: 'fixture-unused-by-injected-sender', keyId: 'ABCDEFGHIJ', teamId: 'DQQH5H6GBD', topic: apns.topic, environment: 'sandbox' },
|
||||
web: { publicKey: 'fixture-unused', privateKey: 'fixture-unused', subject: 'mailto:tests@example.com' } };
|
||||
function subscription(): Registration {
|
||||
const curve = createECDH('prime256v1'); curve.generateKeys();
|
||||
return { transport: 'web', enabled: true, subscription: { endpoint: `https://web.push.apple.com/test/${randomUUID()}`, expirationTime: null,
|
||||
keys: { p256dh: curve.getPublicKey().toString('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') } } };
|
||||
}
|
||||
async function fixture(options: { warmupMs?: number; config?: PushConfig } = {}) {
|
||||
let now = Date.now();
|
||||
const source = randomUUID();
|
||||
let context: Context = { version: 1, sourceId: source, conversationId: '1', cursor: '10', hasMore: false,
|
||||
latestUserMessageAt: new Date(now - 30_000).toISOString(), nativeReadCursor: '0', uncertain: false,
|
||||
webPresence: { active: false, unknown: false, observedAt: new Date(now).toISOString() }, candidates: [] };
|
||||
const sent: { registration: Registration; identity: any; messageText?: string }[] = [], reads: any[] = [];
|
||||
let result: SendResult = { outcome: 'accepted' }, failure = false;
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: options.config || config, now: () => now,
|
||||
warmupMs: options.warmupMs ?? 0, context: async after => { if (failure) throw new Error('upstream secret must not leak');
|
||||
return { ...context, webPresence: { ...context.webPresence, observedAt: new Date(now).toISOString() }, candidates: after === undefined ? [] : context.candidates.filter(c => Number(c.cursor) > Number(after)) }; },
|
||||
messageText: async () => 'The Agent finished the requested work.',
|
||||
send: async (registration, identity, _expiresAt, _now, messageText) => { sent.push({ registration, identity, messageText }); return result; }, broadcast: value => reads.push(value) });
|
||||
await manager.initialize();
|
||||
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; },
|
||||
result(value: SendResult) { result = value; }, failure(value: boolean) { failure = value; },
|
||||
context(value: Partial<Context>) { context = { ...context, ...value }; },
|
||||
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 }] }; },
|
||||
};
|
||||
}
|
||||
test.beforeEach(async () => {
|
||||
for (const table of ['AgentPushDelivery','AgentPushConnection','AgentPushPresence','AgentPushInstallation','AgentPushCoordinator','AgentReadState']) await prisma.$executeRawUnsafe(`DELETE FROM "${table}"`);
|
||||
});
|
||||
test.after(async () => { await prisma.$disconnect(); rmSync(directory, { recursive: true, force: true }); });
|
||||
|
||||
test('first source bootstrap never alerts history; later durable candidates debounce, survive restart and deduplicate', async () => {
|
||||
const f = await fixture(); assert.equal((await f.manager.config()).sourceId, f.source); assert.equal(f.sent.length, 0);
|
||||
f.candidate(); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
assert.deepEqual(f.sent[0].identity, { version: 1, sourceId: f.source, kind: 'message', messageId: 'outbound:11', cursor: '11' });
|
||||
assert.equal(f.sent[0].messageText, 'The Agent finished the requested work.');
|
||||
assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'accepted');
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('preview resolves only the coalesced eligible root candidate after attention clears and survives a pending restart', async () => {
|
||||
const f = await fixture(), tab = randomUUID(), lookups: unknown[] = [];
|
||||
f.manager.options.messageText = async candidate => { lookups.push(candidate); return `Public reply ${candidate.messageId}`; };
|
||||
f.candidate('11', 'unknown'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(lookups.length, 0);
|
||||
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate('12', 'worker'); await f.manager.tick(); f.candidate('13', 'timer'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(lookups.length, 0, 'focused clients do not cause repeated message fetches');
|
||||
await f.manager.retirePresence(tab); f.advance(6_000);
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick();
|
||||
assert.equal(f.sent.length, 1); assert.equal(f.sent[0].messageText, 'Public reply outbound:13');
|
||||
assert.equal((lookups[0] as any).conversationId, '1'); assert.equal((lookups[0] as any).messageId, 'outbound:13');
|
||||
assert.ok(!JSON.stringify(await prisma.agentPushDelivery.findMany()).includes('Public reply'), 'receipts contain no preview text');
|
||||
});
|
||||
|
||||
for (const change of ['read', 'focus', 'source', 'revoke']) test(`preview lookup cannot bypass ${change} changes before the send intent`, async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.messageText = async () => {
|
||||
if (change === 'read') f.context({ nativeReadCursor: '11' });
|
||||
if (change === 'source') f.context({ sourceId: randomUUID() });
|
||||
if (change === 'focus') await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
if (change === 'revoke') await f.manager.revoke(f.installationId);
|
||||
return 'Public text that must not be sent now';
|
||||
};
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).attempts, 0);
|
||||
});
|
||||
|
||||
test('unavailable public text suppresses without claiming a provider send; the next valid lookup sends actual text', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.messageText = async () => { throw new Error('private upstream detail'); };
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
const pending = await prisma.agentPushDelivery.findFirstOrThrow();
|
||||
assert.equal(pending.status, 'pending'); assert.equal(pending.attempts, 0);
|
||||
assert.equal(pending.lastSuppressionReason, 'source_or_coordinator_unavailable');
|
||||
f.manager.options.messageText = async () => 'Recovered public reply';
|
||||
await f.manager.tick(); assert.equal(f.sent[0].messageText, 'Recovered public reply');
|
||||
});
|
||||
|
||||
test('all tabs veto while focused; hidden heartbeats and stale sequences cannot extend short transition grace', async () => {
|
||||
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID();
|
||||
await f.manager.presence(tab1, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
|
||||
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'focused but idle still vetoes');
|
||||
let receipt = await prisma.agentPushDelivery.findFirstOrThrow();
|
||||
assert.equal(receipt.lastSuppressionReason, 'foreground_focused'); assert.equal(receipt.lastSuppressionClientKind, 'web');
|
||||
await f.manager.presence(tab2, { sequence: 2, visibility: 'hidden', focused: false, interacted: true });
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'recent input vetoes blur');
|
||||
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab2 } })).sequence, 2);
|
||||
await f.manager.presence(tab2, { sequence: 3, visibility: 'hidden', focused: false, interacted: true });
|
||||
f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'hidden receipts do not renew attention');
|
||||
receipt = await prisma.agentPushDelivery.findFirstOrThrow();
|
||||
assert.equal(receipt.lastSuppressionReason, 'inactive_grace'); assert.ok(receipt.lastSuppressedAt);
|
||||
});
|
||||
|
||||
test('blur and visible recent interaction have short grace; fresh focus renews a live lease', 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(2_000);
|
||||
await f.manager.presence(tab, { sequence: 2, visibility: 'visible', focused: false, interacted: true });
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'recent_interaction');
|
||||
f.advance(3_000); await f.manager.presence(tab, { sequence: 3, visibility: 'visible', focused: false, interacted: false });
|
||||
f.advance(3_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
await f.manager.presence(tab, { sequence: 4, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate('12'); await f.manager.tick(); f.advance(45_000);
|
||||
await f.manager.presence(tab, { sequence: 5, visibility: 'visible', focused: true, interacted: false });
|
||||
f.advance(20_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'fresh foreground heartbeat extends attention');
|
||||
f.advance(41_000); await f.manager.tick(); assert.equal(f.sent.length, 2, 'stale focus expires');
|
||||
});
|
||||
|
||||
test('anonymous introduction grace survives heartbeat, new UUID reconnects and coordinator restart without renewing', async () => {
|
||||
const f = await fixture(), connection = randomUUID();
|
||||
await f.manager.connect(connection); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(f.sent.length, 0);
|
||||
const first = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'unknown_client_grace');
|
||||
await f.manager.heartbeat(connection); await f.manager.disconnect(connection);
|
||||
await f.manager.connect(randomUUID(), 'web', randomUUID()); f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
f.candidate('12'); await f.manager.tick(); f.advance(2_000);
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 });
|
||||
await restarted.connect(randomUUID()); await restarted.tick(); assert.equal(f.sent.length, 2);
|
||||
assert.deepEqual((await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } })).unknownSince, first.unknownSince);
|
||||
// Spending the anonymous budget never disables an independent authenticated browser lease.
|
||||
await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
f.candidate('13'); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 2);
|
||||
});
|
||||
|
||||
test('expired observed web lease is inactive with a live socket; transport heartbeat preserves attribution and never recreates it', async () => {
|
||||
const f = await fixture(), tab = randomUUID(), connection = randomUUID(), native = randomUUID();
|
||||
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
|
||||
await f.manager.connect(connection, 'web', tab); await f.manager.connect(native, 'native');
|
||||
f.candidate(); await f.manager.tick(); f.advance(61_000);
|
||||
await f.manager.heartbeat(connection); await f.manager.heartbeat(native); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
assert.equal((await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } })).unknownSince, null);
|
||||
const row = await prisma.agentPushConnection.findUniqueOrThrow({ where: { id: connection } });
|
||||
assert.equal(row.kind, 'web'); assert.equal(row.sessionId, tab);
|
||||
assert.equal((await prisma.agentPushConnection.findUniqueOrThrow({ where: { id: native } })).kind, 'native');
|
||||
await f.manager.disconnect(native); await f.manager.heartbeat(native);
|
||||
assert.equal(await prisma.agentPushConnection.findUnique({ where: { id: native } }), null);
|
||||
await f.manager.presence(tab, { sequence: 2, visibility: 'hidden', focused: false, interacted: false });
|
||||
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab } })).inactiveUntil, null, 'stale focus cannot start fresh grace');
|
||||
});
|
||||
|
||||
test('presence classification cannot borrow a session from a different credential generation', async () => {
|
||||
const f = await fixture(), tab = randomUUID();
|
||||
await f.manager.presence(tab, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
|
||||
await prisma.agentPushPresence.update({ where: { id: tab }, data: { ownerEpoch: 'another-generation' } });
|
||||
await f.manager.connect(randomUUID(), 'web', tab); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
assert.equal(f.sent.length, 0); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'unknown_client_grace');
|
||||
});
|
||||
|
||||
test('read at final context refresh cancels without a send and preserves the prior suppression evidence', async () => {
|
||||
const f = await fixture(); await f.manager.connect(randomUUID()); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
const prior = await prisma.agentPushDelivery.findFirstOrThrow(), original = f.manager.options.context; let calls = 0;
|
||||
f.manager.options.context = async (...args) => { if (++calls === 2) await f.manager.advanceRead('11', f.source); return original(...args); };
|
||||
f.advance(4_000); await f.manager.tick();
|
||||
const receipt = await prisma.agentPushDelivery.findFirstOrThrow();
|
||||
assert.equal(f.sent.length, 0); assert.equal(receipt.attempts, 0); assert.equal(receipt.outcome, 'read');
|
||||
assert.equal(receipt.lastSuppressionReason, prior.lastSuppressionReason); assert.deepEqual(receipt.lastSuppressedAt, prior.lastSuppressedAt);
|
||||
});
|
||||
|
||||
test('instrumented hidden web socket is inactive; missing lease and native-runtime unknown veto', async () => {
|
||||
const f = await fixture(), tab = randomUUID(), connection = randomUUID();
|
||||
await f.manager.connect(connection, 'web', tab); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
await f.manager.presence(tab, { sequence: 0, visibility: 'hidden', focused: false, interacted: false });
|
||||
f.context({ webPresence: { active: false, unknown: true, observedAt: '' } }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.context({ webPresence: { active: false, unknown: false, observedAt: '' } }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('prompt grace resets on latest accepted prompt, root bursts coalesce, unknown provenance never sends', async () => {
|
||||
const f = await fixture(); f.context({ latestUserMessageAt: new Date(f.now).toISOString() });
|
||||
f.candidate(); await f.manager.tick(); f.advance(3_000); f.candidate('12', 'timer'); await f.manager.tick();
|
||||
f.context({ latestUserMessageAt: new Date(f.now).toISOString() }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(3_000); await f.manager.tick(); assert.equal(f.sent.length, 1); assert.equal(f.sent[0].identity.cursor, '12');
|
||||
f.candidate('13', 'unknown'); await f.manager.tick(); f.advance(10_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
test('canonical read and native read cancel pending sends and broadcast source-bound state', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); await f.manager.advanceRead('11', f.source); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.candidate('12'); await f.manager.tick(); f.context({ nativeReadCursor: '12' }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await f.manager.readState()).cursor, '12'); assert.equal(f.reads.at(-1).sourceId, f.source);
|
||||
assert.equal((await f.manager.advanceRead('1')).cursor, '12');
|
||||
await assert.rejects(f.manager.advanceRead('13', randomUUID()), { statusCode: 409 });
|
||||
});
|
||||
|
||||
test('read clears target APNs only, coalesce and rate-limit; Web Push is never invisible', async () => {
|
||||
const f = await fixture(); await f.manager.register(randomUUID(), subscription()); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 2);
|
||||
await f.manager.advanceRead('11', f.source); await f.manager.tick();
|
||||
const clears = f.sent.filter(v => v.identity.kind === 'read_state'); assert.equal(clears.length, 1); assert.equal(clears[0].registration.transport, 'apns');
|
||||
await f.manager.advanceRead('12', f.source); await f.manager.tick(); assert.equal(f.sent.length, 3);
|
||||
f.advance(60_000); await f.manager.tick(); assert.equal(f.sent.filter(v => v.identity.kind === 'read_state').length, 2);
|
||||
});
|
||||
|
||||
test('unavailable source, uncertainty, startup warm-up and source replacement suppress safely', async () => {
|
||||
const f = await fixture({ warmupMs: 60_000 }); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(60_000); f.failure(true); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.failure(false); f.context({ uncertain: true }); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.context({ uncertain: false, sourceId: randomUUID(), latestUserMessageAt: null }); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
assert.equal((await f.manager.config()).quarantined, true); assert.equal((await f.manager.config()).sourceId, f.source);
|
||||
assert.equal((await f.manager.readState()).sourceId, f.source);
|
||||
});
|
||||
|
||||
test('known transient rejections retry boundedly; interrupted intent and ambiguous I/O never replay', async () => {
|
||||
const f = await fixture(); f.result({ outcome: 'retry', retryAfterMs: 1_000 }); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
for (let i = 0; i < 6; i++) { await f.manager.tick(); f.advance(2_000); }
|
||||
assert.equal(f.sent.length, 4); assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'failed');
|
||||
f.candidate('12'); f.result({ outcome: 'ambiguous' }); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
f.advance(60_000); await f.manager.tick(); assert.equal(f.sent.length, 5);
|
||||
f.candidate('13'); await f.manager.tick(); await prisma.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'intent', updatedAt: new Date(f.now - 40_000) } });
|
||||
await f.manager.tick(); assert.equal(f.sent.length, 5); assert.equal(await prisma.agentPushDelivery.count({ where: { status: 'ambiguous' } }), 2);
|
||||
});
|
||||
|
||||
test('revoke and rotation prevent pending sends; permanent rejection cannot invalidate a newer endpoint version', async () => {
|
||||
const f = await fixture(); const same = await f.manager.register(f.installationId, apns); assert.equal(same.version, 1);
|
||||
f.candidate(); await f.manager.tick(); await f.manager.revoke(f.installationId); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
const newId = randomUUID(); await f.manager.register(newId, apns); f.candidate('12'); await f.manager.tick(); f.advance(2_000);
|
||||
f.manager.options.send = async () => { await f.manager.register(newId, { ...apns, token: 'b'.repeat(64) }); return { outcome: 'invalid' }; };
|
||||
await f.manager.tick(); const latest = await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: newId } }); assert.equal(latest.enabled, true); assert.equal(latest.version, 2);
|
||||
});
|
||||
|
||||
test('credential generation change disables opt-ins; two coordinators do not send one delivery twice', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
const second = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await second.initialize();
|
||||
await Promise.all([f.manager.tick(), second.tick()]); assert.equal(f.sent.length, 1);
|
||||
const rotated = new AgentPushCoordinator({ ...f.manager.options, config: { ...config, adminToken: 'rotated-fixture-token' }, warmupMs: 0 }); await rotated.initialize();
|
||||
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).enabled, false);
|
||||
});
|
||||
|
||||
test('authenticated routes reject open mode, wrong owner/body, malformed endpoints and secrets are never reflected', async () => {
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
await registerPushRoutes(app, prisma, { baseUrl: 'http://unused.invalid' }, () => {}, { config, context: async () => { throw new Error(); }, send: async () => { throw new Error('must never send'); } });
|
||||
const headers = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, url = `/v1/agent/push/installations/${randomUUID()}`;
|
||||
assert.equal((await app.inject({ url: '/v1/agent/push/config' })).statusCode, 401);
|
||||
assert.equal((await app.inject({ url: '/v1/agent/push/config', headers: { authorization: 'Bearer wrong' } })).statusCode, 403);
|
||||
const valid = await app.inject({ method: 'PUT', url, headers, payload: apns }); assert.equal(valid.statusCode, 200); assert.ok(!valid.body.includes(apns.token));
|
||||
assert.equal((await app.inject({ method: 'PUT', url, headers, payload: { ...apns, ownerId: 'someone-else' } })).statusCode, 400);
|
||||
const invalid = subscription(); if (invalid.transport === 'web') invalid.subscription.endpoint = 'https://127.0.0.1/private-secret';
|
||||
const response = await app.inject({ method: 'PUT', url, headers, payload: invalid }); assert.equal(response.statusCode, 400); assert.ok(!response.body.includes('private-secret'));
|
||||
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
|
||||
try { assert.equal((await app.inject({ url: '/v1/agent/push/config', headers })).statusCode, 503); } finally { env.ADMIN_TOKEN = saved; }
|
||||
assert.equal((await app.inject({ method: 'DELETE', url, headers })).statusCode, 204);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('provider endpoint validation rejects SSRF targets and private/reserved DNS addresses', () => {
|
||||
for (const value of ['http://fcm.googleapis.com/x','https://user:pass@fcm.googleapis.com/x','https://fcm.googleapis.com.evil.example/x', 'https://fcm.googleapis.com:8443/x', 'https://[::1]/x','https://web.push.apple.com/x#fragment']) assert.throws(() => validatePushEndpoint(value));
|
||||
for (const address of ['127.0.0.1','10.0.0.1','169.254.1.1','192.168.1.1','172.16.0.1','::1','::ffff:127.0.0.1','fd00::1','fe80::1','2001:db8::1']) assert.equal(isPublicAddress(address), false, address);
|
||||
assert.equal(isPublicAddress('8.8.8.8'), true); assert.equal(isPublicAddress('2606:4700::1111'), true);
|
||||
});
|
||||
|
||||
test('real APNs ES256 request construction uses message text, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
|
||||
const key = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const requests: any[] = []; const now = Date.now(), sourceId = randomUUID();
|
||||
const sender = createPushSender({ ...config, apns: { ...config.apns!, key: key.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() } },
|
||||
{ apns: async request => { requests.push(request); return { status: 200 }; } });
|
||||
const identity = { version: 1 as const, sourceId, kind: 'message' as const, messageId: 'outbound:12', cursor: '12' };
|
||||
assert.equal((await sender(apns, identity, now + 300_000, now, 'Your **report** is ready.')).outcome, 'accepted');
|
||||
assert.equal(requests[0].endpoint, `https://api.sandbox.push.apple.com/3/device/${apns.token}`);
|
||||
assert.equal(requests[0].headers['apns-push-type'], 'alert'); assert.equal(requests[0].headers['apns-topic'], apns.topic);
|
||||
const [header, claims, signature] = requests[0].headers.authorization.slice(7).split('.');
|
||||
assert.equal(JSON.parse(Buffer.from(header, 'base64url').toString()).kid, 'ABCDEFGHIJ');
|
||||
assert.equal(JSON.parse(Buffer.from(claims, 'base64url').toString()).iss, 'DQQH5H6GBD');
|
||||
assert.equal(verify('sha256', Buffer.from(`${header}.${claims}`), { key: key.publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url')), true);
|
||||
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'Your report is ready.' });
|
||||
await sender(apns, { version: 1, sourceId, kind: 'read_state', readCursor: '12' }, now + 60_000, now);
|
||||
assert.equal(requests[1].headers['apns-push-type'], 'background'); assert.equal(requests[1].headers['apns-priority'], '5');
|
||||
assert.deepEqual(JSON.parse(requests[1].body).aps, { 'content-available': 1 });
|
||||
});
|
||||
|
||||
test('standard encrypted aes128gcm + VAPID Web Push uses library request construction and never sends read clears', async () => {
|
||||
const vapid = webpush.generateVAPIDKeys(), registrations = subscription(), requests: any[] = [];
|
||||
const sender = createPushSender({ ...config, web: { ...vapid, subject: 'mailto:tests@example.com' } }, { web: async request => { requests.push(request); return { status: 201 }; } });
|
||||
const now = Date.now(), sourceId = randomUUID();
|
||||
assert.equal((await sender(registrations, { version: 1, sourceId, kind: 'message', messageId: 'outbound:1', cursor: '1' }, now + 300_000, now)).outcome, 'accepted');
|
||||
assert.equal(requests.length, 1); assert.equal(requests[0].headers['Content-Encoding'], 'aes128gcm'); assert.equal(requests[0].headers.TTL, 60);
|
||||
assert.match(requests[0].headers.Authorization, /^vapid t=/); assert.ok(Buffer.isBuffer(requests[0].body)); assert.ok(!requests[0].body.includes('You have a new message.'));
|
||||
assert.equal((await sender(registrations, { version: 1, sourceId, kind: 'read_state', readCursor: '1' }, now + 60_000, now)).outcome, 'failed'); assert.equal(requests.length, 1);
|
||||
const gone = createPushSender({ ...config, web: { ...vapid, subject: 'mailto:tests@example.com' } }, { web: async () => ({ status: 410 }) });
|
||||
assert.equal((await gone(registrations, { version: 1, sourceId, kind: 'message', messageId: 'outbound:2', cursor: '2' }, now + 60_000, now)).outcome, 'invalid');
|
||||
});
|
||||
|
||||
|
||||
test('production-scoped APNs configuration refuses sandbox registration and cannot send it', async () => {
|
||||
const production = { ...config, apns: { ...config.apns!, environment: 'production' as const } };
|
||||
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, messageText: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
|
||||
await assert.rejects(manager.register(randomUUID(), apns), { statusCode: 400 });
|
||||
let requests = 0;
|
||||
const sender = createPushSender(production, { apns: async () => { requests++; throw new Error('must not send'); } });
|
||||
const now = Date.now();
|
||||
assert.equal((await sender(apns, { version: 1, sourceId: randomUUID(), kind: 'message', messageId: 'outbound:1', cursor: '1' }, now + 60_000, now)).outcome, 'failed');
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
|
||||
test('raw mocked HTTP2 handles request throws, goaway and close; raw HTTPS has absolute timeout even without socket inactivity', async () => {
|
||||
const input = { endpoint: 'https://api.push.apple.com/3/device/fixture', headers: {}, body: Buffer.from('{}') };
|
||||
for (const event of ['connect', 'goaway', 'close', 'never']) {
|
||||
const session = new EventEmitter() as any; let destroyed = 0;
|
||||
session.destroy = () => { destroyed++; }; session.request = () => { throw new Error('fixture request error'); };
|
||||
const transport = createAPNsRequest((() => { if (event !== 'never') queueMicrotask(() => session.emit(event)); return session; }) as any, 5);
|
||||
await assert.rejects(transport(input), /APNs transport unavailable/); assert.equal(destroyed, 1);
|
||||
}
|
||||
const req = new EventEmitter() as any; let destroyed = false; req.destroy = () => { destroyed = true; }; req.end = () => {};
|
||||
const web = createWebRequest((() => req) as any, 5);
|
||||
await assert.rejects(web({ ...input, endpoint: 'https://web.push.apple.com/fixture' }), /Web Push transport unavailable/); assert.equal(destroyed, true);
|
||||
const throwing = createWebRequest((() => { throw new Error('fixture constructor error'); }) as any, 5);
|
||||
await assert.rejects(throwing({ ...input, endpoint: 'https://web.push.apple.com/fixture' }), /Web Push transport unavailable/);
|
||||
});
|
||||
|
||||
|
||||
test('DELETE tombstones unknown and known UUIDs across restart; late PUT and rebound endpoints cannot re-enable retired IDs', async () => {
|
||||
const f = await fixture(), unknown = randomUUID();
|
||||
await f.manager.revoke(unknown);
|
||||
await assert.rejects(f.manager.register(unknown, apns), { statusCode: 409 });
|
||||
await f.manager.revoke(f.installationId); await f.manager.revoke(f.installationId);
|
||||
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.initialize();
|
||||
await assert.rejects(restarted.register(f.installationId, apns), { statusCode: 409 });
|
||||
const first = randomUUID(), second = randomUUID();
|
||||
await restarted.register(first, apns); await restarted.register(second, apns);
|
||||
await assert.rejects(restarted.register(first, apns), { statusCode: 409 });
|
||||
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: first } })).payload, '');
|
||||
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: second } })).enabled, true);
|
||||
});
|
||||
|
||||
|
||||
test('concurrent backend prompt admission starts durable grace even before runtime snapshot catches up', async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
await f.manager.notePrompt(); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
|
||||
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
|
||||
});
|
||||
|
||||
|
||||
async function httpContextFixture(f: Awaited<ReturnType<typeof fixture>>, snapshot: unknown, statuses = { incremental: 409, bootstrap: 200 }) {
|
||||
const requests: (string | null)[] = [], sent: unknown[] = [];
|
||||
const source = createServer((req, res) => {
|
||||
assert.equal(req.headers.authorization, 'Bearer fixture-runtime-secret');
|
||||
assert.equal(req.headers['x-sybil-client'], 'backend-proxy-v1');
|
||||
const url = new URL(req.url!, 'http://fixture.invalid');
|
||||
assert.equal(url.pathname, '/api/notification-context'); assert.equal(url.searchParams.get('limit'), '200');
|
||||
const after = url.searchParams.get('after'); requests.push(after);
|
||||
res.statusCode = after === null ? statuses.bootstrap : statuses.incremental;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(after === null && statuses.bootstrap === 200 ? snapshot : { error: 'cursor unavailable' }));
|
||||
});
|
||||
source.listen(0, '127.0.0.1'); await once(source, 'listening');
|
||||
const app = Fastify({ logger: false }); await app.register(sensible);
|
||||
const manager = await registerPushRoutes(app, prisma, { baseUrl: `http://127.0.0.1:${(source.address() as { port: number }).port}`, apiKey: 'fixture-runtime-secret' }, () => {}, {
|
||||
config, warmupMs: 0, now: () => f.now,
|
||||
send: async (_registration, identity) => { sent.push(identity); return { outcome: 'accepted' }; },
|
||||
});
|
||||
return { manager, requests, sent, close: async () => { await manager.stop(); await app.close(); source.closeAllConnections(); await new Promise<void>(resolve => source.close(() => resolve())); } };
|
||||
}
|
||||
function highWaterSnapshot(f: Awaited<ReturnType<typeof fixture>>, overrides: Partial<Context> = {}): Context {
|
||||
return { version: 1, sourceId: randomUUID(), conversationId: '1', cursor: '3', hasMore: false, latestUserMessageAt: null,
|
||||
nativeReadCursor: '0', uncertain: false, webPresence: { active: false, unknown: false, observedAt: new Date(f.now).toISOString() }, candidates: [], ...overrides };
|
||||
}
|
||||
for (const identityChanged of [true, false]) test(`actual HTTP409 probes once and quarantines ${identityChanged ? 'replacement source' : 'same-source cursor regression'} without rebasing reads/checkpoint`, async () => {
|
||||
const f = await fixture(); await f.manager.advanceRead('4', f.source); f.candidate(); await f.manager.tick(); f.advance(2_000);
|
||||
const oldRead = await f.manager.readState();
|
||||
const http = await httpContextFixture(f, highWaterSnapshot(f, identityChanged ? {} : { sourceId: f.source }));
|
||||
try {
|
||||
await http.manager.tick(); assert.deepEqual(http.requests, ['11', null]); assert.equal(http.sent.length, 0);
|
||||
const state = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
assert.equal(state.quarantined, true); assert.equal(state.sourceId, f.source); assert.equal(state.cursor, '11');
|
||||
assert.deepEqual(await http.manager.readState(), oldRead);
|
||||
const delivery = await prisma.agentPushDelivery.findFirstOrThrow(); assert.equal(delivery.status, 'cancelled'); assert.equal(delivery.outcome, 'source_changed');
|
||||
assert.equal(await prisma.agentPushDelivery.count(), 1, 'probe never enumerates/replays historical candidates');
|
||||
} finally { await http.close(); }
|
||||
});
|
||||
for (const failure of ['rejected', 'conflict', 'malformed', 'candidates', 'paginated', 'same-source-equal', 'same-source-higher', 'non-conflict']) test(`HTTP cursor recovery stays fail closed for ${failure}`, async () => {
|
||||
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000); const oldRead = await f.manager.readState();
|
||||
const snapshot = failure === 'malformed' ? { invalid: true } : highWaterSnapshot(f, {
|
||||
...(failure === 'candidates' ? { candidates: [{ messageId: 'outbound:3', cursor: '3', createdAt: new Date(f.now).toISOString(), trigger: 'timer' as const }] } : {}),
|
||||
...(failure === 'paginated' ? { hasMore: true } : {}),
|
||||
...(failure.startsWith('same-source') ? { sourceId: f.source, cursor: failure === 'same-source-higher' ? '12' : '11', nativeReadCursor: '11' } : {}),
|
||||
});
|
||||
const http = await httpContextFixture(f, snapshot, { incremental: failure === 'non-conflict' ? 503 : 409,
|
||||
bootstrap: failure === 'rejected' ? 503 : failure === 'conflict' ? 409 : 200 });
|
||||
try {
|
||||
await http.manager.tick(); assert.deepEqual(http.requests, failure === 'non-conflict' ? ['11'] : ['11', null]); assert.equal(http.sent.length, 0);
|
||||
const state = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
|
||||
assert.equal(state.uncertain, true); assert.equal(state.quarantined, false); assert.equal(state.sourceId, f.source); assert.equal(state.cursor, '11');
|
||||
assert.deepEqual(await http.manager.readState(), oldRead); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).status, 'pending');
|
||||
} finally { await http.close(); }
|
||||
});
|
||||
|
||||
test('endpoint rebind retires a provider-disabled UUID, rejects its delayed PUT, and leaves no live endpoint after new owner revoke', async () => {
|
||||
const f = await fixture(); f.result({ outcome: 'invalid' }); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
|
||||
const invalid = await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } });
|
||||
assert.equal(invalid.enabled, false); assert.equal(invalid.revokedAt, null); assert.notEqual(invalid.fingerprint, '');
|
||||
const newer = randomUUID(); await f.manager.register(newer, apns);
|
||||
assert.notEqual((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).revokedAt, null);
|
||||
await assert.rejects(f.manager.register(f.installationId, apns), { statusCode: 409 });
|
||||
await f.manager.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
|
||||
});
|
||||
|
||||
test('endpoint rebind also retires disabled registrations from a prior credential generation', async () => {
|
||||
const f = await fixture();
|
||||
const current = new AgentPushCoordinator({ ...f.manager.options, config: { ...config, adminToken: 'new-fixture-bearer-generation' }, warmupMs: 0 });
|
||||
await current.initialize(); assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).enabled, false);
|
||||
const newer = randomUUID(); await current.register(newer, apns);
|
||||
await assert.rejects(f.manager.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);
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { WebSocket, WebSocketServer } from 'ws';
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
|
||||
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
|
||||
process.env.AGENT_PUSH_ENABLED = 'false';
|
||||
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
|
||||
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
|
||||
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
|
||||
@@ -93,7 +94,7 @@ test('REST authenticates, proxies the shared room, and retains stable message ID
|
||||
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
|
||||
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
|
||||
assert.deepEqual(requests.at(-1)!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.deepEqual(requests.findLast(request => request.method === 'POST')!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
|
||||
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
|
||||
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
|
||||
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
|
||||
@@ -165,6 +166,11 @@ test('WebSocket first-frame authentication gates the private connection and forw
|
||||
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
|
||||
assert.equal(frames[1].messages[0].content, 'Later result');
|
||||
assert.equal(frames[2].activity.thinking, true);
|
||||
const beforeRead = subscriptions.length;
|
||||
await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '901' } });
|
||||
await eventually(() => frames.some(frame => frame.type === 'read_state' && frame.cursor === '901'));
|
||||
assert.equal(subscriptions.length, beforeRead, 'read events are backend broadcasts, never runtime subscriptions');
|
||||
assert.equal(frames.find(frame => frame.type === 'read_state').sourceId, null);
|
||||
assert.ok(!JSON.stringify(frames).includes(privateKey));
|
||||
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
|
||||
await eventually(() => subscriptions.at(-1)?.after === '43');
|
||||
|
||||
@@ -12,3 +12,125 @@ self.addEventListener("fetch", (event) => {
|
||||
if (event.request.mode !== "navigate") return;
|
||||
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
|
||||
});
|
||||
|
||||
// This worker never receives credentials and never performs authenticated fetches.
|
||||
// Safari requires a visible notification for each valid message push. Reads are
|
||||
// reconciled only by foreground page messages, never an invisible Web Push.
|
||||
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
|
||||
const bytes = value => new TextEncoder().encode(value).length;
|
||||
const preview = value => typeof value === "string" && value.trim().length > 0 && bytes(value) <= 3000;
|
||||
const sourceId = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
|
||||
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
|
||||
const owned = value => value?.version === 1 && value.kind === "message" && sourceId(value.sourceId) && cursor(value.cursor) && messageId(value.messageId);
|
||||
let queue = Promise.resolve();
|
||||
const serial = operation => { const result = queue.then(operation); queue = result.catch(() => undefined); return result; };
|
||||
function database() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open("sybil-agent-notifications-v1", 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore("sources", { keyPath: "sourceId" });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
request.onblocked = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
}
|
||||
async function stored(id) {
|
||||
const db = await database();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const request = db.transaction("sources").objectStore("sources").get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function save(value) {
|
||||
const db = await database();
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction("sources", "readwrite"), store = tx.objectStore("sources");
|
||||
store.put({ ...value, touched: Date.now() });
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => {
|
||||
const rows = request.result.sort((a, b) => b.touched - a.touched);
|
||||
for (const stale of rows.slice(16)) store.delete(stale.sourceId);
|
||||
};
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(new Error("Notification storage unavailable."));
|
||||
tx.onabort = () => reject(new Error("Notification storage unavailable."));
|
||||
});
|
||||
} finally { db.close(); }
|
||||
}
|
||||
async function announce(type) {
|
||||
for (const client of await self.clients.matchAll({ type: "window", includeUncontrolled: true })) {
|
||||
if (new URL(client.url).origin === self.location.origin) client.postMessage({ type });
|
||||
}
|
||||
}
|
||||
self.addEventListener("push", event => {
|
||||
event.waitUntil(serial(async () => {
|
||||
let value;
|
||||
try { const raw = event.data?.text(); if (!raw || bytes(raw) > 3993) return; value = JSON.parse(raw); } catch { return; }
|
||||
if (!owned(value) || value.url !== PUSH_URL || value.tag !== `sybil-agent:${value.sourceId}`
|
||||
|| value.title !== PUSH_TITLE || !preview(value.body)) return;
|
||||
const previous = await stored(value.sourceId).catch(() => null);
|
||||
// Re-delivery replaces the same owned tag without regressing its cursor.
|
||||
// Still show it visibly: local read state is not a reason for an invisible push.
|
||||
const stale = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor);
|
||||
const identity = stale ? previous.latest : {
|
||||
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
|
||||
};
|
||||
// Keep the displayed text paired with the retained identity across late pushes/restarts.
|
||||
// Older workers persisted identity only, so their retained notifications use the fallback.
|
||||
const body = stale ? preview(previous.latestBody) ? previous.latestBody : PUSH_BODY : value.body;
|
||||
await self.registration.showNotification(PUSH_TITLE, {
|
||||
body, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
|
||||
renotify: false, data: identity,
|
||||
});
|
||||
await save({ ...previous, sourceId: identity.sourceId, latest: identity, latestBody: body }).catch(() => undefined);
|
||||
await announce("sybil-push-shown");
|
||||
}));
|
||||
});
|
||||
self.addEventListener("notificationclick", event => {
|
||||
event.notification.close();
|
||||
if (!owned(event.notification.data) || event.notification.tag !== `sybil-agent:${event.notification.data.sourceId}`) return;
|
||||
event.waitUntil((async () => {
|
||||
const destination = new URL(PUSH_URL, self.location.origin).href;
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
for (const client of windows) {
|
||||
const url = new URL(client.url);
|
||||
if (url.origin !== self.location.origin || !["/", "/search"].includes(url.pathname)) continue;
|
||||
try { const routed = await client.navigate(destination); if (routed) { await routed.focus(); return; } } catch { /* Try another client. */ }
|
||||
}
|
||||
await self.clients.openWindow(destination);
|
||||
// A tap routes only. The rendered transcript owns the read acknowledgment.
|
||||
})());
|
||||
});
|
||||
self.addEventListener("message", event => {
|
||||
const value = event.data;
|
||||
if (value?.version !== 1 || !["sybil-agent-read", "sybil-agent-logout"].includes(value.type)) return;
|
||||
event.waitUntil(serial(async () => {
|
||||
// A same-origin WindowClient is required; no remote/message-channel authority.
|
||||
const client = event.source?.id ? await self.clients.get(event.source.id) : null;
|
||||
if (!client || client.type !== "window" || new URL(client.url).origin !== self.location.origin) return;
|
||||
if (value.type === "sybil-agent-read") {
|
||||
if (!sourceId(value.sourceId) || !cursor(value.cursor)) return;
|
||||
const previous = await stored(value.sourceId).catch(() => null);
|
||||
const readCursor = cursor(previous?.readCursor) && BigInt(previous.readCursor) > BigInt(value.cursor) ? previous.readCursor : BigInt(value.cursor).toString();
|
||||
await save({ ...previous, sourceId: value.sourceId, readCursor }).catch(() => undefined);
|
||||
const notifications = await self.registration.getNotifications({ tag: `sybil-agent:${value.sourceId}` });
|
||||
for (const notification of notifications) {
|
||||
const data = notification.data;
|
||||
if (owned(data) && data.sourceId === value.sourceId && notification.tag === `sybil-agent:${value.sourceId}` && BigInt(data.cursor) <= BigInt(readCursor)) notification.close();
|
||||
}
|
||||
} else {
|
||||
for (const notification of await self.registration.getNotifications()) {
|
||||
if (owned(notification.data) && notification.tag === `sybil-agent:${notification.data.sourceId}`) notification.close();
|
||||
}
|
||||
}
|
||||
event.ports?.[0]?.postMessage({ ok: true });
|
||||
}));
|
||||
});
|
||||
self.addEventListener("pushsubscriptionchange", event => {
|
||||
// Re-registration needs a currently authenticated page. Never cache its token here.
|
||||
event.waitUntil(announce("sybil-push-subscription-changed"));
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
|
||||
import { ChatComposer } from "@/components/chat/chat-composer";
|
||||
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
|
||||
import { AgentConversation } from "@/components/agent/agent-conversation";
|
||||
import { AgentNotifications } from "@/components/agent/agent-notifications";
|
||||
import { SearchResultsPanel } from "@/components/search/search-results-panel";
|
||||
import { SearchMatchLabel } from "@/components/search/search-match-label";
|
||||
import { SybilCharacter } from "@/components/sybil-character";
|
||||
@@ -965,6 +966,7 @@ export default function App() {
|
||||
isCheckingSession,
|
||||
isSigningIn,
|
||||
isAuthenticated,
|
||||
authMode,
|
||||
authError,
|
||||
handleAuthFailure: baseHandleAuthFailure,
|
||||
handleSignIn,
|
||||
@@ -3721,6 +3723,7 @@ export default function App() {
|
||||
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
|
||||
) : null}
|
||||
</div>
|
||||
{authMode === "token" ? <AgentNotifications onLogout={logout} /> : null}
|
||||
</aside>
|
||||
|
||||
<main
|
||||
|
||||
@@ -238,31 +238,6 @@
|
||||
font-size:9px;
|
||||
}
|
||||
|
||||
.agent-room__jump {
|
||||
position:absolute;
|
||||
z-index:2;
|
||||
left:50%;
|
||||
bottom:14px;
|
||||
transform:translateX(-50%);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:7px;
|
||||
white-space:nowrap;
|
||||
padding:9px 14px;
|
||||
border:1px solid hsl(266 76% 72%/.43);
|
||||
border-radius:99px;
|
||||
background:hsl(256 46% 20%/.96);
|
||||
color:hsl(263 60% 93%);
|
||||
box-shadow:0 5px 24px hsl(240 70% 2%/.5);
|
||||
font-size:11px;
|
||||
cursor:pointer;
|
||||
backdrop-filter:blur(14px);
|
||||
}
|
||||
|
||||
.agent-room__jump:hover {
|
||||
background:hsl(261 48% 27%);
|
||||
}
|
||||
|
||||
.agent-room__composer-area {
|
||||
flex:none;
|
||||
border-top:1px solid hsl(253 40% 40%/.14);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ArrowDown, Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||
import { Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||
import type { Message } from "@/lib/api";
|
||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
|
||||
@@ -98,7 +98,6 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||
const typingHeightRef = useRef(0);
|
||||
const historyReadyRef = useRef(!room.isLoading);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -151,7 +150,6 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
const jumpToLatest = () => {
|
||||
pinnedRef.current = true;
|
||||
anchorRef.current = null;
|
||||
setAtBottom(true);
|
||||
if (scrollerRef.current) {
|
||||
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
||||
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
||||
@@ -305,7 +303,6 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
|
||||
const bottom = distanceFromBottom() <= 48;
|
||||
pinnedRef.current = bottom;
|
||||
setAtBottom(current => current === bottom ? current : bottom);
|
||||
rememberAnchor();
|
||||
if (bottom) scheduleRead();
|
||||
}}
|
||||
@@ -317,7 +314,6 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
|
||||
</div>
|
||||
|
||||
<div className="agent-room__composer-area"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { Bell } from "lucide-preact";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, observeAgentPush, refreshAgentBrowserPush } from "@/lib/agent-push-browser";
|
||||
|
||||
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
|
||||
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
|
||||
useEffect(() => observeAgentPush(setState), []);
|
||||
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>
|
||||
<div className="space-y-2 pb-1 pt-2">
|
||||
<p>Notifications include Agent message text and may appear on your lock screen.</p>
|
||||
<p role="status">{state.message}</p>
|
||||
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state.enabled ? <Button className="h-8 text-xs" variant="secondary" disabled={state.busy} onClick={() => void disableAgentBrowserPush()}>Turn off notifications</Button>
|
||||
: <Button className="h-8 text-xs" variant="secondary" disabled={!state.canEnable} onClick={() => void enableAgentBrowserPush()}>Enable notifications</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>
|
||||
</div>
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||
import {
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentReadState,
|
||||
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
type AgentActivity, type AgentRoomState,
|
||||
} from "@/lib/agent-room";
|
||||
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
|
||||
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
|
||||
import { getAgentPresenceSessionId, syncAgentNotificationRead } from "@/lib/agent-push-browser";
|
||||
|
||||
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
|
||||
|
||||
@@ -47,7 +48,8 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
},
|
||||
});
|
||||
useEffect(() => () => uploadQueue.current?.dispose(), []);
|
||||
const mark = useRef<((cursor: string) => void) | null>(null);
|
||||
const mark = useRef<((cursor: string, sourceId: string | null | undefined) => void) | null>(null);
|
||||
const sourceMismatch = useRef(false);
|
||||
const [outboxRevision, setOutboxRevision] = useState(0);
|
||||
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
|
||||
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
|
||||
@@ -80,11 +82,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
sourceMismatch.current = false;
|
||||
client.current = null; mark.current = null; setAttachmentCapability(undefined);
|
||||
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let sourceChanged = false;
|
||||
let socket: WebSocket | undefined;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -100,7 +104,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
client.current = api;
|
||||
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
|
||||
publish: (next, isTyping) => {
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
outbox.current?.reconcile(next);
|
||||
current.current = next;
|
||||
setView({ room: next, isTyping });
|
||||
@@ -108,35 +112,36 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
});
|
||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
||||
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
|
||||
// writer must observe that acknowledged cursor immediately to avoid a
|
||||
// repeated PUT loop while the presenter waits to publish its batch.
|
||||
let acknowledgedReadCursor = current.current.readCursor;
|
||||
const readWriter = createAgentReadWriter({
|
||||
current: () => acknowledgedReadCursor,
|
||||
save: cursor => api.markRead(cursor, controller.signal),
|
||||
const readSession = createAgentReadSession({
|
||||
initial: current.current,
|
||||
save: (cursor, sourceId) => api.markRead(cursor, controller.signal, sourceId),
|
||||
invalidated: failure => {
|
||||
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
||||
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
presenter.dispose(); controller.abort(); socket?.close();
|
||||
setError(failure.message); setConnection("offline"); setLoading(false);
|
||||
},
|
||||
apply: read => {
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
void syncAgentNotificationRead(read);
|
||||
if (active && !sourceChanged) presenter.update(state => applyAgentReadState(state, read));
|
||||
},
|
||||
});
|
||||
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
|
||||
mark.current = (cursor, sourceId) => { void readSession.request(cursor, sourceId).catch(reportError); };
|
||||
|
||||
const refreshRead = async () => {
|
||||
const read = await api.readState(controller.signal);
|
||||
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
|
||||
if (active) presenter.update(state => applyAgentReadState(state, read));
|
||||
if (active && !sourceChanged) readSession.receive(read);
|
||||
};
|
||||
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
|
||||
if (active) presenter.push({ type: "messages", ...page }, { replay });
|
||||
if (active && !sourceChanged) presenter.push({ type: "messages", ...page }, { replay });
|
||||
}, controller.signal);
|
||||
const refreshActivity = async () => {
|
||||
const descriptor = await api.descriptor(controller.signal);
|
||||
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (!active || authFailed) return;
|
||||
if (!active || authFailed || sourceChanged) return;
|
||||
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
|
||||
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
|
||||
socketAuthenticated = false;
|
||||
@@ -144,12 +149,12 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
socket = next;
|
||||
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
|
||||
next.onopen = () => {
|
||||
if (!active || socket !== next) { next.close(); return; }
|
||||
if (!active || sourceChanged || socket !== next) { next.close(); return; }
|
||||
lastPacket = Date.now();
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
|
||||
next.send(agentAuthFrame(getAuthToken(), current.current.cursor, getAgentPresenceSessionId()));
|
||||
};
|
||||
next.onmessage = ({ data }) => {
|
||||
if (!active || socket !== next) return;
|
||||
if (!active || sourceChanged || socket !== next) return;
|
||||
lastPacket = Date.now();
|
||||
try {
|
||||
const event = parseAgentRoomEvent(String(data));
|
||||
@@ -159,6 +164,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
|
||||
setConnection("connected"); setError(null); return;
|
||||
}
|
||||
if (event.type === "read_state") {
|
||||
readSession.receive(event); return;
|
||||
}
|
||||
presenter.push(event);
|
||||
} catch (failure) {
|
||||
reportError(failure);
|
||||
@@ -167,9 +175,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
next.close();
|
||||
}
|
||||
};
|
||||
next.onerror = () => { if (active) setConnection("reconnecting"); };
|
||||
next.onerror = () => { if (active && !sourceChanged) setConnection("reconnecting"); };
|
||||
next.onclose = event => {
|
||||
if (!active || socket !== next) return;
|
||||
if (!active || sourceChanged || socket !== next) return;
|
||||
clearTimeout(socketTimeout); socketAuthenticated = false;
|
||||
if (event.code === 4401) {
|
||||
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
|
||||
@@ -186,21 +194,21 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
// The first start begins at zero, not the latest page. Unread counts
|
||||
// therefore include older public replies even after a long absence.
|
||||
await catchUp(!started);
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
presenter.flush();
|
||||
started = true; setLoading(false); connect();
|
||||
} catch (failure) {
|
||||
if (!active) return;
|
||||
if (!active || sourceChanged) return;
|
||||
reportError(failure); setLoading(false); setConnection("offline");
|
||||
startupTimer = setTimeout(() => { void start(); }, 10_000);
|
||||
}
|
||||
};
|
||||
const maintain = async (foreground = false) => {
|
||||
if (!active || !started || maintaining || authFailed) return;
|
||||
if (!active || sourceChanged || !started || maintaining || authFailed) return;
|
||||
maintaining = true;
|
||||
try {
|
||||
await refreshRead();
|
||||
await readWriter.flush();
|
||||
await readSession.flush();
|
||||
if (!socketAuthenticated) {
|
||||
await Promise.all([catchUp(), refreshActivity()]);
|
||||
} else if (foreground) await catchUp();
|
||||
@@ -217,7 +225,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
document.addEventListener("visibilitychange", focus);
|
||||
void start();
|
||||
return () => {
|
||||
active = false; controller.abort();
|
||||
active = false; readSession.cancel(); controller.abort();
|
||||
presenter.dispose();
|
||||
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||
@@ -228,12 +236,13 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
}, [enabled, revision, update]);
|
||||
|
||||
const renderedCursor = room.cursor;
|
||||
const renderedSourceId = room.sourceId;
|
||||
const markRead = useCallback(() => {
|
||||
if (!enabled || isLoading) return;
|
||||
// This callback belongs to this rendered snapshot. Reading current.current
|
||||
// here would incorrectly mark a socket message that has not rendered yet.
|
||||
mark.current?.(renderedCursor);
|
||||
}, [enabled, isLoading, renderedCursor]);
|
||||
mark.current?.(renderedCursor, renderedSourceId);
|
||||
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
|
||||
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
||||
@@ -250,7 +259,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||
await outbox.current.retry(messageId);
|
||||
}, [enabled]);
|
||||
const retry = useCallback(() => { setRevision(value => value + 1); }, []);
|
||||
const retry = useCallback(() => {
|
||||
if (sourceMismatch.current) { window.location.reload(); return; }
|
||||
setRevision(value => value + 1);
|
||||
}, []);
|
||||
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
|
||||
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
|
||||
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
|
||||
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
|
||||
@@ -47,6 +48,7 @@ export function useSessionAuth() {
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string | null) => {
|
||||
void revokeAgentBrowserPush();
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(message ? normalizeAuthError(message) : null);
|
||||
@@ -69,7 +71,8 @@ export function useSessionAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
await revokeAgentBrowserPush();
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -77,6 +80,18 @@ export function useSessionAuth() {
|
||||
setAuthError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || authMode !== "token") return;
|
||||
return startAgentBrowserSession();
|
||||
}, [isAuthenticated, authMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Signing out in one tab retires the others instead of using a stale token.
|
||||
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
|
||||
window.addEventListener("storage", signedOut);
|
||||
return () => window.removeEventListener("storage", signedOut);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredToken() ?? getConfiguredToken();
|
||||
void (async () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { getApiBaseUrl, getAuthToken } from "./api";
|
||||
import { ensureServiceWorker } from "../pwa";
|
||||
import {
|
||||
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
|
||||
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
|
||||
} from "./agent-push.ts";
|
||||
|
||||
let sessionId: string | undefined;
|
||||
function browserUuid() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
|
||||
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
|
||||
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
|
||||
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
|
||||
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
|
||||
async function consentScope() {
|
||||
const token = getAuthToken(), base = new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href;
|
||||
const digest = await agentPushAuthorityScope(base, token);
|
||||
if (token !== getAuthToken() || base !== new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href) return null;
|
||||
return digest;
|
||||
}
|
||||
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 => ({
|
||||
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
|
||||
});
|
||||
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
|
||||
const support = () => {
|
||||
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
|
||||
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||
if (ios && !standalone) return "install" as const;
|
||||
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !!navigator.locks
|
||||
? "supported" as const : "unsupported" as const;
|
||||
};
|
||||
async function postWorker(message: unknown) {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// No registration from background sync. A page worker can safely receive only sanitized IDs.
|
||||
const registration = await navigator.serviceWorker.getRegistration("/");
|
||||
const worker = registration?.active;
|
||||
if (!worker) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const channel = new MessageChannel(), timeout = setTimeout(() => { channel.port1.close(); resolve(); }, 1500);
|
||||
channel.port1.onmessage = () => { clearTimeout(timeout); channel.port1.close(); resolve(); };
|
||||
worker.postMessage(message, [channel.port2]);
|
||||
});
|
||||
}
|
||||
export async function syncAgentNotificationRead(read: { sourceId?: string | null; cursor: string }) {
|
||||
if (!getAuthToken() || !agentPushSourceId(read.sourceId) || !agentPushCursor(read.cursor)) return;
|
||||
await postWorker({ type: "sybil-agent-read", version: 1, sourceId: read.sourceId, cursor: BigInt(read.cursor).toString() }).catch(() => undefined);
|
||||
}
|
||||
function decodePublicKey(value: string): Uint8Array<ArrayBuffer> {
|
||||
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0));
|
||||
}
|
||||
const controller = createAgentPushController({
|
||||
api, read: readConsent, scope: consentScope,
|
||||
write: value => { localStorage.setItem(AGENT_PUSH_STORAGE_KEY, JSON.stringify(value)); },
|
||||
remove: () => localStorage.removeItem(AGENT_PUSH_STORAGE_KEY),
|
||||
uuid: browserUuid,
|
||||
lock: async operation => {
|
||||
if (!navigator.locks) return await operation(); // Cleanup only; support() disables opt-in.
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 12000);
|
||||
try { return await navigator.locks.request("sybil-agent-push-installation-v1", { signal: controller.signal }, operation); }
|
||||
finally { clearTimeout(timeout); }
|
||||
},
|
||||
changed: state => { for (const listener of listeners) listener(state); },
|
||||
platform: {
|
||||
support, permission: () => "Notification" in window ? Notification.permission : "denied",
|
||||
requestPermission: () => Notification.requestPermission(),
|
||||
subscription: async () => {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
|
||||
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
|
||||
return subscription ? subscriptionHandle(subscription) : null;
|
||||
},
|
||||
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
|
||||
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
|
||||
},
|
||||
});
|
||||
export const getAgentPushState = () => controller.state();
|
||||
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
|
||||
export const enableAgentBrowserPush = () => controller.enable();
|
||||
export const disableAgentBrowserPush = () => controller.disable();
|
||||
export const refreshAgentBrowserPush = () => controller.refresh();
|
||||
|
||||
const presence = createAgentPresenceReporter({
|
||||
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
|
||||
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
|
||||
});
|
||||
let stopSession: (() => void) | undefined;
|
||||
export async function revokeAgentBrowserPush() {
|
||||
stopSession?.();
|
||||
// Revoke/mark the durable tombstone before the caller clears the bearer token.
|
||||
const id = getAgentPresenceSessionId();
|
||||
await Promise.allSettled([readConsent() ? controller.disable() : postWorker({ type: "sybil-agent-logout", version: 1 }), ...(id ? [api.retirePresence(id)] : [])]);
|
||||
}
|
||||
/** Mounted from shared authentication so /search, every chat and every tab participate. */
|
||||
export function startAgentBrowserSession() {
|
||||
stopSession?.();
|
||||
let active = true, refreshing = false, interactionTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const report = (keepalive = false, hidden = false) => { if (active) void presence.report(keepalive, hidden).catch(() => undefined); };
|
||||
const refresh = async () => {
|
||||
if (!active || refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
await controller.refresh();
|
||||
if (active && document.visibilityState === "visible") {
|
||||
const read = await api.readState();
|
||||
if (active && read) await syncAgentNotificationRead(read);
|
||||
}
|
||||
} catch { /* Absence of an acknowledged source/read never closes notifications. */ }
|
||||
finally { refreshing = false; }
|
||||
};
|
||||
const attention = () => { report(); if (document.visibilityState === "visible") void refresh(); };
|
||||
const interaction = (event: Event) => {
|
||||
if (!event.isTrusted) return;
|
||||
presence.interact();
|
||||
if (!interactionTimer) {
|
||||
report();
|
||||
interactionTimer = setTimeout(() => { interactionTimer = undefined; report(); }, 1000);
|
||||
}
|
||||
};
|
||||
const hide = () => report(true, true);
|
||||
const storage = (event: StorageEvent) => { if (event.key === AGENT_PUSH_STORAGE_KEY) void refresh(); };
|
||||
const workerMessage = (event: MessageEvent) => { if (["sybil-push-subscription-changed", "sybil-push-shown"].includes(event.data?.type)) void refresh(); };
|
||||
const heartbeat = setInterval(() => { report(); void refresh(); }, 15000);
|
||||
window.addEventListener("focus", attention); window.addEventListener("blur", attention);
|
||||
window.addEventListener("pageshow", attention); window.addEventListener("online", attention);
|
||||
window.addEventListener("pagehide", hide); window.addEventListener("storage", storage);
|
||||
document.addEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.addEventListener(type, interaction, { passive: true, capture: true });
|
||||
navigator.serviceWorker?.addEventListener("message", workerMessage);
|
||||
report(); void refresh();
|
||||
const stop = () => {
|
||||
if (!active) return;
|
||||
active = false; clearInterval(heartbeat); clearTimeout(interactionTimer);
|
||||
window.removeEventListener("focus", attention); window.removeEventListener("blur", attention);
|
||||
window.removeEventListener("pageshow", attention); window.removeEventListener("online", attention);
|
||||
window.removeEventListener("pagehide", hide); window.removeEventListener("storage", storage);
|
||||
document.removeEventListener("visibilitychange", attention);
|
||||
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.removeEventListener(type, interaction, true);
|
||||
navigator.serviceWorker?.removeEventListener("message", workerMessage);
|
||||
if (stopSession === stop) stopSession = undefined;
|
||||
// On unmount preserve the final lease until expiry; a late PUT cannot undo a DELETE.
|
||||
};
|
||||
stopSession = stop;
|
||||
return stop;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
/** 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_CHANGED_EVENT = "sybil-agent-push-changed";
|
||||
export const agentPushCursor = (value: unknown): value is string => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
|
||||
export const agentPushUuid = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
export const agentPushSourceId = agentPushUuid;
|
||||
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
|
||||
export type AgentPushConfig = {
|
||||
version: 1; enabled: boolean; webPushAvailable: boolean; apnsAvailable: boolean;
|
||||
sourceId: string | null; quarantined: boolean; vapidPublicKey?: string;
|
||||
policy: { presenceHeartbeatMs: number; presenceLeaseMs: number };
|
||||
};
|
||||
export function parseAgentPushConfig(value: unknown): AgentPushConfig {
|
||||
const config = object(value), policy = object(config?.policy);
|
||||
if (!config || config.version !== 1 || typeof config.enabled !== "boolean" || typeof config.webPushAvailable !== "boolean"
|
||||
|| typeof config.apnsAvailable !== "boolean" || (config.sourceId !== null && !agentPushSourceId(config.sourceId))
|
||||
|| (config.quarantined !== undefined && typeof config.quarantined !== "boolean") || !policy
|
||||
|| typeof policy.presenceHeartbeatMs !== "number" || policy.presenceHeartbeatMs < 5000 || policy.presenceHeartbeatMs > 30000
|
||||
|| typeof policy.presenceLeaseMs !== "number" || policy.presenceLeaseMs < 30000 || policy.presenceLeaseMs > 120000
|
||||
|| (config.vapidPublicKey !== undefined && (typeof config.vapidPublicKey !== "string" || !/^[A-Za-z0-9_-]{87}$/.test(config.vapidPublicKey)))) {
|
||||
throw new Error("Notification configuration is unavailable.");
|
||||
}
|
||||
return { ...config, quarantined: config.quarantined === true } as AgentPushConfig;
|
||||
}
|
||||
export type AgentPresence = { sequence: number; visibility: "visible" | "hidden"; focused: boolean; interacted: boolean };
|
||||
/** Nonsecret identity only; the bearer is never retained with the digest. */
|
||||
export async function agentPushAuthorityScope(baseUrl: string, token: string | null, origin = globalThis.location?.href): Promise<string | null> {
|
||||
if (!token || !globalThis.crypto?.subtle) return null;
|
||||
const base = new URL(baseUrl.replace(/\/+$/, ""), origin).href;
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([base, token])));
|
||||
return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
export class AgentPushClient {
|
||||
private options: { baseUrl: string; token: () => string | null; fetch?: typeof globalThis.fetch };
|
||||
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> {
|
||||
const token = this.options.token();
|
||||
if (!token) throw new Error("Sign in to manage notifications.");
|
||||
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 8000);
|
||||
try {
|
||||
// Capture the bearer before hashing, then use that exact bearer for the
|
||||
// request. A concurrent sign-in must not turn old-authority cleanup into
|
||||
// a new-authority DELETE whose204 falsely clears a durable tombstone.
|
||||
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}`, {
|
||||
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,
|
||||
});
|
||||
// Never echo server/provider bodies: they may contain endpoint or key material.
|
||||
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();
|
||||
} catch { throw new Error("Notification service is unavailable. Try again when connected."); }
|
||||
finally { clearTimeout(timeout); }
|
||||
}
|
||||
async config() { return parseAgentPushConfig(await this.request("/push/config")); }
|
||||
async register(id: string, subscription: PushSubscriptionJSON, expectedScope?: string) {
|
||||
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));
|
||||
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) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid notification installation.");
|
||||
await this.request(`/push/installations/${id}`, "DELETE", undefined, false, expectedScope);
|
||||
}
|
||||
async presence(id: string, presence: AgentPresence, keepalive = false) {
|
||||
if (!agentPushUuid(id)) throw new Error("Invalid attention session.");
|
||||
await this.request(`/presence/${id}`, "PUT", presence, keepalive);
|
||||
}
|
||||
async retirePresence(id: string) { if (agentPushUuid(id)) await this.request(`/presence/${id}`, "DELETE", undefined, true); }
|
||||
async readState() {
|
||||
const value = object(await this.request("/read-state"));
|
||||
if (agentPushSourceId(value?.sourceId) && agentPushCursor(value?.cursor)) return { sourceId: value.sourceId, cursor: BigInt(value.cursor).toString() };
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_AGENT_PUSH_REVOCATIONS = 16;
|
||||
export type AgentPushRevocation = { installationId: string; scope: string };
|
||||
export type AgentPushConsent = { version: 1; installationId: string; revision: string; scope?: string; sourceId?: string; enabled: boolean; revocationPending: boolean; pendingRevocations?: AgentPushRevocation[] };
|
||||
export function parseAgentPushConsent(raw: string | null): AgentPushConsent | null {
|
||||
try {
|
||||
const value = object(JSON.parse(raw || "null"));
|
||||
if (!(value?.version === 1 && agentPushUuid(value.installationId) && agentPushUuid(value.revision)
|
||||
&& (value.scope === undefined || (typeof value.scope === "string" && /^[a-f0-9]{64}$/.test(value.scope)))
|
||||
&& (value.pendingRevocations === undefined || (Array.isArray(value.pendingRevocations) && value.pendingRevocations.length <= MAX_AGENT_PUSH_REVOCATIONS
|
||||
&& value.pendingRevocations.every(item => agentPushUuid(object(item)?.installationId) && typeof object(item)?.scope === "string" && /^[a-f0-9]{64}$/.test(String(object(item)?.scope)))))
|
||||
&& typeof value.enabled === "boolean" && typeof value.revocationPending === "boolean")) return null;
|
||||
// Keep the installation identity for cleanup when migrating old/unbound
|
||||
// consent. An absent or malformed source can never authorize registration.
|
||||
return { ...value, sourceId: agentPushSourceId(value.sourceId) ? value.sourceId : undefined } as AgentPushConsent;
|
||||
} catch { return null; }
|
||||
}
|
||||
export type AgentPushView = { busy: boolean; enabled: boolean; canEnable: boolean; message: string; error: string | null };
|
||||
export type PushSubscriptionHandle = Pick<PushSubscription, "toJSON" | "unsubscribe">;
|
||||
type PushPlatform = {
|
||||
support: () => "supported" | "unsupported" | "install";
|
||||
permission: () => NotificationPermission;
|
||||
requestPermission: () => Promise<NotificationPermission>;
|
||||
subscription: () => Promise<PushSubscriptionHandle | null>;
|
||||
subscribe: (publicKey: string) => Promise<PushSubscriptionHandle>;
|
||||
clearNotifications: () => Promise<void>;
|
||||
};
|
||||
/** Explicit consent survives restarts; a revoke tombstone always wins over an older enable. */
|
||||
export function createAgentPushController(options: {
|
||||
api: Pick<AgentPushClient, "config" | "register" | "revoke">;
|
||||
platform: PushPlatform; read: () => AgentPushConsent | null; write: (consent: AgentPushConsent) => void; remove?: () => void;
|
||||
scope: () => Promise<string | null>;
|
||||
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 scope: string | null = null;
|
||||
let registeredSubscription: string | null = null;
|
||||
const currentConsent = () => localRevocation || options.read();
|
||||
const matchesContext = (record: AgentPushConsent) => !!scope && record.scope === scope
|
||||
&& agentPushSourceId(record.sourceId) && record.sourceId === config?.sourceId;
|
||||
const transportAvailable = () => !!config?.enabled && config.webPushAvailable && !config.quarantined && agentPushSourceId(config.sourceId);
|
||||
const refreshContext = async () => {
|
||||
const nextScope = await options.scope(), nextConfig = await options.api.config();
|
||||
if (nextScope !== await options.scope()) { scope = null; config = null; throw new Error("Notification authority changed."); }
|
||||
scope = nextScope; config = nextConfig;
|
||||
};
|
||||
const recheckContext = async (record: AgentPushConsent) => {
|
||||
await refreshContext();
|
||||
return matchesContext(record) && transportAvailable();
|
||||
};
|
||||
const state = (): AgentPushView => {
|
||||
const consent = currentConsent(), support = options.platform.support(), permission = options.platform.permission();
|
||||
const available = !!scope && transportAvailable();
|
||||
const cleanupFull = (consent?.pendingRevocations?.length || 0) >= MAX_AGENT_PUSH_REVOCATIONS;
|
||||
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."
|
||||
: support === "unsupported" ? "This browser does not support notifications here. Use a supported browser over HTTPS."
|
||||
: 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."
|
||||
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
|
||||
: !available ? "Notifications are not configured on this server yet."
|
||||
: enabled ? "Notifications are on for this browser."
|
||||
: "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 };
|
||||
};
|
||||
const publish = () => options.changed(state());
|
||||
const write = (consent: AgentPushConsent) => { options.write(consent); publish(); };
|
||||
const consent = (): AgentPushConsent => currentConsent() || { version: 1, installationId: options.uuid(), revision: options.uuid(), ...(scope ? { scope } : {}), enabled: false, revocationPending: false };
|
||||
const retireAtOriginalAuthority = async (record: Pick<AgentPushConsent, "installationId" | "scope">) => {
|
||||
if (!record.scope || record.scope !== await options.scope()) return false;
|
||||
await options.api.revoke(record.installationId, record.scope);
|
||||
return true;
|
||||
};
|
||||
const retryRevocations = async () => {
|
||||
const original = currentConsent();
|
||||
if (!original) return;
|
||||
for (const pending of original.pendingRevocations || []) {
|
||||
if (currentConsent()?.revision !== original.revision) return;
|
||||
const removed = await retireAtOriginalAuthority(pending).catch(() => false);
|
||||
const latest = currentConsent();
|
||||
if (removed && latest?.revision === original.revision) {
|
||||
write({ ...latest, pendingRevocations: (latest.pendingRevocations || []).filter(item => item.installationId !== pending.installationId || item.scope !== pending.scope) });
|
||||
}
|
||||
}
|
||||
};
|
||||
const revoke = async (record: AgentPushConsent) => {
|
||||
let serverRemoved = false, subscriptionRemoved = false;
|
||||
await Promise.all([
|
||||
retireAtOriginalAuthority(record).then(removed => { serverRemoved = removed; }).catch(() => undefined),
|
||||
options.platform.subscription().then(async subscription => { subscriptionRemoved = !subscription || await subscription.unsubscribe(); }).catch(() => undefined),
|
||||
options.platform.clearNotifications().catch(() => undefined),
|
||||
]);
|
||||
const authority = await options.scope().catch(() => null);
|
||||
const latest = currentConsent();
|
||||
if (latest?.revision === record.revision && subscriptionRemoved) {
|
||||
const queue = latest.pendingRevocations || [];
|
||||
const archived = !!record.scope && queue.some(item => item.installationId === record.installationId && item.scope === record.scope);
|
||||
const differentAuthority = record.scope && record.scope !== authority;
|
||||
if (serverRemoved || archived) {
|
||||
const removed = { ...latest, revocationPending: false };
|
||||
try { write(removed); localRevocation = null; } catch { localRevocation = removed; }
|
||||
} else if (differentAuthority && queue.length < MAX_AGENT_PUSH_REVOCATIONS) {
|
||||
// Keep only the old nonsecret authority digest and ID. A current-server
|
||||
// DELETE/204 is never evidence that a different server retired this ID.
|
||||
const archivedRecord = { ...latest, revocationPending: false,
|
||||
pendingRevocations: [...queue, { installationId: record.installationId, scope: record.scope! }] };
|
||||
try { write(archivedRecord); localRevocation = null; } catch { localRevocation = latest; }
|
||||
}
|
||||
}
|
||||
};
|
||||
const invalidate = async (record: AgentPushConsent) => {
|
||||
if (currentConsent()?.revision !== record.revision) {
|
||||
await retireAtOriginalAuthority(record).catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const pending = { ...record, enabled: false, revocationPending: true, revision: options.uuid() };
|
||||
registeredSubscription = null;
|
||||
try { write(pending); } catch { localRevocation = pending; }
|
||||
await revoke(pending);
|
||||
};
|
||||
const disable = async () => {
|
||||
error = null;
|
||||
const record = { ...consent(), enabled: false, revocationPending: true, revision: options.uuid() };
|
||||
// Synchronous durable intent is necessary before logout clears page credentials.
|
||||
registeredSubscription = null;
|
||||
try { write(record); }
|
||||
catch {
|
||||
localRevocation = record;
|
||||
// Some browsers reject setItem when full but still permit removal.
|
||||
try { options.remove?.(); } catch { /* Unsubscribe/revoke below still run. */ }
|
||||
error = "Browser storage is unavailable. Check that notifications are off in browser settings.";
|
||||
}
|
||||
busy = true; publish();
|
||||
try { await options.lock(() => revoke(record)); }
|
||||
catch {
|
||||
// A stuck tab must not prevent logout cleanup. The server permanently
|
||||
// retires this UUID, so cleanup remains safe without acquiring that lock.
|
||||
await revoke(record).catch(() => undefined);
|
||||
error = "Notifications are off here. Removal will retry when connected.";
|
||||
}
|
||||
finally { busy = false; publish(); }
|
||||
};
|
||||
const refresh = async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
await refreshContext();
|
||||
await options.lock(async () => {
|
||||
await retryRevocations();
|
||||
let record = currentConsent();
|
||||
if (!record) return;
|
||||
if (record.revocationPending) { await revoke(record); return; }
|
||||
if (!record.enabled) return;
|
||||
if (!matchesContext(record)) { await invalidate(record); return; }
|
||||
const subscription = await options.platform.subscription();
|
||||
if (currentConsent()?.revision !== record.revision) return;
|
||||
if (options.platform.permission() !== "granted" || !subscription) {
|
||||
record = { ...record, enabled: false, revocationPending: true, revision: options.uuid() }; write(record); await revoke(record); return;
|
||||
}
|
||||
if (transportAvailable()) {
|
||||
const serialized = JSON.stringify(subscription.toJSON());
|
||||
if (registeredSubscription !== serialized) {
|
||||
const matching = await recheckContext(record);
|
||||
if (currentConsent()?.revision !== record.revision) return;
|
||||
if (!matching) { await invalidate(record); return; }
|
||||
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
||||
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
||||
// A source replacement may complete while the PUT is in flight.
|
||||
// Never retain that registration without a fresh source check.
|
||||
try {
|
||||
const stillMatching = await recheckContext(record);
|
||||
if (currentConsent()?.revision !== record.revision) { await retireAtOriginalAuthority(record); return; }
|
||||
if (!stillMatching) { await invalidate(record); return; }
|
||||
} catch { await invalidate(record); throw new Error("Notification service is unavailable."); }
|
||||
registeredSubscription = serialized;
|
||||
}
|
||||
}
|
||||
});
|
||||
error = null;
|
||||
} catch { error = "Notification service is unavailable. Try again when connected."; }
|
||||
publish();
|
||||
};
|
||||
const enable = async () => {
|
||||
if (!state().canEnable || state().enabled || busy || !config?.vapidPublicKey || !scope || !agentPushSourceId(config.sourceId)) return;
|
||||
error = null;
|
||||
// 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.
|
||||
const record: AgentPushConsent = { version: 1, installationId: options.uuid(), revision: options.uuid(), scope, sourceId: config.sourceId,
|
||||
enabled: false, revocationPending: false, pendingRevocations: currentConsent()?.pendingRevocations || [] };
|
||||
const publicKey = config.vapidPublicKey;
|
||||
try { options.write(record); localRevocation = null; } catch { error = "Allow browser storage before enabling notifications."; publish(); return; }
|
||||
// Must execute in the original click stack, before locks, worker startup or fetches.
|
||||
let permission: Promise<NotificationPermission>;
|
||||
try { permission = options.platform.requestPermission(); }
|
||||
catch { error = "Notifications could not be requested. Check browser settings and try again."; publish(); return; }
|
||||
busy = true; publish();
|
||||
try {
|
||||
if (await permission !== "granted") { error = "Notifications were not allowed. You can change this in browser settings."; return; }
|
||||
await options.lock(async () => {
|
||||
const unchanged = () => options.read()?.revision === record.revision;
|
||||
if (!unchanged()) return;
|
||||
const matching = await recheckContext(record);
|
||||
if (!unchanged()) return;
|
||||
if (!matching || config?.vapidPublicKey !== publicKey) { await invalidate(record); return; }
|
||||
const subscription = await options.platform.subscription() || await options.platform.subscribe(publicKey);
|
||||
if (!unchanged()) { await subscription.unsubscribe(); return; }
|
||||
// A tombstone also covers a lost registration response or failed local storage write.
|
||||
const pending = { ...record, enabled: false, revocationPending: true }; write(pending);
|
||||
try {
|
||||
const matchingBeforeRegister = await recheckContext(record);
|
||||
if (!unchanged() || !matchingBeforeRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||
await options.api.register(record.installationId, subscription.toJSON(), record.scope);
|
||||
if (!unchanged()) { await retireAtOriginalAuthority(record); await subscription.unsubscribe(); return; }
|
||||
const matchingAfterRegister = await recheckContext(record);
|
||||
if (!unchanged() || !matchingAfterRegister || config?.vapidPublicKey !== publicKey) { await revoke(pending); return; }
|
||||
write({ ...record, enabled: true, revocationPending: false });
|
||||
registeredSubscription = JSON.stringify(subscription.toJSON());
|
||||
} catch {
|
||||
await revoke(pending);
|
||||
throw new Error("Notifications could not be enabled. Try again when connected.");
|
||||
}
|
||||
});
|
||||
} catch { error = "Notifications could not be enabled. Try again when connected."; }
|
||||
finally { busy = false; publish(); }
|
||||
};
|
||||
return { state, refresh, enable, disable };
|
||||
}
|
||||
|
||||
/** Independent ordered lease per document, including focused-but-idle and recent input. */
|
||||
export function createAgentPresenceReporter(options: {
|
||||
snapshot: () => { visibility: "visible" | "hidden"; focused: boolean };
|
||||
send: (presence: AgentPresence, keepalive: boolean) => Promise<void>;
|
||||
}) {
|
||||
let sequence = 0, interaction = 0, acknowledgedInteraction = 0;
|
||||
return {
|
||||
interact() { interaction++; },
|
||||
async report(keepalive = false, hidden = false) {
|
||||
const snapshot = hidden ? { visibility: "hidden" as const, focused: false } : options.snapshot();
|
||||
const capturedInteraction = interaction;
|
||||
const presence = { ...snapshot, focused: snapshot.visibility === "visible" && snapshot.focused, sequence: ++sequence, interacted: capturedInteraction > acknowledgedInteraction };
|
||||
await options.send(presence, keepalive);
|
||||
acknowledgedInteraction = Math.max(acknowledgedInteraction, capturedInteraction);
|
||||
},
|
||||
};
|
||||
}
|
||||
+91
-13
@@ -1,5 +1,6 @@
|
||||
import type { Message } from "./api";
|
||||
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
|
||||
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
|
||||
|
||||
export type AgentActivity = {
|
||||
state: "idle" | "thinking" | "tool" | "waiting" | "error";
|
||||
@@ -19,15 +20,18 @@ export type AgentDescriptor = {
|
||||
id: string; title: string; model: string; activity: AgentActivity;
|
||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
|
||||
};
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null };
|
||||
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
|
||||
export type AgentRoomState = {
|
||||
conversationId: string | null; messages: Message[]; cursor: string;
|
||||
readCursor: string; activity: AgentActivity | null;
|
||||
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
||||
sourceId?: string | null;
|
||||
};
|
||||
export type AgentRoomEvent =
|
||||
| ({ type: "messages" } & AgentMessagesPage)
|
||||
| { type: "activity"; conversationId: string; activity: AgentActivity }
|
||||
| { type: "ready"; conversationId?: string }
|
||||
| ({ type: "read_state" } & AgentReadState)
|
||||
| { type: "pong" | "heartbeat" }
|
||||
| { type: "error"; error?: string; message?: string };
|
||||
|
||||
@@ -81,6 +85,17 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
||||
export const createAgentRoomState = (): AgentRoomState => ({
|
||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||
});
|
||||
export class AgentReadSourceChangedError extends Error {
|
||||
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
||||
}
|
||||
function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCursor" | "messages">, read: AgentReadState): string | null {
|
||||
if (read.sourceId != null && !agentPushSourceId(read.sourceId)) throw new AgentReadSourceChangedError();
|
||||
const incoming = read.sourceId ?? null;
|
||||
if (state.sourceId !== undefined && state.sourceId !== incoming) throw new AgentReadSourceChangedError();
|
||||
// A source cannot be attached retroactively to already rendered unknown history.
|
||||
if (state.sourceId === undefined && incoming !== null && (state.cursor !== "0" || state.readCursor !== "0" || state.messages.length > 0)) throw new AgentReadSourceChangedError();
|
||||
return incoming;
|
||||
}
|
||||
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
|
||||
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.
|
||||
@@ -88,10 +103,12 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
|
||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||
}
|
||||
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
const sourceId = readSource(state, read);
|
||||
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||
}
|
||||
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
|
||||
if (event.type === "messages") return applyAgentPage(state, event);
|
||||
if (event.type === "read_state") return applyAgentReadState(state, event);
|
||||
if (event.type !== "activity") return state;
|
||||
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
|
||||
return { ...state, conversationId: event.conversationId, activity: event.activity };
|
||||
@@ -110,7 +127,10 @@ export function agentDisplayMessages(state: AgentRoomState): Message[] {
|
||||
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
const event = object(JSON.parse(raw));
|
||||
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
|
||||
if (!["messages", "activity", "ready", "pong", "heartbeat", "error", "read_state"].includes(event.type)) return null;
|
||||
if (event.type === "read_state" && (!agentPushCursor(event.cursor)
|
||||
|| (event.updatedAt !== null && typeof event.updatedAt !== "string")
|
||||
|| (event.sourceId !== undefined && event.sourceId !== null && !agentPushSourceId(event.sourceId)))) throw new Error("Invalid Agent read frame.");
|
||||
if (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);
|
||||
@@ -123,8 +143,9 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|
||||
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
|
||||
return event as AgentRoomEvent;
|
||||
}
|
||||
export function agentAuthFrame(token: string | null, cursor: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
|
||||
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
|
||||
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
|
||||
clientKind: "web", ...(agentPushUuid(presenceSessionId) ? { presenceSessionId } : {}) });
|
||||
}
|
||||
|
||||
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
|
||||
@@ -146,10 +167,10 @@ export class AgentRoomClient {
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
|
||||
let value: unknown;
|
||||
try { value = await response.json(); }
|
||||
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
|
||||
catch { throw Object.assign(new Error(`Agent API returned ${response.status} without a JSON response.`), { status: response.status }); }
|
||||
if (!response.ok) {
|
||||
const error = object(value);
|
||||
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
|
||||
throw Object.assign(new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`), { status: response.status });
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
@@ -158,8 +179,8 @@ export class AgentRoomClient {
|
||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||
}
|
||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
|
||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
||||
}
|
||||
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
|
||||
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
|
||||
@@ -215,18 +236,75 @@ export function createAgentReadWriter(options: {
|
||||
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
|
||||
}) {
|
||||
let desired = "0";
|
||||
let stopped = false;
|
||||
let running: Promise<void> | undefined;
|
||||
const cancel = () => { stopped = true; desired = "0"; };
|
||||
const flush = (): Promise<void> => {
|
||||
if (stopped) return Promise.resolve();
|
||||
if (running) return running;
|
||||
running = (async () => {
|
||||
while (BigInt(desired) > BigInt(options.current())) {
|
||||
while (!stopped && BigInt(desired) > BigInt(options.current())) {
|
||||
const captured = desired;
|
||||
const saved = await options.save(captured);
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
if (stopped) return;
|
||||
try {
|
||||
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
options.apply(saved);
|
||||
// A rejected/source-mismatched acknowledgment must never become a PUT loop.
|
||||
if (BigInt(options.current()) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
|
||||
} catch (error) { cancel(); throw error; }
|
||||
}
|
||||
})().finally(() => { running = undefined; });
|
||||
return running;
|
||||
};
|
||||
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
|
||||
return { request(cursor: string) { if (!stopped) desired = advanceAgentCursor(desired, cursor); return flush(); }, flush, cancel };
|
||||
}
|
||||
|
||||
/** Pin reads to the source that owns this rendered room, including queued writes. */
|
||||
export function createAgentReadSession(options: {
|
||||
initial: AgentRoomState;
|
||||
save: (cursor: string, sourceId: string | null) => Promise<AgentReadState>;
|
||||
apply: (read: AgentReadState) => void;
|
||||
invalidated?: (error: AgentReadSourceChangedError) => void;
|
||||
}) {
|
||||
let binding = options.initial.sourceId, acknowledged = options.initial.readCursor;
|
||||
let failure: AgentReadSourceChangedError | undefined;
|
||||
const invalidate = () => {
|
||||
if (!failure) { failure = new AgentReadSourceChangedError(); writer.cancel(); options.invalidated?.(failure); }
|
||||
return failure;
|
||||
};
|
||||
const receive = (read: AgentReadState) => {
|
||||
if (failure) throw failure;
|
||||
try {
|
||||
const nextSource = readSource({ ...options.initial, sourceId: binding, readCursor: acknowledged }, read);
|
||||
const nextCursor = advanceAgentCursor(acknowledged, read.cursor);
|
||||
binding = nextSource; acknowledged = nextCursor;
|
||||
options.apply({ ...read, sourceId: binding });
|
||||
} catch (error) { if (error instanceof AgentReadSourceChangedError) throw invalidate(); throw error; }
|
||||
};
|
||||
const writer = createAgentReadWriter({
|
||||
current: () => acknowledged,
|
||||
save: async cursor => {
|
||||
if (failure || binding === undefined) throw invalidate();
|
||||
const source = binding;
|
||||
try { return await options.save(cursor, source); }
|
||||
catch (error) {
|
||||
if (error instanceof AgentReadSourceChangedError || object(error)?.status === 409) throw invalidate();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
apply: receive,
|
||||
});
|
||||
return {
|
||||
receive,
|
||||
request(cursor: string, renderedSourceId: string | null | undefined) {
|
||||
if (failure) return Promise.reject(failure);
|
||||
if (binding === undefined || renderedSourceId !== binding) return Promise.reject(invalidate());
|
||||
return writer.request(cursor);
|
||||
},
|
||||
flush: writer.flush,
|
||||
cancel: writer.cancel,
|
||||
sourceId: () => binding,
|
||||
readCursor: () => acknowledged,
|
||||
};
|
||||
}
|
||||
|
||||
+13
-6
@@ -1,12 +1,19 @@
|
||||
export async function ensureServiceWorker(): Promise<ServiceWorkerRegistration> {
|
||||
const registration = await navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" });
|
||||
// ready can otherwise remain pending forever after an install failure.
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error("The notification worker did not become ready. Reload and try again.")), 15000);
|
||||
void navigator.serviceWorker.ready.then(ready => { clearTimeout(timeout); resolve(ready); }, () => {
|
||||
clearTimeout(timeout); reject(new Error("The notification worker is unavailable."));
|
||||
});
|
||||
void registration.update().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export function registerServiceWorker() {
|
||||
if (!import.meta.env.PROD || !("serviceWorker" in navigator)) return;
|
||||
|
||||
window.addEventListener("load", () => {
|
||||
void navigator.serviceWorker
|
||||
.register("/sw.js", { updateViaCache: "none" })
|
||||
.then((registration) => registration.update())
|
||||
.catch((error: unknown) => {
|
||||
console.warn("Sybil service worker registration failed", error);
|
||||
});
|
||||
void ensureServiceWorker().catch(() => undefined);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import vm from "node:vm";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const workerSource = await readFile(new URL("../public/sw.js", import.meta.url), "utf8");
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", otherSource = "550caef2-b4bc-482c-bd9f-58045ed0a8a8";
|
||||
const payload = (cursor = "5", extra = {}) => ({ version: 1, kind: "message", sourceId, messageId: `outbound:${cursor}`, cursor,
|
||||
title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}`, ...extra });
|
||||
function idb(records) {
|
||||
const request = value => { const result = {}; queueMicrotask(() => { result.result = structuredClone(value); result.onsuccess?.(); }); return result; };
|
||||
return { open() {
|
||||
const opening = {};
|
||||
queueMicrotask(() => {
|
||||
opening.result = { close() {}, transaction() {
|
||||
const transaction = { objectStore: () => ({
|
||||
get: key => request(records.get(key)), getAll: () => request([...records.values()]),
|
||||
put: value => records.set(value.sourceId, structuredClone(value)), delete: key => records.delete(key),
|
||||
}) };
|
||||
setImmediate(() => transaction.oncomplete?.()); return transaction;
|
||||
} };
|
||||
opening.onsuccess?.();
|
||||
}); return opening;
|
||||
} };
|
||||
}
|
||||
function fixture(sharedRecords = new Map()) {
|
||||
const handlers = new Map(), shown = [], notifications = [], routes = [], messages = [], fetches = [];
|
||||
const normalClient = { id: "client", type: "window", url: "https://sybil.example.test/?chat=somewhere", postMessage: value => messages.push(value),
|
||||
navigate: async url => { routes.push(["navigate", url]); return normalClient; }, focus: async () => { routes.push(["focus"]); return normalClient; } };
|
||||
let windows = [normalClient];
|
||||
const makeNotification = value => {
|
||||
const notification = { tag: value.tag, data: value.data, closed: false, close() { notification.closed = true; } };
|
||||
notifications.push(notification); return notification;
|
||||
};
|
||||
const self = {
|
||||
addEventListener: (name, handler) => handlers.set(name, handler), skipWaiting() {}, location: { origin: "https://sybil.example.test" },
|
||||
clients: { claim: async () => undefined, matchAll: async () => windows,
|
||||
get: async id => windows.find(client => client.id === id), openWindow: async url => { routes.push(["open", url]); } },
|
||||
registration: {
|
||||
showNotification: async (title, options) => {
|
||||
shown.push({ title, ...structuredClone(options) });
|
||||
for (const notification of notifications) if (notification.tag === options.tag) notification.close();
|
||||
makeNotification(options);
|
||||
},
|
||||
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt, TextEncoder,
|
||||
fetch: async request => { fetches.push(request); throw new Error("Network blocked in worker tests"); }, Request });
|
||||
const emit = async (type, properties = {}) => {
|
||||
const waits = []; handlers.get(type)?.({ ...properties, waitUntil: value => waits.push(value) }); await Promise.all(waits);
|
||||
};
|
||||
return { shown, notifications, routes, messages, fetches, records: sharedRecords, makeNotification,
|
||||
push: value => emit("push", { data: { text: () => typeof value === "string" ? value : JSON.stringify(value) } }),
|
||||
click: notification => emit("notificationclick", { notification }),
|
||||
message: (data, id = "client") => emit("message", { data, source: { id }, ports: [] }),
|
||||
emit, windows: value => { windows = value; }, normalClient,
|
||||
};
|
||||
}
|
||||
|
||||
test("actual worker push handler displays supplied public message text and never fetches credentials", async () => {
|
||||
const f = fixture(); await f.push(payload("5", { body: "Your report is ready. 日本語 🌧️" }));
|
||||
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "Your report is ready. 日本語 🌧️");
|
||||
assert.deepEqual(f.shown[0].data, { version: 1, kind: "message", sourceId, messageId: "outbound:5", cursor: "5" });
|
||||
assert.equal(f.shown[0].tag, `sybil-agent:${sourceId}`); assert.equal(f.shown[0].renotify, false);
|
||||
assert.deepEqual(f.fetches, []); assert.ok(!JSON.stringify([...f.records]).includes("token"));
|
||||
});
|
||||
|
||||
test("every valid message push remains visible, including read or duplicate deliveries; stale payload never regresses identity", async () => {
|
||||
const records = new Map(), f = fixture(records);
|
||||
await f.message({ type: "sybil-agent-read", version: 1, sourceId, cursor: "10" });
|
||||
await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("5", { body: "Older reply" }));
|
||||
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
|
||||
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
|
||||
assert.ok(f.shown.every(notification => notification.body === "Latest reply"));
|
||||
const restarted = fixture(records); await restarted.push(payload("6"));
|
||||
assert.equal(restarted.shown[0].data.cursor, "8");
|
||||
assert.equal(restarted.shown[0].body, "Latest reply");
|
||||
assert.equal(records.get(sourceId).readCursor, "10");
|
||||
});
|
||||
|
||||
test("malformed/unexpected payloads cannot supply invalid bodies, destinations, cross-source tags or silent read pushes", async () => {
|
||||
const f = fixture();
|
||||
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: { text: "invalid" } }),
|
||||
payload("1", { body: " " }), payload("1", { body: "界".repeat(1001) }), payload("1", { body: "x".repeat(3001) }),
|
||||
payload("1", { body: "界".repeat(900), extra: "界".repeat(500) }),
|
||||
payload("1", { url: "https://evil.example/" }), payload("1", { tag: "some-other-app" }), payload("-1"), payload("9007199254740992"),
|
||||
payload("1", { sourceId: "../../bad" }), payload("1", { messageId: "<script>" }), { version: 1, kind: "read_state", sourceId, readCursor: "8" }]) await f.push(value);
|
||||
assert.equal(f.shown.length, 0); assert.equal(f.fetches.length, 0);
|
||||
});
|
||||
|
||||
test("worker accepts bounded text, renders HTML literally and remains compatible with old generic pushes and stored identities", async () => {
|
||||
const f = fixture();
|
||||
for (const body of ["界".repeat(1000), "🌧".repeat(750), '<b>public literal text</b>', "You have a new message."]) {
|
||||
await f.push(payload("9", { body })); assert.equal(f.shown.at(-1).body, body);
|
||||
}
|
||||
const records = new Map([[sourceId, { sourceId, latest: payload("10") }]]);
|
||||
const upgraded = fixture(records); await upgraded.push(payload("8", { body: "Earlier reply" }));
|
||||
assert.equal(upgraded.shown[0].data.cursor, "10"); assert.equal(upgraded.shown[0].body, "You have a new message.");
|
||||
});
|
||||
|
||||
test("authenticated page read sync closes only owned matching source at or below monotonic read cursor", async () => {
|
||||
const f = fixture();
|
||||
const older = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("4") });
|
||||
const newer = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("8") });
|
||||
const different = f.makeNotification({ tag: `sybil-agent:${otherSource}`, data: payload("1", { sourceId: otherSource }) });
|
||||
const unrelated = f.makeNotification({ tag: "calendar", data: payload("1") });
|
||||
const read = { type: "sybil-agent-read", version: 1, sourceId, cursor: "5" };
|
||||
await f.message(read, "unknown-client"); assert.equal(older.closed, false);
|
||||
f.windows([{ ...f.normalClient, id: "foreign", url: "https://evil.example/" }]);
|
||||
await f.message(read, "foreign"); assert.equal(older.closed, false);
|
||||
f.windows([f.normalClient]); await f.message(read); await f.message({ ...read, cursor: "2" });
|
||||
assert.equal(older.closed, true); assert.equal(newer.closed, false); assert.equal(different.closed, false); assert.equal(unrelated.closed, false);
|
||||
assert.equal(f.records.get(sourceId).readCursor, "5");
|
||||
await f.message({ ...read, cursor: "8" }); assert.equal(newer.closed, true);
|
||||
});
|
||||
|
||||
test("notification click closes and navigates a same-origin client using fixed root URL, without marking read", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
const notification = f.notifications[0]; notification.data.url = "https://evil.example/";
|
||||
await f.click(notification);
|
||||
assert.equal(notification.closed, true);
|
||||
assert.deepEqual(f.routes, [["navigate", "https://sybil.example.test/?agent=root"], ["focus"]]);
|
||||
assert.equal(f.records.get(sourceId).readCursor, undefined);
|
||||
f.windows([{ ...f.normalClient, url: "https://evil.example/" }]); await f.click(notification);
|
||||
assert.deepEqual(f.routes.at(-1), ["open", "https://sybil.example.test/?agent=root"]);
|
||||
});
|
||||
|
||||
test("logout closes only owned alerts and subscription change asks pages to re-register without network or secrets", async () => {
|
||||
const f = fixture(); await f.push(payload());
|
||||
const unrelated = f.makeNotification({ tag: "another-app", data: payload() });
|
||||
await f.message({ type: "sybil-agent-logout", version: 1 });
|
||||
assert.equal(f.notifications[0].closed, true); assert.equal(unrelated.closed, false);
|
||||
await f.emit("pushsubscriptionchange");
|
||||
assert.equal(f.messages.at(-1).type, "sybil-push-subscription-changed");
|
||||
assert.deepEqual(f.fetches, []);
|
||||
});
|
||||
|
||||
test("worker bounds durable source metadata", async () => {
|
||||
const f = fixture();
|
||||
for (let index = 0; index < 20; index++) await f.message({ type: "sybil-agent-read", version: 1, sourceId: `4599d348-e053-4937-987d-${String(index).padStart(12, "0")}`, cursor: "1" });
|
||||
assert.equal(f.records.size, 16);
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from "node:assert/strict";
|
||||
// Fresh isolated Chromium only. All REST, sockets and PushManager provider calls
|
||||
// are mocked. The real /sw.js and browser IndexedDB/notification APIs execute.
|
||||
// PLAYWRIGHT_MODULE=/path/playwright-core/index.mjs CHROME_EXECUTABLE=/path/chrome
|
||||
// WEB_TEST_URL=http://127.0.0.1:5197 node tests/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: 1200, height: 900 }, permissions: ["notifications"] });
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce", requests = [], sockets = [], errors = [], blocked = [];
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
const config = { version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId: null, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } };
|
||||
const idle = { state: "idle", isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
let readCursor = "0", readSourceId = sourceId, failDelete = false;
|
||||
await context.addInitScript(() => {
|
||||
if (!localStorage.getItem("fixture-bootstrapped")) {
|
||||
localStorage.setItem("fixture-bootstrapped", "true"); localStorage.setItem("sybil_admin_token", "browser-fixture-token");
|
||||
}
|
||||
window.__pushCalls = [];
|
||||
window.__fixturePermission = localStorage.getItem("fixture-subscription") ? "granted" : "default";
|
||||
window.__fixtureDecision = "denied";
|
||||
Object.defineProperty(Notification, "permission", { configurable: true, get: () => window.__fixturePermission });
|
||||
Notification.requestPermission = () => {
|
||||
window.__pushCalls.push({ type: "permission", gesture: navigator.userActivation.isActive });
|
||||
window.__fixturePermission = window.__fixtureDecision; return Promise.resolve(window.__fixturePermission);
|
||||
};
|
||||
const subscription = () => ({
|
||||
toJSON: () => ({ endpoint: "https://fcm.googleapis.com/fcm/send/browser-fixture", expirationTime: null, keys: { p256dh: "B".repeat(87), auth: "A".repeat(22) } }),
|
||||
unsubscribe: async () => { window.__pushCalls.push({ type: "unsubscribe" }); localStorage.removeItem("fixture-subscription"); return true; },
|
||||
});
|
||||
PushManager.prototype.getSubscription = async () => localStorage.getItem("fixture-subscription") ? subscription() : null;
|
||||
PushManager.prototype.subscribe = async options => {
|
||||
window.__pushCalls.push({ type: "subscribe", visible: options.userVisibleOnly, keyLength: options.applicationServerKey.byteLength });
|
||||
localStorage.setItem("fixture-subscription", "true"); return subscription();
|
||||
};
|
||||
});
|
||||
await context.route("**/*", async route => {
|
||||
const request = route.request(), url = new URL(request.url());
|
||||
if (url.origin !== target.origin) { blocked.push(url.origin); await route.abort(); return; }
|
||||
if (!url.pathname.startsWith("/api/")) { await route.continue(); return; }
|
||||
const method = request.method(), path = url.pathname;
|
||||
const body = request.postData() ? request.postDataJSON() : undefined;
|
||||
requests.push({ path, method, body, authorization: request.headers().authorization });
|
||||
assert.ok(["Bearer browser-fixture-token", "Bearer browser-fixture-rotated-token"].includes(request.headers().authorization));
|
||||
let value;
|
||||
if (path.endsWith("/push/config")) value = config;
|
||||
else if (path.includes("/push/installations/")) {
|
||||
if (method === "DELETE") { await route.fulfill({ status: failDelete ? 503 : 204, ...(failDelete ? { json: { message: "offline" } } : {}) }); return; }
|
||||
value = { installationId: path.split("/").at(-1), transport: "web", enabled: true, version: 1 };
|
||||
} else if (path.includes("/presence/")) {
|
||||
if (method === "DELETE") { await route.fulfill({ status: 204 }); return; }
|
||||
value = { expiresAt: new Date(Date.now() + 60000).toISOString() };
|
||||
} else if (path.endsWith("/read-state")) {
|
||||
if (method === "PUT") readCursor = body.cursor;
|
||||
value = { cursor: readCursor, sourceId: readSourceId, updatedAt: null };
|
||||
} else if (path.endsWith("/agent/messages")) value = { conversationId: "1", messages: [], cursor: "0", 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.includes("session")) value = { mode: "token", authenticated: true };
|
||||
else if (path.endsWith("/tools") || path.endsWith("/chat-tools")) value = { tools: [] };
|
||||
else if (path.endsWith("/models")) value = { models: [], providers: {} };
|
||||
else value = [];
|
||||
await route.fulfill({ json: value });
|
||||
});
|
||||
await context.routeWebSocket("**/api/v1/agent/ws", socket => {
|
||||
socket.onMessage(raw => {
|
||||
const frame = JSON.parse(String(raw));
|
||||
if (frame.type === "auth") { sockets.push({ socket, frame }); socket.send(JSON.stringify({ type: "ready", conversationId: "1" })); }
|
||||
});
|
||||
});
|
||||
const page = await context.newPage(); page.on("pageerror", error => errors.push(error.message));
|
||||
const expectEventually = async predicate => { for (let i = 0; i < 100; i++) { if (await predicate()) return; await new Promise(resolve => setTimeout(resolve, 50)); } assert.fail("Condition did not settle"); };
|
||||
try {
|
||||
await page.goto(target.href, { waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
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.deepEqual(await page.evaluate(() => window.__pushCalls), []);
|
||||
config.sourceId = sourceId;
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0);
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByText(/Notifications were not allowed/).waitFor();
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/")).length, 0);
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls), [{ type: "permission", gesture: true }]);
|
||||
await page.evaluate(() => { window.__fixturePermission = "default"; window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const registrations = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT");
|
||||
assert.equal(registrations.length, 1); assert.equal(registrations[0].body.transport, "web");
|
||||
assert.deepEqual(await page.evaluate(() => window.__pushCalls.filter(call => call.type === "subscribe")), [{ type: "subscribe", visible: true, keyLength: 65 }]);
|
||||
await expectEventually(() => sockets.length === 1);
|
||||
const firstSession = sockets[0].frame.presenceSessionId;
|
||||
assert.equal(sockets[0].frame.clientKind, "web");
|
||||
assert.ok(requests.some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible"));
|
||||
|
||||
// /search has no Agent pane or room socket but must still report attention.
|
||||
const second = await context.newPage(); second.on("pageerror", error => errors.push(error.message));
|
||||
await second.goto(new URL("/search", target).href, { waitUntil: "networkidle" });
|
||||
await second.bringToFront();
|
||||
await expectEventually(() => new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path)).size >= 2);
|
||||
const sessionPaths = [...new Set(requests.filter(request => request.path.includes("/presence/")).map(request => request.path))];
|
||||
assert.equal(sessionPaths.length, 2);
|
||||
const secondPath = sessionPaths.find(path => !path.endsWith(firstSession));
|
||||
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.focused === true));
|
||||
// Playwright forces every target focused/visible. Supply controlled DOM state
|
||||
// to exercise the production reporter's blur/visibility wiring deterministically.
|
||||
const beforeBlur = requests.length;
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false });
|
||||
window.dispatchEvent(new Event("blur"));
|
||||
});
|
||||
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === false));
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, "hidden", { configurable: true, value: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
|
||||
await page.evaluate(() => { delete document.hasFocus; delete document.hidden; window.dispatchEvent(new Event("focus")); });
|
||||
await second.getByRole("textbox").first().click();
|
||||
await second.getByRole("textbox").first().pressSequentially("interaction");
|
||||
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.interacted === true));
|
||||
for (const path of sessionPaths) {
|
||||
const sequences = requests.filter(request => request.path === path && request.method === "PUT").map(request => request.body.sequence);
|
||||
assert.deepEqual(sequences, [...new Set(sequences)].sort((a, b) => a - b));
|
||||
}
|
||||
await page.bringToFront();
|
||||
const beforeHide = requests.length;
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("pagehide")));
|
||||
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("pageshow")));
|
||||
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === true));
|
||||
const worker = context.serviceWorkers().find(worker => new URL(worker.url()).pathname === "/sw.js");
|
||||
assert.ok(worker, "actual service worker activated");
|
||||
const push = async cursor => worker.evaluate(async ({ cursor, sourceId }) => {
|
||||
const value = { version: 1, kind: "message", sourceId, cursor, messageId: `outbound:${cursor}`, title: "Sybil Agent", body: "You have a new message.", url: "/?agent=root", tag: `sybil-agent:${sourceId}` };
|
||||
const event = new Event("push"), waits = [];
|
||||
Object.defineProperties(event, { data: { value: { text: () => JSON.stringify(value) } }, waitUntil: { value: promise => waits.push(promise) } });
|
||||
self.dispatchEvent(event); await Promise.all(waits);
|
||||
return (await self.registration.getNotifications()).map(notification => ({ title: notification.title, body: notification.body, tag: notification.tag, data: notification.data }));
|
||||
}, { cursor, sourceId });
|
||||
const notification = await push("8");
|
||||
assert.equal(notification.length, 1); assert.equal(notification[0].title, "Sybil Agent"); assert.equal(notification[0].body, "You have a new message.");
|
||||
assert.equal(notification[0].data.cursor, "8");
|
||||
assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "push reception never marks read");
|
||||
readCursor = "7";
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null }));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
assert.equal(await worker.evaluate(async () => (await self.registration.getNotifications()).length), 1);
|
||||
readCursor = "8";
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId, cursor: readCursor, updatedAt: null }));
|
||||
await expectEventually(async () => await worker.evaluate(async () => (await self.registration.getNotifications()).length) === 0);
|
||||
const dbRows = await worker.evaluate(async () => {
|
||||
const db = await new Promise(resolve => { const request = indexedDB.open("sybil-agent-notifications-v1"); request.onsuccess = () => resolve(request.result); });
|
||||
const rows = await new Promise(resolve => { const request = db.transaction("sources").objectStore("sources").getAll(); request.onsuccess = () => resolve(request.result); });
|
||||
db.close(); return rows;
|
||||
});
|
||||
assert.equal(dbRows[0].readCursor, "8"); assert.doesNotMatch(JSON.stringify(dbRows), /browser-fixture-token|fcm.googleapis/);
|
||||
|
||||
// The rendered room belongs to source A. A B read frame must stop the room,
|
||||
// preserving A's cursor instead of relabeling old queued reads with B.
|
||||
await second.close();
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
sockets[0].socket.send(JSON.stringify({ type: "read_state", sourceId: replacementSourceId, cursor: "0", updatedAt: null }));
|
||||
await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).waitFor();
|
||||
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
assert.equal(requests.filter(request => request.method === "PUT" && request.path.endsWith("/read-state")).length, 0, "source mismatch cannot produce relabeled writes or a write loop");
|
||||
|
||||
// Replacing the source at the same backend/token invalidates persisted push
|
||||
// consent. The new source requires another gesture and another installation.
|
||||
const oldConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
const registrationCount = requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length;
|
||||
config.sourceId = replacementSourceId; readSourceId = replacementSourceId; readCursor = "0";
|
||||
await page.getByRole("button", { name: "Refresh", exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const retiredConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(retiredConsent.enabled, false); assert.equal(retiredConsent.sourceId, sourceId);
|
||||
assert.equal(requests.filter(request => request.path.includes("/push/installations/") && request.method === "PUT").length, registrationCount);
|
||||
assert.ok(requests.some(request => request.path.endsWith(oldConsent.installationId) && request.method === "DELETE"));
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const replacementConsent = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(replacementConsent.enabled, true); assert.equal(replacementConsent.sourceId, replacementSourceId);
|
||||
assert.equal(replacementConsent.scope, oldConsent.scope); assert.notEqual(replacementConsent.installationId, oldConsent.installationId);
|
||||
assert.ok((await page.evaluate(() => window.__pushCalls.filter(call => call.type === "permission"))).every(call => call.gesture === true));
|
||||
const socketCount = sockets.length;
|
||||
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
||||
await expectEventually(() => sockets.length > socketCount);
|
||||
assert.equal(await page.getByText("Agent history changed. Reload the page before marking messages read.", { exact: true }).count(), 0, "explicit reload binds a fresh room to B");
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
|
||||
// Credential rotation is a new authority scope. The old UUID remains queued
|
||||
// locally even if this server would return204 for an unknown DELETE.
|
||||
const originalAuthority = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-rotated-token"));
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const rotatedOff = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.deepEqual(rotatedOff.pendingRevocations, [{ installationId: originalAuthority.installationId, scope: originalAuthority.scope }]);
|
||||
assert.ok(!requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId)
|
||||
&& request.authorization === "Bearer browser-fixture-rotated-token"), "rotated credentials cannot falsely acknowledge old-authority cleanup");
|
||||
await page.evaluate(() => { window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
const rotatedOn = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.equal(rotatedOn.enabled, true); assert.notEqual(rotatedOn.scope, originalAuthority.scope);
|
||||
assert.notEqual(rotatedOn.installationId, originalAuthority.installationId); assert.deepEqual(rotatedOn.pendingRevocations, rotatedOff.pendingRevocations);
|
||||
await page.evaluate(() => localStorage.setItem("sybil_admin_token", "browser-fixture-token"));
|
||||
await page.reload({ waitUntil: "networkidle" });
|
||||
await page.getByText("Agent notifications", { exact: true }).click();
|
||||
await expectEventually(() => page.getByRole("button", { name: "Enable notifications", exact: true }).isEnabled());
|
||||
const returned = await page.evaluate(() => JSON.parse(localStorage.getItem("sybil.agent.push.v1")));
|
||||
assert.deepEqual(returned.pendingRevocations, [{ installationId: rotatedOn.installationId, scope: rotatedOn.scope }]);
|
||||
assert.ok(requests.some(request => request.method === "DELETE" && request.path.endsWith(originalAuthority.installationId)
|
||||
&& request.authorization === "Bearer browser-fixture-token"));
|
||||
await page.evaluate(() => { window.__fixtureDecision = "granted"; });
|
||||
await page.getByRole("button", { name: "Enable notifications", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Turn off notifications", exact: true }).waitFor();
|
||||
|
||||
// Offline sign-out still removes the local subscription and leaves a tombstone.
|
||||
failDelete = true;
|
||||
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Sign in/i }).waitFor();
|
||||
const local = await page.evaluate(() => ({ token: localStorage.getItem("sybil_admin_token"), subscription: localStorage.getItem("fixture-subscription"), consent: JSON.parse(localStorage.getItem("sybil.agent.push.v1")) }));
|
||||
assert.equal(local.token, null); assert.equal(local.subscription, null); assert.equal(local.consent.enabled, false); assert.equal(local.consent.revocationPending, true);
|
||||
assert.ok(requests.some(request => request.method === "DELETE" && request.path.includes("/push/installations/") && request.authorization === "Bearer browser-fixture-token"));
|
||||
// Existing app fonts are blocked as well; nothing leaves the loopback fixture.
|
||||
assert.ok(blocked.every(origin => origin === "https://fonts.googleapis.com")); assert.deepEqual(errors, []);
|
||||
console.log("PASS real worker activation/showNotification/IndexedDB/read close; unknown-source/gesture denial/enable; two-tab + standalone search presence; auth-frame binding; source-replacement read quiescence and explicit re-opt-in/reload; authority-scoped cleanup/credential rotation/return retry; offline logout; no provider network");
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
@@ -0,0 +1,467 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
AgentPushClient, agentPushAuthorityScope, agentPushCursor, createAgentPresenceReporter, createAgentPushController,
|
||||
MAX_AGENT_PUSH_REVOCATIONS, parseAgentPushConfig, parseAgentPushConsent,
|
||||
} from "../src/lib/agent-push.ts";
|
||||
import { agentAuthFrame, applyAgentEvent, createAgentRoomState, parseAgentRoomEvent } from "../src/lib/agent-room.ts";
|
||||
|
||||
const sourceId = "4599d348-e053-4937-987d-1392746fb2ce";
|
||||
const replacementSourceId = "22222222-2222-4222-8222-222222222222";
|
||||
const configuration = () => ({ version: 1, enabled: true, webPushAvailable: true, apnsAvailable: false, sourceId, quarantined: false,
|
||||
vapidPublicKey: "B".repeat(87), policy: { presenceHeartbeatMs: 15000, presenceLeaseMs: 60000 } });
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve));
|
||||
function fixture(overrides = {}, shared = {}) {
|
||||
let record = shared.record || null, subscription = null, permission = "default", support = "supported", config = configuration();
|
||||
const calls = [], states = [];
|
||||
const api = {
|
||||
config: async () => config,
|
||||
register: async (id, value) => { calls.push(["register", id, value]); },
|
||||
revoke: async id => { calls.push(["revoke", id]); },
|
||||
...overrides.api,
|
||||
};
|
||||
const makeSubscription = (endpoint = "https://push.example.test/opaque") => ({
|
||||
toJSON: () => ({ endpoint, keys: { p256dh: "fixture-public", auth: "fixture-auth" } }),
|
||||
unsubscribe: async () => { calls.push(["unsubscribe"]); subscription = null; return true; },
|
||||
});
|
||||
const platform = {
|
||||
support: () => support, permission: () => permission,
|
||||
requestPermission: () => { calls.push(["permission"]); permission = "granted"; return Promise.resolve(permission); },
|
||||
subscription: async () => subscription,
|
||||
subscribe: async key => { calls.push(["subscribe", key]); return subscription = makeSubscription(); },
|
||||
clearNotifications: async () => { calls.push(["clear"]); },
|
||||
...overrides.platform,
|
||||
};
|
||||
const options = {
|
||||
api, platform, read: () => record, write: value => { record = structuredClone(value); }, uuid: randomUUID,
|
||||
scope: async () => "a".repeat(64),
|
||||
lock: async operation => operation(), changed: state => states.push(state), ...shared.options,
|
||||
};
|
||||
const controller = createAgentPushController(options);
|
||||
return { controller, options, calls, states, api, platform, makeSubscription, record: () => record,
|
||||
setConfig(value) { config = value; }, setPermission(value) { permission = value; }, setSupport(value) { support = value; }, setSubscription(value) { subscription = value; } };
|
||||
}
|
||||
|
||||
test("explicit gesture requests permission before yielding; permission alone never opts in", async () => {
|
||||
const f = fixture(); f.setPermission("granted");
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
||||
const enabling = f.controller.enable();
|
||||
assert.deepEqual(f.calls, [["permission"]], "request runs directly inside the click stack");
|
||||
await enabling;
|
||||
assert.equal(f.record().enabled, true);
|
||||
assert.equal(f.record().sourceId, sourceId);
|
||||
assert.equal(f.record().revocationPending, false);
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
await f.controller.refresh(); await f.controller.refresh();
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "heartbeat does not repeatedly register an unchanged subscription");
|
||||
});
|
||||
|
||||
test("denial, disabled config, unsupported browser and iOS installation guidance do not subscribe", async () => {
|
||||
for (const support of ["unsupported", "install"]) {
|
||||
const f = fixture(); f.setSupport(support); await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.deepEqual(f.calls, []);
|
||||
assert.match(f.controller.state().message, support === "install" ? /Home Screen/ : /HTTPS/);
|
||||
}
|
||||
const disabled = fixture(); disabled.setConfig({ ...configuration(), enabled: false });
|
||||
await disabled.controller.refresh(); await disabled.controller.enable(); assert.deepEqual(disabled.calls, []);
|
||||
const denied = fixture({ platform: { requestPermission: async () => "denied" } });
|
||||
await denied.controller.refresh(); await denied.controller.enable();
|
||||
assert.equal(denied.record().enabled, false); assert.equal(denied.calls.length, 0);
|
||||
assert.match(denied.controller.state().error, /not allowed/);
|
||||
});
|
||||
|
||||
test("registration failure rolls back subscription, persists revoke intent and never echoes provider errors", async () => {
|
||||
const f = fixture({ api: { register: async () => { throw new Error("https://private-provider/token-secret"); }, revoke: async () => { throw new Error("offline"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
assert.doesNotMatch(JSON.stringify(f.states), /private-provider|token-secret/);
|
||||
assert.equal(f.controller.state().canEnable, false);
|
||||
});
|
||||
|
||||
test("offline logout writes tombstone synchronously, unsubscribes locally and retries before any enable", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const id = f.record().installationId;
|
||||
f.api.revoke = async () => { throw new Error("offline"); };
|
||||
const disabling = f.controller.disable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, true);
|
||||
await disabling;
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
const count = f.calls.filter(([name]) => name === "register").length;
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
||||
f.api.revoke = async revoked => { assert.equal(revoked, id); };
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record().revocationPending, false); assert.equal(f.record().enabled, false);
|
||||
});
|
||||
|
||||
test("storage failures during logout still revoke, unsubscribe and close notifications", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
f.options.write = () => { throw new Error("quota"); };
|
||||
let removed = false; f.options.remove = () => { removed = true; };
|
||||
await f.controller.disable();
|
||||
assert.ok(removed); assert.equal(f.controller.state().enabled, false);
|
||||
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.controller.state().enabled, false);
|
||||
});
|
||||
|
||||
test("a stuck cross-tab lock cannot prevent server retirement or local logout cleanup", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
f.options.lock = async () => { throw new Error("lock acquisition expired"); };
|
||||
await f.controller.disable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
||||
for (const name of ["revoke", "unsubscribe", "clear"]) assert.ok(f.calls.some(([action]) => action === name));
|
||||
});
|
||||
|
||||
test("synchronous permission API failures remain a recoverable opt-in error", async () => {
|
||||
const f = fixture({ platform: { requestPermission: () => { throw new Error("user gesture rejected"); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.match(f.controller.state().error, /could not be requested/);
|
||||
});
|
||||
|
||||
test("subscription rotation upserts the same installation; permission revocation removes it", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const id = f.record().installationId;
|
||||
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
||||
await f.controller.refresh();
|
||||
const registrations = f.calls.filter(([name]) => name === "register");
|
||||
assert.equal(registrations.length, 2); assert.ok(registrations.every(([, installation]) => installation === id));
|
||||
f.setPermission("denied"); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false);
|
||||
});
|
||||
|
||||
test("logout while permission prompt is open cancels the older opt-in", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
await f.controller.disable(); prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false);
|
||||
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
});
|
||||
|
||||
test("two-tab enable → disable → enable uses permanent server tombstones and a new installation UUID", async () => {
|
||||
let sharedRecord = null, queue = Promise.resolve(), currentRegistration = false;
|
||||
const calls = [], pendingRegister = deferred(), retired = new Set(); let first = true;
|
||||
const common = { options: {
|
||||
read: () => sharedRecord, write: value => { sharedRecord = structuredClone(value); },
|
||||
lock: operation => { const running = queue.then(operation); queue = running.catch(() => undefined); return running; },
|
||||
} };
|
||||
const api = {
|
||||
register: async id => { calls.push("register"); if (first) { first = false; await pendingRegister.promise; } assert.ok(!retired.has(id), "retired UUID must never be reused"); currentRegistration = true; },
|
||||
revoke: async id => { calls.push("revoke"); retired.add(id); currentRegistration = false; },
|
||||
};
|
||||
const a = fixture({ api }, common), b = fixture({ api }, common);
|
||||
await a.controller.refresh(); await b.controller.refresh();
|
||||
const enabling = a.controller.enable(); await settle();
|
||||
assert.deepEqual(calls, ["register"]);
|
||||
const disabling = b.controller.disable();
|
||||
assert.equal(sharedRecord.enabled, false); assert.equal(sharedRecord.revocationPending, true);
|
||||
pendingRegister.resolve(); await Promise.all([enabling, disabling]);
|
||||
assert.equal(currentRegistration, false);
|
||||
const id = sharedRecord.installationId;
|
||||
await b.controller.enable();
|
||||
assert.equal(currentRegistration, true); assert.equal(sharedRecord.enabled, true); assert.notEqual(sharedRecord.installationId, id);
|
||||
assert.ok(retired.has(id));
|
||||
assert.equal(calls.at(-1), "register");
|
||||
});
|
||||
|
||||
test("a timed-out old PUT completing after DELETE cannot revive the retired installation or affect a new opt-in", async () => {
|
||||
const retired = new Set(), active = new Set(), late = deferred(); let firstId, lateStatus, first = true;
|
||||
const f = fixture({ api: {
|
||||
register: async id => {
|
||||
if (first) {
|
||||
first = false; firstId = id;
|
||||
late.promise.then(() => { lateStatus = retired.has(id) ? 409 : 200; if (lateStatus === 200) active.add(id); });
|
||||
throw new Error("request timed out while server is still processing");
|
||||
}
|
||||
assert.ok(!retired.has(id)); active.add(id);
|
||||
},
|
||||
revoke: async id => { retired.add(id); active.delete(id); },
|
||||
} });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().revocationPending, false); assert.ok(retired.has(firstId));
|
||||
await f.controller.enable(); const currentId = f.record().installationId;
|
||||
assert.notEqual(currentId, firstId); assert.ok(active.has(currentId));
|
||||
late.resolve(); await settle();
|
||||
assert.equal(lateStatus, 409); assert.deepEqual([...active], [currentId]);
|
||||
});
|
||||
|
||||
test("authority changes retain original-server cleanup without issuing a misleading DELETE to the new server", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId, registrations = f.calls.filter(([name]) => name === "register").length;
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
await f.controller.refresh(); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false);
|
||||
assert.equal(f.record().revocationPending, false, "local unsubscribe succeeded, but old server cleanup stays queued");
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
assert.ok(!f.calls.some(([name]) => name === "revoke"), "new server's unknown-ID DELETE/204 cannot retire the original server registration");
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, registrations);
|
||||
await f.controller.enable();
|
||||
const newId = f.record().installationId;
|
||||
assert.notEqual(newId, oldId); assert.equal(f.record().scope, "b".repeat(64)); assert.equal(f.record().enabled, true);
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
const reloaded = createAgentPushController(f.options);
|
||||
await reloaded.refresh();
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }], "queue survives a reload on B");
|
||||
assert.ok(!f.calls.some(([name]) => name === "revoke"));
|
||||
f.options.scope = async () => "a".repeat(64);
|
||||
await reloaded.refresh();
|
||||
assert.deepEqual(f.calls.filter(([name]) => name === "revoke"), [["revoke", oldId]], "returning with A credentials retries only A's intent");
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: newId, scope: "b".repeat(64) }]);
|
||||
assert.equal(f.record().enabled, false);
|
||||
await reloaded.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "a".repeat(64));
|
||||
assert.notEqual(f.record().installationId, oldId);
|
||||
});
|
||||
|
||||
test("revoked old credentials do not prevent fresh opt-in after same-server credential rotation", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.api.revoke = async () => { throw new Error("old credentials revoked"); };
|
||||
await f.controller.disable();
|
||||
assert.equal(f.record().revocationPending, true);
|
||||
f.options.scope = async () => "c".repeat(64);
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().scope, "c".repeat(64));
|
||||
assert.notEqual(f.record().installationId, oldId);
|
||||
assert.deepEqual(f.record().pendingRevocations, [{ installationId: oldId, scope: "a".repeat(64) }]);
|
||||
assert.doesNotMatch(JSON.stringify(f.record()), /credentials|provider|opaque|fixture-auth/);
|
||||
});
|
||||
|
||||
test("failed old-authority retries retain intent while other queued authorities are never contacted", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
||||
const queue = [{ installationId: randomUUID(), scope: "a".repeat(64) }, { installationId: randomUUID(), scope: "b".repeat(64) }];
|
||||
f.options.write({ ...f.record(), pendingRevocations: queue });
|
||||
const attempted = []; f.api.revoke = async id => { attempted.push(id); throw new Error("offline"); };
|
||||
await f.controller.refresh();
|
||||
assert.deepEqual(attempted, [queue[0].installationId]); assert.deepEqual(f.record().pendingRevocations, queue);
|
||||
await f.controller.enable();
|
||||
assert.equal(f.record().enabled, true); assert.deepEqual(f.record().pendingRevocations, queue);
|
||||
});
|
||||
|
||||
test("bounded revocation queue never silently evicts cleanup and gives actionable opt-in recovery", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable(); await f.controller.disable();
|
||||
const queue = Array.from({ length: MAX_AGENT_PUSH_REVOCATIONS }, () => ({ installationId: randomUUID(), scope: "b".repeat(64) }));
|
||||
f.options.write({ ...f.record(), pendingRevocations: queue });
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.match(f.controller.state().message, /Sign in to a previous server/);
|
||||
const count = f.calls.filter(([name]) => name === "register").length;
|
||||
await f.controller.enable();
|
||||
assert.deepEqual(f.record().pendingRevocations, queue); assert.equal(f.calls.filter(([name]) => name === "register").length, count);
|
||||
assert.ok(parseAgentPushConsent(JSON.stringify(f.record())));
|
||||
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [...queue, queue[0]] })), null);
|
||||
assert.equal(parseAgentPushConsent(JSON.stringify({ ...f.record(), pendingRevocations: [{ installationId: queue[0].installationId, scope: "raw-token" }] })), null);
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
await f.controller.refresh();
|
||||
assert.deepEqual(f.record().pendingRevocations, []); assert.equal(f.controller.state().canEnable, true);
|
||||
});
|
||||
|
||||
test("in-flight cleanup cannot overwrite a newer tab's consent revision", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const entered = deferred(), release = deferred();
|
||||
let reads = 0;
|
||||
f.options.scope = async () => {
|
||||
if (++reads === 2) { entered.resolve(); await release.promise; }
|
||||
return "a".repeat(64);
|
||||
};
|
||||
const disabling = f.controller.disable(); await entered.promise;
|
||||
const newer = { ...f.record(), installationId: randomUUID(), revision: randomUUID(), enabled: true, revocationPending: false };
|
||||
f.options.write(newer); release.resolve(); await disabling;
|
||||
assert.deepEqual(f.record(), newer);
|
||||
});
|
||||
|
||||
test("principal scope changing while permission is pending prevents registration", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
f.options.scope = async () => "b".repeat(64);
|
||||
prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
});
|
||||
|
||||
test("authority changing during config fetch cannot enable against a mixed identity snapshot", async () => {
|
||||
const f = fixture({ api: { config: async () => { f.options.scope = async () => "b".repeat(64); return configuration(); } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null); assert.deepEqual(f.calls, []);
|
||||
});
|
||||
|
||||
test("same backend/token with a replaced source invalidates persisted consent on reload and requires a new opt-in", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const old = structuredClone(f.record());
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
||||
const reloaded = createAgentPushController(f.options);
|
||||
await reloaded.refresh(); await reloaded.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.equal(f.calls.filter(([name]) => name === "register").length, 1, "source replacement cannot reuse old consent");
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === old.installationId));
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
await reloaded.enable();
|
||||
assert.equal(f.record().enabled, true); assert.equal(f.record().sourceId, replacementSourceId);
|
||||
assert.equal(f.record().scope, old.scope); assert.notEqual(f.record().installationId, old.installationId);
|
||||
assert.equal(f.calls.filter(([name]) => name === "permission").length, 2, "replacement requires another explicit permission gesture");
|
||||
});
|
||||
|
||||
test("null or invalid source prevents new opt-in even when transport is configured", async () => {
|
||||
for (const unknown of [null, undefined, "not-a-source-uuid"]) {
|
||||
const f = fixture(); f.setConfig({ ...configuration(), sourceId: unknown });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.controller.state().canEnable, false); assert.equal(f.record(), null);
|
||||
assert.deepEqual(f.calls, [], "unknown source must not request permission, subscribe or register");
|
||||
}
|
||||
});
|
||||
|
||||
test("losing a known source retires old consent and never resumes it automatically", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.setConfig({ ...configuration(), sourceId: null });
|
||||
await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.controller.state().canEnable, false);
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
f.setConfig(configuration()); await f.controller.refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
});
|
||||
|
||||
test("legacy or malformed source bindings retain installation identity for retirement, not re-enrollment", async () => {
|
||||
for (const invalid of [undefined, null, "invalid-source"]) {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
const stored = parseAgentPushConsent(JSON.stringify({ ...f.record(), sourceId: invalid }));
|
||||
assert.equal(stored.installationId, oldId); assert.equal(stored.sourceId, undefined);
|
||||
f.options.write(stored);
|
||||
await createAgentPushController(f.options).refresh();
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 1);
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
}
|
||||
});
|
||||
|
||||
test("source change while permission is pending prevents subscription and retires the incomplete intent", async () => {
|
||||
const prompt = deferred(), f = fixture({ platform: { requestPermission: () => prompt.promise } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable();
|
||||
const oldId = f.record().installationId;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId });
|
||||
f.setPermission("granted"); prompt.resolve("granted"); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.ok(!f.calls.some(([name]) => name === "subscribe" || name === "register"));
|
||||
assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
});
|
||||
|
||||
test("source change while subscribe is pending unsubscribes without registering", async () => {
|
||||
const entered = deferred(), release = deferred();
|
||||
const f = fixture({ platform: { subscribe: async () => {
|
||||
entered.resolve(); await release.promise;
|
||||
const subscription = f.makeSubscription(); f.setSubscription(subscription); return subscription;
|
||||
} } });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.calls.filter(([name]) => name === "register").length, 0);
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("source change during initial registration rolls back instead of persisting enabled consent", async () => {
|
||||
const entered = deferred(), release = deferred(), registered = [], retired = [];
|
||||
const f = fixture({ api: {
|
||||
register: async id => { registered.push(id); entered.resolve(); await release.promise; },
|
||||
revoke: async id => { retired.push(id); },
|
||||
} });
|
||||
await f.controller.refresh(); const enabling = f.controller.enable(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await enabling;
|
||||
assert.equal(f.record().enabled, false); assert.equal(f.record().sourceId, sourceId);
|
||||
assert.deepEqual(retired, registered); assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("source change during automatic rotation registration retires the source-bound installation", async () => {
|
||||
const f = fixture(); await f.controller.refresh(); await f.controller.enable();
|
||||
const oldId = f.record().installationId, entered = deferred(), release = deferred();
|
||||
f.setSubscription(f.makeSubscription("https://push.example.test/rotated"));
|
||||
f.api.register = async id => { assert.equal(id, oldId); entered.resolve(); await release.promise; };
|
||||
const refreshing = f.controller.refresh(); await entered.promise;
|
||||
f.setConfig({ ...configuration(), sourceId: replacementSourceId }); release.resolve(); await refreshing;
|
||||
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name, id]) => name === "revoke" && id === oldId));
|
||||
});
|
||||
|
||||
test("source verification failure after registration fails closed and rolls back", async () => {
|
||||
const f = fixture({ api: { register: async () => { f.api.config = async () => { throw new Error("source unavailable"); }; } } });
|
||||
await f.controller.refresh(); await f.controller.enable();
|
||||
assert.equal(f.record().enabled, false); assert.ok(f.calls.some(([name]) => name === "revoke"));
|
||||
assert.ok(f.calls.some(([name]) => name === "unsubscribe"));
|
||||
});
|
||||
|
||||
test("attention is per-tab, ordered, focused while idle, and recent input persists until acknowledged", async () => {
|
||||
const rows = [], failure = deferred(); let focus = true;
|
||||
const a = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: focus }), send: async value => { rows.push(["a", value]); } });
|
||||
const b = createAgentPresenceReporter({ snapshot: () => ({ visibility: "hidden", focused: true }), send: async value => { rows.push(["b", value]); } });
|
||||
await a.report(); await b.report();
|
||||
assert.equal(rows[0][1].focused, true); assert.equal(rows[0][1].interacted, false);
|
||||
assert.equal(rows[1][1].focused, false); assert.equal(rows[1][1].sequence, 1);
|
||||
a.interact(); focus = false; await a.report(); await a.report();
|
||||
assert.equal(rows[2][1].interacted, true); assert.equal(rows[2][1].focused, false);
|
||||
assert.equal(rows[3][1].interacted, false); assert.equal(rows[3][1].sequence, 3);
|
||||
let attempts = 0;
|
||||
const c = createAgentPresenceReporter({ snapshot: () => ({ visibility: "visible", focused: false }), send: async value => {
|
||||
assert.equal(value.interacted, true); if (++attempts === 1) return failure.promise;
|
||||
} });
|
||||
c.interact(); const report = c.report(); failure.reject(new Error("offline")); await assert.rejects(report); await c.report();
|
||||
const id = randomUUID(), frame = JSON.parse(agentAuthFrame("fixture-token", "2", id));
|
||||
assert.equal(frame.clientKind, "web"); assert.equal(frame.presenceSessionId, id);
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(null, "0", "not-an-id")), { type: "auth", token: null, after: "0", clientKind: "web" });
|
||||
});
|
||||
|
||||
test("read frames validate source/cursor and preserve existing read semantics", () => {
|
||||
const event = parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "9", updatedAt: null, sourceId }));
|
||||
assert.equal(applyAgentEvent(createAgentRoomState(), event).readCursor, "9");
|
||||
for (const cursor of ["-1", "1e5", "9007199254740992", "1".repeat(200)]) {
|
||||
assert.equal(agentPushCursor(cursor), false);
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor, updatedAt: null, sourceId })));
|
||||
}
|
||||
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "read_state", cursor: "1", updatedAt: null, sourceId: "https://evil.test" })));
|
||||
assert.equal(parseAgentPushConsent('{"enabled":true}'), null);
|
||||
assert.throws(() => parseAgentPushConfig({ ...configuration(), policy: { presenceHeartbeatMs: 1, presenceLeaseMs: 60000 } }));
|
||||
});
|
||||
|
||||
test("API keeps credentials in headers, validates receipts, handles 204 and redacts provider bodies", async () => {
|
||||
const id = randomUUID(), requests = []; let mode = "config";
|
||||
const api = new AgentPushClient({ baseUrl: "/api", token: () => "fixture-bearer", fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
if (mode === "revoke") return new Response(null, { status: 204 });
|
||||
if (mode === "fail") return new Response(JSON.stringify({ error: "private-token-and-endpoint" }), { status: 500 });
|
||||
if (mode === "register") return Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
||||
if (mode === "read") return Response.json({ cursor: "0008", sourceId });
|
||||
return Response.json(configuration());
|
||||
} });
|
||||
await api.config(); mode = "register"; await api.register(id, { endpoint: "https://provider.test/endpoint-secret" });
|
||||
mode = "read"; assert.deepEqual(await api.readState(), { sourceId, cursor: "8" });
|
||||
mode = "revoke"; await api.revoke(id);
|
||||
mode = "fail"; await assert.rejects(api.revoke(id), error => !/private-token/.test(error.message));
|
||||
for (const request of requests) {
|
||||
assert.equal(request.init.headers.Authorization, "Bearer fixture-bearer");
|
||||
assert.equal(request.init.cache, "no-store"); assert.equal(request.init.redirect, "error");
|
||||
assert.doesNotMatch(request.url, /fixture-bearer|endpoint-secret/);
|
||||
}
|
||||
await assert.rejects(api.revoke("../../bad"));
|
||||
const unauthenticated = new AgentPushClient({ baseUrl: "/api", token: () => null, fetch: () => { throw new Error("must not send"); } });
|
||||
await assert.rejects(unauthenticated.config(), /Sign in/);
|
||||
});
|
||||
|
||||
test("authority-guarded API refuses mismatched credentials and pins the captured bearer across async hashing", async () => {
|
||||
const id = randomUUID(), requests = [], baseUrl = "https://sybil.example.test/api/";
|
||||
const scope = await agentPushAuthorityScope(baseUrl, "original-fixture-token");
|
||||
assert.equal(scope, await agentPushAuthorityScope("/api", "original-fixture-token", "https://sybil.example.test/"));
|
||||
assert.match(scope, /^[a-f0-9]{64}$/); assert.equal(await agentPushAuthorityScope(baseUrl, null), null);
|
||||
let token = "rotated-fixture-token";
|
||||
const api = new AgentPushClient({ baseUrl, token: () => token, fetch: async (url, init) => {
|
||||
requests.push({ url, init });
|
||||
return init.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json({ installationId: id, transport: "web", enabled: true, version: 1 });
|
||||
} });
|
||||
await assert.rejects(api.revoke(id, scope));
|
||||
await assert.rejects(api.register(id, { endpoint: "https://provider.test/fixture" }, scope));
|
||||
assert.deepEqual(requests, [], "wrong authority cannot send a mutation or falsely acknowledge cleanup");
|
||||
token = "original-fixture-token";
|
||||
const deleting = api.revoke(id, scope); token = "rotated-fixture-token"; await deleting;
|
||||
token = "original-fixture-token";
|
||||
const registering = api.register(id, { endpoint: "https://provider.test/fixture" }, scope); token = "rotated-fixture-token"; await registering;
|
||||
assert.equal(requests.length, 2);
|
||||
assert.ok(requests.every(({ init }) => init.headers.Authorization === "Bearer original-fixture-token"));
|
||||
assert.ok(requests.every(({ url }) => !url.includes("fixture-token")));
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
|
||||
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
||||
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
||||
} from "../src/lib/agent-room.ts";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
@@ -16,6 +16,9 @@ const page = (cursor, messages = [], hasMore = false) => ({
|
||||
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
|
||||
});
|
||||
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
|
||||
const sourceA = "4599d348-e053-4937-987d-1392746fb2ce", sourceB = "22222222-2222-4222-8222-222222222222";
|
||||
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)]));
|
||||
|
||||
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" } });
|
||||
@@ -113,6 +116,93 @@ test("read acknowledgments coalesce concurrent seen snapshots and reject unackno
|
||||
await assert.rejects(broken.request("2"), /did not acknowledge/);
|
||||
});
|
||||
|
||||
test("source replacement through GET or WS quiesces queued old-history reads without relabeling their cursor", async () => {
|
||||
for (const via of ["GET", "WS"]) {
|
||||
let room = sourceRoom(); const saved = [], invalidated = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); throw new Error("temporarily offline"); },
|
||||
apply: read => { room = applyAgentReadState(room, read); }, invalidated: error => invalidated.push(error),
|
||||
});
|
||||
await assert.rejects(session.request("100", sourceA), /temporarily offline/);
|
||||
const replacement = { cursor: "0", sourceId: sourceB, updatedAt: null };
|
||||
const incoming = via === "WS" ? parseAgentRoomEvent(JSON.stringify({ type: "read_state", ...replacement })) : replacement;
|
||||
assert.throws(() => session.receive(incoming), AgentReadSourceChangedError);
|
||||
for (let i = 0; i < 3; i++) await session.flush();
|
||||
await assert.rejects(session.request("100", sourceA), AgentReadSourceChangedError);
|
||||
await assert.rejects(session.request("5", sourceB), AgentReadSourceChangedError);
|
||||
assert.deepEqual(saved, [{ cursor: "100", sourceId: sourceA }], `${via}: no write to B and no retry loop`);
|
||||
assert.equal(room.sourceId, sourceA); assert.equal(room.readCursor, "90"); assert.equal(room.cursor, "100");
|
||||
assert.equal(session.sourceId(), sourceA); assert.equal(invalidated.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test("source replacement cancels an in-flight old read and prevents a coalesced larger write", async () => {
|
||||
let room = sourceRoom(); const gate = deferred(), saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return gate.promise; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
const first = session.request("100", sourceA), second = session.request("110", sourceA);
|
||||
assert.throws(() => session.receive({ cursor: "0", sourceId: sourceB, updatedAt: null }), AgentReadSourceChangedError);
|
||||
gate.resolve({ cursor: "100", sourceId: sourceA, updatedAt: null });
|
||||
await Promise.all([first, second]); await session.flush();
|
||||
assert.deepEqual(saved, [{ cursor: "100", sourceId: sourceA }]);
|
||||
assert.equal(room.sourceId, sourceA); assert.equal(room.readCursor, "90");
|
||||
});
|
||||
|
||||
test("mismatched acknowledgment and server409 stop retries without applying a read to new history", async () => {
|
||||
for (const mode of ["ack", "409"]) {
|
||||
let room = sourceRoom(), saves = 0, invalidated = 0;
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async () => { saves++; if (mode === "409") throw Object.assign(new Error("cursor not valid for this source"), { status: 409 });
|
||||
return { cursor: "100", sourceId: sourceB, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); }, invalidated: () => invalidated++,
|
||||
});
|
||||
await assert.rejects(session.request("100", sourceA), AgentReadSourceChangedError);
|
||||
await session.flush(); await session.flush();
|
||||
await assert.rejects(session.request("101", sourceA), AgentReadSourceChangedError);
|
||||
assert.equal(saves, 1); assert.equal(invalidated, 1); assert.equal(room.readCursor, "90"); assert.equal(room.sourceId, sourceA);
|
||||
}
|
||||
});
|
||||
|
||||
test("read writer stops after an ignored acknowledgment instead of repeating the same PUT forever", async () => {
|
||||
let saves = 0;
|
||||
const writer = createAgentReadWriter({ current: () => "0", save: async cursor => { saves++; return { cursor, updatedAt: null }; }, apply: () => {} });
|
||||
await assert.rejects(writer.request("2"), /did not acknowledge/);
|
||||
await writer.flush(); await writer.request("3"); assert.equal(saves, 1);
|
||||
});
|
||||
|
||||
test("legacy source-absent sessions stay compatible but cannot adopt a source after rendering old history", async () => {
|
||||
let room = applyAgentPage(createAgentRoomState(), page("5", [message(5)])); const saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return { cursor, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
session.receive({ cursor: "0", updatedAt: null });
|
||||
assert.equal(room.sourceId, null);
|
||||
await session.request("5", room.sourceId);
|
||||
assert.deepEqual(saved, [{ cursor: "5", sourceId: null }]); assert.equal(room.readCursor, "5");
|
||||
assert.throws(() => session.receive({ cursor: "0", sourceId: sourceB, updatedAt: null }), AgentReadSourceChangedError);
|
||||
await session.flush(); assert.equal(saved.length, 1);
|
||||
assert.throws(() => applyAgentReadState(applyAgentPage(createAgentRoomState(), page("5", [message(5)])), { cursor: "0", sourceId: sourceA, updatedAt: null }), AgentReadSourceChangedError);
|
||||
assert.throws(() => applyAgentReadState(sourceRoom(), { cursor: "0", updatedAt: null }), AgentReadSourceChangedError);
|
||||
});
|
||||
|
||||
test("rendered snapshot source must match the pinned session and new sessions cannot read before binding", async () => {
|
||||
for (const initial of [createAgentRoomState(), sourceRoom()]) {
|
||||
const session = createAgentReadSession({ initial, save: async () => assert.fail("must not send unbound or relabeled reads"), apply: () => {} });
|
||||
await assert.rejects(session.request("100", sourceB), AgentReadSourceChangedError); await session.flush();
|
||||
}
|
||||
let room = createAgentRoomState(); const saved = [];
|
||||
const session = createAgentReadSession({ initial: room,
|
||||
save: async (cursor, sourceId) => { saved.push({ cursor, sourceId }); return { cursor, sourceId, updatedAt: null }; },
|
||||
apply: read => { room = applyAgentReadState(room, read); },
|
||||
});
|
||||
session.receive({ cursor: "0", sourceId: sourceA, updatedAt: null });
|
||||
room = applyAgentPage(room, page("5", [message(5)])); await session.request(room.cursor, room.sourceId);
|
||||
assert.deepEqual(saved, [{ cursor: "5", sourceId: sourceA }]); assert.equal(room.readCursor, "5");
|
||||
});
|
||||
|
||||
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
|
||||
let stored = [], writes = 0;
|
||||
const storage = { read: () => structuredClone(stored), write: rows => {
|
||||
@@ -164,8 +254,20 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
|
||||
const url = client.websocketUrl("https://sybil.example/");
|
||||
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
|
||||
assert.equal(new URL(url).search, "");
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42", clientKind: "web" });
|
||||
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42", clientKind: "web" });
|
||||
});
|
||||
|
||||
test("client sends the pinned source and exposes409 without requiring a JSON response body", async () => {
|
||||
for (const json of [true, false]) {
|
||||
let body;
|
||||
const client = new AgentRoomClient({ fetch: async (_url, init) => {
|
||||
body = JSON.parse(init.body);
|
||||
return json ? Response.json({ message: "Read source changed" }, { status: 409 }) : new Response(null, { status: 409 });
|
||||
} });
|
||||
await assert.rejects(client.markRead("100", undefined, sourceA), error => error.status === 409);
|
||||
assert.deepEqual(body, { cursor: "100", sourceId: sourceA });
|
||||
}
|
||||
});
|
||||
|
||||
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
|
||||
|
||||
Reference in New Issue
Block a user