Compare commits

..
Author SHA1 Message Date
buzzert a68caac378 fix(agent): handle partial room timestamps without crashing 2026-10-25 10:16:37 -07:00
buzzert b743523fd5 ios: bound assistant image sets and keep output tools quiet 2026-10-20 14:31:15 -07:00
buzzert ee845c9fa3 ios: support safe assistant inline images 2026-10-20 14:31:15 -07:00
buzzert 9098fee32a Keep assistant image previews stable through live delivery and reload 2026-10-20 14:19:23 -07:00
buzzert 489c347976 Support durable assistant image attachments in Agent clients 2026-10-20 14:14:09 -07:00
buzzert 7b48201e28 web: open Agent at recent history and load older pages on demand 2026-10-16 21:18:15 -07:00
buzzert 671ee98ab1 ios: smooth Agent entrances and refresh app icon
TestFlight / Build and upload (push) Successful in 1m49s
Grow row height and spacing with the existing entrance animation, remove the Latest overlay while retaining scroll/read/send behavior, and reuse the current PWA icon artwork. Cover occupied-height interpolation and scroll-to-latest navigation in focused checks.
2026-10-16 16:54:46 -07:00
buzzert f59c7db7bb web: remove Agent jump-to-latest button 2026-10-16 16:42:58 -07:00
buzzert 15efbcc205 ios: experiment with inverted Agent transcript layout
TestFlight / Build and upload (push) Successful in 1m58s
2026-10-16 14:27:01 -07:00
buzzert ff48a25186 ios: simplify Agent transcript positioning and entrances
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-16 13:58:04 -07:00
buzzert e681e9e05c ios: let native scrolling handle Agent keyboard resizing
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:37:04 -07:00
buzzert d5427926d5 ios: render Agent history lazily
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:10:29 -07:00
buzzert bbaa5e8859 ios: isolate Agent input and read observation 2026-10-16 11:51:07 -07:00
buzzert 7510603b77 feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s
2026-10-14 23:36:38 -07:00
buzzert b9a6f5dd7d ios: isolate Agent scrolling and activity rendering 2026-10-14 23:14:57 -07:00
buzzert 5833a0868e fix(agent): bound push suppression to observed browser attention 2026-10-14 23:06:34 -07:00
buzzert f534f76b23 ci(ios): remove completed push signing bootstrap 2026-10-14 22:45:41 -07:00
63 changed files with 2722 additions and 502 deletions
+101 -5
View File
@@ -605,6 +605,47 @@ 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.
Every room message, including protected tool rows and `activity.queuedMessages`,
has a string `id`, `role`, `content`, and ISO-8601 `createdAt`. Privacy filtering
retains this envelope and tool lifecycle metadata (`kind`, `toolCallId`,
`toolName`, `entryId`, `status`, and available `startedAt`/`completedAt`). Protected
tools use their public tool name as `content`; private arguments, previews, and
errors are withheld. Result-only pages may omit initiation metadata.
Historical entries without a known time use `1970-01-01T00:00:00.000Z` as an
unknown-time sentinel. For compatibility with older partial responses, clients
may recover missing/invalid `createdAt` from `metadata.startedAt`, then
`metadata.completedAt`, or use that sentinel; never substitute the current time.
Older redacted tool rows containing only `id`, `role`, and `name` can retain their
identity with a tool-name label until a complete upsert arrives. The web client
uses the same normalization for REST, WebSocket, and queued activity, retains
known times across partial upserts, and orders by time, numeric `entryId` (when
present, before absent), then stable `id`. Unknown times display as “Earlier”
without a clock time. Invalid identities or non-tool message bodies reject the
page before advancing its replay cursor; timestamps never replace cursors.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
@@ -686,11 +727,54 @@ Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reserv
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. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
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.
@@ -698,7 +782,7 @@ All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer au
### `GET /v1/agent/push/config`
```json
{"version":1,"enabled":false,"apnsAvailable":false,"webPushAvailable":false,"sourceId":null,"quarantined":false,"policy":{"promptGraceMs":5000,"debounceMs":2000,"interactionGraceMs":30000,"presenceHeartbeatMs":15000,"presenceLeaseMs":60000}}
{"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.
@@ -735,9 +819,11 @@ A UUID per browser document/tab, independent of notification permission and sele
{"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. Heartbeat every 15 seconds and on focus/visibility changes and recent pointer/key/touch input. Times are assigned by the server. Visible+focused is active even without recent interaction; any recent interaction blocks alerts for another 30 seconds. Leases expire after 60 seconds. All authenticated workspace sessions must report, even without notification opt-in; at most 512 unexpired sessions are accepted. Associate the UUID with WS auth as described in the streaming contract. Unknown authenticated web sockets block dispatch until classified or their heartbeat lease expires.
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.
`DELETE /v1/agent/presence/:sessionId` returns 204 and retires focus/visibility best effort; recent-interaction grace is retained until expiry. It does not revoke push consent. Backend restarts impose a full 60-second attention warm-up, with leases preserved across restart.
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
@@ -745,14 +831,24 @@ Only complete user-visible root assistant messages enter the durable outbox. Too
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.
Alert payload identity is top-level `{version:1,sourceId,kind:"message",messageId,cursor}`. APNs adds `aps.alert` with title **Sybil Agent**, body **You have a new message.**, default sound and `thread-id:sybil-agent-root`. Web Push adds those same generic title/body strings, fixed `url:"/?agent=root"` and `tag:"sybil-agent:"+sourceId`. No transcript preview, filename, event source, token or key appears in a notification. Normal APNs alerts do not request critical/time-sensitive entitlements.
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`.
+40 -2
View File
@@ -326,6 +326,14 @@ with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages an
socket messages are upserts keyed by stable `id`. Save each returned cursor after
applying its entire frame, even when it contains no visible messages.
Both message frames and queued activity use the required message envelope and
historical timestamp compatibility rules in [REST room history](./rest.md#get-v1agentmessages).
Protected tool redaction preserves `createdAt`, a safe string `content`, and
available lifecycle metadata; it does not remove the row's identity or replay
metadata. Partial tool results may omit `args`/`startedAt`; merge them without
losing a known creation time or downgrading a completed tool on replay. Validate
and normalize all rows before applying a frame or advancing its cursor.
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
ping/pong heartbeats detect dead connections. Other writes belong on REST.
@@ -337,8 +345,34 @@ 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`:
@@ -377,7 +411,9 @@ Attached input admission and its request binding are atomic. Replay verifies the
### Agent attention and shared read events (push v1)
Web auth frames add `{ "clientKind":"web", "presenceSessionId":"per-document-uuid" }` to the existing `type`, `token`, and optional `after`. 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. Old/unclassified authenticated sockets conservatively veto push delivery until closed/expired. A web-classified socket without a live matching presence receipt is also unknown. Transport ping/pong renews connection liveness, never user activity. The proxy classifies itself upstream using the authenticated `X-Sybil-Client: backend-proxy-v1` upgrade header.
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:
@@ -389,4 +425,6 @@ The backend adds this optional server-to-client frame after a successful monoton
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; its unknown sessions participate in the same conservative veto. No Web Push is used for invisible read synchronization.
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs and bounded anonymous grace per runtime source. The separate 60-second restart safety intervals and source/clock uncertainty checks remain in effect. No Web Push is used for invisible read synchronization.
Enabled notifications include a bounded plain-text preview of the eligible completed root public assistant message by default, including on the lock screen when OS settings allow it. The backend resolves only that candidate's exact public `content`, never activity/reasoning/tool/worker fields or linked resources. APNs uses a native string `aps.alert.body`; Web Push uses `body`. This does not change message IDs, stream frames, read acknowledgment or clearing semantics. See the REST contract for Markdown flattening, payload budgets and web worker update ordering.
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"
+6 -2
View File
@@ -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
@@ -46,7 +46,10 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClientin
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 }
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false {
value.capabilities?.attachments = nil
value.capabilities?.assistantImages = nil
}
return value
}
@@ -174,9 +174,15 @@ struct AgentAttachmentCard: View {
if attachment.kind == .image {
ZStack {
SybilTheme.surface
if let image { Image(uiImage: image).resizable().scaledToFit() }
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)
@@ -44,14 +44,24 @@ struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
}
}
struct AgentAssistantImageCapabilities: Decodable, Equatable, Sendable {
var version: Int
var supported: Bool { version == 1 }
}
struct AgentCapabilities: Decodable, Sendable {
var attachments: AgentAttachmentCapabilities?
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
private enum CodingKeys: String, CodingKey { case attachments }
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)
}
}
@@ -91,13 +101,34 @@ struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
extension Message {
var agentAttachments: [AgentAttachment] {
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
return entries.compactMap {
guard let data = try? JSONEncoder().encode($0),
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
}
}
@@ -239,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 {
@@ -249,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 }
@@ -74,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)
@@ -337,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)
@@ -1,183 +1,176 @@
import SwiftUI
import Observation
struct SybilAgentView: View {
@Bindable var room: SybilAgentRoom
var room: SybilAgentRoom
var topInset: CGFloat = 0
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@FocusState private var composerFocused: Bool
@State private var atLatest = true
@State private var isScrolling = false
@State private var historyLimit = 100
@State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition()
@State private var scrollMetrics = AgentScrollMetrics()
@State private var rowFrames: [String: CGRect] = [:]
@State private var restoreAnchor: AgentReadingAnchor?
@State private var appeared = false
private let bottomID = "agent-bottom"
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
private var visibleReadingAnchor: AgentReadingAnchor? {
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
.min { $0.value.minY < $1.value.minY }
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
}
@State private var latestRequest = 0
var body: some View {
VStack(spacing: 0) {
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
AgentStatusBar(room: room)
AgentComposer(room: room) { latestRequest += 1 }
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
}
}
// Draft edits and keyboard focus belong to the composer. The transcript observes
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
private struct AgentTranscriptView: View {
var room: SybilAgentRoom
var topInset: CGFloat
var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var atLatest = true
@State private var scrollPhase: ScrollPhase = .idle
@State private var historyLimit = 100
@State private var historyStartID: String?
private static let bottomID = "agent-bottom"
@State private var scrollTracking = AgentScrollTracking()
@State private var appeared = false
private var isScrolling: Bool { scrollPhase.isScrolling }
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("agentBody")
#endif
let rendered = room.snapshot
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
let allMessages = transcript.messages
let startIndex = transcript.startIndex
let displayed = transcript.displayed
VStack(spacing: 0) {
ScrollViewReader { proxy in
ScrollView {
VStack(alignment: .leading, spacing: 15) {
if allMessages.count > displayed.count {
Button("Load earlier messages") {
if var anchor = visibleReadingAnchor {
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
restoreAnchor = anchor
}
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
// The native scroll origin is the visual bottom. Keep model
// and cursor order ascending; reverse only presentation.
Color.clear.frame(height: 17).id(Self.bottomID)
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
VStack(spacing: 14) {
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) })
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
.padding(.bottom, 15)
.scaleEffect(x: 1, y: -1)
}
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
AgentTranscriptRow(message: message, room: room,
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
.equatable()
// Include the gap in the occupied-space entrance.
.padding(.bottom, 15)
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.scaleEffect(x: 1, y: -1)
.id(message.id)
}
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
}
Color.clear.frame(height: 1).id(bottomID)
}
.scrollTargetLayout()
.padding(.horizontal, 16)
.padding(.bottom, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.top, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.coordinateSpace(name: "agent-scroll")
.scrollPosition($scrollPosition)
.defaultScrollAnchor(.bottom, for: .initialOffset)
.defaultScrollAnchor(.bottom, for: .alignment)
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
isScrolling = phase.isScrolling
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
}
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
AgentScrollMetrics(offset: geometry.contentOffset.y,
height: geometry.containerSize.height,
contentHeight: geometry.contentSize.height,
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
visibleBottom: geometry.visibleRect.maxY)
} action: { previous, next in
scrollMetrics = next
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
if atLatest, !isScrolling, restoreAnchor == nil,
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
proxy.scrollTo(bottomID, anchor: .bottom)
if room.isLoading {
ProgressView("Loading messages…").frame(maxWidth: .infinity)
.padding(.bottom, 15)
.scaleEffect(x: 1, y: -1)
}
if allMessages.count > displayed.count {
Button("Load earlier messages") {
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.padding(.bottom, 15)
.scaleEffect(x: 1, y: -1)
}
}
.onPreferenceChange(AgentRowFrames.self) { frames in
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion or text
// reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
let requested = scrollMetrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
let appliedShift = target - scrollMetrics.offset
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
if abs(appliedShift) > 1 {
// Geometry reports the raw offset; ScrollPosition uses
// zero at the top inset, including the safe area.
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
}
restoreAnchor = nil
} else {
rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
}
}
.overlay(alignment: .bottomTrailing) {
if !atLatest {
Button {
atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
} label: {
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
.font(.sybil(.caption, weight: .semibold))
.padding(12)
.background(SybilTheme.surfaceStrong, in: Capsule())
.overlay(Capsule().stroke(SybilTheme.border))
}
.padding(14)
.accessibilityIdentifier("agent-jump-latest")
}
}
.onAppear {
appeared = true
if !room.isLoading { historyStartID = displayed.first?.id }
}
.onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
}
.onChange(of: allMessages.count) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
}
.onChange(of: composerFocused) { _, _ in
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
// Keep native lazy-row targets without binding scroll position.
.scrollTargetLayout()
.padding(.horizontal, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.bottom, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.scaleEffect(x: 1, y: -1)
.accessibilityIdentifier("agent-transcript")
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
scrollPhase = phase
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.containerSize.height > 0 &&
geometry.contentOffset.y + geometry.contentInsets.top <= 24
} action: { _, atBottom in
#if DEBUG
AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.atBottom = atBottom
// Only user scrolling changes follow intent. Send navigation
// and native keyboard resizing must not cancel it.
if isScrolling && scrollPhase != .animating {
atLatest = atBottom
}
}
statusBar
composer
.onAppear {
appeared = true
if !room.isLoading {
historyStartID = displayed.first?.id
}
}
.onChange(of: room.isLoading) { _, loading in
if !loading && historyStartID == nil {
historyStartID = displayed.first?.id
}
}
.onChange(of: latestRequest) { _, _ in
if !atLatest {
atLatest = true
proxy.scrollTo(Self.bottomID, anchor: .top)
}
}
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling))
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
}
}
// Bottom proximity can change twice per entrance/keyboard frame while the
// scroll view follows the growing content. Only the read-settling task needs
// those transitions; they must not project and rebuild the transcript hierarchy.
private struct AgentReadAcknowledgement: ViewModifier {
var room: SybilAgentRoom
var tracking: AgentScrollTracking
var atLatest: Bool
var isScrolling: Bool
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
func body(content: Content) -> some View {
let cursor = room.snapshot.room.cursor
content
.task(id: readKey) {
// This cursor belongs to the rendered snapshot. New network input cancels
// the task, and cannot be acknowledged while still entering the layout.
let cursor = rendered.room.cursor
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
// Capture the rendered cursor before scheduling. New network input
// cancels this task and must settle in layout before acknowledgement.
guard atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
}
@ViewBuilder private var statusBar: some View {
private struct AgentStatusBar: View {
var room: SybilAgentRoom
@ViewBuilder var body: some View {
let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error {
HStack(alignment: .top) {
@@ -199,12 +192,18 @@ struct SybilAgentView: View {
.padding(.horizontal, 16).padding(.vertical, 6)
}
}
}
private struct AgentComposer: View {
@Bindable var room: SybilAgentRoom
var onSubmit: () -> Void
@FocusState private var composerFocused: Bool
private var canSendComposer: Bool {
room.attachmentDraft.canSend
}
private var composer: some View {
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(
text: $room.composer,
@@ -223,7 +222,7 @@ struct SybilAgentView: View {
private func submitComposer() {
guard canSendComposer, room.sendComposer() else { return }
atLatest = true
onSubmit()
}
}
@@ -236,10 +235,37 @@ struct AgentAvatar: View {
}
}
// Activity/read publications still update the transcript. Completed rows depend
// only on message values and heading; scrolling needs no per-row geometry.
private struct AgentTranscriptRow: View, Equatable {
var message: Message
var room: SybilAgentRoom
var startsDay: Bool
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
}
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("transcriptRow")
#endif
VStack(spacing: 14) {
if startsDay {
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
AgentMessageRow(message: message, room: room)
}
}
}
private struct AgentMessageRow: View {
var message: Message
var room: SybilAgentRoom
var retry: () -> Void
private var own: Bool { message.role == .user }
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
var body: some View {
@@ -249,7 +275,9 @@ private struct AgentMessageRow: View {
ForEach(message.agentAttachments) { attachment in
AgentAttachmentCard(attachment: attachment, room: room)
}
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
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)
@@ -257,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)
@@ -269,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 {
@@ -297,27 +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 visibleBottom: CGFloat = 0
// visibleRect includes the viewport extended into the safe area, as well as
// the alignment inset used when a short transcript is pinned to the bottom.
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
}
private struct AgentReadingAnchor {
var id: String
var offset: CGFloat
var expectedCount: Int? = nil
}
private struct AgentRowFrames: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
}
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
@Observable private final class AgentScrollTracking {
var atBottom = false
}
@@ -223,14 +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 {
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown, agentStyle: agentStyle)
}
}
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
@@ -7,6 +7,7 @@ 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
@@ -28,7 +29,10 @@ struct SybilMessageText: View {
}
var body: some View {
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
#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)
@@ -89,7 +89,7 @@ struct SybilSettingsView: View {
VStack(alignment: .leading, spacing: 10) {
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
Text("Get an alert when the Agent has a new message. Previews say only “You have a new message.” Reading elsewhere clears older alerts when iOS allows the app to sync.")
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")
@@ -14,10 +14,15 @@ private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = or
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) throws -> Data {
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)
}
@@ -124,6 +129,126 @@ private func requestBytes(_ request: URLRequest) -> 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)
@@ -347,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")
@@ -6,6 +6,57 @@ import UIKit
@Suite @MainActor
struct MessagePresentationTests {
@Test func agentMarkdownKeepsAltAndFormattingWithoutImageOrHTMLNodes() {
let source = """
**Caption** ![Inline *landscape*](https://images.invalid/inline.png)
![Reference landscape][photo]
[![Linked landscape](//images.invalid/linked.png)](https://example.invalid)
> ![Nested ![inner](file:///private/image.png)](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 = """
`![literal](https://images.invalid/code.png)`
```html
<img src="literal-code">
```
| Image | Value |
| --- | --- |
| ![Table alt](https://images.invalid/table.png) | **Kept** |
<IMG ALT='![literal alt](https://images.invalid/alt.png)' 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("&lt;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)
+107
View File
@@ -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.
+32
View File
@@ -3,6 +3,7 @@
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
"""
import asyncio
import base64
import datetime
import json
import hashlib
@@ -23,6 +24,8 @@ 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
@@ -95,6 +98,7 @@ async def reply(content):
async def route(request):
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':
@@ -106,6 +110,7 @@ async def route(request):
reply_tasks.clear()
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
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)):
@@ -120,6 +125,16 @@ async def route(request):
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':
@@ -138,6 +153,18 @@ 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()))
@@ -178,6 +205,8 @@ async def route(request):
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:
@@ -251,6 +280,9 @@ async def route(request):
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:
@@ -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]
}
}
+93 -8
View File
@@ -65,6 +65,59 @@ final class AgentUITests: XCTestCase {
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": "![Markdown landscape](http://127.0.0.1:8799/blocked-markdown.png)\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)
@@ -241,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")
@@ -295,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"])
@@ -303,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 {
@@ -37,8 +37,19 @@ final class TranscriptFadeUITests: XCTestCase {
}
private var transcript: XCUIElement {
// The tablet sidebar is also a scroll view; select the detail by its content.
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
// Identify the transcript independently of any particular message row.
app.scrollViews["agent-transcript"]
}
private func scrollToLatest(_ latest: XCUIElement) {
for _ in 0..<30 {
if latest.isHittable { break }
transcript.swipeUp(velocity: .fast)
}
// Finish at the physical end, including the reply metadata and spacing.
transcript.swipeUp(velocity: .slow)
XCTAssertTrue(latest.isHittable)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
}
private func capture(_ name: String) {
@@ -55,6 +66,79 @@ final class TranscriptFadeUITests: XCTestCase {
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func reloadMixedHistory() async throws {
app.terminate()
let rows = (1...160).map { index in
let short = "History message \(index): local simulator fixture."
return ["role": index % 3 == 0 ? "user" : "assistant",
"content": index == 160 || index % 3 == 0 ? short :
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
}
try await control(["action": "presentation-samples", "messages": rows])
app.launch()
}
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
try await reloadMixedHistory()
openAgent()
let latest = app.staticTexts["History message 160: local simulator fixture."]
XCTAssertTrue(latest.isHittable)
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-initial-open")
showSidebar()
app.buttons["Settings"].tap()
XCTAssertFalse(composer.exists)
openAgent()
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-tab-return")
}
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
try await reloadMixedHistory()
openAgent()
composer.tap()
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
}
let draft = "Keyboard send\nsecond line\nthird line"
composer.typeText(draft)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let multilineHeight = composer.frame.height
app.buttons["agent-send"].tap()
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
await fulfillment(of: [cleared], timeout: 5)
XCTAssertLessThan(composer.frame.height, multilineHeight)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let sent = app.staticTexts[draft]
XCTAssertTrue(sent.waitForExistence(timeout: 5))
XCTAssertTrue(sent.isHittable)
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
await fulfillment(of: [followed], timeout: 10)
capture("agent-send-cleared-composer-with-keyboard")
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
// Finished: is only the first Markdown paragraph. Measure the final
// reply content, leaving the same allowance for metadata and spacing.
let replyEnd = app.staticTexts["The local fixture returned three matches."]
XCTAssertTrue(replyEnd.isHittable)
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
print("Agent send settled final-content gap: \(bottomGap)pt")
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
func testSharedHeaderFadeGeometry() async throws {
showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
@@ -73,7 +157,7 @@ final class TranscriptFadeUITests: XCTestCase {
let scroll = transcript
XCTAssertTrue(scroll.exists)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
capture("agent-content-crossing-shared-fade")
XCTAssertTrue(composer.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
@@ -89,7 +173,8 @@ final class TranscriptFadeUITests: XCTestCase {
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown(velocity: .slow)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
// Stay below the header's content clearance, where text is readable.
let visibleTop = scroll.frame.minY + 100
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
@@ -98,8 +183,9 @@ final class TranscriptFadeUITests: XCTestCase {
let before = anchor.frame.minY
let stateBefore = try await control(["action": "state"])
try await control(["action": "reply", "content": "Fade regression reply while reading"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
await fulfillment(of: [unread], timeout: 8)
// The removed button no longer exposes unread state. Allow the entrance
// and read debounce to settle, then inspect the fixture's actual cursor.
try await Task.sleep(for: .milliseconds(500))
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
let stateAfter = try await control(["action": "state"])
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
@@ -107,23 +193,23 @@ final class TranscriptFadeUITests: XCTestCase {
capture("agent-fade-reading-position-and-unread")
for _ in 0..<30 {
if app.buttons["Load earlier messages"].isHittable { break }
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
let load = app.buttons["Load earlier messages"]
XCTAssertTrue(load.isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
load.tap()
capture("agent-fade-prepend-reading-position")
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
"Prepending must preserve the readable row's position beneath the header")
// Prepend position is intentionally not preserved with lazy history.
// New-message reading anchors above and follow/read settling below remain strict.
XCTAssertFalse(load.exists)
capture("agent-fade-older-history-loaded")
app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"]
scrollToLatest(latest)
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard")
@@ -134,5 +220,27 @@ final class TranscriptFadeUITests: XCTestCase {
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
// A reply still follows and settles as read while the keyboard is up.
try await control(["action": "reply", "content": "Reply with keyboard visible"])
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
await fulfillment(of: [keyboardFollowed], timeout: 5)
try await Task.sleep(for: .milliseconds(500))
let keyboardRead = try await control(["action": "state"])
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
// Interactive dismissal is user navigation; scrolling must still return
// to the bottom, and presenting the keyboard again must keep it visible.
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
await fulfillment(of: [dismissed], timeout: 5)
scrollToLatest(keyboardReply)
XCTAssertTrue(keyboardReply.isHittable)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(keyboardReply.isHittable)
}
}
+1 -4
View File
@@ -1,5 +1,3 @@
require_relative 'refresh_push_profile'
default_platform(:ios)
platform :ios do
@@ -7,8 +5,7 @@ platform :ios do
lane :beta do
setup_ci
SybilPushProfile.run
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]
-75
View File
@@ -1,75 +0,0 @@
# One app-scoped bootstrap for the already-regenerated Sybil distribution profile.
# Invoked inside the existing CI job so MATCH_PASSWORD never leaves that job.
require 'match'
require 'match/encryption'
require 'cfpropertylist'
require 'digest'
require 'fileutils'
require 'open3'
require 'tmpdir'
module SybilPushProfile
PROFILE = File.expand_path('~/.config/sybil-release-signing/Sybil_AppStore_CI.mobileprovision')
REPOSITORY = 'https://code.buzzert.dev/buzzert/fastlane-match.git'
TARGET = 'profiles/appstore/AppStore_net.buzzert.sybil2.mobileprovision'
UUID = '6ca8c49d-ca89-4b9f-8ae8-a0e33fafd19c'
CERTIFICATE = '6B74B268C4761720FB2051D01D8BB3E47B55D9F5'
def self.run
return unless File.exist?(PROFILE)
stage = 'profile validation'
file = File.lstat(PROFILE)
parent = File.stat(File.dirname(PROFILE))
raise unless file.file? && !file.symlink? && file.uid == Process.uid && (file.mode & 0o077).zero?
raise unless parent.uid == Process.uid && (parent.mode & 0o077).zero?
xml, _, status = Open3.capture3('/usr/bin/security', 'cms', '-D', '-i', PROFILE)
raise unless status.success?
profile = CFPropertyList.native_types(CFPropertyList::List.new(data: xml).value)
entitlements = profile.fetch('Entitlements')
raise unless profile['UUID'] == UUID && profile['Name'] == 'Sybil AppStore CI'
raise unless profile['TeamIdentifier'] == ['DQQH5H6GBD']
raise unless entitlements['application-identifier'] == 'DQQH5H6GBD.net.buzzert.sybil2'
raise unless entitlements['aps-environment'] == 'production' && entitlements['get-task-allow'] == false
raise unless profile.fetch('DeveloperCertificates').any? { |c| Digest::SHA1.hexdigest(c).upcase == CERTIFICATE }
raise unless profile.fetch('ExpirationDate') > Time.now
plaintext = File.binread(PROFILE)
password = ENV.fetch('MATCH_PASSWORD')
authorization = ENV.fetch('MATCH_GIT_BASIC_AUTHORIZATION')
raise if password.empty? || authorization.empty? || authorization.match?(/[\r\n]/)
git_env = { 'GIT_TERMINAL_PROMPT' => '0', 'GIT_CONFIG_COUNT' => '1',
'GIT_CONFIG_KEY_0' => 'http.https://code.buzzert.dev/.extraheader',
'GIT_CONFIG_VALUE_0' => "Authorization: Basic #{authorization}" }
git = lambda do |*args|
out, _, result = Open3.capture3(git_env, 'git', *args)
raise unless result.success?
out
end
stage = 'encrypted profile refresh'
Dir.mktmpdir('sybil-profile-') do |temp|
File.chmod(0o700, temp)
repo = File.join(temp, 'match')
git.call('clone', '--quiet', '--single-branch', REPOSITORY, repo)
target = File.join(repo, TARGET)
raise unless File.file?(target) && !File.symlink?(target)
crypto = Match::Encryption::MatchDataEncryption.new
current = crypto.decrypt(base64encoded_encrypted: File.read(target), password: password)
if current == plaintext
puts 'Sybil production Push profile is already current in match.'
return
end
encrypted = crypto.encrypt(data: plaintext, password: password, version: 2)
raise unless crypto.decrypt(base64encoded_encrypted: encrypted, password: password) == plaintext
File.write(target, encrypted)
changed = git.call('-C', repo, 'diff', '--name-only').lines.map(&:strip)
raise unless changed == [TARGET]
git.call('-C', repo, 'add', '--', TARGET)
git.call('-C', repo, '-c', 'user.name=Sybil CI', '-c', 'user.email=james@magahern.com',
'commit', '--quiet', '-m', 'signing: refresh Sybil production Push profile')
stage = 'normal signing-repository push'
git.call('-C', repo, 'push', 'origin', 'HEAD')
puts 'Updated only the encrypted Sybil production Push profile in match.'
end
rescue StandardError
abort "Sybil signing profile refresh failed at #{stage}; credential details withheld."
end
end
+13
View File
@@ -19,6 +19,7 @@
"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",
@@ -1741,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",
+1
View File
@@ -28,6 +28,7 @@
"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",
@@ -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;
+5
View File
@@ -249,6 +249,7 @@ model AgentPushPresence {
visible Boolean
focused Boolean
lastInteractionAt DateTime?
inactiveUntil DateTime?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
@@ -273,6 +274,7 @@ model AgentPushCoordinator {
localPromptAt DateTime?
nativeActive Boolean @default(false)
nativeUnknown Boolean @default(true)
unknownSince DateTime?
uncertain Boolean @default(true)
lockOwner String?
lockUntil DateTime?
@@ -296,6 +298,9 @@ model AgentPushDelivery {
expiresAt DateTime
updatedAt DateTime @updatedAt
outcome String?
lastSuppressionReason String?
lastSuppressionClientKind String?
lastSuppressedAt DateTime?
@@index([status, dueAt])
@@index([installationId, status])
@@index([ownerEpoch, sourceId, cursor])
+5 -4
View File
@@ -18,11 +18,12 @@ export type AgentProxyJson = (path: string, method?: string, body?: unknown, sig
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
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 } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
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.');
}
@@ -52,7 +53,7 @@ export async function registerAgentAttachmentRoutes(app: FastifyInstance, option
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);
await requireAgentAttachments(options.proxyJson, controller.signal, !uploading);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
+2 -2
View File
@@ -1,8 +1,8 @@
import { createHash, createPublicKey, ECDH } from 'node:crypto';
import { z } from 'zod';
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 30_000,
presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
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';
+92
View File
@@ -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;
}
+7 -4
View File
@@ -6,12 +6,13 @@ 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) => Promise<SendResult>;
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;
@@ -104,7 +105,7 @@ 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) => {
return async (registration, identity, expiresAt, now, messageText) => {
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
try {
if (registration.transport === 'apns') {
@@ -117,7 +118,8 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
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 = Buffer.from(JSON.stringify({ aps: clear ? { 'content-available': 1 } : { alert: { title: 'Sybil Agent', body: 'You have a new message.' }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }));
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',
@@ -130,10 +132,11 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
// 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 = JSON.stringify({ ...identity, title: 'Sybil Agent', body: 'You have a new message.', url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` });
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 {
+93 -27
View File
@@ -8,13 +8,15 @@ 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>; send: PushSend; now?: () => number;
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
broadcast?: (state: ReadState) => void; warmupMs?: number };
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 {
@@ -23,10 +25,11 @@ export class AgentPushCoordinator {
private readonly now: () => number;
private readonly warmUntil: number;
private started = false;
private untrackedConnections = new Set<string>();
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;
@@ -42,7 +45,7 @@ export class AgentPushCoordinator {
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, uncertain: true, lockOwner: null, lockUntil: null } });
nativeUnknown: true, unknownSince: null, uncertain: true, lockOwner: null, lockUntil: null } });
}
});
this.started = true;
@@ -102,22 +105,46 @@ export class AgentPushCoordinator {
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 data = { ownerEpoch: this.epoch, sequence: value.sequence, visible: value.visibility === 'visible', focused: value.focused,
lastInteractionAt: value.interacted ? date(now) : old?.ownerEpoch === this.epoch ? old.lastInteractionAt : null, expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
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) {
// Keep recent interaction's grace even after a best-effort pagehide release.
await this.options.db.agentPushPresence.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { visible: false, focused: false } });
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) {
this.untrackedConnections.add(id);
await this.options.db.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
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;
@@ -236,21 +263,34 @@ export class AgentPushCoordinator {
return changedRead;
});
if (read) this.options.broadcast?.(read);
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
return context.hasMore;
}
private async browserActive(tx: Tx, now: number): Promise<boolean> {
if (this.untrackedConnections.size) return true;
const presences = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
if (presences.some(v => (v.visible && v.focused) || (v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now))) return true;
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) } } });
return connections.some(v => v.kind !== 'native' && (v.kind !== 'web' || !presences.some(p => p.id === v.sessionId)));
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
if (!unobserved.length && !this.untrackedConnections.size) return;
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 dispatch(id: string) {
const claim = await this.options.db.$transaction(async tx => {
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
@@ -258,11 +298,17 @@ export class AgentPushCoordinator {
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) return;
if (row.kind === 'message' && (state.nativeActive || state.nativeUnknown || await this.browserActive(tx, now)
|| (state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now))) {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs) } }); return;
|| 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));
@@ -270,17 +316,31 @@ export class AgentPushCoordinator {
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) } });
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 };
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); }
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;
@@ -312,7 +372,13 @@ export class AgentPushCoordinator {
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.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } }); } catch { /* Retry on next loop. */ }
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;
@@ -349,7 +415,7 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
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;
}, send: createPushSender(config), ...overrides });
}, 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 };
+4 -3
View File
@@ -43,8 +43,8 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
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 } };
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
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) => {
@@ -53,6 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
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),
});
@@ -138,7 +139,7 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
};
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.connect(connectionId).catch(() => {}); });
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);
+18 -2
View File
@@ -20,14 +20,14 @@ const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/age
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, badReceipt=false, errorStatus=0, uploadCalls=0;
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',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;}
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;}
@@ -57,6 +57,22 @@ test('multipart forwards Unicode filename/uploadId with private auth; quota deta
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});
+116
View File
@@ -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_ &amp; 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![Chart](https://private.invalid/image)\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));
});
+122 -10
View File
@@ -40,12 +40,13 @@ async function fixture(options: { warmupMs?: number; config?: PushConfig } = {})
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 }[] = [], reads: any[] = [];
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)) }; },
send: async (registration, identity) => { sent.push({ registration, identity }); return result; }, broadcast: value => reads.push(value) });
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; },
@@ -66,21 +67,132 @@ test('first source bootstrap never alerts history; later durable candidates debo
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('all tabs veto: visible focused idle, recent interaction and unknown connections; leases eventually expire', async () => {
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID(), unknown = randomUUID();
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.connect(unknown); f.advance(31_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'unknown authenticated socket vetoes');
f.advance(61_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'expired stale sessions retire');
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 () => {
@@ -172,20 +284,20 @@ test('provider endpoint validation rejects SSRF targets and private/reserved DNS
assert.equal(isPublicAddress('8.8.8.8'), true); assert.equal(isPublicAddress('2606:4700::1111'), true);
});
test('real APNs ES256 request construction uses generic alerts, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
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)).outcome, 'accepted');
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: 'You have a new message.' });
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 });
@@ -206,7 +318,7 @@ test('standard encrypted aes128gcm + VAPID Web Push uses library request constru
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(); }, send: async () => { throw new Error('must not send'); } });
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'); } });
+11 -5
View File
@@ -17,6 +17,8 @@ self.addEventListener("fetch", (event) => {
// 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);
@@ -67,20 +69,24 @@ async function announce(type) {
self.addEventListener("push", event => {
event.waitUntil(serial(async () => {
let value;
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) return; value = JSON.parse(raw); } catch { return; }
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 || value.body !== PUSH_BODY) return;
|| 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 identity = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor) ? previous.latest : {
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: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
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 }).catch(() => undefined);
await save({ ...previous, sourceId: identity.sourceId, latest: identity, latestBody: body }).catch(() => undefined);
await announce("sybil-push-shown");
}));
});
+3 -3
View File
@@ -3554,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);
@@ -3580,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>
+20 -11
View File
@@ -1,19 +1,20 @@
import { useEffect, useRef, useState } from "preact/hooks";
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; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
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 }: Props) {
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>();
useEffect(() => () => { controller.current?.abort(); }, []);
const transferring = useRef(false);
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
const retrieve = async (show: boolean) => {
if (busy) return;
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;
@@ -24,14 +25,22 @@ export function AgentAttachmentCard({ file, download }: Props) {
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
finally { if (!request.signal.aborted) setBusy(false); }
};
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>
{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}
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
{!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" ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{preview ? "Hide preview" : "Preview"}</button> : null}
{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}
+28 -25
View File
@@ -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);
@@ -475,4 +476,6 @@
.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; }
+45 -22
View File
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ArrowDown, Clock3, LoaderCircle, Menu, Paperclip, X, 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";
@@ -49,7 +50,7 @@ function toolName(value: unknown) {
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
}
function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
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";
@@ -60,8 +61,8 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message
{!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 />}
{agentAttachments(message.metadata).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} />)}
{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">
@@ -77,7 +78,7 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message
</div>
</article>
);
}
});
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
const [draft, setDraft] = useState(savedDraft);
@@ -98,7 +99,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
const entryAnimationsRef = useRef(new Set<Animation>());
const typingHeightRef = useRef(0);
const historyReadyRef = useRef(!room.isLoading);
const [atBottom, setAtBottom] = useState(true);
const historyBeforeRef = useRef(room.historyBeforeCursor);
const scrollerRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -131,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;
@@ -141,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;
@@ -197,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;
@@ -231,10 +245,10 @@ 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 && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
@@ -247,9 +261,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
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);
@@ -265,7 +284,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
</div>
);
});
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
}, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]);
const activity = room.activity;
const running = room.isTyping;
@@ -305,19 +324,23 @@ 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"
@@ -9,6 +9,7 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
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">
@@ -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 }} />;
}
+37 -9
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
import {
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentReadState,
applyAgentEvent, applyAgentReadState, applyAgentHistoryPage, agentUnreadCountIsPartial,
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
type AgentActivity, type AgentRoomState,
} from "@/lib/agent-room";
@@ -16,6 +16,7 @@ export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" |
export type AgentRoomHookResult = {
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
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>;
@@ -33,6 +34,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
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 [, renderUploads] = useState(0);
@@ -81,9 +85,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
}
useEffect(() => {
setLoadingOlder(false); setOlderError(null);
if (!enabled) {
sourceMismatch.current = false;
client.current = null; mark.current = null; setAttachmentCapability(undefined);
client.current = null; mark.current = null; older.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
@@ -96,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;
@@ -110,7 +116,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
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)); };
const readSession = createAgentReadSession({
initial: current.current,
@@ -119,6 +125,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
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 => {
@@ -139,6 +146,22 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const descriptor = await api.descriptor(controller.signal);
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 || sourceChanged) return;
@@ -191,9 +214,11 @@ 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 (!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();
@@ -231,18 +256,19 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
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, renderedSourceId);
}, [enabled, isLoading, 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, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
@@ -263,7 +289,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
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,
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 };
}
+15 -2
View File
@@ -19,10 +19,23 @@ export function parseAgentAttachment(value: unknown): AgentAttachment {
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): AgentAttachment[] {
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 { return values.map(parseAgentAttachment); } catch { 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(", ")}.`;
+55
View File
@@ -0,0 +1,55 @@
import type { Message } from "./api";
// The runtime also uses the Unix epoch for historical entries without a time.
// The transcript renders these as "Earlier", without inventing a send time.
export const UNKNOWN_AGENT_TIME = "1970-01-01T00:00:00.000Z";
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
const timestamp = (value: unknown): string | undefined => {
if (typeof value !== "string" || !value.trim()) return undefined;
const time = Date.parse(value);
return Number.isFinite(time) && time !== 0 ? new Date(time).toISOString() : undefined;
};
export function agentMessageTime(message: Pick<Message, "createdAt" | "metadata">): string | undefined {
const info = object(message.metadata);
return timestamp(message.createdAt) ?? timestamp(info?.startedAt) ?? timestamp(info?.completedAt);
}
/** Normalize historical/partial rows at both transports, before state or cursors change. */
export function normalizeAgentMessage(value: unknown): Message {
const message = object(value);
if (!message || typeof message.id !== "string" || !message.id
|| typeof message.role !== "string" || !["system", "user", "assistant", "tool"].includes(message.role)) throw new Error("Invalid agent message.");
const info = object(message.metadata);
// Older runtime REST redaction emitted protected tool rows with only id/role/name.
// Keep their identity for a later complete upsert; never discard a whole page.
const content = typeof message.content === "string" ? message.content
: message.role === "tool" && message.content == null ? (typeof message.name === "string" ? message.name : "Tool") : undefined;
if (content === undefined) throw new Error("Invalid agent message.");
const createdAt = agentMessageTime(message as Message) ?? UNKNOWN_AGENT_TIME;
const needsToolMetadata = message.role === "tool" && info?.kind !== "tool_call";
if (message.createdAt === createdAt && message.content === content && !needsToolMetadata) return message as Message;
return { ...message, content, createdAt,
...(needsToolMetadata ? { metadata: { ...info, kind: "tool_call" } } : {}),
} as Message;
}
function cursor(message: Message): bigint | undefined {
const entryId = object(message.metadata)?.entryId;
if ((typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0)
|| (typeof entryId === "string" && /^\d+$/.test(entryId))) return BigInt(entryId);
}
/** One total order for history, queue snapshots and optimistic rows, including ties. */
export function compareAgentMessages(left: Message, right: Message): number {
const byTime = Date.parse(agentMessageTime(left) ?? UNKNOWN_AGENT_TIME) - Date.parse(agentMessageTime(right) ?? UNKNOWN_AGENT_TIME);
if (byTime) return byTime;
const a = cursor(left), b = cursor(right);
if (a !== b) {
if (a === undefined) return 1;
if (b === undefined) return -1;
return a < b ? -1 : 1;
}
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import type { Message } from "./api";
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
import { compareAgentMessages, normalizeAgentMessage } from "./agent-messages.ts";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
@@ -165,7 +166,7 @@ export function createAgentOptimisticOutbox(options: {
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return [...displayed.values()].map(normalizeAgentMessage).sort(compareAgentMessages);
},
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
};
+1 -1
View File
@@ -94,7 +94,7 @@ export const disableAgentBrowserPush = () => controller.disable();
export const refreshAgentBrowserPush = () => controller.refresh();
const presence = createAgentPresenceReporter({
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
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;
+1 -1
View File
@@ -135,7 +135,7 @@ export function createAgentPushController(options: {
: 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. Alerts use a private, generic preview."
: 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 };
};
+1 -1
View File
@@ -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 => {
+105 -31
View File
@@ -1,6 +1,7 @@
import type { Message } from "./api";
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
import { agentPushCursor, agentPushSourceId, agentPushUuid } from "./agent-push.ts";
import { agentMessageTime, compareAgentMessages, normalizeAgentMessage, UNKNOWN_AGENT_TIME } from "./agent-messages.ts";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
@@ -18,12 +19,13 @@ export type AgentMessagesPage = {
};
export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability; assistantImages?: { version: 1 } };
};
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;
};
@@ -56,7 +58,9 @@ function mergeMessage(previous: Message, incoming: Message): Message {
const prior = metadata(previous), next = metadata(incoming);
// A delayed activity snapshot must not demote an already placed user message.
if (prior?.status === "complete" && next?.status === "queued") return previous;
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
const priorTime = agentMessageTime(previous), nextTime = agentMessageTime(incoming);
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming,
createdAt: nextTime ?? priorTime ?? UNKNOWN_AGENT_TIME };
const terminal = prior.status === "completed" || prior.status === "failed";
const staleStart = terminal && next.status === "initiated";
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
@@ -64,26 +68,36 @@ function mergeMessage(previous: Message, incoming: Message): Message {
const oldArgs = object(prior.args), newArgs = object(next.args);
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
createdAt: priorTime && nextTime ? (priorTime < nextTime ? priorTime : nextTime) : priorTime ?? nextTime ?? UNKNOWN_AGENT_TIME, metadata: merged };
}
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
const messages = new Map(previous.map(message => [message.id, message]));
for (const message of incoming) {
const messages = new Map(previous.map(normalizeAgentMessage).map(message => [message.id, message]));
for (const message of incoming.map(normalizeAgentMessage)) {
const prior = messages.get(message.id);
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
}
return [...messages.values()].sort((a, b) => {
const byTime = a.createdAt.localeCompare(b.createdAt);
if (byTime) return byTime;
const left = messageCursor(a), right = messageCursor(b);
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
return a.id.localeCompare(b.id);
});
return [...messages.values()].sort(compareAgentMessages);
}
function parseAgentPage(value: unknown): AgentMessagesPage {
const page = object(value);
if (!page || typeof page.conversationId !== "string" || !Array.isArray(page.messages) || typeof page.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (page.cursor !== null) normalizeAgentCursor(page.cursor);
if (page.beforeCursor != null) normalizeAgentCursor(page.beforeCursor);
return { ...page, beforeCursor: page.beforeCursor ?? null, messages: page.messages.map(normalizeAgentMessage) } as AgentMessagesPage;
}
function parseAgentActivity(value: unknown): AgentActivity {
const activity = object(value);
if (!activity || (activity.tools !== undefined && !Array.isArray(activity.tools))
|| (activity.queuedMessages !== undefined && !Array.isArray(activity.queuedMessages))) throw new Error("Invalid agent activity frame.");
return { ...activity, tools: activity.tools ?? [],
queuedMessages: ((activity.queuedMessages ?? []) as unknown[]).map(normalizeAgentMessage) } as AgentActivity;
}
export const createAgentRoomState = (): AgentRoomState => ({
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"; }
@@ -97,11 +111,31 @@ function readSource(state: Pick<AgentRoomState, "sourceId" | "cursor" | "readCur
return incoming;
}
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
page = parseAgentPage(page);
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// 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 {
const sourceId = readSource(state, read);
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
@@ -111,7 +145,7 @@ export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): A
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 };
return { ...state, conversationId: event.conversationId, activity: parseAgentActivity(event.activity) };
}
export function agentUnreadCount(state: AgentRoomState): number {
return state.messages.filter(message => {
@@ -132,20 +166,17 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
|| (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);
for (const item of event.messages) {
const message = object(item);
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
}
return { ...parseAgentPage(event), type: "messages" };
}
if (event.type === "activity") {
if (typeof event.conversationId !== "string") throw new Error("Invalid agent activity frame.");
return { ...event, activity: parseAgentActivity(event.activity) } as AgentRoomEvent;
}
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, presenceSessionId?: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
clientKind: "web", ...(agentPushUuid(presenceSessionId) ? { presenceSessionId } : {}) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
@@ -154,6 +185,8 @@ 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);
@@ -174,9 +207,16 @@ export class AgentRoomClient {
}
return value as T;
}
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
async descriptor(signal?: AbortSignal): Promise<AgentDescriptor> {
const value = object(await this.request("", { signal }));
if (!value || typeof value.id !== "string") throw new Error("Invalid agent descriptor.");
return { ...value, activity: parseAgentActivity(value.activity) } as AgentDescriptor;
}
async messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return parseAgentPage(await this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal }));
}
async history(before?: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return parseAgentPage(await this.request(`/messages?${new URLSearchParams({ ...(before === undefined ? {} : { before: normalizeAgentCursor(before) }), limit: "100" })}`, { signal }));
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
@@ -199,12 +239,46 @@ export class AgentRoomClient {
}
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
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) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); }
const blob = await response.blob();
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete.");
return blob;
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);
+28 -5
View File
@@ -11,13 +11,15 @@ const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABL
...(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=[];
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;const history=[];
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');
@@ -26,7 +28,7 @@ await page.route('**/api/**',async route=>{
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());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});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;}
@@ -37,7 +39,7 @@ await page.route('**/api/**',async route=>{
}
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:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
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:{}};
@@ -75,7 +77,28 @@ try{
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![tracking](https://never-fetch.invalid/markdown.png) <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, mobile layout');
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();}
+37
View File
@@ -5,11 +5,48 @@ import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '
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);}});
@@ -7,6 +7,15 @@ const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") =>
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
});
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
test("display sorts partial server rows and tied entry cursors consistently", () => {
const box = createAgentOptimisticOutbox({ submit: async () => assert.fail("no send"),
storage: { read: () => [], write: () => {} }, changed: () => {} });
const rows = [canonical("z"), { id: "tool:1:partial", role: "tool", name: "owner_phone_context" },
{ ...canonical("a"), metadata: { entryId: 124 } }];
assert.deepEqual(box.display(rows).map(row => row.id), ["tool:1:partial", "z", "a"]);
assert.deepEqual(box.display([...rows].reverse()), box.display(rows));
});
function fixture(submit, legacy = {}) {
let stored = null;
let serial = 0;
+21 -7
View File
@@ -45,7 +45,7 @@ function fixture(sharedRecords = new Map()) {
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
},
};
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt,
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);
@@ -58,9 +58,9 @@ function fixture(sharedRecords = new Map()) {
};
}
test("actual worker push handler shows fixed generic notification and never fetches credentials", async () => {
const f = fixture(); await f.push(payload());
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "You have a new message.");
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"));
@@ -69,22 +69,36 @@ test("actual worker push handler shows fixed generic notification and never fetc
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")); await f.push(payload("8")); await f.push(payload("5"));
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 text, destinations, cross-source tags or silent read pushes", async () => {
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: "transcript" }),
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") });
+19
View File
@@ -108,6 +108,20 @@ try {
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));
@@ -116,6 +130,11 @@ try {
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 }) => {
+1 -1
View File
@@ -405,7 +405,7 @@ test("attention is per-tab, ordered, focused while idle, and recent input persis
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.equal(JSON.parse(agentAuthFrame(null, "0", "not-an-id")).clientKind, undefined);
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", () => {
+162 -4
View File
@@ -4,6 +4,7 @@ import {
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";
@@ -20,6 +21,91 @@ const sourceA = "4599d348-e053-4937-987d-1392746fb2ce", sourceB = "22222222-2222
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
const sourceRoom = () => applyAgentPage(applyAgentReadState(createAgentRoomState(), { sourceId: sourceA, cursor: "90", updatedAt: null }), page("100", [message(100)]));
test("redacted historical tool rows survive REST loading, socket replay and outbox rendering", async () => {
const redacted = { id: "tool:1:protected", role: "tool", name: "owner_phone_context" };
const input = page("3", [message(1), redacted, message(3)]);
const client = new AgentRoomClient({ fetch: async () => Response.json(input) });
let state = createAgentRoomState();
await syncAgentHistory(client, "0", update => { state = applyAgentPage(state, update); });
const initial = state.messages;
assert.deepEqual((await client.history()).messages, (await client.messages("0")).messages,
"recent/older history uses the same boundary as forward replay");
const row = initial.find(row => row.id === redacted.id);
assert.equal(row.createdAt, "1970-01-01T00:00:00.000Z", "unknown times are stable, never fabricated as now");
assert.equal(row.content, "owner_phone_context");
assert.equal(state.cursor, "3");
const event = parseAgentRoomEvent(JSON.stringify({ type: "messages", ...input }));
state = applyAgentEvent(state, event);
assert.deepEqual(state.messages, initial, "replaying partial history is idempotent");
const box = createAgentOptimisticOutbox({ submit: async () => assert.fail("no send"),
storage: { read: () => [], write: () => {} }, changed: () => {} });
assert.deepEqual(box.display(agentDisplayMessages(state)), initial);
});
test("partial tool updates retain known time, terminal metadata and chronological ordering", () => {
const id = "tool:1:partial";
const start = message(2, "tool", { id, metadata: { kind: "tool_call", entryId: 2, status: "initiated", startedAt: "2026-10-14T10:00:02.000Z" } });
const done = message(3, "tool", { id, createdAt: undefined, metadata: { kind: "tool_call", entryId: 3, status: "completed", completedAt: "2026-10-14T10:00:03.000Z" } });
const partial = { id, role: "tool", name: "owner_phone_context" };
const rows = mergeAgentMessages([message(1), start], [done, partial, start, message(4)]);
assert.deepEqual(rows.map(row => row.id), ["entry:1", id, "entry:4"]);
assert.equal(rows[1].createdAt, start.createdAt);
assert.equal(rows[1].metadata.status, "completed");
assert.equal(rows[1].metadata.completedAt, done.metadata.completedAt);
assert.equal(mergeAgentMessages([], [done])[0].createdAt, done.metadata.completedAt);
assert.equal(mergeAgentMessages([partial], [start])[0].createdAt, start.createdAt);
});
test("an unknown historical tool start never masks a known completion timestamp", () => {
const unknown = "1970-01-01T00:00:00.000Z", known = "2026-10-14T10:00:03.000Z";
const start = message(2, "tool", { id: "tool:1:historical", createdAt: unknown,
metadata: { kind: "tool_call", entryId: 2, status: "initiated", startedAt: unknown } });
const done = message(3, "tool", { id: start.id, createdAt: known,
metadata: { kind: "tool_call", entryId: 3, status: "completed", completedAt: known } });
for (const rows of [[start, done], [done, start], [{ ...start, metadata: { ...start.metadata, ...done.metadata } }]]) {
const merged = mergeAgentMessages([], rows);
assert.equal(merged[0].createdAt, known);
assert.equal(merged[0].metadata.status, "completed");
}
});
test("missing, null, invalid and tied timestamps have stable ordering independent of arrival order", () => {
const rows = [message(11, "assistant", { createdAt: null }), message(2, "assistant", { createdAt: undefined }),
message(3, "assistant", { createdAt: "not-a-date" }), message(4, "assistant", { createdAt: 0 }),
message(5, "assistant", { createdAt: null, metadata: { kind: "message" } })];
const expected = ["entry:2", "entry:3", "entry:4", "entry:11", "entry:5"];
assert.deepEqual(mergeAgentMessages([], rows).map(row => row.id), expected);
assert.deepEqual(mergeAgentMessages([], [...rows].reverse()).map(row => row.id), expected);
const tied = [message(11, "assistant", { createdAt: "2026-10-14T10:00:00Z" }), message(2, "assistant", { createdAt: "2026-10-14T11:00:00+01:00" })];
assert.deepEqual(mergeAgentMessages([], tied).map(row => row.id), ["entry:2", "entry:11"]);
});
test("REST descriptors and socket activity normalize partial queued messages consistently", async () => {
const pending = message(1, "user", { id: "inbound:sybil:partial", createdAt: undefined, metadata: { kind: "message", status: "queued" } });
const value = { id: "1", activity: activity([pending]) };
const client = new AgentRoomClient({ fetch: async () => Response.json(value) });
const descriptor = await client.descriptor();
const fromRest = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: descriptor.id, activity: descriptor.activity });
const fromSocket = applyAgentEvent(createAgentRoomState(), parseAgentRoomEvent(JSON.stringify({ type: "activity", conversationId: "1", activity: value.activity })));
assert.deepEqual(agentDisplayMessages(fromRest), agentDisplayMessages(fromSocket));
assert.equal(agentDisplayMessages(fromRest)[0].createdAt, "1970-01-01T00:00:00.000Z");
const malformed = { ...pending, content: undefined };
assert.throws(() => applyAgentEvent(fromRest, { type: "activity", conversationId: "1", activity: activity([malformed]) }), /Invalid agent message/);
assert.equal(fromRest.cursor, "0");
});
test("invalid message bodies fail at both transport boundaries before advancing replay", async () => {
for (const malformed of [null, { role: "assistant", content: "missing ID" }, message(2, "assistant", { content: null }), message(2, ["assistant"])]) {
const input = page("3", [message(1), malformed]);
const client = new AgentRoomClient({ fetch: async () => Response.json(input) });
let applied = false;
await assert.rejects(syncAgentHistory(client, "0", () => { applied = true; }), /Invalid agent message/);
await assert.rejects(client.history(), /Invalid agent message/);
assert.equal(applied, false);
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...input })), /Invalid agent message/);
}
});
test("tool starts and results merge across replay without downgrading completed cards", () => {
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
@@ -32,7 +118,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();
@@ -47,6 +133,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]) });
@@ -239,7 +397,7 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
let token = "first-session-token";
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : url.includes("/messages?") ? page("0") : { cursor: "0", updatedAt: null }), { status: 200 });
} });
await client.messages("0");
token = "second-session-token";
@@ -254,8 +412,8 @@ 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 () => {
+250
View File
@@ -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(); }