Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b743523fd5 | ||
|
|
ee845c9fa3 | ||
|
|
9098fee32a | ||
|
|
489c347976 | ||
|
|
7b48201e28 | ||
|
|
671ee98ab1 | ||
|
|
f59c7db7bb | ||
|
|
15efbcc205 | ||
|
|
ff48a25186 | ||
|
|
e681e9e05c | ||
|
|
d5427926d5 | ||
|
|
bbaa5e8859 | ||
|
|
7510603b77 | ||
|
|
b9a6f5dd7d | ||
|
|
5833a0868e | ||
|
|
f534f76b23 | ||
|
|
e33adb8bbf | ||
|
|
60a35509d4 | ||
|
|
a1c0264d36 | ||
|
|
80748f764f | ||
|
|
2e62bfa736 | ||
|
|
844f48e9e0 | ||
|
|
8b125c13a5 |
+207
-6
@@ -23,6 +23,8 @@ Chat upload limits:
|
||||
|
||||
### `GET /v1/auth/session`
|
||||
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
|
||||
- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`).
|
||||
- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass.
|
||||
|
||||
## Models
|
||||
|
||||
@@ -603,10 +605,32 @@ durable entries and can advance without visible messages. Use returned cursors;
|
||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||
messages.
|
||||
|
||||
The web Agent starts with one cursorless `limit=100` window and renders it in
|
||||
chronological order, initially positioned at its newest end. `hasMore` on this
|
||||
window or a `before` page means **older** source entries exist; on an `after`
|
||||
page it means **newer** entries remain. Each **Load older messages** action fetches
|
||||
one `before=<beforeCursor>&limit=100` page, even if that page has no visible rows.
|
||||
Older pages do not advance the live replay/read cursor or overwrite newer copies
|
||||
of the same message. Initial loading never walks forward from zero or drains
|
||||
older history. Reconnect recovery still follows `after` from the accepted live
|
||||
cursor. Aborted/replaced room sessions discard late responses.
|
||||
|
||||
Web unread counts cover loaded public replies. A `+` suffix (or a dot when no
|
||||
loaded reply is known unread) indicates that earlier messages may also be unread;
|
||||
the API does not supply an exact total for unloaded history. Observing the latest
|
||||
end in a focused visible conversation marks it read through that rendered cursor,
|
||||
including earlier unloaded history. Fetching or reading older pages does not
|
||||
acknowledge newer replies. An internal-only window with no rendered public
|
||||
messages does not acknowledge history; older loading remains an explicit action.
|
||||
Initial source-entry/network work is bounded; retained
|
||||
messages/DOM grow with explicit older loads and live updates. Offscreen web rows
|
||||
use CSS `content-visibility: auto`, which skips rendering work while retaining
|
||||
chronological DOM, selection, find and accessibility; it is not DOM virtualization.
|
||||
|
||||
### `POST /v1/agent/messages`
|
||||
|
||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||
Content must contain non-whitespace text and is limited to 40,000 characters;
|
||||
Content is limited to 40,000 characters; it must contain non-whitespace text unless attachment IDs are included;
|
||||
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
|
||||
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
|
||||
`wait: false`.
|
||||
@@ -614,21 +638,198 @@ network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
|
||||
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
|
||||
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
|
||||
completion: watch the room for answers, including unsolicited later replies.
|
||||
Multiple requests can be accepted while the agent works. Attachments are not yet supported.
|
||||
Multiple requests can be accepted while the agent works. Optional `attachments: [{"id":"att_…"}]` accepts uploaded files as specified below; no client paths or inline file bytes are accepted.
|
||||
The proxy verifies successful receipts contain a positive submission ID, a known
|
||||
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`.
|
||||
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. Attached sends also require matching ordered `attachmentIds` in the receipt.
|
||||
Invalid receipts return 502; delivery remains uncertain, so retry using the original
|
||||
`requestId` to avoid creating a duplicate message.
|
||||
|
||||
### `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)
|
||||
|
||||
`POST /v1/agent/attachments`: multipart/form-data with exactly one `file` part (original UTF-8 filename in Content-Disposition) and one `uploadId` field (client-generated UUID, stable across retries). Either part order is accepted. Request limit 8 MiB + 64 KiB. 201 new / 200 identical retry:
|
||||
|
||||
```json
|
||||
{"attachment":{"id":"att_12345678-1234-4234-8234-123456789abc","kind":"text","filename":"notes.txt","mimeType":"text/plain","sizeBytes":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","workspacePath":"/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"}}
|
||||
```
|
||||
|
||||
`workspacePath` is OPTIONAL for clients, canonical absolute runtime path when supplied. Never a host path, URL, or request parameter. Descriptors must retain filename/mimeType/sizeBytes/sha256. `kind` is `image`, `text`, or `file`. IDs are opaque. UUID retries must match normalized filename, effective MIME type and bytes, otherwise 409 `idempotency_conflict`. Filename is NFC normalized, 1–255 UTF-8 bytes, no slash/backslash/control characters, not `.`/`..`. A leading dot becomes `_` in the on-disk basename only; display filename remains unchanged.
|
||||
|
||||
`POST /v1/agent/messages`:
|
||||
|
||||
```json
|
||||
{"content":"Read these notes","requestId":"client-stable-id","attachments":[{"id":"att_12345678-1234-4234-8234-123456789abc"}]}
|
||||
```
|
||||
|
||||
`content` is required, <=40,000 characters; empty/whitespace is allowed with attachments. `requestId` is 1–256 characters and must survive retries. Omit attachments for text-only requests. At most 8 distinct references, 32 MiB total original bytes. References contain ONLY `id`; filenames, paths, URLs, base64 and forged workspacePath are rejected. Root delivery uses channel sybil, followUp and wait=false. 202 receipt:
|
||||
|
||||
```json
|
||||
{"id":123,"status":"queued","messageId":"inbound:sybil:client-stable-id","attachmentIds":["att_12345678-1234-4234-8234-123456789abc"]}
|
||||
```
|
||||
|
||||
Receipt status also permits placed/done/unanswered. `attachmentIds` is present for attached input, in request order, and MUST be checked before treating delivery as acknowledged. Caption + ordered IDs are durably bound to requestId; conflicting replay 409. Native admission atomically stores the input payload, binding, quota charge and worker eligibility; a competing text-only input cannot falsely acknowledge attachment IDs. Network errors may be retried with the same request/upload IDs. Never auto-create a fresh ID on ambiguous failure.
|
||||
|
||||
History, replay and queued activity use the existing public message schema with `metadata.agentAttachments` containing these descriptors. `content` is the original caption, or `Attached: notes.txt.` for a file-only message. No model-only manifest, text preview or base64 is inserted into public message content. Ordinary chat's `metadata.attachments` schema is unchanged. Raw tool rows stay in the protocol; client transcript filters stay unchanged.
|
||||
|
||||
`GET /v1/agent/attachments/:id/content`: authenticated original bytes, Content-Length, Content-Disposition attachment (safe ASCII filename and UTF-8 filename*), Cache-Control private/no-store and nosniff. No URLs containing tokens, public static mount, redirects or inline serving. Browser previews use authenticated fetch + revocable blob URL. Native downloads hold one of four slots through response finish/close/error, with a 30-second outbound timeout and disconnect cleanup. Raster types retain their validated MIME type; text downloads use text/plain; other files application/octet-stream. Clients can display/copy workspacePath as text.
|
||||
|
||||
### Attachment capability and errors
|
||||
|
||||
Optional `GET /v1/agent` field:
|
||||
|
||||
```json
|
||||
{"capabilities":{"attachments":{"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,"remainingBytes":1073741824,"maxConcurrentUploads":2,"maxConcurrentDecodes":1}}}
|
||||
```
|
||||
|
||||
Quota values are a snapshot, not a reservation. Absence disables attachment selection and sending. Backend rejects attachments against an old/unsafe upstream rather than allowing dropped references. Sybil also requires ADMIN_TOKEN for attachments; text-only open development behavior remains compatible.
|
||||
|
||||
Errors: `{ "error": true, "message": "...", "statusCode": 507, "code": "attachment_quota_exceeded", "quotaBytes": 1073741824, "remainingBytes": 1200, "requiredBytes": 20000 }`. Quota numbers are included for 507; no host filesystem information. 400 invalid_upload/invalid_attachment; 401 authentication; 404 attachment_not_found; 409 idempotency_conflict; 413 attachment_limit_exceeded; 415 unsupported_media_type; 422 invalid_image/image_model_unsupported; 429 attachments_busy with Retry-After: 2; 503 attachments_unavailable; 507 attachment_quota_exceeded; 502 unavailable/invalid upstream at proxy. Clients show message and retain retry identity. Remove from composer does not delete completed durable uploads.
|
||||
|
||||
### Runtime access and release requirements
|
||||
|
||||
The runtime resolves IDs to persistent `WORKSPACE_DIR/uploads/<id>/<safe-original-basename>` files (default `/data/workspace/uploads/...`), backed by the existing `/data` mount. Images are native model image blocks, never remote links: supported PNG/JPEG/GIF/WebP originals are at most 6 MiB and are prepared as a bounded static first frame (orientation corrected, at most 2000×2000 and 4.5 MiB base64, further limited by the actual model). Reject unsupported image models; no arbitrary document/file provider blocks. Other files are at most 8 MiB and are available through actual workspace read/bash tools. Valid recognized UTF-8 text receives a bounded preview of 16,000 Unicode characters per file / 64,000 per input. PDF/archive/binary files are paths plus metadata. Original hash, size and confinement are validated during resolution. Root and workers share the same workspace; explicit `subagent` spawn/send `attachmentIds` persist in durable Reporter tasks and use the same resolver. Preparation saturation retries through a durable checkpoint after two seconds using the same Reporter request ID. Permanent admission failures, including quota exhaustion, reach the parent through the durable worker-report path. Uploads never auto-execute.
|
||||
|
||||
Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reservations cover originals, static renditions, staging, metadata and conservative attachment input copies. Quota is conservative accounting, not free disk space. No complete upload expiry or delete endpoint is enabled. Removed composer files stay stored; crashed/uncertain reservations stay charged across restart until deliberate operator reconciliation. Ordinary authorized workspace tool writes and unrelated conversation history are outside attachment-ingestion accounting. Runtime uploads default to two concurrently (`SYBIL_AGENT_ATTACHMENT_UPLOADS`, max8), image decodes one (`SYBIL_AGENT_ATTACHMENT_DECODES`, max4); bounded input preparation and proxy transfers return429 when saturated. Disconnected proxy transfers cancel upstream work and release slots.
|
||||
|
||||
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
|
||||
|
||||
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. Deployment must use native token auth and matching backend credentials before clients advertise attachments; no additional public mount is needed.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
### Assistant image output (v1)
|
||||
|
||||
The independent optional capability `capabilities.assistantImages: {"version":1}`
|
||||
advertises the runtime's root-only `output_image` tool. It does **not** enable input
|
||||
upload controls; those still require `capabilities.attachments.version == 1`.
|
||||
Authenticated downloads accept either supported capability. Open proxies refuse
|
||||
history and socket access if either private attachment capability is advertised.
|
||||
Capability absence does not hide previously stored assistant image messages.
|
||||
|
||||
The agent invokes `output_image({path, uploadId, filename?, caption?})` with a stable
|
||||
UUID retry identity and a local persistent raster exported beneath the workspace's
|
||||
`artifacts/` or `downloads/` directory. It copies validated bytes into the existing
|
||||
attachment store and publishes one durable assistant message. This is an internal
|
||||
agent tool, with no browser/iOS publishing endpoint or client-supplied role.
|
||||
Workers cannot publish through it. No URL fetch, remote image proxy, file execution
|
||||
or implicit forwarding of native tool image blocks is performed. Generation is
|
||||
available only when a separate generation provider/tool is actually installed.
|
||||
|
||||
Output uses the same original-byte/hash/quota limits (PNG/JPEG/GIF/WebP, 6 MiB,
|
||||
50 million pixels, 16,384 pixels per axis), authenticated content route and backup
|
||||
policy as uploads. Sources must be confined regular files without symlink/hardlink
|
||||
substitution or private path segments. Upload IDs are bound to output provenance;
|
||||
conflicting arguments fail and identical retries retain their message/attachment IDs.
|
||||
Retries reuse the stored snapshot even if the original source moves or changes;
|
||||
publishing changed bytes requires a new upload UUID.
|
||||
|
||||
Public output rows have `id: "outbound:<entryId>"`, `role: "assistant"`,
|
||||
`metadata.kind: "message"`, `metadata.status: "complete"`, and
|
||||
`metadata.agentAttachments` containing image descriptors. `content` is the optional
|
||||
caption, otherwise an empty string; attachment-only rows remain visible. Descriptors
|
||||
contain only `id`, `kind`, safe `filename`, `mimeType`, `sizeBytes` and `sha256`.
|
||||
They contain no workspace/source path, URL, credential, or image bytes. The public
|
||||
tool row redacts output tool arguments/results; internal task/submission provenance
|
||||
stays in the existing durable outbox.
|
||||
|
||||
Web/iOS automatically render assistant rasters in fixed preview frames, retrieving
|
||||
only authenticated ID-based originals, checking size and SHA-256, rejecting
|
||||
redirects, and offering retry/download. Web limits downloads to two at a time and
|
||||
uses revocable blob URLs. Clients discard assistant descriptor paths and unsupported
|
||||
types. Agent Markdown cannot load images; external links remain explicit navigation.
|
||||
Image-bearing push alerts use **You have a new message.**, including when a caption
|
||||
exists, and never include attachment names, paths, bytes or URLs.
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -15,6 +15,7 @@ Transport:
|
||||
|
||||
Authentication:
|
||||
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
|
||||
- The web client checks [`GET /v1/auth/session`](rest.md#get-v1authsession) before opening streams. An initial missing-token response prompts sign-in without an error; an incorrect supplied token is an authentication failure. The web sign-in form has no tokenless bypass; open development servers still authenticate automatically. Stream authorization requirements are unchanged.
|
||||
|
||||
## Chat Completion Request Body
|
||||
|
||||
@@ -335,3 +336,87 @@ cursor. Missing agent configuration closes with 4503.
|
||||
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
|
||||
History remains durable in Sybil Agent. Shared read position is separate from delivery:
|
||||
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
||||
|
||||
The web client first accepts source-bound read state and one recent REST history
|
||||
window, then authenticates this socket with that window's `cursor`. Initial
|
||||
`hasMore` refers to older history and does not trigger a forward replay from zero.
|
||||
Older pages load on demand using a separate `beforeCursor`; delayed older pages
|
||||
preserve live edits and never change the socket replay position. Reconnects and
|
||||
foreground catch-up use the last accepted live cursor as before. Incoming output
|
||||
follows the viewport only while it is pinned to the newest end; older prepends and
|
||||
dynamic heights preserve the reader's keyed row. Only a focused, visible, settled
|
||||
latest view acknowledges the rendered cursor, marking the conversation read
|
||||
through that point. See [web history windows and unread counts](rest.md#get-v1agentmessages)
|
||||
for unloaded-history count semantics and rendering limits.
|
||||
|
||||
### Agent attachment history, queued activity and replay
|
||||
|
||||
Assistant image output uses the same `messages` frames and durable history rows,
|
||||
with `role: "assistant"`, `id: "outbound:<entryId>"`, `metadata.kind: "message"`,
|
||||
`metadata.status: "complete"`, and `metadata.agentAttachments`. Each image descriptor
|
||||
contains `id`, `kind: "image"`, `filename`, `mimeType`, `sizeBytes` and `sha256` only;
|
||||
there is no source/workspace path or URL. An omitted caption yields empty `content`;
|
||||
clients must retain this image-only row. Merge/replay by the unchanged message ID,
|
||||
render authenticated image bytes inline in a fixed frame, and retain loaded history
|
||||
even if the optional `capabilities.assistantImages.version: 1` is absent later.
|
||||
That capability does not authorize input uploads. Native tool image blocks are not
|
||||
automatically forwarded; the root agent explicitly calls `output_image`. Public
|
||||
output tool rows redact arguments/results, and Agent Markdown does not fetch images.
|
||||
See [assistant image output](./rest.md#assistant-image-output-v1) for publishing,
|
||||
retry, auth and generic push-preview semantics. No new socket event is introduced.
|
||||
|
||||
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
|
||||
|
||||
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "inbound:sybil:client-stable-id",
|
||||
"role": "user",
|
||||
"content": "Read these notes",
|
||||
"createdAt": "2026-10-14T18:00:00.000Z",
|
||||
"metadata": {
|
||||
"kind": "message",
|
||||
"channel": "sybil",
|
||||
"status": "queued",
|
||||
"agentAttachments": [{
|
||||
"id": "att_12345678-1234-4234-8234-123456789abc",
|
||||
"kind": "text",
|
||||
"filename": "notes.txt",
|
||||
"mimeType": "text/plain",
|
||||
"sizeBytes": 12,
|
||||
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
"workspacePath": "/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`workspacePath` is optional, authoritative runtime text for display/copy, never a download URL or send parameter. Only `attachments:[{id}]` goes into POST messages. Committed/replayed messages retain the same ordered descriptors and original caption; file-only content is `Attached: notes.txt.`. Internal model manifests/previews and native base64 are excluded from public user message content. Root/worker native inputs and raw authorized model endpoints may contain the real image blocks; native token auth must protect all of them.
|
||||
|
||||
Merge queued and committed messages by stable message ID. A matching ID alone does not confirm an attachment-bearing optimistic message: compare the ordered `metadata.agentAttachments[].id` against its local IDs as well. Retain descriptors, error and retry identity on a mismatch or uncertain receipt; show the error inline for touch clients. Matching canonical echoes can acknowledge delivery even if HTTP was lost. Preserve raw tool rows/cursors in client room state and existing transcript filtering. Old clients can ignore the additive metadata and still see a meaningful caption.
|
||||
|
||||
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
|
||||
|
||||
@@ -15,7 +15,9 @@ let package = Package(
|
||||
targets: ["Sybil"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.1")
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.1"),
|
||||
// Use MarkdownUI's existing parser to remove Agent image/HTML nodes structurally.
|
||||
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.7.1")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
@@ -23,7 +25,9 @@ let package = Package(
|
||||
.target(
|
||||
name: "Sybil",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
.product(name: "cmark-gfm", package: "swift-cmark"),
|
||||
.product(name: "cmark-gfm-extensions", package: "swift-cmark")
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
.testTarget(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import MarkdownUI
|
||||
import cmark_gfm
|
||||
import cmark_gfm_extensions
|
||||
|
||||
/// Agent images come only from validated attachment descriptors. Rewrite parsed nodes,
|
||||
/// including reference/nested images, so neither MarkdownUI image loader receives a URL.
|
||||
enum AgentMessageMarkdown {
|
||||
static func content(_ source: String) -> MarkdownContent {
|
||||
cmark_gfm_core_extensions_ensure_registered()
|
||||
guard let parser = cmark_parser_new(CMARK_OPT_DEFAULT) else { return literal(source) }
|
||||
defer { cmark_parser_free(parser) }
|
||||
for name in ["table", "strikethrough", "autolink", "tasklist"] {
|
||||
if let ext = cmark_find_syntax_extension(name) { cmark_parser_attach_syntax_extension(parser, ext) }
|
||||
}
|
||||
source.withCString { cmark_parser_feed(parser, $0, source.utf8.count) }
|
||||
guard let document = cmark_parser_finish(parser) else { return literal(source) }
|
||||
defer { cmark_node_free(document) }
|
||||
stripImagesAndHTML(document)
|
||||
guard let markdown = cmark_render_commonmark(document, CMARK_OPT_DEFAULT, 0) else { return literal(source) }
|
||||
defer { free(markdown) }
|
||||
return MarkdownContent(String(cString: markdown))
|
||||
}
|
||||
|
||||
private static func literal(_ source: String) -> MarkdownContent {
|
||||
MarkdownContent { Paragraph { source } }
|
||||
}
|
||||
|
||||
private static func stripImagesAndHTML(_ parent: UnsafeMutablePointer<cmark_node>) {
|
||||
var child = cmark_node_first_child(parent)
|
||||
while let node = child {
|
||||
child = cmark_node_next(node)
|
||||
let type = cmark_node_get_type(node)
|
||||
if type == CMARK_NODE_IMAGE || type == CMARK_NODE_HTML_INLINE || type == CMARK_NODE_HTML_BLOCK {
|
||||
let text = type == CMARK_NODE_IMAGE ? altText(node) : htmlText(cmark_node_get_literal(node).map { String(cString: $0) } ?? "")
|
||||
let replacement = cmark_node_new(type == CMARK_NODE_HTML_BLOCK ? CMARK_NODE_PARAGRAPH : CMARK_NODE_TEXT)!
|
||||
if type == CMARK_NODE_HTML_BLOCK {
|
||||
let inline = cmark_node_new(CMARK_NODE_TEXT)!
|
||||
// cmark includes the HTML block's terminating newline in its literal.
|
||||
// It is a block delimiter, not an extra line in the visible alt text.
|
||||
cmark_node_set_literal(inline, text.trimmingCharacters(in: .newlines))
|
||||
cmark_node_append_child(replacement, inline)
|
||||
} else {
|
||||
cmark_node_set_literal(replacement, text)
|
||||
}
|
||||
cmark_node_replace(node, replacement)
|
||||
cmark_node_free(node)
|
||||
} else {
|
||||
stripImagesAndHTML(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func altText(_ parent: UnsafeMutablePointer<cmark_node>) -> String {
|
||||
var result = "", child = cmark_node_first_child(parent)
|
||||
while let node = child {
|
||||
let type = cmark_node_get_type(node)
|
||||
if type == CMARK_NODE_TEXT || type == CMARK_NODE_CODE {
|
||||
result += cmark_node_get_literal(node).map { String(cString: $0) } ?? ""
|
||||
} else if type == CMARK_NODE_SOFTBREAK || type == CMARK_NODE_LINEBREAK {
|
||||
result += " "
|
||||
} else if type != CMARK_NODE_HTML_INLINE {
|
||||
result += altText(node)
|
||||
}
|
||||
child = cmark_node_next(node)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func htmlText(_ source: String) -> String {
|
||||
// HTML is never rendered. Retain ordinary text and img alt attributes as text
|
||||
// nodes; quoted '>' characters cannot expose the rest of an image attribute.
|
||||
let tags = try! NSRegularExpression(pattern: #"(?s)<(?:[^>"']|"[^"]*"|'[^']*')*>"#)
|
||||
let alt = try! NSRegularExpression(pattern: #"(?is)^<img\b.*?\s+alt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#)
|
||||
var result = source
|
||||
for tag in tags.matches(in: source, range: NSRange(source.startIndex..., in: source)).reversed() {
|
||||
let range = Range(tag.range, in: source)!
|
||||
let value = String(source[range])
|
||||
let match = alt.firstMatch(in: value, range: NSRange(value.startIndex..., in: value))
|
||||
let label = match.flatMap { match in
|
||||
(1...3).compactMap { Range(match.range(at: $0), in: value).map { String(value[$0]) } }.first
|
||||
} ?? ""
|
||||
result.replaceSubrange(Range(tag.range, in: result)!, with: label)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
struct APIConfiguration: Sendable {
|
||||
var baseURL: URL
|
||||
@@ -17,7 +18,7 @@ struct AnyEncodable: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClienting {
|
||||
private let configuration: APIConfiguration
|
||||
private let session: URLSession
|
||||
|
||||
@@ -41,10 +42,14 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
}
|
||||
|
||||
func agentDescriptor() async throws -> AgentDescriptor {
|
||||
let value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
var value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
|
||||
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
||||
}
|
||||
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false {
|
||||
value.capabilities?.attachments = nil
|
||||
value.capabilities?.assistantImages = nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -62,8 +67,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 {
|
||||
@@ -72,6 +120,72 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
|
||||
if !submission.attachmentIDs.isEmpty {
|
||||
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
guard submission.attachmentIDs.count <= limits.countLimit else { throw AgentAttachmentIssue.local("Too many attachments for this Agent.", code: "attachment_limit_exceeded") }
|
||||
}
|
||||
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(submission), responseType: AgentReceipt.self)
|
||||
try receipt.validate(requestID: submission.requestId, attachmentIDs: submission.attachmentIDs)
|
||||
return receipt
|
||||
}
|
||||
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
||||
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
let prepared = try AgentAttachmentPreparation.prepare(file.data, filename: file.filename, mimeType: file.mimeType, limits: limits)
|
||||
let multipart = try AgentMultipart(file: prepared, uploadID: uploadID)
|
||||
var request = try makeRequest(path: "/v1/agent/attachments", method: "POST", body: nil, acceptsSSE: false)
|
||||
request.timeoutInterval = 120
|
||||
request.setValue("multipart/form-data; boundary=\(multipart.boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = multipart.body
|
||||
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: 64 * 1024)
|
||||
guard status == 200 || status == 201 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
|
||||
struct UploadResponse: Decodable { var attachment: AgentAttachment }
|
||||
guard let response = try? JSONDecoder().decode(UploadResponse.self, from: data) else {
|
||||
throw AgentAttachmentIssue.local("Invalid attachment upload receipt. Retry to confirm.", code: "invalid_upstream_response")
|
||||
}
|
||||
let attachment = response.attachment
|
||||
try attachment.validate()
|
||||
guard attachment.filename == prepared.filename, attachment.mimeType == prepared.mimeType,
|
||||
attachment.sizeBytes == prepared.data.count, attachment.sha256 == prepared.sha256 else {
|
||||
throw AgentAttachmentIssue.local("The upload receipt does not match the selected file.", code: "invalid_upstream_response")
|
||||
}
|
||||
return attachment
|
||||
}
|
||||
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data {
|
||||
try attachment.validate()
|
||||
guard configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw AgentAttachmentIssue.unavailable }
|
||||
var request = try makeRequest(path: "/v1/agent/attachments", method: "GET", body: nil, acceptsSSE: false)
|
||||
request.url = try configuration.agentAttachmentContentURL(id: attachment.id)
|
||||
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
|
||||
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: attachment.sizeBytes)
|
||||
guard status == 200 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
|
||||
guard data.count == attachment.sizeBytes,
|
||||
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == attachment.sha256 else {
|
||||
throw AgentAttachmentIssue.local("The downloaded original failed its size or checksum check. Retry the download.", code: "invalid_upstream_response")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func attachmentBytes(_ request: URLRequest, maximum: Int) async throws -> (Data, Int, String?) {
|
||||
let (stream, response) = try await session.bytes(for: request, delegate: AgentAttachmentRedirectPolicy())
|
||||
defer { stream.task.cancel() }
|
||||
guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse }
|
||||
let success = (200...299).contains(response.statusCode)
|
||||
let limit = success ? maximum : 64 * 1024
|
||||
guard response.expectedContentLength <= Int64(limit) else {
|
||||
throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response")
|
||||
}
|
||||
var data = Data()
|
||||
for try await byte in stream {
|
||||
try Task.checkCancellation()
|
||||
guard data.count < limit else { throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response") }
|
||||
data.append(byte)
|
||||
}
|
||||
return (data, response.statusCode, response.value(forHTTPHeaderField: "Retry-After"))
|
||||
}
|
||||
|
||||
func agentSocket(after: String) async throws -> any AgentSocket {
|
||||
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
|
||||
task.maximumMessageSize = 8 * 1024 * 1024
|
||||
@@ -355,7 +469,7 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
let response: URLResponse
|
||||
|
||||
do {
|
||||
(data, response) = try await session.data(for: request)
|
||||
(data, response) = try await session.data(for: request, delegate: path.hasPrefix("/v1/agent") ? AgentAttachmentRedirectPolicy() : nil)
|
||||
} catch {
|
||||
if let urlError = error as? URLError, urlError.code == .cancelled {
|
||||
SybilLog.debug(
|
||||
@@ -369,6 +483,10 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting {
|
||||
throw wrapped
|
||||
}
|
||||
|
||||
if path.hasPrefix("/v1/agent"), let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode),
|
||||
(try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue?["code"]?.stringValue != nil {
|
||||
throw AgentAttachmentIssue.response(data, status: http.statusCode, retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
|
||||
}
|
||||
try validate(response: response, data: data)
|
||||
|
||||
do {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import Foundation
|
||||
|
||||
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
|
||||
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
|
||||
actor AgentAttachmentDownloads {
|
||||
private var active = 0
|
||||
private var invalidated = false
|
||||
private var transfers: [UUID: Task<Data, any Error>] = [:]
|
||||
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
|
||||
private var cache: [AgentAttachment: Data] = [:]
|
||||
private var order: [AgentAttachment] = []
|
||||
private var byteCount = 0
|
||||
|
||||
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
|
||||
try Task.checkCancellation()
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
if let value = cache[attachment] { return value }
|
||||
try await acquire()
|
||||
defer { release() }
|
||||
try Task.checkCancellation()
|
||||
if let value = cache[attachment] { return value }
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
let id = UUID()
|
||||
let transfer = Task { try await client.agentDownload(attachment) }
|
||||
transfers[id] = transfer
|
||||
defer { transfers[id] = nil }
|
||||
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
|
||||
try Task.checkCancellation()
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
|
||||
let oldest = order.removeFirst()
|
||||
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
|
||||
}
|
||||
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
|
||||
return value
|
||||
}
|
||||
func invalidate() {
|
||||
invalidated = true
|
||||
transfers.values.forEach { $0.cancel() }
|
||||
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
|
||||
waiters = []; cache = [:]; order = []; byteCount = 0
|
||||
}
|
||||
private func acquire() async throws {
|
||||
guard !invalidated else { throw CancellationError() }
|
||||
if active < 2 { active += 1; return }
|
||||
let id = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
|
||||
else { waiters.append((id, continuation)) }
|
||||
}
|
||||
} onCancel: { Task { await self.cancel(id) } }
|
||||
}
|
||||
private func cancel(_ id: UUID) {
|
||||
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
|
||||
waiters.remove(at: index).1.resume(throwing: CancellationError())
|
||||
}
|
||||
private func release() {
|
||||
if waiters.isEmpty { active -= 1 }
|
||||
else { waiters.removeFirst().1.resume() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct AgentDraftItem: Codable, Equatable, Identifiable, Sendable {
|
||||
enum State: String, Codable, Sendable { case loading, uploading, ready, failed }
|
||||
var id: String = UUID().uuidString // Stable multipart uploadId, including across relaunch.
|
||||
var filename: String
|
||||
var mimeType: String?
|
||||
var sizeBytes: Int?
|
||||
var state: State = .loading
|
||||
var attachment: AgentAttachment?
|
||||
var issue: AgentAttachmentIssue?
|
||||
}
|
||||
struct AgentDraftSnapshot: Codable, Sendable {
|
||||
var caption = ""
|
||||
var items: [AgentDraftItem] = []
|
||||
var id: String? = nil // Optional for drafts saved before durable outbox handoff tracking.
|
||||
}
|
||||
struct AgentDraftStorage: Sendable {
|
||||
var read: @Sendable () throws -> AgentDraftSnapshot
|
||||
var write: @Sendable (AgentDraftSnapshot) throws -> Void
|
||||
var stage: @Sendable (String, Data) throws -> Void
|
||||
var bytes: @Sendable (String) throws -> Data
|
||||
var remove: @Sendable (String) -> Void
|
||||
|
||||
static func file(baseURL: URL, root: URL = .applicationSupportDirectory) -> Self {
|
||||
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||
let directory = root.appendingPathComponent("AgentAttachmentDrafts/\(key)", isDirectory: true)
|
||||
let manifest = directory.appendingPathComponent("draft.json")
|
||||
let file: @Sendable (String) throws -> URL = { id in
|
||||
guard UUID(uuidString: id) != nil else { throw AgentAttachmentIssue.local("Invalid staged upload identity.") }
|
||||
return directory.appendingPathComponent(id + ".bytes")
|
||||
}
|
||||
let write: @Sendable (Data, URL) throws -> Void = { data, destination in
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
}
|
||||
return Self(read: {
|
||||
guard FileManager.default.fileExists(atPath: manifest.path) else { return AgentDraftSnapshot() }
|
||||
let data = try Data(contentsOf: manifest)
|
||||
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
|
||||
let snapshot = try JSONDecoder().decode(AgentDraftSnapshot.self, from: data)
|
||||
guard snapshot.items.count <= 8, Set(snapshot.items.map(\.id)).count == snapshot.items.count,
|
||||
snapshot.items.allSatisfy({ UUID(uuidString: $0.id) != nil }) else { throw AgentAttachmentIssue.local("The saved attachment draft is invalid.") }
|
||||
return snapshot
|
||||
}, write: { snapshot in
|
||||
let data = try JSONEncoder().encode(snapshot)
|
||||
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
|
||||
try write(data, manifest)
|
||||
}, stage: { id, data in
|
||||
guard data.count <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
|
||||
try write(data, file(id))
|
||||
}, bytes: { id in
|
||||
let url = try file(id)
|
||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
|
||||
guard size <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
|
||||
return try Data(contentsOf: url)
|
||||
}, remove: { id in if let url = try? file(id) { try? FileManager.default.removeItem(at: url) } })
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor @Observable
|
||||
final class AgentAttachmentDraft {
|
||||
private(set) var id = UUID().uuidString
|
||||
var caption = "" { didSet { save() } }
|
||||
private(set) var items: [AgentDraftItem] = []
|
||||
private(set) var capability: AgentAttachmentCapabilities?
|
||||
private(set) var error: String?
|
||||
private(set) var persistenceAvailable = true
|
||||
@ObservationIgnored private var client: (any AgentAPIClienting)?
|
||||
@ObservationIgnored private var storage: AgentDraftStorage?
|
||||
@ObservationIgnored private var prepared: [String: AgentPreparedAttachment] = [:]
|
||||
@ObservationIgnored private var loaders: [String: @Sendable () async throws -> AgentPreparedAttachment] = [:]
|
||||
@ObservationIgnored private var worker: Task<Void, Never>?
|
||||
@ObservationIgnored private var generation = UUID()
|
||||
@ObservationIgnored private var restoring = false
|
||||
@ObservationIgnored private var active = true
|
||||
var supportsAttachments: Bool { capability?.supported == true && persistenceAvailable }
|
||||
var ready: [AgentAttachment] { items.compactMap(\.attachment) }
|
||||
var canSend: Bool {
|
||||
caption.utf16.count <= 40_000 && (!caption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !items.isEmpty)
|
||||
&& (items.isEmpty || (supportsAttachments && items.allSatisfy { $0.state == .ready && $0.attachment != nil }))
|
||||
&& persistenceAvailable
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentDraftStorage?) {
|
||||
suspend()
|
||||
self.client = client; self.storage = storage
|
||||
prepared = [:]; loaders = [:]; capability = nil; error = nil; persistenceAvailable = true
|
||||
restoring = true
|
||||
defer { restoring = false; active = true }
|
||||
do {
|
||||
let saved = try storage?.read() ?? AgentDraftSnapshot()
|
||||
id = saved.id ?? UUID().uuidString
|
||||
caption = saved.caption
|
||||
items = saved.items.map { item in
|
||||
var item = item
|
||||
if item.state != .ready {
|
||||
item.state = .failed
|
||||
item.issue = .local("Upload interrupted. Retry with the same upload ID. If selection did not finish, remove and select the file again.", code: "upload_interrupted")
|
||||
}
|
||||
return item
|
||||
}
|
||||
} catch {
|
||||
caption = ""; items = []; persistenceAvailable = false
|
||||
self.error = "Could not restore attachment draft: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
func updateCapability(_ value: AgentAttachmentCapabilities?) {
|
||||
capability = value?.supported == true ? value : nil
|
||||
if capability == nil { suspend(); active = true }
|
||||
}
|
||||
func resume() { active = true; pump() }
|
||||
func suspend() {
|
||||
active = false; generation = UUID(); worker?.cancel(); worker = nil
|
||||
for index in items.indices where items[index].state == .loading || items[index].state == .uploading {
|
||||
items[index].state = .failed
|
||||
items[index].issue = .local("Transfer interrupted. Retry to continue.", code: "upload_interrupted")
|
||||
}
|
||||
save()
|
||||
}
|
||||
@discardableResult
|
||||
func add(filename: String, load: @escaping @Sendable () async throws -> AgentPreparedAttachment) -> String? {
|
||||
guard supportsAttachments, let capability else { error = AgentAttachmentIssue.unavailable.localizedDescription; return nil }
|
||||
guard items.count < capability.countLimit else { error = "You can attach up to \(capability.countLimit) files."; return nil }
|
||||
let item = AgentDraftItem(filename: filename)
|
||||
items.append(item); loaders[item.id] = load; error = nil
|
||||
save(); pump()
|
||||
return item.id
|
||||
}
|
||||
func remove(_ id: String) {
|
||||
// Restart the serial worker; completion from the old generation can never reinsert a removed item.
|
||||
let interrupted = items.filter { $0.state == .loading || $0.state == .uploading }.map(\.id)
|
||||
generation = UUID(); worker?.cancel(); worker = nil
|
||||
items.removeAll { $0.id == id }; loaders[id] = nil; prepared[id] = nil
|
||||
save()
|
||||
if persistenceAvailable { storage?.remove(id) }
|
||||
for other in interrupted where other != id {
|
||||
if let index = items.firstIndex(where: { $0.id == other }) { items[index].state = .loading }
|
||||
}
|
||||
pump()
|
||||
}
|
||||
func retry(_ id: String) {
|
||||
guard supportsAttachments else { error = AgentAttachmentIssue.unavailable.localizedDescription; return }
|
||||
guard let index = items.firstIndex(where: { $0.id == id }), items[index].state == .failed else { return }
|
||||
items[index].state = .loading; items[index].issue = nil; error = nil
|
||||
save(); pump()
|
||||
}
|
||||
func validateReady() throws -> [AgentAttachment] {
|
||||
guard items.isEmpty || supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
guard items.allSatisfy({ $0.state == .ready && $0.attachment != nil }) else { throw AgentAttachmentIssue.local("Wait for every attachment to finish, or retry/remove failed files.") }
|
||||
if let capability { try capability.validate(ready) }
|
||||
return ready
|
||||
}
|
||||
@discardableResult func clearAfterSubmission() -> Bool {
|
||||
guard persistenceAvailable else { return false }
|
||||
let nextID = UUID().uuidString
|
||||
do { try storage?.write(AgentDraftSnapshot(id: nextID)) }
|
||||
catch {
|
||||
persistenceAvailable = false
|
||||
self.error = "Message is saved in the outbox, but its draft could not be cleared. Reconnect before retrying: \(error.localizedDescription)"
|
||||
return false
|
||||
}
|
||||
let ids = items.map(\.id)
|
||||
generation = UUID(); worker?.cancel(); worker = nil
|
||||
restoring = true; caption = ""; items = []; id = nextID; restoring = false
|
||||
prepared = [:]; loaders = [:]
|
||||
ids.forEach { storage?.remove($0) }
|
||||
return true
|
||||
}
|
||||
func save() {
|
||||
guard !restoring, persistenceAvailable else { return }
|
||||
do { try storage?.write(AgentDraftSnapshot(caption: caption, items: items, id: id)) }
|
||||
catch { persistenceAvailable = false; self.error = "Could not save attachment draft: \(error.localizedDescription)" }
|
||||
}
|
||||
private func pump() {
|
||||
guard active, supportsAttachments, let client, let limits = capability, worker == nil,
|
||||
let item = items.first(where: { $0.state == .loading }) else { return }
|
||||
let run = generation
|
||||
worker = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.worker = nil; self.pump() } }
|
||||
do {
|
||||
let file: AgentPreparedAttachment
|
||||
if let existing = self.prepared[item.id] { file = existing }
|
||||
else if let mime = item.mimeType, let storage = self.storage {
|
||||
file = try AgentAttachmentPreparation.prepare(storage.bytes(item.id), filename: item.filename, mimeType: mime, limits: limits)
|
||||
} else if let loader = self.loaders[item.id] { file = try await loader() }
|
||||
else { throw AgentAttachmentIssue.local("Selection did not finish before the app closed. Remove this item and select it again.", code: "selection_unavailable") }
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
let otherBytes = self.items.filter { $0.id != item.id }.reduce(0) { $0 + ($1.sizeBytes ?? 0) }
|
||||
guard otherBytes + file.data.count <= limits.totalLimit else { throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded") }
|
||||
self.prepared[item.id] = file
|
||||
try self.storage?.stage(item.id, file.data)
|
||||
self.items[index].filename = file.filename; self.items[index].mimeType = file.mimeType
|
||||
self.items[index].sizeBytes = file.data.count; self.items[index].state = .uploading
|
||||
self.save()
|
||||
guard self.persistenceAvailable else { throw AgentAttachmentIssue.local("Save failed. Reconnect before uploading.") }
|
||||
let attachment = try await client.agentUpload(file: file, uploadID: item.id)
|
||||
try attachment.validate()
|
||||
guard attachment.filename == file.filename, attachment.mimeType == file.mimeType,
|
||||
attachment.sizeBytes == file.data.count, attachment.sha256 == file.sha256 else {
|
||||
throw AgentAttachmentIssue.local("Upload receipt does not match the original file. Retry to confirm.", code: "invalid_upstream_response")
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
self.items[index].attachment = attachment; self.items[index].state = .ready; self.items[index].issue = nil
|
||||
self.save()
|
||||
self.loaders[item.id] = nil; self.prepared[item.id] = nil
|
||||
// Keep staged bytes until the message is durably handed to the outbox.
|
||||
} catch {
|
||||
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
|
||||
self.items[index].state = .failed; self.items[index].issue = .wrapping(error); self.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
|
||||
// PhotosPicker does not expose the original PHAsset filename without broader library access.
|
||||
struct AgentPickedPhoto: Transferable, Sendable {
|
||||
var data: Data
|
||||
var filename: String?
|
||||
var mimeType: String?
|
||||
static var transferRepresentation: some TransferRepresentation {
|
||||
FileRepresentation(importedContentType: .image) { received in
|
||||
let url = received.file
|
||||
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
|
||||
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var data = Data()
|
||||
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
data.append(chunk)
|
||||
}
|
||||
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
|
||||
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@State private var showFiles = false
|
||||
@State private var showPhotos = false
|
||||
@State private var photos: [PhotosPickerItem] = []
|
||||
private var modal: Bool { showChoices || showFiles || showPhotos }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !room.attachmentDraft.items.isEmpty {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
ForEach(room.attachmentDraft.items) { item in
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(item.filename).lineLimit(1)
|
||||
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
|
||||
.accessibilityLabel("Remove \(item.filename)")
|
||||
}
|
||||
if let attachment = item.attachment {
|
||||
AgentAttachmentDetails(attachment: attachment)
|
||||
} else {
|
||||
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
|
||||
}
|
||||
if let issue = item.issue {
|
||||
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
|
||||
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
|
||||
.accessibilityIdentifier("agent-upload-retry")
|
||||
}
|
||||
}
|
||||
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}.frame(maxHeight: 190)
|
||||
}
|
||||
if let error = room.attachmentDraft.error {
|
||||
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
|
||||
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
|
||||
}
|
||||
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) {
|
||||
Button("Photo Library") { showPhotos = true }
|
||||
Button("Files") { showFiles = true }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.photosPicker(isPresented: $showPhotos, selection: $photos,
|
||||
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
|
||||
selectionBehavior: .ordered, matching: .images)
|
||||
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
|
||||
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
|
||||
switch result {
|
||||
case let .success(urls):
|
||||
for url in urls {
|
||||
room.attachmentDraft.add(filename: url.lastPathComponent) {
|
||||
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
|
||||
}
|
||||
}
|
||||
case let .failure(error):
|
||||
if (error as NSError).code != NSUserCancelledError {
|
||||
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: photos) { _, selected in
|
||||
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
|
||||
for item in selected {
|
||||
room.attachmentDraft.add(filename: "Photo") {
|
||||
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
|
||||
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
|
||||
}
|
||||
return try await AgentAttachmentPreparation.offMain {
|
||||
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
|
||||
}
|
||||
}
|
||||
}
|
||||
photos = []
|
||||
}
|
||||
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
|
||||
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
|
||||
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentDetails: View {
|
||||
var attachment: AgentAttachment
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
|
||||
.foregroundStyle(SybilTheme.textMuted)
|
||||
if let path = attachment.workspacePath {
|
||||
Button {
|
||||
UIPasteboard.general.string = path
|
||||
} label: {
|
||||
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
|
||||
}
|
||||
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
|
||||
}
|
||||
}.font(.sybil(.caption2))
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachmentCard: View {
|
||||
var attachment: AgentAttachment
|
||||
var room: SybilAgentRoom
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@State private var image: UIImage?
|
||||
@State private var text: String?
|
||||
@State private var issue: String?
|
||||
@State private var loading = false
|
||||
@State private var wantsDownload = false
|
||||
@State private var revision = 0
|
||||
@State private var exportURL: URL?
|
||||
@State private var showShare = false
|
||||
@State private var modalID = UUID()
|
||||
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
|
||||
AgentAttachmentDetails(attachment: attachment)
|
||||
if attachment.kind == .image {
|
||||
ZStack {
|
||||
SybilTheme.surface
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFit()
|
||||
.accessibilityLabel("Image: \(attachment.filename)")
|
||||
.accessibilityIdentifier("agent-image-preview")
|
||||
}
|
||||
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
|
||||
}.frame(height: 180).clipped()
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("agent-image-frame")
|
||||
}
|
||||
if let text {
|
||||
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
|
||||
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
|
||||
}
|
||||
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
|
||||
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
|
||||
HStack {
|
||||
if exportURL != nil {
|
||||
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
|
||||
} else if !loading {
|
||||
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
|
||||
.accessibilityIdentifier("agent-download")
|
||||
}
|
||||
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
|
||||
}.font(.sybil(.caption))
|
||||
}
|
||||
.padding(12).frame(maxWidth: 340, alignment: .leading)
|
||||
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
|
||||
.task(id: loadKey) {
|
||||
// Each view owns its transfer; configuration and foreground changes cancel it.
|
||||
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
|
||||
loading = true; issue = nil
|
||||
let session = room.attachmentSessionID
|
||||
do {
|
||||
let data = try await room.downloadAttachment(attachment)
|
||||
try Task.checkCancellation()
|
||||
guard session == room.attachmentSessionID else { return }
|
||||
if attachment.kind == .image {
|
||||
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
|
||||
try Task.checkCancellation()
|
||||
image = UIImage(cgImage: bitmap)
|
||||
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
|
||||
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
|
||||
}
|
||||
if wantsDownload {
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
|
||||
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
|
||||
exportURL = destination
|
||||
}
|
||||
loading = false
|
||||
} catch {
|
||||
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShare) {
|
||||
if let exportURL { AgentOriginalShare(url: exportURL) }
|
||||
}
|
||||
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
|
||||
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
|
||||
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
|
||||
}
|
||||
private func reset() {
|
||||
image = nil; text = nil
|
||||
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
|
||||
exportURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentOriginalShare: UIViewControllerRepresentable {
|
||||
var url: URL
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
||||
}
|
||||
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
|
||||
var version: Int
|
||||
var maxCount: Int
|
||||
var maxTotalBytes: Int
|
||||
var maxImageBytes: Int
|
||||
var maxFileBytes: Int
|
||||
var imageMimeTypes: [String]
|
||||
var maxImagePixels: Int
|
||||
var maxImageDimension: Int
|
||||
var quotaBytes: Int64? = nil
|
||||
var usedBytes: Int64? = nil
|
||||
var remainingBytes: Int64? = nil
|
||||
var maxConcurrentUploads: Int? = nil
|
||||
var maxConcurrentDecodes: Int? = nil
|
||||
|
||||
var supported: Bool {
|
||||
version == 1 && maxCount > 0 && maxTotalBytes > 0 && maxImageBytes > 0 && maxFileBytes > 0
|
||||
&& maxImagePixels > 0 && maxImageDimension > 0
|
||||
}
|
||||
var countLimit: Int { min(maxCount, 8) }
|
||||
var totalLimit: Int { min(maxTotalBytes, 32 * 1024 * 1024) }
|
||||
var imageLimit: Int { min(maxImageBytes, 6 * 1024 * 1024) }
|
||||
var fileLimit: Int { min(maxFileBytes, 8 * 1024 * 1024) }
|
||||
var pixelLimit: Int { min(maxImagePixels, 50_000_000) }
|
||||
var dimensionLimit: Int { min(maxImageDimension, 16_384) }
|
||||
func validate(_ attachments: [AgentAttachment]) throws {
|
||||
guard supported else { throw AgentAttachmentIssue.unavailable }
|
||||
for attachment in attachments { try attachment.validate() }
|
||||
guard attachments.count <= countLimit, attachments.reduce(0, { $0 + $1.sizeBytes }) <= totalLimit else {
|
||||
throw AgentAttachmentIssue.local("Attachments exceed the advertised message limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
for attachment in attachments {
|
||||
guard attachment.sizeBytes <= (attachment.kind == .image ? imageLimit : fileLimit),
|
||||
attachment.kind != .image || imageMimeTypes.contains(attachment.mimeType) else {
|
||||
throw AgentAttachmentIssue.local("An attachment exceeds the current Agent limits or uses an unavailable image format.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAssistantImageCapabilities: Decodable, Equatable, Sendable {
|
||||
var version: Int
|
||||
var supported: Bool { version == 1 }
|
||||
}
|
||||
|
||||
struct AgentCapabilities: Decodable, Sendable {
|
||||
var attachments: AgentAttachmentCapabilities?
|
||||
var assistantImages: AgentAssistantImageCapabilities?
|
||||
init(attachments: AgentAttachmentCapabilities? = nil, assistantImages: AgentAssistantImageCapabilities? = nil) {
|
||||
self.attachments = attachments
|
||||
self.assistantImages = assistantImages
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case attachments, assistantImages }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Unknown versions or malformed future capabilities must not break text chat.
|
||||
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
|
||||
assistantImages = try? values.decode(AgentAssistantImageCapabilities.self, forKey: .assistantImages)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
enum Kind: String, Codable, Sendable { case image, text, file }
|
||||
var id: String
|
||||
var kind: Kind
|
||||
var filename: String
|
||||
var mimeType: String
|
||||
var sizeBytes: Int
|
||||
var sha256: String
|
||||
var workspacePath: String? = nil
|
||||
|
||||
func validate() throws {
|
||||
guard !id.isEmpty, id.utf8.count <= 1024, !id.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
|
||||
try AgentAttachmentPreparation.filename(filename) == filename,
|
||||
sizeBytes >= 0, sizeBytes <= 8 * 1024 * 1024,
|
||||
sha256.count == 64, sha256.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }),
|
||||
try AgentAttachmentPreparation.mime(mimeType) == mimeType else {
|
||||
throw AgentAttachmentIssue.local("The Agent returned an invalid attachment descriptor.", code: "invalid_upstream_response")
|
||||
}
|
||||
guard (kind == .image) == AgentAttachmentPreparation.nativeMIMEs.contains(mimeType),
|
||||
kind != .image || sizeBytes <= 6 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("Invalid image attachment descriptor.", code: "invalid_upstream_response")
|
||||
}
|
||||
if let workspacePath {
|
||||
guard workspacePath.hasPrefix("/"), !workspacePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
|
||||
throw AgentAttachmentIssue.local("The Agent returned an invalid workspace path.", code: "invalid_upstream_response")
|
||||
}
|
||||
}
|
||||
}
|
||||
var metadataValue: JSONValue {
|
||||
// All stored fields are JSON-compatible; this also keeps optional future fields separate from ChatAttachment.
|
||||
(try? JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(self))) ?? .null
|
||||
}
|
||||
}
|
||||
|
||||
extension Message {
|
||||
var agentAttachments: [AgentAttachment] {
|
||||
guard role == .user || role == .assistant,
|
||||
let info = metadata?.objectValue,
|
||||
let entries = info["agentAttachments"]?.arrayValue else { return [] }
|
||||
if role == .assistant {
|
||||
guard info["kind"]?.stringValue == "message", info["status"]?.stringValue == "complete",
|
||||
entries.count <= 8 else { return [] }
|
||||
}
|
||||
let attachments: [AgentAttachment] = entries.compactMap { entry in
|
||||
// Inspect the original JSON before Codable can discard unknown fields. Output
|
||||
// descriptors are deliberately path/URL-free; legacy user uploads stay compatible.
|
||||
if role == .assistant {
|
||||
let safeFields: Set<String> = ["id", "kind", "filename", "mimeType", "sizeBytes", "sha256"]
|
||||
guard let fields = entry.objectValue, Set(fields.keys) == safeFields,
|
||||
fields["kind"]?.stringValue == "image" else { return nil }
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(entry),
|
||||
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
|
||||
(try? attachment.validate()) != nil else { return nil }
|
||||
return attachment
|
||||
}
|
||||
if role == .assistant {
|
||||
// Reject the entire output set, including partially invalid arrays. The
|
||||
// count and per-image bounds above also bound this aggregate calculation.
|
||||
guard attachments.count == entries.count,
|
||||
Set(attachments.map(\.id)).count == attachments.count,
|
||||
attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else { return [] }
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentSubmission: Encodable, Sendable {
|
||||
struct Reference: Encodable, Sendable { let id: String }
|
||||
let requestId: String
|
||||
let content: String
|
||||
let attachments: [Reference]?
|
||||
init(content: String, requestID: String, attachments: [AgentAttachment] = []) throws {
|
||||
guard !requestID.isEmpty, requestID.utf16.count <= 256, content.utf16.count <= 40_000,
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty,
|
||||
attachments.count <= 8, Set(attachments.map(\.id)).count == attachments.count else {
|
||||
throw AgentAttachmentIssue.local("Enter a message of at most 40,000 characters or attach files.", code: "invalid_attachment")
|
||||
}
|
||||
for attachment in attachments { try attachment.validate() }
|
||||
guard attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else {
|
||||
throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
self.content = content; requestId = requestID
|
||||
self.attachments = attachments.isEmpty ? nil : attachments.map { Reference(id: $0.id) }
|
||||
}
|
||||
var attachmentIDs: [String] { attachments?.map(\.id) ?? [] }
|
||||
}
|
||||
|
||||
struct AgentAttachmentIssue: Error, LocalizedError, Codable, Equatable, Sendable {
|
||||
var message: String
|
||||
var code: String
|
||||
var statusCode: Int?
|
||||
// Preserve additive quota information without inventing field names.
|
||||
var details: [String: JSONValue] = [:]
|
||||
var retryAfter: String? = nil
|
||||
var errorDescription: String? {
|
||||
var result = "\(message) [\(code)]"
|
||||
if code == "attachment_quota_exceeded" {
|
||||
let numbers = ["requiredBytes", "remainingBytes", "quotaBytes"].compactMap { key -> String? in
|
||||
guard let number = details[key]?.numberValue, number.isFinite, number >= 0, number < Double(Int64.max) else { return nil }
|
||||
return "\(key): \(ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .file))"
|
||||
}
|
||||
if !numbers.isEmpty { result += " " + numbers.joined(separator: ", ") }
|
||||
}
|
||||
if let retryAfter { result += " Retry-After: \(retryAfter)." }
|
||||
return result
|
||||
}
|
||||
static func local(_ message: String, code: String = "invalid_attachment") -> Self { Self(message: message, code: code) }
|
||||
static let unavailable = Self.local("Attachments are unavailable on this Agent. Reconnect to check support, or remove the files to send text.", code: "attachments_unavailable")
|
||||
static func wrapping(_ error: any Error) -> Self {
|
||||
error as? Self ?? .local(error.localizedDescription, code: "attachment_transfer_failed")
|
||||
}
|
||||
static func response(_ data: Data, status: Int, retryAfter: String? = nil) -> Self {
|
||||
let fields = (try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue ?? [:]
|
||||
return Self(message: fields["message"]?.stringValue ?? fields["error"]?.stringValue ?? "Attachment request failed (HTTP \(status)).",
|
||||
code: fields["code"]?.stringValue ?? "attachment_request_failed", statusCode: status, details: fields, retryAfter: retryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentPreparedAttachment: Sendable {
|
||||
var filename: String
|
||||
var mimeType: String
|
||||
var data: Data
|
||||
var sha256: String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
|
||||
}
|
||||
|
||||
enum AgentAttachmentPreparation {
|
||||
static func offMain<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
|
||||
let task = Task.detached(operation: operation)
|
||||
return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() }
|
||||
}
|
||||
static let nativeMIMEs: Set<String> = ["image/png", "image/jpeg", "image/gif", "image/webp"]
|
||||
static func filename(_ value: String) throws -> String {
|
||||
let value = value.precomposedStringWithCanonicalMapping
|
||||
guard !value.isEmpty, value.utf8.count <= 255, value != ".", value != "..",
|
||||
!value.contains("/"), !value.contains("\\"),
|
||||
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
|
||||
throw AgentAttachmentIssue.local("The filename must be a safe basename of 1–255 UTF-8 bytes.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
static func mime(_ value: String) throws -> String {
|
||||
let value = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let parts = value.split(separator: "/", omittingEmptySubsequences: false)
|
||||
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~")
|
||||
guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }), value.utf8.count <= 255 else {
|
||||
throw AgentAttachmentIssue.local("The selected file has an invalid media type.")
|
||||
}
|
||||
return value
|
||||
}
|
||||
static func file(_ url: URL, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
let accessed = url.startAccessingSecurityScopedResource()
|
||||
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
|
||||
let name = try filename(url.lastPathComponent)
|
||||
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType ?? UTType(filenameExtension: url.pathExtension)
|
||||
let media = try mime(type?.preferredMIMEType ?? "application/octet-stream")
|
||||
let handle = try FileHandle(forReadingFrom: url)
|
||||
defer { try? handle.close() }
|
||||
var data = Data()
|
||||
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
|
||||
try Task.checkCancellation()
|
||||
guard data.count + chunk.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else {
|
||||
throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
data.append(chunk)
|
||||
}
|
||||
return try prepare(data, filename: name, mimeType: media, limits: limits)
|
||||
}
|
||||
static func prepare(_ data: Data, filename name: String, mimeType: String, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
guard limits.supported else { throw AgentAttachmentIssue.unavailable }
|
||||
let name = try filename(name), media = try mime(mimeType)
|
||||
guard data.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else { throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded") }
|
||||
if nativeMIMEs.contains(media) {
|
||||
guard limits.imageMimeTypes.contains(media), data.count <= limits.imageLimit else {
|
||||
throw AgentAttachmentIssue.local("\(name) exceeds the image limit or uses an unavailable image format.", code: "attachment_limit_exceeded")
|
||||
}
|
||||
_ = try raster(data, mime: media, pixels: limits.pixelLimit, dimension: limits.dimensionLimit)
|
||||
}
|
||||
return AgentPreparedAttachment(filename: name, mimeType: media, data: data)
|
||||
}
|
||||
static func raster(_ data: Data, mime: String, pixels: Int = 50_000_000, dimension: Int = 16_384) throws -> CGImage {
|
||||
guard nativeMIMEs.contains(mime), let source = CGImageSourceCreateWithData(data as CFData, nil),
|
||||
let type = CGImageSourceGetType(source), UTType(type as String)?.preferredMIMEType == mime,
|
||||
CGImageSourceGetStatus(source) == .statusComplete,
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
||||
width > 0, height > 0, width <= dimension, height <= dimension, width <= pixels / height,
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 600, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else {
|
||||
throw AgentAttachmentIssue.local("The image is invalid, has a mismatched media type, or exceeds the dimension/pixel limit.", code: "invalid_image")
|
||||
}
|
||||
return image
|
||||
}
|
||||
static func photo(_ data: Data, filename originalName: String?, mimeType: String?, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
|
||||
let media = mimeType ?? "application/octet-stream"
|
||||
if nativeMIMEs.contains(media) {
|
||||
return try prepare(data, filename: originalName ?? "photo.\(UTType(mimeType: media)?.preferredFilenameExtension ?? "jpg")", mimeType: media, limits: limits)
|
||||
}
|
||||
// Photo Library is an explicit visual-input selection. Convert HEIC/HEIF with bounded decoding.
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let sourceType = CGImageSourceGetType(source),
|
||||
["public.heic", "public.heif"].contains(sourceType as String), limits.imageMimeTypes.contains("image/jpeg"),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
|
||||
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
|
||||
width > 0, height > 0, width <= limits.dimensionLimit, height <= limits.dimensionLimit, width <= limits.pixelLimit / height,
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: min(2000, limits.dimensionLimit), kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary),
|
||||
let jpeg = UIImage(cgImage: image).jpegData(compressionQuality: 0.88) else {
|
||||
throw AgentAttachmentIssue.local("This photo could not be prepared as a supported image. Select its original in Files to upload it as a file.", code: "invalid_image")
|
||||
}
|
||||
let safe = try filename(originalName ?? "photo.heic")
|
||||
let stem = (safe as NSString).deletingPathExtension
|
||||
return try prepare(jpeg, filename: stem + ".jpg", mimeType: "image/jpeg", limits: limits)
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentMultipart: Sendable {
|
||||
let boundary: String
|
||||
let body: Data
|
||||
init(file: AgentPreparedAttachment, uploadID: String) throws {
|
||||
guard UUID(uuidString: uploadID) != nil else { throw AgentAttachmentIssue.local("Invalid upload identity.") }
|
||||
let name = try AgentAttachmentPreparation.filename(file.filename)
|
||||
let media = try AgentAttachmentPreparation.mime(file.mimeType)
|
||||
var boundary: String
|
||||
repeat { boundary = "Sybil-" + UUID().uuidString } while file.data.range(of: Data(boundary.utf8)) != nil
|
||||
self.boundary = boundary
|
||||
let quoted = name.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
var bytes = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(quoted)\"\r\nContent-Type: \(media)\r\n\r\n".utf8)
|
||||
bytes.append(file.data)
|
||||
bytes.append(Data("\r\n--\(boundary)--\r\n".utf8))
|
||||
guard bytes.count <= 8 * 1024 * 1024 + 64 * 1024 else { throw AgentAttachmentIssue.local("The upload exceeds the request size limit.", code: "attachment_limit_exceeded") }
|
||||
body = bytes
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ struct AgentDescriptor: Decodable, Sendable {
|
||||
var `protocol`: String
|
||||
var activity: AgentActivity
|
||||
var websocket: String
|
||||
var capabilities: AgentCapabilities? = nil
|
||||
}
|
||||
struct AgentPage: Codable, Sendable {
|
||||
var conversationId: String
|
||||
@@ -72,17 +73,22 @@ 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
|
||||
var status: String
|
||||
var messageId: String
|
||||
func validate(requestID: String) throws {
|
||||
var attachmentIds: [String]? = nil
|
||||
func validate(requestID: String, attachmentIDs: [String] = []) throws {
|
||||
guard id > 0, id <= 9_007_199_254_740_991,
|
||||
["queued", "placed", "done", "unanswered"].contains(status),
|
||||
messageId == "inbound:sybil:\(requestID)" else {
|
||||
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
|
||||
}
|
||||
if !attachmentIDs.isEmpty, attachmentIds != attachmentIDs {
|
||||
throw AgentAttachmentIssue.local("Agent receipt did not confirm the exact ordered attachments. Retry this message to confirm delivery.", code: "invalid_upstream_response")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +96,7 @@ enum AgentEvent: Sendable {
|
||||
case ready(String?)
|
||||
case messages(AgentPage)
|
||||
case activity(String, AgentActivity)
|
||||
case readState(AgentReadState)
|
||||
case heartbeat
|
||||
case error(String)
|
||||
|
||||
@@ -113,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
|
||||
}
|
||||
@@ -225,7 +239,7 @@ struct AgentPresentation {
|
||||
private(set) var suppressed = false
|
||||
private(set) var busySince: Date?
|
||||
private var seenTools: Set<String> = []
|
||||
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
|
||||
static let quietTools: Set<String> = ["user_message", "output_image", "notify_parent", "clock_wait", "event_decision"]
|
||||
|
||||
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
||||
switch event {
|
||||
@@ -235,7 +249,7 @@ struct AgentPresentation {
|
||||
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
||||
if !replay {
|
||||
for message in fresh {
|
||||
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
|
||||
if message.isAgentPublicReply && (!message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !message.agentAttachments.isEmpty) { suppressed = true }
|
||||
else if message.role == .user { suppressed = false }
|
||||
else if message.toolCallMetadata?.status == "initiated",
|
||||
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,13 @@ struct AgentOutboxRow: Codable, Equatable, Sendable {
|
||||
var seenOnServer = false
|
||||
var confirmed = false
|
||||
var error: String?
|
||||
var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes.
|
||||
var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer.
|
||||
var id: String { "inbound:sybil:\(requestID)" }
|
||||
var message: Message {
|
||||
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
|
||||
if let error { info["error"] = .string(error) }
|
||||
if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) }
|
||||
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,39 @@ 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] = []
|
||||
var composer = ""
|
||||
let attachmentDraft = AgentAttachmentDraft()
|
||||
private(set) var attachmentSessionID = UUID()
|
||||
private var attachmentModals: Set<UUID> = []
|
||||
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
|
||||
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 }
|
||||
}
|
||||
var isObscured = false
|
||||
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)?
|
||||
@ObservationIgnored private var storage: AgentOutboxStorage?
|
||||
@ObservationIgnored private var storageReadable = true
|
||||
@@ -48,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)
|
||||
@@ -56,6 +85,10 @@ final class SybilAgentRoom {
|
||||
messages[index].createdAt = row.createdAt
|
||||
if !row.confirmed {
|
||||
var info = messages[index].metadata?.objectValue ?? [:]
|
||||
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
|
||||
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
|
||||
messages[index].content = row.content
|
||||
}
|
||||
info["status"] = .string(row.status)
|
||||
if let error = row.error { info["error"] = .string(error) }
|
||||
messages[index].metadata = .object(info)
|
||||
@@ -65,12 +98,16 @@ final class SybilAgentRoom {
|
||||
return AgentRoomState.merge([], messages)
|
||||
}
|
||||
|
||||
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage) {
|
||||
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) {
|
||||
stop()
|
||||
self.client = client
|
||||
self.storage = storage
|
||||
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
|
||||
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false; composer = ""
|
||||
attachmentSessionID = UUID()
|
||||
let oldDownloads = attachmentDownloads
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
attachmentDraft.configure(client: client, storage: draftStorage)
|
||||
resetHistory()
|
||||
error = nil; storageReadable = true
|
||||
do {
|
||||
outbox = try storage.read().map { row in
|
||||
@@ -81,6 +118,9 @@ final class SybilAgentRoom {
|
||||
}
|
||||
return restored
|
||||
}
|
||||
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
|
||||
attachmentDraft.clearAfterSubmission()
|
||||
}
|
||||
} catch {
|
||||
outbox = []; storageReadable = false
|
||||
self.error = "Could not restore pending messages: \(error.localizedDescription)"
|
||||
@@ -91,9 +131,15 @@ final class SybilAgentRoom {
|
||||
func disconnect() {
|
||||
stop()
|
||||
client = nil
|
||||
attachmentDraft.updateCapability(nil)
|
||||
attachmentSessionID = UUID()
|
||||
let oldDownloads = attachmentDownloads
|
||||
Task { await oldDownloads.invalidate() }
|
||||
attachmentDownloads = AgentAttachmentDownloads()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
attachmentDraft.suspend()
|
||||
generation = UUID()
|
||||
connectionTask?.cancel(); connectionTask = nil
|
||||
maintenanceTask?.cancel(); maintenanceTask = nil
|
||||
@@ -117,7 +163,7 @@ final class SybilAgentRoom {
|
||||
func setActive(_ active: Bool) {
|
||||
guard isActive != active else { return }
|
||||
isActive = active
|
||||
if active { start() }
|
||||
if active { attachmentDraft.resume(); start() }
|
||||
else {
|
||||
// Publish successfully merged input before cancelling; replay resumes here.
|
||||
flush()
|
||||
@@ -125,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"
|
||||
@@ -139,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"))
|
||||
@@ -151,6 +219,8 @@ final class SybilAgentRoom {
|
||||
do {
|
||||
let descriptor = try await client.agentDescriptor()
|
||||
guard self.generation == run else { return }
|
||||
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
|
||||
self.attachmentDraft.resume()
|
||||
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
|
||||
try await self.refreshRead(client: client, run: run)
|
||||
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
|
||||
@@ -176,7 +246,11 @@ final class SybilAgentRoom {
|
||||
guard self.generation == run, !Task.isCancelled else { return }
|
||||
self.socket = nil
|
||||
self.isLoading = false
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings." : error.localizedDescription
|
||||
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
|
||||
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
|
||||
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
|
||||
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
|
||||
: error.localizedDescription
|
||||
self.connection = code == 4401 ? "Offline" : "Reconnecting"
|
||||
if code == 4401 { return }
|
||||
}
|
||||
@@ -210,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
|
||||
@@ -229,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())
|
||||
@@ -260,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)
|
||||
@@ -280,46 +363,102 @@ 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 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
|
||||
guard let client else { throw AgentAttachmentIssue.unavailable }
|
||||
let session = attachmentSessionID
|
||||
let data = try await attachmentDownloads.data(for: attachment, client: client)
|
||||
try Task.checkCancellation()
|
||||
guard attachmentSessionID == session else { throw CancellationError() }
|
||||
return data
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func send(_ content: String) -> Bool {
|
||||
guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, content.utf16.count <= 40_000 else {
|
||||
error = "Enter a message of at most 40,000 characters."; return false
|
||||
}
|
||||
func sendComposer() -> Bool {
|
||||
do {
|
||||
let attachments = try attachmentDraft.validateReady()
|
||||
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
|
||||
return true
|
||||
} catch { self.error = error.localizedDescription; return false }
|
||||
}
|
||||
|
||||
@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 {
|
||||
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
try limits.validate(attachments)
|
||||
}
|
||||
} catch { self.error = error.localizedDescription; return false }
|
||||
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
|
||||
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date())
|
||||
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
|
||||
outbox.append(row)
|
||||
do { if !attachments.isEmpty || draftID != nil { try persist() } }
|
||||
catch {
|
||||
outbox.removeAll { $0.id == row.id }
|
||||
self.error = "Could not save message: \(error.localizedDescription)"
|
||||
return false // Keep the entire draft when durable handoff fails.
|
||||
}
|
||||
pendingEntrances.insert(row.id)
|
||||
presenter.sent(now: Date(), activity: pending.activity)
|
||||
flush()
|
||||
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
|
||||
outbox[outbox.count - 1].status = "failed"
|
||||
outbox[outbox.count - 1].error = attachmentDraft.error
|
||||
try? persist()
|
||||
return false
|
||||
}
|
||||
dispatch(row.id)
|
||||
return true
|
||||
}
|
||||
@@ -329,7 +468,9 @@ 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
|
||||
do { try persist() }
|
||||
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
|
||||
@@ -338,8 +479,14 @@ final class SybilAgentRoom {
|
||||
guard let self else { return }
|
||||
defer { if self.generation == run { self.sends[id] = nil } }
|
||||
do {
|
||||
let receipt = try await client.agentSubmit(content: row.content, requestID: row.requestID)
|
||||
try receipt.validate(requestID: row.requestID)
|
||||
let attachments = row.attachments ?? []
|
||||
if !attachments.isEmpty {
|
||||
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
|
||||
try limits.validate(attachments)
|
||||
}
|
||||
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
|
||||
let receipt = try await client.agentSubmit(submission)
|
||||
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
|
||||
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
|
||||
self.outbox[index].seenOnServer = true
|
||||
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
|
||||
@@ -353,15 +500,19 @@ final class SybilAgentRoom {
|
||||
}
|
||||
}
|
||||
private func reconcileOutbox() {
|
||||
let committed = Set(pending.messages.map(\.id))
|
||||
let queued = Set(pending.activity.queuedMessages.map(\.id))
|
||||
let committed = pending.messages
|
||||
let queued = pending.activity.queuedMessages
|
||||
var changed = false
|
||||
for index in outbox.indices {
|
||||
if committed.contains(outbox[index].id) {
|
||||
let row = outbox[index]
|
||||
let matches: (Message) -> Bool = { message in
|
||||
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
|
||||
}
|
||||
if committed.contains(where: matches) {
|
||||
if !outbox[index].confirmed { changed = true }
|
||||
outbox[index].confirmed = true; outbox[index].seenOnServer = true
|
||||
outbox[index].status = "complete"; outbox[index].error = nil
|
||||
} else if queued.contains(outbox[index].id), !outbox[index].confirmed {
|
||||
} else if queued.contains(where: matches), !outbox[index].confirmed {
|
||||
changed = true
|
||||
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,11 @@ 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
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
|
||||
func agentSocket(after: String) async throws -> any AgentSocket
|
||||
}
|
||||
|
||||
@@ -48,9 +52,44 @@ 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))
|
||||
])
|
||||
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
|
||||
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
|
||||
}
|
||||
|
||||
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
|
||||
// Reject every redirect; attachment credentials are valid only for the configured API.
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
|
||||
completionHandler(nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension APIConfiguration {
|
||||
func agentAttachmentContentURL(id: String) throws -> URL {
|
||||
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
|
||||
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
|
||||
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
|
||||
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
|
||||
parts.query = nil; parts.fragment = nil
|
||||
guard let url = parts.url else { throw APIError.invalidBaseURL }
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,172 +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
|
||||
@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
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var atLatest = true
|
||||
@State private var isScrolling = false
|
||||
@State private var scrollPhase: ScrollPhase = .idle
|
||||
@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?
|
||||
private static let bottomID = "agent-bottom"
|
||||
@State private var scrollTracking = AgentScrollTracking()
|
||||
@State private var appeared = false
|
||||
private let bottomID = "agent-bottom"
|
||||
private var isScrolling: Bool { scrollPhase.isScrolling }
|
||||
|
||||
private var readKey: String {
|
||||
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||
}
|
||||
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 let anchor = rowFrames.filter({ $0.value.maxY > 0 }).min(by: { $0.value.minY < $1.value.minY }) {
|
||||
restoreAnchor = AgentReadingAnchor(id: anchor.key, offset: anchor.value.minY, expectedCount: min(allMessages.count, displayed.count + 100))
|
||||
}
|
||||
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, 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 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
|
||||
}
|
||||
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(.vertical, 16)
|
||||
.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)
|
||||
} 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)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.bottom, 15)
|
||||
.scaleEffect(x: 1, y: -1)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
||||
let previousAnchor = rowFrames.filter { $0.value.maxY > 0 && $0.value.minY < scrollMetrics.height }
|
||||
.min { $0.value.minY < $1.value.minY }
|
||||
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
|
||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? previousAnchor : 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), max(-scrollMetrics.topInset, scrollMetrics.contentHeight - scrollMetrics.height + scrollMetrics.bottomInset))
|
||||
let appliedShift = target - scrollMetrics.offset
|
||||
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
||||
if abs(appliedShift) > 1 { scrollPosition.scrollTo(y: target) }
|
||||
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))
|
||||
}
|
||||
.padding(.top, topInset)
|
||||
.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, 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, 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) {
|
||||
@@ -188,13 +192,19 @@ 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.composer.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && room.composer.utf16.count <= 40_000
|
||||
room.attachmentDraft.canSend
|
||||
}
|
||||
|
||||
private var composer: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
var body: some View {
|
||||
AgentAttachmentComposer(room: room) {
|
||||
SybilPromptField(
|
||||
text: $room.composer,
|
||||
focused: $composerFocused,
|
||||
@@ -211,9 +221,8 @@ struct SybilAgentView: View {
|
||||
}
|
||||
|
||||
private func submitComposer() {
|
||||
guard canSendComposer, room.send(room.composer) else { return }
|
||||
room.composer = ""
|
||||
atLatest = true
|
||||
guard canSendComposer, room.sendComposer() else { return }
|
||||
onSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,16 +235,49 @@ 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 retry: () -> Void
|
||||
var room: SybilAgentRoom
|
||||
private var own: Bool { message.role == .user }
|
||||
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
|
||||
var body: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if !own { AgentAvatar().padding(.bottom, 23) }
|
||||
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
ForEach(message.agentAttachments) { attachment in
|
||||
AgentAttachmentCard(attachment: attachment, room: room)
|
||||
}
|
||||
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.agentAttachments.isEmpty {
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
Text(message.createdAt, style: .time)
|
||||
@@ -243,7 +285,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)
|
||||
@@ -255,26 +297,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 {
|
||||
@@ -283,22 +334,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 atBottom: Bool { height > 0 && contentHeight - offset - height + bottomInset <= 24 }
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -223,22 +223,14 @@ struct MessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
if isStreamingAssistant {
|
||||
if isStreamingAssistant && !agentStyle {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
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, agentStyle: agentStyle)
|
||||
}
|
||||
}
|
||||
.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,40 @@
|
||||
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
|
||||
var agentStyle = false
|
||||
|
||||
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) : agentStyle ? AgentMessageMarkdown.content(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 {
|
||||
agent.configure(client: agentClient, storage: .file(baseURL: baseURL))
|
||||
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,670 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import Testing
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
@testable import Sybil
|
||||
|
||||
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", "image/gif", "image/webp"],
|
||||
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
|
||||
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
|
||||
private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = originalBytes, name: String = "original.bin") -> AgentAttachment {
|
||||
AgentAttachment(id: id, kind: .file, filename: name, mimeType: "application/octet-stream", sizeBytes: data.count,
|
||||
sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), workspacePath: "/runtime/provided/\(name)")
|
||||
}
|
||||
private func descriptorData(capability: Bool = true, assistantImages: Bool = false) throws -> Data {
|
||||
var object: [String: Any] = ["id": "root", "title": "Sybil", "type": "persistent-agent", "model": "fixture", "protocol": "pi-room.v1", "websocket": "/v1/agent/ws",
|
||||
"activity": ["state": "idle", "isThinking": false, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": []]]
|
||||
if capability { object["capabilities"] = ["attachments": try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)), "future": true] }
|
||||
if assistantImages {
|
||||
var capabilities = object["capabilities"] as? [String: Any] ?? [:]
|
||||
capabilities["assistantImages"] = ["version": 1]
|
||||
object["capabilities"] = capabilities
|
||||
}
|
||||
object["unknown"] = ["additive": true]
|
||||
return try JSONSerialization.data(withJSONObject: object)
|
||||
}
|
||||
private final class AttachmentTestStore: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var rows: [AgentOutboxRow] = []
|
||||
var storage: AgentOutboxStorage { .init(read: { self.read() }, write: { value in self.lock.withLock { self.rows = value } }) }
|
||||
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
|
||||
}
|
||||
private actor AttachmentTestClient: AgentAPIClienting {
|
||||
var uploads: [String] = []
|
||||
var submissions: [AgentSubmission] = []
|
||||
var failUploads = 0
|
||||
var badReceipt = false
|
||||
var uploadDelay = Duration.milliseconds(20)
|
||||
var holdDownloads = false
|
||||
var downloadWaiters: [String: CheckedContinuation<Data, Never>] = [:]
|
||||
var downloadStarts: [String] = []
|
||||
var downloadActive = 0
|
||||
var downloadPeak = 0
|
||||
var beforeSubmit: (@Sendable () -> Bool)?
|
||||
var unavailableDescriptor = false
|
||||
var unavailableSocket = false
|
||||
func agentDescriptor() throws -> AgentDescriptor {
|
||||
if unavailableDescriptor { throw AgentAttachmentIssue(message: "Synthetic open proxy requires authentication", code: "attachments_unavailable", statusCode: 503) }
|
||||
return try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
|
||||
}
|
||||
func agentMessages(after: String?, before: String?) -> AgentPage { .init(conversationId: "root", messages: [], cursor: after ?? "0", hasMore: false) }
|
||||
func agentReadState() -> AgentReadState { .init(cursor: "0") }
|
||||
func agentMarkRead(cursor: String) -> AgentReadState { .init(cursor: cursor) }
|
||||
func agentSubmit(content: String, requestID: String) -> AgentReceipt { .init(id: 1, status: "queued", messageId: "inbound:sybil:\(requestID)") }
|
||||
func agentSubmit(_ submission: AgentSubmission) throws -> AgentReceipt {
|
||||
#expect(beforeSubmit?() ?? true)
|
||||
submissions.append(submission)
|
||||
return .init(id: 1, status: "queued", messageId: "inbound:sybil:\(submission.requestId)", attachmentIds: badReceipt ? nil : submission.attachmentIDs)
|
||||
}
|
||||
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
|
||||
uploads.append(uploadID)
|
||||
try await Task.sleep(for: uploadDelay)
|
||||
if failUploads > 0 { failUploads -= 1; throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507, details: ["futureQuotaField": .number(1)]) }
|
||||
return AgentAttachment(id: "server-\(uploadID)", kind: .file, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
|
||||
}
|
||||
func agentDownload(_ attachment: AgentAttachment) async -> Data {
|
||||
downloadStarts.append(attachment.id); downloadActive += 1; downloadPeak = max(downloadPeak, downloadActive)
|
||||
defer { downloadActive -= 1 }
|
||||
if holdDownloads { return await withCheckedContinuation { downloadWaiters[attachment.id] = $0 } }
|
||||
return originalBytes
|
||||
}
|
||||
func pauseDownloads() { holdDownloads = true }
|
||||
func releaseDownload(_ id: String) { downloadWaiters.removeValue(forKey: id)?.resume(returning: originalBytes) }
|
||||
func downloadCounts() -> (Int, Int) { (downloadStarts.count, downloadPeak) }
|
||||
func downloadedIDs() -> [String] { downloadStarts }
|
||||
func agentSocket(after: String) throws -> any AgentSocket {
|
||||
if unavailableSocket { return AttachmentUnavailableSocket() }
|
||||
throw AgentFailure(message: "No socket in unit fixture")
|
||||
}
|
||||
func blockDescriptor() { unavailableDescriptor = true }
|
||||
func blockSocket() { unavailableSocket = true }
|
||||
func failNextUpload(delay: Duration = .milliseconds(20)) { failUploads = 1; uploadDelay = delay }
|
||||
func delayUploads(_ delay: Duration) { uploadDelay = delay }
|
||||
func malformedReceipts(_ value: Bool) { badReceipt = value }
|
||||
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
|
||||
func uploadIDs() -> [String] { uploads }
|
||||
func sent() -> [AgentSubmission] { submissions }
|
||||
}
|
||||
private struct AttachmentUnavailableSocket: AgentSocket {
|
||||
func receive() throws -> Data { throw AgentFailure(message: "Synthetic closed socket") }
|
||||
func send(_ text: String) {}
|
||||
func close() {}
|
||||
func closeCode() -> Int { 4503 }
|
||||
}
|
||||
@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
|
||||
}
|
||||
private final class AttachmentURLProtocol: URLProtocol, @unchecked Sendable {
|
||||
private static let lock = NSLock()
|
||||
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, [String: String], Data))?
|
||||
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, [String: String], 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, headers, data) = try handler(request)
|
||||
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed)
|
||||
client?.urlProtocol(self, didLoad: data)
|
||||
client?.urlProtocolDidFinishLoading(self)
|
||||
} catch { client?.urlProtocol(self, didFailWithError: error) }
|
||||
}
|
||||
override func stopLoading() {}
|
||||
}
|
||||
private func attachmentSession() -> URLSession {
|
||||
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [AttachmentURLProtocol.self]
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
private func requestBytes(_ request: URLRequest) -> Data {
|
||||
if let body = request.httpBody { return body }
|
||||
guard let stream = request.httpBodyStream else { return Data() }
|
||||
stream.open(); defer { stream.close() }
|
||||
var data = Data(), buffer = [UInt8](repeating: 0, count: 4096)
|
||||
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(contentsOf: buffer.prefix(count)) }
|
||||
return data
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentAttachmentTests {
|
||||
@Test @MainActor func assistantCapabilityDoesNotAdmitInputAndRequiresToken() async throws {
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.path == "/api/v1/agent") // Never upload or submit references.
|
||||
return (200, [:], try descriptorData(capability: false, assistantImages: true))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
for token in [nil, " ", "synthetic-test"] {
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: token), session: session)
|
||||
let descriptor = try await client.agentDescriptor()
|
||||
#expect(descriptor.capabilities?.attachments == nil)
|
||||
#expect((descriptor.capabilities?.assistantImages?.supported == true) == (token == "synthetic-test"))
|
||||
let draft = AgentAttachmentDraft()
|
||||
draft.configure(client: client, storage: nil); draft.updateCapability(descriptor.capabilities?.attachments)
|
||||
#expect(!draft.supportsAttachments)
|
||||
#expect(draft.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) } == nil)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "output-only", attachments: [sampleAttachment()])) }
|
||||
}
|
||||
for json in [#"{"assistantImages":{"version":2}}"#, #"{"assistantImages":{"version":"future"}}"#] {
|
||||
#expect(try JSONDecoder().decode(AgentCapabilities.self, from: Data(json.utf8)).assistantImages?.supported != true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func assistantDescriptorsRequireCompletedAssistantImageAndSafeFields() throws {
|
||||
let image = AgentAttachment(id: "output-image", kind: .image, filename: "landscape.png", mimeType: "image/png", sizeBytes: 12, sha256: String(repeating: "a", count: 64))
|
||||
var row = Message(id: "outbound:1", createdAt: Date(), role: .assistant, content: "", metadata: .object([
|
||||
"kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([image.metadataValue])]))
|
||||
#expect(row.agentAttachments == [image])
|
||||
for role in [MessageRole.system, .tool] {
|
||||
var wrong = row; wrong.role = role
|
||||
#expect(wrong.agentAttachments.isEmpty)
|
||||
}
|
||||
for (key, value) in [("kind", "tool_call"), ("kind", "other"), ("status", "queued"), ("status", "streaming")] {
|
||||
var wrong = row, fields = row.metadata!.objectValue!
|
||||
fields[key] = .string(value); wrong.metadata = .object(fields)
|
||||
#expect(wrong.agentAttachments.isEmpty)
|
||||
}
|
||||
var descriptors: [JSONValue] = [sampleAttachment().metadataValue]
|
||||
for key in ["workspacePath", "sourcePath", "privatePath", "url", "sourceURL", "downloadUrl", "external", "future"] {
|
||||
var fields = image.metadataValue.objectValue!; fields[key] = .string("/private/synthetic.png")
|
||||
descriptors.append(.object(fields))
|
||||
}
|
||||
for (key, value) in [("filename", "../private.png"), ("filename", "https://example.invalid/a.png"), ("mimeType", "image/svg+xml"), ("sha256", "bad")] {
|
||||
var fields = image.metadataValue.objectValue!; fields[key] = .string(value)
|
||||
descriptors.append(.object(fields))
|
||||
}
|
||||
for descriptor in descriptors {
|
||||
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([descriptor])])
|
||||
#expect(row.agentAttachments.isEmpty)
|
||||
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([image.metadataValue, descriptor])])
|
||||
#expect(row.agentAttachments.isEmpty) // No valid prefix from an invalid output set.
|
||||
}
|
||||
let maximum = (0..<8).map { index in
|
||||
var item = image; item.id = "image-\(index)"; item.sizeBytes = 4 * 1024 * 1024
|
||||
return item
|
||||
}
|
||||
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array(maximum.map(\.metadataValue))])
|
||||
#expect(row.agentAttachments == maximum) // Exactly eight IDs and 32 MiB.
|
||||
let tooMany = (0..<9).map { index in
|
||||
var item = image; item.id = "image-\(index)"
|
||||
return item
|
||||
}
|
||||
var tooLarge = maximum; tooLarge[0].sizeBytes += 1
|
||||
for invalid in [tooMany, [image, image], tooLarge] {
|
||||
row.role = .assistant
|
||||
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array(invalid.map(\.metadataValue))])
|
||||
#expect(row.agentAttachments.isEmpty)
|
||||
row.role = .user
|
||||
#expect(row.agentAttachments == invalid) // These new output limits do not change legacy input parsing.
|
||||
}
|
||||
row.role = .user
|
||||
row.metadata = .object(["agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
#expect(row.agentAttachments == [sampleAttachment()])
|
||||
}
|
||||
|
||||
@Test @MainActor func imageOnlyAssistantLiveAndHistoryUseSameMergeAndAuthenticatedLoader() async throws {
|
||||
let png = try #require(UIGraphicsImageRenderer(size: CGSize(width: 24, height: 16)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 24, height: 16))
|
||||
}.pngData())
|
||||
let attachment = AgentAttachment(id: "output/opaque", kind: .image, filename: "landscape.png", mimeType: "image/png", sizeBytes: png.count,
|
||||
sha256: SHA256.hash(data: png).map { String(format: "%02x", $0) }.joined())
|
||||
let row = Message(id: "outbound:1", createdAt: Date(timeIntervalSince1970: 1), role: .assistant, content: "", metadata: .object([
|
||||
"kind": .string("message"), "status": .string("complete"), "entryId": .number(1), "agentAttachments": .array([attachment.metadataValue])]))
|
||||
let page = AgentPage(conversationId: "fixture-root", messages: [row], cursor: "1", hasMore: false)
|
||||
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
|
||||
let bytes = try encoder.encode(page)
|
||||
var envelope = try JSONSerialization.jsonObject(with: bytes) as! [String: Any]; envelope["type"] = "messages"
|
||||
let decodedEvent = try AgentEvent.decode(JSONSerialization.data(withJSONObject: envelope))
|
||||
let event = try #require(decodedEvent)
|
||||
let room = SybilAgentRoom(); defer { room.stop() }
|
||||
try room.receive(event); room.flush()
|
||||
var history = AgentRoomState()
|
||||
try history.apply(AgentCoding.decoder().decode(AgentPage.self, from: bytes))
|
||||
// Replay must not duplicate a completed output; no current capability is needed.
|
||||
try room.receive(event); room.flush()
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == AgentTranscript(messages: history.messages).displayed)
|
||||
#expect(room.messages.count == 1); #expect(room.messages[0].content.isEmpty)
|
||||
#expect(room.messages[0].agentAttachments == [attachment])
|
||||
var presentation = AgentPresentation()
|
||||
presentation.receive(event, before: AgentRoomState(), after: history, replay: false, now: Date())
|
||||
#expect(presentation.suppressed)
|
||||
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/output%2Fopaque/content")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
|
||||
return (200, ["Content-Length": String(png.count)], png)
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
|
||||
let downloads = AgentAttachmentDownloads()
|
||||
#expect(try await downloads.data(for: attachment, client: client) == png)
|
||||
AttachmentURLProtocol.install { _ in Issue.record("Verified output should be cached"); return (500, [:], Data()) }
|
||||
#expect(try await downloads.data(for: attachment, client: client) == png)
|
||||
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 0, count: png.count)) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
|
||||
let unauthenticated = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await unauthenticated.agentDownload(attachment) }
|
||||
}
|
||||
|
||||
@Test func additiveCapabilityAndDistinctMetadata() throws {
|
||||
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
|
||||
#expect(old.capabilities == nil)
|
||||
let current = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
|
||||
#expect(current.capabilities?.attachments?.supported == true)
|
||||
var future = try JSONSerialization.jsonObject(with: descriptorData()) as! [String: Any]
|
||||
future["capabilities"] = ["attachments": ["version": 9, "newWire": true]]
|
||||
let unsupported = try AgentCoding.decoder().decode(AgentDescriptor.self, from: JSONSerialization.data(withJSONObject: future))
|
||||
#expect(unsupported.capabilities?.attachments == nil)
|
||||
var other = attachmentLimits; other.version = 2
|
||||
#expect(!other.supported)
|
||||
let attachment = sampleAttachment()
|
||||
let message = Message(id: "user", createdAt: Date(), role: .user, content: "caption", metadata: .object(["agentAttachments": .array([attachment.metadataValue]), "future": .bool(true)]))
|
||||
#expect(message.attachments.isEmpty)
|
||||
#expect(message.agentAttachments == [attachment])
|
||||
#expect(message.content == "caption")
|
||||
let legacy = Data(#"{"id":"x","kind":"file","filename":"x.bin","mimeType":"application/octet-stream","sizeBytes":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","unknown":true}"#.utf8)
|
||||
#expect(try JSONDecoder().decode(AgentAttachment.self, from: legacy).workspacePath == nil)
|
||||
}
|
||||
@Test func safeNamesAndExactOriginalBytes() throws {
|
||||
#expect(try AgentAttachmentPreparation.filename("cafe\u{301}.txt") == "café.txt")
|
||||
for invalid in ["", ".", "..", "../secret", "x/y", "x\\y", "a\u{0000}b", "x\ny", String(repeating: "é", count: 128)] {
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.filename(invalid) }
|
||||
}
|
||||
let file = try AgentAttachmentPreparation.prepare(originalBytes, filename: "archive.zip", mimeType: "application/zip", limits: attachmentLimits)
|
||||
#expect(file.data == originalBytes)
|
||||
let text = try AgentAttachmentPreparation.prepare(originalBytes, filename: "invalid-utf8.txt", mimeType: "text/plain", limits: attachmentLimits)
|
||||
#expect(text.data == originalBytes) // Server classifies as file; client never decodes/truncates upload bytes.
|
||||
#expect(try AgentAttachmentPreparation.prepare(Data(), filename: "empty.txt", mimeType: "text/plain", limits: attachmentLimits).data.isEmpty)
|
||||
var small = attachmentLimits; small.maxFileBytes = 2
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.prepare(originalBytes, filename: "x.bin", mimeType: "application/octet-stream", limits: small) }
|
||||
}
|
||||
@Test @MainActor func rasterValidationAndFileSelection() throws {
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 8)).image { context in UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 12, height: 8)) }
|
||||
let png = try #require(image.pngData())
|
||||
#expect(try AgentAttachmentPreparation.prepare(png, filename: "red.png", mimeType: "image/png", limits: attachmentLimits).data == png)
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/jpeg") }
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/png", pixels: 20) }
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(Data("<svg/>".utf8), mime: "image/svg+xml") }
|
||||
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appendingPathComponent("sample.pdf"); try originalBytes.write(to: url)
|
||||
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
|
||||
#expect(file.data == originalBytes); #expect(file.mimeType == "application/pdf")
|
||||
}
|
||||
@Test @MainActor func syntheticHEICPhotoConvertsButFileKeepsOriginal() async throws {
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1800)).image { context in
|
||||
UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1800))
|
||||
}
|
||||
let encoded = NSMutableData()
|
||||
let destination = try #require(CGImageDestinationCreateWithData(encoded, UTType.heic.identifier as CFString, 1, nil), "Simulator must provide an ImageIO HEIC encoder for this regression")
|
||||
CGImageDestinationAddImage(destination, try #require(image.cgImage), nil)
|
||||
#expect(CGImageDestinationFinalize(destination), "Synthetic HEIC encoding must succeed")
|
||||
let data = encoded as Data
|
||||
let prepared = try await AgentAttachmentPreparation.offMain {
|
||||
try AgentAttachmentPreparation.photo(data, filename: "cafe\u{301}.HEIC", mimeType: "image/heic", limits: attachmentLimits)
|
||||
}
|
||||
#expect(prepared.filename == "café.jpg"); #expect(prepared.mimeType == "image/jpeg")
|
||||
#expect(prepared.data.count <= attachmentLimits.imageLimit)
|
||||
let source = try #require(CGImageSourceCreateWithData(prepared.data as CFData, nil))
|
||||
#expect(CGImageSourceGetType(source) as String? == UTType.jpeg.identifier)
|
||||
let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
|
||||
#expect((properties[kCGImagePropertyPixelWidth] as? Int ?? Int.max) <= 2000)
|
||||
#expect((properties[kCGImagePropertyPixelHeight] as? Int ?? Int.max) <= 2000)
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let url = root.appendingPathComponent("original.heic"); try data.write(to: url)
|
||||
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
|
||||
#expect(file.data == data); #expect(file.filename == "original.heic"); #expect(file.mimeType == "image/heic")
|
||||
var tooSmall = attachmentLimits; tooSmall.maxImagePixels = 100
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.photo(data, filename: "large.heic", mimeType: "image/heic", limits: tooSmall) }
|
||||
}
|
||||
|
||||
@Test @MainActor func downloadCoordinatorBoundsAndCancelsQueuedTransfers() async throws {
|
||||
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
|
||||
await client.pauseDownloads()
|
||||
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
|
||||
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
|
||||
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
let three = Task { try await downloads.data(for: sampleAttachment("three"), client: client) }
|
||||
let cancelled = Task { try await downloads.data(for: sampleAttachment("cancelled"), client: client) }
|
||||
try await Task.sleep(for: .milliseconds(30)); cancelled.cancel()
|
||||
await #expect(throws: CancellationError.self) { try await cancelled.value }
|
||||
#expect(await client.downloadCounts().0 == 2)
|
||||
await client.releaseDownload("one"); _ = try await one.value
|
||||
for _ in 0..<100 { if await client.downloadCounts().0 == 3 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
#expect(await client.downloadCounts().1 == 2)
|
||||
#expect(await client.downloadedIDs().contains("cancelled") == false)
|
||||
await client.releaseDownload("two"); await client.releaseDownload("three")
|
||||
_ = try await two.value; _ = try await three.value
|
||||
#expect(try await downloads.data(for: sampleAttachment("one"), client: client) == originalBytes)
|
||||
#expect(await client.downloadCounts().0 == 3) // Verified cache hit.
|
||||
}
|
||||
|
||||
@Test @MainActor func configurationResetRejectsOldDownloadsAndCache() async throws {
|
||||
let old = AttachmentTestClient(), next = AttachmentTestClient(), room = SybilAgentRoom()
|
||||
room.configure(client: old, storage: AttachmentTestStore().storage)
|
||||
defer { room.stop() }
|
||||
await old.pauseDownloads()
|
||||
let stale = Task { try await room.downloadAttachment(sampleAttachment()) }
|
||||
for _ in 0..<100 { if await old.downloadCounts().0 == 1 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
room.configure(client: next, storage: AttachmentTestStore().storage)
|
||||
await old.releaseDownload(sampleAttachment().id)
|
||||
await #expect(throws: CancellationError.self) { try await stale.value }
|
||||
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
|
||||
#expect(await next.downloadCounts().0 == 1)
|
||||
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
|
||||
#expect(await next.downloadCounts().0 == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func invalidatedDownloadQueueNeverStartsOrCaches() async throws {
|
||||
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
|
||||
await client.pauseDownloads()
|
||||
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
|
||||
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
|
||||
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
|
||||
let queued = Task { try await downloads.data(for: sampleAttachment("queued"), client: client) }
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
await downloads.invalidate()
|
||||
await #expect(throws: CancellationError.self) { try await queued.value }
|
||||
await client.releaseDownload("one"); await client.releaseDownload("two")
|
||||
await #expect(throws: CancellationError.self) { try await one.value }
|
||||
await #expect(throws: CancellationError.self) { try await two.value }
|
||||
await #expect(throws: CancellationError.self) { try await downloads.data(for: sampleAttachment("one"), client: client) }
|
||||
#expect(await client.downloadCounts().0 == 2)
|
||||
}
|
||||
|
||||
@Test func orderedReferenceAndReceiptValidation() throws {
|
||||
let a = sampleAttachment("one"), b = sampleAttachment("two")
|
||||
let request = try AgentSubmission(content: " ", requestID: "stable", attachments: [a, b])
|
||||
let fields = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any])
|
||||
#expect((fields["attachments"] as? [[String: String]]) == [["id": "one"], ["id": "two"]])
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable") }
|
||||
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable", attachments: [a, a]) }
|
||||
for ids: [String]? in [nil, [], ["one"], ["two", "one"], ["one", "two", "extra"]] {
|
||||
let receipt = AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ids)
|
||||
#expect(throws: AgentAttachmentIssue.self) { try receipt.validate(requestID: "stable", attachmentIDs: ["one", "two"]) }
|
||||
}
|
||||
try AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ["one", "two"]).validate(requestID: "stable", attachmentIDs: request.attachmentIDs)
|
||||
}
|
||||
@Test func multipartPreservesBinaryAndBoundary() throws {
|
||||
let id = UUID().uuidString
|
||||
let file = AgentPreparedAttachment(filename: "café \"original\".bin", mimeType: "application/octet-stream", data: originalBytes)
|
||||
let multipart = try AgentMultipart(file: file, uploadID: id)
|
||||
let prefix = Data("\r\n\r\n".utf8)
|
||||
#expect(multipart.body.range(of: originalBytes) != nil)
|
||||
let printable = String(decoding: multipart.body, as: UTF8.self)
|
||||
#expect(printable.components(separatedBy: "Content-Disposition:").count == 3)
|
||||
#expect(printable.contains("name=\"uploadId\"\r\n\r\n\(id)"))
|
||||
#expect(printable.contains("filename=\"café \\\"original\\\".bin\""))
|
||||
#expect(multipart.body.suffix(Data("\r\n--\(multipart.boundary)--\r\n".utf8).count) == Data("\r\n--\(multipart.boundary)--\r\n".utf8))
|
||||
#expect(multipart.body.range(of: prefix) != nil)
|
||||
}
|
||||
@Test func uploadTransportAuthBytesAndReceipt() async throws {
|
||||
let file = AgentPreparedAttachment(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), id = UUID().uuidString
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
|
||||
#expect(request.url?.query == nil)
|
||||
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
|
||||
#expect(request.url!.path == "/api/v1/agent/attachments"); #expect(request.httpMethod == "POST")
|
||||
let contentType = try #require(request.value(forHTTPHeaderField: "Content-Type"))
|
||||
let boundary = try #require(contentType.components(separatedBy: "boundary=").last)
|
||||
let bytes = requestBytes(request)
|
||||
#expect(bytes.starts(with: Data("--\(boundary)\r\n".utf8)))
|
||||
#expect(bytes.range(of: originalBytes) != nil); #expect(bytes.range(of: Data(id.utf8)) != nil)
|
||||
return (201, [:], try JSONEncoder().encode(["attachment": sampleAttachment()]))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
|
||||
#expect(try await client.agentUpload(file: file, uploadID: id) == sampleAttachment())
|
||||
}
|
||||
@Test func missingCapabilityNeverUploadsOrSubmitsReferences() async throws {
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.path == "/api/v1/agent")
|
||||
return (200, [:], try descriptorData(capability: false))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "stable", attachments: [sampleAttachment()])) }
|
||||
}
|
||||
@Test func openPublicAPIDisablesAttachmentCapabilityAndDownloads() async throws {
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.path == "/v1/agent")
|
||||
return (200, [:], try descriptorData())
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!), session: session)
|
||||
#expect(try await client.agentDescriptor().capabilities?.attachments == nil)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(sampleAttachment()) }
|
||||
}
|
||||
@Test func quotaCapabilitySnapshotAndDotFilenameAreAdditive() throws {
|
||||
var fields = try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)) as! [String: Any]
|
||||
fields.merge(["quotaBytes": 1073741824, "usedBytes": 20, "remainingBytes": 1073741804, "maxConcurrentUploads": 2, "maxConcurrentDecodes": 1, "future": true]) { _, next in next }
|
||||
let limits = try JSONDecoder().decode(AgentAttachmentCapabilities.self, from: JSONSerialization.data(withJSONObject: fields))
|
||||
#expect(limits.supported); #expect(limits.remainingBytes == 1073741804)
|
||||
#expect(limits.maxConcurrentUploads == 2); #expect(limits.maxConcurrentDecodes == 1)
|
||||
var attachment = sampleAttachment(name: ".notes.txt")
|
||||
attachment.workspacePath = "/data/workspace/uploads/server-id/_notes.txt"
|
||||
try attachment.validate()
|
||||
#expect(attachment.filename == ".notes.txt")
|
||||
#expect(attachment.workspacePath?.hasSuffix("/_notes.txt") == true)
|
||||
}
|
||||
@Test func downloadAuthOpaqueIDExactBytesAndLimits() async throws {
|
||||
let attachment = sampleAttachment("opaque/part?value#x")
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/opaque%2Fpart%3Fvalue%23x/content")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
|
||||
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
|
||||
return (200, ["Content-Length": String(originalBytes.count)], originalBytes)
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api/")!, authToken: "synthetic-test"), session: session)
|
||||
#expect(try await client.agentDownload(attachment) == originalBytes)
|
||||
AttachmentURLProtocol.install { _ in (200, [:], originalBytes + Data([9])) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
|
||||
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 1, count: originalBytes.count)) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
|
||||
}
|
||||
@Test func errorCodesAndAdditiveQuotaFieldsSurvive() async throws {
|
||||
for status in [409, 413, 422, 429, 507, 503] {
|
||||
AttachmentURLProtocol.install { request in
|
||||
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
|
||||
return (status, status == 429 ? ["Retry-After": "2"] : [:], try JSONSerialization.data(withJSONObject: ["error": true, "message": "Actionable storage message", "statusCode": status, "code": status == 507 ? "attachment_quota_exceeded" : status == 422 ? "image_model_unsupported" : "attachments_busy", "futureByteField": 123]))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!, authToken: "synthetic-test"), session: session)
|
||||
do { _ = try await client.agentDownload(sampleAttachment()); Issue.record("Expected failure") }
|
||||
catch let issue as AgentAttachmentIssue {
|
||||
#expect(issue.statusCode == status); #expect(issue.message == "Actionable storage message")
|
||||
#expect(issue.details["futureByteField"] == .number(123))
|
||||
if status == 507 { #expect(issue.code == "attachment_quota_exceeded") }
|
||||
if status == 429 { #expect(issue.retryAfter == "2") }
|
||||
if status == 422 { #expect(issue.code == "image_model_unsupported") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@Test func redirectPolicyRejectsUntrustedOrigin() async throws {
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let task = session.dataTask(with: URL(string: "https://fixture.invalid/api")!)
|
||||
let policy = AgentAttachmentRedirectPolicy()
|
||||
let response = HTTPURLResponse(url: URL(string: "https://fixture.invalid/api")!, statusCode: 302, httpVersion: nil, headerFields: [:])!
|
||||
await withCheckedContinuation { continuation in
|
||||
policy.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: URLRequest(url: URL(string: "https://untrusted.invalid")!)) { request in
|
||||
#expect(request == nil); continuation.resume()
|
||||
}
|
||||
}
|
||||
task.cancel()
|
||||
}
|
||||
@Test @MainActor func orderedSelectionFailureRetryRemoveAndCapability() async throws {
|
||||
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
|
||||
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
|
||||
await client.failNextUpload()
|
||||
let first = try #require(draft.add(filename: "one.bin") { .init(filename: "one.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
let second = try #require(draft.add(filename: "two.bin") { .init(filename: "two.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
#expect(!draft.canSend)
|
||||
#expect(await attachmentEventually { draft.items[0].state == .failed && draft.items[1].state == .ready })
|
||||
#expect(draft.items[0].issue?.code == "attachment_quota_exceeded")
|
||||
#expect(!draft.canSend); draft.retry(first)
|
||||
#expect(await attachmentEventually { draft.items.allSatisfy { $0.state == .ready } })
|
||||
#expect(draft.ready.map(\.filename) == ["one.bin", "two.bin"])
|
||||
#expect(await client.uploadIDs() == [first, second, first])
|
||||
#expect(draft.canSend)
|
||||
draft.updateCapability(nil); #expect(!draft.canSend)
|
||||
#expect(draft.ready.count == 2)
|
||||
draft.remove(first); draft.remove(second); draft.caption = "Text still works"
|
||||
#expect(draft.canSend)
|
||||
}
|
||||
@Test @MainActor func removedInflightUploadCannotReappear() async throws {
|
||||
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
|
||||
await client.delayUploads(.milliseconds(250))
|
||||
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
|
||||
let id = try #require(draft.add(filename: "remove.bin") { .init(filename: "remove.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
#expect(await attachmentEventually { draft.items.first?.state == .uploading })
|
||||
draft.remove(id)
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
#expect(draft.items.isEmpty); #expect(!draft.canSend)
|
||||
}
|
||||
@Test @MainActor func stagedUploadAndReadyDraftSurviveRelaunch() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let storage = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
|
||||
let client = AttachmentTestClient(), first = AgentAttachmentDraft()
|
||||
await client.failNextUpload()
|
||||
first.configure(client: client, storage: storage); first.updateCapability(attachmentLimits); first.caption = "Keep my caption"
|
||||
let id = try #require(first.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) })
|
||||
#expect(await attachmentEventually { first.items.first?.state == .failed })
|
||||
first.suspend()
|
||||
let next = AgentAttachmentDraft(); next.configure(client: client, storage: storage); next.updateCapability(attachmentLimits)
|
||||
#expect(next.caption == "Keep my caption"); #expect(next.items.first?.id == id)
|
||||
next.retry(id); #expect(await attachmentEventually { next.items.first?.state == .ready })
|
||||
#expect(await client.uploadIDs() == [id, id]); #expect(try storage.bytes(id) == originalBytes)
|
||||
let reopened = AgentAttachmentDraft(); reopened.configure(client: client, storage: storage)
|
||||
#expect(reopened.ready == next.ready); #expect(!reopened.canSend)
|
||||
reopened.updateCapability(attachmentLimits); #expect(reopened.canSend)
|
||||
reopened.clearAfterSubmission(); #expect(try storage.read().items.isEmpty)
|
||||
#expect(throws: (any Error).self) { try storage.bytes(id) }
|
||||
}
|
||||
@Test @MainActor func outboxReferencesPersistBeforePostRetryAndEchoWins() async throws {
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
room.configure(client: client, storage: store.storage); defer { room.stop() }
|
||||
#expect(await attachmentEventually { !room.isLoading })
|
||||
await client.malformedReceipts(true)
|
||||
await client.requirePersisted { store.read().first?.attachments == [sampleAttachment()] }
|
||||
#expect(room.send("", attachments: [sampleAttachment()]))
|
||||
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
|
||||
let original = try #require(room.outbox.first)
|
||||
room.retry(original.id)
|
||||
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
|
||||
let submitted = await client.sent()
|
||||
#expect(submitted.count == 2); #expect(submitted.allSatisfy { $0.requestId == original.requestID && $0.attachmentIDs == [sampleAttachment().id] })
|
||||
var echo = original.message
|
||||
echo.content = "Attached: original.bin."
|
||||
echo.metadata?.self = .object(["kind": .string("message"), "entryId": .number(1), "status": .string("complete"), "agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
try room.receive(.messages(.init(conversationId: "root", messages: [echo], cursor: "1", hasMore: false)))
|
||||
room.flush()
|
||||
#expect(room.outbox.first?.confirmed == true); #expect(store.read().isEmpty)
|
||||
#expect(room.messages.count == 1); #expect(room.messages.first?.agentAttachments == [sampleAttachment()])
|
||||
#expect(room.messages.first?.createdAt == original.createdAt)
|
||||
}
|
||||
@Test @MainActor func failedDraftHandoffCannotPostOrCreateAnotherIdentityAfterRelaunch() async throws {
|
||||
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
|
||||
defer { try? FileManager.default.removeItem(at: root) }
|
||||
let disk = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
|
||||
let draftID = UUID().uuidString
|
||||
let item = AgentDraftItem(filename: "original.bin", state: .ready, attachment: sampleAttachment())
|
||||
try disk.write(.init(caption: "Keep caption and references", items: [item], id: draftID))
|
||||
var unavailable = disk
|
||||
unavailable.write = { snapshot in
|
||||
if snapshot.items.isEmpty { throw AgentAttachmentIssue.local("Synthetic disk failure") }
|
||||
try disk.write(snapshot)
|
||||
}
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
room.configure(client: client, storage: store.storage, draftStorage: unavailable)
|
||||
#expect(await attachmentEventually { !room.isLoading })
|
||||
#expect(!room.sendComposer())
|
||||
#expect(room.composer == "Keep caption and references")
|
||||
#expect(room.attachmentDraft.ready == [sampleAttachment()])
|
||||
#expect(store.read().count == 1)
|
||||
let saved = try #require(store.read().first)
|
||||
#expect(saved.draftID == draftID)
|
||||
room.retry(saved.id)
|
||||
#expect(await client.sent().isEmpty)
|
||||
room.stop()
|
||||
let reopened = SybilAgentRoom()
|
||||
reopened.configure(client: client, storage: store.storage, draftStorage: disk)
|
||||
defer { reopened.stop() }
|
||||
#expect(await attachmentEventually { !reopened.isLoading })
|
||||
#expect(reopened.composer.isEmpty); #expect(reopened.attachmentDraft.items.isEmpty)
|
||||
#expect(!reopened.sendComposer())
|
||||
reopened.retry(saved.id)
|
||||
#expect(await attachmentEventually { reopened.outbox.first?.seenOnServer == true })
|
||||
#expect(await client.sent().map(\.requestId) == [saved.requestID])
|
||||
#expect(await client.sent().first?.attachmentIDs == [sampleAttachment().id])
|
||||
}
|
||||
@Test @MainActor func openProxyHTTP503AndSocket4503KeepReadyDraftAndOutbox() async throws {
|
||||
for socketFailure in [false, true] {
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
if socketFailure { await client.blockSocket() } else { await client.blockDescriptor() }
|
||||
let row = AgentOutboxRow(requestID: "unchanged-retry", content: "Retained caption", createdAt: Date(), attachments: [sampleAttachment()])
|
||||
try store.storage.write([row])
|
||||
let snapshot = AgentDraftSnapshot(caption: "Retained draft", items: [.init(filename: "original.bin", state: .ready, attachment: sampleAttachment())])
|
||||
let draft = AgentDraftStorage(read: { snapshot }, write: { _ in }, stage: { _, _ in }, bytes: { _ in originalBytes }, remove: { _ in })
|
||||
room.configure(client: client, storage: store.storage, draftStorage: draft)
|
||||
#expect(await attachmentEventually { room.error?.contains(socketFailure ? "4503" : "Synthetic open proxy") == true })
|
||||
#expect(!room.attachmentDraft.supportsAttachments)
|
||||
#expect(room.composer == "Retained draft"); #expect(room.attachmentDraft.ready == [sampleAttachment()])
|
||||
#expect(!room.sendComposer())
|
||||
#expect(room.outbox.first?.requestID == "unchanged-retry")
|
||||
#expect(store.read().first?.attachments == [sampleAttachment()])
|
||||
#expect(await client.sent().isEmpty)
|
||||
room.stop()
|
||||
}
|
||||
}
|
||||
@Test @MainActor func mismatchedCanonicalAndQueuedEchoesNeverHideLocalReferencesOrRetry() async throws {
|
||||
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
|
||||
await client.malformedReceipts(true)
|
||||
room.configure(client: client, storage: store.storage); defer { room.stop() }
|
||||
#expect(await attachmentEventually { !room.isLoading })
|
||||
let attachments = [sampleAttachment("first"), sampleAttachment("second")]
|
||||
#expect(room.send("Original caption", attachments: attachments))
|
||||
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
|
||||
let row = try #require(room.outbox.first)
|
||||
for (index, wrong) in [[], Array(attachments.reversed()), [attachments[0]]].enumerated() {
|
||||
var message = row.message
|
||||
message.content = "Incorrect server echo"
|
||||
message.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(Double(index + 1)), "agentAttachments": .array(wrong.map(\.metadataValue))])
|
||||
try room.receive(.messages(.init(conversationId: "root", messages: [message], cursor: String(index + 1), hasMore: false)))
|
||||
var activity = room.snapshot.room.activity
|
||||
activity.queuedMessages = [message]
|
||||
try room.receive(.activity("root", activity)); room.flush()
|
||||
#expect(room.outbox.first?.confirmed == false); #expect(room.outbox.first?.seenOnServer == false)
|
||||
#expect(room.outbox.first?.status == "failed"); #expect(room.outbox.first?.error != nil)
|
||||
#expect(room.messages.first?.agentAttachments == attachments)
|
||||
#expect(room.messages.first?.content == "Original caption")
|
||||
#expect(store.read().first?.requestID == row.requestID)
|
||||
}
|
||||
}
|
||||
@Test func legacyOutboxAndQueuedHistoryKeepDistinctAttachmentsAndHiddenTools() throws {
|
||||
let row = AgentOutboxRow(requestID: "stable", content: "caption", createdAt: Date(timeIntervalSince1970: 1))
|
||||
#expect(try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(row)).attachments == nil)
|
||||
var attached = row; attached.attachments = [sampleAttachment()]
|
||||
let restored = try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(attached))
|
||||
#expect(restored.attachments == attached.attachments); #expect(restored.requestID == row.requestID)
|
||||
var queued = restored.message; queued.metadata = .object(["kind": .string("message"), "status": .string("queued"), "agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
var committed = queued; committed.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(2), "agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
let tool = Message(id: "hidden", createdAt: Date(), role: .tool, content: "raw tool", metadata: .object(["kind": .string("tool_call"), "entryId": .number(3)]))
|
||||
var state = AgentRoomState()
|
||||
try state.apply(.init(conversationId: "root", messages: [queued, tool], cursor: "3", hasMore: false))
|
||||
try state.apply(.init(conversationId: "root", messages: [committed], cursor: "3", hasMore: false))
|
||||
try state.apply(.init(conversationId: "root", messages: [queued], cursor: "2", hasMore: false))
|
||||
#expect(state.messages.count == 2); #expect(state.cursor == "3"); #expect(state.unreadCount == 0)
|
||||
let transcript = AgentTranscript(messages: state.messages)
|
||||
#expect(transcript.messages.count == 1); #expect(transcript.messages[0].agentAttachments == [sampleAttachment()])
|
||||
#expect(transcript.messages[0].attachments.isEmpty)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -323,6 +347,33 @@ struct AgentTests {
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
|
||||
}
|
||||
|
||||
@Test func outputImageActivityKeepsImageReplyTypingSuppressed() throws {
|
||||
var presentation = AgentPresentation(), state = AgentRoomState()
|
||||
let now = Date(timeIntervalSince1970: 100)
|
||||
state.activity.isThinking = true
|
||||
presentation.sent(now: now, activity: state.activity)
|
||||
#expect(presentation.isTyping(now: now.addingTimeInterval(0.3)))
|
||||
let image = AgentAttachment(id: "image", kind: .image, filename: "image.png", mimeType: "image/png", sizeBytes: 12, sha256: String(repeating: "a", count: 64))
|
||||
var reply = agentMessage("image-reply", entry: "1", info: ["status": .string("complete"), "agentAttachments": .array([image.metadataValue])])
|
||||
reply.content = ""
|
||||
let page = agentPage([reply], cursor: "1")
|
||||
var after = state; try after.apply(page)
|
||||
presentation.receive(.messages(page), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
|
||||
state = after
|
||||
#expect(presentation.suppressed)
|
||||
|
||||
after.activity.tools = [.init(id: "output-activity", name: "output_image", status: "initiated")]
|
||||
presentation.receive(.activity("root", after.activity), before: state, after: after, replay: false, now: now.addingTimeInterval(2))
|
||||
state = after
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(3)))
|
||||
|
||||
let tool = agentPage([agentMessage("output-tool", entry: "2", role: .tool, info: ["toolName": .string("output_image"), "status": .string("initiated")])], cursor: "2")
|
||||
try after.apply(tool)
|
||||
presentation.receive(.messages(tool), before: state, after: after, replay: false, now: now.addingTimeInterval(4))
|
||||
#expect(presentation.suppressed)
|
||||
#expect(!presentation.isTyping(now: now.addingTimeInterval(5)))
|
||||
}
|
||||
|
||||
@Test func socketURLAndAuthentication() throws {
|
||||
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
|
||||
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
|
||||
@@ -420,6 +471,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,105 @@
|
||||
import MarkdownUI
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
@testable import Sybil
|
||||
|
||||
@Suite @MainActor
|
||||
struct MessagePresentationTests {
|
||||
@Test func agentMarkdownKeepsAltAndFormattingWithoutImageOrHTMLNodes() {
|
||||
let source = """
|
||||
**Caption** 
|
||||
|
||||
![Reference landscape][photo]
|
||||
|
||||
[](https://example.invalid)
|
||||
|
||||
> ](data:image/png;base64,AAAA)
|
||||
|
||||
<picture><source srcset="https://images.invalid/source.png"><img src="https://images.invalid/html.png" alt="HTML landscape" title="a > b"></picture>
|
||||
|
||||
[Useful link](https://example.invalid)
|
||||
|
||||
[photo]: https://images.invalid/reference.png
|
||||
"""
|
||||
let content = AgentMessageMarkdown.content(source)
|
||||
let html = content.renderHTML(), plain = content.renderPlainText()
|
||||
for label in ["Inline landscape", "Reference landscape", "Linked landscape", "Nested inner", "HTML landscape"] {
|
||||
#expect(plain.contains(label))
|
||||
}
|
||||
#expect(!html.contains("<img")); #expect(!html.contains("<picture")); #expect(!html.contains("<source"))
|
||||
#expect(!html.contains("images.invalid")); #expect(!html.contains("/private/"))
|
||||
#expect(html.contains("<strong>Caption</strong>"))
|
||||
#expect(html.contains(#"href="https://example.invalid""#))
|
||||
// Normal chat still uses unmodified MarkdownUI content.
|
||||
#expect(MarkdownContent(source).renderHTML().contains("<img"))
|
||||
#expect(AgentMessageMarkdown.content("<img src='https://images.invalid/a.png' alt='HTML landscape'>\n").renderHTML() == "<p>HTML landscape</p>\n")
|
||||
}
|
||||
|
||||
@Test func agentMarkdownRetainsCodeTablesAndEscapesImageLikeAltText() {
|
||||
let source = """
|
||||
``
|
||||
|
||||
```html
|
||||
<img src="literal-code">
|
||||
```
|
||||
|
||||
| Image | Value |
|
||||
| --- | --- |
|
||||
|  | **Kept** |
|
||||
|
||||
<IMG ALT='' SRC='ignored'>
|
||||
"""
|
||||
let html = AgentMessageMarkdown.content(source).renderHTML()
|
||||
#expect(html.contains("<table>")); #expect(html.contains("Table alt"))
|
||||
#expect(html.contains("<strong>Kept</strong>"))
|
||||
#expect(html.contains("<img src=")); #expect(html.contains("![literal]"))
|
||||
#expect(html.contains("![literal alt]")); #expect(!html.contains("<img"))
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
+74
-3
@@ -30,10 +30,69 @@ even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
|
||||
in the Agent transcript, including loaded history and live updates. History windows,
|
||||
date separators and scroll anchors use visible messages; raw merged history and
|
||||
cursors remain intact. Send failures, room errors and activity indicators remain
|
||||
visible. Attachments, model selection and tool cards belong to ordinary chats.
|
||||
visible. Model selection and tool cards continue to belong to ordinary chats.
|
||||
|
||||
Agent contracts remain in `../docs/api/rest.md` and
|
||||
`../docs/api/streaming-chat.md`; this client requires no backend deployment.
|
||||
`../docs/api/streaming-chat.md`. ROOT attachments consume the coordinated v1
|
||||
contract (2026-10-14), now documented in the integrated API sources above.
|
||||
Attachment readiness requires the coordinated backend/runtime and existing public
|
||||
API bearer authentication. This iOS change does not deploy or configure them.
|
||||
|
||||
### ROOT attachments
|
||||
|
||||
The Agent composer exposes Files and Photo Library only when the authenticated
|
||||
descriptor advertises supported `capabilities.attachments.version == 1`. Missing,
|
||||
malformed, or unsupported capability keeps the client text-only. Ordinary chat
|
||||
attachments retain their existing inline format and behavior.
|
||||
Open-proxy HTTP 503 / WebSocket 4503 failures disable attachment selection while
|
||||
retaining draft descriptors and outbox retry identities; reconnect checks support.
|
||||
|
||||
Files preserve their original bytes, including PDF, archives, text and HEIC files.
|
||||
Photo Library HEIC/HEIF selections are intentionally converted to bounded JPEG
|
||||
visual input with a matching `.jpg` filename. PNG/JPEG/GIF/WebP are validated as
|
||||
raster images; model input is static, with no claim of document or animation
|
||||
understanding. Safe original names supplied by the picker are retained; PhotosPicker
|
||||
may provide an export filename rather than the original Photo Library asset name.
|
||||
The production app does not request broad Photo Library access.
|
||||
|
||||
Selection order is stable. One preparation/upload runs at a time, with per-item
|
||||
loading, upload, error, retry and removal. Sending requires every item to be ready;
|
||||
an empty caption can send ready attachments. Advertised limits are checked against
|
||||
v1 hard ceilings (8 files, 32 MiB total, 6 MiB/image, 8 MiB/file, 50 million pixels,
|
||||
16,384 pixels/axis). Quota snapshots are decoded but do not reserve space. Server
|
||||
messages/codes, quota details and Retry-After survive failures, including 409, 413,
|
||||
429 and 507. Removal cancels local work and removes the reference; it does not
|
||||
delete an uploaded server original.
|
||||
|
||||
The caption, ordered upload UUIDs and ready descriptors are saved in a small atomic
|
||||
manifest under Application Support, scoped to the API base URL. Prepared bytes are
|
||||
staged in app-owned files (at most 8 MiB each and 32 MiB per draft); no inline bytes
|
||||
or tokens enter the outbox. Relaunch restores ready drafts and offers explicit retry
|
||||
for interrupted staged uploads with the same upload UUID. A selection interrupted
|
||||
before staging must be removed and selected again. Uploads suspend when the room
|
||||
stops; failed work does not automatically retry. Uploaded originals have no client
|
||||
expiration. After submission, the outbox owns the caption, ordered descriptors and
|
||||
stable request ID. A persisted draft identity recovers a crash during this handoff
|
||||
without creating a second send. Storage failure blocks submission rather than
|
||||
discarding a draft. Attachment receipts must acknowledge exactly the submitted IDs
|
||||
in order; matching canonical echoes still win ambiguous HTTP failures.
|
||||
|
||||
History and queued/live messages decode only `metadata.agentAttachments` for ROOT.
|
||||
Cards retain filename, MIME, size and the optional server-provided `workspacePath`,
|
||||
which can be copied. The client never constructs a runtime path or sends one as a
|
||||
reference; leading-dot display filenames remain unchanged. Captions remain public
|
||||
text, including the server's fallback for attachment-only messages.
|
||||
|
||||
Original downloads use the configured authenticated URLSession and API base
|
||||
(including `/api`), reject redirects, and verify exact size and SHA-256. At most two
|
||||
downloads run concurrently, with a 16 MiB in-memory cache discarded on configuration
|
||||
change. Queued/active cancellation and generation checks prevent stale previews
|
||||
from crossing configurations. Raster previews use bounded thumbnails; text previews
|
||||
are plain text, including HTML/SVG. Save/share exports exact original bytes to a
|
||||
temporary file removed when its card disappears or configuration changes. An app
|
||||
crash may leave a temporary export for normal OS temporary-directory cleanup.
|
||||
Image preview frames have fixed height to preserve scroll anchors during loading.
|
||||
Attachment modals track their own visibility without changing Quick Question state.
|
||||
|
||||
### Agent regression and Simulator checks
|
||||
|
||||
@@ -47,7 +106,10 @@ just screenshot build/agent-evidence/simulator.png
|
||||
```
|
||||
|
||||
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
|
||||
regressions along with the existing app tests through Xcode on iPhone 16e.
|
||||
regressions along with attachment capability/metadata, multipart/auth/bytes/errors,
|
||||
HEIC conversion versus original-file preservation, ordered receipts, draft/outbox
|
||||
relaunch, cancellation, two-transfer concurrency and configuration isolation tests
|
||||
through Xcode on iPhone 16e.
|
||||
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
|
||||
|
||||
```sh
|
||||
@@ -71,6 +133,15 @@ admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
|
||||
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
|
||||
anchors and multiline keyboard layout. The launch arguments select the fixture
|
||||
through ordinary API settings; no fixture mode is compiled into the application.
|
||||
Attachment cases exercise authenticated originals, safe text/raster display,
|
||||
history reload, capability absence and picker cancellation. The real Photo Library
|
||||
selection-to-send test is explicitly skipped: this iOS 26.5 XCTest environment has
|
||||
unreliable Photos authorization, callback isolation and synthetic-asset cleanup.
|
||||
Selection-to-send remains a manual/device pre-release check, not a validated UI
|
||||
path. The gate prevents photo mutations during the general UI suite. Synthetic
|
||||
HEIC conversion and original HEIC file preservation are covered by package tests.
|
||||
The fixture accepts multipart uploads, stable upload/message retries, structured
|
||||
errors and attachment receipts; it is not a runtime image-decoder or quota test.
|
||||
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
|
||||
Simulator Reduce Motion enabled before release.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -3,8 +3,13 @@
|
||||
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
import unicodedata
|
||||
import uuid
|
||||
from urllib.parse import quote
|
||||
from aiohttp import web
|
||||
|
||||
sockets = set()
|
||||
@@ -16,6 +21,29 @@ busy = False
|
||||
attempts = {}
|
||||
log = []
|
||||
reply_tasks = set()
|
||||
attachments = {}
|
||||
uploads = {}
|
||||
attachment_capability = True
|
||||
assistant_image_capability = False
|
||||
fail_image_download = False
|
||||
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,
|
||||
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
|
||||
|
||||
def attachment_error(status, code, message, **fields):
|
||||
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
|
||||
headers={"Retry-After": "2"} if status == 429 else None)
|
||||
|
||||
|
||||
def stamp():
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
|
||||
@@ -69,7 +97,8 @@ async def reply(content):
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
|
||||
global assistant_image_capability, fail_image_download
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
@@ -80,8 +109,40 @@ async def route(request):
|
||||
task.cancel()
|
||||
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
|
||||
assistant_image_capability = False; fail_image_download = 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':
|
||||
attachment_capability = body.get('enabled', True)
|
||||
assistant_image_capability = body.get('assistantImages', False)
|
||||
elif action == 'assistant-image':
|
||||
# Synthetic output supplied by XCTest, never a workspace file or provider.
|
||||
data = base64.b64decode(body['png'], validate=True)
|
||||
descriptor = dict(id='fixture-output-image', kind='image', filename='fixture-landscape.png',
|
||||
mimeType='image/png', sizeBytes=len(data), sha256=hashlib.sha256(data).hexdigest())
|
||||
attachments[descriptor['id']] = dict(descriptor=descriptor, data=data)
|
||||
fail_image_download = body.get('failFirstDownload', False)
|
||||
row = message(body.get('content', ''), info={'agentAttachments': [descriptor]})
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'upload-error':
|
||||
next_upload_error = body
|
||||
elif action == 'blocked-proxy':
|
||||
blocked_proxy = body.get('enabled', True)
|
||||
elif action == 'attachment-history':
|
||||
selected = [attachments[key]['descriptor'] for key in body['ids']]
|
||||
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'reply':
|
||||
await emit(body.get('content', 'Late asynchronous fixture reply'))
|
||||
elif action == 'long-tool':
|
||||
@@ -92,20 +153,43 @@ 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':
|
||||
return web.json_response(dict(items=[], hasMore=False))
|
||||
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
|
||||
if path == '/api/v1/chats':
|
||||
return web.json_response(dict(chats=[]))
|
||||
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
|
||||
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
|
||||
return web.json_response(dict(chat=dict(comparison, messages=messages)))
|
||||
if path == '/api/v1/searches':
|
||||
return web.json_response(dict(searches=[]))
|
||||
if path == '/api/v1/models':
|
||||
@@ -115,23 +199,129 @@ async def route(request):
|
||||
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
|
||||
if request.headers.get('Authorization') != 'Bearer fixture-token':
|
||||
return web.json_response(dict(error='Fixture token missing'), status=401)
|
||||
if blocked_proxy:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
|
||||
if path == '/api/v1/agent':
|
||||
return web.json_response(dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity()))
|
||||
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
if assistant_image_capability:
|
||||
descriptor.setdefault('capabilities', {})['assistantImages'] = dict(version=1)
|
||||
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.')
|
||||
if next_upload_error:
|
||||
failure = next_upload_error; next_upload_error = None
|
||||
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
|
||||
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
|
||||
reader = await request.multipart()
|
||||
parts = {}
|
||||
while part := await reader.next():
|
||||
if part.name not in ('file', 'uploadId') or part.name in parts:
|
||||
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
|
||||
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
|
||||
if set(parts) != {'file', 'uploadId'}:
|
||||
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
|
||||
try:
|
||||
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
|
||||
filename = unicodedata.normalize('NFC', parts['file'][0])
|
||||
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
|
||||
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
|
||||
except (ValueError, AssertionError, TypeError):
|
||||
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
|
||||
_, mime, data = parts['file']
|
||||
mime = (mime or 'application/octet-stream').lower()
|
||||
if len(data) > CAPABILITY['maxFileBytes']:
|
||||
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
identity = (filename, mime, digest)
|
||||
if upload_id in uploads:
|
||||
old_identity, attachment_id = uploads[upload_id]
|
||||
if old_identity != identity:
|
||||
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
|
||||
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
|
||||
kind = 'file'
|
||||
if mime in CAPABILITY['imageMimeTypes']:
|
||||
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
|
||||
elif mime.startswith('text/'):
|
||||
try:
|
||||
if '\0' not in data.decode('utf-8-sig'):
|
||||
kind = 'text'
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
attachment_id = 'fixture-' + str(uuid.uuid4())
|
||||
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
|
||||
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
|
||||
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
|
||||
uploads[upload_id] = (identity, attachment_id)
|
||||
return web.json_response({'attachment': descriptor}, status=201)
|
||||
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
|
||||
if fail_image_download:
|
||||
fail_image_download = False
|
||||
return attachment_error(503, 'fixture_retry', 'Synthetic image unavailable. Retry the download.')
|
||||
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
|
||||
stored = attachments.get(attachment_id)
|
||||
if stored is None:
|
||||
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
|
||||
descriptor = stored['descriptor']
|
||||
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
|
||||
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
|
||||
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
|
||||
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()
|
||||
key, content = body['requestId'], body['content']
|
||||
refs = body.get('attachments', [])
|
||||
ids = [ref.get('id') for ref in refs]
|
||||
if refs and not attachment_capability:
|
||||
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
|
||||
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
|
||||
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
|
||||
if any(key not in attachments for key in ids):
|
||||
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
|
||||
if not content.strip() and not ids:
|
||||
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
|
||||
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
|
||||
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
|
||||
attempts[key] = attempts.get(key, 0) + 1
|
||||
if content == 'retry fixture' and attempts[key] == 1:
|
||||
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
|
||||
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
|
||||
if ids:
|
||||
receipt['attachmentIds'] = ids
|
||||
if key not in requests:
|
||||
requests[key] = receipt
|
||||
row = message(content, 'user', id=receipt['messageId'])
|
||||
requests[key] = dict(receipt, _payload=[content, ids])
|
||||
descriptors = [attachments[key]['descriptor'] for key in ids]
|
||||
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
|
||||
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
|
||||
busy = True
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
|
||||
@@ -141,7 +331,7 @@ async def route(request):
|
||||
if content == 'echo wins fixture':
|
||||
await asyncio.sleep(0.3)
|
||||
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
|
||||
return web.json_response(requests[key], status=202)
|
||||
return web.json_response({k: v for k, v in requests[key].items() if k != '_payload'}, status=202)
|
||||
after = int(request.query.get('after', 0))
|
||||
rows = [m for m in messages if m['metadata']['entryId'] > after]
|
||||
limit = min(int(request.query.get('limit', 200)), 200)
|
||||
@@ -151,6 +341,9 @@ async def route(request):
|
||||
if request.query:
|
||||
return web.Response(status=400)
|
||||
socket = web.WebSocketResponse(); await socket.prepare(request)
|
||||
if blocked_proxy:
|
||||
await socket.close(code=4503, message=b'attachments_unavailable')
|
||||
return socket
|
||||
auth = await socket.receive_json(timeout=5)
|
||||
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
|
||||
await socket.close(code=4401); return socket
|
||||
@@ -172,6 +365,6 @@ async def route(request):
|
||||
|
||||
for i in range(3):
|
||||
message(f'History message {i + 1}: local simulator fixture.')
|
||||
app = web.Application()
|
||||
app = web.Application(client_max_size=8 * 1024 * 1024 + 64 * 1024)
|
||||
app.router.add_route('*', '/{path:.*}', route)
|
||||
web.run_app(app, host='127.0.0.1', port=8799)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
import Photos
|
||||
|
||||
@MainActor
|
||||
final class AgentUITests: XCTestCase {
|
||||
@@ -10,7 +11,9 @@ final class AgentUITests: XCTestCase {
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
openAgent()
|
||||
try clearComposer()
|
||||
}
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
private func openAgent() {
|
||||
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
@@ -23,6 +26,16 @@ final class AgentUITests: XCTestCase {
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
private func clearComposer() throws {
|
||||
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
|
||||
composer.tap(); 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 value = composer.value as? String ?? ""
|
||||
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
|
||||
}
|
||||
private func send(_ text: String) {
|
||||
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
|
||||
}
|
||||
@@ -37,6 +50,224 @@ final class AgentUITests: XCTestCase {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
}
|
||||
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
|
||||
let boundary = "Fixture-" + UUID().uuidString
|
||||
let uploadID = UUID().uuidString
|
||||
var body = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
|
||||
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
|
||||
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
|
||||
request.httpMethod = "POST"; request.httpBody = body
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
|
||||
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
return try XCTUnwrap(json["attachment"] as? [String: Any])
|
||||
}
|
||||
|
||||
func testAssistantImageOnlyLiveRetryAndHistoryWithoutInputCapability() async throws {
|
||||
app.terminate()
|
||||
try await control(["action": "reset", "count": 0])
|
||||
try await control(["action": "capability", "enabled": false, "assistantImages": true])
|
||||
try await control(["action": "push-available", "enabled": false])
|
||||
app.launch(); openAgent()
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 180)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 320, height: 180))
|
||||
UIColor.systemYellow.setFill(); context.cgContext.fillEllipse(in: CGRect(x: 225, y: 25, width: 50, height: 50))
|
||||
UIColor.systemIndigo.setFill()
|
||||
context.cgContext.move(to: CGPoint(x: 0, y: 180)); context.cgContext.addLine(to: CGPoint(x: 120, y: 55))
|
||||
context.cgContext.addLine(to: CGPoint(x: 250, y: 180)); context.cgContext.fillPath()
|
||||
}
|
||||
try await control(["action": "reply", "content": "\n\n<img src='http://127.0.0.1:8799/blocked-html.png' alt='HTML landscape'>"])
|
||||
try await control(["action": "assistant-image", "content": "", "png": try XCTUnwrap(image.pngData()).base64EncodedString(), "failFirstDownload": true])
|
||||
XCTAssertTrue(app.staticTexts["fixture-landscape.png"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.buttons["Retry download"].waitForExistence(timeout: 8))
|
||||
let imageFrame = app.otherElements["agent-image-frame"]
|
||||
XCTAssertTrue(imageFrame.exists)
|
||||
let placeholder = imageFrame.frame
|
||||
XCTAssertGreaterThan(placeholder.width, 0)
|
||||
XCTAssertEqual(placeholder.height, 180, accuracy: 1)
|
||||
app.buttons["Retry download"].tap()
|
||||
let preview = app.images["agent-image-preview"]
|
||||
XCTAssertTrue(preview.waitForExistence(timeout: 8))
|
||||
XCTAssertEqual(preview.label, "Image: fixture-landscape.png")
|
||||
XCTAssertLessThanOrEqual(preview.frame.height, 180)
|
||||
XCTAssertEqual(imageFrame.frame.width, placeholder.width, accuracy: 1)
|
||||
XCTAssertEqual(imageFrame.frame.height, placeholder.height, accuracy: 1)
|
||||
let loadedSize = imageFrame.frame.size
|
||||
XCTAssertTrue(app.staticTexts["Markdown landscape"].exists)
|
||||
XCTAssertTrue(app.staticTexts["HTML landscape"].exists)
|
||||
XCTAssertFalse(app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Copy workspace path:")).firstMatch.exists)
|
||||
capture("agent-assistant-image-live")
|
||||
|
||||
// Previously saved output remains visible when both capabilities disappear.
|
||||
try await control(["action": "capability", "enabled": false, "assistantImages": false])
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(preview.waitForExistence(timeout: 8))
|
||||
XCTAssertEqual(imageFrame.frame.width, placeholder.width, accuracy: 1)
|
||||
XCTAssertEqual(imageFrame.frame.height, placeholder.height, accuracy: 1)
|
||||
print("Assistant image frame sizes: placeholder=\(placeholder.size), live=\(loadedSize), history=\(imageFrame.frame.size)")
|
||||
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "fixture-landscape.png")).count, 1)
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
capture("agent-assistant-image-history")
|
||||
let state = try await control(["action": "state"])
|
||||
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
|
||||
XCTAssertFalse(log.contains { ($0["path"] as? String)?.hasPrefix("/blocked-") == true })
|
||||
XCTAssertGreaterThanOrEqual(log.filter { ($0["path"] as? String) == "/api/v1/agent/attachments/fixture-output-image/content" }.count, 3)
|
||||
XCTAssertTrue((state["requests"] as? [String: Any])?.isEmpty == true)
|
||||
}
|
||||
|
||||
func testAttachmentHistoryDownloadAndReload() async throws {
|
||||
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
|
||||
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
|
||||
}
|
||||
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
|
||||
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
|
||||
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
|
||||
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
|
||||
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
|
||||
let path = try XCTUnwrap(file["workspacePath"] as? String)
|
||||
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
|
||||
let download = app.buttons["agent-download"].firstMatch
|
||||
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
|
||||
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
|
||||
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
|
||||
capture("agent-attachment-original-and-preview")
|
||||
try await control(["action": "long-tool"])
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
|
||||
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
|
||||
capture("agent-attachment-reloaded")
|
||||
// The real app's authenticated content request is recorded by the loopback fixture.
|
||||
let state = try await control(["action": "state"])
|
||||
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
|
||||
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
|
||||
}
|
||||
|
||||
func testAttachmentCapabilityAndPickerCancellation() async throws {
|
||||
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
|
||||
composer.tap(); composer.typeText("Preserved attachment draft caption")
|
||||
app.buttons["agent-attach"].tap()
|
||||
app.buttons["Files"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["Cancel"].firstMatch.tap()
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
|
||||
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
|
||||
app.buttons["Cancel"].firstMatch.tap()
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
capture("agent-attachment-pickers-cancelled")
|
||||
try await control(["action": "capability", "enabled": false])
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
|
||||
try clearComposer()
|
||||
capture("agent-text-only-capability")
|
||||
}
|
||||
|
||||
func testPhotoSelectionToAttachmentOnlySend() async throws {
|
||||
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
|
||||
// Only synthetic assets are touched; no unrelated photo bytes are read.
|
||||
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
|
||||
// XCTest's synchronous UI waits must not block the authorization request.
|
||||
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
|
||||
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
|
||||
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
|
||||
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
|
||||
}
|
||||
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
|
||||
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
|
||||
// Recover only fixtures from an interrupted test, identified by BOTH our unique
|
||||
// date and original-filename prefix, before creating this run's asset.
|
||||
let options = PHFetchOptions()
|
||||
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
|
||||
let previous = PHAsset.fetchAssets(with: .image, options: options)
|
||||
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
|
||||
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
|
||||
}.map(\.localIdentifier)
|
||||
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
|
||||
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
|
||||
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
|
||||
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
|
||||
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
|
||||
}
|
||||
try XCTUnwrap(photo.pngData()).write(to: url)
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
nonisolated(unsafe) var assetID: String?
|
||||
try await PHPhotoLibrary.shared().performChanges { @Sendable in
|
||||
let creation = PHAssetCreationRequest.forAsset()
|
||||
// A unique synthetic date lets us find this asset without enumerating other photos.
|
||||
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
|
||||
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
|
||||
creation.addResource(with: .photo, fileURL: url, options: options)
|
||||
assetID = creation.placeholderForCreatedAsset?.localIdentifier
|
||||
}
|
||||
let ownedID = try XCTUnwrap(assetID)
|
||||
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
|
||||
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
|
||||
do { try await exercisePhotoSelection() }
|
||||
catch {
|
||||
try await removeSyntheticPhotos([ownedID])
|
||||
throw error
|
||||
}
|
||||
try await removeSyntheticPhotos([ownedID])
|
||||
}
|
||||
|
||||
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
|
||||
try await Self.eraseSyntheticPhotos(identifiers)
|
||||
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
|
||||
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
|
||||
}
|
||||
|
||||
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
|
||||
try await Task.detached {
|
||||
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
|
||||
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
|
||||
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
|
||||
XCTAssertEqual(remaining, 0)
|
||||
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
|
||||
}.value
|
||||
}
|
||||
|
||||
private func exercisePhotoSelection() async throws {
|
||||
app.activate()
|
||||
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
|
||||
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
|
||||
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
|
||||
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 8))
|
||||
cell.tap()
|
||||
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
|
||||
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
|
||||
await fulfillment(of: [ready], timeout: 15)
|
||||
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
|
||||
capture("agent-photo-upload-ready")
|
||||
app.buttons["agent-send"].tap()
|
||||
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
|
||||
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
let state = try await control(["action": "state"])
|
||||
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
|
||||
XCTAssertEqual(requests.count, 1)
|
||||
let submission = try XCTUnwrap(requests.values.first)
|
||||
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
|
||||
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
|
||||
capture("agent-photo-attachment-only-canonical")
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
|
||||
XCTAssertFalse(app.buttons["agent-retry"].exists)
|
||||
capture("agent-photo-attachment-only-reloaded")
|
||||
}
|
||||
|
||||
func testSendQueueToolsAndRetry() async throws {
|
||||
capture("agent-history")
|
||||
send("first fixture")
|
||||
@@ -63,6 +294,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")
|
||||
@@ -117,7 +366,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"])
|
||||
@@ -125,20 +375,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 {
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import XCTest
|
||||
import UIKit
|
||||
|
||||
/// Loopback-only regressions. No picker selection or external backend writes.
|
||||
@MainActor
|
||||
final class TranscriptFadeUITests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
|
||||
override func setUp() async throws {
|
||||
continueAfterFailure = false
|
||||
XCUIDevice.shared.orientation = .portrait
|
||||
try await control(["action": "reset", "count": 160])
|
||||
try await control(["action": "transcript-comparison"])
|
||||
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
|
||||
app.launch()
|
||||
}
|
||||
|
||||
override func tearDown() async throws { app.terminate() }
|
||||
|
||||
private func showSidebar() {
|
||||
if !app.buttons["agent-sidebar"].isHittable {
|
||||
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
|
||||
app.buttons["Show sidebar"].tap()
|
||||
}
|
||||
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
|
||||
}
|
||||
|
||||
private func openAgent() {
|
||||
showSidebar()
|
||||
app.buttons["agent-sidebar"].tap()
|
||||
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists)
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8))
|
||||
}
|
||||
|
||||
private var composer: XCUIElement {
|
||||
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
|
||||
}
|
||||
|
||||
private var transcript: XCUIElement {
|
||||
// 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) {
|
||||
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
|
||||
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||
}
|
||||
|
||||
@discardableResult private func control(_ value: [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: value)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
|
||||
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
|
||||
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
|
||||
// Normal chat is lazy and can open at either end on phone/tablet.
|
||||
let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch
|
||||
XCTAssertTrue(normal.waitForExistence(timeout: 8))
|
||||
let normalTop = normal.frame.minY
|
||||
let first = app.staticTexts["History message 1: local simulator fixture."]
|
||||
if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) }
|
||||
else { normal.swipeDown(velocity: .slow) }
|
||||
capture("normal-chat-content-crossing-shared-fade")
|
||||
XCTAssertTrue(app.buttons["Attach file"].isHittable)
|
||||
|
||||
openAgent()
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(scroll.exists)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
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)
|
||||
XCTAssertTrue(app.buttons["agent-send"].exists)
|
||||
// A viewport padded below the header clips bubbles before they reach its fade.
|
||||
XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1,
|
||||
"Agent must scroll beneath the same workspace header as normal chat")
|
||||
}
|
||||
|
||||
func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws {
|
||||
openAgent()
|
||||
let scroll = transcript
|
||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
scroll.swipeDown(velocity: .slow)
|
||||
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 {
|
||||
$0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY
|
||||
})
|
||||
let before = anchor.frame.minY
|
||||
let stateBefore = try await control(["action": "state"])
|
||||
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
||||
// 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)
|
||||
XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int))
|
||||
capture("agent-fade-reading-position-and-unread")
|
||||
|
||||
for _ in 0..<30 {
|
||||
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)
|
||||
load.tap()
|
||||
// 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")
|
||||
|
||||
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")
|
||||
// No send: this only changes a local draft, then clears it.
|
||||
composer.typeText("Fade draft\nsecond line\nthird line")
|
||||
XCTAssertTrue(latest.isHittable)
|
||||
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count))
|
||||
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
|
||||
@@ -23,9 +39,24 @@ targets:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: Synthetic attachment UI tests create and remove their own photo.
|
||||
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,142 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
|
||||
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
|
||||
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
|
||||
export const AgentAttachmentDescriptor = z.object({
|
||||
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
|
||||
});
|
||||
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
|
||||
|
||||
function apiError(statusCode: number, code: string, message: string) {
|
||||
return Object.assign(new Error(message), { statusCode, code });
|
||||
}
|
||||
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal, download = false) {
|
||||
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
|
||||
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
|
||||
const value = descriptor.data as { capabilities?: { attachments?: { version?: number }; assistantImages?: { version?: number } } } | null;
|
||||
if (descriptor.status !== 200 || !(value?.capabilities?.attachments?.version === 1
|
||||
|| (download && value?.capabilities?.assistantImages?.version === 1)))
|
||||
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
|
||||
}
|
||||
|
||||
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
|
||||
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
|
||||
await app.register(async routes => {
|
||||
let uploads = 0, downloads = 0;
|
||||
type Transfer = { controller: AbortController; release(): void };
|
||||
const transfers = new WeakMap<FastifyRequest, Transfer>();
|
||||
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
|
||||
routes.addHook('onRequest', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
|
||||
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
|
||||
const uploading = req.method === 'POST';
|
||||
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
|
||||
if (uploading) uploads++; else downloads++;
|
||||
const controller = new AbortController(); let released = false;
|
||||
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
|
||||
const release = () => {
|
||||
if (released) return; released = true;
|
||||
if (uploading) uploads--; else downloads--;
|
||||
clearTimeout(timeout); controller.abort();
|
||||
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
|
||||
};
|
||||
transfers.set(req, { controller, release });
|
||||
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
|
||||
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
|
||||
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
|
||||
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||
await requireAgentAttachments(options.proxyJson, controller.signal, !uploading);
|
||||
controller.signal.throwIfAborted();
|
||||
});
|
||||
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
|
||||
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
|
||||
if (reply.raw.destroyed) return;
|
||||
if (_req.method === 'POST') reply.header('Connection', 'close');
|
||||
const statusCode = error.statusCode || 502;
|
||||
const code = typeof error.code === 'string' ? error.code : undefined;
|
||||
if (statusCode === 429) reply.header('Retry-After', '2');
|
||||
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
|
||||
statusCode, code: code || 'agent_unavailable' });
|
||||
});
|
||||
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
|
||||
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
|
||||
else done(null, payload);
|
||||
});
|
||||
const json = async (response: Response) => {
|
||||
let size = 0; const chunks: Uint8Array[] = [];
|
||||
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
|
||||
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
|
||||
finally { reader.releaseLock(); }
|
||||
};
|
||||
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
|
||||
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
|
||||
let oversized = false;
|
||||
const incoming = req.body;
|
||||
const { controller } = transfer(req);
|
||||
const body = Readable.from((async function* () {
|
||||
let count = 0;
|
||||
for await (const chunk of incoming) {
|
||||
count += chunk.length;
|
||||
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
|
||||
yield chunk;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
|
||||
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
|
||||
} as RequestInit & { duplex: string });
|
||||
const value = await json(response); controller.signal.throwIfAborted();
|
||||
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
|
||||
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
|
||||
if (response.status === 429) reply.header('Retry-After', '2');
|
||||
return reply.code(response.status).send(value);
|
||||
} catch (error) {
|
||||
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
|
||||
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
|
||||
} finally { body.destroy(); }
|
||||
});
|
||||
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
|
||||
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
|
||||
const { controller } = transfer(req);
|
||||
let response: Response;
|
||||
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
|
||||
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
|
||||
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
|
||||
controller.signal.throwIfAborted();
|
||||
if (!response.ok) return reply.code(response.status).send(await json(response));
|
||||
const length = Number(response.headers.get('content-length'));
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
const type = response.headers.get('content-type') || '';
|
||||
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|
||||
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
|
||||
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
|
||||
}
|
||||
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
|
||||
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
|
||||
try {
|
||||
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
|
||||
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
|
||||
if (size !== length) throw new Error('Download length mismatch');
|
||||
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
|
||||
finally { reader.releaseLock(); }
|
||||
controller.signal.throwIfAborted();
|
||||
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(Buffer.concat(chunks, size));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,92 @@
|
||||
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(),
|
||||
agentAttachments: z.unknown().optional() }) });
|
||||
|
||||
/** 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');
|
||||
// Attachment messages use the generic alert. Never expose captions, filenames,
|
||||
// paths, image data or linked resources in an image notification.
|
||||
if (message.data.metadata.agentAttachments !== undefined) return '';
|
||||
// Never inspect 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;
|
||||
}
|
||||
+55
-29
@@ -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';
|
||||
@@ -6,17 +8,19 @@ import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { prisma } from './db.js';
|
||||
import { env } from './env.js';
|
||||
import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.js';
|
||||
|
||||
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
|
||||
.transform(value => String(Number(value)));
|
||||
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
|
||||
.refine(value => !(value.after && value.before), 'Use after or before, not both');
|
||||
const MessageBody = z.object({ content: z.string().min(1).max(40_000).refine(value => value.trim().length > 0, 'content is required'), requestId: z.string().min(1).max(256) });
|
||||
const ReadBody = z.object({ cursor: Cursor });
|
||||
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional() });
|
||||
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, 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() });
|
||||
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
|
||||
|
||||
type AgentOptions = { baseUrl?: string; apiKey?: string };
|
||||
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
|
||||
@@ -25,22 +29,33 @@ 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 proxyJson = async (path: string, method = 'GET', body?: unknown) => {
|
||||
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: { '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; assistantImages?: unknown } };
|
||||
if (!response.ok || descriptor?.capabilities?.attachments || descriptor?.capabilities?.assistantImages) throw new Error('Private runtime');
|
||||
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
|
||||
};
|
||||
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
|
||||
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
|
||||
await guardOpenProxy(signal);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(new URL(path, baseUrl), { method,
|
||||
headers: { Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(15_000),
|
||||
redirect: 'error',
|
||||
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'); }
|
||||
let data: unknown;
|
||||
@@ -55,7 +70,9 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
||||
const result = await proxyJson('/api/conversations/root');
|
||||
reply.code(result.status);
|
||||
if (result.status !== 200) return result.data;
|
||||
return { ...result.data as object, websocket: '/v1/agent/ws' };
|
||||
const descriptor: Record<string, unknown> = { ...result.data as Record<string, unknown>, websocket: '/v1/agent/ws' };
|
||||
if (!env.ADMIN_TOKEN) delete descriptor.capabilities;
|
||||
return descriptor;
|
||||
});
|
||||
app.get('/v1/agent/messages', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'no-store');
|
||||
@@ -71,10 +88,13 @@ 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) {
|
||||
const receipt = SubmissionReceipt.safeParse(result.data);
|
||||
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`) {
|
||||
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`
|
||||
|| (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) {
|
||||
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
|
||||
}
|
||||
}
|
||||
@@ -84,25 +104,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);
|
||||
});
|
||||
|
||||
attachAgentSocket(app, baseUrl, apiKey);
|
||||
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
|
||||
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy, push, readListeners);
|
||||
}
|
||||
|
||||
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: string) {
|
||||
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) => {
|
||||
@@ -114,7 +129,9 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
|
||||
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;
|
||||
let authenticated = false, disposed = false, alive = true;
|
||||
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;
|
||||
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
|
||||
@@ -122,25 +139,34 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey?: strin
|
||||
};
|
||||
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);
|
||||
browser.on('close', dispose); browser.on('error', dispose);
|
||||
browser.on('message', (raw, isBinary) => {
|
||||
browser.on('message', async (raw, isBinary) => {
|
||||
try {
|
||||
if (isBinary) throw new Error('JSON text frames are required');
|
||||
const frame = JSON.parse(raw.toString());
|
||||
if (!authenticated) {
|
||||
if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; }
|
||||
authenticating = true;
|
||||
const auth = parse(AuthFrame, frame);
|
||||
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
|
||||
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
|
||||
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,
|
||||
|
||||
|
||||
+11
-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,17 +71,20 @@ 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 } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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';
|
||||
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' };
|
||||
const bodies: any[] = [];
|
||||
let capable=true, assistantImages=false, badReceipt=false, errorStatus=0, uploadCalls=0;
|
||||
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
|
||||
const source=createServer(async(req,res)=>{
|
||||
assert.equal(req.headers.authorization,`Bearer ${key}`);
|
||||
const chunks: Buffer[]=[];
|
||||
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
|
||||
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
|
||||
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',capabilities:{...(capable?{attachments:{version:1}}:{}),...(assistantImages?{assistantImages:{version:1}}:{})}}));return;}
|
||||
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
|
||||
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
|
||||
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
|
||||
if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;}
|
||||
res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false}));
|
||||
});
|
||||
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()));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()=>{
|
||||
const before=uploadCalls;
|
||||
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before);
|
||||
capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
|
||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503);
|
||||
capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined;
|
||||
try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;}
|
||||
});
|
||||
|
||||
test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{
|
||||
const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/);
|
||||
errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;}
|
||||
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
|
||||
const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff');
|
||||
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
|
||||
});
|
||||
|
||||
test('assistant-only capability allows authenticated retrieval without granting input uploads or open history',async()=>{
|
||||
capable=false;assistantImages=true;
|
||||
const saved=env.ADMIN_TOKEN;
|
||||
try {
|
||||
const descriptor=(await app.inject({url:'/v1/agent',headers:auth})).json();
|
||||
assert.deepEqual(descriptor.capabilities,{assistantImages:{version:1}});
|
||||
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
|
||||
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
|
||||
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
|
||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'assistant-only',attachments:[{id:file.id}]}})).statusCode,503);
|
||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/output_image',headers:auth,payload:{path:'https://never-fetch.invalid/image'}})).statusCode,404);
|
||||
env.ADMIN_TOKEN=undefined;
|
||||
for(const url of ['/v1/agent','/v1/agent/messages',`/v1/agent/attachments/${file.id}/content`]) assert.equal((await app.inject({url})).statusCode,503);
|
||||
} finally {env.ADMIN_TOKEN=saved;capable=true;assistantImages=false;}
|
||||
});
|
||||
|
||||
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
|
||||
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
|
||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
|
||||
badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;}
|
||||
for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400);
|
||||
const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool');
|
||||
});
|
||||
|
||||
test('proxy rejects oversized declared bodies and chunked bodies',async()=>{
|
||||
const before=uploadCalls;
|
||||
const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before);
|
||||
const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})());
|
||||
const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string});
|
||||
assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded');
|
||||
});
|
||||
|
||||
async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()<end)await new Promise(r=>setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');}
|
||||
test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{
|
||||
const before=uploadCalls;
|
||||
holdDescriptor=true;
|
||||
for(let n=0;n<3;n++){
|
||||
const expected=held.length+1,terminal=closed.length+1;
|
||||
const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--');
|
||||
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
|
||||
}
|
||||
holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload');
|
||||
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201);
|
||||
holdDownload=true;
|
||||
for(let n=0;n<5;n++){
|
||||
const expected=held.length+1,terminal=closed.length+1;
|
||||
const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end();
|
||||
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
|
||||
}
|
||||
holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
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');
|
||||
page = { ...original, messages: [{ ...message, content: 'Caption stays inside authenticated chat.', metadata: { ...message.metadata,
|
||||
agentAttachments: [{ filename: 'private-name.png', workspacePath: '/private/source.png', url: 'https://never-fetch.invalid/image' }] } }] };
|
||||
const imageText = await load(candidate);
|
||||
assert.equal(imageText, '', 'image captions and metadata use only a generic notification');
|
||||
assert.deepEqual(JSON.parse(previewPayload(imageText, body => ({ body }), 4096).toString()), { body: 'You have a new message.' });
|
||||
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');
|
||||
|
||||
+125
-15
@@ -3,24 +3,134 @@ self.addEventListener("install", () => {
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
await self.clients.claim();
|
||||
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
await Promise.all(
|
||||
windows.map(async (client) => {
|
||||
try {
|
||||
await client.navigate(client.url);
|
||||
} catch {
|
||||
// The client may have closed while the new worker was activating.
|
||||
}
|
||||
})
|
||||
);
|
||||
})()
|
||||
);
|
||||
// Navigations through this worker wait for activation. Awaiting a client
|
||||
// reload here deadlocks first visits, including the token sign-in screen.
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
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"));
|
||||
});
|
||||
|
||||
+6
-3
@@ -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,
|
||||
@@ -3552,7 +3554,7 @@ export default function App() {
|
||||
type="button"
|
||||
data-testid="agent-sidebar-item"
|
||||
aria-current={isAgentMode ? "page" : undefined}
|
||||
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
|
||||
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? " or more" : ""} unread messages` : agentRoom.unreadCountIsPartial ? ", earlier messages may be unread" : ""}${isAgentBusy ? ", working" : ""}`}
|
||||
onClick={() => {
|
||||
setContextMenu(null);
|
||||
setError(null);
|
||||
@@ -3578,9 +3580,9 @@ export default function App() {
|
||||
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
||||
</span>
|
||||
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
||||
{agentRoom.unreadCount > 0 ? (
|
||||
{agentRoom.unreadCount > 0 || agentRoom.unreadCountIsPartial ? (
|
||||
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
||||
{agentRoom.unreadCount}
|
||||
{agentRoom.unreadCount ? `${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? "+" : ""}` : "•"}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact";
|
||||
import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
|
||||
|
||||
type Props = { file: AgentAttachment; inline?: boolean; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
|
||||
/** Authenticated bytes only; no native credential, remote href, or public file serving. */
|
||||
export function AgentAttachmentCard({ file, download, inline = false }: Props) {
|
||||
const [preview, setPreview] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const controller = useRef<AbortController>();
|
||||
const transferring = useRef(false);
|
||||
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
|
||||
const retrieve = useCallback(async (show: boolean) => {
|
||||
if (transferring.current) return;
|
||||
transferring.current = true;
|
||||
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
|
||||
try {
|
||||
const blob = await download(file, request.signal); if (request.signal.aborted) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
if (show && file.kind === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(blob.type)) setPreview(url);
|
||||
else {
|
||||
const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.filename; anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
|
||||
finally { if (controller.current === request) { transferring.current = false; if (!request.signal.aborted) setBusy(false); } }
|
||||
}, [download, file.id, file.sha256, file.mimeType, file.kind, file.sizeBytes, file.filename]);
|
||||
useEffect(() => {
|
||||
setPreview(undefined); setError(undefined); setBusy(false); transferring.current = false;
|
||||
if (inline && file.kind === "image") void retrieve(true);
|
||||
return () => { controller.current?.abort(); };
|
||||
}, [inline, retrieve]);
|
||||
return <div className="agent-attachment">
|
||||
<div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div>
|
||||
{!inline && file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null}
|
||||
{inline ? <div className="agent-attachment__image-frame" aria-busy={busy}>
|
||||
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} onError={() => { setPreview(undefined); setError("Image could not be displayed."); }} />
|
||||
: <span role="status">{error ? "Image unavailable" : "Loading image…"}</span>}
|
||||
</div> : preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
|
||||
<div className="agent-attachment__actions">
|
||||
{file.kind === "image" && (!inline || error) ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{inline ? "Retry image" : preview ? "Hide preview" : "Preview"}</button> : null}
|
||||
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
|
||||
</div>
|
||||
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -45,6 +45,32 @@
|
||||
.agent-room__entry {
|
||||
flex-shrink:0;
|
||||
min-width:0;
|
||||
/* Retain chronological DOM/selection/a11y; skip offscreen layout and paint.
|
||||
This is not DOM virtualization. Remember measured heights when skipped. */
|
||||
content-visibility:auto;
|
||||
contain-intrinsic-size:auto 160px;
|
||||
}
|
||||
|
||||
.agent-room__history {
|
||||
display:grid;
|
||||
justify-items:center;
|
||||
gap:8px;
|
||||
flex-shrink:0;
|
||||
color:hsl(255 19% 70%);
|
||||
font-size:12px;
|
||||
}
|
||||
|
||||
.agent-room__history button {
|
||||
border:1px solid hsl(267 75% 73%/.2);
|
||||
border-radius:8px;
|
||||
padding:8px 14px;
|
||||
background:hsl(261 52% 20%/.56);
|
||||
cursor:pointer;
|
||||
}
|
||||
|
||||
.agent-room__history button:disabled {
|
||||
cursor:wait;
|
||||
opacity:.65;
|
||||
}
|
||||
|
||||
.agent-room__day {
|
||||
@@ -238,31 +264,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);
|
||||
@@ -455,3 +456,26 @@
|
||||
transition:none!important;
|
||||
}
|
||||
}
|
||||
|
||||
.agent-room__attach { display: grid; place-items: center; align-self: end; width: 38px; height: 46px; color: #c4b5fd; border-radius: 12px; }
|
||||
.agent-room__attach:hover { background: #a78bfa22; }
|
||||
.agent-room__uploads { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
|
||||
.agent-room__upload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; padding: 9px 12px; border: 1px solid #a78bfa44; border-radius: 12px; background: #211b36; font-size: 12px; }
|
||||
.agent-room__upload strong { display: block; overflow-wrap: anywhere; font-weight: 500; }
|
||||
.agent-room__upload small { color: #c4b5fd; }
|
||||
.agent-room__upload button { padding: 5px; color: #ddd6fe; }
|
||||
.agent-room__upload-path { flex-basis: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: #b9a7d1; }
|
||||
.agent-attachment { margin-top: 10px; padding: 10px; border: 1px solid #a78bfa44; border-radius: 10px; background: #0e0a1933; font-size: 12px; min-width: 0; }
|
||||
.agent-attachment__title { display: flex; align-items: center; gap: 6px; }
|
||||
.agent-attachment__title span { overflow-wrap: anywhere; }
|
||||
.agent-attachment__title small { margin-left: auto; white-space: nowrap; opacity: .7; }
|
||||
.agent-attachment__path { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.agent-attachment__path code { min-width: 0; overflow-wrap: anywhere; font-size: 10px; opacity: .75; }
|
||||
.agent-attachment__path button { flex-shrink: 0; }
|
||||
.agent-attachment__actions { display: flex; gap: 14px; margin-top: 8px; }
|
||||
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
|
||||
.agent-attachment__actions button:disabled { opacity: .5; }
|
||||
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
|
||||
.agent-attachment__image-frame { display: flex; align-items: center; justify-content: center; height: 260px; width: 480px; max-width: 100%; margin-top: 8px; }
|
||||
.agent-attachment__image-frame .agent-attachment__preview { width: 100%; height: 100%; object-fit: contain; margin: 0; }
|
||||
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { ArrowDown, Clock3, LoaderCircle, Menu, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { memo } from "preact/compat";
|
||||
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";
|
||||
import { MarkdownContent } from "@/components/markdown/markdown-content";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { agentAttachments, attachmentSize } from "@/lib/agent-attachments";
|
||||
import { AgentAttachmentCard } from "./agent-attachment";
|
||||
import "./agent-conversation.css";
|
||||
|
||||
type Props = {
|
||||
@@ -47,7 +50,7 @@ function toolName(value: unknown) {
|
||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||
}
|
||||
|
||||
function AgentBubble({ message, animate, onRetry }: { message: Message; animate: boolean; onRetry: (id: string) => void }) {
|
||||
const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
||||
const own = message.role === "user";
|
||||
const info = metadata(message);
|
||||
const status = typeof info.status === "string" ? info.status : "complete";
|
||||
@@ -58,8 +61,10 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
|
||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||
<div className="agent-room__message-content">
|
||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />}
|
||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
||||
</div>
|
||||
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
||||
<div className="agent-room__message-meta">
|
||||
<span>{own ? "You" : "Sybil"}</span>
|
||||
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
|
||||
@@ -73,18 +78,28 @@ function AgentBubble({ message, animate, onRetry }: { message: Message; animate:
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
||||
const [draft, setDraft] = useState(savedDraft);
|
||||
const draftRef = useRef(draft);
|
||||
const [uploadError, setUploadError] = useState<string>();
|
||||
const pickerRef = useRef<HTMLInputElement>(null);
|
||||
const reselectRef = useRef<HTMLInputElement>(null);
|
||||
const reselectId = useRef<string>();
|
||||
const uploads = room.uploadQueue.snapshot();
|
||||
const pendingUploads = uploads.some(file => file.status !== "ready");
|
||||
const addFiles = (files: Iterable<File>) => {
|
||||
try { room.uploadQueue.add(files); setUploadError(undefined); } catch (error) { setUploadError(error instanceof Error ? error.message : String(error)); }
|
||||
};
|
||||
const removeFile = (id: string) => { try { room.uploadQueue.remove(id); setUploadError(undefined); } catch (error) { setUploadError(String(error)); } };
|
||||
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
|
||||
const enteringMessageIdsRef = useRef(new Set<string>());
|
||||
const animatedEntryIdsRef = useRef(new Set<string>());
|
||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||
const typingHeightRef = useRef(0);
|
||||
const historyReadyRef = useRef(!room.isLoading);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const historyBeforeRef = useRef(room.historyBeforeCursor);
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -117,9 +132,20 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
const element = scrollerRef.current;
|
||||
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
||||
const top = element.getBoundingClientRect().top;
|
||||
// Anchor to layout, independently of the bubble's entrance transform.
|
||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8);
|
||||
anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null;
|
||||
// Find the first visible row without laying out every offscreen message.
|
||||
const entries = element.querySelectorAll<HTMLElement>("[data-agent-entry-id]");
|
||||
// Probe row boxes without measuring every skipped subtree in a long history.
|
||||
let low = 0, high = entries.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (entries[mid].getBoundingClientRect().bottom <= top + 8) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
const node = entries[low];
|
||||
// The first row can lose its day separator when older rows arrive. Anchor
|
||||
// the message itself so that changing that separator cannot shift its text.
|
||||
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||
anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.getBoundingClientRect().top - top } : null;
|
||||
};
|
||||
const restoreViewport = () => {
|
||||
const element = scrollerRef.current;
|
||||
@@ -127,17 +153,19 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||
else if (anchorRef.current) {
|
||||
const anchor = anchorRef.current;
|
||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id);
|
||||
if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||
const node = element.querySelector<HTMLElement>(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`);
|
||||
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||
if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||
}
|
||||
restoredScrollTopRef.current = element.scrollTop;
|
||||
rememberAnchor();
|
||||
// Keep the user's key/offset through successive intrinsic-size corrections.
|
||||
// Recapturing here can anchor a newly revealed older row while its neighbors
|
||||
// are still being measured, causing the original reading position to drift.
|
||||
scheduleRead();
|
||||
};
|
||||
const jumpToLatest = () => {
|
||||
pinnedRef.current = true;
|
||||
anchorRef.current = null;
|
||||
setAtBottom(true);
|
||||
if (scrollerRef.current) {
|
||||
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
||||
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
||||
@@ -183,7 +211,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
|
||||
useLayoutEffect(() => {
|
||||
restoreViewport();
|
||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
|
||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, room.isLoadingOlder, room.olderError, room.hasOlder, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
@@ -217,25 +245,30 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
setDraft(value);
|
||||
};
|
||||
|
||||
const retryMessage = (id: string) => {
|
||||
const retryMessage = useCallback((id: string) => {
|
||||
// Admission errors remain attached to their own bubble.
|
||||
void room.retryMessage(id).catch(() => {});
|
||||
};
|
||||
}, [room.retryMessage]);
|
||||
const submit = () => {
|
||||
const content = draftRef.current.trim();
|
||||
if (!content) return;
|
||||
const delivery = room.sendMessage(content);
|
||||
updateDraft("");
|
||||
jumpToLatest();
|
||||
void delivery.catch(() => {});
|
||||
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
|
||||
let attachments;
|
||||
try { attachments = room.uploadQueue.ready(); } catch (error) { setUploadError(String(error)); return; }
|
||||
const delivery = room.sendMessage(content, attachments, () => { updateDraft(""); setUploadError(undefined); jumpToLatest(); });
|
||||
void delivery.catch(error => { if (draftRef.current === content) setUploadError(error instanceof Error ? error.message : String(error)); });
|
||||
textareaRef.current?.focus();
|
||||
};
|
||||
|
||||
const messages = useMemo(() => {
|
||||
const transcript = agentTranscriptMessages(room.messages);
|
||||
const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor;
|
||||
historyBeforeRef.current = room.historyBeforeCursor;
|
||||
let previousDay = "";
|
||||
for (const message of transcript) {
|
||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
||||
let lastSeen = -1;
|
||||
transcript.forEach((message, index) => { if (seenMessageIdsRef.current.has(message.id)) lastSeen = index; });
|
||||
for (const [index, message] of transcript.entries()) {
|
||||
// Historical prepends keep their measured space; only new tail rows enter.
|
||||
if (index > lastSeen && !(historyChanged && lastSeen < 0) && !seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
||||
enteringMessageIdsRef.current.add(message.id);
|
||||
}
|
||||
seenMessageIdsRef.current.add(message.id);
|
||||
@@ -247,11 +280,11 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
||||
return (
|
||||
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
|
||||
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
|
||||
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} />
|
||||
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} download={room.downloadAttachment} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, [room.messages, room.isLoading, room.retryMessage]);
|
||||
}, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]);
|
||||
|
||||
const activity = room.activity;
|
||||
const running = room.isTyping;
|
||||
@@ -291,36 +324,61 @@ 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();
|
||||
}}
|
||||
>
|
||||
<div ref={timelineRef} className="agent-room__timeline">
|
||||
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
||||
{room.hasOlder ? <div className="agent-room__history">
|
||||
<button type="button" disabled={room.isLoadingOlder} onClick={() => {
|
||||
pinnedRef.current = false; rememberAnchor(); room.loadOlder();
|
||||
}}>{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}</button>
|
||||
{room.olderError ? <span role="alert">{room.olderError} Try loading again.</span> : null}
|
||||
</div> : null}
|
||||
{messages}
|
||||
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
||||
{!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">
|
||||
<div className="agent-room__composer-area"
|
||||
onDragOver={event => { if (room.attachmentCapability && event.dataTransfer?.types.includes("Files")) event.preventDefault(); }}
|
||||
onDrop={event => { if (room.attachmentCapability && event.dataTransfer?.files.length) { event.preventDefault(); addFiles(event.dataTransfer.files); } }}>
|
||||
<div className="agent-room__composer-inner">
|
||||
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
|
||||
{uploadError ? <div className="agent-room__error" role="alert">{uploadError}</div> : null}
|
||||
<input ref={reselectRef} type="file" hidden aria-label="Reselect attachment" onChange={event => {
|
||||
const file = event.currentTarget.files?.[0], id = reselectId.current; event.currentTarget.value = "";
|
||||
if (file && id) void room.uploadQueue.reselect(id, file).then(() => setUploadError(undefined)).catch(error => setUploadError(error instanceof Error ? error.message : String(error)));
|
||||
}} />
|
||||
{uploads.length ? <div className="agent-room__uploads" aria-label="Selected attachments">{uploads.map(row => <div className="agent-room__upload" key={row.uploadId}>
|
||||
<div><strong>{row.filename}</strong><small>{attachmentSize(row.sizeBytes)} · <span role="status">{row.status === "ready" ? "Uploaded" : row.status === "uploading" ? "Uploading…" : row.status === "waiting" ? "Waiting…" : row.status === "reselect" ? "Reselect file" : "Upload failed"}</span></small></div>
|
||||
{row.status === "reselect" ? <button type="button" onClick={() => { reselectId.current = row.uploadId; reselectRef.current?.click(); }}>Reselect file</button> : null}
|
||||
{row.status === "failed" ? <button type="button" onClick={() => room.uploadQueue.retry(row.uploadId)}>Retry upload</button> : null}
|
||||
<button type="button" aria-label={`Remove ${row.filename}`} onClick={() => removeFile(row.uploadId)}><X size={15} /></button>
|
||||
{row.error ? <span className="agent-attachment__error" role="alert">{row.error}</span> : null}
|
||||
{row.attachment?.workspacePath ? <code className="agent-room__upload-path" title={row.attachment.workspacePath}>{row.attachment.workspacePath}</code> : null}
|
||||
</div>)}</div> : null}
|
||||
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
|
||||
{room.attachmentCapability ? <>
|
||||
<input ref={pickerRef} type="file" multiple hidden aria-label="Choose attachments" onChange={event => { if (event.currentTarget.files) addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} />
|
||||
<button type="button" className="agent-room__attach" aria-label="Attach files" title="Attach files (8 files, 32 MiB total)" onClick={() => pickerRef.current?.click()}><Paperclip size={19} /></button>
|
||||
</> : null}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
rows={1}
|
||||
maxLength={40000}
|
||||
onPaste={event => { if (room.attachmentCapability && event.clipboardData?.files.length) { event.preventDefault(); addFiles(event.clipboardData.files); } }}
|
||||
onInput={event => updateDraft(event.currentTarget.value)}
|
||||
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
|
||||
placeholder="Message Sybil Agent…"
|
||||
aria-label="Message Sybil Agent"
|
||||
enterkeyhint="send"
|
||||
/>
|
||||
<button type="submit" className="agent-room__send" disabled={!draft.trim()} aria-label="Send message"><SendHorizontal size={19} /></button>
|
||||
<button type="submit" className="agent-room__send" disabled={(!draft.trim() && !uploads.length) || pendingUploads || Boolean(uploads.length && !room.attachmentCapability)} aria-label="Send message"><SendHorizontal size={19} /></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Enter your admin token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,28 +34,27 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSignIn(authTokenInput.trim() || null);
|
||||
const token = authTokenInput.trim();
|
||||
if (token) void onSignIn(token);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
aria-label="Admin token"
|
||||
placeholder="ADMIN_TOKEN"
|
||||
required
|
||||
value={authTokenInput}
|
||||
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
||||
disabled={isSigningIn}
|
||||
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
|
||||
/>
|
||||
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
||||
<Button className="w-full" type="submit" disabled={isSigningIn || !authTokenInput.trim()}>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
<Button className="w-full" type="button" variant="secondary" disabled={isSigningIn} onClick={() => void onSignIn(null)}>
|
||||
Continue without token
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
||||
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
|
||||
{authError ? <p role="alert" className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,8 @@ type Props = {
|
||||
mode?: MarkdownMode;
|
||||
resolveCitationIndex?: (href: string) => number | undefined;
|
||||
openLinksInNewTab?: boolean;
|
||||
/** Agent images are delivered only through authenticated attachment IDs. */
|
||||
allowImages?: boolean;
|
||||
};
|
||||
|
||||
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
|
||||
@@ -35,24 +37,28 @@ function setNewTabLinkAttributes(currentNode: Element) {
|
||||
currentNode.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
|
||||
function renderMarkdown(markdown: string, openLinksInNewTab: boolean, allowImages: boolean) {
|
||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||
const config = { ADD_ATTR: ["class", "target", "rel"], ...(!allowImages ? {
|
||||
FORBID_TAGS: ["img", "picture", "source", "video", "audio", "svg", "math", "style"],
|
||||
FORBID_ATTR: ["style", "srcset", "background", "poster"],
|
||||
} : {}) };
|
||||
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, config);
|
||||
|
||||
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||
try {
|
||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
||||
return DOMPurify.sanitize(rawHtml, config);
|
||||
} finally {
|
||||
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||
}
|
||||
}
|
||||
|
||||
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
|
||||
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false, allowImages = true }: Props) {
|
||||
const html = useMemo(() => {
|
||||
const prepared =
|
||||
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
|
||||
return renderMarkdown(prepared, openLinksInNewTab);
|
||||
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
|
||||
return renderMarkdown(prepared, openLinksInNewTab, allowImages);
|
||||
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex, allowImages]);
|
||||
|
||||
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
+114
-46
@@ -1,19 +1,27 @@
|
||||
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,
|
||||
applyAgentEvent, applyAgentReadState,
|
||||
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||
applyAgentEvent, applyAgentReadState, applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||
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";
|
||||
|
||||
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
|
||||
export type AgentRoomHookResult = {
|
||||
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
||||
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
|
||||
sendMessage(content: string): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
|
||||
hasOlder: boolean; historyBeforeCursor: string | null; isLoadingOlder: boolean; olderError: string | null; loadOlder(): void; unreadCountIsPartial: boolean;
|
||||
attachmentCapability?: AgentAttachmentCapability;
|
||||
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
||||
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
|
||||
uploadQueue: ReturnType<typeof createAgentUploadQueue>;
|
||||
sendMessage(content: string, attachments?: AgentAttachment[], onPersisted?: () => void): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
|
||||
};
|
||||
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -23,11 +31,29 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
const room = view.room;
|
||||
const current = useRef(room);
|
||||
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
|
||||
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setLoading] = useState(enabled);
|
||||
const [isLoadingOlder, setLoadingOlder] = useState(false);
|
||||
const [olderError, setOlderError] = useState<string | null>(null);
|
||||
const older = useRef<(() => Promise<void>) | null>(null);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const client = useRef<AgentRoomClient | null>(null);
|
||||
const mark = useRef<((cursor: string) => void) | null>(null);
|
||||
const [, renderUploads] = useState(0);
|
||||
const capabilityRef = useRef(attachmentCapability); capabilityRef.current = enabled ? attachmentCapability : undefined;
|
||||
const uploadQueue = useRef<ReturnType<typeof createAgentUploadQueue> | null>(null);
|
||||
if (!uploadQueue.current) uploadQueue.current = createAgentUploadQueue({
|
||||
capability: () => capabilityRef.current,
|
||||
upload: (file, id, signal) => { if (!client.current) throw new Error("Agent connection is unavailable."); return client.current.upload(file, id, signal); },
|
||||
changed: () => renderUploads(n => n + 1),
|
||||
storage: {
|
||||
read: () => { try { return JSON.parse(sessionStorage.getItem(AGENT_ATTACHMENT_DRAFT_KEY) || "null"); } catch { return null; } },
|
||||
write: rows => { try { sessionStorage.setItem(AGENT_ATTACHMENT_DRAFT_KEY, JSON.stringify(rows)); } catch { throw new Error("Could not save attachment retry details. Allow session storage before uploading."); } },
|
||||
},
|
||||
});
|
||||
useEffect(() => () => uploadQueue.current?.dispose(), []);
|
||||
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 }));
|
||||
@@ -36,9 +62,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
if (!outbox.current) {
|
||||
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
|
||||
outbox.current = createAgentOptimisticOutbox({
|
||||
submit: (content, requestId) => {
|
||||
submit: (content, requestId, attachments) => {
|
||||
if (!client.current) throw new Error("Agent connection is unavailable.");
|
||||
return client.current.submit(content, requestId);
|
||||
return client.current.submit(content, requestId, attachments);
|
||||
},
|
||||
changed: () => setOutboxRevision(value => value + 1),
|
||||
storage: {
|
||||
@@ -59,12 +85,15 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoadingOlder(false); setOlderError(null);
|
||||
if (!enabled) {
|
||||
client.current = null; mark.current = null;
|
||||
sourceMismatch.current = false;
|
||||
client.current = null; mark.current = null; older.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;
|
||||
@@ -72,6 +101,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
let reconnectAttempt = 0;
|
||||
let started = false;
|
||||
let maintaining = false;
|
||||
let loadingOlder = false;
|
||||
let lastPacket = Date.now();
|
||||
let socketAuthenticated = false;
|
||||
let authFailed = false;
|
||||
@@ -80,43 +110,61 @@ 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 });
|
||||
},
|
||||
});
|
||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
||||
setLoading(!current.current.historyLoaded); 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();
|
||||
older.current = null; setLoadingOlder(false);
|
||||
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) 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 }); }
|
||||
};
|
||||
older.current = async () => {
|
||||
const before = current.current.beforeCursor;
|
||||
if (!active || sourceChanged || !started || loadingOlder || !current.current.hasOlder || before === null) return;
|
||||
loadingOlder = true; setLoadingOlder(true); setOlderError(null);
|
||||
try {
|
||||
const page = await api.history(before, controller.signal);
|
||||
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||
presenter.update(state => applyAgentHistoryPage(state, page, before));
|
||||
presenter.flush();
|
||||
} catch (failure) {
|
||||
if (active && !sourceChanged && !controller.signal.aborted) setOlderError(errorText(failure));
|
||||
} finally {
|
||||
loadingOlder = false;
|
||||
if (active && !sourceChanged) setLoadingOlder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const connect = () => {
|
||||
if (!active || authFailed) return;
|
||||
if (!active || authFailed || sourceChanged) return;
|
||||
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
|
||||
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
|
||||
socketAuthenticated = false;
|
||||
@@ -124,12 +172,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));
|
||||
@@ -139,6 +187,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);
|
||||
@@ -147,9 +198,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;
|
||||
@@ -163,24 +214,26 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
||||
const start = async () => {
|
||||
try {
|
||||
await Promise.all([refreshRead(), refreshActivity()]);
|
||||
// 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 (!current.current.historyLoaded) {
|
||||
const page = await api.history(undefined, controller.signal);
|
||||
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||
presenter.update(state => applyAgentHistoryPage(state, page));
|
||||
} else await catchUp(!started);
|
||||
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();
|
||||
@@ -197,33 +250,48 @@ 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);
|
||||
socket?.close();
|
||||
if (client.current === api) client.current = null;
|
||||
mark.current = null;
|
||||
mark.current = null; older.current = null;
|
||||
};
|
||||
}, [enabled, revision, update]);
|
||||
|
||||
const renderedCursor = room.cursor;
|
||||
const renderedSourceId = room.sourceId;
|
||||
const hasRenderedHistory = room.messages.some(message => message.role === "user" || message.role === "assistant");
|
||||
const markRead = useCallback(() => {
|
||||
if (!enabled || isLoading) return;
|
||||
if (!enabled || isLoading || !hasRenderedHistory) 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]);
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
mark.current?.(renderedCursor, renderedSourceId);
|
||||
}, [enabled, isLoading, hasRenderedHistory, 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);
|
||||
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
||||
}, [enabled]);
|
||||
const uploadAttachment = useCallback((file: File, uploadId: string, signal: AbortSignal) => {
|
||||
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
|
||||
return client.current.upload(file, uploadId, signal);
|
||||
}, [enabled]);
|
||||
const downloadAttachment = useCallback((file: AgentAttachment, signal?: AbortSignal) => {
|
||||
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
|
||||
return client.current.download(file, signal);
|
||||
}, [enabled]);
|
||||
const retryMessage = useCallback(async (messageId: string) => {
|
||||
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 loadOlder = useCallback(() => { void older.current?.(); }, []);
|
||||
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,
|
||||
isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||
hasOlder: room.hasOlder, historyBeforeCursor: room.beforeCursor, isLoadingOlder, olderError, loadOlder, unreadCountIsPartial: agentUnreadCountIsPartial(room),
|
||||
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";
|
||||
|
||||
@@ -18,8 +19,11 @@ function persistToken(token: string | null) {
|
||||
}
|
||||
|
||||
function normalizeAuthError(message: string) {
|
||||
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
|
||||
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
|
||||
if (message.includes("invalid bearer token")) {
|
||||
return "Incorrect token. Please try again.";
|
||||
}
|
||||
if (message.includes("missing bearer token")) {
|
||||
return "Enter your admin token.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -43,10 +47,11 @@ export function useSessionAuth() {
|
||||
persistToken(tokenCandidate);
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string) => {
|
||||
const handleAuthFailure = (message: string | null) => {
|
||||
void revokeAgentBrowserPush();
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(normalizeAuthError(message));
|
||||
setAuthError(message ? normalizeAuthError(message) : null);
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
};
|
||||
@@ -66,7 +71,8 @@ export function useSessionAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
await revokeAgentBrowserPush();
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -74,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 () => {
|
||||
@@ -81,7 +99,8 @@ export function useSessionAuth() {
|
||||
await completeSessionCheck(token);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
handleAuthFailure(message);
|
||||
// A first visit without a token is an invitation to sign in, not a failed attempt.
|
||||
handleAuthFailure(!token && message.includes("missing bearer token") ? null : message);
|
||||
} finally {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
|
||||
export type AgentAttachment = {
|
||||
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
|
||||
sizeBytes: number; sha256: string; workspacePath?: string;
|
||||
};
|
||||
export type AgentAttachmentCapability = {
|
||||
version: 1; maxCount: number; maxTotalBytes: number; maxImageBytes: number; maxFileBytes: number;
|
||||
imageMimeTypes: string[]; quotaBytes: number; usedBytes: number; remainingBytes: number;
|
||||
maxConcurrentUploads: number; maxConcurrentDecodes: number;
|
||||
};
|
||||
export function parseAgentAttachment(value: unknown): AgentAttachment {
|
||||
const item = value as AgentAttachment | null;
|
||||
if (!item || !/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item.id)
|
||||
|| !["image", "text", "file"].includes(item.kind) || typeof item.filename !== "string" || !item.filename
|
||||
|| typeof item.mimeType !== "string" || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > 8 * 1024 * 1024
|
||||
|| typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)
|
||||
|| (item.workspacePath !== undefined && (typeof item.workspacePath !== "string" || !item.workspacePath.startsWith("/"))))
|
||||
throw new Error("Agent returned an invalid attachment descriptor.");
|
||||
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
|
||||
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
|
||||
}
|
||||
export function agentAttachments(metadata: unknown, role = "user"): AgentAttachment[] {
|
||||
if (role !== "user" && role !== "assistant") return [];
|
||||
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
|
||||
if (!Array.isArray(values) || values.length > 8) return [];
|
||||
try {
|
||||
const files = values.map(value => {
|
||||
// Source paths are not part of the assistant output contract, even if an
|
||||
// older/malformed server supplies one. Only the ID authorizes retrieval.
|
||||
const file = parseAgentAttachment(role === "assistant" ? { ...value, workspacePath: undefined } : value);
|
||||
if (role === "assistant" && (file.kind !== "image" || !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.mimeType)
|
||||
|| file.sizeBytes > 6 * 1024 * 1024 || file.filename.length > 255 || /[\\/\u0000-\u001f\u007f]/.test(file.filename)))
|
||||
throw new Error("Invalid assistant image.");
|
||||
return file;
|
||||
});
|
||||
if (new Set(files.map(file => file.id)).size !== files.length || files.reduce((total, file) => total + file.sizeBytes, 0) > 32 * 1024 * 1024) return [];
|
||||
return files;
|
||||
} catch { return []; }
|
||||
}
|
||||
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
|
||||
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
|
||||
}
|
||||
export function attachmentSize(bytes: number): string { return bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.ceil(bytes / 1024)} KiB` : `${(bytes / 1024 ** 2).toFixed(1)} MiB`; }
|
||||
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1";
|
||||
export type AgentUpload = {
|
||||
uploadId: string; filename: string; sizeBytes: number; mimeType: string; sha256?: string; file?: File;
|
||||
status: "waiting" | "uploading" | "ready" | "failed" | "reselect"; attachment?: AgentAttachment; error?: string;
|
||||
};
|
||||
export type AgentUploadStorage = { read(): unknown; write(rows: Omit<AgentUpload, "file">[]): void };
|
||||
|
||||
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
|
||||
export function createAgentUploadQueue(options: {
|
||||
capability(): AgentAttachmentCapability | undefined;
|
||||
upload(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
||||
changed(): void; storage?: AgentUploadStorage; newId?: () => string;
|
||||
}) {
|
||||
const rows = new Map<string, AgentUpload>();
|
||||
let running: { id: string; controller: AbortController } | undefined, disposed = false;
|
||||
const persist = () => options.storage?.write([...rows.values()].map(({ file: _file, ...row }) => ({ ...row })));
|
||||
const publish = () => { if (!disposed) options.changed(); };
|
||||
const save = () => { if (disposed) return; try { persist(); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish(); };
|
||||
const restored = options.storage?.read();
|
||||
if (Array.isArray(restored)) for (const value of restored.slice(0, 8)) {
|
||||
if (!value || typeof value.uploadId !== "string" || !/^[a-f0-9-]{36}$/i.test(value.uploadId)
|
||||
|| typeof value.filename !== "string" || typeof value.mimeType !== "string" || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || value.sizeBytes > 8 * 1024 * 1024) continue;
|
||||
try {
|
||||
const attachment = value.attachment ? parseAgentAttachment(value.attachment) : undefined;
|
||||
rows.set(value.uploadId, { uploadId: value.uploadId, filename: value.filename, sizeBytes: value.sizeBytes, mimeType: value.mimeType,
|
||||
...(typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) ? { sha256: value.sha256 } : {}),
|
||||
...(attachment ? { attachment, status: "ready" } : { status: "reselect", error: "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
|
||||
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
|
||||
}
|
||||
const digest = async (file: File) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))].map(n => n.toString(16).padStart(2, "0")).join("");
|
||||
const pump = () => {
|
||||
if (disposed || running) return;
|
||||
const row = [...rows.values()].find(row => row.status === "waiting" && row.file);
|
||||
if (!row) return;
|
||||
const controller = new AbortController(); running = { id: row.uploadId, controller }; row.status = "uploading"; publish();
|
||||
void (async () => {
|
||||
row.sha256 ||= await digest(row.file!);
|
||||
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
||||
// Persist the upload identity AND original byte hash before any network request.
|
||||
persist();
|
||||
const value = await options.upload(row.file!, row.uploadId, controller.signal);
|
||||
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
||||
const attachment = parseAgentAttachment(value);
|
||||
if (attachment.sha256 !== row.sha256 || attachment.sizeBytes !== row.sizeBytes || attachment.filename !== row.filename.normalize("NFC"))
|
||||
throw new Error("The uploaded file receipt does not match this file. Retry to confirm.");
|
||||
row.attachment = attachment; row.status = "ready"; delete row.error;
|
||||
})().catch(error => {
|
||||
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
||||
row.status = "failed"; row.error = error instanceof Error ? error.message : String(error);
|
||||
}).finally(() => { running = undefined; save(); pump(); });
|
||||
};
|
||||
return {
|
||||
add(files: Iterable<File>) {
|
||||
const cap = options.capability(); if (!cap || cap.version !== 1) throw new Error("Attachments are not available on this Agent.");
|
||||
const selected = Array.from(files);
|
||||
if (rows.size + selected.length > Math.min(8, cap.maxCount)) throw new Error("Attach at most 8 files per message.");
|
||||
if ([...rows.values()].reduce((n, r) => n + r.sizeBytes, 0) + selected.reduce((n, f) => n + f.size, 0) > Math.min(32 * 1024 * 1024, cap.maxTotalBytes))
|
||||
throw new Error("Attachments exceed 32 MiB per message.");
|
||||
for (const file of selected) {
|
||||
const image = cap.imageMimeTypes.includes(file.type);
|
||||
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
|
||||
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
|
||||
}
|
||||
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
|
||||
for (const row of added) rows.set(row.uploadId, row);
|
||||
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
|
||||
publish(); pump();
|
||||
},
|
||||
async reselect(id: string, file: File) {
|
||||
const row = rows.get(id); if (!row || row.status !== "reselect") return;
|
||||
if (file.name !== row.filename || file.size !== row.sizeBytes || file.type !== row.mimeType) throw new Error("Choose the same filename, type and size to resume this upload.");
|
||||
const hash = await digest(file);
|
||||
if (row.sha256 && row.sha256 !== hash) throw new Error("The selected file has different contents. Choose the original file or remove this attachment.");
|
||||
if (rows.get(id) !== row) return;
|
||||
row.sha256 = hash; row.file = file; row.status = "waiting"; delete row.error; persist(); publish(); pump();
|
||||
},
|
||||
remove(id: string) { const row = rows.get(id); rows.delete(id); try { persist(); } catch (error) { if (row) rows.set(id, row); throw error; } if (running?.id === id) running.controller.abort(); publish(); },
|
||||
retry(id: string) { const row = rows.get(id); if (row?.status === "failed") { row.status = row.file ? "waiting" : "reselect"; delete row.error; save(); pump(); } },
|
||||
snapshot(): AgentUpload[] { return [...rows.values()].map(row => ({ ...row })); },
|
||||
ready(): AgentAttachment[] {
|
||||
if ([...rows.values()].some(row => row.status !== "ready")) throw new Error("Wait for uploads, reselect or retry failed files, or remove them before sending.");
|
||||
return [...rows.values()].map(row => row.attachment!);
|
||||
},
|
||||
clear() { rows.clear(); running?.controller.abort(); save(); },
|
||||
dispose() { disposed = true; running?.controller.abort(); try { persist(); } catch { /* Existing stored retry IDs remain usable. */ } },
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { Message } from "./api";
|
||||
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
|
||||
|
||||
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
|
||||
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
|
||||
export type AgentOutboxRow = {
|
||||
id: string; requestId: string; content: string; createdAt: string;
|
||||
status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
|
||||
attachments?: AgentAttachment[]; status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
|
||||
};
|
||||
export type AgentOutboxStorage = {
|
||||
read(): unknown; write(rows: AgentOutboxRow[]): void;
|
||||
@@ -20,7 +21,7 @@ const validRequestId = (value: unknown): value is string => typeof value === "st
|
||||
|
||||
/** Local rows use the server's identity from their first render through final echo. */
|
||||
export function createAgentOptimisticOutbox(options: {
|
||||
submit(content: string, requestId: string): Promise<Receipt>;
|
||||
submit(content: string, requestId: string, attachments?: AgentAttachment[]): Promise<Receipt>;
|
||||
storage: AgentOutboxStorage;
|
||||
changed(): void;
|
||||
newId?: () => string;
|
||||
@@ -40,12 +41,14 @@ export function createAgentOptimisticOutbox(options: {
|
||||
const restored = options.storage.read();
|
||||
if (Array.isArray(restored)) {
|
||||
for (const value of restored.slice(0, 100)) {
|
||||
if (!value || typeof value.content !== "string" || !value.content.trim() || !validRequestId(value.requestId)) continue;
|
||||
if (!value || typeof value.content !== "string" || (!value.content.trim() && !value.attachments?.length) || !validRequestId(value.requestId)) continue;
|
||||
let attachments: AgentAttachment[] = [];
|
||||
try { if (value.attachments !== undefined) { if (!Array.isArray(value.attachments) || value.attachments.length > 8) continue; attachments = value.attachments.map(parseAgentAttachment); } } catch { continue; }
|
||||
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
|
||||
latestTime = Math.max(latestTime, Date.parse(time));
|
||||
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
|
||||
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
|
||||
status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
|
||||
...(attachments.length ? { attachments } : {}), status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
|
||||
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
|
||||
rows.set(row.id, row);
|
||||
}
|
||||
@@ -78,7 +81,8 @@ export function createAgentOptimisticOutbox(options: {
|
||||
};
|
||||
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
|
||||
const publish = () => options.changed();
|
||||
const dispatch = (row: AgentOutboxRow): Promise<void> => {
|
||||
const matches = (row: AgentOutboxRow, message: Message) => !row.attachments?.length || JSON.stringify(agentAttachments(message.metadata).map(file => file.id)) === JSON.stringify(row.attachments.map(file => file.id));
|
||||
const dispatch = (row: AgentOutboxRow, onPersisted?: () => void): Promise<void> => {
|
||||
const running = inFlight.get(row.id);
|
||||
if (running) return running;
|
||||
if (row.confirmed || row.seenOnServer) return Promise.resolve();
|
||||
@@ -92,9 +96,10 @@ export function createAgentOptimisticOutbox(options: {
|
||||
row.status = "failed"; row.error = errorText(error); publish();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
onPersisted?.();
|
||||
// Both durable local storage and the visible row exist before POST begins.
|
||||
publish();
|
||||
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId)).then(receipt => {
|
||||
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId, row.attachments)).then(receipt => {
|
||||
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
|
||||
if (!row.confirmed) {
|
||||
row.seenOnServer = true;
|
||||
@@ -115,20 +120,21 @@ export function createAgentOptimisticOutbox(options: {
|
||||
// their newly assigned identity/time must survive the next reload too.
|
||||
saveBestEffort();
|
||||
return {
|
||||
send(content: string): Promise<void> {
|
||||
if (!content.trim()) return Promise.reject(new Error("Enter a message."));
|
||||
send(content: string, attachments: AgentAttachment[] = [], onPersisted?: () => void): Promise<void> {
|
||||
if (!content.trim() && !attachments.length) return Promise.reject(new Error("Enter a message."));
|
||||
const requestId = newId();
|
||||
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
|
||||
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, ...(attachments.length ? { attachments: attachments.map(parseAgentAttachment) } : {}), createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
|
||||
rows.set(row.id, row);
|
||||
return dispatch(row);
|
||||
return dispatch(row, onPersisted);
|
||||
},
|
||||
retry(messageId: string): Promise<void> {
|
||||
const row = rows.get(messageId);
|
||||
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
|
||||
},
|
||||
reconcile(room: RoomMessages): void {
|
||||
const committed = new Map(room.messages.map(message => [message.id, message]));
|
||||
const queued = new Set((room.activity?.queuedMessages || []).map(message => message.id));
|
||||
const compatible = (message: Message) => { const row = rows.get(message.id); return !row || matches(row, message); };
|
||||
const committed = new Map(room.messages.filter(compatible).map(message => [message.id, message]));
|
||||
const queued = new Set((room.activity?.queuedMessages || []).filter(compatible).map(message => message.id));
|
||||
let changed = false;
|
||||
for (const row of rows.values()) {
|
||||
if (committed.has(row.id)) {
|
||||
@@ -143,7 +149,7 @@ export function createAgentOptimisticOutbox(options: {
|
||||
if (changed) { saveBestEffort(); publish(); }
|
||||
},
|
||||
display(serverMessages: readonly Message[]): Message[] {
|
||||
const displayed = new Map(serverMessages.map(message => {
|
||||
const displayed = new Map(serverMessages.filter(message => { const row = rows.get(message.id); return !row || matches(row, message); }).map(message => {
|
||||
const row = rows.get(message.id);
|
||||
if (!row) return [message.id, message] as const;
|
||||
const serverInfo = info(message);
|
||||
@@ -155,8 +161,8 @@ export function createAgentOptimisticOutbox(options: {
|
||||
const echo = confirmedEchoes.get(row.id);
|
||||
if (row.confirmed && !echo) continue;
|
||||
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
|
||||
id: row.id, createdAt: row.createdAt, role: "user", content: row.content, name: "buzzert",
|
||||
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.error ? { error: row.error } : {}) },
|
||||
id: row.id, createdAt: row.createdAt, role: "user", content: row.attachments?.length ? attachmentCaption(row.content, row.attachments) : row.content, name: "buzzert",
|
||||
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
|
||||
});
|
||||
}
|
||||
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export type AgentRoomPresenterOptions = {
|
||||
typingDelayMs?: number;
|
||||
clock?: Scheduler;
|
||||
};
|
||||
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
|
||||
const quietTools = new Set(['user_message', 'output_image', 'notify_parent', 'clock_wait', 'event_decision']);
|
||||
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
|
||||
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
|
||||
const entryId = (message: Message): bigint | undefined => {
|
||||
|
||||
+174
-20
@@ -1,4 +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";
|
||||
@@ -16,17 +18,21 @@ export type AgentMessagesPage = {
|
||||
};
|
||||
export type AgentDescriptor = {
|
||||
id: string; title: string; model: string; activity: AgentActivity;
|
||||
websocket: string; protocol: string;
|
||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability; assistantImages?: { version: 1 } };
|
||||
};
|
||||
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;
|
||||
historyLoaded: boolean; beforeCursor: string | null; hasOlder: boolean;
|
||||
/** 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 };
|
||||
|
||||
@@ -79,18 +85,51 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
||||
}
|
||||
export const createAgentRoomState = (): AgentRoomState => ({
|
||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||
historyLoaded: false, beforeCursor: null, hasOlder: false,
|
||||
});
|
||||
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.
|
||||
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||
}
|
||||
/** Recent/older windows have their own boundary; live replay never moves it. */
|
||||
export function applyAgentHistoryPage(state: AgentRoomState, page: AgentMessagesPage, before?: string): AgentRoomState {
|
||||
// A retired or repeated older request cannot overwrite a newer window.
|
||||
if (before !== undefined && before !== state.beforeCursor) return state;
|
||||
const boundary = page.beforeCursor === null ? null : normalizeAgentCursor(page.beforeCursor);
|
||||
const cursor = page.cursor === null ? null : normalizeAgentCursor(page.cursor);
|
||||
if (page.hasMore && (boundary === null || boundary === "0"
|
||||
|| (before !== undefined && BigInt(boundary) >= BigInt(before)))) throw new Error("Agent older history did not advance its cursor.");
|
||||
if (before !== undefined && ((cursor !== null && BigInt(cursor) >= BigInt(before))
|
||||
|| (boundary !== null && BigInt(boundary) > BigInt(before)))) throw new Error("Agent older history returned an invalid boundary.");
|
||||
const next = applyAgentPage(state, page);
|
||||
return { ...next, historyLoaded: true, beforeCursor: boundary, hasOlder: page.hasMore,
|
||||
// Older snapshots must not replace newer socket edits or advance delivery/read state.
|
||||
...(before === undefined ? {} : { cursor: state.cursor, messages: mergeAgentMessages(page.messages, state.messages) }),
|
||||
};
|
||||
}
|
||||
export function agentUnreadCountIsPartial(state: AgentRoomState): boolean {
|
||||
return state.hasOlder && state.beforeCursor !== null && BigInt(state.readCursor) + 1n < BigInt(state.beforeCursor);
|
||||
}
|
||||
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 };
|
||||
@@ -109,7 +148,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);
|
||||
@@ -122,16 +164,19 @@ 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 };
|
||||
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
|
||||
export type AgentRoomClientOptions = { baseUrl?: string; token?: () => string | null; fetch?: typeof globalThis.fetch };
|
||||
export class AgentRoomClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetcher: typeof globalThis.fetch;
|
||||
private readonly token: () => string | null;
|
||||
private activeDownloads = 0;
|
||||
private readonly downloadWaiters = new Set<() => void>();
|
||||
constructor(options: AgentRoomClientOptions = {}) {
|
||||
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
|
||||
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
|
||||
@@ -141,14 +186,14 @@ export class AgentRoomClient {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = this.token();
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
if (init.body !== undefined) headers.set("Content-Type", "application/json");
|
||||
if (init.body !== undefined && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||||
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;
|
||||
}
|
||||
@@ -156,19 +201,71 @@ export class AgentRoomClient {
|
||||
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||
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 });
|
||||
history(before?: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||
return this.request(`/messages?${new URLSearchParams({ ...(before === undefined ? {} : { before: normalizeAgentCursor(before) }), limit: "100" })}`, { signal });
|
||||
}
|
||||
async submit(content: string, requestId: string): Promise<AgentSubmission> {
|
||||
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId }) }));
|
||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { 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 })) } : {}) }) }));
|
||||
// An HTTP success alone does not acknowledge delivery. Keep the original
|
||||
// outbox receipt key retryable if a proxy or upstream returns malformed data.
|
||||
if (!receipt || typeof receipt.id !== "number" || !Number.isSafeInteger(receipt.id) || receipt.id <= 0
|
||||
|| typeof receipt.status !== "string" || !["queued", "placed", "done", "unanswered"].includes(receipt.status)
|
||||
|| receipt.messageId !== `inbound:sybil:${requestId}`) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
|
||||
|| receipt.messageId !== `inbound:sybil:${requestId}`
|
||||
|| (attachments.length && JSON.stringify(receipt.attachmentIds) !== JSON.stringify(attachments.map(file => file.id)))) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
|
||||
return receipt as AgentSubmission;
|
||||
}
|
||||
async upload(file: File, uploadId: string, signal?: AbortSignal): Promise<AgentAttachment> {
|
||||
const body = new FormData(); body.append("uploadId", uploadId); body.append("file", file, file.name);
|
||||
const value = await this.request<{ attachment: unknown }>("/attachments", { method: "POST", body, signal });
|
||||
return parseAgentAttachment(value.attachment);
|
||||
}
|
||||
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
|
||||
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
|
||||
signal?.throwIfAborted();
|
||||
while (this.activeDownloads >= 2) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => { this.downloadWaiters.delete(wake); signal?.removeEventListener("abort", abort); };
|
||||
const wake = () => { cleanup(); resolve(); };
|
||||
const abort = () => { cleanup(); reject(signal?.reason || new Error("Download canceled.")); };
|
||||
this.downloadWaiters.add(wake); signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) abort();
|
||||
});
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
this.activeDownloads++;
|
||||
try {
|
||||
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error("Attachment download failed. Retry the download."); }
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error("Attachment download was incomplete.");
|
||||
const bytes = new Uint8Array(file.sizeBytes); let size = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
signal?.throwIfAborted();
|
||||
if (size + value.length > bytes.length) throw new Error("Attachment download exceeds its size limit.");
|
||||
bytes.set(value, size); size += value.length;
|
||||
}
|
||||
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||
if (size !== file.sizeBytes) throw new Error("Attachment download was incomplete.");
|
||||
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(n => n.toString(16).padStart(2, "0")).join("");
|
||||
if (sha256 !== file.sha256) throw new Error("Attachment checksum did not match.");
|
||||
if (file.kind === "image" && response.headers.get("content-type")?.split(";")[0].trim() !== file.mimeType)
|
||||
throw new Error("Attachment image type did not match.");
|
||||
signal?.throwIfAborted();
|
||||
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||
return new Blob([bytes], { type: response.headers.get("content-type") || "application/octet-stream" });
|
||||
} finally {
|
||||
this.activeDownloads--;
|
||||
this.downloadWaiters.values().next().value?.();
|
||||
}
|
||||
}
|
||||
websocketUrl(pageUrl: string): string {
|
||||
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
@@ -199,18 +296,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,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
// Run against a local Vite server with an existing Playwright/Chromium installation:
|
||||
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
|
||||
// WEB_TEST_URL=http://127.0.0.1:5192 node tests/agent-attachments.browser.mjs
|
||||
// Every API and Agent socket is mocked; this does not contact the deployed runtime.
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
|
||||
const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5192/');
|
||||
assert.ok(target.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname), 'Use a loopback development server');
|
||||
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:['clipboard-write']});
|
||||
await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token'));
|
||||
const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[], trackingRequests=[];
|
||||
page.on('pageerror',e=>errors.push(e.stack || e.message));
|
||||
const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1};
|
||||
const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]};
|
||||
let failedUpload=true, failedSend=true, live, cursor=0, inputAttachments=true;const history=[];
|
||||
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64');
|
||||
const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`});
|
||||
const assistantImage={...attachment('picture.png'),id:'att_12345678-1234-4234-8234-123456789abe',filename:'assistant.png'};delete assistantImage.workspacePath;
|
||||
await page.route('https://never-fetch.invalid/**',async route=>{trackingRequests.push(route.request().url());await route.abort();});
|
||||
await page.route('**/api/**',async route=>{
|
||||
const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value;
|
||||
if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token');
|
||||
if(p.endsWith('/attachments')&&m==='POST'){
|
||||
const body=req.postDataBuffer().toString();uploads.push(body);
|
||||
if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;}
|
||||
await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return;
|
||||
}
|
||||
if(p.endsWith('/content')){downloads.push(req.url());const isImage=p.includes('789abd')||p.includes('789abe');await route.fulfill({contentType:isImage?'image/png':'text/plain',body:isImage?image:'data'});return;}
|
||||
if(p.endsWith('/agent/messages')&&m==='POST'){
|
||||
const body=req.postDataJSON();sends.push(body);
|
||||
if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;}
|
||||
const files=(body.attachments||[]).map(f=>attachment(f.id.endsWith('abd')?'picture.png':'notes.txt'));
|
||||
const row={id:`inbound:sybil:${body.requestId}`,role:'user',createdAt:new Date().toISOString(),content:body.content||`Attached: ${files.map(f=>f.filename).join(', ')}.`,metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:files}};history.push(row);
|
||||
live?.send(JSON.stringify({type:'messages',conversationId:'1',messages:[row],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
|
||||
await route.fulfill({status:202,json:{id:cursor,status:'queued',messageId:row.id,attachmentIds:files.map(f=>f.id)}});return;
|
||||
}
|
||||
if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false};
|
||||
else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null};
|
||||
else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{...(inputAttachments?{attachments:cap}:{}),assistantImages:{version:1}},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
|
||||
else if(p.includes('session'))value={mode:'token',authenticated:true};
|
||||
else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]};
|
||||
else if(p.endsWith('/models'))value={models:[],providers:{}};
|
||||
else if(p.includes('stream'))value=[];
|
||||
else value=[];
|
||||
await route.fulfill({json:value});
|
||||
});
|
||||
await page.routeWebSocket('**/api/v1/agent/ws',socket=>{live=socket;socket.onMessage(raw=>{if(JSON.parse(String(raw)).type==='auth')socket.send(JSON.stringify({type:'ready',conversationId:'1'}));});});
|
||||
try{
|
||||
await page.goto(target.href,{waitUntil:'networkidle'});
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
await page.getByRole('button',{name:'Attach files',exact:true}).waitFor();
|
||||
const picker=page.getByLabel('Choose attachments');
|
||||
await picker.setInputFiles({name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')});
|
||||
await page.getByText('Storage quota reached',{exact:true}).waitFor();
|
||||
assert.equal(await page.getByRole('button',{name:'Send message',exact:true}).isDisabled(),true);
|
||||
await page.getByRole('button',{name:'Retry upload',exact:true}).click();
|
||||
await page.getByText('Uploaded',{exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:/^New chat/}).first().click();await page.locator('.agent-room__composer-area').waitFor({state:'detached'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
|
||||
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
|
||||
const uuid=body=>body.match(/name="uploadId"\r\n\r\n([^\r]+)/)?.[1];assert.equal(uuid(uploads[0]),uuid(uploads[1]));
|
||||
await page.getByRole('button',{name:'Remove notes.txt'}).click();assert.equal(await page.locator('.agent-room__upload').count(),0);
|
||||
await picker.setInputFiles([{name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')},{name:'picture.png',mimeType:'image/png',buffer:image}]);
|
||||
await page.waitForFunction(()=>document.querySelectorAll('.agent-room__upload [role="status"]').length===2&&Array.from(document.querySelectorAll('.agent-room__upload [role="status"]')).every(e=>e.textContent==='Uploaded'));
|
||||
await page.getByRole('button',{name:'Send message',exact:true}).click();await page.getByRole('button',{name:'Retry message',exact:true}).waitFor();
|
||||
assert.deepEqual(sends[0].attachments,[{id:attachment().id},{id:attachment('picture.png').id}]);assert.equal(sends[0].content,'');
|
||||
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByRole('button',{name:'Retry message',exact:true}).click();
|
||||
await page.getByText('Attached: notes.txt, picture.png.',{exact:true}).waitFor();assert.equal(sends[1].requestId,sends[0].requestId);
|
||||
await page.getByRole('button',{name:'Preview',exact:true}).click();await page.locator('.agent-attachment__preview').waitFor();assert.ok((await page.locator('.agent-attachment__preview').getAttribute('src')).startsWith('blob:'));
|
||||
await page.getByRole('button',{name:'Hide preview',exact:true}).click();assert.equal(await page.locator('.agent-attachment__preview').count(),0);
|
||||
const download=page.waitForEvent('download');await page.getByRole('button',{name:'Download',exact:true}).first().click();assert.equal((await download).suggestedFilename(),'notes.txt');assert.ok(downloads.every(url=>!url.includes('token')));
|
||||
await page.getByRole('button',{name:'Copy path for notes.txt'}).click();
|
||||
// Real paste/drop DOM events feed the same upload queue.
|
||||
await page.getByRole('textbox',{name:'Message Sybil Agent'}).evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));});
|
||||
await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click();
|
||||
await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));});
|
||||
await page.getByText('Uploaded',{exact:true}).waitFor();
|
||||
await page.getByRole('button',{name:'Remove notes.txt'}).click();
|
||||
// Assistant image-only messages use authenticated bytes automatically, with no source path.
|
||||
const assistant={id:'outbound:900',role:'assistant',createdAt:new Date().toISOString(),content:'',metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:[assistantImage]}};history.push(assistant);
|
||||
live.send(JSON.stringify({type:'messages',conversationId:'1',messages:[assistant],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
|
||||
const assistantBubble=page.locator('[data-agent-message-id="outbound:900"]'),inlineImage=assistantBubble.locator('.agent-attachment__preview');
|
||||
await inlineImage.waitFor();await inlineImage.evaluate(img=>img.decode());
|
||||
assert.ok((await inlineImage.getAttribute('src')).startsWith('blob:'));
|
||||
assert.equal(await assistantBubble.getByRole('button',{name:'Preview',exact:true}).count(),0);
|
||||
assert.equal(await assistantBubble.locator('.agent-attachment__path').count(),0);
|
||||
const tracking={id:'outbound:901',role:'assistant',createdAt:new Date().toISOString(),content:'Remote images must not load.\n\n <img src="https://never-fetch.invalid/html.png">',metadata:{kind:'message',status:'complete',entryId:++cursor}};history.push(tracking);
|
||||
live.send(JSON.stringify({type:'messages',conversationId:'1',messages:[tracking],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
|
||||
await page.getByText('Remote images must not load.',{exact:true}).waitFor();
|
||||
assert.equal(await page.locator('[data-agent-message-id="outbound:901"] .md-content img').count(),0);
|
||||
// An output-only capability must preserve history without enabling input controls.
|
||||
inputAttachments=false;await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
await inlineImage.waitFor();await inlineImage.evaluate(img=>img.decode());
|
||||
assert.equal(await assistantBubble.count(),1);assert.equal(await page.locator('.agent-attachment__preview').count(),1);
|
||||
assert.ok((await inlineImage.getAttribute('src')).startsWith('blob:'));
|
||||
assert.equal(await page.getByRole('button',{name:'Attach files',exact:true}).count(),0);
|
||||
assert.equal(await page.getByLabel('Choose attachments').count(),0);
|
||||
assert.deepEqual(trackingRequests,[],'Neither live nor historical Markdown/HTML image markup fetches remote bytes');
|
||||
await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT});
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
|
||||
assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, assistant image-only live/history, output-only capability, blocked Markdown/HTML images, mobile layout');
|
||||
}finally{await context.close();await browser.close();}
|
||||
@@ -0,0 +1,138 @@
|
||||
import test from 'node:test';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '../src/lib/agent-attachments.ts';
|
||||
import { AgentRoomClient } from '../src/lib/agent-room.ts';
|
||||
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
|
||||
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
|
||||
import { createAgentRoomState, applyAgentEvent, mergeAgentMessages } from '../src/lib/agent-room.ts';
|
||||
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
|
||||
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
|
||||
const tick=()=>new Promise(r=>setTimeout(r,0));
|
||||
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
|
||||
|
||||
test('assistant images retain stable live/history identity, omit paths, and never enable input capability',async()=>{
|
||||
const image={...file,kind:'image',mimeType:'image/png',filename:'image.png',url:'https://never-fetch.invalid/image'};
|
||||
const safe={...image};delete safe.workspacePath;delete safe.url;
|
||||
const message={id:'outbound:42',role:'assistant',content:'',createdAt:new Date().toISOString(),metadata:{kind:'message',status:'complete',entryId:42,agentAttachments:[image]}};
|
||||
const page={type:'messages',conversationId:'1',messages:[message],cursor:'42',beforeCursor:'42',hasMore:false};
|
||||
const live=applyAgentEvent(createAgentRoomState(),page);
|
||||
assert.deepEqual(agentAttachments(live.messages[0].metadata,'assistant'),[safe]);
|
||||
const history=mergeAgentMessages(live.messages,[structuredClone(message)]);
|
||||
assert.equal(history.length,1);assert.equal(history[0].id,'outbound:42');
|
||||
assert.deepEqual(agentAttachments(history[0].metadata,'assistant'),[safe]);
|
||||
assert.equal(agentTranscriptMessages(history).length,1,'image-only assistant bubble remains visible');
|
||||
for(const invalid of [{...image,kind:'file'},{...image,mimeType:'image/svg+xml'},{...image,sizeBytes:6*1024*1024+1},{...image,filename:'/private/source.png'}])
|
||||
assert.deepEqual(agentAttachments({agentAttachments:[invalid]},'assistant'),[]);
|
||||
assert.deepEqual(agentAttachments(message.metadata,'tool'),[]);
|
||||
assert.deepEqual(agentAttachments({agentAttachments:[image,image]},'assistant'),[]);
|
||||
const descriptor={capabilities:{assistantImages:{version:1}}};
|
||||
const queue=createAgentUploadQueue({capability:()=>descriptor.capabilities.attachments,changed:()=>{},upload:async()=>{throw new Error('Must not upload');}});
|
||||
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/not available/);queue.dispose();
|
||||
});
|
||||
|
||||
test('downloads bound concurrent bytes, validate hashes/types, cancel queued work, and reject changed credentials',async()=>{
|
||||
const complete=[];let calls=0,token='one';
|
||||
const client=new AgentRoomClient({token:()=>token,fetch:async()=>{calls++;return new Promise(resolve=>complete.push(resolve));}});
|
||||
const a=client.download(file), b=client.download(file), cancel=new AbortController();
|
||||
const canceled=client.download(file,cancel.signal);const canceledAssertion=assert.rejects(canceled);cancel.abort();
|
||||
await canceledAssertion;assert.equal(calls,2);
|
||||
complete.shift()(new Response('data'));await a;
|
||||
const c=client.download(file);assert.equal(calls,3);
|
||||
complete.shift()(new Response('evil'));await assert.rejects(b,/checksum/);
|
||||
token='two';complete.shift()(new Response('data'));await assert.rejects(c,/Authentication changed/);
|
||||
const huge=new AgentRoomClient({fetch:async()=>new Response('data-exceeds-receipt')});
|
||||
await assert.rejects(huge.download(file),/size limit/);
|
||||
const wrongType=new AgentRoomClient({fetch:async()=>new Response('data',{headers:{'content-type':'text/html'}})});
|
||||
await assert.rejects(wrongType.download({...file,kind:'image',mimeType:'image/png'}),/type did not match/);
|
||||
});
|
||||
|
||||
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
|
||||
let capability, attempts=[], next=0, changed=0, finish;
|
||||
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
|
||||
const input=new File(['data'],'notes.txt',{type:'text/plain'});
|
||||
assert.throws(()=>queue.add([input]),/not available/);capability=cap;
|
||||
assert.throws(()=>queue.add(Array(9).fill(input)),/8 files/);
|
||||
assert.throws(()=>queue.add([new File([new Uint8Array(6*1024*1024+1)],'big.png',{type:'image/png'})]),/6 MiB/);
|
||||
assert.throws(()=>queue.add(Array(5).fill(new File([new Uint8Array(7*1024*1024)],'large.bin'))),/32 MiB/);
|
||||
queue.add([input]);assert.throws(()=>queue.ready(),/Wait/);await waitFor(()=>queue.snapshot()[0].status==='failed');
|
||||
assert.equal(queue.snapshot()[0].status,'failed');assert.match(queue.snapshot()[0].error,/Quota/);
|
||||
queue.retry('upload-1');await waitFor(()=>attempts.length===2);assert.deepEqual(attempts.map(a=>a.id),['upload-1','upload-1']);
|
||||
queue.remove('upload-1');assert.equal(attempts.at(-1).signal.aborted,true);finish(file);await tick();assert.deepEqual(queue.ready(),[]);
|
||||
assert.ok(changed>0);queue.dispose();
|
||||
});
|
||||
|
||||
test('composer serializes transfers, keeps ready descriptors, and clears without orphaning pending state',async()=>{
|
||||
const done=[];let count=0;
|
||||
const queue=createAgentUploadQueue({capability:()=>cap,changed:()=>{},newId:()=>`id-${++count}`,upload:async()=>new Promise(r=>done.push(r))});
|
||||
queue.add([new File(['data'],'notes.txt'),new File(['data'],'notes.txt')]);await waitFor(()=>done.length===1);assert.deepEqual(queue.snapshot().map(r=>r.status),['uploading','waiting']);
|
||||
done[0](file);await waitFor(()=>done.length===2);assert.deepEqual(queue.snapshot().map(r=>r.status),['ready','uploading']);done[1]({...file,id:file.id.replace('12345678','12345679')});await waitFor(()=>queue.snapshot().every(row=>row.status==='ready'));assert.equal(queue.ready().length,2);queue.clear();assert.deepEqual(queue.ready(),[]);queue.dispose();
|
||||
});
|
||||
|
||||
test('transport uses auth headers/FormData, ID-only send and checked receipt, authenticated download with no token URLs',async()=>{
|
||||
const calls=[];let acknowledge=true;
|
||||
const client=new AgentRoomClient({baseUrl:'/api',token:()=> 'browser-token',fetch:async(url,init)=>{
|
||||
calls.push({url,init});assert.ok(!url.includes('token'));assert.equal(init.headers.get('Authorization'),'Bearer browser-token');
|
||||
if(url.endsWith('/attachments')){assert.ok(init.body instanceof FormData);assert.equal(init.headers.get('Content-Type'),null);assert.equal(init.body.get('uploadId'),'uuid');return Response.json({attachment:file},{status:201});}
|
||||
if(url.endsWith('/content'))return new Response('data',{headers:{'content-type':'text/plain'}});
|
||||
const body=JSON.parse(init.body);assert.deepEqual(body,{content:'',requestId:'send-id',attachments:[{id:file.id}]});
|
||||
return Response.json({id:1,status:'queued',messageId:'inbound:sybil:send-id',...(acknowledge?{attachmentIds:[file.id]}:{})});
|
||||
}});
|
||||
assert.deepEqual(await client.upload(new File(['data'],'notes.txt'),'uuid'),file);
|
||||
assert.equal((await client.submit('','send-id',[file])).status,'queued');acknowledge=false;await assert.rejects(client.submit('','send-id',[file]),/invalid message receipt/);
|
||||
assert.equal(await (await client.download(file)).text(),'data');assert.equal(calls.at(-1).init.cache,'no-store');assert.equal(calls.at(-1).init.redirect,'error');
|
||||
});
|
||||
|
||||
test('file-only outbox survives reload and retry without sending paths, confirms matching attachment echoes and retains tool filtering',async()=>{
|
||||
let saved, call, fail=true;
|
||||
const options={storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},changed:()=>{},newId:()=> 'request',submit:async(content,requestId,attachments)=>{call={content,requestId,attachments};if(fail)throw new Error('lost response');return {status:'queued',messageId:'inbound:sybil:request'};}};
|
||||
let outbox=createAgentOptimisticOutbox(options);await assert.rejects(outbox.send('',[file]));assert.deepEqual(saved[0].attachments,[file]);
|
||||
outbox=createAgentOptimisticOutbox(options);assert.equal(outbox.snapshot().length,1);fail=false;await outbox.retry('inbound:sybil:request');assert.equal(call.content,'');assert.deepEqual(call.attachments,[file]);
|
||||
const row=outbox.display([])[0];assert.equal(row.content,'Attached: notes.txt.');assert.deepEqual(agentAttachments(row.metadata),[file]);
|
||||
outbox.reconcile({messages:[{...row,metadata:{kind:'message',status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,false);
|
||||
const mismatch=outbox.display([{...row,metadata:{kind:'message',status:'complete'}}])[0];assert.deepEqual(agentAttachments(mismatch.metadata),[file]);assert.equal(mismatch.metadata.status,'queued');
|
||||
outbox.reconcile({messages:[{...row,metadata:{...row.metadata,status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,true);
|
||||
const raw=[row,{...row,id:'tool',role:'tool',metadata:{kind:'tool_call'}}];assert.equal(agentTranscriptMessages(raw).length,1);assert.equal(raw.length,2);
|
||||
assert.throws(()=>parseAgentAttachment({...file,workspacePath:'https://bad'}));
|
||||
});
|
||||
|
||||
test('attachment drafts preserve completed receipts and unresolved IDs/hash across disposal/reload, verified reselection resumes without a new ID',async()=>{
|
||||
let saved=[], attempts=[], complete;
|
||||
const storage={read:()=>saved,write:rows=>saved=structuredClone(rows)};
|
||||
const options={capability:()=>cap,storage,changed:()=>{},upload:async(_file,id,signal)=>{attempts.push({id,signal});return new Promise(r=>complete=r);}};
|
||||
let queue=createAgentUploadQueue(options);queue.add([new File(['data'],'notes.txt',{type:'text/plain'})]);
|
||||
await waitFor(()=>Boolean(complete));const original=attempts[0].id;assert.equal(saved[0].sha256,file.sha256);
|
||||
queue.dispose();assert.equal(attempts[0].signal.aborted,true);
|
||||
queue=createAgentUploadQueue(options);assert.equal(queue.snapshot()[0].status,'reselect');assert.equal(queue.snapshot()[0].uploadId,original);assert.throws(()=>queue.ready(),/Wait/);
|
||||
await assert.rejects(queue.reselect(original,new File(['evil'],'notes.txt',{type:'text/plain'})),/different contents/);assert.equal(attempts.length,1);
|
||||
await queue.reselect(original,new File(['data'],'notes.txt',{type:'text/plain'}));await waitFor(()=>attempts.length===2);assert.equal(attempts[1].id,original);
|
||||
complete(file);await tick();assert.equal(queue.ready()[0].id,file.id);queue.dispose();
|
||||
const restored=createAgentUploadQueue(options);assert.deepEqual(restored.ready(),[file]);assert.equal(attempts.length,2,'completed upload is never repeated');restored.clear();assert.deepEqual(saved,[]);
|
||||
});
|
||||
|
||||
test('mismatched complete echoes cannot hide local attachment errors or the retry action',async()=>{
|
||||
const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{}},changed:()=>{},newId:()=> 'mismatch',submit:async()=>{throw new Error('quota failure');}});
|
||||
await assert.rejects(box.send('caption',[file]));const initial=box.display([])[0];
|
||||
for(const attachments of [undefined,[{...file,id:file.id.replace('12345678','12345679')}]]){
|
||||
const echo={...initial,metadata:{kind:'message',status:'complete',...(attachments?{agentAttachments:attachments}:{})}};
|
||||
box.reconcile({messages:[echo]});const shown=box.display([echo])[0];assert.equal(shown.metadata.status,'failed');assert.equal(shown.metadata.error,'quota failure');assert.deepEqual(shown.metadata.agentAttachments,[file]);assert.equal(box.snapshot()[0].confirmed,false);
|
||||
}
|
||||
});
|
||||
|
||||
test('storage refusal prevents uploads and outbox handoff; no attachment draft is cleared before persistence',async()=>{
|
||||
let uploads=0;const queue=createAgentUploadQueue({capability:()=>cap,storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},upload:async()=>{uploads++;return file;}});
|
||||
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/storage full/);await tick();assert.equal(uploads,0);
|
||||
let cleared=false;const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},submit:async()=>({status:'queued'})});
|
||||
await assert.rejects(box.send('draft',[file],()=>{cleared=true;}));assert.equal(cleared,false);
|
||||
});
|
||||
|
||||
|
||||
test('a late canceled queue completion cannot overwrite a restored draft',async()=>{
|
||||
let saved=[];const pending=[];
|
||||
const options={capability:()=>cap,changed:()=>{},storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},upload:async()=>new Promise(resolve=>pending.push(resolve))};
|
||||
const old=createAgentUploadQueue(options);old.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===1);
|
||||
const oldId=old.snapshot()[0].uploadId;old.dispose();
|
||||
const restored=createAgentUploadQueue(options);restored.remove(oldId);restored.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===2);
|
||||
pending[1](file);await tick();const current=structuredClone(saved);assert.equal(current[0].status,'ready');assert.notEqual(current[0].uploadId,oldId);
|
||||
pending[0](file);await tick();assert.deepEqual(saved,current);assert.deepEqual(restored.ready(),[file]);restored.dispose();
|
||||
});
|
||||
@@ -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,9 +1,10 @@
|
||||
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,
|
||||
applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||
} from "../src/lib/agent-room.ts";
|
||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||
|
||||
@@ -16,6 +17,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" } });
|
||||
@@ -29,7 +33,7 @@ test("tool starts and results merge across replay without downgrading completed
|
||||
assert.equal(messages[0].createdAt, start.createdAt);
|
||||
});
|
||||
|
||||
test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => {
|
||||
test("forward recovery reads every missing page including invisible entries and counts only unread assistant messages", async () => {
|
||||
const requests = [];
|
||||
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
|
||||
let state = createAgentRoomState();
|
||||
@@ -44,6 +48,78 @@ test("initial synchronization reads every page including invisible entries and c
|
||||
assert.equal(agentUnreadCount(state), 1);
|
||||
});
|
||||
|
||||
test("recent history is one cursorless window and older history uses the returned source boundary", async () => {
|
||||
const requests = [];
|
||||
const client = new AgentRoomClient({ fetch: async (url, options) => {
|
||||
requests.push({ url, signal: options.signal });
|
||||
return new Response(JSON.stringify({ ...page("1200", [message(1200)], true), beforeCursor: "1101" }));
|
||||
} });
|
||||
const controller = new AbortController();
|
||||
let state = applyAgentHistoryPage(createAgentRoomState(), await client.history(undefined, controller.signal));
|
||||
assert.equal(requests.length, 1, "hasMore must not drain old history");
|
||||
assert.equal(requests[0].url, "/api/v1/agent/messages?limit=100");
|
||||
assert.equal(requests[0].signal, controller.signal);
|
||||
assert.equal(state.historyLoaded, true);
|
||||
assert.equal(state.cursor, "1200");
|
||||
assert.equal(state.beforeCursor, "1101", "boundary is not derived from visible message 1200");
|
||||
assert.equal(state.hasOlder, true);
|
||||
await client.history(state.beforeCursor, controller.signal);
|
||||
assert.equal(requests[1].url, "/api/v1/agent/messages?before=1101&limit=100");
|
||||
});
|
||||
|
||||
test("delayed older pages preserve newer edits, live cursor, source and read state", () => {
|
||||
let state = applyAgentHistoryPage(sourceRoom(), { ...page("100", [message(100)], true), beforeCursor: "90" });
|
||||
state = applyAgentPage(state, page("110", [message(100, "assistant", { content: "New streamed text" }), message(110)]));
|
||||
const next = applyAgentHistoryPage(state, { ...page("89", [message(80), message(100, "assistant", { content: "Old snapshot" })], true), beforeCursor: "70" }, "90");
|
||||
assert.equal(next.messages.find(m => m.id === "entry:100").content, "New streamed text");
|
||||
assert.equal(next.cursor, "110");
|
||||
assert.equal(next.readCursor, "90");
|
||||
assert.equal(next.sourceId, sourceA);
|
||||
assert.equal(next.beforeCursor, "70");
|
||||
assert.equal(next.hasOlder, true);
|
||||
assert.equal(applyAgentHistoryPage(next, page("89", [message(80)], false), "90"), next, "retired boundary cannot replace the next window");
|
||||
});
|
||||
|
||||
test("empty internal pages advance older boundaries without chasing another page or moving read state", () => {
|
||||
const initial = applyAgentHistoryPage(createAgentRoomState(), { ...page("1200", [], true), beforeCursor: "1101" });
|
||||
assert.equal(initial.historyLoaded, true);
|
||||
assert.equal(initial.messages.length, 0);
|
||||
const next = applyAgentHistoryPage(initial, { ...page("1100", [], true), beforeCursor: "1001" }, "1101");
|
||||
assert.equal(next.beforeCursor, "1001");
|
||||
assert.equal(next.cursor, "1200");
|
||||
assert.equal(next.readCursor, "0");
|
||||
assert.equal(next.hasOlder, true);
|
||||
const end = applyAgentHistoryPage(next, { ...page("0"), beforeCursor: "1001" }, "1001");
|
||||
assert.equal(end.hasOlder, false);
|
||||
assert.equal(end.cursor, "1200");
|
||||
});
|
||||
|
||||
test("malformed or stalled older pages cannot change history or delivery boundaries", () => {
|
||||
const state = applyAgentHistoryPage(createAgentRoomState(), { ...page("100", [message(100)], true), beforeCursor: "90" });
|
||||
for (const bad of [
|
||||
{ ...page("89", [], true), beforeCursor: "90" },
|
||||
{ ...page("89", [], true), beforeCursor: null },
|
||||
{ ...page("91", [], false), beforeCursor: "80" },
|
||||
{ ...page("89", [], false), beforeCursor: "91" },
|
||||
{ ...page("bad", [], false), beforeCursor: "80" },
|
||||
]) assert.throws(() => applyAgentHistoryPage(state, bad, "90"), /cursor|boundary/);
|
||||
assert.throws(() => applyAgentHistoryPage(state, { ...page("89"), conversationId: "different" }, "90"), /conversation changed/);
|
||||
assert.equal(state.beforeCursor, "90");
|
||||
assert.equal(state.cursor, "100");
|
||||
});
|
||||
|
||||
test("unread counts disclose unknown older replies until reading the latest rendered window", () => {
|
||||
let state = applyAgentHistoryPage(createAgentRoomState(), { ...page("1200", [message(1199), message(1200, "user")], true), beforeCursor: "1101" });
|
||||
assert.equal(agentUnreadCount(state), 1);
|
||||
assert.equal(agentUnreadCountIsPartial(state), true);
|
||||
state = applyAgentReadState(state, { cursor: "1100", updatedAt: null });
|
||||
assert.equal(agentUnreadCountIsPartial(state), false);
|
||||
assert.equal(agentUnreadCount(state), 1);
|
||||
state = applyAgentReadState(state, { cursor: "1200", updatedAt: null });
|
||||
assert.equal(agentUnreadCount(state), 0);
|
||||
assert.equal(state.hasOlder, true, "reading latest does not fetch or discard older pages");
|
||||
});
|
||||
|
||||
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
|
||||
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
|
||||
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
||||
@@ -113,6 +189,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 +327,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 () => {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
// Local production preview; all APIs/sockets are synthetic and external requests are blocked.
|
||||
// PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs WEB_TEST_URL=http://127.0.0.1:5196
|
||||
// TRANSCRIPT_EVIDENCE=/tmp/evidence TRANSCRIPT_BASELINE=1 node tests/agent-transcript.browser.mjs
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5196/');
|
||||
assert.ok(target.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname));
|
||||
const baseline = process.env.TRANSCRIPT_BASELINE === '1';
|
||||
const evidence = process.env.TRANSCRIPT_EVIDENCE;
|
||||
if (evidence) await mkdir(evidence, { recursive: true });
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE || undefined });
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' });
|
||||
const page = await context.newPage();
|
||||
const errors = [], requests = [], reads = [], socketCursors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
const idle = { state: 'idle', isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||
const sourceId = '00000000-0000-4000-8000-000000000001';
|
||||
const row = (id, content) => ({ id: `entry:${id}`, role: id % 3 ? 'assistant' : 'user',
|
||||
createdAt: new Date(Date.UTC(2026, 9, 16, 0, 0, id)).toISOString(),
|
||||
content: content || `Fixture message ${id}\n\n${'Synthetic transcript text for delayed loading. '.repeat(id % 11 === 0 ? 30 : 3)}`,
|
||||
metadata: { kind: 'message', entryId: id, status: 'complete' } });
|
||||
const history = Array.from({ length: 1200 }, (_, i) => row(i + 1));
|
||||
let live, readCursor = '0', failOlder = false, imageBytes, emptyRecent = false;
|
||||
const start = performance.now();
|
||||
const elapsed = () => Math.round(performance.now() - start);
|
||||
await context.addInitScript(() => {
|
||||
localStorage.clear(); sessionStorage.clear();
|
||||
window.fixtureTiming = {};
|
||||
const sample = () => {
|
||||
const scroller = document.querySelector('.agent-room__scroll');
|
||||
if (scroller) {
|
||||
const box = scroller.getBoundingClientRect();
|
||||
const visible = [...scroller.querySelectorAll('[data-agent-message-id]')].filter(e => {
|
||||
const r = e.getBoundingClientRect(); return r.bottom > box.top && r.top < box.bottom;
|
||||
});
|
||||
if (visible.length && !window.fixtureTiming.first) window.fixtureTiming.first = { ms: performance.now(), ids: visible.map(e => e.dataset.agentMessageId) };
|
||||
if (visible.some(e => e.dataset.agentMessageId === 'entry:1200') && !window.fixtureTiming.latestMs) window.fixtureTiming.latestMs = performance.now();
|
||||
}
|
||||
if (!window.fixtureTiming.latestMs) requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await page.route('**/*', async route => {
|
||||
const req = route.request(), url = new URL(req.url());
|
||||
if (url.origin !== target.origin) return route.abort();
|
||||
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||
const path = url.pathname, method = req.method();
|
||||
assert.equal(req.headers().authorization, undefined, 'Fixture must never use a real credential');
|
||||
let value;
|
||||
if (path.endsWith('/content')) {
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
return route.fulfill({ contentType: 'image/png', body: imageBytes });
|
||||
} else if (path.endsWith('/agent/messages')) {
|
||||
assert.equal(method, 'GET', 'Fixture must not send messages');
|
||||
const after = url.searchParams.get('after'), before = url.searchParams.get('before'), limit = Number(url.searchParams.get('limit') || 200);
|
||||
const record = { query: url.search, requestedMs: elapsed() }; requests.push(record);
|
||||
await new Promise(resolve => setTimeout(resolve, before ? 1600 : 800));
|
||||
if (before && failOlder) { failOlder = false; return route.fulfill({ status: 503, json: { message: 'Fixture older page unavailable' } }); }
|
||||
const eligible = history.filter(m => after !== null ? m.metadata.entryId > Number(after) : before !== null ? m.metadata.entryId < Number(before) : true);
|
||||
const messages = after !== null ? eligible.slice(0, limit) : eligible.slice(-limit);
|
||||
value = { conversationId: 'fixture-room', messages, cursor: String(messages.at(-1)?.metadata.entryId ?? after ?? 0),
|
||||
beforeCursor: messages.length ? String(messages[0].metadata.entryId) : null, hasMore: eligible.length > messages.length };
|
||||
if (emptyRecent && before === null && after === null) value.messages = [];
|
||||
Object.assign(record, { responseMs: elapsed(), rows: messages.length, bytes: Buffer.byteLength(JSON.stringify(value)) });
|
||||
} else if (path.endsWith('/read-state')) {
|
||||
if (method === 'PUT') { readCursor = req.postDataJSON().cursor; reads.push({ cursor: readCursor, ms: elapsed() }); }
|
||||
value = { cursor: readCursor, sourceId, updatedAt: null };
|
||||
} else if (path.endsWith('/agent')) value = { id: 'fixture-room', title: 'Sybil Agent', activity: idle, protocol: 'pi-room.v1', websocket: '/v1/agent/ws' };
|
||||
else if (path.endsWith('/auth/session')) value = { authenticated: true, mode: 'open' };
|
||||
else if (path.endsWith('/models')) value = { models: [], providers: {} };
|
||||
else if (path.endsWith('/tools') || path.endsWith('/chat-tools')) value = { tools: [] };
|
||||
else value = [];
|
||||
await route.fulfill({ json: value });
|
||||
});
|
||||
await page.routeWebSocket('**/api/v1/agent/ws', socket => {
|
||||
live = socket;
|
||||
socket.onMessage(raw => { const frame = JSON.parse(String(raw)); if (frame.type === 'auth') {
|
||||
assert.equal(frame.token, null); socketCursors.push(frame.after);
|
||||
socket.send(JSON.stringify({ type: 'ready', conversationId: 'fixture-room' }));
|
||||
} });
|
||||
});
|
||||
const send = messages => {
|
||||
for (const message of messages) { const index = history.findIndex(m => m.id === message.id); if (index < 0) history.push(message); else history[index] = message; }
|
||||
live.send(JSON.stringify({ type: 'messages', conversationId: 'fixture-room', messages,
|
||||
cursor: String(messages.at(-1).metadata.entryId), beforeCursor: String(messages[0].metadata.entryId), hasMore: false }));
|
||||
};
|
||||
const position = () => page.locator('.agent-room__scroll').evaluate(el => ({ top: el.scrollTop, distance: el.scrollHeight - el.scrollTop - el.clientHeight,
|
||||
rows: el.querySelectorAll('[data-agent-entry-id]').length }));
|
||||
const anchor = () => page.locator('.agent-room__scroll').evaluate(el => {
|
||||
const top = el.getBoundingClientRect().top;
|
||||
const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.getBoundingClientRect().bottom > top + 8);
|
||||
return { id: entry.dataset.agentEntryId, offset: entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - top };
|
||||
});
|
||||
const anchorDrift = saved => page.locator('.agent-room__scroll').evaluate((el, saved) => {
|
||||
const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.dataset.agentEntryId === saved.id);
|
||||
return entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - el.getBoundingClientRect().top - saved.offset;
|
||||
}, saved);
|
||||
const metrics = { mode: baseline ? 'before' : 'after', fixtureRows: 1200, pageDelayMs: 800, olderDelayMs: 1600 };
|
||||
try {
|
||||
await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
await page.waitForFunction(() => window.fixtureTiming?.first);
|
||||
if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-first.png` });
|
||||
await page.waitForFunction(() => window.fixtureTiming?.latestMs, { timeout: 20000 });
|
||||
await page.getByRole('status').filter({ hasText: /^Connected$/ }).waitFor();
|
||||
await page.waitForTimeout(400);
|
||||
Object.assign(metrics, await page.evaluate(() => window.fixtureTiming), { initial: await position(), initialRequests: requests.map(r => ({ ...r })), socketCursors: [...socketCursors] });
|
||||
if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-latest.png` });
|
||||
if (!baseline) {
|
||||
assert.equal(requests.length, 1, 'No eager older/forward chain on initial load');
|
||||
assert.equal(new URLSearchParams(requests[0].query).has('after'), false);
|
||||
assert.equal(socketCursors[0], '1200');
|
||||
assert.ok(metrics.initial.rows <= 100);
|
||||
assert.ok(metrics.initial.distance < 2);
|
||||
await page.waitForTimeout(1800);
|
||||
assert.equal(requests.length, 1, 'Idle initial view must not drain history');
|
||||
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; });
|
||||
await page.waitForTimeout(150);
|
||||
const saved = await anchor(), readBefore = readCursor;
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||
send([row(1201)]);
|
||||
await page.waitForTimeout(500);
|
||||
assert.equal(readCursor, readBefore, 'Incoming while reading must stay unread');
|
||||
metrics.incomingDriftPx = await anchorDrift(saved);
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor();
|
||||
await page.waitForTimeout(400);
|
||||
metrics.prependDriftPx = await anchorDrift(saved);
|
||||
metrics.afterPrepend = await position();
|
||||
assert.ok(Math.abs(metrics.incomingDriftPx) < 2);
|
||||
assert.ok(Math.abs(metrics.prependDriftPx) < 2);
|
||||
assert.equal(readCursor, readBefore);
|
||||
assert.equal(requests.length, 2, 'One user action loads one older page');
|
||||
assert.equal(await page.locator('[data-agent-entry-id="entry:1001"] [data-animation="enter"]').count(), 0);
|
||||
const beforeGrowth = await anchor();
|
||||
send([row(1201, 'Dynamic streamed content.\n\n'.repeat(80))]);
|
||||
await page.waitForTimeout(350);
|
||||
metrics.streamingReadingDriftPx = await anchorDrift(beforeGrowth);
|
||||
assert.ok(Math.abs(metrics.streamingReadingDriftPx) < 2);
|
||||
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; });
|
||||
await page.waitForTimeout(250);
|
||||
send([row(1201, 'Dynamic streamed content continues.\n\n'.repeat(100))]);
|
||||
await page.waitForTimeout(400);
|
||||
metrics.streamingPinnedDistancePx = (await position()).distance;
|
||||
assert.ok(metrics.streamingPinnedDistancePx < 2);
|
||||
const order = await page.locator('.agent-room__timeline').evaluate(el => {
|
||||
const ids = [...el.querySelectorAll('[data-agent-entry-id]')].map(e => Number(e.dataset.agentEntryId.split(':')[1]));
|
||||
const range = document.createRange(); range.selectNodeContents(el);
|
||||
return { ids, text: range.toString(), direction: getComputedStyle(el).flexDirection,
|
||||
skipped: [...el.querySelectorAll('[data-agent-message-id]')].filter(e => !e.checkVisibility({ contentVisibilityAuto: true })).length };
|
||||
});
|
||||
assert.deepEqual(order.ids, [...order.ids].sort((a, b) => a - b));
|
||||
assert.ok(order.text.indexOf('Fixture message 1001') < order.text.indexOf('Fixture message 1200'));
|
||||
assert.equal(order.direction, 'column');
|
||||
assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0);
|
||||
metrics.skippedOffscreenRows = order.skipped;
|
||||
assert.ok(order.skipped > 0, 'Offscreen rows should skip layout/paint');
|
||||
const ax = await page.locator('.agent-room__scroll').ariaSnapshot();
|
||||
assert.ok(ax.includes('Fixture message 1001') && ax.includes('Fixture message 1200'));
|
||||
assert.ok(ax.indexOf('Fixture message 1001') < ax.indexOf('Fixture message 1200'));
|
||||
|
||||
// Authenticated attachment bytes are synthetic, delayed, and never fetched off origin.
|
||||
imageBytes = Buffer.from(await page.evaluate(() => {
|
||||
const canvas = document.createElement('canvas'); canvas.width = 240; canvas.height = 180;
|
||||
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#8974da'; ctx.fillRect(0, 0, 240, 180);
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
}), 'base64');
|
||||
const file = { id: 'att_12345678-1234-4234-8234-123456789abc', filename: 'fixture.png', mimeType: 'image/png', kind: 'image',
|
||||
sizeBytes: imageBytes.length, sha256: createHash('sha256').update(imageBytes).digest('hex') };
|
||||
send([{ ...row(1202), metadata: { ...row(1202).metadata, agentAttachments: [file] } }]);
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('button', { name: 'Preview', exact: true }).click();
|
||||
await page.locator('.agent-attachment__preview').waitFor();
|
||||
await page.waitForTimeout(250);
|
||||
metrics.attachmentPinnedDistancePx = (await position()).distance;
|
||||
assert.ok(metrics.attachmentPinnedDistancePx < 2);
|
||||
send([row(1203, 'Following the delayed attachment.\n\n'.repeat(60))]);
|
||||
await page.waitForTimeout(500);
|
||||
await page.locator('.agent-room__scroll').evaluate(el => {
|
||||
const entry = el.querySelector('[data-agent-entry-id="entry:1203"]');
|
||||
el.scrollTop += entry.getBoundingClientRect().top - el.getBoundingClientRect().top + 80;
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
const attachmentAnchor = await anchor();
|
||||
// Collapse an earlier preview while reading the following message.
|
||||
await page.getByRole('button', { name: 'Hide preview', exact: true }).evaluate(el => el.click());
|
||||
await page.waitForTimeout(300);
|
||||
metrics.attachmentReadingDriftPx = await anchorDrift(attachmentAnchor);
|
||||
assert.ok(Math.abs(metrics.attachmentReadingDriftPx) < 2);
|
||||
|
||||
// A failed older page is locally retryable without clearing the recent window.
|
||||
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; });
|
||||
await page.waitForTimeout(200);
|
||||
const rowsBeforeFailure = (await position()).rows;
|
||||
failOlder = true;
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||
await page.getByRole('alert').filter({ hasText: 'Fixture older page unavailable' }).waitFor();
|
||||
assert.equal((await position()).rows, rowsBeforeFailure);
|
||||
const socketsBefore = socketCursors.length;
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||
await page.waitForTimeout(150);
|
||||
live.close({ code: 1012, reason: 'Fixture reconnect' });
|
||||
await page.getByRole('button', { name: 'Reconnect to Sybil Agent', exact: true }).click();
|
||||
await page.waitForTimeout(2200);
|
||||
assert.ok(socketCursors.length > socketsBefore);
|
||||
assert.equal(socketCursors.at(-1), '1203');
|
||||
assert.equal((await position()).rows, rowsBeforeFailure, 'Retired older response must not publish into the new session');
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; });
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelector('[data-agent-entry-id="entry:901"]'));
|
||||
metrics.retiredOlderIgnored = true;
|
||||
metrics.olderFailureRetry = true;
|
||||
assert.equal(requests.filter(r => r.query.includes('after=0')).length, 0);
|
||||
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; });
|
||||
await page.waitForTimeout(200);
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.waitForTimeout(400);
|
||||
metrics.mobilePinnedDistancePx = (await position()).distance;
|
||||
assert.ok(metrics.mobilePinnedDistancePx < 2);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0);
|
||||
if (evidence) await page.screenshot({ path: `${evidence}/after-mobile.png` });
|
||||
// A source window can contain only internal entries. It stays bounded and
|
||||
// must not mark unloaded public history read or animate an older prepend.
|
||||
emptyRecent = true; readCursor = '0';
|
||||
const requestsBeforeEmpty = requests.length;
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor();
|
||||
await page.waitForTimeout(1000);
|
||||
assert.equal((await position()).rows, 0);
|
||||
assert.equal(requests.length, requestsBeforeEmpty + 1);
|
||||
assert.equal(readCursor, '0');
|
||||
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-agent-entry-id]').length > 0);
|
||||
assert.equal(await page.locator('[data-animation="enter"]').count(), 0);
|
||||
metrics.internalOnlyWindowBounded = true;
|
||||
metrics.internalOnlyWindowUnacknowledged = true;
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
metrics.requests = requests; metrics.reads = reads; metrics.errors = errors;
|
||||
if (evidence) await writeFile(`${evidence}/${metrics.mode}.json`, JSON.stringify(metrics, null, 2));
|
||||
console.log(JSON.stringify(metrics, null, 2));
|
||||
} catch (error) {
|
||||
console.error(JSON.stringify({ metrics, requests, errors, body: (await page.locator('body').innerText()).slice(0, 1800) }));
|
||||
throw error;
|
||||
} finally { await context.close(); await browser.close(); }
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Run after npm run build, with an existing Playwright/Chromium installation:
|
||||
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
|
||||
// node tests/pwa.browser.mjs
|
||||
// This serves the real production bundle and service worker on loopback. No live API is used.
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
|
||||
const root = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||
await readFile(resolve(root, 'sw.js'));
|
||||
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.ttf': 'font/ttf' };
|
||||
let sessionMode = 'token';
|
||||
const server = createServer(async (request, response) => {
|
||||
const path = new URL(request.url, 'http://localhost').pathname;
|
||||
if (path.startsWith('/api/')) {
|
||||
let status = 200;
|
||||
let body;
|
||||
if (sessionMode === 'unavailable') {
|
||||
status = 503;
|
||||
body = { message: 'Service temporarily unavailable' };
|
||||
} else if (sessionMode !== 'open' && request.headers.authorization !== 'Bearer local-login-fixture') {
|
||||
status = request.headers.authorization ? 403 : 401;
|
||||
body = { message: status === 401 ? 'missing bearer token' : 'invalid bearer token' };
|
||||
} else {
|
||||
body = {
|
||||
'/api/v1/auth/session': { authenticated: true, mode: sessionMode },
|
||||
'/api/v1/workspace-items': { items: [] },
|
||||
'/api/v1/models': { providers: {} },
|
||||
'/api/v1/chat-tools': { tools: [] },
|
||||
'/api/v1/active-runs': { chats: [], searches: [] },
|
||||
}[path] ?? {};
|
||||
}
|
||||
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
response.end(JSON.stringify(body));
|
||||
return;
|
||||
}
|
||||
const file = resolve(root, '.' + (path === '/' ? '/index.html' : path));
|
||||
if (!file.startsWith(root.endsWith(sep) ? root : root + sep)) { response.writeHead(404); response.end(); return; }
|
||||
try {
|
||||
const bytes = await readFile(file);
|
||||
response.writeHead(200, { 'Content-Type': types[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
|
||||
response.end(bytes);
|
||||
} catch { response.writeHead(404); response.end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === '1' ? { args: ['--no-sandbox'] } : {}) });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
await context.route('https://fonts.googleapis.com/**', route => route.abort());
|
||||
await context.route('https://fonts.gstatic.com/**', route => route.abort());
|
||||
const page = await context.newPage();
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`, { waitUntil: 'domcontentloaded', timeout: 15_000 });
|
||||
// Activation must settle before a worker-controlled navigation can finish.
|
||||
await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated', undefined, { timeout: 15_000 });
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 });
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: 'Continue without token' }).count(), 0);
|
||||
assert.equal(await page.getByText(/If.*ADMIN_TOKEN/).count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isDisabled(), true);
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').fill('incorrect-login-fixture');
|
||||
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isEnabled(), true);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.');
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 10_000 });
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 });
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
assert.equal(await page.evaluate(() => navigator.serviceWorker.controller?.state), 'activated');
|
||||
|
||||
// Invalid saved credentials are a real failure, unlike a first visit without credentials.
|
||||
await page.evaluate(() => localStorage.setItem('sybil_admin_token', 'outdated-login-fixture'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.');
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.locator('.workspace-shell').waitFor();
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('sybil_admin_token')), 'local-login-fixture');
|
||||
|
||||
await page.evaluate(() => localStorage.removeItem('sybil_admin_token'));
|
||||
sessionMode = 'open';
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.workspace-shell').waitFor();
|
||||
assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').count(), 0);
|
||||
|
||||
// Suppress only the expected missing-token response, never connectivity/server failures.
|
||||
sessionMode = 'unavailable';
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.equal(await page.getByRole('alert').textContent(), 'Service temporarily unavailable');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS service-worker activation/reload, neutral first sign-in, rejected tokens, valid sign-in, automatic open-mode entry, and visible server failures');
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user