Compare commits

...
Author SHA1 Message Date
buzzert e33adb8bbf ci(ios): refresh scoped production push signing profile
TestFlight / Build and upload (push) Successful in 1m45s
2026-10-14 22:21:22 -07:00
buzzert 60a35509d4 ios: unify attachment controls and message typography 2026-10-14 22:03:21 -07:00
buzzert a1c0264d36 feat(agent): add opt-in push notifications 2026-10-14 22:03:21 -07:00
buzzert 80748f764f ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s
2026-10-14 19:51:27 -07:00
buzzert 2e62bfa736 fix(web): simplify first-time token sign-in 2026-10-14 18:43:06 -07:00
buzzert 844f48e9e0 fix(web): avoid service-worker activation deadlock 2026-10-14 18:30:06 -07:00
buzzert 8b125c13a5 feat(agent): add authenticated durable uploads to backend and web 2026-10-14 17:25:52 -07:00
buzzert 4cbab487d0 ios: hide raw tool entries in Agent transcript
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-14 15:40:37 -07:00
buzzert 56279fba7f web: hide tool entries in Sybil Agent transcript 2026-10-14 15:29:50 -07:00
buzzert 153aa5d2fa ios: share prompt field across chat and agent views
TestFlight / Build and upload (push) Successful in 1m44s
2026-10-14 15:08:34 -07:00
buzzert 4389f805f4 ios: add persistent Sybil Agent room
TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details.

Validated with 58 Xcode package tests, six Simulator UI scenarios, read-only production connectivity and independent review.
2026-10-14 13:34:37 -07:00
buzzert e83281c400 Rename the persistent agent integration to Sybil Agent
Use SYBIL_AGENT_BASE_URL and SYBIL_AGENT_API_KEY with the renamed sybil-agent
service. Update errors, API documentation, and test fixtures while retaining the
existing room protocol and browser storage keys for compatibility.

Validation: 44 server tests, 51 web tests, and both production builds.
2026-10-14 12:37:03 -07:00
buzzert ef3d79c428 Add persistent agent messaging and shared chat bubbles
Connect Sybil to the Pi root conversation with authenticated REST and WebSocket
APIs, persistent read state, and a dedicated agent tab. Keep optimistic messages
stable through delivery and retries, and animate arrivals without losing the
reader's scroll position.

Share bubble styling with regular chats and remove the obsolete admission sender.
Reject malformed upstream receipts and WebSocket upgrade requests, with regression
coverage and updated API documentation.

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
2026-10-14 12:20:11 -07:00
buzzert dd6cd1ea7b Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
2026-10-07 22:05:49 -07:00
buzzert 3f9c1409fe ios: remove animated workspace character
TestFlight / Build and upload (push) Successful in 1m40s
2026-10-04 21:48:45 -07:00
buzzert 411a8cebc7 Expose chat-latest and default Quick Question to it
TestFlight / Build and upload (push) Successful in 1m54s
2026-10-02 13:56:44 -07:00
buzzert 14191de8f4 No rounded corners 2026-09-30 21:30:54 -07:00
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
buzzert 3a9c9bb74a ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s
2026-09-06 14:03:30 -07:00
buzzert 04ac39499f ios: submit quick questions with return 2026-09-06 13:36:18 -07:00
122 changed files with 14739 additions and 513 deletions
+7 -1
View File
@@ -1,3 +1,8 @@
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
listen 80;
server_name _;
@@ -19,7 +24,8 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location = /sw.js {
+207 -2
View File
@@ -23,6 +23,8 @@ Chat upload limits:
### `GET /v1/auth/session`
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`).
- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass.
## Models
@@ -31,7 +33,7 @@ Chat upload limits:
```json
{
"providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
@@ -39,7 +41,7 @@ Chat upload limits:
}
}
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
@@ -81,6 +83,8 @@ Behavior notes:
### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
@@ -123,6 +127,15 @@ Behavior notes:
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
History search (`q`) notes:
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
## Chats
### `GET /v1/chats`
@@ -551,3 +564,195 @@ Behavior notes:
```
For streaming contracts, see `docs/api/streaming-chat.md`.
## Sybil Agent Room
The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
background work, and history; these endpoints do not create ordinary Sybil chats or
copy the transcript into the Sybil message database. All endpoints use normal Sybil
REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
`http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
### `GET /v1/agent`
Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
configuration returns 503; an unreachable agent server or malformed upstream JSON
returns 502.
### `GET /v1/agent/messages`
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
history. `after` catches up forward; `before` loads earlier history.
```json
{
"conversationId": "1",
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
"cursor": "43",
"beforeCursor": "43",
"hasMore": false
}
```
Messages use `user`, `assistant`, and `tool` roles. Tools use
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
pages can update initiated tools to completed or failed. Internal worker reports,
event prompts, and model reasoning are excluded. Cursors refer to underlying
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.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
Content is limited to 40,000 characters; it must contain non-whitespace text unless attachment IDs are included;
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
`wait: false`.
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
completion: watch the room for answers, including unsolicited later replies.
Multiple requests can be accepted while the agent works. Optional `attachments: [{"id":"att_…"}]` accepts uploaded files as specified below; no client paths or inline file bytes are accepted.
The proxy verifies successful receipts contain a positive submission ID, a known
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. Attached sends also require matching ordered `attachmentIds` in the receipt.
Invalid receipts return 502; delivery remains uncertain, so retry using the original
`requestId` to avoid creating a duplicate message.
### `GET /v1/agent/read-state`
### `PUT /v1/agent/read-state`
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z", "sourceId": "00000000-0000-4000-8000-000000000001" }`.
Initially: `{ "cursor": "0", "updatedAt": null, "sourceId": null }` (after source discovery the zero row also has its source UUID).
PUT takes `{ "cursor": "43", "sourceId": "00000000-0000-4000-8000-000000000001" }`; `sourceId` is optional for old clients.
A supplied source must match the bound runtime or the server returns 409. Both operations return the same additive response shape. The cursor is shared across this
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
a stale browser from lowering a cursor saved by another browser. Advance it only
after showing the corresponding history; poll it to reconcile unread indicators
across clients. Agent REST responses use `Cache-Control: no-store`.
Source-aware clients bind both history and queued acknowledgments to the first accepted read source. A missing/null source is a legacy binding once read state or history has been accepted, not permission to adopt a later UUID. A source mismatch, HTTP 409 or invalid/non-advancing PUT acknowledgment stops old read writers and requires an explicit fresh history load; old cursors must never be relabeled. Known-source writes include `sourceId`; legacy writes omit it. Client reload does not reset server read state. See the source-binding rules in the streaming contract.
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
### Agent attachments (v1)
`POST /v1/agent/attachments`: multipart/form-data with exactly one `file` part (original UTF-8 filename in Content-Disposition) and one `uploadId` field (client-generated UUID, stable across retries). Either part order is accepted. Request limit 8 MiB + 64 KiB. 201 new / 200 identical retry:
```json
{"attachment":{"id":"att_12345678-1234-4234-8234-123456789abc","kind":"text","filename":"notes.txt","mimeType":"text/plain","sizeBytes":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","workspacePath":"/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"}}
```
`workspacePath` is OPTIONAL for clients, canonical absolute runtime path when supplied. Never a host path, URL, or request parameter. Descriptors must retain filename/mimeType/sizeBytes/sha256. `kind` is `image`, `text`, or `file`. IDs are opaque. UUID retries must match normalized filename, effective MIME type and bytes, otherwise 409 `idempotency_conflict`. Filename is NFC normalized, 1–255 UTF-8 bytes, no slash/backslash/control characters, not `.`/`..`. A leading dot becomes `_` in the on-disk basename only; display filename remains unchanged.
`POST /v1/agent/messages`:
```json
{"content":"Read these notes","requestId":"client-stable-id","attachments":[{"id":"att_12345678-1234-4234-8234-123456789abc"}]}
```
`content` is required, <=40,000 characters; empty/whitespace is allowed with attachments. `requestId` is 1–256 characters and must survive retries. Omit attachments for text-only requests. At most 8 distinct references, 32 MiB total original bytes. References contain ONLY `id`; filenames, paths, URLs, base64 and forged workspacePath are rejected. Root delivery uses channel sybil, followUp and wait=false. 202 receipt:
```json
{"id":123,"status":"queued","messageId":"inbound:sybil:client-stable-id","attachmentIds":["att_12345678-1234-4234-8234-123456789abc"]}
```
Receipt status also permits placed/done/unanswered. `attachmentIds` is present for attached input, in request order, and MUST be checked before treating delivery as acknowledged. Caption + ordered IDs are durably bound to requestId; conflicting replay 409. Native admission atomically stores the input payload, binding, quota charge and worker eligibility; a competing text-only input cannot falsely acknowledge attachment IDs. Network errors may be retried with the same request/upload IDs. Never auto-create a fresh ID on ambiguous failure.
History, replay and queued activity use the existing public message schema with `metadata.agentAttachments` containing these descriptors. `content` is the original caption, or `Attached: notes.txt.` for a file-only message. No model-only manifest, text preview or base64 is inserted into public message content. Ordinary chat's `metadata.attachments` schema is unchanged. Raw tool rows stay in the protocol; client transcript filters stay unchanged.
`GET /v1/agent/attachments/:id/content`: authenticated original bytes, Content-Length, Content-Disposition attachment (safe ASCII filename and UTF-8 filename*), Cache-Control private/no-store and nosniff. No URLs containing tokens, public static mount, redirects or inline serving. Browser previews use authenticated fetch + revocable blob URL. Native downloads hold one of four slots through response finish/close/error, with a 30-second outbound timeout and disconnect cleanup. Raster types retain their validated MIME type; text downloads use text/plain; other files application/octet-stream. Clients can display/copy workspacePath as text.
### Attachment capability and errors
Optional `GET /v1/agent` field:
```json
{"capabilities":{"attachments":{"version":1,"maxCount":8,"maxTotalBytes":33554432,"maxImageBytes":6291456,"maxFileBytes":8388608,"imageMimeTypes":["image/png","image/jpeg","image/gif","image/webp"],"maxImagePixels":50000000,"maxImageDimension":16384,"quotaBytes":1073741824,"usedBytes":0,"remainingBytes":1073741824,"maxConcurrentUploads":2,"maxConcurrentDecodes":1}}}
```
Quota values are a snapshot, not a reservation. Absence disables attachment selection and sending. Backend rejects attachments against an old/unsafe upstream rather than allowing dropped references. Sybil also requires ADMIN_TOKEN for attachments; text-only open development behavior remains compatible.
Errors: `{ "error": true, "message": "...", "statusCode": 507, "code": "attachment_quota_exceeded", "quotaBytes": 1073741824, "remainingBytes": 1200, "requiredBytes": 20000 }`. Quota numbers are included for 507; no host filesystem information. 400 invalid_upload/invalid_attachment; 401 authentication; 404 attachment_not_found; 409 idempotency_conflict; 413 attachment_limit_exceeded; 415 unsupported_media_type; 422 invalid_image/image_model_unsupported; 429 attachments_busy with Retry-After: 2; 503 attachments_unavailable; 507 attachment_quota_exceeded; 502 unavailable/invalid upstream at proxy. Clients show message and retain retry identity. Remove from composer does not delete completed durable uploads.
### Runtime access and release requirements
The runtime resolves IDs to persistent `WORKSPACE_DIR/uploads/<id>/<safe-original-basename>` files (default `/data/workspace/uploads/...`), backed by the existing `/data` mount. Images are native model image blocks, never remote links: supported PNG/JPEG/GIF/WebP originals are at most 6 MiB and are prepared as a bounded static first frame (orientation corrected, at most 2000×2000 and 4.5 MiB base64, further limited by the actual model). Reject unsupported image models; no arbitrary document/file provider blocks. Other files are at most 8 MiB and are available through actual workspace read/bash tools. Valid recognized UTF-8 text receives a bounded preview of 16,000 Unicode characters per file / 64,000 per input. PDF/archive/binary files are paths plus metadata. Original hash, size and confinement are validated during resolution. Root and workers share the same workspace; explicit `subagent` spawn/send `attachmentIds` persist in durable Reporter tasks and use the same resolver. Preparation saturation retries through a durable checkpoint after two seconds using the same Reporter request ID. Permanent admission failures, including quota exhaustion, reach the parent through the durable worker-report path. Uploads never auto-execute.
Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reservations cover originals, static renditions, staging, metadata and conservative attachment input copies. Quota is conservative accounting, not free disk space. No complete upload expiry or delete endpoint is enabled. Removed composer files stay stored; crashed/uncertain reservations stay charged across restart until deliberate operator reconciliation. Ordinary authorized workspace tool writes and unrelated conversation history are outside attachment-ingestion accounting. Runtime uploads default to two concurrently (`SYBIL_AGENT_ATTACHMENT_UPLOADS`, max8), image decodes one (`SYBIL_AGENT_ATTACHMENT_DECODES`, max4); bounded input preparation and proxy transfers return429 when saturated. Disconnected proxy transfers cancel upstream work and release slots.
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
## Agent push notifications (v1)
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
### `GET /v1/agent/push/config`
```json
{"version":1,"enabled":false,"apnsAvailable":false,"webPushAvailable":false,"sourceId":null,"quarantined":false,"policy":{"promptGraceMs":5000,"debounceMs":2000,"interactionGraceMs":30000,"presenceHeartbeatMs":15000,"presenceLeaseMs":60000}}
```
`vapidPublicKey` is present only when Web Push is enabled and its credentials are valid. No private keys, tokens, paths or configured account identifiers are returned. Source discovery and browser presence run independently of transport enablement/installation consent. Clients bind local consent to a known source UUID as well as the authenticated backend; null/unknown source cannot opt in. A source change retires prior local consent/installation and requires a new UUID plus explicit user opt-in, rechecking the source after asynchronous permission/subscription/registration work. `sourceId` is the persisted bound runtime UUID or null before discovery. A changed runtime source or regressed durable cursor quarantines delivery and cancels pending alerts without resetting the canonical read cursor. There is deliberately no HTTP read-reset/rebaseline endpoint; an operator must reconcile replacement runtime data with source-aware clients before enabling a new baseline.
### `PUT /v1/agent/push/installations/:installationId`
Use a stable random UUID per local installation, authenticated with the current shared token. Registration is explicit user opt-in and requires enabled/configured transport. Native body:
```json
{"transport":"apns","enabled":true,"token":"hex-device-token","environment":"production","topic":"net.buzzert.sybil2"}
```
Device tokens must be even-length hexadecimal, 32–512 characters; use the exact token returned by the OS. The server pins permitted topic and environment. TestFlight/App Store normally use production; local development must match the signed `aps-environment` entitlement. Tokens rotate: upsert the current token after registration callbacks and each opted-in launch. App Store upload passwords cannot authenticate APNs.
Web body (the keys and endpoint come from the browser's actual `PushSubscription`):
```json
{"transport":"web","enabled":true,"subscription":{"endpoint":"https://web.push.apple.com/provider-path","expirationTime":null,"keys":{"p256dh":"base64url-P256-public-key","auth":"base64url-16-byte-auth-secret"}}}
```
Subscription keys are curve/size validated. Endpoints must use HTTPS, port 443, no userinfo/fragment, and the standard allowlisted Apple (`web.push.apple.com` or one-label `*.push.apple.com`), Google (`fcm.googleapis.com`) or Mozilla (`updates.push.services.mozilla.com`) provider hosts. HTTP redirects are never followed; DNS addresses are checked at connection time to reject private/reserved addresses. Other providers require a reviewed server allowlist change. There is no arbitrary push URL fetch.
Response: `{ "installationId":"uuid", "transport":"apns|web", "enabled":true, "version":1 }`. No endpoint/token/key is reflected. Identical registrations retain the same version. Rotation increments the version and cancels old pending deliveries; a provider rejection of an old version cannot disable a newer one. A single endpoint is active under at most one installation ID; rebinding it irreversibly retires every prior non-retired UUID for that endpoint, including provider-disabled and prior credential-generation registrations. A provider-invalid UUID may refresh only until a newer installation claims its endpoint. A PUT to an explicitly retired UUID returns 409. At most 64 opted-in installations are allowed (429 when full).
### `DELETE /v1/agent/push/installations/:installationId`
Idempotent authenticated 204. Irreversibly retires the UUID, including an unknown UUID (durable tombstone), removes stored transport payload, increments its generation once and cancels pending deliveries. Late/delayed PUTs cannot re-enable a retired UUID, including after restart. A new explicit user opt-in must mint a **new UUID**; rotations may continue using the still-active UUID. Provider-invalid disabled endpoints are separate from explicit retirement and may re-register their active UUID. Clients unregister before discarding their bearer on logout and unsubscribe locally. Offline logout must persist a local revocation tombstone for a later authenticated retry and must never silently re-enable consent. Retry that tombstone only against its original backend and credential authority; a 204 from a different backend does not acknowledge the old revocation. Persist authority digests and installation IDs, never raw credentials. Changing source under the same authority can still revoke the old UUID. A transport request already crossing the provider boundary cannot be recalled reliably.
### `PUT /v1/agent/presence/:sessionId`
A UUID per browser document/tab, independent of notification permission and selected workspace tab:
```json
{"sequence":12,"visibility":"visible","focused":true,"interacted":false}
```
Response `{ "expiresAt":"2026-10-15T03:00:00.000Z" }`. Sequence is a nonnegative signed 32-bit integer; stale/out-of-order heartbeats neither overwrite state nor extend the lease. 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.
`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.
### Scheduling, read reconciliation and delivery identities
Only complete user-visible root assistant messages enter the durable outbox. Tool output, model reasoning, worker envelopes and raw events do not. A runtime external event or timer may result in a later qualifying assistant message. First source discovery bootstraps at the current high-water cursor without historical alerts. Checkpoint advancement and enqueue are atomic. Latest accepted human input, including queued input and runtime-native channels, delays alerts for at least five seconds; backend prompt admission also records a durable five-second guard before proxying, covering a concurrent source snapshot (a failed submission only delays briefly). Bursts debounce for two seconds, with a 15-second burst cap subject to the prompt grace. Timer/external-event results and replies arriving at least 30 seconds after a prompt receive higher queue priority; coalescing keeps the latest message identity and preserves the burst's higher priority.
All web sessions must be inactive at dispatch, including the runtime-native browser. Before each provider request, the backend refreshes runtime read/prompt/presence, merges native read into the canonical shared read row, and transactionally rechecks opt-in/version, owner generation, source freshness, read cursor and all attention leases. Unknown/malformed/unavailable source data suppresses sends. Messages expire after five minutes; provider TTL is at most 60 seconds. The active outbox coalesces per installation; completed receipts are capped at 10,000 and expire after seven days, while revocation tombstones remain durable. There are at most four attempts, only after definite retryable provider rejections, with bounded backoff. A persisted send intent protects crash recovery: interrupted/ambiguous network sends are never automatically replayed. Exactly-once provider/device delivery is not promised; a final focus/read race after dispatch remains possible.
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.
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.
### 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.
+117 -1
View File
@@ -1,6 +1,6 @@
# Streaming Chat API Contract
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
Endpoint:
- `POST /v1/chat-completions/stream`
@@ -15,6 +15,7 @@ Transport:
Authentication:
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
- The web client checks [`GET /v1/auth/session`](rest.md#get-v1authsession) before opening streams. An initial missing-token response prompts sign-in without an error; an incorrect supplied token is an authentication failure. The web sign-in form has no tokenless bypass; open development servers still authenticate automatically. Stream authorization requirements are unchanged.
## Chat Completion Request Body
@@ -109,6 +110,7 @@ Request body:
```
Behavior notes:
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
- `question` is required, trimmed by the server, and must not be empty.
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
@@ -274,3 +276,117 @@ event: done
data: {"type":"done","text":"Hello world"}
```
## Sybil Agent WebSocket
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
connected after each assistant turn and receives later background results. Sending
messages uses `POST /v1/agent/messages`.
The browser must send this first frame within five seconds:
```json
{ "type": "auth", "token": "Sybil token", "after": "43" }
```
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
the last successfully merged cursor. Invalid credentials close with 4401, malformed
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
browser credentials are not forwarded to the agent.
Native `pi-room.v1` frames are forwarded after authentication. This protocol
identifier is retained for compatibility with existing clients:
```json
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
```
```json
{
"type": "messages",
"conversationId": "1",
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
"cursor": "44",
"beforeCursor": "44",
"hasMore": false
}
```
```json
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
```
Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
socket messages are upserts keyed by stable `id`. Save each returned cursor after
applying its entire frame, even when it contains no visible messages.
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.
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
cursor. Missing agent configuration closes with 4503.
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
History remains durable in Sybil Agent. Shared read position is separate from delivery:
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
### Agent attachment history, queued activity and replay
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
```json
{
"id": "inbound:sybil:client-stable-id",
"role": "user",
"content": "Read these notes",
"createdAt": "2026-10-14T18:00:00.000Z",
"metadata": {
"kind": "message",
"channel": "sybil",
"status": "queued",
"agentAttachments": [{
"id": "att_12345678-1234-4234-8234-123456789abc",
"kind": "text",
"filename": "notes.txt",
"mimeType": "text/plain",
"sizeBytes": 12,
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"workspacePath": "/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"
}]
}
}
```
`workspacePath` is optional, authoritative runtime text for display/copy, never a download URL or send parameter. Only `attachments:[{id}]` goes into POST messages. Committed/replayed messages retain the same ordered descriptors and original caption; file-only content is `Attached: notes.txt.`. Internal model manifests/previews and native base64 are excluded from public user message content. Root/worker native inputs and raw authorized model endpoints may contain the real image blocks; native token auth must protect all of them.
Merge queued and committed messages by stable message ID. A matching ID alone does not confirm an attachment-bearing optimistic message: compare the ordered `metadata.agentAttachments[].id` against its local IDs as well. Retain descriptors, error and retry identity on a mismatch or uncertain receipt; show the error inline for touch clients. Matching canonical echoes can acknowledge delivery even if HTTP was lost. Preserve raw tool rows/cursors in client room state and existing transcript filtering. Old clients can ignore the additive metadata and still see a meaningful caption.
Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract.
Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path.
### Agent attention and shared read events (push v1)
Web auth frames 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.
The backend adds this optional server-to-client frame after a successful monotonic read advance, including a runtime-native browser acknowledgment merged into the shared row:
```json
{"type":"read_state","cursor":"44","updatedAt":"2026-10-15T03:00:00.000Z","sourceId":"00000000-0000-4000-8000-000000000001"}
```
`sourceId` is nullable before source discovery and is never silently rebound after runtime replacement. Source replacement quarantines push delivery; the backend does not reset old read cursors. Apply read cursors monotonically within their source, and only clear notifications whose payload source matches this frame and whose cursor is at/below it. Frames without a source remain usable by legacy unread indicators but cannot safely authorize notification clearing. Clients still poll `GET /v1/agent/read-state` on foreground/reconnect to recover missed frames; receiving history, activity, a push, or a notification tap does not acknowledge that history was viewed.
Distinguish an unbound room from one already bound to a legacy absent/null source or a source UUID. Bind on the first accepted read state; if history arrives first, pin the legacy state before publishing it. A different source in a later GET, stream frame or PUT acknowledgment, including null-to-UUID or UUID-to-null, must stop stale read writes and require an explicit fresh room load. Preserve the old snapshot and cursors until that reload, then fetch read state before fresh history; never attach a new UUID to an old cursor. Ordinary foreground/network recovery must not reset this stopped room. HTTP 409, an invalid acknowledgment cursor or an acknowledgment below the requested cursor also stops the writer. A transient transport failure can retry only within the same pinned source. None of these client reloads resets server read state.
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs; its unknown sessions participate in the same conservative veto. No Web Push is used for invisible read synchronization.
+4
View File
@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SybilAPNSEnvironment</key>
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
<key>UIBackgroundModes</key>
<array><string>remote-notification</string></array>
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
<key>NSSupportsAutomaticTermination</key>
<false/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+32 -1
View File
@@ -1,6 +1,7 @@
import Sybil
import SwiftUI
import UIKit
import UserNotifications
@main
struct SybilApp: App
@@ -18,11 +19,13 @@ struct SybilApp: App
}
@MainActor
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
final class SybilAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
SybilPushNotifications.launchFixtureIfRequested()
SybilHomeScreenQuickActionHandler.configureQuickActions()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.start()
@@ -30,6 +33,31 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
SybilPushNotifications.didRegister(deviceToken: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
SybilPushNotifications.didFailToRegister()
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
SybilPushNotifications.receiveBackground(userInfo: userInfo) { result in completionHandler(result) }
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
let event = SybilPushNotifications.Event(userInfo: notification.request.content.userInfo)
return await SybilPushNotifications.presentationOptions(event: event)
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
let event = SybilPushNotifications.Event(userInfo: response.notification.request.content.userInfo)
await SybilPushNotifications.didRespond(event: event, identifier: response.notification.request.identifier)
}
}
#if targetEnvironment(macCatalyst)
func applicationWillTerminate(_ application: UIApplication) {
SybilMacQuickQuestionController.shared.stop()
@@ -73,6 +101,9 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
}
if let response = connectionOptions.notificationResponse, response.actionIdentifier == UNNotificationDefaultActionIdentifier {
SybilPushNotifications.didRespond(userInfo: response.notification.request.content.userInfo, identifier: response.notification.request.identifier)
}
}
#if targetEnvironment(macCatalyst)
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
</dict>
</plist>
+12
View File
@@ -5,6 +5,12 @@ targets:
- iOS
- macCatalyst
deploymentTarget: "18.0"
attributes:
SystemCapabilities:
com.apple.Push:
enabled: 1
com.apple.BackgroundModes:
enabled: 1
sources:
- Sources
- Resources
@@ -25,6 +31,9 @@ targets:
PRODUCT_MODULE_NAME: SybilApp
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Apps/Sybil/Sybil.entitlements
"CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]": Apps/Sybil/Sybil.entitlements
INFOPLIST_KEY_SybilAPNSEnvironment: "$(SYBIL_APNS_ENVIRONMENT)"
SWIFT_VERSION: 6.0
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
@@ -41,7 +50,10 @@ targets:
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
configs:
Debug:
SYBIL_APNS_ENVIRONMENT: development
Release:
SYBIL_APNS_ENVIRONMENT: production
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: Apple Distribution
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
+2 -1
View File
@@ -24,7 +24,8 @@ let package = Package(
name: "Sybil",
dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui")
]),
],
resources: [.process("Resources")]),
.testTarget(
name: "SybilTests",
dependencies: ["Sybil"]
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "character-still.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -11,6 +11,7 @@ public struct SplitView: View {
@State private var hasPendingQuickQuestionPresentation = false
@State private var isQuickQuestionPresented = false
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
@State private var notificationSceneID = UUID()
private var keyboardActions: SybilKeyboardActions? {
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
@@ -92,8 +93,14 @@ public struct SplitView: View {
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
await viewModel.bootstrap()
SybilPushNotifications.foregroundFixtureIfRequested()
viewModel.notifications.setScene(notificationSceneID, active: scenePhase == .active)
routePendingNotification()
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.notifications.pendingRouteID) { _, _ in routePendingNotification() }
.onChange(of: viewModel.notifications.scope) { _, _ in routePendingNotification() }
.onDisappear { viewModel.notifications.setScene(notificationSceneID, active: false) }
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
guard request > 0 else {
return
@@ -101,17 +108,24 @@ public struct SplitView: View {
queueQuickQuestionPresentation()
}
.onChange(of: viewModel.isCheckingSession) { _, _ in
routePendingNotification()
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: isQuickQuestionPresented) { _, presented in
viewModel.agent.isModalPresented = presented
}
.onChange(of: scenePhase) { _, nextPhase in
viewModel.notifications.setScene(notificationSceneID, active: nextPhase == .active)
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
SybilPushNotifications.foregroundFixtureIfRequested()
routePendingNotification()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
@@ -135,6 +149,13 @@ public struct SplitView: View {
}
}
private func routePendingNotification() {
guard scenePhase == .active, viewModel.consumeAgentNotificationRoute() else { return }
hasPendingQuickQuestionPresentation = false
isQuickQuestionPresented = false
columnVisibility = .detailOnly
}
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
return size.width < size.height ? .showSidebar : .hidden
}
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit
struct APIConfiguration: Sendable {
var baseURL: URL
@@ -17,7 +18,7 @@ struct AnyEncodable: Encodable {
}
}
actor SybilAPIClient: SybilAPIClienting {
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClienting {
private let configuration: APIConfiguration
private let session: URLSession
@@ -40,6 +41,158 @@ actor SybilAPIClient: SybilAPIClienting {
self.session = session
}
func agentDescriptor() async throws -> AgentDescriptor {
var value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
throw AgentFailure(message: "Unsupported Agent room protocol.")
}
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { value.capabilities?.attachments = nil }
return value
}
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
var query = [URLQueryItem(name: "limit", value: "200")]
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
try page.validate()
return page
}
func agentReadState() async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
}
func agentPushConfig() async throws -> AgentPushConfig {
let data = try await pushRequest(path: "/v1/agent/push/config", method: "GET")
guard let config = try? JSONDecoder().decode(AgentPushConfig.self, from: data) else { throw APIError.invalidResponse }
return config
}
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
let data = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "PUT", body: AnyEncodable(registration))
guard let receipt = try? JSONDecoder().decode(AgentPushReceipt.self, from: data) else { throw APIError.invalidResponse }
return receipt
}
func agentRevokePush(id: UUID) async throws {
_ = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "DELETE")
}
private func pushRequest(path: String, method: String, body: AnyEncodable? = nil) async throws -> Data {
#if DEBUG && targetEnvironment(simulator)
let allowLoopback = true // Local URLProtocol/UI fixtures; no device enrollment.
#else
let allowLoopback = false
#endif
guard AgentPushScope.canonicalBackend(configuration.baseURL, allowLoopback: allowLoopback) != nil,
configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw APIError.invalidBaseURL }
var request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
request.timeoutInterval = 8
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
// Same bounded, redirect-rejecting authenticated Agent transport as attachments.
// Never decode/log a server error body that might reflect an address or token.
let (data, status, _) = try await attachmentBytes(request, maximum: 64 * 1024)
guard method == "DELETE" ? status == 204 : (200...299).contains(status) else { throw AgentPushRequestError(status: status) }
return data
}
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor, sourceID: nil)
}
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
var body = ["cursor": try AgentCursor.normalize(cursor)]
if let sourceID {
guard AgentPushPayload.validSource(sourceID) else { throw APIError.invalidResponse }
body["sourceId"] = sourceID
}
return try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(body), responseType: AgentReadState.self)
}
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
try receipt.validate(requestID: requestID)
return receipt
}
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
if !submission.attachmentIDs.isEmpty {
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
guard submission.attachmentIDs.count <= limits.countLimit else { throw AgentAttachmentIssue.local("Too many attachments for this Agent.", code: "attachment_limit_exceeded") }
}
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(submission), responseType: AgentReceipt.self)
try receipt.validate(requestID: submission.requestId, attachmentIDs: submission.attachmentIDs)
return receipt
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
let prepared = try AgentAttachmentPreparation.prepare(file.data, filename: file.filename, mimeType: file.mimeType, limits: limits)
let multipart = try AgentMultipart(file: prepared, uploadID: uploadID)
var request = try makeRequest(path: "/v1/agent/attachments", method: "POST", body: nil, acceptsSSE: false)
request.timeoutInterval = 120
request.setValue("multipart/form-data; boundary=\(multipart.boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = multipart.body
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: 64 * 1024)
guard status == 200 || status == 201 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
struct UploadResponse: Decodable { var attachment: AgentAttachment }
guard let response = try? JSONDecoder().decode(UploadResponse.self, from: data) else {
throw AgentAttachmentIssue.local("Invalid attachment upload receipt. Retry to confirm.", code: "invalid_upstream_response")
}
let attachment = response.attachment
try attachment.validate()
guard attachment.filename == prepared.filename, attachment.mimeType == prepared.mimeType,
attachment.sizeBytes == prepared.data.count, attachment.sha256 == prepared.sha256 else {
throw AgentAttachmentIssue.local("The upload receipt does not match the selected file.", code: "invalid_upstream_response")
}
return attachment
}
func agentDownload(_ attachment: AgentAttachment) async throws -> Data {
try attachment.validate()
guard configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw AgentAttachmentIssue.unavailable }
var request = try makeRequest(path: "/v1/agent/attachments", method: "GET", body: nil, acceptsSSE: false)
request.url = try configuration.agentAttachmentContentURL(id: attachment.id)
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: attachment.sizeBytes)
guard status == 200 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
guard data.count == attachment.sizeBytes,
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == attachment.sha256 else {
throw AgentAttachmentIssue.local("The downloaded original failed its size or checksum check. Retry the download.", code: "invalid_upstream_response")
}
return data
}
private func attachmentBytes(_ request: URLRequest, maximum: Int) async throws -> (Data, Int, String?) {
let (stream, response) = try await session.bytes(for: request, delegate: AgentAttachmentRedirectPolicy())
defer { stream.task.cancel() }
guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse }
let success = (200...299).contains(response.statusCode)
let limit = success ? maximum : 64 * 1024
guard response.expectedContentLength <= Int64(limit) else {
throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response")
}
var data = Data()
for try await byte in stream {
try Task.checkCancellation()
guard data.count < limit else { throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response") }
data.append(byte)
}
return (data, response.statusCode, response.value(forHTTPHeaderField: "Retry-After"))
}
func agentSocket(after: String) async throws -> any AgentSocket {
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
task.maximumMessageSize = 8 * 1024 * 1024
task.resume()
let socket = AgentURLSocket(task: task)
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
catch { await socket.close(); throw error }
return socket
}
func verifySession() async throws -> AuthSession {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
}
@@ -49,6 +202,15 @@ actor SybilAPIClient: SybilAPIClienting {
return response.items
}
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
try await request(
"/v1/workspace-items",
method: "GET",
queryItems: [URLQueryItem(name: "q", value: query)],
responseType: WorkspaceSearchResponse.self
)
}
func listChats() async throws -> [ChatSummary] {
let response = try await request("/v1/chats", method: "GET", responseType: ChatListResponse.self)
return response.chats
@@ -74,6 +236,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func forkChat(chatID: String, messageID: String? = nil) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/fork",
method: "POST",
body: AnyEncodable(ChatForkBody(messageId: messageID)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)",
@@ -280,9 +452,10 @@ actor SybilAPIClient: SybilAPIClienting {
_ path: String,
method: String,
body: AnyEncodable? = nil,
queryItems: [URLQueryItem] = [],
responseType: Response.Type
) async throws -> Response {
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
SybilLog.debug(
SybilLog.network,
@@ -293,7 +466,7 @@ actor SybilAPIClient: SybilAPIClienting {
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
(data, response) = try await session.data(for: request, delegate: path.hasPrefix("/v1/agent") ? AgentAttachmentRedirectPolicy() : nil)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
SybilLog.debug(
@@ -307,6 +480,10 @@ actor SybilAPIClient: SybilAPIClienting {
throw wrapped
}
if path.hasPrefix("/v1/agent"), let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode),
(try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue?["code"]?.stringValue != nil {
throw AgentAttachmentIssue.response(data, status: http.statusCode, retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
}
try validate(response: response, data: data)
do {
@@ -334,12 +511,14 @@ actor SybilAPIClient: SybilAPIClienting {
path: String,
method: String,
body: AnyEncodable?,
acceptsSSE: Bool
acceptsSSE: Bool,
queryItems: [URLQueryItem] = []
) throws -> URLRequest {
let url = try buildURL(path: path)
let url = try buildURL(path: path, queryItems: queryItems)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 120
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
if acceptsSSE {
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
@@ -359,7 +538,7 @@ actor SybilAPIClient: SybilAPIClienting {
return request
}
private func buildURL(path: String) throws -> URL {
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
throw APIError.invalidBaseURL
}
@@ -370,6 +549,9 @@ actor SybilAPIClient: SybilAPIClienting {
basePath.removeLast()
}
components.path = "\(basePath)/\(trimmedPath)"
if !queryItems.isEmpty {
components.queryItems = (components.queryItems ?? []) + queryItems
}
guard let url = components.url else {
throw APIError.invalidBaseURL
@@ -704,6 +886,10 @@ private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct ChatForkBody: Encodable {
var messageId: String?
}
private struct StarUpdateBody: Encodable {
var starred: Bool
}
@@ -3,6 +3,7 @@ import Foundation
protocol SybilAPIClienting: Sendable {
func verifySession() async throws -> AuthSession
func listWorkspaceItems() async throws -> [WorkspaceItem]
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse
func listChats() async throws -> [ChatSummary]
func createChat(
title: String?,
@@ -11,6 +12,7 @@ protocol SybilAPIClienting: Sendable {
messages: [CompletionRequestMessage]?
) async throws -> ChatSummary
func getChat(chatID: String) async throws -> ChatDetail
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary
func deleteChat(chatID: String) async throws
@@ -0,0 +1,62 @@
import Foundation
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
actor AgentAttachmentDownloads {
private var active = 0
private var invalidated = false
private var transfers: [UUID: Task<Data, any Error>] = [:]
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
private var cache: [AgentAttachment: Data] = [:]
private var order: [AgentAttachment] = []
private var byteCount = 0
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
if let value = cache[attachment] { return value }
try await acquire()
defer { release() }
try Task.checkCancellation()
if let value = cache[attachment] { return value }
guard !invalidated else { throw CancellationError() }
let id = UUID()
let transfer = Task { try await client.agentDownload(attachment) }
transfers[id] = transfer
defer { transfers[id] = nil }
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
let oldest = order.removeFirst()
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
}
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
return value
}
func invalidate() {
invalidated = true
transfers.values.forEach { $0.cancel() }
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
waiters = []; cache = [:]; order = []; byteCount = 0
}
private func acquire() async throws {
guard !invalidated else { throw CancellationError() }
if active < 2 { active += 1; return }
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
else { waiters.append((id, continuation)) }
}
} onCancel: { Task { await self.cancel(id) } }
}
private func cancel(_ id: UUID) {
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
waiters.remove(at: index).1.resume(throwing: CancellationError())
}
private func release() {
if waiters.isEmpty { active -= 1 }
else { waiters.removeFirst().1.resume() }
}
}
@@ -0,0 +1,219 @@
import CryptoKit
import Foundation
import Observation
struct AgentDraftItem: Codable, Equatable, Identifiable, Sendable {
enum State: String, Codable, Sendable { case loading, uploading, ready, failed }
var id: String = UUID().uuidString // Stable multipart uploadId, including across relaunch.
var filename: String
var mimeType: String?
var sizeBytes: Int?
var state: State = .loading
var attachment: AgentAttachment?
var issue: AgentAttachmentIssue?
}
struct AgentDraftSnapshot: Codable, Sendable {
var caption = ""
var items: [AgentDraftItem] = []
var id: String? = nil // Optional for drafts saved before durable outbox handoff tracking.
}
struct AgentDraftStorage: Sendable {
var read: @Sendable () throws -> AgentDraftSnapshot
var write: @Sendable (AgentDraftSnapshot) throws -> Void
var stage: @Sendable (String, Data) throws -> Void
var bytes: @Sendable (String) throws -> Data
var remove: @Sendable (String) -> Void
static func file(baseURL: URL, root: URL = .applicationSupportDirectory) -> Self {
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
let directory = root.appendingPathComponent("AgentAttachmentDrafts/\(key)", isDirectory: true)
let manifest = directory.appendingPathComponent("draft.json")
let file: @Sendable (String) throws -> URL = { id in
guard UUID(uuidString: id) != nil else { throw AgentAttachmentIssue.local("Invalid staged upload identity.") }
return directory.appendingPathComponent(id + ".bytes")
}
let write: @Sendable (Data, URL) throws -> Void = { data, destination in
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
}
return Self(read: {
guard FileManager.default.fileExists(atPath: manifest.path) else { return AgentDraftSnapshot() }
let data = try Data(contentsOf: manifest)
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
let snapshot = try JSONDecoder().decode(AgentDraftSnapshot.self, from: data)
guard snapshot.items.count <= 8, Set(snapshot.items.map(\.id)).count == snapshot.items.count,
snapshot.items.allSatisfy({ UUID(uuidString: $0.id) != nil }) else { throw AgentAttachmentIssue.local("The saved attachment draft is invalid.") }
return snapshot
}, write: { snapshot in
let data = try JSONEncoder().encode(snapshot)
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
try write(data, manifest)
}, stage: { id, data in
guard data.count <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
try write(data, file(id))
}, bytes: { id in
let url = try file(id)
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
return try Data(contentsOf: url)
}, remove: { id in if let url = try? file(id) { try? FileManager.default.removeItem(at: url) } })
}
}
@MainActor @Observable
final class AgentAttachmentDraft {
private(set) var id = UUID().uuidString
var caption = "" { didSet { save() } }
private(set) var items: [AgentDraftItem] = []
private(set) var capability: AgentAttachmentCapabilities?
private(set) var error: String?
private(set) var persistenceAvailable = true
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentDraftStorage?
@ObservationIgnored private var prepared: [String: AgentPreparedAttachment] = [:]
@ObservationIgnored private var loaders: [String: @Sendable () async throws -> AgentPreparedAttachment] = [:]
@ObservationIgnored private var worker: Task<Void, Never>?
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var restoring = false
@ObservationIgnored private var active = true
var supportsAttachments: Bool { capability?.supported == true && persistenceAvailable }
var ready: [AgentAttachment] { items.compactMap(\.attachment) }
var canSend: Bool {
caption.utf16.count <= 40_000 && (!caption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !items.isEmpty)
&& (items.isEmpty || (supportsAttachments && items.allSatisfy { $0.state == .ready && $0.attachment != nil }))
&& persistenceAvailable
}
func configure(client: any AgentAPIClienting, storage: AgentDraftStorage?) {
suspend()
self.client = client; self.storage = storage
prepared = [:]; loaders = [:]; capability = nil; error = nil; persistenceAvailable = true
restoring = true
defer { restoring = false; active = true }
do {
let saved = try storage?.read() ?? AgentDraftSnapshot()
id = saved.id ?? UUID().uuidString
caption = saved.caption
items = saved.items.map { item in
var item = item
if item.state != .ready {
item.state = .failed
item.issue = .local("Upload interrupted. Retry with the same upload ID. If selection did not finish, remove and select the file again.", code: "upload_interrupted")
}
return item
}
} catch {
caption = ""; items = []; persistenceAvailable = false
self.error = "Could not restore attachment draft: \(error.localizedDescription)"
}
}
func updateCapability(_ value: AgentAttachmentCapabilities?) {
capability = value?.supported == true ? value : nil
if capability == nil { suspend(); active = true }
}
func resume() { active = true; pump() }
func suspend() {
active = false; generation = UUID(); worker?.cancel(); worker = nil
for index in items.indices where items[index].state == .loading || items[index].state == .uploading {
items[index].state = .failed
items[index].issue = .local("Transfer interrupted. Retry to continue.", code: "upload_interrupted")
}
save()
}
@discardableResult
func add(filename: String, load: @escaping @Sendable () async throws -> AgentPreparedAttachment) -> String? {
guard supportsAttachments, let capability else { error = AgentAttachmentIssue.unavailable.localizedDescription; return nil }
guard items.count < capability.countLimit else { error = "You can attach up to \(capability.countLimit) files."; return nil }
let item = AgentDraftItem(filename: filename)
items.append(item); loaders[item.id] = load; error = nil
save(); pump()
return item.id
}
func remove(_ id: String) {
// Restart the serial worker; completion from the old generation can never reinsert a removed item.
let interrupted = items.filter { $0.state == .loading || $0.state == .uploading }.map(\.id)
generation = UUID(); worker?.cancel(); worker = nil
items.removeAll { $0.id == id }; loaders[id] = nil; prepared[id] = nil
save()
if persistenceAvailable { storage?.remove(id) }
for other in interrupted where other != id {
if let index = items.firstIndex(where: { $0.id == other }) { items[index].state = .loading }
}
pump()
}
func retry(_ id: String) {
guard supportsAttachments else { error = AgentAttachmentIssue.unavailable.localizedDescription; return }
guard let index = items.firstIndex(where: { $0.id == id }), items[index].state == .failed else { return }
items[index].state = .loading; items[index].issue = nil; error = nil
save(); pump()
}
func validateReady() throws -> [AgentAttachment] {
guard items.isEmpty || supportsAttachments else { throw AgentAttachmentIssue.unavailable }
guard items.allSatisfy({ $0.state == .ready && $0.attachment != nil }) else { throw AgentAttachmentIssue.local("Wait for every attachment to finish, or retry/remove failed files.") }
if let capability { try capability.validate(ready) }
return ready
}
@discardableResult func clearAfterSubmission() -> Bool {
guard persistenceAvailable else { return false }
let nextID = UUID().uuidString
do { try storage?.write(AgentDraftSnapshot(id: nextID)) }
catch {
persistenceAvailable = false
self.error = "Message is saved in the outbox, but its draft could not be cleared. Reconnect before retrying: \(error.localizedDescription)"
return false
}
let ids = items.map(\.id)
generation = UUID(); worker?.cancel(); worker = nil
restoring = true; caption = ""; items = []; id = nextID; restoring = false
prepared = [:]; loaders = [:]
ids.forEach { storage?.remove($0) }
return true
}
func save() {
guard !restoring, persistenceAvailable else { return }
do { try storage?.write(AgentDraftSnapshot(caption: caption, items: items, id: id)) }
catch { persistenceAvailable = false; self.error = "Could not save attachment draft: \(error.localizedDescription)" }
}
private func pump() {
guard active, supportsAttachments, let client, let limits = capability, worker == nil,
let item = items.first(where: { $0.state == .loading }) else { return }
let run = generation
worker = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.worker = nil; self.pump() } }
do {
let file: AgentPreparedAttachment
if let existing = self.prepared[item.id] { file = existing }
else if let mime = item.mimeType, let storage = self.storage {
file = try AgentAttachmentPreparation.prepare(storage.bytes(item.id), filename: item.filename, mimeType: mime, limits: limits)
} else if let loader = self.loaders[item.id] { file = try await loader() }
else { throw AgentAttachmentIssue.local("Selection did not finish before the app closed. Remove this item and select it again.", code: "selection_unavailable") }
try Task.checkCancellation()
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
let otherBytes = self.items.filter { $0.id != item.id }.reduce(0) { $0 + ($1.sizeBytes ?? 0) }
guard otherBytes + file.data.count <= limits.totalLimit else { throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded") }
self.prepared[item.id] = file
try self.storage?.stage(item.id, file.data)
self.items[index].filename = file.filename; self.items[index].mimeType = file.mimeType
self.items[index].sizeBytes = file.data.count; self.items[index].state = .uploading
self.save()
guard self.persistenceAvailable else { throw AgentAttachmentIssue.local("Save failed. Reconnect before uploading.") }
let attachment = try await client.agentUpload(file: file, uploadID: item.id)
try attachment.validate()
guard attachment.filename == file.filename, attachment.mimeType == file.mimeType,
attachment.sizeBytes == file.data.count, attachment.sha256 == file.sha256 else {
throw AgentAttachmentIssue.local("Upload receipt does not match the original file. Retry to confirm.", code: "invalid_upstream_response")
}
try Task.checkCancellation()
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
self.items[index].attachment = attachment; self.items[index].state = .ready; self.items[index].issue = nil
self.save()
self.loaders[item.id] = nil; self.prepared[item.id] = nil
// Keep staged bytes until the message is durably handed to the outbox.
} catch {
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
self.items[index].state = .failed; self.items[index].issue = .wrapping(error); self.save()
}
}
}
}
@@ -0,0 +1,247 @@
import Foundation
import ImageIO
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
import UIKit
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
// PhotosPicker does not expose the original PHAsset filename without broader library access.
struct AgentPickedPhoto: Transferable, Sendable {
var data: Data
var filename: String?
var mimeType: String?
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(importedContentType: .image) { received in
let url = received.file
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
}
}
}
struct AgentAttachmentComposer<Prompt: View>: View {
@Bindable var room: SybilAgentRoom
@ViewBuilder var prompt: () -> Prompt
@State private var modalID = UUID()
@State private var pickerSession: UUID?
@State private var showChoices = false
@State private var showFiles = false
@State private var showPhotos = false
@State private var photos: [PhotosPickerItem] = []
private var modal: Bool { showChoices || showFiles || showPhotos }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !room.attachmentDraft.items.isEmpty {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 8) {
ForEach(room.attachmentDraft.items) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.filename).lineLimit(1)
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
.accessibilityLabel("Remove \(item.filename)")
}
if let attachment = item.attachment {
AgentAttachmentDetails(attachment: attachment)
} else {
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
.foregroundStyle(SybilTheme.textMuted)
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
}
if let issue = item.issue {
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
.accessibilityIdentifier("agent-upload-retry")
}
}
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
}
}
}.frame(maxHeight: 190)
}
if let error = room.attachmentDraft.error {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
HStack(alignment: .bottom, spacing: 10) {
if room.attachmentDraft.supportsAttachments {
SybilPromptAttachmentButton(
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
) {
pickerSession = room.attachmentSessionID
showChoices = true
}
.accessibilityIdentifier("agent-attach")
}
prompt()
}
}
.confirmationDialog("Add attachment", isPresented: $showChoices) {
Button("Photo Library") { showPhotos = true }
Button("Files") { showFiles = true }
Button("Cancel", role: .cancel) {}
}
.photosPicker(isPresented: $showPhotos, selection: $photos,
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
selectionBehavior: .ordered, matching: .images)
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
switch result {
case let .success(urls):
for url in urls {
room.attachmentDraft.add(filename: url.lastPathComponent) {
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
}
}
case let .failure(error):
if (error as NSError).code != NSUserCancelledError {
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
}
}
}
.onChange(of: photos) { _, selected in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
for item in selected {
room.attachmentDraft.add(filename: "Photo") {
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
}
return try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
}
}
}
photos = []
}
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
}
}
struct AgentAttachmentDetails: View {
var attachment: AgentAttachment
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
.foregroundStyle(SybilTheme.textMuted)
if let path = attachment.workspacePath {
Button {
UIPasteboard.general.string = path
} label: {
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
}
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
}
}.font(.sybil(.caption2))
}
}
struct AgentAttachmentCard: View {
var attachment: AgentAttachment
var room: SybilAgentRoom
@Environment(\.scenePhase) private var scenePhase
@State private var image: UIImage?
@State private var text: String?
@State private var issue: String?
@State private var loading = false
@State private var wantsDownload = false
@State private var revision = 0
@State private var exportURL: URL?
@State private var showShare = false
@State private var modalID = UUID()
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
AgentAttachmentDetails(attachment: attachment)
if attachment.kind == .image {
ZStack {
SybilTheme.surface
if let image { Image(uiImage: image).resizable().scaledToFit() }
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
}.frame(height: 180).clipped()
}
if let text {
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
}
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
HStack {
if exportURL != nil {
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
} else if !loading {
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
.accessibilityIdentifier("agent-download")
}
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
}.font(.sybil(.caption))
}
.padding(12).frame(maxWidth: 340, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
.task(id: loadKey) {
// Each view owns its transfer; configuration and foreground changes cancel it.
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
loading = true; issue = nil
let session = room.attachmentSessionID
do {
let data = try await room.downloadAttachment(attachment)
try Task.checkCancellation()
guard session == room.attachmentSessionID else { return }
if attachment.kind == .image {
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
try Task.checkCancellation()
image = UIImage(cgImage: bitmap)
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
}
if wantsDownload {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
exportURL = destination
}
loading = false
} catch {
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
}
}
.sheet(isPresented: $showShare) {
if let exportURL { AgentOriginalShare(url: exportURL) }
}
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
}
private func reset() {
image = nil; text = nil
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
exportURL = nil
}
}
private struct AgentOriginalShare: UIViewControllerRepresentable {
var url: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [url], applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
@@ -0,0 +1,269 @@
import CryptoKit
import Foundation
import ImageIO
import UniformTypeIdentifiers
import UIKit
struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
var version: Int
var maxCount: Int
var maxTotalBytes: Int
var maxImageBytes: Int
var maxFileBytes: Int
var imageMimeTypes: [String]
var maxImagePixels: Int
var maxImageDimension: Int
var quotaBytes: Int64? = nil
var usedBytes: Int64? = nil
var remainingBytes: Int64? = nil
var maxConcurrentUploads: Int? = nil
var maxConcurrentDecodes: Int? = nil
var supported: Bool {
version == 1 && maxCount > 0 && maxTotalBytes > 0 && maxImageBytes > 0 && maxFileBytes > 0
&& maxImagePixels > 0 && maxImageDimension > 0
}
var countLimit: Int { min(maxCount, 8) }
var totalLimit: Int { min(maxTotalBytes, 32 * 1024 * 1024) }
var imageLimit: Int { min(maxImageBytes, 6 * 1024 * 1024) }
var fileLimit: Int { min(maxFileBytes, 8 * 1024 * 1024) }
var pixelLimit: Int { min(maxImagePixels, 50_000_000) }
var dimensionLimit: Int { min(maxImageDimension, 16_384) }
func validate(_ attachments: [AgentAttachment]) throws {
guard supported else { throw AgentAttachmentIssue.unavailable }
for attachment in attachments { try attachment.validate() }
guard attachments.count <= countLimit, attachments.reduce(0, { $0 + $1.sizeBytes }) <= totalLimit else {
throw AgentAttachmentIssue.local("Attachments exceed the advertised message limit.", code: "attachment_limit_exceeded")
}
for attachment in attachments {
guard attachment.sizeBytes <= (attachment.kind == .image ? imageLimit : fileLimit),
attachment.kind != .image || imageMimeTypes.contains(attachment.mimeType) else {
throw AgentAttachmentIssue.local("An attachment exceeds the current Agent limits or uses an unavailable image format.", code: "attachment_limit_exceeded")
}
}
}
}
struct AgentCapabilities: Decodable, Sendable {
var attachments: AgentAttachmentCapabilities?
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
private enum CodingKeys: String, CodingKey { case attachments }
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)
}
}
struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
enum Kind: String, Codable, Sendable { case image, text, file }
var id: String
var kind: Kind
var filename: String
var mimeType: String
var sizeBytes: Int
var sha256: String
var workspacePath: String? = nil
func validate() throws {
guard !id.isEmpty, id.utf8.count <= 1024, !id.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
try AgentAttachmentPreparation.filename(filename) == filename,
sizeBytes >= 0, sizeBytes <= 8 * 1024 * 1024,
sha256.count == 64, sha256.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }),
try AgentAttachmentPreparation.mime(mimeType) == mimeType else {
throw AgentAttachmentIssue.local("The Agent returned an invalid attachment descriptor.", code: "invalid_upstream_response")
}
guard (kind == .image) == AgentAttachmentPreparation.nativeMIMEs.contains(mimeType),
kind != .image || sizeBytes <= 6 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("Invalid image attachment descriptor.", code: "invalid_upstream_response")
}
if let workspacePath {
guard workspacePath.hasPrefix("/"), !workspacePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw AgentAttachmentIssue.local("The Agent returned an invalid workspace path.", code: "invalid_upstream_response")
}
}
}
var metadataValue: JSONValue {
// All stored fields are JSON-compatible; this also keeps optional future fields separate from ChatAttachment.
(try? JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(self))) ?? .null
}
}
extension Message {
var agentAttachments: [AgentAttachment] {
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
return entries.compactMap {
guard let data = try? JSONEncoder().encode($0),
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
(try? attachment.validate()) != nil else { return nil }
return attachment
}
}
}
struct AgentSubmission: Encodable, Sendable {
struct Reference: Encodable, Sendable { let id: String }
let requestId: String
let content: String
let attachments: [Reference]?
init(content: String, requestID: String, attachments: [AgentAttachment] = []) throws {
guard !requestID.isEmpty, requestID.utf16.count <= 256, content.utf16.count <= 40_000,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty,
attachments.count <= 8, Set(attachments.map(\.id)).count == attachments.count else {
throw AgentAttachmentIssue.local("Enter a message of at most 40,000 characters or attach files.", code: "invalid_attachment")
}
for attachment in attachments { try attachment.validate() }
guard attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded")
}
self.content = content; requestId = requestID
self.attachments = attachments.isEmpty ? nil : attachments.map { Reference(id: $0.id) }
}
var attachmentIDs: [String] { attachments?.map(\.id) ?? [] }
}
struct AgentAttachmentIssue: Error, LocalizedError, Codable, Equatable, Sendable {
var message: String
var code: String
var statusCode: Int?
// Preserve additive quota information without inventing field names.
var details: [String: JSONValue] = [:]
var retryAfter: String? = nil
var errorDescription: String? {
var result = "\(message) [\(code)]"
if code == "attachment_quota_exceeded" {
let numbers = ["requiredBytes", "remainingBytes", "quotaBytes"].compactMap { key -> String? in
guard let number = details[key]?.numberValue, number.isFinite, number >= 0, number < Double(Int64.max) else { return nil }
return "\(key): \(ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .file))"
}
if !numbers.isEmpty { result += " " + numbers.joined(separator: ", ") }
}
if let retryAfter { result += " Retry-After: \(retryAfter)." }
return result
}
static func local(_ message: String, code: String = "invalid_attachment") -> Self { Self(message: message, code: code) }
static let unavailable = Self.local("Attachments are unavailable on this Agent. Reconnect to check support, or remove the files to send text.", code: "attachments_unavailable")
static func wrapping(_ error: any Error) -> Self {
error as? Self ?? .local(error.localizedDescription, code: "attachment_transfer_failed")
}
static func response(_ data: Data, status: Int, retryAfter: String? = nil) -> Self {
let fields = (try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue ?? [:]
return Self(message: fields["message"]?.stringValue ?? fields["error"]?.stringValue ?? "Attachment request failed (HTTP \(status)).",
code: fields["code"]?.stringValue ?? "attachment_request_failed", statusCode: status, details: fields, retryAfter: retryAfter)
}
}
struct AgentPreparedAttachment: Sendable {
var filename: String
var mimeType: String
var data: Data
var sha256: String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
}
enum AgentAttachmentPreparation {
static func offMain<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
let task = Task.detached(operation: operation)
return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() }
}
static let nativeMIMEs: Set<String> = ["image/png", "image/jpeg", "image/gif", "image/webp"]
static func filename(_ value: String) throws -> String {
let value = value.precomposedStringWithCanonicalMapping
guard !value.isEmpty, value.utf8.count <= 255, value != ".", value != "..",
!value.contains("/"), !value.contains("\\"),
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw AgentAttachmentIssue.local("The filename must be a safe basename of 1–255 UTF-8 bytes.")
}
return value
}
static func mime(_ value: String) throws -> String {
let value = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
let parts = value.split(separator: "/", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~")
guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }), value.utf8.count <= 255 else {
throw AgentAttachmentIssue.local("The selected file has an invalid media type.")
}
return value
}
static func file(_ url: URL, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
let accessed = url.startAccessingSecurityScopedResource()
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
let name = try filename(url.lastPathComponent)
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType ?? UTType(filenameExtension: url.pathExtension)
let media = try mime(type?.preferredMIMEType ?? "application/octet-stream")
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else {
throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
return try prepare(data, filename: name, mimeType: media, limits: limits)
}
static func prepare(_ data: Data, filename name: String, mimeType: String, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
guard limits.supported else { throw AgentAttachmentIssue.unavailable }
let name = try filename(name), media = try mime(mimeType)
guard data.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else { throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded") }
if nativeMIMEs.contains(media) {
guard limits.imageMimeTypes.contains(media), data.count <= limits.imageLimit else {
throw AgentAttachmentIssue.local("\(name) exceeds the image limit or uses an unavailable image format.", code: "attachment_limit_exceeded")
}
_ = try raster(data, mime: media, pixels: limits.pixelLimit, dimension: limits.dimensionLimit)
}
return AgentPreparedAttachment(filename: name, mimeType: media, data: data)
}
static func raster(_ data: Data, mime: String, pixels: Int = 50_000_000, dimension: Int = 16_384) throws -> CGImage {
guard nativeMIMEs.contains(mime), let source = CGImageSourceCreateWithData(data as CFData, nil),
let type = CGImageSourceGetType(source), UTType(type as String)?.preferredMIMEType == mime,
CGImageSourceGetStatus(source) == .statusComplete,
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
width > 0, height > 0, width <= dimension, height <= dimension, width <= pixels / height,
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 600, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else {
throw AgentAttachmentIssue.local("The image is invalid, has a mismatched media type, or exceeds the dimension/pixel limit.", code: "invalid_image")
}
return image
}
static func photo(_ data: Data, filename originalName: String?, mimeType: String?, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
let media = mimeType ?? "application/octet-stream"
if nativeMIMEs.contains(media) {
return try prepare(data, filename: originalName ?? "photo.\(UTType(mimeType: media)?.preferredFilenameExtension ?? "jpg")", mimeType: media, limits: limits)
}
// Photo Library is an explicit visual-input selection. Convert HEIC/HEIF with bounded decoding.
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let sourceType = CGImageSourceGetType(source),
["public.heic", "public.heif"].contains(sourceType as String), limits.imageMimeTypes.contains("image/jpeg"),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
width > 0, height > 0, width <= limits.dimensionLimit, height <= limits.dimensionLimit, width <= limits.pixelLimit / height,
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: min(2000, limits.dimensionLimit), kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary),
let jpeg = UIImage(cgImage: image).jpegData(compressionQuality: 0.88) else {
throw AgentAttachmentIssue.local("This photo could not be prepared as a supported image. Select its original in Files to upload it as a file.", code: "invalid_image")
}
let safe = try filename(originalName ?? "photo.heic")
let stem = (safe as NSString).deletingPathExtension
return try prepare(jpeg, filename: stem + ".jpg", mimeType: "image/jpeg", limits: limits)
}
}
struct AgentMultipart: Sendable {
let boundary: String
let body: Data
init(file: AgentPreparedAttachment, uploadID: String) throws {
guard UUID(uuidString: uploadID) != nil else { throw AgentAttachmentIssue.local("Invalid upload identity.") }
let name = try AgentAttachmentPreparation.filename(file.filename)
let media = try AgentAttachmentPreparation.mime(file.mimeType)
var boundary: String
repeat { boundary = "Sybil-" + UUID().uuidString } while file.data.range(of: Data(boundary.utf8)) != nil
self.boundary = boundary
let quoted = name.replacingOccurrences(of: "\"", with: "\\\"")
var bytes = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(quoted)\"\r\nContent-Type: \(media)\r\n\r\n".utf8)
bytes.append(file.data)
bytes.append(Data("\r\n--\(boundary)--\r\n".utf8))
guard bytes.count <= 8 * 1024 * 1024 + 64 * 1024 else { throw AgentAttachmentIssue.local("The upload exceeds the request size limit.", code: "attachment_limit_exceeded") }
body = bytes
}
}
@@ -0,0 +1,272 @@
import Foundation
struct AgentFailure: LocalizedError, Sendable {
let message: String
var errorDescription: String? { message }
}
// Compare decimal strings without losing precision through Double or overflowing Int.
enum AgentCursor {
static func normalize(_ value: String) throws -> String {
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
throw AgentFailure(message: "Invalid Agent cursor.")
}
let trimmed = value.drop(while: { $0 == "0" })
return trimmed.isEmpty ? "0" : String(trimmed)
}
static func less(_ a: String, _ b: String) -> Bool {
a.count == b.count ? a < b : a.count < b.count
}
static func advance(_ current: String, _ incoming: String) throws -> String {
let value = try normalize(incoming)
return less(current, value) ? value : current
}
}
struct AgentActivity: Codable, Equatable, Sendable {
struct Tool: Codable, Equatable, Sendable {
var id: String
var name: String
var status: String
var output: String?
}
struct Wait: Codable, Equatable, Sendable {
var due: Double
var reason: String?
}
var state: String = "idle"
var isThinking = false
var queued = 0
var backgroundWorkers = 0
var tools: [Tool] = []
var queuedMessages: [Message] = []
var wait: Wait?
var error: String?
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
}
struct AgentDescriptor: Decodable, Sendable {
var id: String
var title: String
var type: String
var model: String
var `protocol`: String
var activity: AgentActivity
var websocket: String
var capabilities: AgentCapabilities? = nil
}
struct AgentPage: Codable, Sendable {
var conversationId: String
var messages: [Message]
var cursor: String?
var beforeCursor: String?
var hasMore: Bool
func validate() throws {
guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else {
throw AgentFailure(message: "Invalid Agent history.")
}
if let cursor { _ = try AgentCursor.normalize(cursor) }
if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) }
}
}
struct AgentReadState: Codable, Sendable {
var cursor: String
var updatedAt: String?
var sourceId: String? = nil
}
struct AgentReceipt: Codable, Sendable {
var id: Int64
var status: String
var messageId: String
var attachmentIds: [String]? = nil
func validate(requestID: String, attachmentIDs: [String] = []) throws {
guard id > 0, id <= 9_007_199_254_740_991,
["queued", "placed", "done", "unanswered"].contains(status),
messageId == "inbound:sybil:\(requestID)" else {
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
}
if !attachmentIDs.isEmpty, attachmentIds != attachmentIDs {
throw AgentAttachmentIssue.local("Agent receipt did not confirm the exact ordered attachments. Retry this message to confirm delivery.", code: "invalid_upstream_response")
}
}
}
enum AgentEvent: Sendable {
case ready(String?)
case messages(AgentPage)
case activity(String, AgentActivity)
case readState(AgentReadState)
case heartbeat
case error(String)
static func decode(_ data: Data) throws -> AgentEvent? {
struct Envelope: Decodable {
var type: String
var conversationId: String?
var activity: AgentActivity?
var error: String?
}
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
switch envelope.type {
case "ready": return .ready(envelope.conversationId)
case "messages":
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
try page.validate()
return .messages(page)
case "activity":
guard let id = envelope.conversationId, let activity = envelope.activity else {
throw AgentFailure(message: "Invalid Agent activity frame.")
}
return .activity(id, activity)
case "pong", "heartbeat": return .heartbeat
case "read_state":
// Additive optional frame: malformed new metadata must not tear down
// an otherwise compatible Agent connection.
guard let read = try? AgentCoding.decoder().decode(AgentReadState.self, from: data),
AgentPushPayload.validCursor(read.cursor) != nil,
read.sourceId.map(AgentPushPayload.validSource) ?? true else { return nil }
return .readState(read)
case "error": return .error(envelope.error ?? "Agent connection error.")
default: return nil
}
}
}
enum AgentCoding {
static func decoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: text) { return date }
formatter.formatOptions = [.withInternetDateTime]
guard let date = formatter.date(from: text) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
}
return date
}
return decoder
}
}
extension Message {
var agentEntryCursor: String? {
let value = metadata?.objectValue?["entryId"]
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
return String(Int64(number))
}
return nil
}
var isAgentPublicReply: Bool {
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
}
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
}
struct AgentRoomState: Equatable, Sendable {
var conversationID: String?
var messages: [Message] = []
var cursor = "0"
var readCursor = "0"
var activity = AgentActivity()
var unreadCount: Int {
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
}
mutating func checkConversation(_ id: String) throws {
guard conversationID == nil || conversationID == id else {
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
}
conversationID = id
}
mutating func apply(_ page: AgentPage) throws {
try page.validate()
var next = self
try next.checkConversation(page.conversationId)
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
next.messages = Self.merge(messages, page.messages)
self = next
}
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
for var message in incoming {
if let old = byID[message.id] {
let prior = old.metadata?.objectValue ?? [:]
let next = message.metadata?.objectValue ?? [:]
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
message.metadata = .object(merged)
message.createdAt = min(old.createdAt, message.createdAt)
if stale { message.content = old.content; message.name = old.name }
}
}
byID[message.id] = message
}
return byID.values.sorted {
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
return $0.id < $1.id
}
}
}
// Filter only the transcript projection, before windowing. Keep the room's raw
// messages and cursors intact for upserts, replay, unread state and activity.
struct AgentTranscript {
let messages: [Message]
let startIndex: Int
var displayed: [Message] { Array(messages.dropFirst(startIndex)) }
init(messages: [Message], startID: String? = nil, limit: Int = 100) {
let visible = messages.filter {
$0.role != .tool && $0.metadata?.objectValue?["kind"]?.stringValue != "tool_call"
}
self.messages = visible
startIndex = startID.flatMap { id in visible.firstIndex(where: { $0.id == id }) }
?? max(0, visible.count - limit)
}
}
// The transcript latch is independent of the truthful sidebar/worker activity.
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"]
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
switch event {
case let .messages(page):
let existing = Set(before.messages.map(\.id))
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
.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 }
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 }
}
}
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
case let .activity(_, activity):
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
seenTools.formUnion(activity.tools.map(\.id))
default: break
}
reconcile(activity: after.activity, now: now)
}
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
private mutating func reconcile(activity: AgentActivity, now: Date) {
if suppressed || !activity.isBusy { busySince = nil }
else if busySince == nil { busySince = now }
}
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
}
@@ -0,0 +1,159 @@
import Foundation
import CoreFoundation
import CryptoKit
struct AgentPushConfig: Codable, Sendable {
var version: Int
var enabled: Bool
var apnsAvailable: Bool
var webPushAvailable: Bool
var sourceId: String?
var quarantined: Bool
var available: Bool { version == 1 && enabled && apnsAvailable && !quarantined && sourceId.map(AgentPushPayload.validSource) == true }
}
enum AgentAPNSEnvironment: String, Codable, Sendable {
case sandbox, production
// The app's Info.plist and entitlement expand the SAME XcodeGen build setting.
// Never guess from DEBUG, token length, simulator, or marketing version.
init?(entitlementSetting: String?) {
switch entitlementSetting {
case "development": self = .sandbox
case "production": self = .production
default: return nil
}
}
}
struct AgentPushRegistration: Encodable, Equatable, Sendable {
let transport = "apns"
let enabled = true
var token: String
var environment: AgentAPNSEnvironment
let topic = "net.buzzert.sybil2"
}
struct AgentPushReceipt: Codable, Sendable {
var installationId: String
var transport: String
var enabled: Bool
var version: Int
}
protocol AgentPushAPIClienting: Sendable {
func agentPushConfig() async throws -> AgentPushConfig
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt
func agentRevokePush(id: UUID) async throws
func agentReadState() async throws -> AgentReadState
}
struct AgentPushRequestError: LocalizedError, Sendable {
var status: Int
var retryable: Bool { status == 408 || status == 429 || status >= 500 }
var errorDescription: String? { "Agent notification request failed (\(status))." }
}
struct AgentPushScope: Codable, Equatable, Hashable, Sendable {
var backend: String
// ADMIN_TOKEN identifies one shared personal principal, not individual people.
var owner = "shared-personal-admin"
var credentialScope: String
var sourceID: String
static func credentialScope(backend: String, token: String) -> String {
// Nonsecret consent epoch, never logged. A credential replacement requires
// new consent even when the server still represents one personal principal.
SHA256.hash(data: Data((backend + "\n" + token.trimmingCharacters(in: .whitespacesAndNewlines)).utf8))
.map { String(format: "%02x", $0) }.joined()
}
static func canonicalBackend(_ url: URL, allowLoopback: Bool = false) -> String? {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = parts.host?.lowercased(), parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.scheme?.lowercased() == "https" || (allowLoopback && parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { return nil }
parts.scheme = parts.scheme?.lowercased(); parts.host = host
if (parts.scheme == "https" && parts.port == 443) || (parts.scheme == "http" && parts.port == 80) { parts.port = nil }
let path = parts.path
guard !path.contains("//"), !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil }
parts.path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty ? "" : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return parts.url?.absoluteString
}
}
struct AgentPushPayload: Equatable, Sendable {
enum Kind: String { case message, readState = "read_state" }
var sourceID: String
var kind: Kind
var cursor: String
var messageID: String?
init?(_ info: [AnyHashable: Any]) {
guard let version = info["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1,
let source = info["sourceId"] as? String, Self.validSource(source),
let name = info["kind"] as? String, let kind = Kind(rawValue: name),
let raw = info[kind == .message ? "cursor" : "readCursor"] as? String,
let cursor = Self.validCursor(raw) else { return nil }
let message = info["messageId"] as? String
if kind == .message && (message?.isEmpty != false || (message?.utf8.count ?? 0) > 256) { return nil }
self.sourceID = source.lowercased(); self.kind = kind; self.cursor = cursor; self.messageID = message
}
static func validSource(_ value: String) -> Bool {
value.utf8.count == 36 && UUID(uuidString: value) != nil
}
static func validCursor(_ value: String) -> String? {
guard value.utf8.count <= 16, let normalized = try? AgentCursor.normalize(value),
!AgentCursor.less("9007199254740991", normalized) else { return nil }
return normalized
}
}
struct AgentDeliveredNotification: Sendable {
var identifier: String // UNNotification.request.identifier, never messageId.
var payload: AgentPushPayload?
}
enum AgentNotificationPermission: Sendable { case notDetermined, denied, authorized, provisional }
@MainActor
protocol AgentNotificationPlatform: AnyObject {
var environment: AgentAPNSEnvironment? { get }
var allowsLoopback: Bool { get }
func permission() async -> AgentNotificationPermission
func requestPermission() async throws -> Bool
func register(generation: UUID)
func unregister()
func delivered() async -> [AgentDeliveredNotification]
func removeDelivered(identifiers: [String])
}
struct AgentPushRecord: Codable, Equatable {
var scope: AgentPushScope
var installationID = UUID()
var optedIn = false
var mayBeRegistered = false
var version: Int?
}
struct AgentPushPersistence: Codable {
var records: [AgentPushRecord] = []
var tombstones: [AgentPushRecord] = []
}
@MainActor
struct AgentPushStorage {
var read: () throws -> AgentPushPersistence
var write: (AgentPushPersistence) throws -> Void
static func defaults(_ defaults: UserDefaults = .standard) -> Self {
let key = "sybil.ios.agentPush.v1"
return Self(read: {
guard let data = defaults.data(forKey: key) else { return .init() }
return try JSONDecoder().decode(AgentPushPersistence.self, from: data)
}, write: { value in
// No authentication credential or device token is ever persisted here.
defaults.set(try JSONEncoder().encode(value), forKey: key)
})
}
}
@@ -0,0 +1,343 @@
import Foundation
import Observation
/// One app-lifetime owner of push enrollment. OS addresses stay in memory; retired
/// installation IDs are never reused. Every suspension checks its session generation.
@MainActor @Observable
final class SybilAgentNotifications {
static let shared = SybilAgentNotifications(platform: SybilNotificationPlatform.shared, storage: .defaults())
private(set) var optedIn = false
private(set) var available = false
private(set) var permission: AgentNotificationPermission = .notDetermined
private(set) var status = "Connect to a server to configure notifications."
private(set) var pendingRouteID: UUID?
private(set) var generation = UUID()
private(set) var scope: AgentPushScope?
private(set) var pendingRevocations = 0
@ObservationIgnored private let platform: any AgentNotificationPlatform
@ObservationIgnored private let storage: AgentPushStorage
@ObservationIgnored private var saved = AgentPushPersistence()
@ObservationIgnored private var storageHealthy = true
@ObservationIgnored private var client: (any AgentPushAPIClienting)?
@ObservationIgnored private var backend: String?
@ObservationIgnored private var credentialScope: String?
@ObservationIgnored private var token: String?
@ObservationIgnored private var uploaded: AgentPushRegistration?
@ObservationIgnored private var enrollment: Task<Void, Never>?
@ObservationIgnored private var refreshing: UUID?
@ObservationIgnored private var pendingPayload: AgentPushPayload?
@ObservationIgnored private var consumedResponses: [String] = []
@ObservationIgnored private var readCursor = "0"
@ObservationIgnored private var foregroundScenes: Set<UUID> = []
@ObservationIgnored private var sessionBackend: String?
@ObservationIgnored private var sessionIdentity: String?
func transition(baseURL: URL?, token: String?) async {
// Multiple windows and ordinary reconnects share one app enrollment. Compare
// credentials only in memory; never derive/persist an owner from a token.
let canonical = baseURL.flatMap { AgentPushScope.canonicalBackend($0, allowLoopback: platform.allowsLoopback) }
let identity = canonical.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
guard sessionBackend != canonical || sessionIdentity != identity else { return }
let hadSession = sessionBackend != nil || sessionIdentity != nil
sessionBackend = canonical; sessionIdentity = identity
if hadSession { await deactivate() }
}
init(platform: any AgentNotificationPlatform, storage: AgentPushStorage) {
self.platform = platform; self.storage = storage
do { saved = try storage.read(); pendingRevocations = saved.tombstones.count }
catch { storageHealthy = false; status = "Notification settings could not be restored." }
}
private func persist() -> Bool {
do { try storage.write(saved); pendingRevocations = saved.tombstones.count; return true }
catch { storageHealthy = false; available = false; status = "Notification settings could not be saved."; return false }
}
private var recordIndex: Int? { scope.flatMap { scope in saved.records.firstIndex { $0.scope == scope } } }
/// Call BEFORE overwriting the old session settings/client. This invalidates OS,
/// config, read, permission and upload callbacks before attempting revocation.
@discardableResult func deactivate() async -> UUID {
let oldClient = client
let oldScope = scope
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
let retiringGeneration = generation
refreshing = nil; available = false; optedIn = false; readCursor = "0"
scope = nil; client = nil; backend = nil; credentialScope = nil
if oldScope != nil { pendingPayload = nil; pendingRouteID = nil }
platform.unregister()
if let oldScope { retire(oldScope, clearOptIn: true) }
// Server DELETE is a terminal UUID tombstone, even if PUT is in flight.
// Attempt it before settings are replaced. A stale PUT also retries revoke.
if let oldClient, let oldScope {
await revokePending(client: oldClient, scope: oldScope)
}
if generation == retiringGeneration {
status = pendingRevocations > 0 ? "Revocation pending. Reconnect to the original server to retry." : "Notifications are off."
}
return retiringGeneration
}
func bind(client: any AgentPushAPIClienting, baseURL: URL, authenticated: Bool, mode: String, token: String?) async {
await transition(baseURL: baseURL, token: token)
let requestedBackend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback)
let requestedIdentity = requestedBackend.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
guard sessionBackend == requestedBackend, sessionIdentity == requestedIdentity else { return }
guard authenticated, mode == "token", let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storageHealthy,
let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback) else {
if self.client != nil { await deactivate() }
available = false; status = "Notifications require a token-authenticated HTTPS server."; return
}
self.client = client; self.backend = backend
self.credentialScope = AgentPushScope.credentialScope(backend: backend, token: token)
await refresh()
}
func refresh() async {
guard refreshing == nil, let client, let backend, let credentialScope else { return }
let refreshID = UUID()
refreshing = refreshID
let run = generation
defer { if refreshing == refreshID { refreshing = nil } }
do {
let config = try await client.agentPushConfig()
guard generation == run else { return }
guard config.version == 1, let source = config.sourceId, AgentPushPayload.validSource(source) else {
available = false; status = "This server does not support Agent notifications."; return
}
let nextScope = AgentPushScope(backend: backend, credentialScope: credentialScope, sourceID: source.lowercased())
if let scope, scope != nextScope {
let retired = await deactivate()
guard generation == retired else { return }
self.client = client; self.backend = backend; self.credentialScope = credentialScope
await refresh()
return
}
await revokePending(client: client, scope: nextScope)
guard generation == run else { return }
scope = nextScope
if recordIndex == nil { saved.records.append(AgentPushRecord(scope: nextScope)); guard persist() else { return } }
optedIn = recordIndex.map { saved.records[$0].optedIn } ?? false
available = config.available && platform.environment != nil
permission = await platform.permission()
guard generation == run else { return }
if permission == .denied {
await disable()
guard self.scope == nextScope else { return }
status = "Notifications are denied in iOS Settings."
return
}
if !available {
if let index = recordIndex, saved.records[index].mayBeRegistered {
// Invalidate both the cached success and any outstanding PUT
// before replacing the UUID. A fresh callback must enroll the
// replacement even when APNs returns the same token.
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
refreshing = nil; platform.unregister()
let retirementRun = generation
retire(nextScope); await revokePending(client: client, scope: nextScope)
guard generation == retirementRun else { return }
}
status = "Agent notifications are unavailable on this server or build."
} else if optedIn && (permission == .authorized || permission == .provisional) {
status = "Registering notifications…"
// A fresh OS callback is required on each launch/foreground refresh.
platform.register(generation: run)
} else { status = optedIn ? "Enable notifications to allow alerts." : "Notifications are off." }
await syncRead()
} catch {
guard generation == run else { return }
available = false
status = "Agent notifications are unavailable. Retry after reconnecting."
}
}
/// The only code path that can display the system permission prompt.
func enable() async {
guard available, let index = recordIndex else { return }
let run = generation
do {
let current = await platform.permission()
guard generation == run else { return }
let allowed: Bool
if current == .notDetermined { allowed = try await platform.requestPermission() }
else { allowed = current == .authorized || current == .provisional }
guard generation == run else { return }
permission = await platform.permission()
guard generation == run else { return }
guard allowed else { status = "Notifications are denied in iOS Settings."; return }
saved.records[index].optedIn = true
guard persist() else { return }
optedIn = true; status = "Registering notifications…"
platform.register(generation: run)
} catch { if generation == run { status = "Could not request notification permission. Try again." } }
}
func disable() async {
guard let scope else { return }
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
let disablingGeneration = generation
refreshing = nil
optedIn = false; platform.unregister()
retire(scope, clearOptIn: true)
if let client { await revokePending(client: client, scope: scope) }
if generation == disablingGeneration {
status = pendingRevocations > 0 ? "Notifications off; revocation pending until this server reconnects." : "Notifications are off."
}
}
private func retire(_ scope: AgentPushScope, clearOptIn: Bool = false) {
guard let index = saved.records.firstIndex(where: { $0.scope == scope }) else { return }
let old = saved.records[index]
if self.scope == scope { uploaded = nil; token = nil }
if old.mayBeRegistered && !saved.tombstones.contains(where: { $0.installationID == old.installationID }) {
saved.tombstones.append(old)
}
saved.records[index] = AgentPushRecord(scope: scope, optedIn: clearOptIn ? false : old.optedIn)
_ = persist()
}
private func revokePending(client: any AgentPushAPIClienting, scope: AgentPushScope) async {
for record in saved.tombstones.filter({ $0.scope == scope }) {
do {
try await client.agentRevokePush(id: record.installationID)
saved.tombstones.removeAll { $0.installationID == record.installationID }
_ = persist()
} catch { /* Durable nonsecret tombstone survives offline/logout. */ }
}
}
func receivedDeviceToken(_ data: Data, generation run: UUID) {
guard generation == run, available, optedIn, !data.isEmpty, data.count <= 256 else { return }
token = data.map { String(format: "%02x", $0) }.joined()
startEnrollment()
}
func registrationFailed(generation run: UUID) {
guard generation == run else { return }
status = "iOS registration failed. Retry when connected."
}
private func startEnrollment() {
guard enrollment == nil, optedIn, available, let client, let token,
let environment = platform.environment, let index = recordIndex else { return }
let registration = AgentPushRegistration(token: token, environment: environment)
guard uploaded != registration else { status = "Notifications are enabled."; return }
let record = saved.records[index], run = generation
// Persist revocation intent BEFORE the first request, even if its response is lost.
saved.records[index].mayBeRegistered = true
guard persist() else { return }
enrollment = Task { [weak self] in
guard let self else { return }
defer {
if self.generation == run {
self.enrollment = nil
if self.token != token { self.startEnrollment() }
}
}
for attempt in 0..<3 {
do {
let receipt = try await client.agentRegisterPush(id: record.installationID, registration: registration)
guard self.generation == run else {
await self.revokePending(client: client, scope: record.scope); return
}
guard UUID(uuidString: receipt.installationId) == record.installationID,
receipt.transport == "apns", receipt.enabled, receipt.version > 0 else { throw APIError.invalidResponse }
guard let index = self.recordIndex, self.saved.records[index].installationID == record.installationID else { return }
self.saved.records[index].version = receipt.version
guard self.persist() else { return }
self.uploaded = registration; self.status = "Notifications are enabled."
return
} catch {
guard self.generation == run else {
await self.revokePending(client: client, scope: record.scope); return
}
if let failure = error as? AgentPushRequestError, !failure.retryable {
self.status = "The server rejected this notification registration. Disable it and check the server configuration before enabling again."
return
}
self.status = "Registration pending. Retry when connected."
if attempt < 2 { try? await Task.sleep(for: .seconds(attempt + 1)) }
guard self.generation == run else { return }
}
}
}
}
func setScene(_ id: UUID, active: Bool) {
if active { foregroundScenes.insert(id) } else { foregroundScenes.remove(id) }
}
func shouldPresent(_ payload: AgentPushPayload) -> Bool {
// Foreground updates already flow through the Agent transcript. Never mark
// them read here; the visible, bottom-rendered, settled guard owns that.
matches(payload) && payload.kind == .message && foregroundScenes.isEmpty && AgentCursor.less(readCursor, payload.cursor)
}
private func matches(_ payload: AgentPushPayload) -> Bool {
guard let scope, scope.sourceID == payload.sourceID else { return false }
// The wire payload has no backend origin. If one source is known at two
// origins, refuse to route/clear rather than guess which server sent it.
// Credentials may rotate for the same shared personal owner and runtime.
// Old revoke tombstones must not veto a newly consented installation there.
return !(saved.records + saved.tombstones).contains {
$0.scope.sourceID == payload.sourceID &&
($0.scope.backend != scope.backend || $0.scope.owner != scope.owner)
}
}
func syncRead() async {
guard let client else { return }
let run = generation
if let read = try? await client.agentReadState(), generation == run { await acknowledged(read, generation: run) }
}
func acknowledged(_ read: AgentReadState, generation run: UUID) async {
guard generation == run, let source = read.sourceId, source.lowercased() == scope?.sourceID,
let cursor = AgentPushPayload.validCursor(read.cursor) else { return }
readCursor = (try? AgentCursor.advance(readCursor, cursor)) ?? readCursor
await clearDelivered(generation: run)
}
private func clearDelivered(generation run: UUID) async {
let delivered = await platform.delivered()
guard generation == run else { return }
let ids = delivered.compactMap { notification -> String? in
guard let payload = notification.payload, matches(payload), payload.kind == .message,
!AgentCursor.less(readCursor, payload.cursor) else { return nil }
return notification.identifier
}
if !ids.isEmpty { platform.removeDelivered(identifiers: ids) }
}
func receiveBackground(_ payload: AgentPushPayload) async -> Bool {
guard payload.kind == .readState, matches(payload) else { return false }
await acknowledged(AgentReadState(cursor: payload.cursor, sourceId: payload.sourceID), generation: generation)
// No foreground socket, no PUT read receipt, no badge reset. APNs is the
// source of this scoped read hint; foreground GET converges missed hints.
return true
}
/// Cold background delivery may run before SwiftUI/auth bootstrap. Only restore
/// the opted-in record at the currently saved backend; never enroll from here.
func restoreBackgroundScope(baseURL: URL, token: String?, payload: AgentPushPayload) {
guard client == nil, let token, !token.isEmpty, let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback),
let record = saved.records.first(where: { $0.scope.backend == backend && $0.scope.credentialScope == AgentPushScope.credentialScope(backend: backend, token: token) && $0.scope.sourceID == payload.sourceID && $0.optedIn && $0.mayBeRegistered }) else { return }
scope = record.scope
}
func notificationResponse(_ payload: AgentPushPayload, identifier: String) {
guard payload.kind == .message else { return }
let identity = payload.sourceID + "|" + (payload.messageID ?? "") + "|" + payload.cursor
guard !consumedResponses.contains(identity) else { return }
consumedResponses.append(identity)
if consumedResponses.count > 64 { consumedResponses.removeFirst() }
pendingPayload = payload; pendingRouteID = UUID()
}
func consumeRoute() -> Bool {
guard let payload = pendingPayload, client != nil, matches(payload) else { return false }
pendingPayload = nil; pendingRouteID = nil
return true
}
}
@@ -0,0 +1,44 @@
import CryptoKit
import Foundation
struct AgentOutboxRow: Codable, Equatable, Sendable {
var requestID: String
var content: String
var createdAt: Date
var status: String = "sending"
var seenOnServer = false
var confirmed = false
var error: String?
var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes.
var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer.
var id: String { "inbound:sybil:\(requestID)" }
var message: Message {
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
if let error { info["error"] = .string(error) }
if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) }
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
}
}
struct AgentOutboxStorage: Sendable {
var read: @Sendable () throws -> [AgentOutboxRow]
var write: @Sendable ([AgentOutboxRow]) throws -> Void
static func file(baseURL: URL) -> Self {
// Separate installations, but never persist tokens in a filename or payload.
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true)
let url = directory.appendingPathComponent("\(key).json")
return Self(read: {
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url))
}, write: { rows in
let data = try JSONEncoder().encode(rows)
guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else {
throw AgentFailure(message: "Retry pending messages before sending more.")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
})
}
}
@@ -0,0 +1,516 @@
import Foundation
import Network
import Observation
@MainActor
@Observable
final class SybilAgentRoom {
struct Snapshot: Equatable {
var room = AgentRoomState()
var isTyping = false
var entranceIDs: Set<String> = []
var revision = 0
}
private(set) var snapshot = Snapshot()
private(set) var connection = "Offline"
private(set) var error: String?
private(set) var isLoading = false
private(set) var requiresReload = false
private(set) var outbox: [AgentOutboxRow] = []
let attachmentDraft = AgentAttachmentDraft()
private(set) var attachmentSessionID = UUID()
private var attachmentModals: Set<UUID> = []
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
func setAttachmentModal(_ id: UUID, presented: Bool) {
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
}
func dismissAttachmentPresentations() {
// Existing picker/share views observe this identity and dismiss themselves.
// Keep draft contents and transcript/view identity intact during routing.
attachmentSessionID = UUID()
}
var composer: String {
get { attachmentDraft.caption }
set { attachmentDraft.caption = newValue }
}
var isObscured = false
var isModalPresented = false
var isSelected = false
private(set) var isActive = true
@ObservationIgnored var onAcknowledgedRead: ((AgentReadState) -> Void)?
private enum ReadSourceBinding: Equatable {
case unbound, legacy, source(String)
}
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
private var readSourceID: String? {
if case let .source(id) = readBinding { return id }
return nil
}
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentOutboxStorage?
@ObservationIgnored private var storageReadable = true
@ObservationIgnored private var pending = AgentRoomState()
@ObservationIgnored private var presenter = AgentPresentation()
@ObservationIgnored private var pendingEntrances: Set<String> = []
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var connectionTask: Task<Void, Never>?
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
@ObservationIgnored private var batchTask: Task<Void, Never>?
@ObservationIgnored private var typingTask: Task<Void, Never>?
@ObservationIgnored private var readTask: Task<Void, Never>?
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
@ObservationIgnored private var socket: (any AgentSocket)?
@ObservationIgnored private var monitor: NWPathMonitor?
@ObservationIgnored private var lastPacket = Date()
@ObservationIgnored private var socketStarted = Date()
@ObservationIgnored private var desiredRead = "0"
@ObservationIgnored private var acknowledgedRead = "0"
@ObservationIgnored private var hasLoaded = false
@ObservationIgnored private var syncing = false
@ObservationIgnored private var wasNetworkOffline = false
var isBusy: Bool { snapshot.room.activity.isBusy }
var unreadCount: Int { snapshot.room.unreadCount }
var messages: [Message] {
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)
for row in outbox {
if let index = messages.firstIndex(where: { $0.id == row.id }) {
messages[index].createdAt = row.createdAt
if !row.confirmed {
var info = messages[index].metadata?.objectValue ?? [:]
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
messages[index].content = row.content
}
info["status"] = .string(row.status)
if let error = row.error { info["error"] = .string(error) }
messages[index].metadata = .object(info)
}
} else { messages.append(row.message) }
}
return AgentRoomState.merge([], messages)
}
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) {
stop()
self.client = client
self.storage = storage
attachmentSessionID = UUID()
let oldDownloads = attachmentDownloads
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
attachmentDraft.configure(client: client, storage: draftStorage)
resetHistory()
error = nil; storageReadable = true
do {
outbox = try storage.read().map { row in
var restored = row
if !restored.seenOnServer {
restored.status = "failed"
restored.error = "Delivery was interrupted. Retry to confirm."
}
return restored
}
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
attachmentDraft.clearAfterSubmission()
}
} catch {
outbox = []; storageReadable = false
self.error = "Could not restore pending messages: \(error.localizedDescription)"
}
start()
}
func disconnect() {
stop()
client = nil
attachmentDraft.updateCapability(nil)
attachmentSessionID = UUID()
let oldDownloads = attachmentDownloads
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
}
func stop() {
attachmentDraft.suspend()
generation = UUID()
connectionTask?.cancel(); connectionTask = nil
maintenanceTask?.cancel(); maintenanceTask = nil
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
readTask?.cancel(); readTask = nil
sends.values.forEach { $0.cancel() }; sends = [:]
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
outbox[index].status = "failed"
outbox[index].error = "Delivery was interrupted. Retry to confirm."
}
if !outbox.isEmpty { try? persist() }
monitor?.cancel(); monitor = nil
let old = socket; socket = nil
Task { await old?.close() }
syncing = false
pendingEntrances = []
connection = "Offline"
}
func setActive(_ active: Bool) {
guard isActive != active else { return }
isActive = active
if active { attachmentDraft.resume(); start() }
else {
// Publish successfully merged input before cancelling; replay resumes here.
flush()
stop()
}
}
func retryConnection() {
flush(); stop()
// Explicit Reconnect starts a new history/source boundary. Foreground and
// network recovery must not relabel the preserved old room automatically.
if requiresReload { resetHistory(); error = nil }
if isActive { start() }
}
private func resetHistory() {
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
readBinding = .unbound; requiresReload = false
}
private func requireReload(_ reason: String) -> AgentFailure {
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
requiresReload = true
// Preserve already accepted old cursors/history, cancel every old writer,
// and invalidate callbacks before allowing any further source observation.
flush(); stop()
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
return failure
}
private func start() {
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
let run = generation
isLoading = !hasLoaded
connection = "Connecting"
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
Task { @MainActor [weak self] in
guard let self, self.generation == run else { return }
let recovered = self.wasNetworkOffline && satisfied
self.wasNetworkOffline = !satisfied
if recovered && !self.requiresReload { self.retryConnection() }
}
}
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
self.monitor = monitor
connectionTask = Task { [weak self] in
guard let self else { return }
var delay = 1
while !Task.isCancelled, self.generation == run {
do {
let descriptor = try await client.agentDescriptor()
guard self.generation == run else { return }
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
self.attachmentDraft.resume()
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
try await self.refreshRead(client: client, run: run)
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
guard self.generation == run, !Task.isCancelled else { return }
self.hasLoaded = true; self.isLoading = false; self.flush()
let socket = try await client.agentSocket(after: self.pending.cursor)
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
self.socket = socket
self.socketStarted = Date(); self.lastPacket = Date()
while !Task.isCancelled, self.generation == run {
let data = try await socket.receive()
guard self.generation == run, !Task.isCancelled else { return }
self.lastPacket = Date()
guard let event = try AgentEvent.decode(data) else { continue }
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
try self.receive(event)
}
} catch {
guard self.generation == run, !Task.isCancelled else { return }
let failedSocket = self.socket
let code = await failedSocket?.closeCode()
await failedSocket?.close()
guard self.generation == run, !Task.isCancelled else { return }
self.socket = nil
self.isLoading = false
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
: error.localizedDescription
self.connection = code == 4401 ? "Offline" : "Reconnecting"
if code == 4401 { return }
}
try? await Task.sleep(for: .seconds(delay))
delay = min(delay * 2, 30)
}
}
maintenanceTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard let self, self.generation == run, !Task.isCancelled else { return }
do {
try await self.refreshRead(client: client, run: run)
guard self.generation == run, !Task.isCancelled else { return }
self.flushRead()
if self.connection != "Connected" {
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
}
guard self.generation == run, !Task.isCancelled else { return }
if let socket = self.socket {
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
await socket.close()
} else { try await socket.send("{\"type\":\"ping\"}") }
}
} catch {
if self.generation == run { self.error = error.localizedDescription }
}
}
}
}
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
let run = run ?? generation
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
syncing = true
defer { if generation == run { syncing = false } }
var cursor = pending.cursor
while !Task.isCancelled, generation == run {
let page = try await client.agentMessages(after: cursor, before: nil)
guard generation == run, !Task.isCancelled else { return }
try page.validate()
let next = try AgentCursor.normalize(page.cursor ?? cursor)
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
throw AgentFailure(message: "Agent history did not advance its cursor.")
}
try receive(.messages(page), replay: replay)
cursor = next
if !page.hasMore { return }
}
}
func receive(_ event: AgentEvent, replay: Bool = false) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let before = pending
var next = pending
switch event {
case let .messages(page):
try next.apply(page)
// History has no source field. Pin even an empty first page before
// publishing it so a later UUID cannot adopt legacy cursor space.
if readBinding == .unbound { readBinding = .legacy }
case let .activity(id, activity):
try next.checkConversation(id)
next.activity = activity
case let .ready(id): if let id { try next.checkConversation(id) }
case let .error(message): throw AgentFailure(message: message)
case let .readState(read): try applyRead(read); return
case .heartbeat: return
}
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
pending = next
if !replay {
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
}
reconcileOutbox()
schedulePublish()
}
private func schedulePublish() {
guard batchTask == nil else { return }
batchTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
self?.flush()
}
}
func flush() {
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
pendingEntrances = []
if let start = presenter.busySince, !snapshot.isTyping {
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
typingTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
self?.flush()
}
}
}
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
let read = try await client.agentReadState()
guard generation == run else { return }
try applyRead(read)
}
func applyRead(_ read: AgentReadState) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let cursor = try AgentCursor.normalize(read.cursor)
let incoming: ReadSourceBinding
if let source = read.sourceId {
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
incoming = .source(source.lowercased())
} else { incoming = .legacy }
guard readBinding == .unbound || readBinding == incoming else {
throw requireReload("Agent read source changed.")
}
readBinding = incoming
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
pending.readCursor = acknowledgedRead
schedulePublish()
onAcknowledgedRead?(read)
}
func markRead(renderedCursor: String) {
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
private func flushRead() {
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
let run = generation
readTask = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.readTask = nil } }
do {
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
let captured = self.desiredRead
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
guard self.generation == run, !Task.isCancelled else { return }
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
throw self.requireReload("Agent read state was not acknowledged.")
}
try self.applyRead(read)
}
} catch {
if self.generation == run {
let isHTTPConflict: Bool
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
if isHTTPConflict {
_ = self.requireReload("Agent read source changed.")
} else { self.error = error.localizedDescription }
}
}
}
}
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
guard let client else { throw AgentAttachmentIssue.unavailable }
let session = attachmentSessionID
let data = try await attachmentDownloads.data(for: attachment, client: client)
try Task.checkCancellation()
guard attachmentSessionID == session else { throw CancellationError() }
return data
}
@discardableResult
func sendComposer() -> Bool {
do {
let attachments = try attachmentDraft.validateReady()
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
return true
} catch { self.error = error.localizedDescription; return false }
}
@discardableResult
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
guard !requiresReload else { return false }
do {
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
if !attachments.isEmpty {
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
try limits.validate(attachments)
}
} catch { self.error = error.localizedDescription; return false }
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
outbox.append(row)
do { if !attachments.isEmpty || draftID != nil { try persist() } }
catch {
outbox.removeAll { $0.id == row.id }
self.error = "Could not save message: \(error.localizedDescription)"
return false // Keep the entire draft when durable handoff fails.
}
pendingEntrances.insert(row.id)
presenter.sent(now: Date(), activity: pending.activity)
flush()
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
outbox[outbox.count - 1].status = "failed"
outbox[outbox.count - 1].error = attachmentDraft.error
try? persist()
return false
}
dispatch(row.id)
return true
}
func retry(_ id: String) { dispatch(id) }
private func persist() throws {
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
try storage.write(outbox.filter { !$0.confirmed })
}
private func dispatch(_ id: String) {
guard !requiresReload else { return }
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
outbox[index].status = "sending"; outbox[index].error = nil
do { try persist() }
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
let row = outbox[index], run = generation
sends[id] = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.sends[id] = nil } }
do {
let attachments = row.attachments ?? []
if !attachments.isEmpty {
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
try limits.validate(attachments)
}
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
let receipt = try await client.agentSubmit(submission)
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
self.outbox[index].seenOnServer = true
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
self.outbox[index].error = nil
try? self.persist()
} catch {
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
try? self.persist()
}
}
}
private func reconcileOutbox() {
let committed = pending.messages
let queued = pending.activity.queuedMessages
var changed = false
for index in outbox.indices {
let row = outbox[index]
let matches: (Message) -> Bool = { message in
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
}
if committed.contains(where: matches) {
if !outbox[index].confirmed { changed = true }
outbox[index].confirmed = true; outbox[index].seenOnServer = true
outbox[index].status = "complete"; outbox[index].error = nil
} else if queued.contains(where: matches), !outbox[index].confirmed {
changed = true
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
}
}
if changed { try? persist() }
}
}
@@ -0,0 +1,95 @@
import Foundation
protocol AgentSocket: Sendable {
func receive() async throws -> Data
func send(_ text: String) async throws
func close() async
func closeCode() async -> Int
}
protocol AgentAPIClienting: Sendable {
func agentDescriptor() async throws -> AgentDescriptor
func agentMessages(after: String?, before: String?) async throws -> AgentPage
func agentReadState() async throws -> AgentReadState
func agentMarkRead(cursor: String) async throws -> AgentReadState
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
func agentSocket(after: String) async throws -> any AgentSocket
}
actor AgentURLSocket: AgentSocket {
private let task: URLSessionWebSocketTask
init(task: URLSessionWebSocketTask) { self.task = task }
func receive() async throws -> Data {
switch try await task.receive() {
case let .string(text): return Data(text.utf8)
case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.")
@unknown default: throw AgentFailure(message: "Unsupported Agent frame.")
}
}
func send(_ text: String) async throws { try await task.send(.string(text)) }
func close() { task.cancel(with: .goingAway, reason: nil) }
func closeCode() -> Int { task.closeCode.rawValue }
}
extension APIConfiguration {
func agentWebSocketURL() throws -> URL {
guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
["http", "https"].contains(parts.scheme), parts.host != nil,
parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL }
parts.scheme = parts.scheme == "https" ? "wss" : "ws"
parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws"
parts.query = nil
parts.fragment = nil
guard let url = parts.url else { throw APIError.invalidBaseURL }
return url
}
func agentAuthFrame(after: String) throws -> String {
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
let value: JSONValue = .object([
"type": .string("auth"),
"clientKind": .string("native"),
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
"after": .string(try AgentCursor.normalize(after))
])
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
}
}
// Text-only test/legacy clients fail closed for the new wire path.
extension AgentAPIClienting {
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor)
}
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
}
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
// Reject every redirect; attachment credentials are valid only for the configured API.
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}
extension APIConfiguration {
func agentAttachmentContentURL(id: String) throws -> URL {
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
parts.query = nil; parts.fragment = nil
guard let url = parts.url else { throw APIError.invalidBaseURL }
return url
}
}
@@ -0,0 +1,323 @@
import SwiftUI
struct SybilAgentView: View {
@Bindable 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) }
}
var body: some View {
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)
}
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"))])
})
}
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.id(message.id)
}
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
}
Color.clear.frame(height: 1).id(bottomID)
}
.scrollTargetLayout()
.padding(.horizontal, 16)
.padding(.bottom, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.top, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.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)
}
}
.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) }
}
}
statusBar
composer
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
.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 }
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 }
room.markRead(renderedCursor: cursor)
}
}
@ViewBuilder private var statusBar: some View {
let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error {
HStack(alignment: .top) {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
Spacer(minLength: 4)
Button("Reconnect") { room.retryConnection() }.font(.sybil(.caption, weight: .semibold))
}.padding(.horizontal, 16).padding(.vertical, 8)
} else if activity.queued > 0 || activity.backgroundWorkers > 0 || activity.wait != nil {
HStack(spacing: 10) {
if activity.queued > 0 { Label("\(activity.queued) queued", systemImage: "clock") }
if activity.backgroundWorkers > 0 { Label("\(activity.backgroundWorkers) working", systemImage: "gearshape.2") }
if let wait = activity.wait {
Text(wait.reason ?? "Waiting")
Text(Date(timeIntervalSince1970: wait.due / 1000), style: .time)
}
Spacer(minLength: 0)
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
.padding(.horizontal, 16).padding(.vertical, 6)
}
}
private var canSendComposer: Bool {
room.attachmentDraft.canSend
}
private var composer: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
}
private func submitComposer() {
guard canSendComposer, room.sendComposer() else { return }
atLatest = true
}
}
struct AgentAvatar: View {
var body: some View {
Image("character-still", bundle: .module).resizable().scaledToFill()
.frame(width: 27, height: 27).clipShape(RoundedRectangle(cornerRadius: 9))
.overlay(RoundedRectangle(cornerRadius: 9).stroke(SybilTheme.primary.opacity(0.3)))
.accessibilityHidden(true)
}
}
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 {
HStack(alignment: .bottom, spacing: 8) {
if !own { AgentAvatar().padding(.bottom, 23) }
VStack(alignment: own ? .trailing : .leading, spacing: 5) {
ForEach(message.agentAttachments) { attachment in
AgentAttachmentCard(attachment: attachment, room: room)
}
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)
if own && status == "sending" { Text("Sending…") }
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")
}
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
if own && status == "failed", let error = message.metadata?.objectValue?["error"]?.stringValue {
Text(error).font(.sybil(.caption2)).foregroundStyle(SybilTheme.danger)
}
}
}
}
}
// Animate both occupied height and content, so incoming rows push older history up.
private struct AgentGrowingLayout: 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)
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(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)
}
.clipped()
.onAppear {
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
}
}
}
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 })
}
}
@@ -1,5 +1,6 @@
import MarkdownUI
import SwiftUI
import UIKit
struct SybilChatTranscriptView: View {
var messages: [Message]
@@ -9,6 +10,8 @@ struct SybilChatTranscriptView: View {
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var bottomPinRequestID: Int = 0
var isForking: Bool = false
var onForkResponse: ((String) -> Void)? = nil
@State private var hasTrackedToolCallMessages = false
@State private var knownToolCallMessageIDs: Set<String> = []
@@ -45,7 +48,11 @@ struct SybilChatTranscriptView: View {
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
)
.frame(maxWidth: .infinity)
case let .toolGroup(id, messages):
@@ -142,10 +149,13 @@ func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderIt
return items
}
private struct MessageBubble: View {
struct MessageBubble: View {
var message: Message
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var isForking: Bool
var onFork: (() -> Void)?
var agentStyle = false
private var toolCallMetadata: ToolCallMetadata? {
message.toolCallMetadata
@@ -164,7 +174,37 @@ private struct MessageBubble: View {
return streamingAssistantPresentation
}
private var canFork: Bool {
message.role == .assistant &&
!message.id.hasPrefix("temp-") &&
onFork != nil
}
var body: some View {
Group {
if canFork {
bubbleContent
.contextMenu {
Button {
UIPasteboard.general.string = message.content
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
Button {
onFork?()
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(isForking)
}
} else {
bubbleContent
}
}
}
private var bubbleContent: some View {
HStack(alignment: .top, spacing: 0) {
leadingSpacer
@@ -190,14 +230,11 @@ private struct MessageBubble: View {
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
}
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2)
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
.padding(.vertical, isUser || agentStyle ? 13 : 2)
.textSelection(.enabled)
.background(
Group {
@@ -208,6 +245,10 @@ private struct MessageBubble: View {
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
)
} else if agentStyle {
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
} else {
RoundedRectangle(cornerRadius: 0)
.fill(Color.clear)
@@ -322,10 +363,7 @@ struct StreamingAssistantContentView: View {
}
if hasAnswer {
Markdown(content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
SybilMessageText(content: content, format: .markdown)
.textSelection(.enabled)
.id("streaming-assistant-answer")
.transition(answerTransition)
@@ -562,7 +600,7 @@ private struct ToolCallStackCard: View {
}
}
private struct ToolCallActivityChip: View {
struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
@@ -0,0 +1,36 @@
import MarkdownUI
import SwiftUI
/// Both roles and both workspaces use normal chat's readable, Dynamic Type-aware theme.
/// Literal input is built from text nodes, never passed through the Markdown parser.
struct SybilMessageText: View {
enum Format { case literal, markdown }
var content: String
var format: Format
static func literalContent(_ text: String) -> MarkdownContent {
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
// paragraph boundaries as Markdown. Keep the newline count so deliberate
// extra blank lines survive; nonblank lines and Markdown syntax stay literal.
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
.components(separatedBy: "\n")
.map { line in line.allSatisfy { $0 == " " || $0 == "\t" } ? "" : line }
.joined(separator: "\n")
return MarkdownContent {
for paragraph in normalized.components(separatedBy: "\n\n") {
Paragraph {
// Keep extra blank paragraphs visible, including leading/trailing ones.
paragraph.isEmpty ? "\u{00a0}" : paragraph
}
}
}
}
var body: some View {
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
.tint(SybilTheme.primary)
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
}
@@ -219,6 +219,7 @@ public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var searchSnippet: String? = nil
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
@@ -302,6 +303,7 @@ extension WorkspaceItem {
case id
case title
case query
case searchSnippet
case parentChatId
case titleGenerationPending
case createdAt
@@ -320,6 +322,7 @@ extension WorkspaceItem {
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
query = try container.decodeIfPresent(String.self, forKey: .query)
searchSnippet = try container.decodeIfPresent(String.self, forKey: .searchSnippet)
parentChatId = try container.decodeIfPresent(String.self, forKey: .parentChatId)
titleGenerationPending = try container.decodeIfPresent(Bool.self, forKey: .titleGenerationPending) ?? false
createdAt = try container.decode(Date.self, forKey: .createdAt)
@@ -356,9 +359,14 @@ public struct Message: Codable, Identifiable, Hashable, Sendable {
toolName: object["toolName"]?.stringValue ?? name,
status: object["status"]?.stringValue,
summary: object["summary"]?.stringValue,
durationMs: object["durationMs"]?.numberValue.map(Int.init),
durationMs: object["durationMs"]?.numberValue.flatMap { value in
value >= 0 && value < Double(Int.max) ? Int(value) : nil
},
error: object["error"]?.stringValue,
resultPreview: object["resultPreview"]?.stringValue
resultPreview: object["resultPreview"]?.stringValue,
args: object["args"],
result: object["result"],
startedAt: object["startedAt"]?.stringValue
)
}
@@ -375,6 +383,9 @@ public struct ToolCallMetadata: Hashable, Sendable {
public var durationMs: Int?
public var error: String?
public var resultPreview: String?
public var args: JSONValue? = nil
public var result: JSONValue? = nil
public var startedAt: String? = nil
}
public enum JSONValue: Codable, Hashable, Sendable {
@@ -730,6 +741,11 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary]
}
struct WorkspaceSearchResponse: Codable, Sendable {
var items: [WorkspaceItem]
var hasMore: Bool
}
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
@@ -0,0 +1,191 @@
import Foundation
import UIKit
import UserNotifications
@MainActor
final class SybilNotificationPlatform: AgentNotificationPlatform {
static let shared = SybilNotificationPlatform()
private var center: UNUserNotificationCenter { .current() }
// UIKit's callback has no request ID. Keep an outstanding request tied to its
// original generation until it drains; queue the next session behind it.
private var outstanding: UUID?
private var queued: UUID?
private var registeredGeneration: UUID?
var environment: AgentAPNSEnvironment? {
#if targetEnvironment(simulator)
return isFixture ? .sandbox : nil
#elseif targetEnvironment(macCatalyst)
return nil
#else
return AgentAPNSEnvironment(entitlementSetting: Bundle.main.object(forInfoDictionaryKey: "SybilAPNSEnvironment") as? String)
#endif
}
var allowsLoopback: Bool { isFixture }
var isFixture: Bool {
#if DEBUG && targetEnvironment(simulator)
return ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1"
#else
return false
#endif
}
private var fixturePermission: AgentNotificationPermission = .notDetermined
func permission() async -> AgentNotificationPermission {
if isFixture {
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "authorized" { return .authorized }
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "denied" { return .denied }
return fixturePermission
}
guard environment != nil else { return .notDetermined }
switch await center.notificationSettings().authorizationStatus {
case .authorized, .ephemeral: return .authorized
case .provisional: return .provisional
case .denied: return .denied
default: return .notDetermined
}
}
func requestPermission() async throws -> Bool {
if isFixture {
fixturePermission = ProcessInfo.processInfo.environment["SYBIL_PUSH_DENIED"] == "1" ? .denied : .authorized
return fixturePermission == .authorized
}
guard environment != nil else { return false }
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
}
func register(generation: UUID) {
guard environment != nil else { return }
if let outstanding {
if outstanding != generation { queued = generation }
return
}
outstanding = generation
if isFixture {
// Deliberately synthetic bytes; never call Apple's registrar in a simulator.
Task { didRegister(Data([0x00, 0x01, 0x0a, 0xff] + Array(repeating: 0x42, count: 28))) }
} else {
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
UIApplication.shared.registerForRemoteNotifications()
#endif
}
}
func unregister() {
queued = nil; registeredGeneration = nil
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
UIApplication.shared.unregisterForRemoteNotifications()
#endif
// Do not reassign an old callback to a new session.
}
func didRegister(_ data: Data) {
let run = outstanding ?? registeredGeneration
outstanding = nil
if let run {
registeredGeneration = run
SybilAgentNotifications.shared.receivedDeviceToken(data, generation: run)
}
drainQueued()
}
func didFail() {
let run = outstanding ?? registeredGeneration
outstanding = nil
if let run { SybilAgentNotifications.shared.registrationFailed(generation: run) }
drainQueued()
}
private func drainQueued() {
if let next = queued { queued = nil; register(generation: next) }
}
func delivered() async -> [AgentDeliveredNotification] {
if isFixture || environment == nil { return [] }
return await center.deliveredNotifications().map {
AgentDeliveredNotification(identifier: $0.request.identifier, payload: AgentPushPayload($0.request.content.userInfo))
}
}
func removeDelivered(identifiers: [String]) {
guard !isFixture, environment != nil else { return }
center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
}
/// Public app-delegate bridge. The package coordinator remains injectable in tests.
@MainActor
public enum SybilPushNotifications {
public struct Event: Sendable {
let payload: AgentPushPayload?
public init(userInfo: [AnyHashable: Any]) { payload = AgentPushPayload(userInfo) }
}
#if DEBUG && targetEnvironment(simulator)
private static var fixtureWasActive = false
private static var fixtureForegroundSequence = 0
#endif
public static func didRegister(deviceToken: Data) { SybilNotificationPlatform.shared.didRegister(deviceToken) }
public static func didFailToRegister() { SybilNotificationPlatform.shared.didFail() }
public static func presentationOptions(event: Event) -> UNNotificationPresentationOptions {
guard let payload = event.payload else { return [] }
let notifications = SybilAgentNotifications.shared
Task { await notifications.syncRead() }
// Covers foreground bootstrap before a SwiftUI scene has attached too.
guard UIApplication.shared.applicationState != .active, notifications.shouldPresent(payload) else { return [] }
return [.banner, .list, .sound]
}
public static func didRespond(userInfo: [AnyHashable: Any], identifier: String) {
didRespond(event: Event(userInfo: userInfo), identifier: identifier)
}
public static func didRespond(event: Event, identifier: String) {
guard let payload = event.payload else { return }
SybilAgentNotifications.shared.notificationResponse(payload, identifier: identifier)
}
public static func receiveBackground(userInfo: [AnyHashable: Any], completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
guard let payload = AgentPushPayload(userInfo), payload.kind == .readState else { completion(.noData); return }
let notifications = SybilAgentNotifications.shared
let settings = SybilSettingsStore()
if let base = settings.normalizedAPIBaseURL {
notifications.restoreBackgroundScope(baseURL: base, token: settings.trimmedTokenOrNil, payload: payload)
}
AgentBackgroundCompletion.run(timeout: .seconds(5), operation: {
await notifications.receiveBackground(payload) ? .newData : .noData
}, completion: completion)
}
public static func launchFixtureIfRequested() {
#if DEBUG && targetEnvironment(simulator)
guard SybilNotificationPlatform.shared.isFixture,
ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP"] == "1" else { return }
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "outbound:3", "cursor": "3"], identifier: "fixture-system-request")
#endif
}
static func foregroundFixtureIfRequested() {
#if DEBUG && targetEnvironment(simulator)
guard SybilNotificationPlatform.shared.isFixture else { return }
let wasActive = fixtureWasActive
fixtureWasActive = true
guard wasActive, ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP_ON_FOREGROUND"] == "1" else { return }
fixtureForegroundSequence += 1
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "fixture-warm-tap-\(fixtureForegroundSequence)", "cursor": "3"], identifier: "fixture-warm-system-request")
#endif
}
}
/// A bounded callback gate: even an uncooperative notification-center fixture (or
/// OS suspension) cannot delay the fetch completion waiting for a network/socket.
@MainActor
enum AgentBackgroundCompletion {
static func run(timeout: Duration, operation: @escaping @MainActor () async -> UIBackgroundFetchResult,
completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
let gate = Gate(completion)
let work = Task { gate.finish(await operation()) }
Task {
try? await Task.sleep(for: timeout)
if !gate.finished { work.cancel(); gate.finish(.noData) }
}
}
@MainActor private final class Gate {
var finished = false
let completion: @MainActor (UIBackgroundFetchResult) -> Void
init(_ completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) { self.completion = completion }
func finish(_ result: UIBackgroundFetchResult) {
guard !finished else { return }
finished = true; completion(result)
}
}
}
@@ -2,6 +2,7 @@ import Observation
import SwiftUI
enum PhoneRoute: Hashable {
case agent
case chat(String)
case search(String)
case draftChat
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .agent: return .agent
case .settings:
return .settings
}
@@ -36,6 +38,11 @@ struct SybilPhoneShellView: View {
@State private var sidebarHighlightClearTask: Task<Void, Never>?
@State private var openingSelectionRequestID: UUID?
init(viewModel: SybilViewModel) {
self.viewModel = viewModel
_route = State(initialValue: viewModel.selectedItem == .agent ? .agent : .draftChat)
}
private var canRecognizeSidebarSwipe: Bool {
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
}
@@ -64,6 +71,7 @@ struct SybilPhoneShellView: View {
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .agent: return .agent
case .draftChat, .draftSearch, .settings:
return nil
}
@@ -87,6 +95,14 @@ struct SybilPhoneShellView: View {
.tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
viewModel.agent.isObscured = covered
}
.onDisappear { viewModel.agent.isObscured = false }
.onChange(of: viewModel.agentNotificationRouteRequest) { _, _ in
openingSelectionRequestID = nil
showRouteAndClearSidebarHighlight(.agent)
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -130,7 +146,10 @@ struct SybilPhoneShellView: View {
route: route,
onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay
onShowSidebar: showSidebarOverlay,
onOpenForkedChat: { chatID in
showRoute(.chat(chatID))
}
)
.background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
@@ -239,6 +258,7 @@ struct SybilPhoneShellView: View {
}
private func showRoute(_ nextRoute: PhoneRoute) {
dismissSidebarKeyboard()
let update = {
route = nextRoute
}
@@ -268,12 +288,19 @@ struct SybilPhoneShellView: View {
}
private func hideSidebarOverlay() {
dismissSidebarKeyboard()
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = false
}
resetSidebarSwipe(animated: false)
}
private func dismissSidebarKeyboard() {
if isSidebarOverlayPresented {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
private func openSidebarSelection(_ selection: SidebarSelection) {
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
return
@@ -511,7 +538,12 @@ private struct SybilPhoneSidebarRoot: View {
},
onSelect: { item in
onSelect(item.selection)
}
},
onOpenForkedChat: { chatID in
onSelect(.chat(chatID))
},
onSelectAgent: { onSelect(.agent) },
isAgentSelected: highlightedSelection == .agent
)
}
.background(SybilTheme.panelGradient)
@@ -585,6 +617,7 @@ private struct SybilPhoneDestinationView: View {
let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
let onOpenForkedChat: (String) -> Void
var body: some View {
SybilWorkspaceView(
@@ -593,7 +626,8 @@ private struct SybilPhoneDestinationView: View {
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
onRequestNewChat: onRequestNewChat,
onOpenForkedChat: onOpenForkedChat
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) {
@@ -613,6 +647,8 @@ private struct SybilPhoneDestinationView: View {
return
}
viewModel.select(.search(searchID))
case .agent:
viewModel.select(.agent)
case .draftChat:
viewModel.startNewChat()
case .draftSearch:
@@ -0,0 +1,85 @@
import SwiftUI
struct SybilPromptField: View {
var placeholder: String = "Enter Prompt"
@Binding var text: String
@FocusState.Binding var focused: Bool
var selection: Binding<TextSelection?>? = nil
var axis: Axis = .vertical
var submit: () -> Void
var body: some View {
Group {
if let selection {
TextField(placeholder, text: $text, selection: selection, axis: axis)
} else {
TextField(placeholder, text: $text, axis: axis)
}
}
.focused($focused)
.font(.sybil(.body))
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submit)
.submitOnMacReturn(submit)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
}
}
struct SybilPromptSendButton: View {
var systemImage: String = "arrow.up"
var canSend: Bool
var submit: () -> Void
var body: some View {
Button(action: submit) {
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
canSend
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
)
)
.foregroundStyle(canSend ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!canSend)
.accessibilityLabel("Send message")
}
}
/// The normal chat attachment control, shared with Agent's composer.
/// Picker ownership and availability stay with each composer.
struct SybilPromptAttachmentButton: View {
var isEnabled: Bool
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "paperclip")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(Circle().fill(SybilTheme.surface))
.overlay(Circle().stroke(SybilTheme.border.opacity(0.82), lineWidth: 1))
.foregroundStyle(isEnabled ? SybilTheme.text : SybilTheme.textMuted)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.accessibilityLabel("Attach file")
}
}
@@ -95,47 +95,19 @@ struct SybilQuickQuestionView: View {
private var composer: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .bottom, spacing: 10) {
TextField(
"Ask anything...",
SybilPromptField(
placeholder: "Ask anything...",
text: Binding(
get: { viewModel.quickQuestionPrompt },
set: { viewModel.updateQuickQuestionPrompt($0) }
),
focused: $promptFocused,
selection: $promptSelection,
axis: .vertical
axis: .horizontal,
submit: submitQuestion
)
.focused($promptFocused)
.font(.body)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submitQuestion)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button(action: submitQuestion) {
Image(systemName: "arrow.up")
.font(.body.weight(.semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendQuickQuestion
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surfaceStrong.opacity(0.92))
)
)
.foregroundStyle(viewModel.canSendQuickQuestion ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendQuickQuestion)
SybilPromptSendButton(canSend: viewModel.canSendQuickQuestion, submit: submitQuestion)
.accessibilityLabel("Ask quick question")
}
@@ -52,9 +52,9 @@ final class SybilSettingsStore {
self.preferredModelByProvider = preferredModels
self.quickQuestionPreferredProvider =
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? provider
defaults.string(forKey: Keys.quickQuestionPreferredProvider).flatMap(Provider.init(rawValue:)) ?? .openai
self.quickQuestionPreferredModelByProvider = [
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? preferredModels[.openai] ?? "gpt-4.1-mini",
.openai: defaults.string(forKey: Keys.quickQuestionPreferredOpenAIModel) ?? "chat-latest",
.anthropic: defaults.string(forKey: Keys.quickQuestionPreferredAnthropicModel) ?? preferredModels[.anthropic] ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.quickQuestionPreferredXAIModel) ?? preferredModels[.xai] ?? "grok-3-mini",
.gemini: defaults.string(forKey: Keys.quickQuestionPreferredGeminiModel) ?? preferredModels[.gemini] ?? "gemini-3.5-flash",
@@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UIKit
struct SybilSettingsView: View {
@Bindable var viewModel: SybilViewModel
@@ -86,6 +87,35 @@ struct SybilSettingsView: View {
.foregroundStyle(SybilTheme.textMuted)
}
VStack(alignment: .leading, spacing: 10) {
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
Text("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.")
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
Text(viewModel.notifications.status)
.font(.sybil(.footnote)).accessibilityIdentifier("agent-push-status")
if viewModel.notifications.pendingRevocations > 0 {
Text("A previous registration is awaiting revocation. It will retry when you reconnect with its original server credentials.")
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
}
if viewModel.notifications.optedIn {
Button("Disable Notifications") { Task { await viewModel.notifications.disable() } }
.accessibilityIdentifier("agent-push-disable")
} else {
Button("Enable Notifications") { Task { await viewModel.notifications.enable() } }
.disabled(!viewModel.notifications.available)
.accessibilityIdentifier("agent-push-enable")
}
if viewModel.notifications.permission == .denied {
Button("Open iOS Notification Settings") {
if let url = URL(string: UIApplication.openNotificationSettingsURLString) { UIApplication.shared.open(url) }
}
}
Button("Refresh Notification Status") { Task { await viewModel.notifications.refresh() } }
.accessibilityIdentifier("agent-push-refresh")
}
.buttonStyle(.bordered)
.tint(SybilTheme.primarySoft)
if let authError = viewModel.authError {
Text(authError)
.font(.sybil(.footnote))
@@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UIKit
struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel
@@ -111,8 +112,12 @@ struct SybilSidebarItemList: View {
@Bindable var viewModel: SybilViewModel
var isSelected: (SidebarItem) -> Bool
var onSelect: (SidebarItem) -> Void
var onOpenForkedChat: ((String) -> Void)? = nil
var onSelectAgent: (() -> Void)? = nil
var isAgentSelected: Bool? = nil
@State private var renameTarget: SidebarItem?
@State private var renameTitle = ""
@FocusState private var isSearchFocused: Bool
private var isRenameAlertPresented: Binding<Bool> {
Binding {
@@ -126,37 +131,122 @@ struct SybilSidebarItemList: View {
}
var body: some View {
VStack(spacing: 0) {
Button {
if let onSelectAgent { onSelectAgent() } else { viewModel.select(.agent) }
} label: {
HStack(spacing: 10) {
AgentAvatar()
Text("Sybil Agent").font(.sybil(.subheadline, weight: .semibold))
Spacer()
if viewModel.agent.isBusy { ProgressView().controlSize(.small).accessibilityLabel("Agent is working") }
if viewModel.agent.unreadCount > 0 {
Text(String(viewModel.agent.unreadCount))
.font(.sybil(.caption, weight: .semibold))
.padding(.horizontal, 7).padding(.vertical, 3)
.background(SybilTheme.primary, in: Capsule())
.accessibilityLabel("\(viewModel.agent.unreadCount) unread replies")
}
}
.foregroundStyle(SybilTheme.text)
.padding(12)
.background((isAgentSelected ?? (viewModel.selectedItem == .agent && viewModel.draftKind == nil)) ? SybilTheme.primary.opacity(0.22) : SybilTheme.surface.opacity(0.7), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border))
}
.buttonStyle(.plain)
.accessibilityIdentifier("agent-sidebar")
.padding(.horizontal, 10).padding(.top, 10)
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(SybilTheme.textMuted)
TextField("Search chats and web searches", text: Binding(
get: { viewModel.sidebarSearchQuery },
set: { viewModel.setSidebarSearchQuery($0) }
))
.font(.sybil(.subheadline))
.foregroundStyle(SybilTheme.text)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.focused($isSearchFocused)
.accessibilityLabel("Search conversation history and saved web searches")
.onSubmit {
isSearchFocused = false
Task { await viewModel.refreshSidebarSearch() }
}
if !viewModel.sidebarSearchQuery.isEmpty {
Button {
viewModel.setSidebarSearchQuery("")
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(SybilTheme.textMuted)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear history search")
}
}
.padding(12)
.background(SybilTheme.surface, in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(SybilTheme.border, lineWidth: 1))
.padding(10)
itemList
}
}
private var itemList: some View {
Group {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
if viewModel.isSearchingSidebar ||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
} else if let error = viewModel.sidebarSearchError, viewModel.isSidebarSearchActive {
VStack(spacing: 12) {
Text(error)
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.danger)
.multilineTextAlignment(.center)
Button("Try again") {
Task { await viewModel.refreshSidebarSearch() }
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else if viewModel.visibleSidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
Image(systemName: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
Text(viewModel.isSidebarSearchActive ? "No matching chats or web searches." : "Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
if viewModel.isSidebarSearchActive {
Text("Try a title, a query, or words from a conversation.")
.font(.sybil(.caption))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
ForEach(viewModel.visibleSidebarItems) { item in
Button {
isSearchFocused = false
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
}
.buttonStyle(.plain)
.contextMenu {
@@ -168,13 +258,26 @@ struct SybilSidebarItemList: View {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
}
if item.kind == .chat {
if case let .chat(chatID) = item.selection {
Button {
forkChat(item)
} label: {
Label("Fork", systemImage: "arrow.triangle.branch")
}
.disabled(viewModel.isForkingChat || item.isRunning)
Button {
renameTarget = item
renameTitle = item.title
} label: {
Label("Rename", systemImage: "pencil")
}
Button {
UIPasteboard.general.string = chatID
} label: {
Label("Copy Session ID", systemImage: "doc.on.doc")
}
}
Button(role: .destructive) {
@@ -186,9 +289,16 @@ struct SybilSidebarItemList: View {
}
}
}
if viewModel.isSidebarSearchActive && viewModel.sidebarSearchHasMore {
Text("Showing the 100 most recent matches. Refine your search to find more.")
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.padding(12)
}
}
.padding(10)
}
.scrollDismissesKeyboard(.interactively)
.refreshable {
await viewModel.refreshSidebarCollectionsFromPullToRefresh()
}
@@ -215,11 +325,52 @@ struct SybilSidebarItemList: View {
.disabled(renameTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private func forkChat(_ item: SidebarItem) {
guard case let .chat(chatID) = item.selection else {
return
}
let selectionAtStart = viewModel.selectedItem
let draftKindAtStart = viewModel.draftKind
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == draftKindAtStart else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
}
struct SybilSidebarRow: View {
var item: SidebarItem
var isSelected: Bool
var searchQuery = ""
private func highlightedSnippet(_ snippet: String) -> AttributedString {
var highlighted = AttributedString(snippet)
guard let words = try? NSRegularExpression(pattern: #"[\p{L}\p{N}\p{M}\p{Co}]+"#) else { return highlighted }
let terms = words.matches(in: searchQuery, range: NSRange(searchQuery.startIndex..., in: searchQuery)).compactMap { match -> String? in
guard let range = Range(match.range, in: searchQuery) else { return nil }
return String(searchQuery[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
}.filter { !$0.isEmpty }
for word in words.matches(in: snippet, range: NSRange(snippet.startIndex..., in: snippet)) {
guard let range = Range(word.range, in: snippet) else { continue }
let normalized = String(snippet[range]).folding(options: [.diacriticInsensitive, .caseInsensitive], locale: Locale(identifier: "en_US_POSIX"))
guard terms.contains(where: { normalized.hasPrefix($0) }),
let start = AttributedString.Index(range.lowerBound, within: highlighted),
let end = AttributedString.Index(range.upperBound, within: highlighted) else { continue }
highlighted[start..<end].font = .sybil(.caption, weight: .semibold)
}
return highlighted
}
private var isHighlighted: Bool {
isSelected
@@ -281,6 +432,15 @@ struct SybilSidebarRow: View {
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
if let snippet = item.searchSnippet, !snippet.isEmpty {
Text(highlightedSnippet(snippet.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)))
.font(.sybil(.caption))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.truncationMode(.middle)
.multilineTextAlignment(.leading)
}
}
.foregroundStyle(SybilTheme.text)
.padding(.horizontal, 12)
@@ -7,6 +7,7 @@ enum DraftKind {
}
enum SidebarSelection: Hashable {
case agent
case chat(String)
case search(String)
case settings
@@ -17,6 +18,8 @@ enum SidebarSelection: Hashable {
return "chat:\(chatID)"
case let .search(searchID):
return "search:\(searchID)"
case .agent:
return "agent"
case .settings:
return "settings"
}
@@ -38,6 +41,7 @@ struct SidebarItem: Identifiable, Hashable {
var starredAt: Date?
var initiatedLabel: String?
var isRunning: Bool
var searchSnippet: String? = nil
}
enum StreamingAssistantContentUpdate: Equatable, Sendable {
@@ -152,6 +156,10 @@ private actor SearchStreamStatus {
@Observable
final class SybilViewModel {
let settings: SybilSettingsStore
let agent = SybilAgentRoom()
let notifications: SybilAgentNotifications
private(set) var agentNotificationRouteRequest = 0
@ObservationIgnored private var sessionGeneration = UUID()
var isCheckingSession = true
var isAuthenticated = false
@@ -161,6 +169,15 @@ final class SybilViewModel {
var chats: [ChatSummary] = []
var searches: [SearchSummary] = []
var workspaceItems: [WorkspaceItem] = []
private(set) var sidebarSearchQuery = ""
private(set) var sidebarSearchResults: [WorkspaceItem] = []
private(set) var isSearchingSidebar = false
private(set) var sidebarSearchError: String?
private(set) var sidebarSearchHasMore = false
@ObservationIgnored
private var sidebarSearchTask: Task<Void, Never>?
@ObservationIgnored
private var sidebarSearchRequestID = UUID()
var selectedItem: SidebarSelection?
var selectedChat: ChatDetail?
@@ -170,6 +187,7 @@ final class SybilViewModel {
var isLoadingCollections = false
var isLoadingSelection = false
var isCreatingSearchChat = false
var isForkingChat = false
var chatBottomPinRequestID = 0
var errorMessage: String?
@@ -221,7 +239,7 @@ final class SybilViewModel {
private let clientFactory: (APIConfiguration) -> any SybilAPIClienting
private let fallbackModels: [Provider: [String]] = [
.openai: ["gpt-4.1-mini"],
.openai: ["gpt-4.1-mini", "chat-latest"],
.anthropic: ["claude-3-5-sonnet-latest"],
.xai: ["grok-3-mini"],
.gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -230,11 +248,13 @@ final class SybilViewModel {
init(
settings: SybilSettingsStore = SybilSettingsStore(),
notifications: SybilAgentNotifications? = nil,
clientFactory: @escaping (APIConfiguration) -> any SybilAPIClienting = { configuration in
SybilAPIClient(configuration: configuration)
}
) {
self.settings = settings
self.notifications = notifications ?? .shared
self.clientFactory = clientFactory
let initialProvider = settings.preferredProvider
let initialModel = settings.preferredModelByProvider[initialProvider] ?? "gpt-4.1-mini"
@@ -328,6 +348,8 @@ final class SybilViewModel {
}
return "Search"
case .agent:
return "Sybil Agent"
case .settings:
return "Settings"
}
@@ -367,6 +389,7 @@ final class SybilViewModel {
}
var showsComposer: Bool {
if selectedItem == .agent { return false }
if case .settings = selectedItem {
return false
}
@@ -481,7 +504,19 @@ final class SybilViewModel {
}
var sidebarItems: [SidebarItem] {
workspaceItems.map { item in
makeSidebarItems(workspaceItems)
}
var isSidebarSearchActive: Bool {
!sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var visibleSidebarItems: [SidebarItem] {
isSidebarSearchActive ? makeSidebarItems(sidebarSearchResults) : sidebarItems
}
private func makeSidebarItems(_ items: [WorkspaceItem]) -> [SidebarItem] {
items.map { item in
switch item.type {
case .chat:
let initiatedLabel: String?
@@ -495,7 +530,8 @@ final class SybilViewModel {
initiatedLabel = nil
}
let starOwner = item.parentChatId.flatMap { parentChatID in
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID })
workspaceItems.first(where: { $0.type == .chat && $0.id == parentChatID }) ??
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == parentChatID })
} ?? item
return SidebarItem(
@@ -506,7 +542,8 @@ final class SybilViewModel {
starred: starOwner.starred,
starredAt: starOwner.starredAt,
initiatedLabel: initiatedLabel,
isRunning: isChatRowRunning(item.id)
isRunning: isChatRowRunning(item.id),
searchSnippet: item.searchSnippet
)
case .search:
@@ -518,20 +555,70 @@ final class SybilViewModel {
starred: item.starred,
starredAt: item.starredAt,
initiatedLabel: "exa",
isRunning: isSearchRowRunning(item.id)
isRunning: isSearchRowRunning(item.id),
searchSnippet: item.searchSnippet
)
}
}
}
func setSidebarSearchQuery(_ query: String) {
let previous = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
sidebarSearchQuery = query
guard query.trimmingCharacters(in: .whitespacesAndNewlines) != previous else { return }
scheduleSidebarSearch()
}
func refreshSidebarSearch() async {
guard isSidebarSearchActive else { return }
scheduleSidebarSearch(debounce: false)
await sidebarSearchTask?.value
}
private func scheduleSidebarSearch(debounce: Bool = true) {
sidebarSearchTask?.cancel()
let requestID = UUID()
sidebarSearchRequestID = requestID
sidebarSearchResults = []
sidebarSearchError = nil
sidebarSearchHasMore = false
isSearchingSidebar = false
let query = sidebarSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty, isAuthenticated else { return }
guard query.utf16.count <= 200 else {
sidebarSearchError = "Use 200 characters or fewer."
return
}
isSearchingSidebar = true
sidebarSearchTask = Task { [weak self] in
do {
if debounce { try await Task.sleep(for: .milliseconds(300)) }
guard let self, !Task.isCancelled else { return }
let response = try await self.client().searchWorkspaceItems(query: query)
guard !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
self.sidebarSearchResults = response.items
self.sidebarSearchHasMore = response.hasMore
self.isSearchingSidebar = false
} catch {
guard let self, !Task.isCancelled, self.sidebarSearchRequestID == requestID else { return }
self.isSearchingSidebar = false
if !self.isCancellation(error) {
self.sidebarSearchError = self.normalizeAPIError(error)
}
}
}
}
var selectedChatSummary: ChatSummary? {
guard case let .chat(chatID) = selectedItem else { return nil }
return chats.first(where: { $0.id == chatID })
return chats.first(where: { $0.id == chatID }) ??
sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.chatSummary
}
var selectedSearchSummary: SearchSummary? {
guard case let .search(searchID) = selectedItem else { return nil }
return searches.first(where: { $0.id == searchID })
return searches.first(where: { $0.id == searchID }) ??
sidebarSearchResults.first(where: { $0.type == .search && $0.id == searchID })?.searchSummary
}
var hasRefreshableSelection: Bool {
@@ -542,7 +629,7 @@ final class SybilViewModel {
switch selectedItem {
case .chat, .search:
return true
case .settings:
case .settings, .agent:
return false
}
}
@@ -558,7 +645,13 @@ final class SybilViewModel {
}
func reconnect() async {
let run = UUID()
sessionGeneration = run
agent.disconnect()
agent.onAcknowledgedRead = nil
setSidebarSearchQuery("")
isCheckingSession = true
isAuthenticated = false
authError = nil
errorMessage = nil
resetSelectionLoading()
@@ -575,7 +668,13 @@ final class SybilViewModel {
resetQuickQuestion()
draftIdentity = UUID()
composerAttachments = []
// Keep the old authenticated push client alive until revoke has been
// attempted; never use the edited URL/token to revoke an old registration.
await notifications.transition(baseURL: settings.normalizedAPIBaseURL, token: settings.trimmedTokenOrNil)
guard sessionGeneration == run else { return }
settings.persist()
let baseURL = settings.normalizedAPIBaseURL
let pushToken = settings.trimmedTokenOrNil
SybilLog.info(
SybilLog.app,
@@ -585,6 +684,7 @@ final class SybilViewModel {
do {
let client = try client()
let session = try await client.verifySession()
guard sessionGeneration == run else { return }
isAuthenticated = session.authenticated
authMode = session.mode
authError = nil
@@ -595,8 +695,23 @@ final class SybilViewModel {
)
await loadInitialData(using: client)
guard sessionGeneration == run else { return }
if let pushClient = client as? any AgentPushAPIClienting, let baseURL {
await notifications.bind(client: pushClient, baseURL: baseURL, authenticated: session.authenticated, mode: session.mode, token: pushToken)
}
guard sessionGeneration == run else { return }
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL {
agent.onAcknowledgedRead = { [weak self] read in
guard let self, self.sessionGeneration == run else { return }
let pushRun = self.notifications.generation
Task { await self.notifications.acknowledged(read, generation: pushRun) }
}
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
agent.setActive(isAppActive)
}
startActiveRunPolling()
} catch {
guard sessionGeneration == run else { return }
stopActiveRunPolling()
isAuthenticated = false
authMode = nil
@@ -864,6 +979,15 @@ final class SybilViewModel {
private func beginSelecting(_ selection: SidebarSelection) -> Task<Void, Never>? {
SybilLog.debug(SybilLog.ui, "Selecting \(selection.id)")
// Search can open history outside the recent collection. Retain its
// summary for provider settings, starring, and subsequent refreshes.
if let item = sidebarSearchResults.first(where: {
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == selection
}) {
if let chat = item.chatSummary { applyChatSummary(chat, moveToFront: false) }
if let search = item.searchSummary { applySearchSummary(search, moveToFront: false) }
}
if draftKind == nil, selectedItem == selection {
errorMessage = nil
if case .search = selection {
@@ -896,7 +1020,7 @@ final class SybilViewModel {
selectedChat = nil
composerAttachments = []
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
return nil
@@ -914,7 +1038,7 @@ final class SybilViewModel {
}
private func selectAdjacentSidebarItem(offset: Int) {
let items = sidebarItems
let items = visibleSidebarItems
guard !items.isEmpty else {
return
}
@@ -934,6 +1058,7 @@ final class SybilViewModel {
}
func deleteItem(_ selection: SidebarSelection) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -946,10 +1071,11 @@ final class SybilViewModel {
try await client.deleteChat(chatID: chatID)
case let .search(searchID):
try await client.deleteSearch(searchID: searchID)
case .settings:
case .settings, .agent:
break
}
await refreshCollections(preferredSelection: nil)
await refreshSidebarSearch()
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Delete failed", error: error)
@@ -958,6 +1084,36 @@ final class SybilViewModel {
}
}
@discardableResult
func forkChat(chatID: String, messageID: String? = nil) async -> ChatSummary? {
guard isAuthenticated, !isForkingChat else {
return nil
}
guard messageID != nil || !isChatRowRunning(chatID) else {
errorMessage = "Wait for the response to finish before forking this chat."
return nil
}
let forkDescription = messageID.map { "chat \(chatID) from response \($0)" } ?? "chat \(chatID)"
SybilLog.info(SybilLog.ui, "Forking \(forkDescription)")
errorMessage = nil
isForkingChat = true
defer {
isForkingChat = false
}
do {
let forkedChat = try await client().forkChat(chatID: chatID, messageID: messageID)
applyChatSummary(forkedChat, moveToFront: true)
return forkedChat
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Fork failed", error: error)
return nil
}
}
func renameChat(chatID: String, title: String) async {
guard isAuthenticated else {
return
@@ -975,6 +1131,7 @@ final class SybilViewModel {
do {
let updated = try await client().updateChatTitle(chatID: chatID, title: trimmedTitle)
applyChatSummary(updated, moveToFront: true)
await refreshSidebarSearch()
} catch {
errorMessage = normalizeAPIError(error)
SybilLog.error(SybilLog.ui, "Rename failed", error: error)
@@ -982,6 +1139,7 @@ final class SybilViewModel {
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -999,7 +1157,7 @@ final class SybilViewModel {
case let .search(searchID):
let updated = try await client.updateSearchStar(searchID: searchID, starred: starred)
applySearchSummary(updated, moveToFront: false)
case .settings:
case .settings, .agent:
break
}
} catch {
@@ -1012,7 +1170,6 @@ final class SybilViewModel {
func refreshAfterSettingsChange() async {
SybilLog.info(SybilLog.ui, "Settings changed, reconnecting")
settings.persist()
await reconnect()
}
@@ -1022,12 +1179,25 @@ final class SybilViewModel {
}
isAppActive = false
agent.setActive(false)
appLifecycleGeneration += 1
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
}
func markAppActiveForNetwork() {
let wasInactive = !isAppActive
isAppActive = true
agent.setActive(true)
if wasInactive { Task { await notifications.refresh() } }
}
@discardableResult
func consumeAgentNotificationRoute() -> Bool {
guard isAuthenticated, !isCheckingSession, notifications.consumeRoute() else { return false }
agent.dismissAttachmentPresentations()
select(.agent)
agentNotificationRouteRequest += 1
return true
}
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
@@ -1074,6 +1244,10 @@ final class SybilViewModel {
}
func refreshSidebarCollectionsFromPullToRefresh() async {
if isSidebarSearchActive {
await refreshSidebarSearch()
return
}
guard isAuthenticated, !isCheckingSession else {
return
}
@@ -1351,7 +1525,7 @@ final class SybilViewModel {
if let nextSelection {
switch nextSelection {
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
case .chat, .search:
@@ -1414,7 +1588,15 @@ final class SybilViewModel {
async let activeRunsValue = client.getActiveRuns()
let (nextWorkspaceItems, nextActiveRuns) = try await (workspaceItemsValue, activeRunsValue)
applyWorkspaceItems(nextWorkspaceItems)
var items = nextWorkspaceItems
// A selected historical search result may be outside the latest 100.
if let preferredSelection,
let selectedSummary = workspaceItems.first(where: {
($0.type == .chat ? SidebarSelection.chat($0.id) : .search($0.id)) == preferredSelection
}), !items.contains(where: { $0.type == selectedSummary.type && $0.id == selectedSummary.id }) {
items.append(selectedSummary)
}
applyWorkspaceItems(items)
applyActiveRuns(nextActiveRuns)
SybilLog.info(
@@ -1592,6 +1774,11 @@ final class SybilViewModel {
}
private func upsertWorkspaceItem(_ item: WorkspaceItem, moveToFront: Bool) {
if let index = sidebarSearchResults.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
var updated = item
updated.searchSnippet = sidebarSearchResults[index].searchSnippet
sidebarSearchResults[index] = updated
}
if let existingIndex = workspaceItems.firstIndex(where: { $0.type == item.type && $0.id == item.id }) {
workspaceItems.remove(at: existingIndex)
if moveToFront {
@@ -1615,6 +1802,9 @@ final class SybilViewModel {
if let parentChatID = workspaceItems.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
if let parentChatID = sidebarSearchResults.first(where: { $0.type == .chat && $0.id == chatID })?.parentChatId {
return parentChatID
}
return chatID
}
@@ -1646,7 +1836,7 @@ final class SybilViewModel {
await self?.attachToActiveSearchStream(searchID: searchID)
}
case .settings, nil:
case .settings, .agent, nil:
return
}
}
@@ -1855,12 +2045,13 @@ final class SybilViewModel {
return selectedChat?.id != chatID
case let .search(searchID):
return selectedSearch?.id != searchID
case .settings:
case .settings, .agent:
return false
}
}
private func refreshSelectionIfNeeded() async {
if selectedItem == .agent { isLoadingSelection = false; return }
guard let target = selectedItem else {
selectedChat = nil
selectedSearch = nil
@@ -1902,7 +2093,7 @@ final class SybilViewModel {
selectedSearch = search
selectedChat = nil
case .settings:
case .settings, .agent:
break
}
errorMessage = nil
@@ -2618,7 +2809,7 @@ final class SybilViewModel {
return chats.contains(where: { $0.id == chatID })
case let .search(searchID):
return searches.contains(where: { $0.id == searchID })
case .settings:
case .settings, .agent:
return true
}
}
@@ -1,4 +1,3 @@
import ImageIO
import Observation
import PhotosUI
import SwiftUI
@@ -19,6 +18,7 @@ struct SybilWorkspaceView: View {
var onShowSidebar: (() -> Void)? = nil
var onRequestBack: ((_ animateNavigation: Bool) -> Void)? = nil
var onRequestNewChat: (() -> Void)? = nil
var onOpenForkedChat: ((String) -> Void)? = nil
@FocusState private var composerFocused: Bool
@Environment(\.dismiss) private var dismiss
@State private var isShowingAttachmentOptions = false
@@ -155,8 +155,6 @@ struct SybilWorkspaceView: View {
workspaceContentStack
if showsCustomWorkspaceNavigation {
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
.allowsHitTesting(false)
customWorkspaceNavigationBar
}
}
@@ -175,6 +173,8 @@ struct SybilWorkspaceView: View {
if isSettingsSelected {
SybilSettingsView(viewModel: viewModel)
.padding(.top, showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
} else if viewModel.selectedItem == .agent {
SybilAgentView(room: viewModel.agent, topInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0)
} else if viewModel.isSearchMode {
SybilSearchResultsView(
search: viewModel.displayedSearch,
@@ -196,7 +196,9 @@ struct SybilWorkspaceView: View {
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
bottomPinRequestID: viewModel.chatBottomPinRequestID
bottomPinRequestID: viewModel.chatBottomPinRequestID,
isForking: viewModel.isForkingChat,
onForkResponse: forkResponse
)
.id(transcriptScrollContextID)
}
@@ -230,6 +232,28 @@ struct SybilWorkspaceView: View {
}
}
private func forkResponse(messageID: String) {
guard viewModel.draftKind == nil,
case let .chat(chatID) = viewModel.selectedItem else {
return
}
let selectionAtStart = viewModel.selectedItem
Task {
guard let forkedChat = await viewModel.forkChat(chatID: chatID, messageID: messageID),
viewModel.selectedItem == selectionAtStart,
viewModel.draftKind == nil else {
return
}
if let onOpenForkedChat {
onOpenForkedChat(forkedChat.id)
} else {
viewModel.select(.chat(forkedChat.id))
}
}
}
private var customWorkspaceNavigationBar: some View {
HStack(spacing: 14) {
workspaceNavigationLeadingControl
@@ -263,7 +287,7 @@ struct SybilWorkspaceView: View {
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(selectedProviderModelSubtitle)
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
@@ -485,7 +509,7 @@ struct SybilWorkspaceView: View {
@ViewBuilder
private var workspaceNavigationTrailingControl: some View {
if isSettingsSelected {
if isSettingsSelected || viewModel.selectedItem == .agent {
EmptyView()
} else if viewModel.isSearchMode {
searchModeNavigationLabel
@@ -568,68 +592,25 @@ struct SybilWorkspaceView: View {
HStack(alignment: .bottom, spacing: 10) {
if !viewModel.isSearchMode {
Button {
SybilPromptAttachmentButton(isEnabled: !viewModel.isActiveSelectionSending) {
isShowingAttachmentOptions = true
} label: {
Image(systemName: "paperclip")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(SybilTheme.surface)
)
.overlay(
Circle()
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
)
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
}
.buttonStyle(.plain)
.disabled(viewModel.isActiveSelectionSending)
.accessibilityLabel("Attach file")
.accessibilityIdentifier("chat-attach")
}
TextField(
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
SybilPromptField(
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
text: $viewModel.composer,
axis: .vertical
focused: $composerFocused,
submit: submitComposer
)
.focused($composerFocused)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit {
submitComposer()
}
.submitOnMacReturn(submitComposer)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
Button {
submitComposer()
} label: {
Image(systemName: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
viewModel.canSendComposer
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
)
)
.foregroundStyle(viewModel.canSendComposer ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!viewModel.canSendComposer)
SybilPromptSendButton(
systemImage: viewModel.isSearchMode ? "magnifyingglass" : "arrow.up",
canSend: viewModel.canSendComposer,
submit: submitComposer
)
.accessibilityLabel(viewModel.isSearchMode ? "Search" : "Send message")
}
}
.padding(.horizontal, 14)
@@ -1350,173 +1331,6 @@ private struct SybilNavigationFadeBackground: View {
}
}
private struct SybilWorkspaceCharacterBackdrop: View {
var isBusy: Bool
var body: some View {
ZStack(alignment: .topTrailing) {
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.13),
Color.clear
],
center: .center,
startRadius: 10,
endRadius: 150
)
.frame(width: 136, height: 118)
.blur(radius: 7)
.offset(x: 28, y: -24)
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
.frame(width: 172, height: 172)
.opacity(0.92)
.mask {
LinearGradient(
colors: [
Color.black,
Color.black,
Color.black.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
}
.mask {
LinearGradient(
colors: [
Color.clear,
Color.black.opacity(0.98),
Color.black
],
startPoint: .leading,
endPoint: .trailing
)
}
.offset(x: 8, y: 4)
}
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
.clipped()
.ignoresSafeArea(edges: .top)
.accessibilityHidden(true)
}
}
private struct SybilAnimatedGIFView: UIViewRepresentable {
var resourceName: String
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIImageView {
let imageView = SybilAnimatedUIImageView()
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = false
imageView.isOpaque = false
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return imageView
}
func updateUIView(_ imageView: UIImageView, context: Context) {
guard context.coordinator.resourceName != resourceName else {
if !imageView.isAnimating {
imageView.startAnimating()
}
return
}
context.coordinator.resourceName = resourceName
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
imageView.startAnimating()
}
final class Coordinator {
var resourceName: String?
}
}
private final class SybilAnimatedUIImageView: UIImageView {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
}
@MainActor
private enum SybilAnimatedGIFCache {
private static var images: [String: UIImage] = [:]
static func image(named name: String) -> UIImage? {
if let cached = images[name] {
return cached
}
guard let image = loadImage(named: name) else {
return nil
}
images[name] = image
return image
}
private static func loadImage(named name: String) -> UIImage? {
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
Bundle.main.url(forResource: name, withExtension: "gif")
guard let url,
let data = try? Data(contentsOf: url),
let source = CGImageSourceCreateWithData(data as CFData, nil)
else {
return nil
}
let frameCount = CGImageSourceGetCount(source)
guard frameCount > 0 else {
return nil
}
var frames: [UIImage] = []
frames.reserveCapacity(frameCount)
var duration: TimeInterval = 0
for index in 0 ..< frameCount {
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
continue
}
frames.append(UIImage(cgImage: cgImage))
duration += frameDuration(at: index, source: source)
}
guard !frames.isEmpty else {
return nil
}
if frames.count == 1 {
return frames[0]
}
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
}
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else {
return 0.08
}
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
return max(delay, 0.03)
}
}
private struct NewChatSwipeBackdrop: View {
var progress: CGFloat
var hasLatched: Bool
@@ -0,0 +1,545 @@
import CryptoKit
import Foundation
import ImageIO
import Testing
import UIKit
import UniformTypeIdentifiers
@testable import Sybil
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = originalBytes, name: String = "original.bin") -> AgentAttachment {
AgentAttachment(id: id, kind: .file, filename: name, mimeType: "application/octet-stream", sizeBytes: data.count,
sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), workspacePath: "/runtime/provided/\(name)")
}
private func descriptorData(capability: Bool = true) 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] }
object["unknown"] = ["additive": true]
return try JSONSerialization.data(withJSONObject: object)
}
private final class AttachmentTestStore: @unchecked Sendable {
private let lock = NSLock()
private var rows: [AgentOutboxRow] = []
var storage: AgentOutboxStorage { .init(read: { self.read() }, write: { value in self.lock.withLock { self.rows = value } }) }
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
}
private actor AttachmentTestClient: AgentAPIClienting {
var uploads: [String] = []
var submissions: [AgentSubmission] = []
var failUploads = 0
var badReceipt = false
var uploadDelay = Duration.milliseconds(20)
var holdDownloads = false
var downloadWaiters: [String: CheckedContinuation<Data, Never>] = [:]
var downloadStarts: [String] = []
var downloadActive = 0
var downloadPeak = 0
var beforeSubmit: (@Sendable () -> Bool)?
var unavailableDescriptor = false
var unavailableSocket = false
func agentDescriptor() throws -> AgentDescriptor {
if unavailableDescriptor { throw AgentAttachmentIssue(message: "Synthetic open proxy requires authentication", code: "attachments_unavailable", statusCode: 503) }
return try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
}
func agentMessages(after: String?, before: String?) -> AgentPage { .init(conversationId: "root", messages: [], cursor: after ?? "0", hasMore: false) }
func agentReadState() -> AgentReadState { .init(cursor: "0") }
func agentMarkRead(cursor: String) -> AgentReadState { .init(cursor: cursor) }
func agentSubmit(content: String, requestID: String) -> AgentReceipt { .init(id: 1, status: "queued", messageId: "inbound:sybil:\(requestID)") }
func agentSubmit(_ submission: AgentSubmission) throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append(submission)
return .init(id: 1, status: "queued", messageId: "inbound:sybil:\(submission.requestId)", attachmentIds: badReceipt ? nil : submission.attachmentIDs)
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
uploads.append(uploadID)
try await Task.sleep(for: uploadDelay)
if failUploads > 0 { failUploads -= 1; throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507, details: ["futureQuotaField": .number(1)]) }
return AgentAttachment(id: "server-\(uploadID)", kind: .file, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
}
func agentDownload(_ attachment: AgentAttachment) async -> Data {
downloadStarts.append(attachment.id); downloadActive += 1; downloadPeak = max(downloadPeak, downloadActive)
defer { downloadActive -= 1 }
if holdDownloads { return await withCheckedContinuation { downloadWaiters[attachment.id] = $0 } }
return originalBytes
}
func pauseDownloads() { holdDownloads = true }
func releaseDownload(_ id: String) { downloadWaiters.removeValue(forKey: id)?.resume(returning: originalBytes) }
func downloadCounts() -> (Int, Int) { (downloadStarts.count, downloadPeak) }
func downloadedIDs() -> [String] { downloadStarts }
func agentSocket(after: String) throws -> any AgentSocket {
if unavailableSocket { return AttachmentUnavailableSocket() }
throw AgentFailure(message: "No socket in unit fixture")
}
func blockDescriptor() { unavailableDescriptor = true }
func blockSocket() { unavailableSocket = true }
func failNextUpload(delay: Duration = .milliseconds(20)) { failUploads = 1; uploadDelay = delay }
func delayUploads(_ delay: Duration) { uploadDelay = delay }
func malformedReceipts(_ value: Bool) { badReceipt = value }
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
func uploadIDs() -> [String] { uploads }
func sent() -> [AgentSubmission] { submissions }
}
private struct AttachmentUnavailableSocket: AgentSocket {
func receive() throws -> Data { throw AgentFailure(message: "Synthetic closed socket") }
func send(_ text: String) {}
func close() {}
func closeCode() -> Int { 4503 }
}
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
private final class AttachmentURLProtocol: URLProtocol, @unchecked Sendable {
private static let lock = NSLock()
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, [String: String], Data))?
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, [String: String], Data)) { lock.withLock { handler = value } }
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let handler = Self.lock.withLock { Self.handler! }
let (status, headers, data) = try handler(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
private func attachmentSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [AttachmentURLProtocol.self]
return URLSession(configuration: config)
}
private func requestBytes(_ request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open(); defer { stream.close() }
var data = Data(), buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(contentsOf: buffer.prefix(count)) }
return data
}
@Suite(.serialized) struct AgentAttachmentTests {
@Test func additiveCapabilityAndDistinctMetadata() throws {
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
#expect(old.capabilities == nil)
let current = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
#expect(current.capabilities?.attachments?.supported == true)
var future = try JSONSerialization.jsonObject(with: descriptorData()) as! [String: Any]
future["capabilities"] = ["attachments": ["version": 9, "newWire": true]]
let unsupported = try AgentCoding.decoder().decode(AgentDescriptor.self, from: JSONSerialization.data(withJSONObject: future))
#expect(unsupported.capabilities?.attachments == nil)
var other = attachmentLimits; other.version = 2
#expect(!other.supported)
let attachment = sampleAttachment()
let message = Message(id: "user", createdAt: Date(), role: .user, content: "caption", metadata: .object(["agentAttachments": .array([attachment.metadataValue]), "future": .bool(true)]))
#expect(message.attachments.isEmpty)
#expect(message.agentAttachments == [attachment])
#expect(message.content == "caption")
let legacy = Data(#"{"id":"x","kind":"file","filename":"x.bin","mimeType":"application/octet-stream","sizeBytes":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","unknown":true}"#.utf8)
#expect(try JSONDecoder().decode(AgentAttachment.self, from: legacy).workspacePath == nil)
}
@Test func safeNamesAndExactOriginalBytes() throws {
#expect(try AgentAttachmentPreparation.filename("cafe\u{301}.txt") == "café.txt")
for invalid in ["", ".", "..", "../secret", "x/y", "x\\y", "a\u{0000}b", "x\ny", String(repeating: "é", count: 128)] {
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.filename(invalid) }
}
let file = try AgentAttachmentPreparation.prepare(originalBytes, filename: "archive.zip", mimeType: "application/zip", limits: attachmentLimits)
#expect(file.data == originalBytes)
let text = try AgentAttachmentPreparation.prepare(originalBytes, filename: "invalid-utf8.txt", mimeType: "text/plain", limits: attachmentLimits)
#expect(text.data == originalBytes) // Server classifies as file; client never decodes/truncates upload bytes.
#expect(try AgentAttachmentPreparation.prepare(Data(), filename: "empty.txt", mimeType: "text/plain", limits: attachmentLimits).data.isEmpty)
var small = attachmentLimits; small.maxFileBytes = 2
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.prepare(originalBytes, filename: "x.bin", mimeType: "application/octet-stream", limits: small) }
}
@Test @MainActor func rasterValidationAndFileSelection() throws {
let image = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 8)).image { context in UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 12, height: 8)) }
let png = try #require(image.pngData())
#expect(try AgentAttachmentPreparation.prepare(png, filename: "red.png", mimeType: "image/png", limits: attachmentLimits).data == png)
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/jpeg") }
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/png", pixels: 20) }
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(Data("<svg/>".utf8), mime: "image/svg+xml") }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("sample.pdf"); try originalBytes.write(to: url)
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
#expect(file.data == originalBytes); #expect(file.mimeType == "application/pdf")
}
@Test @MainActor func syntheticHEICPhotoConvertsButFileKeepsOriginal() async throws {
let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1800)).image { context in
UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1800))
}
let encoded = NSMutableData()
let destination = try #require(CGImageDestinationCreateWithData(encoded, UTType.heic.identifier as CFString, 1, nil), "Simulator must provide an ImageIO HEIC encoder for this regression")
CGImageDestinationAddImage(destination, try #require(image.cgImage), nil)
#expect(CGImageDestinationFinalize(destination), "Synthetic HEIC encoding must succeed")
let data = encoded as Data
let prepared = try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(data, filename: "cafe\u{301}.HEIC", mimeType: "image/heic", limits: attachmentLimits)
}
#expect(prepared.filename == "café.jpg"); #expect(prepared.mimeType == "image/jpeg")
#expect(prepared.data.count <= attachmentLimits.imageLimit)
let source = try #require(CGImageSourceCreateWithData(prepared.data as CFData, nil))
#expect(CGImageSourceGetType(source) as String? == UTType.jpeg.identifier)
let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
#expect((properties[kCGImagePropertyPixelWidth] as? Int ?? Int.max) <= 2000)
#expect((properties[kCGImagePropertyPixelHeight] as? Int ?? Int.max) <= 2000)
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appendingPathComponent("original.heic"); try data.write(to: url)
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
#expect(file.data == data); #expect(file.filename == "original.heic"); #expect(file.mimeType == "image/heic")
var tooSmall = attachmentLimits; tooSmall.maxImagePixels = 100
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.photo(data, filename: "large.heic", mimeType: "image/heic", limits: tooSmall) }
}
@Test @MainActor func downloadCoordinatorBoundsAndCancelsQueuedTransfers() async throws {
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
await client.pauseDownloads()
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
let three = Task { try await downloads.data(for: sampleAttachment("three"), client: client) }
let cancelled = Task { try await downloads.data(for: sampleAttachment("cancelled"), client: client) }
try await Task.sleep(for: .milliseconds(30)); cancelled.cancel()
await #expect(throws: CancellationError.self) { try await cancelled.value }
#expect(await client.downloadCounts().0 == 2)
await client.releaseDownload("one"); _ = try await one.value
for _ in 0..<100 { if await client.downloadCounts().0 == 3 { break }; try await Task.sleep(for: .milliseconds(10)) }
#expect(await client.downloadCounts().1 == 2)
#expect(await client.downloadedIDs().contains("cancelled") == false)
await client.releaseDownload("two"); await client.releaseDownload("three")
_ = try await two.value; _ = try await three.value
#expect(try await downloads.data(for: sampleAttachment("one"), client: client) == originalBytes)
#expect(await client.downloadCounts().0 == 3) // Verified cache hit.
}
@Test @MainActor func configurationResetRejectsOldDownloadsAndCache() async throws {
let old = AttachmentTestClient(), next = AttachmentTestClient(), room = SybilAgentRoom()
room.configure(client: old, storage: AttachmentTestStore().storage)
defer { room.stop() }
await old.pauseDownloads()
let stale = Task { try await room.downloadAttachment(sampleAttachment()) }
for _ in 0..<100 { if await old.downloadCounts().0 == 1 { break }; try await Task.sleep(for: .milliseconds(10)) }
room.configure(client: next, storage: AttachmentTestStore().storage)
await old.releaseDownload(sampleAttachment().id)
await #expect(throws: CancellationError.self) { try await stale.value }
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
#expect(await next.downloadCounts().0 == 1)
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
#expect(await next.downloadCounts().0 == 1)
}
@Test @MainActor func invalidatedDownloadQueueNeverStartsOrCaches() async throws {
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
await client.pauseDownloads()
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
let queued = Task { try await downloads.data(for: sampleAttachment("queued"), client: client) }
try await Task.sleep(for: .milliseconds(20))
await downloads.invalidate()
await #expect(throws: CancellationError.self) { try await queued.value }
await client.releaseDownload("one"); await client.releaseDownload("two")
await #expect(throws: CancellationError.self) { try await one.value }
await #expect(throws: CancellationError.self) { try await two.value }
await #expect(throws: CancellationError.self) { try await downloads.data(for: sampleAttachment("one"), client: client) }
#expect(await client.downloadCounts().0 == 2)
}
@Test func orderedReferenceAndReceiptValidation() throws {
let a = sampleAttachment("one"), b = sampleAttachment("two")
let request = try AgentSubmission(content: " ", requestID: "stable", attachments: [a, b])
let fields = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any])
#expect((fields["attachments"] as? [[String: String]]) == [["id": "one"], ["id": "two"]])
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable") }
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable", attachments: [a, a]) }
for ids: [String]? in [nil, [], ["one"], ["two", "one"], ["one", "two", "extra"]] {
let receipt = AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ids)
#expect(throws: AgentAttachmentIssue.self) { try receipt.validate(requestID: "stable", attachmentIDs: ["one", "two"]) }
}
try AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ["one", "two"]).validate(requestID: "stable", attachmentIDs: request.attachmentIDs)
}
@Test func multipartPreservesBinaryAndBoundary() throws {
let id = UUID().uuidString
let file = AgentPreparedAttachment(filename: "café \"original\".bin", mimeType: "application/octet-stream", data: originalBytes)
let multipart = try AgentMultipart(file: file, uploadID: id)
let prefix = Data("\r\n\r\n".utf8)
#expect(multipart.body.range(of: originalBytes) != nil)
let printable = String(decoding: multipart.body, as: UTF8.self)
#expect(printable.components(separatedBy: "Content-Disposition:").count == 3)
#expect(printable.contains("name=\"uploadId\"\r\n\r\n\(id)"))
#expect(printable.contains("filename=\"café \\\"original\\\".bin\""))
#expect(multipart.body.suffix(Data("\r\n--\(multipart.boundary)--\r\n".utf8).count) == Data("\r\n--\(multipart.boundary)--\r\n".utf8))
#expect(multipart.body.range(of: prefix) != nil)
}
@Test func uploadTransportAuthBytesAndReceipt() async throws {
let file = AgentPreparedAttachment(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), id = UUID().uuidString
AttachmentURLProtocol.install { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
#expect(request.url?.query == nil)
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
#expect(request.url!.path == "/api/v1/agent/attachments"); #expect(request.httpMethod == "POST")
let contentType = try #require(request.value(forHTTPHeaderField: "Content-Type"))
let boundary = try #require(contentType.components(separatedBy: "boundary=").last)
let bytes = requestBytes(request)
#expect(bytes.starts(with: Data("--\(boundary)\r\n".utf8)))
#expect(bytes.range(of: originalBytes) != nil); #expect(bytes.range(of: Data(id.utf8)) != nil)
return (201, [:], try JSONEncoder().encode(["attachment": sampleAttachment()]))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
#expect(try await client.agentUpload(file: file, uploadID: id) == sampleAttachment())
}
@Test func missingCapabilityNeverUploadsOrSubmitsReferences() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/api/v1/agent")
return (200, [:], try descriptorData(capability: false))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "stable", attachments: [sampleAttachment()])) }
}
@Test func openPublicAPIDisablesAttachmentCapabilityAndDownloads() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/v1/agent")
return (200, [:], try descriptorData())
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!), session: session)
#expect(try await client.agentDescriptor().capabilities?.attachments == nil)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(sampleAttachment()) }
}
@Test func quotaCapabilitySnapshotAndDotFilenameAreAdditive() throws {
var fields = try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)) as! [String: Any]
fields.merge(["quotaBytes": 1073741824, "usedBytes": 20, "remainingBytes": 1073741804, "maxConcurrentUploads": 2, "maxConcurrentDecodes": 1, "future": true]) { _, next in next }
let limits = try JSONDecoder().decode(AgentAttachmentCapabilities.self, from: JSONSerialization.data(withJSONObject: fields))
#expect(limits.supported); #expect(limits.remainingBytes == 1073741804)
#expect(limits.maxConcurrentUploads == 2); #expect(limits.maxConcurrentDecodes == 1)
var attachment = sampleAttachment(name: ".notes.txt")
attachment.workspacePath = "/data/workspace/uploads/server-id/_notes.txt"
try attachment.validate()
#expect(attachment.filename == ".notes.txt")
#expect(attachment.workspacePath?.hasSuffix("/_notes.txt") == true)
}
@Test func downloadAuthOpaqueIDExactBytesAndLimits() async throws {
let attachment = sampleAttachment("opaque/part?value#x")
AttachmentURLProtocol.install { request in
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/opaque%2Fpart%3Fvalue%23x/content")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
return (200, ["Content-Length": String(originalBytes.count)], originalBytes)
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api/")!, authToken: "synthetic-test"), session: session)
#expect(try await client.agentDownload(attachment) == originalBytes)
AttachmentURLProtocol.install { _ in (200, [:], originalBytes + Data([9])) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 1, count: originalBytes.count)) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
}
@Test func errorCodesAndAdditiveQuotaFieldsSurvive() async throws {
for status in [409, 413, 422, 429, 507, 503] {
AttachmentURLProtocol.install { request in
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
return (status, status == 429 ? ["Retry-After": "2"] : [:], try JSONSerialization.data(withJSONObject: ["error": true, "message": "Actionable storage message", "statusCode": status, "code": status == 507 ? "attachment_quota_exceeded" : status == 422 ? "image_model_unsupported" : "attachments_busy", "futureByteField": 123]))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!, authToken: "synthetic-test"), session: session)
do { _ = try await client.agentDownload(sampleAttachment()); Issue.record("Expected failure") }
catch let issue as AgentAttachmentIssue {
#expect(issue.statusCode == status); #expect(issue.message == "Actionable storage message")
#expect(issue.details["futureByteField"] == .number(123))
if status == 507 { #expect(issue.code == "attachment_quota_exceeded") }
if status == 429 { #expect(issue.retryAfter == "2") }
if status == 422 { #expect(issue.code == "image_model_unsupported") }
}
}
}
@Test func redirectPolicyRejectsUntrustedOrigin() async throws {
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let task = session.dataTask(with: URL(string: "https://fixture.invalid/api")!)
let policy = AgentAttachmentRedirectPolicy()
let response = HTTPURLResponse(url: URL(string: "https://fixture.invalid/api")!, statusCode: 302, httpVersion: nil, headerFields: [:])!
await withCheckedContinuation { continuation in
policy.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: URLRequest(url: URL(string: "https://untrusted.invalid")!)) { request in
#expect(request == nil); continuation.resume()
}
}
task.cancel()
}
@Test @MainActor func orderedSelectionFailureRetryRemoveAndCapability() async throws {
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
await client.failNextUpload()
let first = try #require(draft.add(filename: "one.bin") { .init(filename: "one.bin", mimeType: "application/octet-stream", data: originalBytes) })
let second = try #require(draft.add(filename: "two.bin") { .init(filename: "two.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(!draft.canSend)
#expect(await attachmentEventually { draft.items[0].state == .failed && draft.items[1].state == .ready })
#expect(draft.items[0].issue?.code == "attachment_quota_exceeded")
#expect(!draft.canSend); draft.retry(first)
#expect(await attachmentEventually { draft.items.allSatisfy { $0.state == .ready } })
#expect(draft.ready.map(\.filename) == ["one.bin", "two.bin"])
#expect(await client.uploadIDs() == [first, second, first])
#expect(draft.canSend)
draft.updateCapability(nil); #expect(!draft.canSend)
#expect(draft.ready.count == 2)
draft.remove(first); draft.remove(second); draft.caption = "Text still works"
#expect(draft.canSend)
}
@Test @MainActor func removedInflightUploadCannotReappear() async throws {
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
await client.delayUploads(.milliseconds(250))
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
let id = try #require(draft.add(filename: "remove.bin") { .init(filename: "remove.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(await attachmentEventually { draft.items.first?.state == .uploading })
draft.remove(id)
try await Task.sleep(for: .milliseconds(300))
#expect(draft.items.isEmpty); #expect(!draft.canSend)
}
@Test @MainActor func stagedUploadAndReadyDraftSurviveRelaunch() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let storage = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
let client = AttachmentTestClient(), first = AgentAttachmentDraft()
await client.failNextUpload()
first.configure(client: client, storage: storage); first.updateCapability(attachmentLimits); first.caption = "Keep my caption"
let id = try #require(first.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(await attachmentEventually { first.items.first?.state == .failed })
first.suspend()
let next = AgentAttachmentDraft(); next.configure(client: client, storage: storage); next.updateCapability(attachmentLimits)
#expect(next.caption == "Keep my caption"); #expect(next.items.first?.id == id)
next.retry(id); #expect(await attachmentEventually { next.items.first?.state == .ready })
#expect(await client.uploadIDs() == [id, id]); #expect(try storage.bytes(id) == originalBytes)
let reopened = AgentAttachmentDraft(); reopened.configure(client: client, storage: storage)
#expect(reopened.ready == next.ready); #expect(!reopened.canSend)
reopened.updateCapability(attachmentLimits); #expect(reopened.canSend)
reopened.clearAfterSubmission(); #expect(try storage.read().items.isEmpty)
#expect(throws: (any Error).self) { try storage.bytes(id) }
}
@Test @MainActor func outboxReferencesPersistBeforePostRetryAndEchoWins() async throws {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
room.configure(client: client, storage: store.storage); defer { room.stop() }
#expect(await attachmentEventually { !room.isLoading })
await client.malformedReceipts(true)
await client.requirePersisted { store.read().first?.attachments == [sampleAttachment()] }
#expect(room.send("", attachments: [sampleAttachment()]))
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let original = try #require(room.outbox.first)
room.retry(original.id)
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let submitted = await client.sent()
#expect(submitted.count == 2); #expect(submitted.allSatisfy { $0.requestId == original.requestID && $0.attachmentIDs == [sampleAttachment().id] })
var echo = original.message
echo.content = "Attached: original.bin."
echo.metadata?.self = .object(["kind": .string("message"), "entryId": .number(1), "status": .string("complete"), "agentAttachments": .array([sampleAttachment().metadataValue])])
try room.receive(.messages(.init(conversationId: "root", messages: [echo], cursor: "1", hasMore: false)))
room.flush()
#expect(room.outbox.first?.confirmed == true); #expect(store.read().isEmpty)
#expect(room.messages.count == 1); #expect(room.messages.first?.agentAttachments == [sampleAttachment()])
#expect(room.messages.first?.createdAt == original.createdAt)
}
@Test @MainActor func failedDraftHandoffCannotPostOrCreateAnotherIdentityAfterRelaunch() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let disk = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
let draftID = UUID().uuidString
let item = AgentDraftItem(filename: "original.bin", state: .ready, attachment: sampleAttachment())
try disk.write(.init(caption: "Keep caption and references", items: [item], id: draftID))
var unavailable = disk
unavailable.write = { snapshot in
if snapshot.items.isEmpty { throw AgentAttachmentIssue.local("Synthetic disk failure") }
try disk.write(snapshot)
}
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
room.configure(client: client, storage: store.storage, draftStorage: unavailable)
#expect(await attachmentEventually { !room.isLoading })
#expect(!room.sendComposer())
#expect(room.composer == "Keep caption and references")
#expect(room.attachmentDraft.ready == [sampleAttachment()])
#expect(store.read().count == 1)
let saved = try #require(store.read().first)
#expect(saved.draftID == draftID)
room.retry(saved.id)
#expect(await client.sent().isEmpty)
room.stop()
let reopened = SybilAgentRoom()
reopened.configure(client: client, storage: store.storage, draftStorage: disk)
defer { reopened.stop() }
#expect(await attachmentEventually { !reopened.isLoading })
#expect(reopened.composer.isEmpty); #expect(reopened.attachmentDraft.items.isEmpty)
#expect(!reopened.sendComposer())
reopened.retry(saved.id)
#expect(await attachmentEventually { reopened.outbox.first?.seenOnServer == true })
#expect(await client.sent().map(\.requestId) == [saved.requestID])
#expect(await client.sent().first?.attachmentIDs == [sampleAttachment().id])
}
@Test @MainActor func openProxyHTTP503AndSocket4503KeepReadyDraftAndOutbox() async throws {
for socketFailure in [false, true] {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
if socketFailure { await client.blockSocket() } else { await client.blockDescriptor() }
let row = AgentOutboxRow(requestID: "unchanged-retry", content: "Retained caption", createdAt: Date(), attachments: [sampleAttachment()])
try store.storage.write([row])
let snapshot = AgentDraftSnapshot(caption: "Retained draft", items: [.init(filename: "original.bin", state: .ready, attachment: sampleAttachment())])
let draft = AgentDraftStorage(read: { snapshot }, write: { _ in }, stage: { _, _ in }, bytes: { _ in originalBytes }, remove: { _ in })
room.configure(client: client, storage: store.storage, draftStorage: draft)
#expect(await attachmentEventually { room.error?.contains(socketFailure ? "4503" : "Synthetic open proxy") == true })
#expect(!room.attachmentDraft.supportsAttachments)
#expect(room.composer == "Retained draft"); #expect(room.attachmentDraft.ready == [sampleAttachment()])
#expect(!room.sendComposer())
#expect(room.outbox.first?.requestID == "unchanged-retry")
#expect(store.read().first?.attachments == [sampleAttachment()])
#expect(await client.sent().isEmpty)
room.stop()
}
}
@Test @MainActor func mismatchedCanonicalAndQueuedEchoesNeverHideLocalReferencesOrRetry() async throws {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
await client.malformedReceipts(true)
room.configure(client: client, storage: store.storage); defer { room.stop() }
#expect(await attachmentEventually { !room.isLoading })
let attachments = [sampleAttachment("first"), sampleAttachment("second")]
#expect(room.send("Original caption", attachments: attachments))
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let row = try #require(room.outbox.first)
for (index, wrong) in [[], Array(attachments.reversed()), [attachments[0]]].enumerated() {
var message = row.message
message.content = "Incorrect server echo"
message.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(Double(index + 1)), "agentAttachments": .array(wrong.map(\.metadataValue))])
try room.receive(.messages(.init(conversationId: "root", messages: [message], cursor: String(index + 1), hasMore: false)))
var activity = room.snapshot.room.activity
activity.queuedMessages = [message]
try room.receive(.activity("root", activity)); room.flush()
#expect(room.outbox.first?.confirmed == false); #expect(room.outbox.first?.seenOnServer == false)
#expect(room.outbox.first?.status == "failed"); #expect(room.outbox.first?.error != nil)
#expect(room.messages.first?.agentAttachments == attachments)
#expect(room.messages.first?.content == "Original caption")
#expect(store.read().first?.requestID == row.requestID)
}
}
@Test func legacyOutboxAndQueuedHistoryKeepDistinctAttachmentsAndHiddenTools() throws {
let row = AgentOutboxRow(requestID: "stable", content: "caption", createdAt: Date(timeIntervalSince1970: 1))
#expect(try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(row)).attachments == nil)
var attached = row; attached.attachments = [sampleAttachment()]
let restored = try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(attached))
#expect(restored.attachments == attached.attachments); #expect(restored.requestID == row.requestID)
var queued = restored.message; queued.metadata = .object(["kind": .string("message"), "status": .string("queued"), "agentAttachments": .array([sampleAttachment().metadataValue])])
var committed = queued; committed.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(2), "agentAttachments": .array([sampleAttachment().metadataValue])])
let tool = Message(id: "hidden", createdAt: Date(), role: .tool, content: "raw tool", metadata: .object(["kind": .string("tool_call"), "entryId": .number(3)]))
var state = AgentRoomState()
try state.apply(.init(conversationId: "root", messages: [queued, tool], cursor: "3", hasMore: false))
try state.apply(.init(conversationId: "root", messages: [committed], cursor: "3", hasMore: false))
try state.apply(.init(conversationId: "root", messages: [queued], cursor: "2", hasMore: false))
#expect(state.messages.count == 2); #expect(state.cursor == "3"); #expect(state.unreadCount == 0)
let transcript = AgentTranscript(messages: state.messages)
#expect(transcript.messages.count == 1); #expect(transcript.messages[0].agentAttachments == [sampleAttachment()])
#expect(transcript.messages[0].attachments.isEmpty)
}
}
@@ -0,0 +1,416 @@
import Foundation
import Testing
import UIKit
@testable import Sybil
@MainActor private final class PushMemory {
var value = AgentPushPersistence()
var failWrite = false
var storage: AgentPushStorage { .init(read: { self.value }, write: { value in
if self.failWrite { throw URLError(.cannotWriteToFile) }
self.value = value
}) }
}
@MainActor private final class PushPlatform: AgentNotificationPlatform {
var environment: AgentAPNSEnvironment? = .sandbox
var allowsLoopback = true
var authorization: AgentNotificationPermission = .notDetermined
var allow = true
var prompts = 0
var registrations: [UUID] = []
var unregisters = 0
var notifications: [AgentDeliveredNotification] = []
var removed: [String] = []
var holdPermission = false
var holdPermissionRead = false
var permissionReadWaiter: CheckedContinuation<AgentNotificationPermission, Never>?
var permissionWaiter: CheckedContinuation<Bool, Never>?
func permission() async -> AgentNotificationPermission {
if holdPermissionRead { return await withCheckedContinuation { permissionReadWaiter = $0 } }
return authorization
}
func requestPermission() async -> Bool {
prompts += 1
if holdPermission { return await withCheckedContinuation { permissionWaiter = $0 } }
authorization = allow ? .authorized : .denied
return allow
}
func register(generation: UUID) { registrations.append(generation) }
func unregister() { unregisters += 1 }
func delivered() async -> [AgentDeliveredNotification] { notifications }
func removeDelivered(identifiers: [String]) { removed += identifiers; notifications.removeAll { identifiers.contains($0.identifier) } }
}
private actor PushAPI: AgentPushAPIClienting {
var config = AgentPushConfig(version: 1, enabled: true, apnsAvailable: true, webPushAvailable: false, sourceId: "11111111-1111-4111-8111-111111111111", quarantined: false)
var read = AgentReadState(cursor: "0", sourceId: "11111111-1111-4111-8111-111111111111")
var puts: [(UUID, AgentPushRegistration)] = []
var deletes: [UUID] = []
var failures = 0
var offline = false
var oldServer = false
var revokeStatus: Int?
var holdPut = false
var putWaiter: CheckedContinuation<Void, Never>?
var holdConfig = false
var configWaiter: CheckedContinuation<Void, Never>?
func agentPushConfig() async throws -> AgentPushConfig {
if holdConfig { await withCheckedContinuation { configWaiter = $0 } }
if oldServer { throw URLError(.badServerResponse) }
return config
}
func agentReadState() -> AgentReadState { read }
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
puts.append((id, registration))
if holdPut { await withCheckedContinuation { putWaiter = $0 } }
if failures > 0 { failures -= 1; throw URLError(.networkConnectionLost) }
return .init(installationId: id.uuidString, transport: "apns", enabled: true, version: puts.count)
}
func agentRevokePush(id: UUID) throws {
deletes.append(id)
if let revokeStatus { throw AgentPushRequestError(status: revokeStatus) }
if offline { throw URLError(.notConnectedToInternet) }
}
func failRevoke(status: Int) { revokeStatus = status }
func set(failures: Int = 0, offline: Bool = false, holdPut: Bool = false, oldServer: Bool = false, holdConfig: Bool = false) {
self.failures = failures; self.offline = offline; self.holdPut = holdPut; self.oldServer = oldServer; self.holdConfig = holdConfig
}
func source(_ id: String) { config.sourceId = id; read.sourceId = id }
func setAvailable(_ available: Bool) { config.enabled = available }
func releasePut() { holdPut = false; putWaiter?.resume(); putWaiter = nil }
func releaseConfig() { holdConfig = false; configWaiter?.resume(); configWaiter = nil }
func requests() -> [(UUID, AgentPushRegistration)] { puts }
func revokes() -> [UUID] { deletes }
func waitingPut() -> Bool { putWaiter != nil }
func waitingConfig() -> Bool { configWaiter != nil }
}
@MainActor private func pushEventually(_ predicate: () async -> Bool) async -> Bool {
for _ in 0..<300 { if await predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
private func pushPayload(_ cursor: String = "4", source: String = "11111111-1111-4111-8111-111111111111", kind: String = "message") -> AgentPushPayload {
AgentPushPayload(["version": 1, "sourceId": source, "kind": kind, "cursor": cursor, "readCursor": cursor, "messageId": "outbound:\(cursor)"])!
}
@MainActor private func bindPush(_ notifications: SybilAgentNotifications, _ api: PushAPI, url: String = "https://example.test/api", mode: String = "token") async {
await notifications.bind(client: api, baseURL: URL(string: url)!, authenticated: true, mode: mode, token: mode == "token" ? "fixture-token" : nil)
}
@Suite(.serialized) struct AgentNotificationTests {
@Test func payloadValidationAndCanonicalScope() throws {
#expect(AgentPushPayload(["version": true, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
#expect(AgentPushPayload(["version": 2, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
#expect(AgentPushPayload(["version": 1, "sourceId": "s", "kind": "message", "cursor": 1, "messageId": "m"]) == nil)
for cursor in ["-1", "1e3", "9007199254740992", "1.0", ""] { #expect(AgentPushPayload.validCursor(cursor) == nil) }
#expect(AgentPushPayload.validCursor("0004") == "4")
#expect(AgentPushScope.canonicalBackend(URL(string: "https://EXAMPLE.test:443/api/")!) == "https://example.test/api")
for url in ["https://user:secret@example.test/api", "https://example.test/api?token=bad", "https://example.test/#fragment", "http://example.test/api", "https://example.test/api/../other"] {
#expect(AgentPushScope.canonicalBackend(URL(string: url)!) == nil)
}
#expect(AgentAPNSEnvironment(entitlementSetting: "production") == .production)
#expect(AgentAPNSEnvironment(entitlementSetting: "development") == .sandbox)
#expect(AgentAPNSEnvironment(entitlementSetting: "DEBUG") == nil)
}
@Test @MainActor func explicitAllowFreshTokenRotationAndIdempotence() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
await push.enable()
#expect(platform.prompts == 1 && platform.registrations.count == 1)
#expect(await api.requests().isEmpty)
let run = push.generation
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
#expect(await pushEventually { await api.requests().count == 1 && memory.value.records.first?.version != nil })
let first = try #require(await api.requests().first)
#expect(first.1.token == "00010aff" && first.1.environment == .sandbox)
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
#expect(await api.requests().count == 1)
push.receivedDeviceToken(Data([0x42, 0xab]), generation: run)
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version == 2 })
let next = try #require(await api.requests().last)
#expect(first.0 == next.0 && next.1.token == "42ab")
let encoded = String(decoding: try JSONEncoder().encode(memory.value), as: UTF8.self)
#expect(!encoded.contains("00010aff") && !encoded.contains("42ab"))
await push.refresh()
#expect(platform.prompts == 1 && platform.registrations.count == 2)
let relaunchedPlatform = PushPlatform(); relaunchedPlatform.authorization = .authorized
let relaunched = SybilAgentNotifications(platform: relaunchedPlatform, storage: memory.storage)
await bindPush(relaunched, api)
#expect(relaunchedPlatform.registrations.count == 1 && relaunchedPlatform.prompts == 0)
#expect(await api.requests().count == 2) // persisted token is never used.
}
@Test @MainActor func deniedOpenOldServerAndPersistenceFailureNeverEnroll() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api, mode: "open"); await push.enable()
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
await api.set(oldServer: true); await bindPush(push, api)
#expect(!push.available && platform.prompts == 0)
await api.set(); await bindPush(push, api)
platform.allow = false; await push.enable()
#expect(platform.prompts == 1 && !push.optedIn && platform.registrations.isEmpty)
platform.authorization = .authorized; memory.failWrite = true
await push.enable()
#expect(platform.registrations.isEmpty)
#expect(await api.requests().isEmpty)
}
@Test @MainActor func retryThenRevokeUsesRetiredUUID() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable(); await api.set(failures: 1)
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
let ids = await api.requests().map(\.0)
#expect(Set(ids).count == 1)
await push.disable()
#expect(await api.revokes().contains(ids[0]))
#expect(!push.optedIn && push.pendingRevocations == 0)
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { await api.requests().count == 3 })
#expect(await api.requests().last?.0 != ids[0])
}
@Test @MainActor func logoutAndServerSwitchRejectLateOSAndUploadCallbacks() async throws {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, old); await push.enable(); await old.set(holdPut: true)
let oldRun = push.generation
push.receivedDeviceToken(Data([1]), generation: oldRun)
#expect(await pushEventually { await old.waitingPut() })
let oldID = try #require(await old.requests().first?.0)
await push.deactivate()
await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test/api")
await push.enable()
push.receivedDeviceToken(Data([0xff]), generation: oldRun)
#expect(await next.requests().isEmpty)
await old.releasePut()
#expect(await pushEventually { await old.revokes().contains(oldID) })
#expect(await next.revokes().isEmpty)
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222" && push.optedIn)
push.receivedDeviceToken(Data([2]), generation: push.generation)
#expect(await pushEventually { await next.requests().count == 1 })
}
@Test @MainActor func oldConfigAndPermissionCannotBindNewSession() async {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await old.set(holdConfig: true)
let binding = Task { await bindPush(push, old) }
#expect(await pushEventually { await old.waitingConfig() })
await push.deactivate(); await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test")
await old.releaseConfig(); await binding.value
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222")
platform.holdPermission = true
let enabling = Task { await push.enable() }
#expect(await pushEventually { platform.permissionWaiter != nil })
await push.deactivate()
platform.permissionWaiter?.resume(returning: true); await enabling.value
#expect(platform.registrations.isEmpty && !push.optedIn)
}
@Test @MainActor func offlineTombstoneSurvivesRelaunchAndOnlyExactScopeRetries() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable()
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
await api.set(offline: true); await push.deactivate()
#expect(memory.value.tombstones.count == 1)
let id = try #require(memory.value.tombstones.first?.installationID)
let next = PushAPI(), relaunched = SybilAgentNotifications(platform: PushPlatform(), storage: memory.storage)
await bindPush(relaunched, next, url: "https://other.test/api")
#expect(await next.revokes().isEmpty)
await relaunched.deactivate(); await next.source("33333333-3333-4333-8333-333333333333")
await bindPush(relaunched, next)
#expect(await next.revokes().isEmpty)
await relaunched.deactivate(); await next.source("11111111-1111-4111-8111-111111111111"); await bindPush(relaunched, next)
#expect(await next.revokes() == [id] && memory.value.tombstones.isEmpty)
}
@Test @MainActor func deliveredClearUsesSystemIdentifiersAndRetainsNewerOrUnrelated() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
platform.notifications = [
.init(identifier: "apns-system-a", payload: pushPayload("3")),
.init(identifier: "apns-system-b", payload: pushPayload("4")),
.init(identifier: "newer", payload: pushPayload("5")),
.init(identifier: "foreign", payload: pushPayload("1", source: "44444444-4444-4444-8444-444444444444")),
.init(identifier: "unrelated", payload: nil)]
await push.acknowledged(.init(cursor: "99"), generation: push.generation)
#expect(platform.removed.isEmpty) // legacy reads have no source authority.
await push.acknowledged(.init(cursor: "4", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
#expect(platform.removed == ["apns-system-a", "apns-system-b"])
#expect(platform.notifications.map(\.identifier) == ["newer", "foreign", "unrelated"])
await push.acknowledged(.init(cursor: "1", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
#expect(!push.shouldPresent(pushPayload("4")))
#expect(push.shouldPresent(pushPayload("5")))
let scene = UUID(); push.setScene(scene, active: true)
#expect(!push.shouldPresent(pushPayload("5")))
push.setScene(scene, active: false)
#expect(await push.receiveBackground(pushPayload("5", kind: "read_state")))
#expect(platform.removed.last == "newer")
#expect(await api.requests().isEmpty) // no read/enrollment side effect.
}
@Test @MainActor func coldTapWaitsForAuthenticationDeduplicatesAndDoesNotRead() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
let payload = pushPayload()
push.notificationResponse(payload, identifier: "system-1")
#expect(!push.consumeRoute())
let request = push.pendingRouteID
push.notificationResponse(payload, identifier: "system-2")
#expect(push.pendingRouteID == request)
await bindPush(push, api, mode: "open"); #expect(!push.consumeRoute())
await bindPush(push, api); #expect(push.consumeRoute()); #expect(!push.consumeRoute())
#expect(platform.removed.isEmpty)
#expect(await api.requests().isEmpty)
#expect(push.shouldPresent(payload)) // tap alone did not advance read.
push.notificationResponse(pushPayload(source: "44444444-4444-4444-8444-444444444444"), identifier: "wrong")
#expect(!push.consumeRoute())
}
@Test @MainActor func backgroundCompletionExactlyOnceIncludingTimeout() async {
var outcomes: [UIBackgroundFetchResult] = []
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { .newData }, completion: { outcomes.append($0) })
#expect(await pushEventually { outcomes.count == 1 })
try? await Task.sleep(for: .milliseconds(40)); #expect(outcomes == [.newData])
var continuation: CheckedContinuation<UIBackgroundFetchResult, Never>?
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { await withCheckedContinuation { continuation = $0 } }, completion: { outcomes.append($0) })
#expect(await pushEventually { outcomes.count == 2 })
continuation?.resume(returning: .newData)
try? await Task.sleep(for: .milliseconds(20)); #expect(outcomes == [.newData, .noData])
}
@Test @MainActor func changedCredentialEpochRequiresConsentAndRejectsColdReadHints() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let original = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(original, api); await original.enable()
original.receivedDeviceToken(Data([1]), generation: original.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
let replacementPlatform = PushPlatform(); replacementPlatform.authorization = .authorized
let replacement = SybilAgentNotifications(platform: replacementPlatform, storage: memory.storage)
replacement.restoreBackgroundScope(baseURL: URL(string: "https://example.test/api")!, token: "replacement-fixture-token", payload: pushPayload(kind: "read_state"))
#expect(replacement.scope == nil)
await replacement.bind(client: api, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
#expect(!replacement.optedIn && replacementPlatform.registrations.isEmpty)
#expect(memory.value.records.count == 2)
#expect(memory.value.records[0].scope.credentialScope != memory.value.records[1].scope.credentialScope)
#expect(await api.revokes().isEmpty)
#expect(replacement.scope?.credentialScope == memory.value.records.last?.scope.credentialScope)
}
@Test @MainActor func deniedPermissionThenAuthorizedRefreshRecovers() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
platform.authorization = .denied
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
#expect(push.permission == .denied)
platform.authorization = .authorized
await push.refresh()
#expect(push.permission == .authorized && push.available)
await push.enable()
#expect(platform.registrations.count == 1)
}
@Test @MainActor func equivalentCanonicalBackendDoesNotRetireConsent() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable()
let run = push.generation
await bindPush(push, api, url: "https://EXAMPLE.test:443/api/")
#expect(push.generation == run && push.optedIn)
#expect(memory.value.records.count == 1)
}
@Test @MainActor func sameBackendCredentialRotationWith403TombstoneAllowsNewConsentRoutingAndClear() async {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, old); await push.enable()
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
await old.failRevoke(status: 403)
await push.bind(client: next, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
#expect(!push.optedIn && memory.value.tombstones.count == 1)
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { await next.requests().count == 1 && push.status == "Notifications are enabled." })
push.notificationResponse(pushPayload(), identifier: "system-after-rotation")
#expect(push.consumeRoute())
platform.notifications = [.init(identifier: "actual-system-id", payload: pushPayload())]
#expect(await push.receiveBackground(pushPayload(kind: "read_state")))
#expect(platform.removed == ["actual-system-id"])
#expect(memory.value.tombstones.count == 1)
#expect(await next.revokes().isEmpty)
}
@Test @MainActor func distinctBackendWithSameRuntimeSourceStillRefusesPayloads() async {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, old); await push.enable()
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
await old.failRevoke(status: 403)
await bindPush(push, next, url: "https://other.test/api"); await push.enable()
push.receivedDeviceToken(Data([2]), generation: push.generation)
#expect(await pushEventually { await next.requests().count == 1 })
push.notificationResponse(pushPayload(), identifier: "ambiguous-server")
#expect(!push.consumeRoute())
#expect(!(await push.receiveBackground(pushPayload(kind: "read_state"))))
}
@Test @MainActor func disableDuringConfigOrPermissionRefreshDoesNotBlockNewRefresh() async {
for holdConfig in [true, false] {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
await api.set(holdConfig: holdConfig); platform.holdPermissionRead = !holdConfig
let oldRefresh = Task { await push.refresh() }
#expect(await pushEventually { holdConfig ? await api.waitingConfig() : platform.permissionReadWaiter != nil })
await push.disable()
if holdConfig { await api.releaseConfig() }
else { platform.holdPermissionRead = false; platform.permissionReadWaiter?.resume(returning: .denied) }
await oldRefresh.value
platform.authorization = .authorized
await push.refresh(); await push.enable()
#expect(push.permission == .authorized && platform.registrations.count == 1)
}
}
@Test @MainActor func capabilityRecoveryReenrollsSameTokenWithNewInstallation() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable()
let bytes = Data([1, 2, 3])
push.receivedDeviceToken(bytes, generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
let oldID = try #require(await api.requests().first?.0)
await api.setAvailable(false); await push.refresh()
#expect(await api.revokes().contains(oldID))
#expect(memory.value.records.first?.installationID != oldID)
await api.setAvailable(true); await push.refresh()
push.receivedDeviceToken(bytes, generation: push.generation)
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
#expect(await api.requests().last?.0 != oldID)
#expect(push.status == "Notifications are enabled.")
}
@Test @MainActor func optionalReadStateAndNativeAuthRemainCompatible() throws {
let old = try JSONDecoder().decode(AgentReadState.self, from: Data(#"{"cursor":"4","updatedAt":null}"#.utf8))
#expect(old.sourceId == nil)
let data = Data(#"{"type":"read_state","sourceId":"11111111-1111-4111-8111-111111111111","cursor":"5","updatedAt":null}"#.utf8)
guard case let .readState(read) = try AgentEvent.decode(data) else { Issue.record("Missing read frame"); return }
let room = SybilAgentRoom(); try room.applyRead(read); room.flush()
#expect(room.snapshot.room.readCursor == "5")
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "99", sourceId: "foreign")) }
room.flush()
#expect(room.snapshot.room.readCursor == "5")
#expect(room.requiresReload)
#expect(try AgentEvent.decode(Data(#"{"type":"read_state","cursor":"-1"}"#.utf8)) == nil)
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(APIConfiguration(baseURL: URL(string: "https://example.test")!, authToken: "fixture").agentAuthFrame(after: "0").utf8))
#expect(frame.objectValue?["clientKind"] == .string("native"))
#expect(frame.objectValue?["presenceSessionId"] == nil)
}
}
@@ -0,0 +1,88 @@
import Foundation
import Testing
@testable import Sybil
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
static let lock = NSLock()
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let handler = Self.lock.withLock { Self.handler! }
let (status, data) = try handler(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
}
private func pushRequestBody(_ request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open(); defer { stream.close() }
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
return bytes
}
@Suite(.serialized) struct AgentPushAPITests {
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
let id = UUID()
PushURLProtocol.install { request in
#expect(request.url?.host == "example.test")
#expect(request.url?.query == nil)
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
#expect(request.timeoutInterval == 8)
if request.httpMethod == "GET" {
#expect(request.url?.path == "/api/v1/agent/push/config")
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
}
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
if request.httpMethod == "DELETE" { return (204, Data()) }
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
#expect(object["token"] as? String == "00010aff")
#expect(object["environment"] as? String == "production")
#expect(object["topic"] as? String == "net.buzzert.sybil2")
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
}
let client = pushClient()
#expect(try await client.agentPushConfig().available)
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
try await client.agentRevokePush(id: id)
}
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
for status in [302, 401, 404, 409, 503] {
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
}
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
catch { }
}
}
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
let source = "11111111-1111-4111-8111-111111111111"
for expectedSource: String? in [nil, source] {
PushURLProtocol.install { request in
#expect(request.url?.path == "/api/v1/agent/read-state")
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
}
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
}
}
}
@@ -0,0 +1,589 @@
import Foundation
import Testing
@testable import Sybil
private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message {
Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b })))
}
private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage {
AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more)
}
private final class MemoryOutbox: @unchecked Sendable {
private let lock = NSLock()
private var rows: [AgentOutboxRow] = []
var storage: AgentOutboxStorage {
AgentOutboxStorage(read: { self.read() }, write: { self.save($0) })
}
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } }
}
private actor ControlledAgentSocket: AgentSocket {
let stream: AsyncThrowingStream<Data, any Error>
let continuation: AsyncThrowingStream<Data, any Error>.Continuation
private(set) var closed = false
init() { (stream, continuation) = AsyncThrowingStream.makeStream() }
func receive() async throws -> Data {
var iterator = stream.makeAsyncIterator()
guard let data = try await iterator.next() else { throw CancellationError() }
return data
}
func send(_ text: String) {}
func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) }
func closeCode() -> Int { 0 }
func push(_ text: String) { continuation.yield(Data(text.utf8)) }
}
private actor TestAgentClient: AgentAPIClienting {
var pages: [String: AgentPage]
var requests: [String] = []
var submissions: [(String, String)] = []
var read = "0"
var readSource: String?
var readWrites: [(cursor: String, source: String?)] = []
var writeReply: AgentReadState?
var writeConflict: Bool?
var holdWrite = false
var heldWrite: CheckedContinuation<Void, Never>?
var failRead = false
var failSubmit = false
var malformedReceipt = false
var socketAfters: [String] = []
var sockets: [ControlledAgentSocket] = []
var holdRead = false
var heldRead: CheckedContinuation<AgentReadState, Never>?
var beforeSubmit: (@Sendable () -> Bool)?
init(pages: [String: AgentPage] = [:]) { self.pages = pages }
func agentDescriptor() -> AgentDescriptor {
AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws")
}
func agentMessages(after: String?, before: String?) -> AgentPage {
let cursor = after ?? "0"; requests.append(cursor)
return pages[cursor] ?? agentPage([], cursor: cursor)
}
func agentReadState() async -> AgentReadState {
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
return AgentReadState(cursor: read, sourceId: readSource)
}
func pauseRead() { holdRead = true }
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); heldRead = nil }
func hasHeldRead() -> Bool { heldRead != nil }
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor, sourceID: nil)
}
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
readWrites.append((cursor, sourceID))
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
if let coded = writeConflict {
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
}
if failRead { throw AgentFailure(message: "Offline") }
if let writeReply { return writeReply }
read = try AgentCursor.advance(read, cursor)
return AgentReadState(cursor: read, sourceId: readSource)
}
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
func setWriteConflict(coded: Bool) { writeConflict = coded }
func pauseWrite() { holdWrite = true }
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
func hasHeldWrite() -> Bool { heldWrite != nil }
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append((content, requestID))
try await Task.sleep(for: .milliseconds(80))
if failSubmit { throw AgentFailure(message: "Lost acknowledgement") }
return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)")
}
func agentSocket(after: String) async -> any AgentSocket {
socketAfters.append(after)
let socket = ControlledAgentSocket(); sockets.append(socket)
await socket.push(#"{"type":"ready","conversationId":"root"}"#)
return socket
}
func connectedAfters() -> [String] { socketAfters }
func latestSocket() -> ControlledAgentSocket? { sockets.last }
func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed }
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
func submittedIDs() -> [String] { submissions.map(\.1) }
func requestedCursors() -> [String] { requests }
}
@MainActor
private func eventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 {
if predicate() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
struct AgentTests {
@Test @MainActor func mixedLoadedHistoryFiltersOnlyToolRows() async throws {
var user = agentMessage("user", entry: "1", role: .user)
user.content = "Explain the tool_call and tool result."
let call = agentMessage("call", entry: "2", role: .tool, info: ["status": .string("initiated")])
var reply = agentMessage("reply", entry: "3")
reply.content = "The search tool failed; here is what you can do."
// Role-only results still need hiding even without tool-call metadata.
var result = agentMessage("result", entry: "4", role: .tool)
result.metadata = nil
let markedCall = agentMessage("marked-call", entry: "5", info: ["kind": .string("tool_call")])
var plainReply = agentMessage("plain-reply", entry: "6")
plainReply.metadata = nil
let failure = agentMessage("failure", entry: "7", info: ["status": .string("failed"), "error": .string("Please try again")])
let terminal = agentMessage("terminal", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("Raw tool failure"), "resultPreview": .string("Raw result")])
let history = [user, call, reply, result, markedCall, plainReply, failure, terminal].enumerated().map { index, message in
var message = message
message.createdAt.addTimeInterval(Double(index))
return message
}
let client = TestAgentClient(pages: ["0": agentPage(Array(history.dropLast()), cursor: "7", more: true), "7": agentPage([history[7]], cursor: "8")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
#expect(await client.requestedCursors() == ["0", "7"])
#expect(await client.connectedAfters() == ["8"])
let transcript = AgentTranscript(messages: room.messages)
#expect(transcript.displayed == [history[0], history[2], history[5], history[6]])
#expect(room.snapshot.room.messages == history)
#expect(room.messages.count == 8)
#expect(room.snapshot.room.cursor == "8")
#expect(room.snapshot.room.readCursor == "0")
#expect(room.unreadCount == 2)
#expect(room.snapshot.entranceIDs.isEmpty)
// Shared regular-chat tool metadata remains available and unchanged.
#expect(terminal.isToolCallLog)
#expect(terminal.toolCallMetadata?.resultPreview == "Raw result")
}
@Test func transcriptWindowsAndAnchorsUseVisibleRows() {
var raw: [Message] = [], visible: [Message] = []
for index in 1...160 {
var message = agentMessage("reply-\(index)", entry: "\(index * 2)")
message.createdAt.addTimeInterval(Double(index * 2) * 86_400)
var tool = agentMessage("tool-\(index)", entry: "\(index * 2 + 1)", role: .tool)
// Tools on separate dates must not introduce their own separators.
tool.createdAt = message.createdAt.addingTimeInterval(86_400)
raw += [message, tool]; visible.append(message)
}
let initial = AgentTranscript(messages: raw)
#expect(initial.startIndex == 60)
#expect(initial.displayed == Array(visible.suffix(100)))
let anchorID = initial.displayed.first?.id
let hidden = agentMessage("late-tool", entry: "400", role: .tool)
let updated = AgentTranscript(messages: raw + [hidden], startID: anchorID)
#expect(updated.displayed == initial.displayed)
let earlierID = initial.messages[max(0, initial.startIndex - 100)].id
let expanded = AgentTranscript(messages: raw, startID: earlierID, limit: 200)
#expect(expanded.displayed == visible)
#expect(expanded.displayed.firstIndex(where: { $0.id == anchorID }) == 60)
let hiddenAnchor = AgentTranscript(messages: raw, startID: "tool-160")
#expect(hiddenAnchor.displayed == initial.displayed)
let onlyTools = AgentTranscript(messages: raw.filter { $0.role == .tool })
#expect(onlyTools.messages.isEmpty)
#expect(onlyTools.displayed.isEmpty)
#expect(onlyTools.startIndex == 0)
}
@Test @MainActor func liveToolUpsertsPreserveRawHistoryCursorsAndActivity() async throws {
let user = agentMessage("user", entry: "1", role: .user)
let reply = agentMessage("reply", entry: "2")
let client = TestAgentClient(pages: ["0": agentPage([user, reply], cursor: "2")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
let queued = agentMessage("queued", entry: "0", role: .user, info: ["status": .string("queued")])
var activity = AgentActivity()
activity.isThinking = true; activity.backgroundWorkers = 2; activity.queued = 1
activity.queuedMessages = [queued]
activity.wait = .init(due: 1_800_000_000_000, reason: "Waiting for background work")
activity.error = "A worker needs attention"
try room.receive(.activity("root", activity)); room.flush()
#expect(await eventually { room.snapshot.isTyping })
let visible = AgentTranscript(messages: room.messages).displayed
#expect(Set(visible.map(\.id)) == ["user", "reply", "queued"])
let start = agentMessage("tool", entry: "3", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
let completed = agentMessage("tool", entry: "4", role: .tool, info: ["status": .string("completed"), "resultPreview": .string("Raw preview")])
let result = agentMessage("tool", entry: "5", role: .tool, info: ["status": .string("completed"), "result": .object(["matches": .number(3)])])
for (message, cursor) in [(start, "3"), (completed, "4"), (result, "5"), (start, "3")] {
try room.receive(.messages(agentPage([message], cursor: cursor))); room.flush()
#expect(AgentTranscript(messages: room.messages).displayed == visible)
#expect(room.snapshot.room.messages.count == 3)
#expect(room.unreadCount == 1)
#expect(room.snapshot.room.readCursor == "0")
#expect(room.snapshot.isTyping)
#expect(room.snapshot.room.activity == activity)
}
let tool = try #require(room.snapshot.room.messages.first(where: { $0.id == "tool" })?.toolCallMetadata)
#expect(tool.status == "completed")
#expect(tool.args == .object(["query": .string("fixture")]))
#expect(tool.resultPreview == "Raw preview")
#expect(tool.result == .object(["matches": .number(3)]))
#expect(tool.startedAt == "start")
#expect(room.snapshot.room.cursor == "5")
// The rendered snapshot's cursor includes hidden tool updates. A later
// reply must remain unread when only that captured cursor is acknowledged.
room.isSelected = true
let capturedCursor = room.snapshot.room.cursor
let lateReply = agentMessage("late-reply", entry: "6")
let lateTool = agentMessage("late-tool", entry: "7", role: .tool)
try room.receive(.messages(agentPage([lateReply, lateTool], cursor: "7"))); room.flush()
room.markRead(renderedCursor: capturedCursor)
#expect(await eventually { room.snapshot.room.readCursor == "5" })
#expect(room.snapshot.room.cursor == "7")
#expect(room.unreadCount == 1)
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
#expect(!room.snapshot.isTyping)
#expect(room.isBusy)
let socket = try #require(await client.latestSocket())
await socket.push(#"{"type":"error","error":"Room connection unavailable"}"#)
#expect(await eventually { room.error == "Room connection unavailable" })
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
#expect(room.snapshot.room.messages.count == 5)
}
@Test @MainActor func failedSendDetailsAndRetryStayVisibleWithHiddenTools() async throws {
let tool = agentMessage("tool", entry: "1", role: .tool)
let client = TestAgentClient(pages: ["0": agentPage([tool], cursor: "1")])
await client.setFailures(submit: true)
let memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
#expect(room.send("Please retry the search tool"))
#expect(await eventually { room.outbox.first?.status == "failed" })
let failed = try #require(AgentTranscript(messages: room.messages).displayed.first)
let requestID = try #require(room.outbox.first?.requestID)
#expect(failed.role == .user)
#expect(failed.content == "Please retry the search tool")
#expect(failed.metadata?.objectValue?["status"] == .string("failed"))
#expect(failed.metadata?.objectValue?["error"] == .string("Lost acknowledgement"))
#expect(AgentTranscript(messages: room.messages).displayed.count == 1)
#expect(memory.read().first?.requestID == requestID)
await client.setFailures()
room.retry(failed.id)
#expect(await eventually { room.outbox.first?.status == "queued" })
let retried = try #require(AgentTranscript(messages: room.messages).displayed.first)
#expect(retried.id == failed.id)
#expect(retried.createdAt == failed.createdAt)
#expect(retried.metadata?.objectValue?["status"] == .string("queued"))
#expect(retried.metadata?.objectValue?["error"] == nil)
#expect(await client.submittedIDs() == [requestID, requestID])
#expect(room.snapshot.room.messages == [tool])
#expect(room.messages.count == 2)
}
@Test func cursorAndMalformedFrames() throws {
#expect(try AgentCursor.normalize("00012") == "12")
#expect(try AgentCursor.advance("9", "10") == "10")
#expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993")
#expect(throws: (any Error).self) { try AgentCursor.normalize("-1") }
var state = AgentRoomState()
try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10"))
let original = state
#expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) }
#expect(state == original)
#expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) }
#expect(state.cursor == "10")
let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)])
#expect(oversizedDuration.toolCallMetadata?.durationMs == nil)
}
@Test func terminalToolAndRestSocketRace() throws {
let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])])
end.createdAt.addTimeInterval(5)
var state = AgentRoomState()
try state.apply(agentPage([start], cursor: "5"))
try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9"))
try state.apply(agentPage([start], cursor: "5"))
#expect(state.cursor == "9")
#expect(state.messages.count == 2)
let tool = try #require(state.messages.first?.toolCallMetadata)
#expect(tool.status == "failed")
#expect(tool.error == "fixture failure")
#expect(tool.args == .object(["query": .string("fixture")]))
#expect(tool.startedAt == "start")
#expect(state.messages.first?.createdAt == start.createdAt)
}
@Test func unreadExcludesToolsAndUsers() throws {
var state = AgentRoomState()
try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12"))
state.readCursor = "9"
#expect(state.unreadCount == 1)
state.readCursor = try AgentCursor.advance(state.readCursor, "12")
state.readCursor = try AgentCursor.advance(state.readCursor, "2")
#expect(state.unreadCount == 0)
}
@Test func typingReplyAndStaleActivityAreAtomic() throws {
var presentation = AgentPresentation(), state = AgentRoomState()
let now = Date(timeIntervalSince1970: 100)
var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1
var after = state; after.activity = busy
presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now)
state = after
#expect(!presentation.isTyping(now: now.addingTimeInterval(0.249)))
#expect(presentation.isTyping(now: now.addingTimeInterval(0.251)))
let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1")
try after.apply(reply)
presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
state = after
#expect(!presentation.isTyping(now: now.addingTimeInterval(2)))
#expect(state.activity.isBusy)
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3))
#expect(!presentation.isTyping(now: now.addingTimeInterval(4)))
for name in AgentPresentation.quietTools {
busy.tools.append(.init(id: name, name: name, status: "initiated"))
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5))
#expect(!presentation.isTyping(now: now.addingTimeInterval(6)))
}
busy.tools.append(.init(id: "real", name: "search", status: "initiated"))
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7))
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
}
@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")
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8))
#expect(frame.objectValue?["token"] == .string("token"))
#expect(frame.objectValue?["after"] == .string("9"))
let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil)
let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8))
#expect(openFrame.objectValue?["token"] == .null)
}
@Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws {
let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
#expect(await client.requestedCursors() == ["0", "3", "7"])
#expect(room.snapshot.room.cursor == "8")
#expect(room.unreadCount == 1)
#expect(room.snapshot.entranceIDs.isEmpty)
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
room.flush()
#expect(room.snapshot.entranceIDs == ["late"])
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
room.flush()
#expect(room.snapshot.entranceIDs.isEmpty)
#expect(room.messages.count == 2)
}
@Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws {
let memory = MemoryOutbox(), client = TestAgentClient()
await client.setFailures(submit: true)
await client.requirePersisted { !memory.read().isEmpty }
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
#expect(room.send("identical")); #expect(room.send("identical"))
#expect(room.messages.count == 2)
let first = room.outbox[0], second = room.outbox[1]
#expect(first.requestID != second.requestID)
#expect(memory.read().count == 2)
try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1")))
room.flush()
#expect(await eventually { room.outbox[1].status == "failed" })
#expect(room.outbox[0].status == "complete")
#expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt)
room.retry(second.id)
#expect(await eventually { room.outbox[1].status == "failed" })
let ids = await client.submittedIDs()
#expect(ids.filter { $0 == second.requestID }.count == 2)
let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage)
defer { restored.stop() }
#expect(restored.outbox.count == 1)
#expect(restored.outbox[0].requestID == second.requestID)
#expect(restored.outbox[0].createdAt == second.createdAt)
}
@Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async {
let client = TestAgentClient(); await client.setFailures(malformed: true)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
room.send("fixture")
#expect(await eventually { room.outbox.first?.status == "failed" })
#expect(room.outbox.first?.seenOnServer == false)
let failed = SybilAgentRoom()
failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") }))
defer { failed.stop() }
failed.send("must not submit")
#expect(failed.outbox.first?.status == "failed")
#expect(await client.submittedIDs().count == 1)
}
@Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
room.isSelected = true
await client.setFailures(read: true)
room.markRead(renderedCursor: "1")
#expect(await eventually { room.error != nil })
#expect(room.snapshot.room.readCursor == "0")
await client.setFailures()
try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2")))
room.markRead(renderedCursor: "1")
#expect(await eventually { room.snapshot.room.readCursor == "1" })
#expect(room.unreadCount == 1)
room.isObscured = true; room.markRead(renderedCursor: "2")
try? await Task.sleep(for: .milliseconds(100))
#expect(room.snapshot.room.readCursor == "1")
try room.applyRead(AgentReadState(cursor: "2")); room.flush()
#expect(room.unreadCount == 0)
room.setActive(false); room.setActive(true)
#expect(await eventually { room.connection == "Connecting" })
#expect(room.messages.count == 2)
}
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
let sourceB = "22222222-2222-4222-8222-222222222222"
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90"))
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
for _ in 0..<100 {
if await client.hasHeldWrite() { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(await client.hasHeldWrite())
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
#expect(room.requiresReload && room.connection == "Reload required")
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
try await Task.sleep(for: .milliseconds(50))
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
#expect(await client.writtenReads().first?.source == nil)
#expect(room.messages.map(\.id) == ["old"])
// Explicit reconnect resets cursor space before discovering B or loading
// its history. The old queued100 can never be sent under sourceB.
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
room.retryConnection()
#expect(await eventually { room.connection == "Connected" })
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
#expect(room.messages.map(\.id) == ["new"])
room.markRead(renderedCursor: "1")
#expect(await eventually { room.snapshot.room.readCursor == "1" })
let writes = await client.writtenReads()
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
}
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
let room = SybilAgentRoom(); defer { room.stop() }
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
#expect(throws: AgentFailure.self) {
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
}
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
}
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
let sourceA = "11111111-1111-4111-8111-111111111111"
let sourceB = "22222222-2222-4222-8222-222222222222"
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; room.markRead(renderedCursor: "100")
#expect(await eventually { room.requiresReload })
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
try await Task.sleep(for: .milliseconds(30))
#expect(room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
#expect(await client.writtenReads().first?.source == sourceA)
room.stop()
}
}
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
for coded in [false, true] {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
await client.setWriteConflict(coded: coded)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; room.markRead(renderedCursor: "100")
#expect(await eventually { room.requiresReload })
room.markRead(renderedCursor: "100")
#expect(room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
room.stop()
}
}
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
let client = TestAgentClient(), memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
room.send("interrupted fixture")
let original = try #require(room.outbox.first)
room.setActive(false)
#expect(room.outbox.first?.status == "failed")
#expect(memory.read().first?.requestID == original.requestID)
room.setActive(true)
#expect(await eventually { room.connection == "Connected" })
room.retry(original.id)
#expect(await eventually { room.outbox.first?.seenOnServer == true })
#expect(room.outbox.first?.createdAt == original.createdAt)
#expect(await client.submittedIDs().allSatisfy { $0 == original.requestID })
}
@Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws {
let old = TestAgentClient(); await old.pauseRead()
let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage)
for _ in 0..<100 {
if await old.hasHeldRead() { break }
try? await Task.sleep(for: .milliseconds(10))
}
room.disconnect()
room.setActive(false); room.setActive(true)
#expect(await old.connectedAfters().isEmpty)
let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")])
room.configure(client: next, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
await old.releaseRead()
try? await Task.sleep(for: .milliseconds(50))
try await room.catchUp(client: next)
#expect(room.messages.map(\.id) == ["new"])
#expect(await next.requestedCursors() == ["0", "2"])
#expect(await old.connectedAfters().isEmpty)
}
@Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws {
let client = TestAgentClient()
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
let socket = try #require(await client.latestSocket())
let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"#
await socket.push(frame)
#expect(await eventually { room.snapshot.room.cursor == "7" })
room.setActive(false); room.setActive(true)
#expect(await eventually { room.connection == "Connected" })
let next = try #require(await client.latestSocket())
await next.push(frame)
try? await Task.sleep(for: .milliseconds(150))
#expect(await client.connectedAfters() == ["0", "7"])
#expect(room.messages.count == 1)
#expect(await socket.closed)
}
}
@@ -0,0 +1,54 @@
import MarkdownUI
import SwiftUI
import Testing
import UIKit
@testable import Sybil
@Suite @MainActor
struct MessagePresentationTests {
@Test func userMarkupRemainsLiteral() {
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
let content = SybilMessageText.literalContent(source)
#expect(content.renderPlainText() == source)
#expect(!content.renderHTML().contains("<strong>"))
#expect(!content.renderHTML().contains("<a "))
#expect(!content.renderHTML().contains("<code>"))
#expect(!content.renderHTML().contains("<ul>"))
#expect(content.renderHTML().contains("&lt;script&gt;"))
}
@Test func literalLineBreaksAndParagraphsStayDistinct() {
let content = SybilMessageText.literalContent("First\nSecond\n\nThird")
#expect(content.renderPlainText() == "First\nSecond\n\nThird")
#expect(content.renderHTML() == "<p>First\nSecond</p>\n<p>Third</p>\n")
// Empty paragraphs retain layout space instead of disappearing.
#expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "<p>").count == 6)
}
@Test func pastedParagraphBoundariesMatchMarkdown() {
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
#expect(SybilMessageText.literalContent(source).renderHTML() == "<p>First</p>\n<p>Second</p>\n")
}
#expect(SybilMessageText.literalContent("First\r\nSecond").renderPlainText() == "First\nSecond")
#expect(SybilMessageText.literalContent(" **literal** \n\nLast").renderPlainText() == " **literal** \n\nLast")
#expect(SybilMessageText.literalContent("First\r\n \r\n\t\r\n\r\nLast") == SybilMessageText.literalContent("First\n\n\n\nLast"))
}
@Test func bothFormatsShareLineParagraphMetricsAndDynamicType() {
func height(_ content: String, _ format: SybilMessageText.Format, _ size: DynamicTypeSize) -> CGFloat {
let host = UIHostingController(rootView: SybilMessageText(content: content, format: format)
.environment(\.dynamicTypeSize, size))
return host.sizeThatFits(in: CGSize(width: 320, height: 10_000)).height
}
let literal = "First line\nSecond line\n\nAnother paragraph"
let markdown = "First line \nSecond line\n\nAnother paragraph"
for size in [DynamicTypeSize.large, .accessibility3] {
#expect(abs(height(literal, .literal, size) - height(markdown, .markdown, size)) < 1)
}
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
#expect(abs(height(source, .literal, .large) - height(source, .markdown, .large)) < 1)
}
#expect(height(literal, .literal, .accessibility3) > height(literal, .literal, .large) * 1.5)
#expect(height(markdown, .markdown, .accessibility3) > height(markdown, .markdown, .large) * 1.5)
}
}
@@ -9,10 +9,14 @@ import UIKit
private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0
var workspaceSearchQueries: [String] = []
var listChats = 0
var listSearches = 0
var createChat = 0
var getChat = 0
var forkChat = 0
var lastForkChatID: String?
var lastForkMessageID: String?
var updateChatTitle = 0
var suggestChatTitle = 0
var updateChatStar = 0
@@ -40,14 +44,17 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem]
private let workspaceSearchHandler: @Sendable (String) async throws -> WorkspaceSearchResponse
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let forkChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let suggestChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse
private let modelCatalogResponse: ModelCatalogResponse
private var snapshot = MockClientCallSnapshot()
private var lastCreateChatCall: ChatCreateCallSnapshot?
@@ -74,24 +81,32 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
forkChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:],
suggestChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
workspaceItemsResponse: [WorkspaceItem]? = nil,
workspaceSearchHandler: @escaping @Sendable (String) async throws -> WorkspaceSearchResponse = { _ in
WorkspaceSearchResponse(items: [], hasMore: false)
},
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
) {
self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.workspaceSearchHandler = workspaceSearchHandler
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.forkChatResponse = forkChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.suggestChatTitleResponses = suggestChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse
self.modelCatalogResponse = modelCatalogResponse
}
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
@@ -177,6 +192,11 @@ private actor MockSybilClient: SybilAPIClienting {
return workspaceItemsResponse
}
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
snapshot.workspaceSearchQueries.append(query)
return try await workspaceSearchHandler(query)
}
func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
@@ -220,6 +240,16 @@ private actor MockSybilClient: SybilAPIClienting {
return detail
}
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
snapshot.forkChat += 1
snapshot.lastForkChatID = chatID
snapshot.lastForkMessageID = messageID
guard let forkChatResponse else {
throw UnexpectedClientCall()
}
return forkChatResponse
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
snapshot.updateChatTitle += 1
guard let summary = updateChatTitleResponses[chatID] else {
@@ -290,7 +320,7 @@ private actor MockSybilClient: SybilAPIClienting {
}
func listModels() async throws -> ModelCatalogResponse {
ModelCatalogResponse(providers: [:])
modelCatalogResponse
}
func getActiveRuns() async throws -> ActiveRunsResponse {
@@ -775,6 +805,63 @@ private func makeCompletionToolCall(
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_160)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of Chat chat-source"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
viewModel.selectedItem = .chat(source.id)
let result = await viewModel.forkChat(chatID: source.id)
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == nil)
#expect(result == forked)
#expect(viewModel.chats.first == forked)
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
#expect(viewModel.selectedItem == .chat(source.id))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func responseForkPassesAssistantMessageID() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_165)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of 'A useful response'"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == "assistant-message")
#expect(result == forked)
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func starringItemsUpdatesSidebarState() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_175)
@@ -1184,6 +1271,7 @@ private func makeCompletionToolCall(
#expect(snapshot.runQuickQuestionStream == 1)
#expect(snapshot.runCompletionStream == 0)
#expect(body?.provider == .openai)
#expect(body?.model == "chat-latest")
#expect(body?.question == "How do I reset my password?")
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
#expect(!viewModel.isQuickQuestionSending)
@@ -1297,6 +1385,42 @@ private func makeCompletionToolCall(
#expect(viewModel.quickQuestionPrompt.isEmpty)
}
@MainActor
@Test func quickQuestionDefaultsToChatLatestIndependentlyOfRegularChatPreferences() async {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
defaults.set("anthropic", forKey: "sybil.ios.preferredProvider")
defaults.set("gpt-4o", forKey: "sybil.ios.preferredOpenAIModel")
let settings = SybilSettingsStore(defaults: defaults)
let client = MockSybilClient(modelCatalogResponse: ModelCatalogResponse(providers: [
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "chat-latest"], loadedAt: nil, error: nil),
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest"], loadedAt: nil, error: nil)
]))
let viewModel = SybilViewModel(settings: settings) { _ in client }
#expect(viewModel.provider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
#expect(viewModel.quickQuestionProvider == .openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions.contains("chat-latest"))
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
await viewModel.bootstrap()
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions == ["gpt-4.1-mini", "chat-latest"])
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(SybilSettingsStore(defaults: defaults).quickQuestionPreferredModelByProvider[.openai] == "chat-latest")
#expect(settings.preferredProvider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
}
@MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
let defaults = UserDefaults(suiteName: #function)!
@@ -1885,3 +2009,173 @@ private final class MockQuickQuestionLifecycle {
controller.stop()
}
#endif
// Deliberately ignores cancellation so stale-response protection is exercised.
private actor WorkspaceSearchGate {
private var pending: [String: CheckedContinuation<WorkspaceSearchResponse, any Error>] = [:]
private var observers: [String: CheckedContinuation<Void, Never>] = [:]
func response(for query: String) async throws -> WorkspaceSearchResponse {
try await withCheckedThrowingContinuation { continuation in
pending[query] = continuation
observers.removeValue(forKey: query)?.resume()
}
}
func waitUntilRequested(_ query: String) async {
if pending[query] != nil { return }
await withCheckedContinuation { observers[query] = $0 }
}
func complete(_ query: String, with response: WorkspaceSearchResponse) {
pending.removeValue(forKey: query)?.resume(returning: response)
}
func fail(_ query: String) {
pending.removeValue(forKey: query)?.resume(throwing: APIError.networkError(message: "Offline"))
}
}
@MainActor
@Test func historySearchKeepsRecentItemsAndOpensOlderChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let recent = makeChatSummary(id: "recent", date: date)
let old = makeChatSummary(id: "old", date: date.addingTimeInterval(-100))
var match = WorkspaceItem(chat: old)
match.searchSnippet = "The nebula we discussed"
let response = WorkspaceSearchResponse(items: [match, WorkspaceItem(search: makeSearchSummary(id: "web", date: date))], hasMore: true)
let client = MockSybilClient(
chatsResponse: [recent],
chatDetails: ["old": makeChatDetail(id: "old", date: date, body: "The nebula we discussed")],
workspaceSearchHandler: { _ in response }
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.workspaceItems = [WorkspaceItem(chat: recent)]
viewModel.chats = [recent]
viewModel.selectedItem = .chat("recent")
viewModel.setSidebarSearchQuery(" nebula ")
await viewModel.refreshSidebarSearch()
#expect(viewModel.selectedItem == .chat("recent"))
#expect(viewModel.workspaceItems == [WorkspaceItem(chat: recent)])
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("old"), .search("web")])
#expect(viewModel.visibleSidebarItems.first?.searchSnippet == "The nebula we discussed")
#expect(viewModel.sidebarSearchHasMore)
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
await viewModel.selectForNavigation(.chat("old"))
#expect(viewModel.selectedChat?.id == "old")
viewModel.setSidebarSearchQuery("")
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
#expect(viewModel.selectedItem == .chat("old"))
#expect(viewModel.selectedChat?.id == "old")
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat("old") }))
}
@MainActor
@Test func historySearchDebouncesAndClearingRestoresRecentItems() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
let recent = WorkspaceItem(chat: makeChatSummary(id: "recent", date: Date()))
viewModel.workspaceItems = [recent]
viewModel.setSidebarSearchQuery("n")
viewModel.setSidebarSearchQuery("ne")
viewModel.setSidebarSearchQuery("nebula")
#expect(viewModel.isSearchingSidebar)
#expect(await client.currentSnapshot().workspaceSearchQueries.isEmpty)
try await Task.sleep(for: .milliseconds(600))
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
#expect(viewModel.visibleSidebarItems.isEmpty)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery(" ")
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("recent")])
#expect(!viewModel.isSidebarSearchActive)
#expect(viewModel.sidebarSearchError == nil)
}
@MainActor
@Test func historySearchDiscardsOutOfOrderAndClearedResponses() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.setSidebarSearchQuery("first")
let first = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("first")
viewModel.setSidebarSearchQuery("second")
let second = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("second")
let match = WorkspaceItem(chat: makeChatSummary(id: "second", date: Date()))
await gate.complete("second", with: WorkspaceSearchResponse(items: [match], hasMore: false))
await second.value
await gate.fail("first")
await first.value
#expect(viewModel.sidebarSearchResults == [match])
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery("third")
let third = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("third")
viewModel.setSidebarSearchQuery("")
await gate.complete("third", with: WorkspaceSearchResponse(items: [match], hasMore: true))
await third.value
#expect(viewModel.sidebarSearchResults.isEmpty)
#expect(!viewModel.sidebarSearchHasMore)
#expect(!viewModel.isSearchingSidebar)
}
@MainActor
@Test func historySearchFailureCanRetryWithoutChangingSelection() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.selectedItem = .chat("current")
viewModel.setSidebarSearchQuery("nebula")
let failed = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.fail("nebula")
await failed.value
#expect(viewModel.sidebarSearchError != nil)
#expect(viewModel.selectedItem == .chat("current"))
let retry = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.complete("nebula", with: WorkspaceSearchResponse(items: [], hasMore: false))
await retry.value
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
#expect(viewModel.selectedItem == .chat("current"))
viewModel.setSidebarSearchQuery(String(repeating: "a", count: 201))
#expect(viewModel.sidebarSearchError == "Use 200 characters or fewer.")
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula", "nebula"])
}
private final class HistorySearchURLProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let url = request.url!
#expect(url.path == "/api/v1/workspace-items")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "q", value: "café + AI & #ideas?")])
let json = #"{"items":[{"type":"chat","id":"old","title":"Notes","createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z","searchSnippet":"Some café ideas"}],"hasMore":false}"#
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data(json.utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@Test func historySearchEncodesQueryAndDecodesSnippet() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [HistorySearchURLProtocol.self]
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: APIConfiguration(baseURL: URL(string: "https://example.com/api")!, authToken: "test-token"), session: session)
let response = try await client.searchWorkspaceItems(query: "café + AI & #ideas?")
#expect(response.items.first?.searchSnippet == "Some café ideas")
#expect(!response.hasMore)
}
+149 -2
View File
@@ -5,6 +5,146 @@ Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
release/archive signing settings are unchanged.
The conversation sidebar includes **Search chats and web searches** on iPhone,
iPad, and Mac. It searches all saved history by title, conversation text, and saved
web-search query/answer, with single-line excerpts that bold matching words and
truncate in the middle. Search waits briefly while typing,
cancels superseded requests, and leaves the current conversation open. Clear the
field to return to recent items. Searches use word prefixes (for example, `plan`
finds `planning`); if more than 100 items match, refine the query.
## Sybil Agent
The permanent **Sybil Agent** sidebar item opens the persistent room on both
phone and iPad. It uses the configured Sybil API URL (including an explicit
`/api` path) and token. The room stays subscribed while other chats are selected;
foreground and network recovery replay from the last merged cursor. Shared unread
counts include only public assistant replies and are acknowledged after the
visible transcript reaches the bottom and its entrance animation finishes.
Multiple messages can be queued while the Agent works. The local outbox is saved
atomically under Application Support before submission and is scoped to the API
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
its request ID and timestamp across relaunch. A canonical server echo confirms it
even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
in the Agent transcript, including loaded history and live updates. History windows,
date separators and scroll anchors use visible messages; raw merged history and
cursors remain intact. Send failures, room errors and activity indicators remain
visible. Model selection and tool cards continue to belong to ordinary chats.
Agent contracts remain in `../docs/api/rest.md` and
`../docs/api/streaming-chat.md`. ROOT attachments consume the coordinated v1
contract (2026-10-14), now documented in the integrated API sources above.
Attachment readiness requires the coordinated backend/runtime and existing public
API bearer authentication. This iOS change does not deploy or configure them.
### ROOT attachments
The Agent composer exposes Files and Photo Library only when the authenticated
descriptor advertises supported `capabilities.attachments.version == 1`. Missing,
malformed, or unsupported capability keeps the client text-only. Ordinary chat
attachments retain their existing inline format and behavior.
Open-proxy HTTP 503 / WebSocket 4503 failures disable attachment selection while
retaining draft descriptors and outbox retry identities; reconnect checks support.
Files preserve their original bytes, including PDF, archives, text and HEIC files.
Photo Library HEIC/HEIF selections are intentionally converted to bounded JPEG
visual input with a matching `.jpg` filename. PNG/JPEG/GIF/WebP are validated as
raster images; model input is static, with no claim of document or animation
understanding. Safe original names supplied by the picker are retained; PhotosPicker
may provide an export filename rather than the original Photo Library asset name.
The production app does not request broad Photo Library access.
Selection order is stable. One preparation/upload runs at a time, with per-item
loading, upload, error, retry and removal. Sending requires every item to be ready;
an empty caption can send ready attachments. Advertised limits are checked against
v1 hard ceilings (8 files, 32 MiB total, 6 MiB/image, 8 MiB/file, 50 million pixels,
16,384 pixels/axis). Quota snapshots are decoded but do not reserve space. Server
messages/codes, quota details and Retry-After survive failures, including 409, 413,
429 and 507. Removal cancels local work and removes the reference; it does not
delete an uploaded server original.
The caption, ordered upload UUIDs and ready descriptors are saved in a small atomic
manifest under Application Support, scoped to the API base URL. Prepared bytes are
staged in app-owned files (at most 8 MiB each and 32 MiB per draft); no inline bytes
or tokens enter the outbox. Relaunch restores ready drafts and offers explicit retry
for interrupted staged uploads with the same upload UUID. A selection interrupted
before staging must be removed and selected again. Uploads suspend when the room
stops; failed work does not automatically retry. Uploaded originals have no client
expiration. After submission, the outbox owns the caption, ordered descriptors and
stable request ID. A persisted draft identity recovers a crash during this handoff
without creating a second send. Storage failure blocks submission rather than
discarding a draft. Attachment receipts must acknowledge exactly the submitted IDs
in order; matching canonical echoes still win ambiguous HTTP failures.
History and queued/live messages decode only `metadata.agentAttachments` for ROOT.
Cards retain filename, MIME, size and the optional server-provided `workspacePath`,
which can be copied. The client never constructs a runtime path or sends one as a
reference; leading-dot display filenames remain unchanged. Captions remain public
text, including the server's fallback for attachment-only messages.
Original downloads use the configured authenticated URLSession and API base
(including `/api`), reject redirects, and verify exact size and SHA-256. At most two
downloads run concurrently, with a 16 MiB in-memory cache discarded on configuration
change. Queued/active cancellation and generation checks prevent stale previews
from crossing configurations. Raster previews use bounded thumbnails; text previews
are plain text, including HTML/SVG. Save/share exports exact original bytes to a
temporary file removed when its card disappears or configuration changes. An app
crash may leave a temporary export for normal OS temporary-directory cleanup.
Image preview frames have fixed height to preserve scroll anchors during loading.
Attachment modals track their own visibility without changing Quick Question state.
### Agent regression and Simulator checks
Run these commands from this worktree's `ios/` directory:
```sh
just build
just test
just run
just screenshot build/agent-evidence/simulator.png
```
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
regressions along with attachment capability/metadata, multipart/auth/bytes/errors,
HEIC conversion versus original-file preservation, ordered receipts, draft/outbox
relaunch, cancellation, two-transfer concurrency and configuration isolation tests
through Xcode on iPhone 16e.
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
```sh
python3 Tests/Fixtures/agent_server.py
```
In a second terminal, run:
```sh
just generate
xcodebuild test -scheme SybilAgentUI \
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
-parallel-testing-enabled NO \
-resultBundlePath build/agent-evidence/phone-ui.xcresult
```
The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts
external services. UI tests exercise the real REST/WebSocket client and retain
screenshots in the result bundle. They cover queued submissions, uncertain
admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
anchors and multiline keyboard layout. The launch arguments select the fixture
through ordinary API settings; no fixture mode is compiled into the application.
Attachment cases exercise authenticated originals, safe text/raster display,
history reload, capability absence and picker cancellation. The real Photo Library
selection-to-send test is explicitly skipped: this iOS 26.5 XCTest environment has
unreliable Photos authorization, callback isolation and synthetic-asset cleanup.
Selection-to-send remains a manual/device pre-release check, not a validated UI
path. The gate prevents photo mutations during the general UI suite. Synthetic
HEIC conversion and original HEIC file preservation are covered by package tests.
The fixture accepts multipart uploads, stable upload/message retries, structured
errors and attachment receipts; it is not a runtime image-decoder or quota test.
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
Simulator Reduce Motion enabled before release.
## Quick Question on macOS
While the Catalyst app is running, **Option+Space** toggles a floating Quick
@@ -16,8 +156,13 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
**Open in Chat** saves the question and answer in the regular workspace.
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
provider/model selection takes precedence and is separate from regular chat preferences.
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
Question, while the normal chat/search composer continues to use Return for a
new line.
Closing the last Mac window leaves Sybil running, so Option+Space still works.
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
@@ -66,7 +211,9 @@ action, and does not build or embed the macOS helper.
answer remain available. Reopen the workspace from the panel, then explicitly
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
it. On iOS, verify Return submits Quick Question while Return in the normal
prompt adds a newline. In the Mac workspace, verify Return submits and
Shift+Return adds a newline.
- Check Command+N both with a workspace open and after closing every workspace.
Command+Shift+N must open another window even when one already exists, and
Command+Option+N must start a search without opening a window.
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Local-only deterministic Agent contract fixture. Never contacts external services.
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
"""
import asyncio
import datetime
import json
import hashlib
import unicodedata
import uuid
from urllib.parse import quote
from aiohttp import web
sockets = set()
messages = []
requests = {}
read_cursor = 0
cursor = 0
busy = False
attempts = {}
log = []
reply_tasks = set()
attachments = {}
uploads = {}
attachment_capability = True
next_upload_error = None
blocked_proxy = False
transcript_comparison = False
PUSH_SOURCE = '11111111-1111-4111-8111-111111111111'
push_installations = {}
push_retired = set()
push_requests = []
push_available = True
auth_required = False
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
def attachment_error(status, code, message, **fields):
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
headers={"Retry-After": "2"} if status == 429 else None)
def stamp():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
def activity():
return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0,
backgroundWorkers=0, tools=[], queuedMessages=[])
def message(content, role='assistant', id=None, info=None):
global cursor
cursor += 1
row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(),
metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {})))
messages.append(row)
return row
def page(rows, position=None, more=False):
return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more)
async def broadcast(frame):
for socket in list(sockets):
try:
await socket.send_json(frame)
except ConnectionError:
sockets.discard(socket)
async def emit(content):
row = message(content)
await broadcast(dict(type='messages', **page([row])))
return row
async def reply(content):
global busy, cursor
await asyncio.sleep(1.2)
tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}')
tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp())
await broadcast(dict(type='messages', **page([tool])))
await asyncio.sleep(0.8)
cursor += 1
tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches'))
messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool
await broadcast(dict(type='messages', **page([tool])))
failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}')
failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure')
await broadcast(dict(type='messages', **page([failed])))
await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.')
# Deliberately stale busy frame after the public reply.
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
await asyncio.sleep(0.5)
busy = False
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
async def route(request):
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
path = request.path
log.append(dict(method=request.method, path=path, query=dict(request.query)))
if path == '/control':
body = await request.json()
action = body.get('action')
if action == 'reset':
for task in reply_tasks:
task.cancel()
reply_tasks.clear()
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
push_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
push_available = True; auth_required = False
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
elif action == 'presentation-samples':
# Same source messages in normal chat and Agent; loopback-only UI comparisons.
messages.clear(); cursor = 0; read_cursor = 0
transcript_comparison = True
for sample in body['messages']:
message(sample['content'], sample['role'])
elif action == 'transcript-comparison':
transcript_comparison = True
elif action == 'capability':
attachment_capability = body.get('enabled', True)
elif action == 'upload-error':
next_upload_error = body
elif action == 'blocked-proxy':
blocked_proxy = body.get('enabled', True)
elif action == 'attachment-history':
selected = [attachments[key]['descriptor'] for key in body['ids']]
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
await broadcast(dict(type='messages', **page([row])))
elif action == 'reply':
await emit(body.get('content', 'Late asynchronous fixture reply'))
elif action == 'long-tool':
row = message('Local tool output', 'tool')
row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed',
args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80)))
await broadcast(dict(type='messages', **page([row])))
elif action == 'busy':
busy = body.get('value', True)
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
elif action == 'read':
read_cursor = cursor
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
elif action == 'push-available':
push_available = body.get('enabled', True)
elif action == 'auth-required':
auth_required = body.get('enabled', True)
elif action == 'disconnect':
for socket in list(sockets):
await socket.close(code=1012)
elif action == 'malformed':
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log,
pushRequests=push_requests, pushActive=len(push_installations), pushRetired=len(push_retired)))
if path == '/api/v1/auth/session':
if auth_required and request.headers.get('Authorization') != 'Bearer fixture-token':
return web.json_response(dict(message='Fixture authentication required'), status=401)
return web.json_response(dict(authenticated=True, mode='token'))
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
if path == '/api/v1/workspace-items':
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
if path == '/api/v1/chats':
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
return web.json_response(dict(chat=dict(comparison, messages=messages)))
if path == '/api/v1/searches':
return web.json_response(dict(searches=[]))
if path == '/api/v1/models':
return web.json_response(dict(providers={}))
if path == '/api/v1/active-runs':
return web.json_response(dict(chats=[], searches=[]))
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
if request.headers.get('Authorization') != 'Bearer fixture-token':
return web.json_response(dict(error='Fixture token missing'), status=401)
if blocked_proxy:
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
if path == '/api/v1/agent':
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}
return web.json_response(descriptor)
if path == '/api/v1/agent/push/config':
if not push_available:
return web.json_response(dict(message='Old server fixture'), status=404)
return web.json_response(dict(version=1, enabled=True, apnsAvailable=True, webPushAvailable=False,
sourceId=PUSH_SOURCE, quarantined=False, policy={}))
if path.startswith('/api/v1/agent/push/installations/'):
installation = str(uuid.UUID(path.rsplit('/', 1)[1]))
if request.method == 'DELETE':
push_installations.pop(installation, None); push_retired.add(installation)
push_requests.append(dict(method='DELETE', installationId=installation))
return web.Response(status=204)
if request.method == 'PUT':
body = await request.json()
assert set(body) == {'transport', 'enabled', 'token', 'environment', 'topic'}
assert body['transport'] == 'apns' and body['enabled'] is True
assert body['environment'] == 'sandbox' and body['topic'] == 'net.buzzert.sybil2'
assert body['token'] == '00010aff' + '42' * 28 # explicitly injected synthetic token
if installation in push_retired:
return web.json_response(dict(message='Retired installation'), status=409)
version = push_installations.get(installation, 0) + 1
push_installations[installation] = version
push_requests.append(dict(method='PUT', installationId=installation, environment='sandbox'))
return web.json_response(dict(installationId=installation, transport='apns', enabled=True, version=version))
if path == '/api/v1/agent/attachments' and request.method == 'POST':
if not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if next_upload_error:
failure = next_upload_error; next_upload_error = None
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
reader = await request.multipart()
parts = {}
while part := await reader.next():
if part.name not in ('file', 'uploadId') or part.name in parts:
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
if set(parts) != {'file', 'uploadId'}:
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
try:
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
filename = unicodedata.normalize('NFC', parts['file'][0])
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
except (ValueError, AssertionError, TypeError):
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
_, mime, data = parts['file']
mime = (mime or 'application/octet-stream').lower()
if len(data) > CAPABILITY['maxFileBytes']:
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
digest = hashlib.sha256(data).hexdigest()
identity = (filename, mime, digest)
if upload_id in uploads:
old_identity, attachment_id = uploads[upload_id]
if old_identity != identity:
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
kind = 'file'
if mime in CAPABILITY['imageMimeTypes']:
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
elif mime.startswith('text/'):
try:
if '\0' not in data.decode('utf-8-sig'):
kind = 'text'
except UnicodeDecodeError:
pass
attachment_id = 'fixture-' + str(uuid.uuid4())
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
uploads[upload_id] = (identity, attachment_id)
return web.json_response({'attachment': descriptor}, status=201)
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
stored = attachments.get(attachment_id)
if stored is None:
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
descriptor = stored['descriptor']
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
if path == '/api/v1/agent/read-state':
if request.method == 'PUT':
read_cursor = max(read_cursor, int((await request.json())['cursor']))
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp(), sourceId=PUSH_SOURCE))
if path == '/api/v1/agent/messages':
if request.method == 'POST':
body = await request.json()
key, content = body['requestId'], body['content']
refs = body.get('attachments', [])
ids = [ref.get('id') for ref in refs]
if refs and not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
if any(key not in attachments for key in ids):
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
if not content.strip() and not ids:
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
attempts[key] = attempts.get(key, 0) + 1
if content == 'retry fixture' and attempts[key] == 1:
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
if ids:
receipt['attachmentIds'] = ids
if key not in requests:
requests[key] = dict(receipt, _payload=[content, ids])
descriptors = [attachments[key]['descriptor'] for key in ids]
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
busy = True
await broadcast(dict(type='messages', **page([row])))
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
task = asyncio.create_task(reply(content))
reply_tasks.add(task)
task.add_done_callback(reply_tasks.discard)
if content == 'echo wins fixture':
await asyncio.sleep(0.3)
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
return web.json_response({k: v for k, v in requests[key].items() if k != '_payload'}, status=202)
after = int(request.query.get('after', 0))
rows = [m for m in messages if m['metadata']['entryId'] > after]
limit = min(int(request.query.get('limit', 200)), 200)
selected = rows[:limit]
return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit))
if path == '/api/v1/agent/ws':
if request.query:
return web.Response(status=400)
socket = web.WebSocketResponse(); await socket.prepare(request)
if blocked_proxy:
await socket.close(code=4503, message=b'attachments_unavailable')
return socket
auth = await socket.receive_json(timeout=5)
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
await socket.close(code=4401); return socket
log.append(dict(authAfter=auth.get('after')))
sockets.add(socket)
await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root'))
await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))])))
await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity()))
try:
async for frame in socket:
if frame.type == web.WSMsgType.TEXT:
data = json.loads(frame.data)
if data.get('type') == 'ping':
await socket.send_json(dict(type='pong'))
finally:
sockets.discard(socket)
return socket
return web.json_response(dict(error='No fixture for this endpoint'), status=404)
for i in range(3):
message(f'History message {i + 1}: local simulator fixture.')
app = web.Application(client_max_size=8 * 1024 * 1024 + 64 * 1024)
app.router.add_route('*', '/{path:.*}', route)
web.run_app(app, host='127.0.0.1', port=8799)
@@ -0,0 +1,110 @@
import XCTest
import SwiftUI
@testable import Sybil
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg"],
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
/// In-memory upload-only fixture. Every other path fails; no socket/provider IO.
private actor AttachmentTestClient: AgentAPIClienting {
var fail = false
var delay = Duration.milliseconds(1200)
func failNextUpload(delay: Duration) { fail = true; self.delay = delay }
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
try await Task.sleep(for: delay)
if fail {
fail = false
throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507)
}
return AgentAttachment(id: "synthetic-" + uploadID, kind: .file, filename: file.filename,
mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
}
func agentDescriptor() throws -> AgentDescriptor { throw AgentAttachmentIssue.unavailable }
func agentMessages(after: String?, before: String?) throws -> AgentPage { throw AgentAttachmentIssue.unavailable }
func agentReadState() throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
func agentMarkRead(cursor: String) throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
func agentSubmit(content: String, requestID: String) throws -> AgentReceipt { throw AgentAttachmentIssue.unavailable }
func agentSocket(after: String) throws -> any AgentSocket { throw AgentAttachmentIssue.unavailable }
}
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
// Rendering the production composer against the in-memory upload fixture
// covers transient states without changing the shipping picker or contacting a provider.
@MainActor
final class AgentComposerPresentationTests: XCTestCase {
func testLoadingUploadingFailureRetryCapacityAndCapabilitySnapshots() async throws {
XCTAssertTrue(UserDefaults.standard.string(forKey: "sybil.ios.apiBaseURL") == "http://127.0.0.1:8799/api")
XCTAssertTrue(ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1")
let room = SybilAgentRoom(), client = AttachmentTestClient()
let draft = room.attachmentDraft
draft.configure(client: client, storage: nil)
var limits = attachmentLimits
limits.maxCount = 1
draft.updateCapability(limits)
await client.failNextUpload(delay: .milliseconds(1200))
await capture(room, "composer-enabled-empty")
let id = try XCTUnwrap(draft.add(filename: "synthetic-original.bin") {
try await Task.sleep(for: .milliseconds(600))
return .init(filename: "synthetic-original.bin", mimeType: "application/octet-stream", data: originalBytes)
})
XCTAssertFalse(draft.canSend)
XCTAssertEqual(draft.items.first?.state, .loading)
await capture(room, "composer-loading-at-capacity-disabled")
let uploading = await attachmentEventually { draft.items.first?.state == .uploading }
XCTAssertTrue(uploading)
await capture(room, "composer-uploading-at-capacity-disabled")
let failed = await attachmentEventually { draft.items.first?.state == .failed }
XCTAssertTrue(failed); XCTAssertFalse(draft.canSend)
await capture(room, "composer-error-retry-disabled-send")
draft.retry(id)
let ready = await attachmentEventually { draft.items.first?.state == .ready }
XCTAssertTrue(ready); XCTAssertTrue(draft.canSend)
await capture(room, "composer-ready-enabled-send")
await capture(room, "composer-ready-accessibility3", size: .accessibility3)
draft.updateCapability(nil)
XCTAssertFalse(draft.canSend)
await capture(room, "composer-unavailable-retains-attachment")
draft.remove(id)
draft.caption = "Text still works"
XCTAssertTrue(draft.canSend)
await capture(room, "composer-text-only-capability")
draft.suspend()
}
private func capture(_ room: SybilAgentRoom, _ name: String, size: DynamicTypeSize = .large) async {
// ImageRenderer omits UIKit-backed fields/scroll views; host the real hierarchy.
let host = UIHostingController(rootView: AgentComposerPreview(room: room)
.padding(12).background(SybilTheme.background)
.environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size))
let fitting = host.sizeThatFits(in: CGSize(width: 414, height: 600))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 414, height: max(120, fitting.height)))
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
host.view.frame = window.bounds
host.view.setNeedsLayout(); host.view.layoutIfNeeded()
try? await Task.sleep(for: .milliseconds(100))
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
}
let attachment = XCTAttachment(image: image)
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
}
private struct AgentComposerPreview: View {
var room: SybilAgentRoom
@FocusState private var focused: Bool
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(text: .constant(room.composer), focused: $focused, submit: {})
SybilPromptSendButton(canSend: room.attachmentDraft.canSend, submit: {})
}
}
}
@@ -0,0 +1,118 @@
import XCTest
import UIKit
/// Actual normal chat and Agent views, using only the loopback contract fixture.
@MainActor final class AgentPolishUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
try await control(["action": "reset", "count": 0])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
}
override func tearDown() async throws { app.terminate() }
private func control(_ body: [String: Any]) async throws {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
}
private func sidebar() {
if !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
}
private func openChat() {
sidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
XCTAssertTrue(app.buttons["chat-attach"].waitForExistence(timeout: 8))
}
private func openAgent() {
sidebar(); app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
}
private func capture(_ name: String) {
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
shot.name = name; shot.lifetime = .keepAlways; add(shot)
}
func testSharedAttachmentControlAndPickerFlows() async throws {
try await control(["action": "transcript-comparison"])
app.launch(); openChat()
let normal = app.buttons["chat-attach"]
XCTAssertEqual(normal.label, "Attach file"); XCTAssertTrue(normal.isEnabled)
let normalSize = normal.frame.size
let normalLabel = normal.label
XCTAssertGreaterThanOrEqual(normalSize.width, 44); XCTAssertGreaterThanOrEqual(normalSize.height, 44)
capture("polish-normal-attachment-control")
normal.tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
normal.tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
openAgent()
let agent = app.buttons["agent-attach"]
XCTAssertEqual(agent.label, normalLabel); XCTAssertTrue(agent.isEnabled)
XCTAssertEqual(agent.frame.size.width, normalSize.width, accuracy: 1)
XCTAssertEqual(agent.frame.size.height, normalSize.height, accuracy: 1)
// Same row as the prompt, including the minimum VoiceOver/touch target.
XCTAssertEqual(agent.frame.maxY, app.buttons["agent-send"].frame.maxY, accuracy: 1)
capture("polish-agent-attachment-control")
agent.tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
agent.tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
}
func testUserAssistantMultilineParagraphMetricsInBothWorkspaces() async throws {
try await compareMetrics(user: "User first\nUser second\n\nUser paragraph",
peer: "Peer first \nPeer second\n\nPeer paragraph", name: "multiline-paragraphs")
}
func testPastedParagraphMetricsInBothWorkspaces() async throws {
try await compareMetrics(user: "User first\r\nUser second\r\n \r\nUser paragraph",
peer: "Peer first \nPeer second\n\t\nPeer paragraph", name: "pasted-paragraphs")
}
private func compareMetrics(user: String, peer: String, name: String) async throws {
try await control(["action": "presentation-samples", "messages": [
["role": "user", "content": user], ["role": "assistant", "content": peer]]])
app.launch(); openChat()
let normal = try metrics()
capture("polish-normal-" + name)
openAgent()
let agent = try metrics()
XCTAssertEqual(normal.0, agent.0, accuracy: 1)
XCTAssertEqual(normal.1, agent.1, accuracy: 1)
capture("polish-agent-" + name)
}
private func metrics() throws -> (CGFloat, CGFloat) {
let user = app.staticTexts["User first\nUser second"]
let peer = app.staticTexts["Peer first\nPeer second"]
XCTAssertTrue(user.waitForExistence(timeout: 8)); XCTAssertTrue(peer.waitForExistence(timeout: 8))
let userParagraph = app.staticTexts["User paragraph"], peerParagraph = app.staticTexts["Peer paragraph"]
XCTAssertTrue(userParagraph.exists); XCTAssertTrue(peerParagraph.exists)
XCTAssertEqual(user.frame.height, peer.frame.height, accuracy: 1)
let gap = userParagraph.frame.minY - user.frame.maxY
XCTAssertEqual(gap, peerParagraph.frame.minY - peer.frame.maxY, accuracy: 1)
XCTAssertGreaterThan(gap, 0)
return (user.frame.height, gap)
}
func testLiteralUserAndSemanticAssistantMarkdown() async throws {
try await control(["action": "presentation-samples", "messages": [
["role": "user", "content": "**literal bold** `literal code`\n- literal item\n[not a link](https://example.invalid)"],
["role": "assistant", "content": "Soft break\ncontinues here.\n\n**Real bold** and [Local link](http://127.0.0.1:8799).\n\n- First item\n- Second item\n\n```swift\nlet value = 42\n```"]]])
app.launch(); openChat()
assertMarkdownSemantics(); capture("polish-normal-literal-and-markdown")
openAgent()
assertMarkdownSemantics(); capture("polish-agent-literal-and-markdown")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
}
private func assertMarkdownSemantics() {
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "**literal bold**")).firstMatch.waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "[not a link](https://example.invalid)")).firstMatch.exists)
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "First item")).firstMatch.exists)
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "let value = 42")).firstMatch.exists)
XCTAssertTrue(app.links["Local link"].exists)
}
}
@@ -0,0 +1,128 @@
import XCTest
/// Local fixture + fake notification center/registrar only. No APNs provider or
/// system permission dialog is involved in these deterministic UI scenarios.
@MainActor final class AgentPushUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
try await control(["action": "reset", "count": 3])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launchEnvironment = ["SYBIL_PUSH_FIXTURE": "1", "SYBIL_PUSH_PERMISSION": "authorized"]
}
override func tearDown() async throws { app.terminate() }
@discardableResult private func control(_ body: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func settings() {
if !app.buttons["Settings"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["Settings"].waitForExistence(timeout: 5)); app.buttons["Settings"].tap()
XCTAssertTrue(app.buttons["agent-push-refresh"].waitForExistence(timeout: 5))
if !app.buttons["agent-push-refresh"].isHittable { app.scrollViews.firstMatch.swipeUp() }
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
private func openAgent() {
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20)); app.buttons["Show sidebar"].tap()
}
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5))
}
private func ensureDisabled() {
if app.buttons["agent-push-disable"].exists { app.buttons["agent-push-disable"].tap() }
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
}
func testExplicitEnableRefreshAndDisable() async throws {
app.launch(); settings(); ensureDisabled()
let before = try await control(["action": "state"])
let count = (before["pushRequests"] as? [[String: Any]])?.count ?? 0
app.buttons["agent-push-enable"].tap()
XCTAssertTrue(app.buttons["agent-push-disable"].waitForExistence(timeout: 5))
let status = app.staticTexts["agent-push-status"]
let enabled = NSPredicate(format: "label == %@", "Notifications are enabled.")
let enabledExpectation = expectation(for: enabled, evaluatedWith: status)
await fulfillment(of: [enabledExpectation], timeout: 8)
capture("push-enabled-generic-preview")
app.buttons["agent-push-refresh"].tap()
let state = try await control(["action": "state"])
let requests = try XCTUnwrap(state["pushRequests"] as? [[String: Any]])
XCTAssertEqual(requests.count, count + 1)
XCTAssertEqual(requests.last?["environment"] as? String, "sandbox")
app.buttons["agent-push-disable"].tap()
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
let revoked = try await control(["action": "state"])
XCTAssertEqual(revoked["pushActive"] as? Int, 0)
capture("push-disabled-revoked")
}
func testColdTapRoutesAgentAndPreservesAttachmentCaption() async throws {
app.launch()
openAgent()
let composer = app.textFields["agent-composer"]
XCTAssertTrue(composer.waitForExistence(timeout: 5))
composer.tap(); composer.typeText("Push fixture saved caption")
app.terminate()
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
app.launch()
XCTAssertTrue(composer.waitForExistence(timeout: 20))
XCTAssertTrue((composer.value as? String ?? "").contains("Push fixture saved caption"))
XCTAssertTrue(app.buttons["agent-attach"].exists)
capture("push-cold-tap-agent-draft-preserved")
}
func testTapBeforeLoginWaitsThenRoutesAgent() async throws {
try await control(["action": "auth-required", "enabled": true])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", ""]
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
app.launch()
XCTAssertTrue(app.buttons["Connect"].waitForExistence(timeout: 15))
XCTAssertFalse(app.textFields["agent-composer"].exists)
let state = try await control(["action": "state"])
XCTAssertEqual(state["readCursor"] as? Int, 0)
app.secureTextFields.firstMatch.tap(); app.secureTextFields.firstMatch.typeText("fixture-token")
app.buttons["Connect"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 20))
capture("push-pending-auth-routed-after-login")
}
func testDeniedAndOldServerLeaveAgentUsable() async throws {
app.launchEnvironment["SYBIL_PUSH_PERMISSION"] = "denied"
app.launch(); settings()
XCTAssertTrue(app.buttons["Open iOS Notification Settings"].waitForExistence(timeout: 5))
capture("push-denied-settings")
try await control(["action": "push-available", "enabled": false])
app.buttons["agent-push-refresh"].tap()
XCTAssertTrue(app.buttons["Show sidebar"].exists); app.buttons["Show sidebar"].tap()
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["History message 3: local simulator fixture."].waitForExistence(timeout: 5))
capture("push-old-server-agent-compatible")
}
func testWarmTapDismissesAttachmentPickerPreservingDraft() async throws {
app.launchEnvironment["SYBIL_PUSH_TAP_ON_FOREGROUND"] = "1"
app.launch(); openAgent()
let composer = app.textFields["agent-composer"]
composer.tap(); composer.typeText(" Warm push preserved draft")
app.buttons["agent-attach"].tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
XCUIDevice.shared.press(.home); app.activate()
XCTAssertTrue(composer.waitForExistence(timeout: 10))
let visible = expectation(for: NSPredicate(format: "hittable == true"), evaluatedWith: composer)
await fulfillment(of: [visible], timeout: 5)
XCTAssertFalse(app.buttons["Cancel"].firstMatch.exists)
XCTAssertTrue((composer.value as? String ?? "").contains("Warm push preserved draft"))
capture("push-warm-tap-dismissed-picker-draft-preserved")
}
}
+350
View File
@@ -0,0 +1,350 @@
import XCTest
import UIKit
import Photos
@MainActor
final class AgentUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
try await control(["action": "reset", "count": 3])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
openAgent()
try clearComposer()
}
override func tearDown() async throws { app.terminate() }
private func openAgent() {
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists)
}
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private func clearComposer() throws {
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
composer.tap(); composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
let value = composer.value as? String ?? ""
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
}
private func send(_ text: String) {
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways
add(attachment)
}
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
let boundary = "Fixture-" + UUID().uuidString
let uploadID = UUID().uuidString
var body = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
request.httpMethod = "POST"; request.httpBody = body
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
return try XCTUnwrap(json["attachment"] as? [String: Any])
}
func testAttachmentHistoryDownloadAndReload() async throws {
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
}
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
let path = try XCTUnwrap(file["workspacePath"] as? String)
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
let download = app.buttons["agent-download"].firstMatch
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
capture("agent-attachment-original-and-preview")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
capture("agent-attachment-reloaded")
// The real app's authenticated content request is recorded by the loopback fixture.
let state = try await control(["action": "state"])
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
}
func testAttachmentCapabilityAndPickerCancellation() async throws {
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
composer.tap(); composer.typeText("Preserved attachment draft caption")
app.buttons["agent-attach"].tap()
app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
capture("agent-attachment-pickers-cancelled")
try await control(["action": "capability", "enabled": false])
app.terminate(); app.launch(); openAgent()
XCTAssertFalse(app.buttons["agent-attach"].exists)
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
try clearComposer()
capture("agent-text-only-capability")
}
func testPhotoSelectionToAttachmentOnlySend() async throws {
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
// Only synthetic assets are touched; no unrelated photo bytes are read.
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
// XCTest's synchronous UI waits must not block the authorization request.
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
try await Task.sleep(for: .milliseconds(300))
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
}
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
// Recover only fixtures from an interrupted test, identified by BOTH our unique
// date and original-filename prefix, before creating this run's asset.
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
let previous = PHAsset.fetchAssets(with: .image, options: options)
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
}.map(\.localIdentifier)
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
}
try XCTUnwrap(photo.pngData()).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
nonisolated(unsafe) var assetID: String?
try await PHPhotoLibrary.shared().performChanges { @Sendable in
let creation = PHAssetCreationRequest.forAsset()
// A unique synthetic date lets us find this asset without enumerating other photos.
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
creation.addResource(with: .photo, fileURL: url, options: options)
assetID = creation.placeholderForCreatedAsset?.localIdentifier
}
let ownedID = try XCTUnwrap(assetID)
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
do { try await exercisePhotoSelection() }
catch {
try await removeSyntheticPhotos([ownedID])
throw error
}
try await removeSyntheticPhotos([ownedID])
}
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
try await Self.eraseSyntheticPhotos(identifiers)
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
}
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
try await Task.detached {
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
XCTAssertEqual(remaining, 0)
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
}.value
}
private func exercisePhotoSelection() async throws {
app.activate()
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 8))
cell.tap()
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
await fulfillment(of: [ready], timeout: 15)
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
capture("agent-photo-upload-ready")
app.buttons["agent-send"].tap()
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
let state = try await control(["action": "state"])
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
XCTAssertEqual(requests.count, 1)
let submission = try XCTUnwrap(requests.values.first)
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
capture("agent-photo-attachment-only-canonical")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
XCTAssertFalse(app.buttons["agent-retry"].exists)
capture("agent-photo-attachment-only-reloaded")
}
func testSendQueueToolsAndRetry() async throws {
capture("agent-history")
send("first fixture")
send("second fixture")
XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.otherElements["agent-typing"].exists)
capture("agent-replies-keyboard")
app.swipeDown()
send("retry fixture")
XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5))
capture("agent-retry")
app.buttons["agent-retry"].tap()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12))
send("echo wins fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
composer.tap(); app.swipeDown()
XCTAssertFalse(app.buttons["agent-tool-fixture_search"].exists)
XCTAssertFalse(app.buttons["agent-tool-fixture_error"].exists)
XCTAssertFalse(app.staticTexts["Inspecting the fixture"].exists)
XCTAssertFalse(app.staticTexts["Fixture unavailable"].exists)
capture("agent-hidden-tools")
let state = try await control(["action": "state"])
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
}
func testReduceMotionSend() async throws {
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
send("reduce motion fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.otherElements["agent-typing"].exists)
capture("agent-reduce-motion")
}
func testToolResultsStayHiddenLiveAndAfterReload() async throws {
try await control(["action": "long-tool"])
try await control(["action": "reply", "content": "Reply after a hidden tool result"])
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].isHittable)
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
capture("agent-hidden-tool-live")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
capture("agent-hidden-tool-history")
}
func testTabletSidebarAndInactiveChat() async throws {
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check")
XCUIDevice.shared.orientation = .landscapeLeft
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["agent-sidebar"].isHittable)
app.buttons["New chat"].tap()
try await control(["action": "busy", "value": true])
try await control(["action": "reply", "content": "iPad inactive chat reply"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"])
await fulfillment(of: [unread], timeout: 5)
capture("agent-ipad-sidebar")
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5))
capture("agent-ipad-room-before-send")
send("iPad fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12))
capture("agent-ipad-room")
XCUIDevice.shared.orientation = .portrait
}
func testScrollPreservationAndKeyboard() async throws {
app.terminate()
try await control(["action": "reset", "count": 160])
app.launch(); openAgent()
let scroll = app.scrollViews.firstMatch
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown()
scroll.swipeDown()
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
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"])
try await Task.sleep(for: .milliseconds(500))
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
capture("agent-reading-anchor")
for _ in 0..<25 {
if 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)
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")
}
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
app.buttons["Show sidebar"].tap()
try await control(["action": "busy", "value": true])
try await control(["action": "reply", "content": "Late asynchronous fixture reply"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"])
await fulfillment(of: [unread], timeout: 5)
capture("agent-unread-sidebar")
let covered = try await control(["action": "state"])
XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0)
try await control(["action": "read"])
let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies")
let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"])
await fulfillment(of: [cleared], timeout: 20)
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5))
try await control(["action": "disconnect"])
try await control(["action": "reply", "content": "Reconnect fixture reply"])
XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10))
try await control(["action": "malformed"])
try await control(["action": "reply", "content": "Recovered malformed frame"])
XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12))
XCUIDevice.shared.press(.home)
try await control(["action": "reply", "content": "Foreground fixture reply"])
app.activate()
XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12))
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1)
capture("agent-foreground-replay")
}
}
@@ -0,0 +1,138 @@
import XCTest
import UIKit
/// Loopback-only regressions. No picker selection or external backend writes.
@MainActor
final class TranscriptFadeUITests: XCTestCase {
private let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
try await control(["action": "reset", "count": 160])
try await control(["action": "transcript-comparison"])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
}
override func tearDown() async throws { app.terminate() }
private func showSidebar() {
if !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
}
private func openAgent() {
showSidebar()
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists)
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8))
}
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private var transcript: XCUIElement {
// 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
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
func testSharedHeaderFadeGeometry() async throws {
showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
// Normal chat is lazy and can open at either end on phone/tablet.
let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch
XCTAssertTrue(normal.waitForExistence(timeout: 8))
let normalTop = normal.frame.minY
let first = app.staticTexts["History message 1: local simulator fixture."]
if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) }
else { normal.swipeDown(velocity: .slow) }
capture("normal-chat-content-crossing-shared-fade")
XCTAssertTrue(app.buttons["Attach file"].isHittable)
openAgent()
let scroll = transcript
XCTAssertTrue(scroll.exists)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
capture("agent-content-crossing-shared-fade")
XCTAssertTrue(composer.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
XCTAssertTrue(app.buttons["agent-send"].exists)
// A viewport padded below the header clips bubbles before they reach its fade.
XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1,
"Agent must scroll beneath the same workspace header as normal chat")
}
func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws {
openAgent()
let scroll = transcript
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown(velocity: .slow)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
// Stay below the header's content clearance, where text is readable.
let visibleTop = scroll.frame.minY + 100
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
$0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY
})
let before = anchor.frame.minY
let stateBefore = try await control(["action": "state"])
try await control(["action": "reply", "content": "Fade regression reply while reading"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
await fulfillment(of: [unread], timeout: 8)
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
let stateAfter = try await control(["action": "state"])
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int))
capture("agent-fade-reading-position-and-unread")
for _ in 0..<30 {
if app.buttons["Load earlier messages"].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")
app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"]
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5)
composer.tap()
XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard")
// No send: this only changes a local draft, then clears it.
composer.typeText("Fade draft\nsecond line\nthird line")
XCTAssertTrue(latest.isHittable)
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count))
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
}
+3
View File
@@ -1,3 +1,5 @@
require_relative 'refresh_push_profile'
default_platform(:ios)
platform :ios do
@@ -5,6 +7,7 @@ platform :ios do
lane :beta do
setup_ci
SybilPushProfile.run
match(type: "appstore")
tag = ENV.fetch("GITHUB_REF_NAME")
+75
View File
@@ -0,0 +1,75 @@
# 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
+4 -4
View File
@@ -9,14 +9,14 @@ generate:
xcodegen --spec project.yml
build: generate
if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' | xcbeautify; \
else \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'; \
fi
test:
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' -parallel-testing-enabled NO
run: generate
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
+58
View File
@@ -8,3 +8,61 @@ packages:
include:
- Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
targets:
SybilPresentationTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "18.0"
sources:
- Tests/SybilPresentationTests
dependencies:
- target: SybilApp
- package: Sybil
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.presentation-tests
GENERATE_INFOPLIST_FILE: YES
SWIFT_VERSION: 6.0
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Sybil.app/Sybil"
BUNDLE_LOADER: "$(TEST_HOST)"
SybilAgentUITests:
type: bundle.ui-testing
platform: iOS
deploymentTarget: "18.0"
sources:
- Tests/SybilUITests
dependencies:
- target: SybilApp
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: Synthetic attachment UI tests create and remove their own photo.
TEST_TARGET_NAME: SybilApp
SWIFT_VERSION: 6.0
schemes:
SybilPresentation:
run:
commandLineArguments:
"-sybil.ios.apiBaseURL http://127.0.0.1:8799/api -sybil.ios.adminToken fixture-token": true
environmentVariables:
SYBIL_PUSH_FIXTURE: "1"
build:
targets:
SybilApp: all
SybilPresentationTests: [test]
test:
config: Debug
targets:
- SybilPresentationTests
SybilAgentUI:
build:
targets:
SybilApp: all
SybilAgentUITests: [test]
test:
config: Debug
targets:
- SybilAgentUITests
+24
View File
@@ -106,3 +106,27 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
- User accounts + per-device API keys
- Postgres support + migrations for prod
- Attachments + embeddings + semantic search
## Sybil Agent
Configure the backend with:
```dotenv
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
```
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
`sybil-agent` resolves privately. Supply the key through a private server environment
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
`ADMIN_TOKEN` authentication for clients.
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
same persistent root conversation across devices. Sybil Agent remains the source of
truth for messages and background replies. Sybil stores only the shared,
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
its migration is applied automatically at startup. The bundled nginx configuration
forwards WebSocket upgrades. See `docs/api/rest.md` and
`docs/api/streaming-chat.md` at the repository root for the client contracts.
+181
View File
@@ -22,10 +22,14 @@
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"web-push": "^3.6.7",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/web-push": "^3.6.4",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -876,12 +880,41 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -915,6 +948,18 @@
}
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -934,6 +979,18 @@
"fastq": "^1.17.1"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -1104,6 +1161,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -1478,6 +1544,15 @@
"entities": "^4.4.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1498,6 +1573,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1577,6 +1665,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
@@ -1678,6 +1787,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
@@ -1994,6 +2109,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
@@ -2022,6 +2157,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -2219,6 +2360,25 @@
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -2241,6 +2401,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
+4
View File
@@ -31,10 +31,14 @@
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"web-push": "^3.6.7",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/web-push": "^3.6.4",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -0,0 +1,70 @@
-- External-content indexes avoid storing a second copy of conversation text.
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
title, content='Chat', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
-- Hidden system prompts and tool payloads are not searchable conversation text.
CREATE VIEW "MessageSearchContent" AS
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
content, content='MessageSearchContent', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
title, query, answerText, content='Search', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
END;
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
WHEN old.title IS NOT new.title BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
WHEN new.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
WHEN old.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
INSERT INTO "MessageSearch" (rowid, content)
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
END;
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
-- Index existing history during migration, then maintain it transactionally.
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
@@ -0,0 +1,5 @@
CREATE TABLE "AgentReadState" (
"id" TEXT NOT NULL PRIMARY KEY,
"cursor" TEXT NOT NULL DEFAULT '0',
"updatedAt" DATETIME NOT NULL
);
@@ -0,0 +1,13 @@
ALTER TABLE "AgentReadState" ADD COLUMN "sourceId" TEXT;
CREATE TABLE "AgentPushInstallation" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "transport" TEXT NOT NULL, "payload" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT false, "version" INTEGER NOT NULL DEFAULT 1, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, "lastClearAt" DATETIME, "revokedAt" DATETIME);
CREATE INDEX "AgentPushInstallation_ownerEpoch_enabled_idx" ON "AgentPushInstallation"("ownerEpoch", "enabled");
CREATE INDEX "AgentPushInstallation_fingerprint_idx" ON "AgentPushInstallation"("fingerprint");
CREATE TABLE "AgentPushPresence" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sequence" INTEGER NOT NULL, "visible" BOOLEAN NOT NULL, "focused" BOOLEAN NOT NULL, "lastInteractionAt" DATETIME, "expiresAt" DATETIME NOT NULL);
CREATE INDEX "AgentPushPresence_ownerEpoch_expiresAt_idx" ON "AgentPushPresence"("ownerEpoch", "expiresAt");
CREATE TABLE "AgentPushConnection" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "kind" TEXT NOT NULL, "sessionId" TEXT, "expiresAt" DATETIME NOT NULL);
CREATE INDEX "AgentPushConnection_ownerEpoch_expiresAt_idx" ON "AgentPushConnection"("ownerEpoch", "expiresAt");
CREATE TABLE "AgentPushCoordinator" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sourceId" TEXT, "cursor" TEXT NOT NULL DEFAULT '0', "quarantined" BOOLEAN NOT NULL DEFAULT false, "contextObservedAt" DATETIME, "latestUserMessageAt" DATETIME, "localPromptAt" DATETIME, "nativeActive" BOOLEAN NOT NULL DEFAULT false, "nativeUnknown" BOOLEAN NOT NULL DEFAULT true, "uncertain" BOOLEAN NOT NULL DEFAULT true, "lockOwner" TEXT, "lockUntil" DATETIME);
CREATE TABLE "AgentPushDelivery" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "installationId" TEXT NOT NULL, "installationVersion" INTEGER NOT NULL, "sourceId" TEXT NOT NULL, "messageId" TEXT NOT NULL, "cursor" TEXT NOT NULL, "kind" TEXT NOT NULL, "trigger" TEXT NOT NULL, "priority" INTEGER NOT NULL DEFAULT 0, "status" TEXT NOT NULL DEFAULT 'pending', "attempts" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "dueAt" DATETIME NOT NULL, "expiresAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL, "outcome" TEXT);
CREATE INDEX "AgentPushDelivery_status_dueAt_idx" ON "AgentPushDelivery"("status", "dueAt");
CREATE INDEX "AgentPushDelivery_installationId_status_idx" ON "AgentPushDelivery"("installationId", "status");
CREATE INDEX "AgentPushDelivery_ownerEpoch_sourceId_cursor_idx" ON "AgentPushDelivery"("ownerEpoch", "sourceId", "cursor");
+86
View File
@@ -76,6 +76,7 @@ model Chat {
@@index([userId])
@@index([parentChatId])
@@index([updatedAt])
}
model Message {
@@ -214,3 +215,88 @@ model ProjectItem {
@@index([chatId])
@@index([searchId])
}
// Shared personal read position for the external persistent Sybil Agent room.
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
model AgentReadState {
sourceId String?
id String @id
cursor String @default("0")
updatedAt DateTime @updatedAt
}
// One server-derived shared principal. Transport payloads are secret DB data.
model AgentPushInstallation {
id String @id
ownerEpoch String
transport String
payload String
fingerprint String
enabled Boolean @default(false)
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastClearAt DateTime?
revokedAt DateTime?
@@index([ownerEpoch, enabled])
@@index([fingerprint])
}
model AgentPushPresence {
id String @id
ownerEpoch String
sequence Int
visible Boolean
focused Boolean
lastInteractionAt DateTime?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
model AgentPushConnection {
id String @id
ownerEpoch String
kind String
sessionId String?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
model AgentPushCoordinator {
id String @id
ownerEpoch String
sourceId String?
cursor String @default("0")
quarantined Boolean @default(false)
contextObservedAt DateTime?
latestUserMessageAt DateTime?
localPromptAt DateTime?
nativeActive Boolean @default(false)
nativeUnknown Boolean @default(true)
uncertain Boolean @default(true)
lockOwner String?
lockUntil DateTime?
}
model AgentPushDelivery {
id String @id
ownerEpoch String
installationId String
installationVersion Int
sourceId String
messageId String
cursor String
kind String
trigger String
priority Int @default(0)
status String @default("pending")
attempts Int @default(0)
createdAt DateTime @default(now())
dueAt DateTime
expiresAt DateTime
updatedAt DateTime @updatedAt
outcome String?
@@index([status, dueAt])
@@index([installationId, status])
@@index([ownerEpoch, sourceId, cursor])
}
+141
View File
@@ -0,0 +1,141 @@
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
export const AgentAttachmentDescriptor = z.object({
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
});
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
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)
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
await app.register(async routes => {
let uploads = 0, downloads = 0;
type Transfer = { controller: AbortController; release(): void };
const transfers = new WeakMap<FastifyRequest, Transfer>();
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
routes.addHook('onRequest', async (req, reply) => {
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
const uploading = req.method === 'POST';
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
if (uploading) uploads++; else downloads++;
const controller = new AbortController(); let released = false;
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
const release = () => {
if (released) return; released = true;
if (uploading) uploads--; else downloads--;
clearTimeout(timeout); controller.abort();
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
};
transfers.set(req, { controller, release });
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
if (reply.raw.destroyed) return;
if (_req.method === 'POST') reply.header('Connection', 'close');
const statusCode = error.statusCode || 502;
const code = typeof error.code === 'string' ? error.code : undefined;
if (statusCode === 429) reply.header('Retry-After', '2');
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
statusCode, code: code || 'agent_unavailable' });
});
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
else done(null, payload);
});
const json = async (response: Response) => {
let size = 0; const chunks: Uint8Array[] = [];
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
const reader = response.body.getReader();
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
finally { reader.releaseLock(); }
};
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
let oversized = false;
const incoming = req.body;
const { controller } = transfer(req);
const body = Readable.from((async function* () {
let count = 0;
for await (const chunk of incoming) {
count += chunk.length;
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
yield chunk;
}
})());
try {
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
} as RequestInit & { duplex: string });
const value = await json(response); controller.signal.throwIfAborted();
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
if (response.status === 429) reply.header('Retry-After', '2');
return reply.code(response.status).send(value);
} catch (error) {
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
} finally { body.destroy(); }
});
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
const { controller } = transfer(req);
let response: Response;
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
controller.signal.throwIfAborted();
if (!response.ok) return reply.code(response.status).send(await json(response));
const length = Number(response.headers.get('content-length'));
const disposition = response.headers.get('content-disposition');
const type = response.headers.get('content-type') || '';
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
}
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
if (size !== length) throw new Error('Download length mismatch');
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
finally { reader.releaseLock(); }
controller.signal.throwIfAborted();
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
return reply.send(Buffer.concat(chunks, size));
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync, statSync } from 'node:fs';
import { createECDH } from 'node:crypto';
import { env } from './env.js';
import { validateAPNsKey } from './agent-push-contract.js';
export type PushConfig = { enabled: boolean; adminToken?: string; apns?: { key: string; keyId: string; teamId: string; topic: string; environment: 'sandbox' | 'production' };
web?: { publicKey: string; privateKey: string; subject: string } };
function secretFile(path: string): string {
const stat = statSync(path);
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error('Unsafe push secret file');
return readFileSync(path, 'utf8').trim();
}
/** Never log config objects or secret-file read/crypto errors. Invalid credentials disable their adapter. */
export function loadPushConfig(): PushConfig {
const result: PushConfig = { enabled: env.AGENT_PUSH_ENABLED && Boolean(env.ADMIN_TOKEN), adminToken: env.ADMIN_TOKEN };
if (!result.enabled) return result;
try {
if (env.AGENT_PUSH_APNS_KEY_FILE && env.AGENT_PUSH_APNS_KEY_ID && env.AGENT_PUSH_APNS_TEAM_ID) {
const key = secretFile(env.AGENT_PUSH_APNS_KEY_FILE); validateAPNsKey(key);
result.apns = { key, keyId: env.AGENT_PUSH_APNS_KEY_ID, teamId: env.AGENT_PUSH_APNS_TEAM_ID,
topic: env.AGENT_PUSH_APNS_TOPIC, environment: env.AGENT_PUSH_APNS_ENVIRONMENT };
}
} catch { /* Unavailable, fail closed. Public config exposes only the availability bit. */ }
try {
if (env.AGENT_PUSH_VAPID_FILE) {
const value = JSON.parse(secretFile(env.AGENT_PUSH_VAPID_FILE));
const curve = createECDH('prime256v1'); curve.setPrivateKey(Buffer.from(value.privateKey, 'base64url'));
if (curve.getPublicKey().toString('base64url') !== value.publicKey || typeof value.subject !== 'string'
|| !/^(mailto:[^\s@]+@[^\s@]+|https:\/\/[^\s]+)$/.test(value.subject)) throw new Error();
result.web = { publicKey: value.publicKey, privateKey: value.privateKey, subject: value.subject };
}
} catch { /* Unavailable; never emit credential parse errors. */ }
return result;
}
+67
View File
@@ -0,0 +1,67 @@
import { createHash, createPublicKey, ECDH } from 'node:crypto';
import { z } from 'zod';
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 30_000,
presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
export const MAX_AGE_MS = 5 * 60_000;
export const MAX_ATTEMPTS = 4;
export const ROOT_OWNER = 'root';
export const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)))
.transform(value => String(Number(value)));
export const InstallationId = z.string().uuid();
export const PresenceBody = z.object({ sequence: z.number().int().min(0).max(2_147_483_647),
visibility: z.enum(['visible', 'hidden']), focused: z.boolean(), interacted: z.boolean() }).strict();
export type Presence = z.infer<typeof PresenceBody>;
const Base64url = z.string().regex(/^[A-Za-z0-9_-]+={0,2}$/);
export const WebSubscription = z.object({ endpoint: z.string().max(2048), expirationTime: z.number().nonnegative().nullable().optional(),
keys: z.object({ p256dh: Base64url.max(90), auth: Base64url.max(24) }).strict() }).strict();
export const InstallationBody = z.discriminatedUnion('transport', [
z.object({ transport: z.literal('apns'), enabled: z.literal(true), token: z.string().regex(/^[a-fA-F0-9]{32,512}$/).refine(v => v.length % 2 === 0),
environment: z.enum(['sandbox', 'production']), topic: z.string().min(1).max(128) }).strict(),
z.object({ transport: z.literal('web'), enabled: z.literal(true), subscription: WebSubscription }).strict(),
]);
export type Registration = z.infer<typeof InstallationBody>;
export type Subscription = z.infer<typeof WebSubscription>;
export const NotificationContext = z.object({ version: z.literal(1), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor,
uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }),
candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor,
createdAt: z.string().datetime(), trigger: z.enum(['user', 'external_event', 'timer', 'worker', 'unknown']) })).max(200),
});
export type Context = z.infer<typeof NotificationContext>;
export type Identity = { version: 1; sourceId: string; kind: 'message'; messageId: string; cursor: string } |
{ version: 1; sourceId: string; kind: 'read_state'; readCursor: string };
export const digest = (value: string) => createHash('sha256').update(value).digest('hex');
export const ownerEpoch = (token: string) => digest(`sybil-agent-push-owner-v1\0${token}`);
export const fail = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode });
export function parseSafe<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
// Do not reflect supplied endpoints/tokens or validation objects into logs/errors.
if (!result.success) throw fail(400, 'Invalid Agent push request');
return result.data;
}
/** Exact provider origins only: no arbitrary-host/redirect/private-network egress. */
export function validatePushEndpoint(endpoint: string): URL {
let url: URL;
try { url = new URL(endpoint); } catch { throw fail(400, 'Invalid Web Push subscription'); }
const allowed = url.hostname === 'fcm.googleapis.com' || url.hostname === 'updates.push.services.mozilla.com'
|| url.hostname === 'web.push.apple.com' || /^[a-z0-9-]+\.push\.apple\.com$/.test(url.hostname);
if (!allowed || url.protocol !== 'https:' || url.username || url.password || url.hash || (url.port && url.port !== '443')
|| endpoint.length > 2048 || !url.pathname || url.pathname === '/') throw fail(400, 'Unsupported Web Push provider');
return url;
}
export function validateSubscription(subscription: Subscription, now: number) {
validatePushEndpoint(subscription.endpoint);
if (subscription.expirationTime != null && subscription.expirationTime <= now) throw fail(400, 'Expired Web Push subscription');
try {
const key = Buffer.from(subscription.keys.p256dh, 'base64url');
if (key.length !== 65 || key[0] !== 4 || Buffer.from(subscription.keys.auth, 'base64url').length !== 16) throw new Error();
// Validate the curve point as well as its length before persistence.
ECDH.convertKey(key, 'prime256v1', undefined, undefined, 'uncompressed');
} catch { throw fail(400, 'Invalid Web Push encryption keys'); }
}
export function validateAPNsKey(key: string) {
const publicKey = createPublicKey(key);
if (publicKey.asymmetricKeyType !== 'ec' || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new Error('Invalid APNs key');
}
+144
View File
@@ -0,0 +1,144 @@
import { createHash, sign } from 'node:crypto';
import { lookup } from 'node:dns';
import { connect } from 'node:http2';
import { request } from 'node:https';
import { isIP } from 'node:net';
import webpush from 'web-push';
import type { PushConfig } from './agent-push-config.js';
import { validatePushEndpoint, type Identity, type Registration } from './agent-push-contract.js';
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>;
function boundedRetry(value?: string): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
return Math.min(60_000, Math.max(1_000, Number(value) * 1000));
}
function classify(response: ProviderResponse, apns: boolean): SendResult {
if (response.status >= 200 && response.status < 300) return { outcome: 'accepted' };
if (response.status === 410 || (!apns && response.status === 404)) return { outcome: 'invalid' };
if (apns && response.status === 400) {
try { if (['BadDeviceToken', 'DeviceTokenNotForTopic', 'Unregistered'].includes(JSON.parse(response.body || '{}').reason)) return { outcome: 'invalid' }; } catch { /* Never emit provider text. */ }
}
if (response.status === 429 || response.status >= 500) return { outcome: 'retry', retryAfterMs: boundedRetry(response.retryAfter) };
return { outcome: 'failed' };
}
/** Fixed Apple origin, strict ten-second boundary. Unknown network outcomes are never auto-retried. */
export const createAPNsRequest = (open: typeof connect = connect, timeoutMs = 10_000): RequestTransport => async input => new Promise((resolve, reject) => {
const url = new URL(input.endpoint);
const session = open(url.origin);
let settled = false;
const finish = (error?: Error, value?: ProviderResponse) => {
if (settled) return; settled = true; clearTimeout(timer); session.destroy();
error ? reject(new Error('APNs transport unavailable')) : resolve(value!);
};
const timer = setTimeout(() => finish(new Error()), timeoutMs);
session.on('error', () => finish(new Error()));
session.on('goaway', () => finish(new Error()));
session.on('close', () => finish(new Error()));
session.on('connect', () => {
if (settled) return;
try {
const stream = session.request({ ':method': 'POST', ':path': url.pathname, ...input.headers });
let body = '', status = 0, retryAfter: string | undefined;
stream.on('response', headers => { status = Number(headers[':status']); retryAfter = String(headers['retry-after'] || ''); });
stream.on('data', data => { body += data.toString(); if (body.length > 4096) finish(new Error()); });
stream.on('error', () => finish(new Error()));
stream.on('end', () => finish(undefined, { status, body, retryAfter }));
stream.end(input.body);
} catch { finish(new Error()); }
});
});
export const apnsRequest = createAPNsRequest();
// Only global addresses are permitted even when an allowlisted provider's DNS is compromised.
export function isPublicAddress(address: string): boolean {
if (isIP(address) === 4) {
const [a,b] = address.split('.').map(Number);
return !(a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && [0, 168].includes(b)) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && [18,19,51].includes(b)) || (a === 203 && b === 0));
}
if (isIP(address) === 6) return /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address) && !/^(2002|3fff):/i.test(address);
return false;
}
export const createWebRequest = (open: typeof request = request, timeoutMs = 10_000): RequestTransport => input => new Promise((resolve, reject) => {
const url = validatePushEndpoint(input.endpoint);
// https.request never follows redirects. The DNS result passed to the connection is checked here (no resolve/use race).
let settled = false;
const finish = (error?: Error, response?: ProviderResponse) => {
if (settled) return; settled = true; clearTimeout(deadline);
if (error) { req?.destroy(); reject(new Error('Web Push transport unavailable')); } else resolve(response!);
};
const deadline = setTimeout(() => finish(new Error()), timeoutMs);
let req: ReturnType<typeof request>;
try { req = open(url, { method: 'POST', headers: input.headers, timeout: 10_000,
lookup: (hostname, options, callback) => {
lookup(hostname, { all: true, verbatim: true }, (error, addresses) => {
if (error || !addresses.length || addresses.some(value => !isPublicAddress(value.address))) {
callback(new Error('Unsafe push provider address'), '', 4); return;
}
const selected = addresses.find(value => !options.family || value.family === options.family) || addresses[0];
// Node's automatic address selection requests all results.
if (options.all) (callback as unknown as (error: Error | null, results: { address: string; family: number }[]) => void)(null, addresses);
else callback(null, selected.address, selected.family);
});
},
}, res => {
res.resume(); // Provider bodies can contain endpoint/token values; never retain or log them.
res.on('end', () => finish(undefined, { status: res.statusCode || 0, retryAfter: String(res.headers['retry-after'] || '') }));
res.on('error', () => finish(new Error()));
res.on('aborted', () => finish(new Error()));
});
req.on('timeout', () => req.destroy(new Error('Web Push timeout')));
req.on('error', () => finish(new Error()));
req.on('close', () => { if (!settled) finish(new Error()); });
req.end(input.body);
} catch { finish(new Error()); }
});
export const webRequest = createWebRequest();
export function createPushSender(config: PushConfig, adapters: { apns?: RequestTransport; web?: RequestTransport } = {}): PushSend {
let jwt: { value: string; issuedAt: number } | undefined;
return async (registration, identity, expiresAt, now) => {
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
try {
if (registration.transport === 'apns') {
const credential = config.apns;
if (!credential || credential.topic !== registration.topic || credential.environment !== registration.environment) return { outcome: 'failed' };
if (!jwt || now - jwt.issuedAt >= 50 * 60_000 || now < jwt.issuedAt) {
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: credential.keyId })).toString('base64url');
const claims = Buffer.from(JSON.stringify({ iss: credential.teamId, iat: Math.floor(now / 1000) })).toString('base64url');
const value = `${header}.${claims}`;
jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now };
}
const clear = identity.kind === 'read_state';
const body = 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 response = await (adapters.apns || apnsRequest)({
endpoint: `https://${registration.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'}/3/device/${registration.token}`,
headers: { authorization: `bearer ${jwt.value}`, 'apns-topic': credential.topic, 'apns-push-type': clear ? 'background' : 'alert',
'apns-priority': clear ? '5' : '10', 'apns-expiration': String(Math.floor(Math.min(expiresAt, now + 60_000) / 1000)),
'apns-collapse-id': `sybil-${clear ? 'read' : 'message'}-${createHash('sha256').update(identity.sourceId).digest('hex').slice(0,24)}`,
'content-type': 'application/json' }, body,
});
return classify(response, true);
}
// Safari requires visible pushes; there is deliberately no Web Push read-clear transport.
if (!config.web || identity.kind !== 'message') return { outcome: 'failed' };
validatePushEndpoint(registration.subscription.endpoint);
const payload = JSON.stringify({ ...identity, title: 'Sybil Agent', body: 'You have a new message.', url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` });
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) });
const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! });
return classify(response, false);
} catch {
// Encryption/config and I/O errors are intentionally indistinguishable externally. Never replay an uncertain send.
return { outcome: 'ambiguous' };
}
};
}
+374
View File
@@ -0,0 +1,374 @@
import { randomUUID } from 'node:crypto';
import type { Prisma, PrismaClient } from '@prisma/client';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
import type { PushConfig } from './agent-push-config.js';
import { loadPushConfig } from './agent-push-config.js';
import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, NotificationContext, PresenceBody, PUSH_POLICY,
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
import { createPushSender, type PushSend } from './agent-push-transports.js';
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;
broadcast?: (state: ReadState) => void; warmupMs?: number };
const date = (ms: number) => new Date(ms);
const activeStatuses = ['pending', 'intent'];
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
export class AgentPushCoordinator {
readonly instanceId = randomUUID();
readonly epoch: string;
private readonly now: () => number;
private readonly warmUntil: number;
private started = false;
private untrackedConnections = new Set<string>();
private running = false;
private timer?: ReturnType<typeof setInterval>;
private stopped = false;
constructor(readonly options: Options) {
this.epoch = ownerEpoch(options.config.adminToken || 'disabled');
this.now = options.now || Date.now;
this.warmUntil = this.now() + (options.warmupMs ?? PUSH_POLICY.presenceLeaseMs);
}
async initialize() {
if (this.started) return;
await this.options.db.$transaction(async tx => {
const current = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
if (!current) await tx.agentPushCoordinator.create({ data: { id: 'root', ownerEpoch: this.epoch } });
else if (current.ownerEpoch !== this.epoch) {
// Credentials rotate ownership generation; old endpoint opt-ins do not cross it.
await tx.agentPushInstallation.updateMany({ where: { enabled: true }, data: { enabled: false } });
await tx.agentPushDelivery.updateMany({ where: { status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'owner_changed' } });
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { ownerEpoch: this.epoch, contextObservedAt: null,
nativeUnknown: true, uncertain: true, lockOwner: null, lockUntil: null } });
}
});
this.started = true;
}
start() {
if (this.timer || !this.options.config.adminToken) return;
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
void this.tick();
}
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
async config() {
await this.initialize();
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
const config = this.options.config;
return { version: 1, enabled: config.enabled, apnsAvailable: Boolean(config.enabled && config.apns),
webPushAvailable: Boolean(config.enabled && config.web), ...(config.enabled && config.web ? { vapidPublicKey: config.web.publicKey } : {}),
sourceId: state?.sourceId || null, quarantined: state?.quarantined || false, policy: PUSH_POLICY };
}
async register(id: string, value: Registration) {
await this.initialize();
const config = this.options.config;
if (!config.enabled) throw fail(503, 'Agent push is disabled');
if (value.transport === 'apns') {
if (!config.apns || config.apns.topic !== value.topic || config.apns.environment !== value.environment) throw fail(400, 'APNs topic or environment unavailable');
value = { ...value, token: value.token.toLowerCase() };
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
return this.options.db.$transaction(async tx => {
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
if (await tx.agentPushInstallation.count({ where: { ownerEpoch: this.epoch, enabled: true } }) >= 64 && !old?.enabled) throw fail(429, 'Too many push installations');
// A rotated/recreated local installation cannot retain another active delivery to the same endpoint.
const duplicates = await tx.agentPushInstallation.findMany({ where: { fingerprint, revokedAt: null, id: { not: id } }, select: { id: true } });
if (duplicates.length) {
await tx.agentPushInstallation.updateMany({ where: { id: { in: duplicates.map(v => v.id) } }, data: { enabled: false, revokedAt: date(this.now()), payload: '', fingerprint: '' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: { in: duplicates.map(v => v.id) }, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'endpoint_rebound' } });
}
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'registration_changed' } });
const record = await tx.agentPushInstallation.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true },
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
});
}
async revoke(id: string) {
await this.options.db.$transaction(async tx => {
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
if (!old?.revokedAt) await tx.agentPushInstallation.upsert({ where: { id },
create: { id, ownerEpoch: this.epoch, transport: 'revoked', payload: '', fingerprint: '', enabled: false, revokedAt: date(this.now()) },
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
});
}
async presence(id: string, value: Presence) {
const now = this.now();
return this.options.db.$transaction(async tx => {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
const 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 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 } });
}
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) } });
this.untrackedConnections.delete(id);
}
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
async notePrompt() {
if (!this.options.config.adminToken) return;
await this.initialize();
// Covers submissions arriving after the last runtime snapshot but before the send claim.
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
}
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
return { cursor: row?.cursor || '0', updatedAt: row?.updatedAt.toISOString() || null, sourceId: row?.sourceId || null };
}
private async mergeRead(tx: Tx, cursor: string, sourceId: string | null): Promise<ReadState | undefined> {
const old = await tx.agentReadState.findUnique({ where: { id: 'root' } });
if (old?.sourceId && sourceId && old.sourceId !== sourceId) return;
if (Number(cursor) <= Number(old?.cursor || 0)) return;
await tx.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt", "sourceId") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP, ${sourceId})
ON CONFLICT("id") DO UPDATE SET "cursor" = excluded."cursor", "updatedAt" = CURRENT_TIMESTAMP,
"sourceId" = COALESCE("AgentReadState"."sourceId", excluded."sourceId")
WHERE CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER)`;
if (sourceId) {
await tx.$executeRaw`UPDATE "AgentPushDelivery" SET "status" = 'cancelled', "outcome" = 'read', "updatedAt" = CURRENT_TIMESTAMP
WHERE "ownerEpoch" = ${this.epoch} AND "sourceId" = ${sourceId} AND "kind" = 'message' AND "status" = 'pending' AND CAST("cursor" AS INTEGER) <= ${Number(cursor)}`;
if (this.options.config.enabled && this.options.config.apns) {
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true, transport: 'apns' } });
for (const installation of installations) {
// Do not send background clears to installations that never received an alert.
const delivered = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId, kind: 'message', status: { in: ['accepted', 'intent', 'ambiguous'] } } });
if (!delivered) continue;
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, kind: 'read_state', status: 'pending' }, data: { status: 'cancelled', outcome: 'superseded' } });
const id = digest(`clear:${installation.id}:${installation.version}:${sourceId}:${cursor}`);
const now = this.now();
await tx.agentPushDelivery.upsert({ where: { id }, update: {}, create: { id, ownerEpoch: this.epoch, installationId: installation.id,
installationVersion: installation.version, sourceId, cursor, messageId: `read:${cursor}`, kind: 'read_state', trigger: 'read',
dueAt: date(Math.max(now, (installation.lastClearAt?.getTime() || 0) + 60_000)), expiresAt: date(now + MAX_AGE_MS) } });
}
}
}
return this.readState(tx);
}
async advanceRead(cursor: string, suppliedSource?: string): Promise<ReadState> {
await this.initialize();
const result = await this.options.db.$transaction(async tx => {
const coordinator = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
if (suppliedSource && coordinator?.sourceId !== suppliedSource) throw fail(409, 'Agent source changed; refresh before acknowledging');
// Quarantine never relabels old read positions as a different runtime.
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
});
if (result.changed) this.options.broadcast?.(result.state);
return result.state;
}
private async acquire(): Promise<boolean> {
const now = this.now();
const result = await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch,
OR: [{ lockOwner: this.instanceId }, { lockUntil: null }, { lockUntil: { lte: date(now) } }] },
data: { lockOwner: this.instanceId, lockUntil: date(now + 30_000) } });
return result.count === 1;
}
private async observe(): Promise<boolean> {
const state = await this.options.db.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
const context = parseSafe(NotificationContext, await this.options.context(state.sourceId ? state.cursor : undefined, state.sourceId || undefined));
const now = this.now(), observedAt = Date.parse(context.webPresence.observedAt);
if ((!state.sourceId || (state.sourceId === context.sourceId && Number(context.cursor) >= Number(state.cursor))) && (observedAt > now + 5_000 || observedAt < now - 10_000 || (state.contextObservedAt && observedAt < state.contextObservedAt.getTime())
|| (context.latestUserMessageAt && Date.parse(context.latestUserMessageAt) > now + 5_000)
|| (state.latestUserMessageAt && (!context.latestUserMessageAt || Date.parse(context.latestUserMessageAt) < state.latestUserMessageAt.getTime())))) throw new Error('Uncertain runtime clock');
const read = await this.options.db.$transaction(async tx => {
const current = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
if (current.lockOwner !== this.instanceId || current.ownerEpoch !== this.epoch) throw new Error('Lost coordinator lease');
if ((current.sourceId && current.sourceId !== context.sourceId) || (current.sourceId && Number(context.cursor) < Number(current.cursor))) {
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { quarantined: true, uncertain: true } });
await tx.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'cancelled', outcome: 'source_changed' } });
return undefined;
}
if (current.quarantined) return undefined;
const initial = !current.sourceId;
const previousCursor = Number(current.cursor);
let candidateCursor = previousCursor;
for (const candidate of context.candidates) {
if (Number(candidate.cursor) < candidateCursor || Number(candidate.cursor) > Number(context.cursor)) throw new Error('Invalid source order');
candidateCursor = Number(candidate.cursor);
}
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { sourceId: context.sourceId, cursor: context.cursor,
latestUserMessageAt: context.latestUserMessageAt ? date(Date.parse(context.latestUserMessageAt)) : null,
nativeActive: context.webPresence.active, nativeUnknown: context.webPresence.unknown, contextObservedAt: date(observedAt), uncertain: context.uncertain,
lockUntil: date(now + 30_000) } });
await tx.agentReadState.upsert({ where: { id: 'root' }, create: { id: 'root', cursor: '0', sourceId: context.sourceId }, update: {} });
await tx.agentReadState.updateMany({ where: { id: 'root', sourceId: null }, data: { sourceId: context.sourceId } });
const changedRead = await this.mergeRead(tx, context.nativeReadCursor, context.sourceId);
const readState = await this.readState(tx);
if (!initial && this.options.config.enabled && !context.uncertain) {
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true } });
for (const candidate of context.candidates) {
const createdAt = Date.parse(candidate.createdAt);
if (Number(candidate.cursor) <= previousCursor || Number(candidate.cursor) <= Number(readState.cursor) || candidate.trigger === 'unknown'
|| createdAt > now + 5_000 || createdAt + MAX_AGE_MS <= now || (candidate.trigger === 'user' && !context.latestUserMessageAt)) continue;
for (const installation of installations) {
const id = digest(`${this.epoch}:${installation.id}:${installation.version}:${context.sourceId}:${candidate.messageId}`);
if (await tx.agentPushDelivery.findUnique({ where: { id } })) continue;
const older = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, orderBy: { createdAt: 'asc' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, data: { status: 'cancelled', outcome: 'coalesced' } });
const burstStart = older?.createdAt.getTime() || now;
await tx.agentPushDelivery.create({ data: { id, ownerEpoch: this.epoch, installationId: installation.id, installationVersion: installation.version,
sourceId: context.sourceId, messageId: candidate.messageId, cursor: candidate.cursor, kind: 'message', trigger: candidate.trigger,
priority: Math.max(older?.priority || 0, ['timer', 'external_event'].includes(candidate.trigger) ||
(context.latestUserMessageAt && createdAt - Date.parse(context.latestUserMessageAt) >= 30_000) ? 2 : 1),
createdAt: date(burstStart), dueAt: date(Math.max(Math.min(now + PUSH_POLICY.debounceMs, burstStart + 15_000),
context.latestUserMessageAt ? Date.parse(context.latestUserMessageAt) + PUSH_POLICY.promptGraceMs : 0)), expiresAt: date(createdAt + MAX_AGE_MS) } });
}
}
}
// Keep the durable active outbox plus at most 10,000 completed receipts. The source checkpoint,
// not receipt retention, prevents historical replay after cleanup.
await tx.$executeRaw`DELETE FROM "AgentPushDelivery" WHERE "id" IN (
SELECT "id" FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent') ORDER BY "updatedAt" ASC
LIMIT MAX(0, (SELECT COUNT(*) FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent')) - 10000))`;
return changedRead;
});
if (read) this.options.broadcast?.(read);
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;
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)));
}
private async dispatch(id: string) {
const claim = await 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 installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
const read = await this.readState(tx);
if (!installation?.enabled || installation.ownerEpoch !== this.epoch || installation.version !== row.installationVersion || row.expiresAt.getTime() <= now
|| row.ownerEpoch !== this.epoch || row.sourceId !== state.sourceId || state.quarantined || (row.kind === 'message' && Number(row.cursor) <= Number(read.cursor))) {
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'obsolete' } }); return;
}
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) 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;
}
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
const registration = parseSafe(InstallationBody, JSON.parse(installation.payload));
if (registration.transport === 'web' && registration.subscription.expirationTime != null && registration.subscription.expirationTime <= now) {
await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { enabled: false } });
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'expired_subscription' } }); return;
}
// 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 };
});
if (!claim) return;
let result;
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now); }
catch { result = { outcome: 'ambiguous' as const }; }
await this.options.db.$transaction(async tx => {
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
outcome: result.outcome, ...(pending ? { dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
});
}
async tick() {
if (this.running || this.stopped || !this.options.config.adminToken) return;
this.running = true;
try {
await this.initialize(); if (!await this.acquire()) return;
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
let more = true;
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
for (const row of pending) {
if (this.stopped || !await this.acquire()) break;
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
if (await this.observe()) break;
await this.dispatch(row.id);
}
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
} catch {
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } }); } catch { /* Retry on next loop. */ }
} finally {
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
this.running = false;
}
}
}
export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient, upstream: { baseUrl: string; apiKey?: string }, broadcast: (state: ReadState) => void,
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
const config = overrides?.config || loadPushConfig();
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
context: async (after, sourceId) => {
if (!upstream.apiKey) throw new Error('Runtime unavailable');
// A replaced/restored runtime can reject the old cursor before returning its identity.
// Share one deadline across the incremental request and its single high-water probe.
const signal = AbortSignal.timeout(10_000);
const request = async (cursor?: string) => {
const url = new URL('/api/notification-context', upstream.baseUrl);
if (cursor !== undefined) url.searchParams.set('after', cursor); url.searchParams.set('limit', '200');
return fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' }, signal, redirect: 'error' });
};
let response = await request(after);
const probe = response.status === 409 && after !== undefined;
if (probe) { await response.body?.cancel(); response = await request(); }
if (!response.ok) { await response.body?.cancel(); throw new Error('Runtime unavailable'); }
const reader = response.body?.getReader(); if (!reader) throw new Error('Invalid runtime response');
const chunks: Uint8Array[] = []; let length = 0;
try { for (;;) { const { done, value } = await reader.read(); if (done) break; length += value.length;
if (length > 512 * 1024) throw new Error('Invalid runtime response'); chunks.push(value); }
} finally { await reader.cancel(); }
const context = parseSafe(NotificationContext, JSON.parse(Buffer.concat(chunks).toString()));
// This probe may only establish quarantine. Never use it to skip a backlog, advance
// the checkpoint, merge new reads, or replay historical candidates after a generic409.
if (probe && (!sourceId || context.hasMore || context.candidates.length > 0
|| (context.sourceId === sourceId && Number(context.cursor) >= Number(after)))) throw new Error('Runtime cursor unavailable');
return context;
}, send: createPushSender(config), ...overrides });
const auth = (req: FastifyRequest) => { if (!env.ADMIN_TOKEN) throw fail(503, 'Agent push requires authenticated access'); requireAdmin(req); };
const safe = <T>(fn: () => Promise<T>): Promise<T> => fn().catch(error => { if (error?.statusCode) throw error; throw fail(503, 'Agent push is unavailable'); });
const routeOptions = { bodyLimit: 16 * 1024 };
app.get('/v1/agent/push/config', async (req, reply) => { reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.config()); });
app.put<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', routeOptions, async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req);
const id = parseSafe(InstallationId, req.params.installationId), value = parseSafe(InstallationBody, req.body);
return safe(() => coordinator.register(id, value));
});
app.delete<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.revoke(parseSafe(InstallationId, req.params.installationId))); return reply.code(204).send();
});
app.put<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', routeOptions, async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.presence(parseSafe(InstallationId, req.params.sessionId), parseSafe(PresenceBody, req.body)));
});
app.delete<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.retirePresence(parseSafe(InstallationId, req.params.sessionId))); return reply.code(204).send();
});
app.addHook('onReady', async () => { if (config.adminToken && upstream.apiKey) coordinator.start(); });
app.addHook('onClose', async () => coordinator.stop());
return coordinator;
}
+196
View File
@@ -0,0 +1,196 @@
import { randomUUID } from 'node:crypto';
import { registerPushRoutes, type AgentPushCoordinator, type ReadState } from './agent-push.js';
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import type { FastifyInstance } from 'fastify';
import { WebSocket, WebSocketServer } from 'ws';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { prisma } from './db.js';
import { env } from './env.js';
import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.js';
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
.transform(value => String(Number(value)));
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
.refine(value => !(value.after && value.before), 'Use after or before, not both');
const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
const ReadBody = z.object({ cursor: Cursor, sourceId: z.string().uuid().optional() });
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional(), clientKind: z.enum(['web', 'native']).optional(), presenceSessionId: z.string().uuid().optional() });
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
type AgentOptions = { baseUrl?: string; apiKey?: string };
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
const result = schema.safeParse(value);
if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 });
return result.data;
};
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const readListeners = new Set<(state: ReadState) => void>();
const push = await registerPushRoutes(app, prisma, { baseUrl, apiKey }, state => { for (const send of readListeners) send(state); });
// An open development proxy must not expose a token-protected attachment runtime's
// history/model context using its server-side credential.
const guardOpenProxy = async (signal?: AbortSignal) => {
if (env.ADMIN_TOKEN) return;
try {
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { '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');
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
};
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
await guardOpenProxy(signal);
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
});
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown;
try { data = await response.json(); }
catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
return { status: response.status, data };
};
app.get('/v1/agent', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const result = await proxyJson('/api/conversations/root');
reply.code(result.status);
if (result.status !== 200) return result.data;
const descriptor: Record<string, unknown> = { ...result.data as Record<string, unknown>, websocket: '/v1/agent/ws' };
if (!env.ADMIN_TOKEN) delete descriptor.capabilities;
return descriptor;
});
app.get('/v1/agent/messages', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const query = parse(PageQuery, req.query);
const params = new URLSearchParams();
for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value));
const result = await proxyJson(`/api/conversations/root/messages?${params}`);
reply.code(result.status);
return result.data;
});
app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const body = parse(MessageBody, req.body);
await push.notePrompt();
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`
|| (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) {
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
}
}
reply.code(result.status);
return result.data;
});
app.get('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
return push.readState();
});
app.put('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const { cursor, sourceId } = parse(ReadBody, req.body);
return push.advanceRead(cursor, sourceId);
});
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy, push, readListeners);
}
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>, push: AgentPushCoordinator, readListeners: Set<(state: ReadState) => void>) {
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
const cleanup = new Set<() => void>();
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
let url: URL;
try { url = new URL(request.url || '/', 'http://localhost'); }
catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
// Credentials must never enter URLs and access logs.
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
sockets.handleUpgrade(request, socket, head, browser => {
let upstream: WebSocket | undefined;
const connectionId = randomUUID();
const sendRead = (state: ReadState) => send({ type: 'read_state', ...state });
let authenticated = false, authenticating = false, disposed = false, alive = true;
const send = (value: unknown) => {
if (browser.readyState !== WebSocket.OPEN) return;
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
browser.send(typeof value === 'string' ? value : JSON.stringify(value));
};
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(() => {}); });
const dispose = () => {
if (disposed) return;
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
readListeners.delete(sendRead);
void push.disconnect(connectionId).catch(() => {});
upstream?.terminate(); browser.terminate();
};
cleanup.add(dispose);
browser.on('close', dispose); browser.on('error', dispose);
browser.on('message', async (raw, isBinary) => {
try {
if (isBinary) throw new Error('JSON text frames are required');
const frame = JSON.parse(raw.toString());
if (!authenticated) {
if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; }
authenticating = true;
const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
if (disposed || browser.readyState !== WebSocket.OPEN) return;
authenticated = true; clearTimeout(authTimeout);
readListeners.add(sendRead);
if (env.ADMIN_TOKEN) await push.connect(connectionId, auth.clientKind, auth.presenceSessionId).catch(() => {});
if (disposed) { void push.disconnect(connectionId).catch(() => {}); return; }
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
upstream.on('message', (data, binary) => {
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
send(data.toString());
});
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
return;
}
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
const subscribe = parse(SubscribeFrame, frame);
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
upstream.send(JSON.stringify(subscribe));
} catch (error) {
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
}
});
});
};
app.server.on('upgrade', upgrade);
app.addHook('preClose', async () => {
app.server.off('upgrade', upgrade);
for (const stop of cleanup) stop();
for (const client of sockets.clients) client.terminate();
sockets.close();
});
}
+2 -2
View File
@@ -8,9 +8,9 @@ export const prisma = new PrismaClient({
});
prisma.$on("error", (e: any) => {
console.error("[prisma:error]", e);
console.error("[prisma:error] Database operation failed");
});
prisma.$on("warn", (e: any) => {
console.warn("[prisma:warn]", e);
console.warn("[prisma:warn] Database warning");
});
+10
View File
@@ -70,6 +70,16 @@ const EnvSchema = z.object({
HERMES_AGENT_API_BASE_URL: HermesAgentApiBaseUrlSchema,
HERMES_AGENT_API_KEY: OptionalTrimmedStringSchema,
HERMES_AGENT_MODEL: OptionalTrimmedStringSchema,
SYBIL_AGENT_BASE_URL: z.string().url().default("http://sybil-agent:8788"),
SYBIL_AGENT_API_KEY: OptionalTrimmedStringSchema,
// Off until a deliberate server opt-in plus per-installation user consent.
AGENT_PUSH_ENABLED: BooleanFlagSchema,
AGENT_PUSH_APNS_KEY_FILE: OptionalTrimmedStringSchema,
AGENT_PUSH_APNS_KEY_ID: z.string().regex(/^[A-Z0-9]{10}$/).optional(),
AGENT_PUSH_APNS_TEAM_ID: z.string().regex(/^[A-Z0-9]{10}$/).optional(),
AGENT_PUSH_APNS_TOPIC: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9.-]{0,127}$/).default("net.buzzert.sybil2"),
AGENT_PUSH_APNS_ENVIRONMENT: z.enum(["sandbox", "production"]).default("production"),
AGENT_PUSH_VAPID_FILE: OptionalTrimmedStringSchema,
EXA_API_KEY: z.string().optional(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
+14 -6
View File
@@ -6,8 +6,12 @@ import sensible from "@fastify/sensible";
import { env } from "./env.js";
import { ensureDatabaseReady } from "./db-init.js";
import { startModelCatalogRefreshLoop, warmModelCatalog } from "./llm/model-catalog.js";
import { registerAgentRoutes } from "./agent.js";
import { registerRoutes } from "./routes.js";
// Push URL parameters are identifiers, never credentials. Redact even malformed caller URLs.
const logUrl = (url: string) => url.startsWith('/v1/agent/push/') ? '/v1/agent/push/[redacted]'
: url.startsWith('/v1/agent/presence/') ? '/v1/agent/presence/[redacted]' : url;
const app = Fastify({
bodyLimit: 32 * 1024 * 1024,
disableRequestLogging: true,
@@ -26,7 +30,7 @@ const stopModelCatalogRefreshLoop = startModelCatalogRefreshLoop(app.log);
await app.register(cors, {
origin: true,
credentials: true,
methods: ["GET", "HEAD", "POST", "DELETE", "OPTIONS"],
methods: ["GET", "HEAD", "POST", "PUT", "DELETE", "OPTIONS"],
});
await app.register(swagger, {
@@ -43,7 +47,7 @@ await app.register(sensible);
app.addHook("onRequest", async (req) => {
if (req.url.startsWith("/health")) return;
req.log.info({ method: req.method, url: req.url }, "request received");
req.log.info({ method: req.method, url: logUrl(req.url) }, "request received");
});
app.addHook("onResponse", async (req, reply) => {
@@ -52,7 +56,7 @@ app.addHook("onResponse", async (req, reply) => {
req.log.info(
{
method: req.method,
url: req.url,
url: logUrl(req.url),
statusCode: reply.statusCode,
responseTimeMs,
},
@@ -67,17 +71,20 @@ app.setErrorHandler((err, req, reply) => {
req.log.error(
{
method: req.method,
url: req.url,
url: logUrl(req.url),
statusCode,
err: e,
err: req.url.startsWith('/v1/agent/push/') || req.url.startsWith('/v1/agent/presence/') ? { message: 'Agent push request failed' } : e,
},
"request failed"
);
}
reply.status(statusCode).send({
error: true,
message: e.message ?? String(e),
message: (req.url.startsWith('/v1/agent/push/') || req.url.startsWith('/v1/agent/presence/'))
? (statusCode === 400 ? 'Invalid Agent push request' : statusCode >= 500 ? 'Agent push is unavailable' : e.message || 'Agent push request failed')
: e.message ?? String(e),
statusCode,
...(typeof e.code === 'string' ? { code: e.code } : {}),
});
});
@@ -86,6 +93,7 @@ app.addHook("onClose", async () => {
});
await registerRoutes(app);
await registerAgentRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST });
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
+1 -1
View File
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id);
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
}
function isLikelyGeminiChatModel(model: string) {
+32
View File
@@ -16,6 +16,7 @@ import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import { searchWorkspace } from "./workspace-search.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
@@ -927,6 +928,37 @@ export async function registerRoutes(app: FastifyInstance) {
app.get("/v1/workspace-items", async (req) => {
requireAdmin(req);
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
if (parsed.data.q) {
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
select: {
...chatSummarySelect,
parentChat: { select: { projectItems: starredProjectItemsSelect } },
},
}),
prisma.search.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
select: searchSummarySelect,
}),
]);
const summaries = new Map<string, Record<string, unknown>>([
...chats.map(({ parentChat, ...chat }) => [
`chat:${chat.id}`,
// Search omits unmatched roots, but sidebar fork stars belong to the root.
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
] as const),
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
]);
const items = hits.flatMap((hit) => {
const summary = summaries.get(`${hit.type}:${hit.id}`);
return summary ? [{ ...summary, ...hit }] : [];
});
return { items, hasMore };
}
return { items: await listWorkspaceItems() };
});
+74
View File
@@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./db.js";
export const WORKSPACE_SEARCH_LIMIT = 100;
// Treat input as literal Unicode words, never FTS operators/column expressions.
// Prefix matching supports type-ahead, including short terms such as "AI".
export function workspaceSearchExpression(query: string): string | null {
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
}
export type WorkspaceSearchHit = {
type: "chat" | "search";
id: string;
searchSnippet: string | null;
};
export function workspaceSearchSQL(expression: string) {
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
return Prisma.sql`
WITH chat_hits AS (
SELECT c.id, NULL AS message_rowid
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
WHERE "ChatSearch" MATCH ${expression}
UNION ALL
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
WHERE "MessageSearch" MATCH ${expression}
GROUP BY m.chatId
), matches AS (
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
NULL AS search_rowid
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
GROUP BY c.id
UNION ALL
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
WHERE "SavedSearchSearch" MATCH ${expression}
), limited AS MATERIALIZED (
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
), message_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
FROM "MessageSearch"
WHERE "MessageSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
AND +rowid IN (SELECT message_rowid FROM limited)
), search_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
FROM "SavedSearchSearch"
WHERE "SavedSearchSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
AND +rowid IN (SELECT search_rowid FROM limited)
)
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
FROM limited
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
ORDER BY updatedAt DESC, type, id
`;
}
export async function searchWorkspace(query: string) {
const expression = workspaceSearchExpression(query);
if (!expression) return { hits: [], hasMore: false };
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
// only for the bounded result set, never fetch full transcripts into JS.
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
}
+94
View File
@@ -0,0 +1,94 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { once } from 'node:events';
import { createServer, request as httpRequest, type ServerResponse } from 'node:http';
import { Readable } from 'node:stream';
import test from 'node:test';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
process.env.AGENT_PUSH_ENABLED = 'false';
process.env.ADMIN_TOKEN = 'attachments-browser-token-test';
const directory = mkdtempSync(join(tmpdir(), 'sybil-attachment-proxy-'));
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
process.env.DATABASE_URL = `file:${join(directory, 'test.db')}`;
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/agent.js'), import('../src/env.js')]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
const bodies: any[] = [];
let capable=true, 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(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
if(req.method==='POST'){const body=JSON.parse(bytes.toString());bodies.push(body);res.statusCode=202;res.end(JSON.stringify({id:1,status:'queued',messageId:`inbound:sybil:${body.requestId}`,...(!badReceipt?{attachmentIds:body.attachments.map((f:any)=>f.id)}:{})}));return;}
res.end(JSON.stringify({conversationId:'1',messages:[{id:'inbound:sybil:send',role:'user',content:'Attached: résumé.txt.',metadata:{agentAttachments:[file]}},{id:'tool:2',role:'tool',metadata:{kind:'tool_call'}}],cursor:'2',hasMore:false}));
});
source.listen(0,'127.0.0.1');await once(source,'listening');
const app=Fastify();await app.register(sensible);await registerAgentRoutes(app,{baseUrl:`http://127.0.0.1:${(source.address() as {port:number}).port}`,apiKey:key});
await app.listen({port:0,host:'127.0.0.1'});const base=`http://127.0.0.1:${(app.server.address() as {port:number}).port}`;
test.after(async()=>{await app.close();source.closeAllConnections();await new Promise<void>(r=>source.close(()=>r()));const {prisma}=await import('../src/db.js');await prisma.$disconnect();rmSync(directory,{recursive:true,force:true});});
function form(){const body=new FormData();body.append('file',new Blob(['data'],{type:'text/plain'}),'résumé.txt');body.append('uploadId','12345678-1234-4234-8234-123456789abc');return body;}
test('auth precedes parsing/forwarding; capability gates old or unsafe upstream and open backend',async()=>{
const before=uploadCalls;
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,401);assert.equal(uploadCalls,before);
capable=false;assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'x',attachments:[{id:file.id}]}})).statusCode,503);
capable=true;const saved=env.ADMIN_TOKEN;env.ADMIN_TOKEN=undefined;
try {assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',body:form()})).status,503);assert.equal((await app.inject({url:'/v1/agent'})).statusCode,503);assert.equal((await app.inject({url:'/v1/agent/messages'})).statusCode,503);}finally{env.ADMIN_TOKEN=saved;}
});
test('multipart forwards Unicode filename/uploadId with private auth; quota details and authenticated originals survive',async()=>{
const response=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(response.status,201);assert.deepEqual(await response.json(),{attachment:file});assert.match(bodies.at(-1),/12345678-1234-4234-8234-123456789abc/);
errorStatus=507;try{const full=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()});assert.equal(full.status,507);assert.equal((await full.json() as any).remainingBytes,12);}finally{errorStatus=0;}
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
const download=await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth});assert.equal(download.status,200);assert.equal(await download.text(),'data');assert.match(download.headers.get('cache-control')!,/no-store/);assert.equal(download.headers.get('x-content-type-options'),'nosniff');
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
});
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
badReceipt=true;try{assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,502);}finally{badReceipt=false;}
for(const attachments of [[{...file}],[{id:file.id,workspacePath:'/etc/passwd'}],[{id:file.id},{id:file.id}],Array(9).fill({id:file.id})]) assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{...payload,attachments}})).statusCode,400);
const page=(await app.inject({url:'/v1/agent/messages',headers:auth})).json();assert.deepEqual(page.messages[0].metadata.agentAttachments,[file]);assert.equal(page.messages[1].role,'tool');
});
test('proxy rejects oversized declared bodies and chunked bodies',async()=>{
const before=uploadCalls;
const large=await app.inject({method:'POST',url:'/v1/agent/attachments',headers:{...auth,'content-type':'multipart/form-data; boundary=x','content-length':String(9*1024*1024)}});assert.equal(large.statusCode,413);assert.equal(uploadCalls,before);
const chunks=Readable.from((async function*(){for(let i=0;i<145;i++)yield Buffer.alloc(64*1024);})());
const result=await fetch(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'},body:chunks as unknown as RequestInit['body'],duplex:'half'} as RequestInit & {duplex:string});
assert.equal(result.status,413);assert.equal((await result.json() as any).code,'attachment_limit_exceeded');
});
async function eventually(check:()=>boolean){const end=Date.now()+3000;while(!check()&&Date.now()<end)await new Promise(r=>setTimeout(r,10));assert.ok(check(),'Expected cancellation to finish');}
test('disconnect releases upload/download slots exactly once, cancels upstream and never continues after capability cancellation',async()=>{
const before=uploadCalls;
holdDescriptor=true;
for(let n=0;n<3;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+'/v1/agent/attachments',{method:'POST',headers:{...auth,'content-type':'multipart/form-data; boundary=x'}},()=>{});req.on('error',()=>{});req.end('--x--');
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDescriptor=false;assert.equal(uploadCalls,before,'canceled capability checks never forward the upload');
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,201);
holdDownload=true;
for(let n=0;n<5;n++){
const expected=held.length+1,terminal=closed.length+1;
const req=httpRequest(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth},()=>{});req.on('error',()=>{});req.end();
await eventually(()=>held.length===expected);req.destroy();await eventually(()=>closed.length===terminal);
}
holdDownload=false;assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
});
+329
View File
@@ -0,0 +1,329 @@
import assert from 'node:assert/strict';
import { createECDH, generateKeyPairSync, randomUUID, verify } from 'node:crypto';
import { EventEmitter, once } from 'node:events';
import { createServer } from 'node:http';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import webpush from 'web-push';
import type { Context, Registration } from '../src/agent-push-contract.js';
import type { PushConfig } from '../src/agent-push-config.js';
import type { SendResult } from '../src/agent-push-transports.js';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const directory = mkdtempSync(join(tmpdir(), 'sybil-push-test-'));
process.env.DATABASE_URL = `file:${join(directory, 'push.db')}`;
process.env.ADMIN_TOKEN = 'push-test-bearer-0123456789';
process.env.AGENT_PUSH_ENABLED = 'false';
for (const key of ['AGENT_PUSH_APNS_KEY_FILE','AGENT_PUSH_APNS_KEY_ID','AGENT_PUSH_APNS_TEAM_ID','AGENT_PUSH_VAPID_FILE']) delete process.env[key];
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: process.env, stdio: 'pipe' });
const [{ prisma }, { AgentPushCoordinator, registerPushRoutes }, { createPushSender, isPublicAddress, createAPNsRequest, createWebRequest }, { validatePushEndpoint }, { env }] = await Promise.all([
import('../src/db.js'), import('../src/agent-push.js'), import('../src/agent-push-transports.js'), import('../src/agent-push-contract.js'), import('../src/env.js'),
]);
const apns: Registration = { transport: 'apns', enabled: true, token: 'a'.repeat(64), environment: 'sandbox', topic: 'net.buzzert.sybil2' };
const config: PushConfig = { enabled: true, adminToken: process.env.ADMIN_TOKEN,
apns: { key: 'fixture-unused-by-injected-sender', keyId: 'ABCDEFGHIJ', teamId: 'DQQH5H6GBD', topic: apns.topic, environment: 'sandbox' },
web: { publicKey: 'fixture-unused', privateKey: 'fixture-unused', subject: 'mailto:tests@example.com' } };
function subscription(): Registration {
const curve = createECDH('prime256v1'); curve.generateKeys();
return { transport: 'web', enabled: true, subscription: { endpoint: `https://web.push.apple.com/test/${randomUUID()}`, expirationTime: null,
keys: { p256dh: curve.getPublicKey().toString('base64url'), auth: Buffer.alloc(16, 1).toString('base64url') } } };
}
async function fixture(options: { warmupMs?: number; config?: PushConfig } = {}) {
let now = Date.now();
const source = randomUUID();
let context: Context = { version: 1, sourceId: source, conversationId: '1', cursor: '10', hasMore: false,
latestUserMessageAt: new Date(now - 30_000).toISOString(), nativeReadCursor: '0', uncertain: false,
webPresence: { active: false, unknown: false, observedAt: new Date(now).toISOString() }, candidates: [] };
const sent: { registration: Registration; identity: any }[] = [], 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) });
await manager.initialize();
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
result(value: SendResult) { result = value; }, failure(value: boolean) { failure = value; },
context(value: Partial<Context>) { context = { ...context, ...value }; },
candidate(cursor = '11', trigger: Context['candidates'][number]['trigger'] = 'user') { context = { ...context, cursor,
candidates: [...context.candidates, { cursor, messageId: `outbound:${cursor}`, createdAt: new Date(now).toISOString(), trigger }] }; },
};
}
test.beforeEach(async () => {
for (const table of ['AgentPushDelivery','AgentPushConnection','AgentPushPresence','AgentPushInstallation','AgentPushCoordinator','AgentReadState']) await prisma.$executeRawUnsafe(`DELETE FROM "${table}"`);
});
test.after(async () => { await prisma.$disconnect(); rmSync(directory, { recursive: true, force: true }); });
test('first source bootstrap never alerts history; later durable candidates debounce, survive restart and deduplicate', async () => {
const f = await fixture(); assert.equal((await f.manager.config()).sourceId, f.source); assert.equal(f.sent.length, 0);
f.candidate(); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
await f.manager.tick(); assert.equal(f.sent.length, 1);
assert.deepEqual(f.sent[0].identity, { version: 1, sourceId: f.source, kind: 'message', messageId: 'outbound:11', cursor: '11' });
assert.equal((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();
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');
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');
});
test('instrumented hidden web socket is inactive; missing lease and native-runtime unknown veto', async () => {
const f = await fixture(), tab = randomUUID(), connection = randomUUID();
await f.manager.connect(connection, 'web', tab); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
await f.manager.presence(tab, { sequence: 0, visibility: 'hidden', focused: false, interacted: false });
f.context({ webPresence: { active: false, unknown: true, observedAt: '' } }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.context({ webPresence: { active: false, unknown: false, observedAt: '' } }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
});
test('prompt grace resets on latest accepted prompt, root bursts coalesce, unknown provenance never sends', async () => {
const f = await fixture(); f.context({ latestUserMessageAt: new Date(f.now).toISOString() });
f.candidate(); await f.manager.tick(); f.advance(3_000); f.candidate('12', 'timer'); await f.manager.tick();
f.context({ latestUserMessageAt: new Date(f.now).toISOString() }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.advance(3_000); await f.manager.tick(); assert.equal(f.sent.length, 1); assert.equal(f.sent[0].identity.cursor, '12');
f.candidate('13', 'unknown'); await f.manager.tick(); f.advance(10_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
});
test('canonical read and native read cancel pending sends and broadcast source-bound state', async () => {
const f = await fixture(); f.candidate(); await f.manager.tick(); await f.manager.advanceRead('11', f.source); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.candidate('12'); await f.manager.tick(); f.context({ nativeReadCursor: '12' }); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
assert.equal((await f.manager.readState()).cursor, '12'); assert.equal(f.reads.at(-1).sourceId, f.source);
assert.equal((await f.manager.advanceRead('1')).cursor, '12');
await assert.rejects(f.manager.advanceRead('13', randomUUID()), { statusCode: 409 });
});
test('read clears target APNs only, coalesce and rate-limit; Web Push is never invisible', async () => {
const f = await fixture(); await f.manager.register(randomUUID(), subscription()); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 2);
await f.manager.advanceRead('11', f.source); await f.manager.tick();
const clears = f.sent.filter(v => v.identity.kind === 'read_state'); assert.equal(clears.length, 1); assert.equal(clears[0].registration.transport, 'apns');
await f.manager.advanceRead('12', f.source); await f.manager.tick(); assert.equal(f.sent.length, 3);
f.advance(60_000); await f.manager.tick(); assert.equal(f.sent.filter(v => v.identity.kind === 'read_state').length, 2);
});
test('unavailable source, uncertainty, startup warm-up and source replacement suppress safely', async () => {
const f = await fixture({ warmupMs: 60_000 }); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.advance(60_000); f.failure(true); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.failure(false); f.context({ uncertain: true }); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.context({ uncertain: false, sourceId: randomUUID(), latestUserMessageAt: null }); await f.manager.tick(); assert.equal(f.sent.length, 0);
assert.equal((await f.manager.config()).quarantined, true); assert.equal((await f.manager.config()).sourceId, f.source);
assert.equal((await f.manager.readState()).sourceId, f.source);
});
test('known transient rejections retry boundedly; interrupted intent and ambiguous I/O never replay', async () => {
const f = await fixture(); f.result({ outcome: 'retry', retryAfterMs: 1_000 }); f.candidate(); await f.manager.tick(); f.advance(2_000);
for (let i = 0; i < 6; i++) { await f.manager.tick(); f.advance(2_000); }
assert.equal(f.sent.length, 4); assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'failed');
f.candidate('12'); f.result({ outcome: 'ambiguous' }); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
f.advance(60_000); await f.manager.tick(); assert.equal(f.sent.length, 5);
f.candidate('13'); await f.manager.tick(); await prisma.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'intent', updatedAt: new Date(f.now - 40_000) } });
await f.manager.tick(); assert.equal(f.sent.length, 5); assert.equal(await prisma.agentPushDelivery.count({ where: { status: 'ambiguous' } }), 2);
});
test('revoke and rotation prevent pending sends; permanent rejection cannot invalidate a newer endpoint version', async () => {
const f = await fixture(); const same = await f.manager.register(f.installationId, apns); assert.equal(same.version, 1);
f.candidate(); await f.manager.tick(); await f.manager.revoke(f.installationId); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
const newId = randomUUID(); await f.manager.register(newId, apns); f.candidate('12'); await f.manager.tick(); f.advance(2_000);
f.manager.options.send = async () => { await f.manager.register(newId, { ...apns, token: 'b'.repeat(64) }); return { outcome: 'invalid' }; };
await f.manager.tick(); const latest = await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: newId } }); assert.equal(latest.enabled, true); assert.equal(latest.version, 2);
});
test('credential generation change disables opt-ins; two coordinators do not send one delivery twice', async () => {
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
const second = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await second.initialize();
await Promise.all([f.manager.tick(), second.tick()]); assert.equal(f.sent.length, 1);
const rotated = new AgentPushCoordinator({ ...f.manager.options, config: { ...config, adminToken: 'rotated-fixture-token' }, warmupMs: 0 }); await rotated.initialize();
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).enabled, false);
});
test('authenticated routes reject open mode, wrong owner/body, malformed endpoints and secrets are never reflected', async () => {
const app = Fastify({ logger: false }); await app.register(sensible);
await registerPushRoutes(app, prisma, { baseUrl: 'http://unused.invalid' }, () => {}, { config, context: async () => { throw new Error(); }, send: async () => { throw new Error('must never send'); } });
const headers = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, url = `/v1/agent/push/installations/${randomUUID()}`;
assert.equal((await app.inject({ url: '/v1/agent/push/config' })).statusCode, 401);
assert.equal((await app.inject({ url: '/v1/agent/push/config', headers: { authorization: 'Bearer wrong' } })).statusCode, 403);
const valid = await app.inject({ method: 'PUT', url, headers, payload: apns }); assert.equal(valid.statusCode, 200); assert.ok(!valid.body.includes(apns.token));
assert.equal((await app.inject({ method: 'PUT', url, headers, payload: { ...apns, ownerId: 'someone-else' } })).statusCode, 400);
const invalid = subscription(); if (invalid.transport === 'web') invalid.subscription.endpoint = 'https://127.0.0.1/private-secret';
const response = await app.inject({ method: 'PUT', url, headers, payload: invalid }); assert.equal(response.statusCode, 400); assert.ok(!response.body.includes('private-secret'));
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try { assert.equal((await app.inject({ url: '/v1/agent/push/config', headers })).statusCode, 503); } finally { env.ADMIN_TOKEN = saved; }
assert.equal((await app.inject({ method: 'DELETE', url, headers })).statusCode, 204);
await app.close();
});
test('provider endpoint validation rejects SSRF targets and private/reserved DNS addresses', () => {
for (const value of ['http://fcm.googleapis.com/x','https://user:pass@fcm.googleapis.com/x','https://fcm.googleapis.com.evil.example/x', 'https://fcm.googleapis.com:8443/x', 'https://[::1]/x','https://web.push.apple.com/x#fragment']) assert.throws(() => validatePushEndpoint(value));
for (const address of ['127.0.0.1','10.0.0.1','169.254.1.1','192.168.1.1','172.16.0.1','::1','::ffff:127.0.0.1','fd00::1','fe80::1','2001:db8::1']) assert.equal(isPublicAddress(address), false, address);
assert.equal(isPublicAddress('8.8.8.8'), true); assert.equal(isPublicAddress('2606:4700::1111'), true);
});
test('real APNs ES256 request construction uses generic alerts, 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(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.' });
await sender(apns, { version: 1, sourceId, kind: 'read_state', readCursor: '12' }, now + 60_000, now);
assert.equal(requests[1].headers['apns-push-type'], 'background'); assert.equal(requests[1].headers['apns-priority'], '5');
assert.deepEqual(JSON.parse(requests[1].body).aps, { 'content-available': 1 });
});
test('standard encrypted aes128gcm + VAPID Web Push uses library request construction and never sends read clears', async () => {
const vapid = webpush.generateVAPIDKeys(), registrations = subscription(), requests: any[] = [];
const sender = createPushSender({ ...config, web: { ...vapid, subject: 'mailto:tests@example.com' } }, { web: async request => { requests.push(request); return { status: 201 }; } });
const now = Date.now(), sourceId = randomUUID();
assert.equal((await sender(registrations, { version: 1, sourceId, kind: 'message', messageId: 'outbound:1', cursor: '1' }, now + 300_000, now)).outcome, 'accepted');
assert.equal(requests.length, 1); assert.equal(requests[0].headers['Content-Encoding'], 'aes128gcm'); assert.equal(requests[0].headers.TTL, 60);
assert.match(requests[0].headers.Authorization, /^vapid t=/); assert.ok(Buffer.isBuffer(requests[0].body)); assert.ok(!requests[0].body.includes('You have a new message.'));
assert.equal((await sender(registrations, { version: 1, sourceId, kind: 'read_state', readCursor: '1' }, now + 60_000, now)).outcome, 'failed'); assert.equal(requests.length, 1);
const gone = createPushSender({ ...config, web: { ...vapid, subject: 'mailto:tests@example.com' } }, { web: async () => ({ status: 410 }) });
assert.equal((await gone(registrations, { version: 1, sourceId, kind: 'message', messageId: 'outbound:2', cursor: '2' }, now + 60_000, now)).outcome, 'invalid');
});
test('production-scoped APNs configuration refuses sandbox registration and cannot send it', async () => {
const production = { ...config, apns: { ...config.apns!, environment: 'production' as const } };
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
await assert.rejects(manager.register(randomUUID(), apns), { statusCode: 400 });
let requests = 0;
const sender = createPushSender(production, { apns: async () => { requests++; throw new Error('must not send'); } });
const now = Date.now();
assert.equal((await sender(apns, { version: 1, sourceId: randomUUID(), kind: 'message', messageId: 'outbound:1', cursor: '1' }, now + 60_000, now)).outcome, 'failed');
assert.equal(requests, 0);
});
test('raw mocked HTTP2 handles request throws, goaway and close; raw HTTPS has absolute timeout even without socket inactivity', async () => {
const input = { endpoint: 'https://api.push.apple.com/3/device/fixture', headers: {}, body: Buffer.from('{}') };
for (const event of ['connect', 'goaway', 'close', 'never']) {
const session = new EventEmitter() as any; let destroyed = 0;
session.destroy = () => { destroyed++; }; session.request = () => { throw new Error('fixture request error'); };
const transport = createAPNsRequest((() => { if (event !== 'never') queueMicrotask(() => session.emit(event)); return session; }) as any, 5);
await assert.rejects(transport(input), /APNs transport unavailable/); assert.equal(destroyed, 1);
}
const req = new EventEmitter() as any; let destroyed = false; req.destroy = () => { destroyed = true; }; req.end = () => {};
const web = createWebRequest((() => req) as any, 5);
await assert.rejects(web({ ...input, endpoint: 'https://web.push.apple.com/fixture' }), /Web Push transport unavailable/); assert.equal(destroyed, true);
const throwing = createWebRequest((() => { throw new Error('fixture constructor error'); }) as any, 5);
await assert.rejects(throwing({ ...input, endpoint: 'https://web.push.apple.com/fixture' }), /Web Push transport unavailable/);
});
test('DELETE tombstones unknown and known UUIDs across restart; late PUT and rebound endpoints cannot re-enable retired IDs', async () => {
const f = await fixture(), unknown = randomUUID();
await f.manager.revoke(unknown);
await assert.rejects(f.manager.register(unknown, apns), { statusCode: 409 });
await f.manager.revoke(f.installationId); await f.manager.revoke(f.installationId);
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.initialize();
await assert.rejects(restarted.register(f.installationId, apns), { statusCode: 409 });
const first = randomUUID(), second = randomUUID();
await restarted.register(first, apns); await restarted.register(second, apns);
await assert.rejects(restarted.register(first, apns), { statusCode: 409 });
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: first } })).payload, '');
assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: second } })).enabled, true);
});
test('concurrent backend prompt admission starts durable grace even before runtime snapshot catches up', async () => {
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
await f.manager.notePrompt(); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 0);
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
});
async function httpContextFixture(f: Awaited<ReturnType<typeof fixture>>, snapshot: unknown, statuses = { incremental: 409, bootstrap: 200 }) {
const requests: (string | null)[] = [], sent: unknown[] = [];
const source = createServer((req, res) => {
assert.equal(req.headers.authorization, 'Bearer fixture-runtime-secret');
assert.equal(req.headers['x-sybil-client'], 'backend-proxy-v1');
const url = new URL(req.url!, 'http://fixture.invalid');
assert.equal(url.pathname, '/api/notification-context'); assert.equal(url.searchParams.get('limit'), '200');
const after = url.searchParams.get('after'); requests.push(after);
res.statusCode = after === null ? statuses.bootstrap : statuses.incremental;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(after === null && statuses.bootstrap === 200 ? snapshot : { error: 'cursor unavailable' }));
});
source.listen(0, '127.0.0.1'); await once(source, 'listening');
const app = Fastify({ logger: false }); await app.register(sensible);
const manager = await registerPushRoutes(app, prisma, { baseUrl: `http://127.0.0.1:${(source.address() as { port: number }).port}`, apiKey: 'fixture-runtime-secret' }, () => {}, {
config, warmupMs: 0, now: () => f.now,
send: async (_registration, identity) => { sent.push(identity); return { outcome: 'accepted' }; },
});
return { manager, requests, sent, close: async () => { await manager.stop(); await app.close(); source.closeAllConnections(); await new Promise<void>(resolve => source.close(() => resolve())); } };
}
function highWaterSnapshot(f: Awaited<ReturnType<typeof fixture>>, overrides: Partial<Context> = {}): Context {
return { version: 1, sourceId: randomUUID(), conversationId: '1', cursor: '3', hasMore: false, latestUserMessageAt: null,
nativeReadCursor: '0', uncertain: false, webPresence: { active: false, unknown: false, observedAt: new Date(f.now).toISOString() }, candidates: [], ...overrides };
}
for (const identityChanged of [true, false]) test(`actual HTTP409 probes once and quarantines ${identityChanged ? 'replacement source' : 'same-source cursor regression'} without rebasing reads/checkpoint`, async () => {
const f = await fixture(); await f.manager.advanceRead('4', f.source); f.candidate(); await f.manager.tick(); f.advance(2_000);
const oldRead = await f.manager.readState();
const http = await httpContextFixture(f, highWaterSnapshot(f, identityChanged ? {} : { sourceId: f.source }));
try {
await http.manager.tick(); assert.deepEqual(http.requests, ['11', null]); assert.equal(http.sent.length, 0);
const state = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
assert.equal(state.quarantined, true); assert.equal(state.sourceId, f.source); assert.equal(state.cursor, '11');
assert.deepEqual(await http.manager.readState(), oldRead);
const delivery = await prisma.agentPushDelivery.findFirstOrThrow(); assert.equal(delivery.status, 'cancelled'); assert.equal(delivery.outcome, 'source_changed');
assert.equal(await prisma.agentPushDelivery.count(), 1, 'probe never enumerates/replays historical candidates');
} finally { await http.close(); }
});
for (const failure of ['rejected', 'conflict', 'malformed', 'candidates', 'paginated', 'same-source-equal', 'same-source-higher', 'non-conflict']) test(`HTTP cursor recovery stays fail closed for ${failure}`, async () => {
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000); const oldRead = await f.manager.readState();
const snapshot = failure === 'malformed' ? { invalid: true } : highWaterSnapshot(f, {
...(failure === 'candidates' ? { candidates: [{ messageId: 'outbound:3', cursor: '3', createdAt: new Date(f.now).toISOString(), trigger: 'timer' as const }] } : {}),
...(failure === 'paginated' ? { hasMore: true } : {}),
...(failure.startsWith('same-source') ? { sourceId: f.source, cursor: failure === 'same-source-higher' ? '12' : '11', nativeReadCursor: '11' } : {}),
});
const http = await httpContextFixture(f, snapshot, { incremental: failure === 'non-conflict' ? 503 : 409,
bootstrap: failure === 'rejected' ? 503 : failure === 'conflict' ? 409 : 200 });
try {
await http.manager.tick(); assert.deepEqual(http.requests, failure === 'non-conflict' ? ['11'] : ['11', null]); assert.equal(http.sent.length, 0);
const state = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
assert.equal(state.uncertain, true); assert.equal(state.quarantined, false); assert.equal(state.sourceId, f.source); assert.equal(state.cursor, '11');
assert.deepEqual(await http.manager.readState(), oldRead); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).status, 'pending');
} finally { await http.close(); }
});
test('endpoint rebind retires a provider-disabled UUID, rejects its delayed PUT, and leaves no live endpoint after new owner revoke', async () => {
const f = await fixture(); f.result({ outcome: 'invalid' }); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
const invalid = await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } });
assert.equal(invalid.enabled, false); assert.equal(invalid.revokedAt, null); assert.notEqual(invalid.fingerprint, '');
const newer = randomUUID(); await f.manager.register(newer, apns);
assert.notEqual((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).revokedAt, null);
await assert.rejects(f.manager.register(f.installationId, apns), { statusCode: 409 });
await f.manager.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
});
test('endpoint rebind also retires disabled registrations from a prior credential generation', async () => {
const f = await fixture();
const current = new AgentPushCoordinator({ ...f.manager.options, config: { ...config, adminToken: 'new-fixture-bearer-generation' }, warmupMs: 0 });
await current.initialize(); assert.equal((await prisma.agentPushInstallation.findUniqueOrThrow({ where: { id: f.installationId } })).enabled, false);
const newer = randomUUID(); await current.register(newer, apns);
await assert.rejects(f.manager.register(f.installationId, apns), { statusCode: 409 });
await assert.rejects(current.register(f.installationId, apns), { statusCode: 409 });
await current.revoke(newer); assert.equal(await prisma.agentPushInstallation.count({ where: { enabled: true } }), 0);
});
+190
View File
@@ -0,0 +1,190 @@
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { once } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:http';
import { createConnection } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import Fastify from 'fastify';
import sensible from '@fastify/sensible';
import { WebSocket, WebSocketServer } from 'ws';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const directory = mkdtempSync(join(tmpdir(), 'sybil-agent-'));
process.env.DATABASE_URL = `file:${join(directory, 'agent.db')}`;
process.env.AGENT_PUSH_ENABLED = 'false';
process.env.ADMIN_TOKEN = 'sybil-test-token-0123456789';
execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], {
cwd: root, env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: '1' }, stdio: 'pipe',
});
const [{ registerAgentRoutes }, { prisma }, { env }] = await Promise.all([
import('../src/agent.js'), import('../src/db.js'), import('../src/env.js'),
]);
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` };
const privateKey = 'sybil-agent-private-key-for-test';
const requests: { method?: string; url?: string; body: any }[] = [];
const accepted = new Map<string, number>();
let upstreamReply: { status: number; body: string } | undefined;
let connections = 0;
const subscriptions: any[] = [];
const upstream = createServer(async (req, res) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
const body = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : null;
requests.push({ method: req.method, url: req.url, body });
res.setHeader('Content-Type', 'application/json');
if (upstreamReply) { res.statusCode = upstreamReply.status; res.end(upstreamReply.body); return; }
if (req.method === 'POST') {
const id = accepted.get(body.requestId) || accepted.size + 1; accepted.set(body.requestId, id);
res.statusCode = 202; res.end(JSON.stringify({ id, status: 'queued', messageId: `inbound:sybil:${body.requestId}` }));
} else if (req.url?.includes('/messages')) {
res.end(JSON.stringify({ conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', beforeCursor: '43', hasMore: false }));
} else res.end(JSON.stringify({ id: '1', type: 'persistent-agent', title: 'Sybil Agent', websocket: '/api/ws', protocol: 'pi-room.v1', activity: { thinking: true } }));
});
const sourceSockets = new WebSocketServer({ noServer: true });
upstream.on('upgrade', (req, socket, head) => {
assert.equal(req.headers.authorization, `Bearer ${privateKey}`);
assert.equal(req.url, '/api/ws');
connections++;
sourceSockets.handleUpgrade(req, socket, head, ws => {
ws.on('message', raw => {
const frame = JSON.parse(raw.toString()); subscriptions.push(frame);
ws.send(JSON.stringify({ type: 'ready', protocol: 'pi-room.v1', conversationId: '1' }));
ws.send(JSON.stringify({ type: 'messages', conversationId: '1', messages: [{ id: 'outbound:43', role: 'assistant', content: 'Later result' }], cursor: '43', hasMore: false }));
ws.send(JSON.stringify({ type: 'activity', conversationId: '1', activity: { thinking: true, responding: true } }));
});
});
});
upstream.listen(0, '127.0.0.1'); await once(upstream, 'listening');
const upstreamAddress = upstream.address() as { port: number };
const app = Fastify({ logger: false }); await app.register(sensible);
await registerAgentRoutes(app, { baseUrl: `http://127.0.0.1:${upstreamAddress.port}`, apiKey: privateKey });
await app.listen({ port: 0, host: '127.0.0.1' });
const address = app.server.address() as { port: number };
const wsUrl = `ws://127.0.0.1:${address.port}/v1/agent/ws`;
test.after(async () => {
await app.close();
for (const socket of sourceSockets.clients) socket.terminate();
sourceSockets.close();
await new Promise<void>(resolve => upstream.close(() => resolve()));
await prisma.$disconnect();
rmSync(directory, { recursive: true, force: true });
});
async function eventually(check: () => boolean) {
for (let i = 0; i < 100; i++) { if (check()) return; await new Promise(resolve => setTimeout(resolve, 10)); }
assert.fail('Expected condition was not reached');
}
test('REST authenticates, proxies the shared room, and retains stable message IDs without revealing Sybil Agent credentials', async () => {
assert.equal((await app.inject({ url: '/v1/agent' })).statusCode, 401);
const descriptor = await app.inject({ url: '/v1/agent', headers: auth });
assert.equal(descriptor.statusCode, 200);
assert.equal(descriptor.json().websocket, '/v1/agent/ws');
assert.ok(!descriptor.body.includes(privateKey));
const page = await app.inject({ url: '/v1/agent/messages?after=42&limit=20', headers: auth });
assert.equal(page.json().messages[0].role, 'assistant');
assert.match(requests.at(-1)!.url!, /after=42&limit=20/);
const input = { content: ' Keep watching\n', requestId: 'same-client-message' };
const first = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
const second = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: input });
assert.equal(first.statusCode, 202); assert.deepEqual(first.json(), second.json());
assert.deepEqual(requests.findLast(request => request.method === 'POST')!.body, { ...input, channel: 'sybil', whenBusy: 'followUp', wait: false });
assert.equal((await prisma.chat.count()), 0, 'Sybil Agent messages are not copied into ordinary Sybil chats');
assert.equal((await app.inject({ url: '/v1/agent/messages?after=2&before=3', headers: auth })).statusCode, 400);
assert.equal((await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload: { content: 'Hello' } })).statusCode, 400);
});
test('the read cursor stays monotonic under concurrent tabs and survives reconnecting to SQLite', async () => {
const initial = await app.inject({ url: '/v1/agent/read-state', headers: auth });
assert.equal(initial.json().cursor, '0');
assert.equal(initial.headers['cache-control'], 'no-store');
await Promise.all(['900', '4', '100', '12'].map(cursor => app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor } })));
assert.equal((await app.inject({ url: '/v1/agent/read-state', headers: auth })).json().cursor, '900');
await prisma.$disconnect();
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '5' } })).json().cursor, '900');
assert.equal((await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '-1' } })).statusCode, 400);
});
test('malformed upstream JSON and unconfirmed message receipts return 502, preserving safe retries', async () => {
const payload = { content: 'One durable message', requestId: 'invalid-receipt-retry' };
try {
upstreamReply = { status: 200, body: '{truncated' };
for (const url of ['/v1/agent', '/v1/agent/messages']) {
const response = await app.inject({ url, headers: auth });
assert.equal(response.statusCode, 502);
assert.equal(response.headers['cache-control'], 'no-store');
}
for (const body of ['{truncated', '{}', 'null', JSON.stringify({ id: 1, status: 'queued', messageId: 'wrong-message' }),
JSON.stringify({ id: 1, status: 'unknown', messageId: `inbound:sybil:${payload.requestId}` })]) {
upstreamReply = { status: 202, body };
const response = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
assert.equal(response.statusCode, 502, body);
assert.equal(response.headers['cache-control'], 'no-store');
}
} finally { upstreamReply = undefined; }
const retry = await app.inject({ method: 'POST', url: '/v1/agent/messages', headers: auth, payload });
assert.equal(retry.statusCode, 202);
assert.equal(retry.json().messageId, `inbound:sybil:${payload.requestId}`);
});
test('malformed and unmatched upgrade targets close without crashing or opening Sybil Agent connections', async () => {
const before = connections;
const requestUpgrade = (target: string): Promise<string> => new Promise((resolve, reject) => {
let response = '';
const socket = createConnection({ host: '127.0.0.1', port: address.port }, () => {
socket.write(`GET ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`);
});
socket.setTimeout(1000, () => socket.destroy(new Error('Upgrade rejection did not close the socket')));
socket.on('data', chunk => { response += chunk.toString(); });
socket.on('error', reject);
socket.on('end', () => { socket.destroy(); resolve(response); });
});
assert.match(await requestUpgrade('http://['), /^HTTP\/1\.1 400 /);
assert.match(await requestUpgrade('/v1/not-an-agent/ws'), /^HTTP\/1\.1 404 /);
assert.match(await requestUpgrade('/v1/agent/ws?token=forbidden'), /^HTTP\/1\.1 400 /);
assert.equal(connections, before);
assert.equal((await app.inject({ url: '/v1/agent', headers: auth })).statusCode, 200);
});
test('WebSocket first-frame authentication gates the private connection and forwards resumable native frames', async () => {
const before = connections;
const denied = new WebSocket(wsUrl); await once(denied, 'open');
denied.send(JSON.stringify({ type: 'auth', token: 'wrong' }));
const [code] = await once(denied, 'close');
assert.equal(code, 4401); assert.equal(connections, before);
const browser = new WebSocket(wsUrl);
const frames: any[] = []; browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: env.ADMIN_TOKEN, after: '42' }));
await eventually(() => frames.some(frame => frame.type === 'activity'));
assert.deepEqual(subscriptions.at(-1), { type: 'subscribe', after: '42' });
assert.deepEqual(frames.map(frame => frame.type), ['ready', 'messages', 'activity']);
assert.equal(frames[1].messages[0].content, 'Later result');
assert.equal(frames[2].activity.thinking, true);
const beforeRead = subscriptions.length;
await app.inject({ method: 'PUT', url: '/v1/agent/read-state', headers: auth, payload: { cursor: '901' } });
await eventually(() => frames.some(frame => frame.type === 'read_state' && frame.cursor === '901'));
assert.equal(subscriptions.length, beforeRead, 'read events are backend broadcasts, never runtime subscriptions');
assert.equal(frames.find(frame => frame.type === 'read_state').sourceId, null);
assert.ok(!JSON.stringify(frames).includes(privateKey));
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
await eventually(() => subscriptions.at(-1)?.after === '43');
browser.close(); await once(browser, 'close');
await eventually(() => sourceSockets.clients.size === 0);
});
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try {
const browser = new WebSocket(wsUrl); const frames: any[] = [];
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
await eventually(() => frames.some(frame => frame.type === 'ready'));
browser.close(); await once(browser, 'close');
} finally { env.ADMIN_TOKEN = saved; }
});
+2 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
@@ -11,6 +11,7 @@ import sensible from "@fastify/sensible";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
writeFileSync(join(databaseDir, "test.db"), "");
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
+45 -1
View File
@@ -1,6 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
import { env } from "../src/env.js";
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
for (const { name, listedModels, expectedModels } of [
{
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
listedModels: [
"gpt-4.1-mini",
"chat-latest",
"o3",
"chatgpt-4o-latest",
"chat-latest",
"text-embedding-3-small",
"omni-moderation-latest",
"gpt-4o-audio-preview",
"gpt-realtime",
"gpt-4o-transcribe",
"gpt-4o-mini-tts",
"gpt-image-1",
"chatgpt-image-latest",
"gpt-4o-search-preview",
"computer-use-preview",
"sora-2",
],
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
},
{
name: "OpenAI catalog does not invent models absent from the provider response",
listedModels: ["gpt-4.1-mini"],
expectedModels: ["gpt-4.1-mini"],
},
]) {
test(name, async (t) => {
const originalKey = env.OPENAI_API_KEY;
env.OPENAI_API_KEY = "test-openai-key";
t.after(() => { env.OPENAI_API_KEY = originalKey; });
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
assert.equal(url, "https://api.openai.com/v1/models");
return Response.json({ data: listedModels.map((id) => ({ id })) });
});
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
});
}
test("provider backend registry selects chat protocol and managed-tool mode", () => {
assert.deepEqual(describeProviderChatBackend("openai", []), {
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import { Prisma } from "@prisma/client";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
const migration = "20261007000000_add_workspace_full_text_search";
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
writeFileSync(join(databaseDir, "test.db"), "");
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
}
function migrate() {
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
cwd: serverRoot, env: process.env, stdio: "pipe",
});
}
migrate();
const { prisma } = await import("../src/db.js");
const oldChat = await prisma.chat.create({ data: {
title: "Old constellation notes",
messages: { create: [
{ role: "user", content: "We photographed a nebula before the migration." },
{ role: "system", content: "secretmigrationprompt" },
] },
} });
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
migrate();
const { registerRoutes } = await import("../src/routes.js");
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
async function search(query: string) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
test.afterEach(async () => {
await prisma.chat.deleteMany();
await prisma.search.deleteMany();
});
test.after(async () => {
await app.close();
await prisma.$disconnect();
rmSync(databaseDir, { recursive: true, force: true });
});
test("migration indexes existing titles, messages, queries, and answers", async () => {
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
assert.equal((await search("constellation")).items.length, 2);
assert.equal((await search("secretmigrationprompt")).items.length, 0);
});
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
const chat = await prisma.chat.create({ data: {
title: "Travel notes", updatedAt: new Date("2000-01-01"),
messages: { create: [
{ role: "user", content: "Let's discuss café planning in Zürich" },
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
{ role: "system", content: "hiddensystemvalue" },
{ role: "tool", content: "hiddentoolvalue" },
] },
} });
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
const result = await search("CAFE plan");
assert.equal(result.hasMore, false);
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
assert.match(result.items[0].searchSnippet, /café planning/i);
assert.equal(result.items[0].messages, undefined);
assert.equal((await search("zuri")).items.length, 1);
assert.equal((await search("hiddensystemvalue")).items.length, 0);
assert.equal((await search("hiddentoolvalue")).items.length, 0);
});
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
const chat = await prisma.chat.create({ data: {
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
}, include: { messages: true } });
const messageID = chat.messages[0].id;
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
assert.equal((await search("original")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
assert.equal((await search("replacementbody")).items.length, 0);
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
assert.equal((await search("replacementbody")).items.length, 1);
await assert.rejects(prisma.$transaction(async (tx) => {
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
throw new Error("rollback");
}));
assert.equal((await search("rolledbackvalue")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
assert.equal(fork.statusCode, 200, fork.body);
assert.equal((await search("replacementbody")).items.length, 2);
await prisma.message.delete({ where: { id: messageID } });
const onlyFork = (await search("replacementbody")).items;
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
assert.equal(starResponse.statusCode, 200, starResponse.body);
const starredFork = (await search("replacementbody")).items[0];
assert.equal(starredFork.starred, true);
assert.ok(starredFork.starredAt);
assert.equal(starredFork.parentChat, undefined);
await prisma.chat.delete({ where: { id: chat.id } });
assert.equal((await search("replacement")).items.length, 0);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
}
});
test("saved searches match query and answer, and update/delete their index", async () => {
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
assert.equal((await search("ai des")).items[0].type, "search");
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
assert.equal((await search("nebula")).items.length, 0);
assert.equal((await search("design")).items.length, 0);
assert.equal((await search("Bookmarks")).items.length, 0);
assert.equal((await search("newanswer")).items.length, 1);
await prisma.search.delete({ where: { id: saved.id } });
assert.equal((await search("new")).items.length, 0);
});
test("literal queries, validation, blank queries, and bounded snippets", async () => {
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
assert.equal((await search('"AI" OR safety*')).items.length, 1);
assert.equal((await search("AI OR other")).items.length, 0);
assert.equal((await search("AI")).items.length, 1);
assert.equal((await search("*** : () --")).items.length, 0);
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.deepEqual(await search(" "), recent);
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
assert.equal(response.statusCode, 400, response.body);
}
});
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
const date = new Date("2026-01-01");
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
const result = await search("common");
assert.equal(result.items.length, 100);
assert.equal(result.hasMore, true);
assert.equal(result.items[0].id, "saved");
assert.equal(result.items[1].id, "chat-000");
assert.equal(result.items.at(-1).id, "chat-098");
});
test("query plan uses all three FTS indexes", async () => {
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
}
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
});
test("vacuum preserves external-content index row identities", async () => {
const removed = await prisma.chat.create({ data: { title: "removed" } });
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
await prisma.chat.delete({ where: { id: removed.id } });
await prisma.$executeRaw`VACUUM`;
assert.equal((await search("kepttitle")).items[0].id, kept.id);
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
});
+7
View File
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
## UI
- Left panel: mixed list of chat conversations and Exa searches.
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
conversation messages, and web-search queries/answers. It waits 300 ms while
typing and cancels superseded requests. Results include a single-line matching excerpt
with matching words in bold and middle truncation
(or “Title match” for title-only chat matches), with up to 100 newest matches.
Clear the field or press Escape to return to recent items. Matching uses word
prefixes, so `plan` also finds `planning`.
- Right panel:
- Chat mode: transcript + provider/model controls.
- Search mode: top AI answer block + Google-style Exa results view.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+119 -15
View File
@@ -3,24 +3,128 @@ self.addEventListener("install", () => {
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
await self.clients.claim();
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
await Promise.all(
windows.map(async (client) => {
try {
await client.navigate(client.url);
} catch {
// The client may have closed while the new worker was activating.
}
})
);
})()
);
// Navigations through this worker wait for activation. Awaiting a client
// reload here deadlocks first visits, including the token sign-in screen.
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") return;
event.respondWith(fetch(new Request(event.request, { cache: "no-store" })));
});
// This worker never receives credentials and never performs authenticated fetches.
// Safari requires a visible notification for each valid message push. Reads are
// reconciled only by foreground page messages, never an invisible Web Push.
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
const sourceId = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
const owned = value => value?.version === 1 && value.kind === "message" && sourceId(value.sourceId) && cursor(value.cursor) && messageId(value.messageId);
let queue = Promise.resolve();
const serial = operation => { const result = queue.then(operation); queue = result.catch(() => undefined); return result; };
function database() {
return new Promise((resolve, reject) => {
const request = indexedDB.open("sybil-agent-notifications-v1", 1);
request.onupgradeneeded = () => request.result.createObjectStore("sources", { keyPath: "sourceId" });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error("Notification storage unavailable."));
request.onblocked = () => reject(new Error("Notification storage unavailable."));
});
}
async function stored(id) {
const db = await database();
try {
return await new Promise((resolve, reject) => {
const request = db.transaction("sources").objectStore("sources").get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(new Error("Notification storage unavailable."));
});
} finally { db.close(); }
}
async function save(value) {
const db = await database();
try {
await new Promise((resolve, reject) => {
const tx = db.transaction("sources", "readwrite"), store = tx.objectStore("sources");
store.put({ ...value, touched: Date.now() });
const request = store.getAll();
request.onsuccess = () => {
const rows = request.result.sort((a, b) => b.touched - a.touched);
for (const stale of rows.slice(16)) store.delete(stale.sourceId);
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new Error("Notification storage unavailable."));
tx.onabort = () => reject(new Error("Notification storage unavailable."));
});
} finally { db.close(); }
}
async function announce(type) {
for (const client of await self.clients.matchAll({ type: "window", includeUncontrolled: true })) {
if (new URL(client.url).origin === self.location.origin) client.postMessage({ type });
}
}
self.addEventListener("push", event => {
event.waitUntil(serial(async () => {
let value;
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) 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;
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 : {
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
};
await self.registration.showNotification(PUSH_TITLE, {
body: PUSH_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 announce("sybil-push-shown");
}));
});
self.addEventListener("notificationclick", event => {
event.notification.close();
if (!owned(event.notification.data) || event.notification.tag !== `sybil-agent:${event.notification.data.sourceId}`) return;
event.waitUntil((async () => {
const destination = new URL(PUSH_URL, self.location.origin).href;
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
for (const client of windows) {
const url = new URL(client.url);
if (url.origin !== self.location.origin || !["/", "/search"].includes(url.pathname)) continue;
try { const routed = await client.navigate(destination); if (routed) { await routed.focus(); return; } } catch { /* Try another client. */ }
}
await self.clients.openWindow(destination);
// A tap routes only. The rendered transcript owns the read acknowledgment.
})());
});
self.addEventListener("message", event => {
const value = event.data;
if (value?.version !== 1 || !["sybil-agent-read", "sybil-agent-logout"].includes(value.type)) return;
event.waitUntil(serial(async () => {
// A same-origin WindowClient is required; no remote/message-channel authority.
const client = event.source?.id ? await self.clients.get(event.source.id) : null;
if (!client || client.type !== "window" || new URL(client.url).origin !== self.location.origin) return;
if (value.type === "sybil-agent-read") {
if (!sourceId(value.sourceId) || !cursor(value.cursor)) return;
const previous = await stored(value.sourceId).catch(() => null);
const readCursor = cursor(previous?.readCursor) && BigInt(previous.readCursor) > BigInt(value.cursor) ? previous.readCursor : BigInt(value.cursor).toString();
await save({ ...previous, sourceId: value.sourceId, readCursor }).catch(() => undefined);
const notifications = await self.registration.getNotifications({ tag: `sybil-agent:${value.sourceId}` });
for (const notification of notifications) {
const data = notification.data;
if (owned(data) && data.sourceId === value.sourceId && notification.tag === `sybil-agent:${value.sourceId}` && BigInt(data.cursor) <= BigInt(readCursor)) notification.close();
}
} else {
for (const notification of await self.registration.getNotifications()) {
if (owned(notification.data) && notification.tag === `sybil-agent:${notification.data.sourceId}`) notification.close();
}
}
event.ports?.[0]?.postMessage({ ok: true });
}));
});
self.addEventListener("pushsubscriptionchange", event => {
// Re-registration needs a currently authenticated page. Never cache its token here.
event.waitUntil(announce("sybil-push-subscription-changed"));
});
+244 -97
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { TargetedTouchEvent } from "preact";
import {
Check,
Bot,
ChevronDown,
GitFork,
Globe2,
@@ -25,7 +26,10 @@ import { AuthScreen } from "@/components/auth/auth-screen";
import { ChatAttachmentList } from "@/components/chat/chat-attachment-list";
import { ChatComposer } from "@/components/chat/chat-composer";
import { ChatMessagesPanel } from "@/components/chat/chat-messages-panel";
import { AgentConversation } from "@/components/agent/agent-conversation";
import { AgentNotifications } from "@/components/agent/agent-notifications";
import { SearchResultsPanel } from "@/components/search/search-results-panel";
import { SearchMatchLabel } from "@/components/search/search-match-label";
import { SybilCharacter } from "@/components/sybil-character";
import {
createChat,
@@ -66,21 +70,24 @@ import {
type WorkspaceItem,
} from "@/lib/api";
import { useSessionAuth } from "@/hooks/use-session-auth";
import { useAgentRoom } from "@/hooks/use-agent-room";
import { useWorkspaceSearch } from "@/hooks/use-workspace-search";
import {
getChatModelSelection,
getChatModelSelectionSyncKey,
} from "@/lib/chat-model-selection";
import {
filterSidebarItemsWithForkGroups,
getForkGroupPresentation,
groupForkedChatItems,
shouldRequestChatTitle,
} from "@/lib/chat-forking";
import {
retainSelectedHistoryItem,
resolveSidebarSelectionAfterRefresh,
type SidebarSelection,
type HistorySelection,
} from "@/lib/sidebar-selection";
import { buildQuickQuestionRequest } from "@/lib/quick-question";
import { buildQuickQuestionRequest, pickQuickQuestionModel, QUICK_QUESTION_DEFAULT_MODEL } from "@/lib/quick-question";
import {
appendStreamingDelta,
clearStreamingAssistantTrace,
@@ -94,8 +101,9 @@ import {
import { cn } from "@/lib/utils";
type DraftSelectionKind = "chat" | "search";
type SidebarItem = SidebarSelection & {
type SidebarItem = HistorySelection & {
title: string;
searchSnippet: string | null;
parentChatId: string | null;
updatedAt: string;
createdAt: string;
@@ -137,6 +145,7 @@ type RefreshCollectionsOptions = {
function readSidebarSelectionFromUrl(): SidebarSelection | null {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
if (params.get("agent") === "root") return { kind: "agent", id: "root" };
const chatId = params.get("chat")?.trim();
if (chatId) {
return { kind: "chat", id: chatId };
@@ -153,15 +162,16 @@ function buildWorkspaceUrl(selection: SidebarSelection | null) {
const params = new URLSearchParams(window.location.search);
params.delete("chat");
params.delete("search");
params.delete("agent");
if (selection) {
params.set(selection.kind === "chat" ? "chat" : "search", selection.id);
params.set(selection.kind, selection.id);
}
const query = params.toString();
return `${window.location.pathname}${query ? `?${query}` : ""}`;
}
const PROVIDER_FALLBACK_MODELS: Record<Provider, string[]> = {
openai: ["gpt-4.1-mini"],
openai: ["gpt-4.1-mini", QUICK_QUESTION_DEFAULT_MODEL],
anthropic: ["claude-3-5-sonnet-latest"],
xai: ["grok-3-mini"],
gemini: ["gemini-3.5-flash", "gemini-flash-latest"],
@@ -654,9 +664,10 @@ type ModelComboboxProps = {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
};
function ModelCombobox({ options, value, onChange, disabled = false }: ModelComboboxProps) {
function ModelCombobox({ options, value, onChange, disabled = false, className }: ModelComboboxProps) {
const [open, setOpen] = useState(false);
const [draftValue, setDraftValue] = useState(value);
const rootRef = useRef<HTMLDivElement>(null);
@@ -710,7 +721,12 @@ function ModelCombobox({ options, value, onChange, disabled = false }: ModelComb
return (
<div className="relative" ref={rootRef}>
<div className="flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]">
<div
className={cn(
"flex h-10 min-w-56 items-center rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)]",
className
)}
>
<input
ref={inputRef}
value={draftValue}
@@ -837,6 +853,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
kind: "chat" as const,
id: chat.id,
title: getChatTitle(chat),
searchSnippet: chat.searchSnippet ?? null,
parentChatId: chat.parentChatId,
updatedAt: chat.updatedAt,
createdAt: chat.createdAt,
@@ -854,6 +871,7 @@ function buildSidebarItems(items: WorkspaceItem[]): SidebarItem[] {
kind: "search" as const,
id: search.id,
title: getSearchTitle(search),
searchSnippet: search.searchSnippet ?? null,
parentChatId: null,
updatedAt: search.updatedAt,
createdAt: search.createdAt,
@@ -954,6 +972,7 @@ export default function App() {
handleSignIn,
logout,
} = useSessionAuth();
const agentRoom = useAgentRoom(isAuthenticated);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]);
@@ -986,7 +1005,7 @@ export default function App() {
);
const [quickModel, setQuickModel] = useState(() => {
const stored = loadStoredQuickQuestionModelSelection();
return stored.modelPreferences[stored.provider] ?? PROVIDER_FALLBACK_MODELS[stored.provider][0];
return pickQuickQuestionModel(stored.provider, PROVIDER_FALLBACK_MODELS[stored.provider], stored.modelPreferences[stored.provider]);
});
const [isQuickQuestionOpen, setIsQuickQuestionOpen] = useState(false);
const [quickPrompt, setQuickPrompt] = useState("");
@@ -1018,6 +1037,7 @@ export default function App() {
const transcriptEndRef = useRef<HTMLDivElement>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
const renameChatInputRef = useRef<HTMLInputElement>(null);
const chatSettingsDialogRef = useRef<HTMLDialogElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const dragDepthRef = useRef(0);
const pendingAttachmentsRef = useRef<ChatAttachment[]>([]);
@@ -1046,6 +1066,10 @@ export default function App() {
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [sidebarQuery, setSidebarQuery] = useState("");
const [sidebarSearchRevision, setSidebarSearchRevision] = useState(0);
const sidebarSearch = useWorkspaceSearch(sidebarQuery, isAuthenticated, sidebarSearchRevision);
const isSidebarSearchActive = Boolean(sidebarQuery.trim());
const selectedHistoryItemRef = useRef<WorkspaceItem | null>(null);
const initialRouteSelectionRef = useRef<SidebarSelection | null>(readSidebarSelectionFromUrl());
const hasSyncedSelectionHistoryRef = useRef(false);
@@ -1111,19 +1135,16 @@ export default function App() {
}, [pendingAttachments]);
const sidebarItems = useMemo(() => buildSidebarItems(workspaceItems), [workspaceItems]);
const filteredSidebarItems = useMemo(() => {
const query = sidebarQuery.trim().toLowerCase();
if (!query) return groupForkedChatItems(sidebarItems).flat();
return filterSidebarItemsWithForkGroups(sidebarItems, (item) => {
const providerLabel = getProviderLabel(item.lastUsedProvider || item.initiatedProvider).toLowerCase();
return [item.title, item.initiatedModel, item.lastUsedModel, providerLabel]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(query));
});
}, [sidebarItems, sidebarQuery]);
const searchSidebarItems = useMemo(() => buildSidebarItems(sidebarSearch.items), [sidebarSearch.items]);
const knownSidebarItems = useMemo(() => {
const matches = new Set(searchSidebarItems.map((item) => `${item.kind}:${item.id}`));
return [...searchSidebarItems, ...sidebarItems.filter((item) => !matches.has(`${item.kind}:${item.id}`))];
}, [sidebarItems, searchSidebarItems]);
const sidebarSections = useMemo(
() => buildSidebarSections(filteredSidebarItems, sidebarItems),
[filteredSidebarItems, sidebarItems]
() => isSidebarSearchActive
? (searchSidebarItems.length ? [{ label: "SEARCH RESULTS", items: searchSidebarItems }] : [])
: buildSidebarSections(sidebarItems),
[isSidebarSearchActive, searchSidebarItems, sidebarItems]
);
const orderedSidebarItems = useMemo(() => sidebarSections.flatMap((section) => section.items), [sidebarSections]);
@@ -1142,6 +1163,8 @@ export default function App() {
setChats([]);
setSearches([]);
setWorkspaceItems([]);
setSidebarQuery("");
selectedHistoryItemRef.current = null;
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
@@ -1193,6 +1216,10 @@ export default function App() {
resetWorkspaceState();
};
useEffect(() => {
if (sidebarSearch.error?.includes("bearer token")) handleAuthFailure(sidebarSearch.error);
}, [sidebarSearch.error]);
const refreshCollections = async ({
initialSelection,
reportTransientError = true,
@@ -1205,12 +1232,14 @@ export default function App() {
};
setIsLoadingCollections(true);
try {
const nextWorkspaceItems = await listWorkspaceItems();
let nextWorkspaceItems = await listWorkspaceItems();
if (requestId !== collectionLoadRequestRef.current) return;
nextWorkspaceItems = retainSelectedHistoryItem(nextWorkspaceItems, selectedItemRef.current, selectedHistoryItemRef.current);
const { chats: nextChats, searches: nextSearches } = splitWorkspaceItems(nextWorkspaceItems);
setWorkspaceItems(nextWorkspaceItems);
setChats(nextChats);
setSearches(nextSearches);
setSidebarSearchRevision((current) => current + 1);
setSelectedItem((current) => {
const nextSelection = resolveSidebarSelectionAfterRefresh(current, nextWorkspaceItems, {
@@ -1443,7 +1472,7 @@ export default function App() {
useEffect(() => {
if (quickModel.trim()) return;
setQuickModel((current) => {
return current.trim() || pickProviderModel(quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
return current.trim() || pickQuickQuestionModel(quickProvider, quickProviderModelOptions, quickProviderModelPreferences[quickProvider]);
});
}, [quickModel, quickProvider, quickProviderModelOptions, quickProviderModelPreferences]);
@@ -1495,6 +1524,8 @@ export default function App() {
selectedItem?.kind === "search" &&
(!!selectedSearchRunState || !!activeRuns.searches[selectedItem.id]);
const isSearchMode = draftKind ? draftKind === "search" : selectedItem?.kind === "search";
const isAgentMode = draftKind === null && selectedItem?.kind === "agent";
const isAgentBusy = !!agentRoom.activity?.isThinking || (agentRoom.activity?.backgroundWorkers ?? 0) > 0;
const isSearchRunning = !!selectedSearchIsActive;
const isSendingActiveChat = draftKind !== "search" && !!selectedChatIsActive;
const isActiveSelectionSending = isSearchMode ? isSearchRunning : isSendingActiveChat;
@@ -1506,16 +1537,16 @@ export default function App() {
const isItemRunning = (item: SidebarSelection) =>
item.kind === "chat" ? !!pendingChatStates[item.id] || !!activeRuns.chats[item.id] : !!runningSearchStates[item.id] || !!activeRuns.searches[item.id];
const getChatFamilyItems = (chatId: string) => {
const chat = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
const chat = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
if (!chat) return [];
const rootId = chat.parentChatId ?? chat.id;
return sidebarItems.filter(
return knownSidebarItems.filter(
(item) => item.kind === "chat" && (item.id === rootId || item.parentChatId === rootId)
);
};
const isRootChat = (item: SidebarSelection) => {
if (item.kind !== "chat") return false;
const chat = sidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
const chat = knownSidebarItems.find((candidate) => candidate.kind === "chat" && candidate.id === item.id);
return chat?.parentChatId === null;
};
const isDeleteBlocked = (item: SidebarSelection) => {
@@ -1524,9 +1555,10 @@ export default function App() {
};
const isSidebarTargetStarred = (item: SidebarSelection) => {
if (item.kind === "chat") {
return getChatFamilyItems(item.id).find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
const family = getChatFamilyItems(item.id);
return (family.find((familyItem) => familyItem.parentChatId === null) ?? family[0])?.starred ?? false;
}
return sidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
return knownSidebarItems.find((candidate) => candidate.kind === "search" && candidate.id === item.id)?.starred ?? false;
};
const isCurrentSelection = (item: SidebarSelection) => {
const current = selectedItemRef.current;
@@ -1556,9 +1588,10 @@ export default function App() {
setSelectedSearch(null);
return;
}
if (!selectedItem) {
if (!selectedItem || selectedItem.kind === "agent") {
setSelectedChat(null);
setSelectedSearch(null);
setIsLoadingSelection(false);
return;
}
@@ -1718,6 +1751,7 @@ export default function App() {
if (draftKind === "chat") return draftChatTitle.trim() || "New chat";
if (draftKind === "search") return "New search";
if (!selectedItem) return "Sybil";
if (selectedItem.kind === "agent") return "Sybil Agent";
if (selectedItem.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return getChatTitle(selectedChat, selectedChat.messages);
if (selectedChatSummary) return getChatTitle(selectedChatSummary);
@@ -1730,6 +1764,7 @@ export default function App() {
const pageTitle = useMemo(() => {
if (draftKind || !selectedItem) return "Sybil";
if (selectedItem.kind === "agent") return "Sybil Agent — Sybil";
if (selectedItem.kind === "chat") {
if (selectedChat?.id === selectedItem.id) return `${getChatTitle(selectedChat, selectedChat.messages)} — Sybil`;
if (selectedChatSummary) return `${getChatTitle(selectedChatSummary)} — Sybil`;
@@ -1904,7 +1939,7 @@ export default function App() {
};
const selectAdjacentSidebarItem = (direction: -1 | 1) => {
if (!orderedSidebarItems.length) return;
const navigationItems: SidebarSelection[] = [{ kind: "agent", id: "root" }, ...orderedSidebarItems];
setError(null);
setContextMenu(null);
@@ -1913,23 +1948,34 @@ export default function App() {
setIsMobileSidebarOpen(false);
const current = selectedItemRef.current;
const currentIndex = current
? orderedSidebarItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
? navigationItems.findIndex((item) => item.kind === current.kind && item.id === current.id)
: -1;
const fallbackIndex = direction > 0 ? 0 : orderedSidebarItems.length - 1;
const fallbackIndex = direction > 0 ? 0 : navigationItems.length - 1;
const nextIndex =
currentIndex < 0
? fallbackIndex
: Math.min(orderedSidebarItems.length - 1, Math.max(0, currentIndex + direction));
const nextItem = orderedSidebarItems[nextIndex];
const target: SidebarSelection = { kind: nextItem.kind, id: nextItem.id };
: Math.min(navigationItems.length - 1, Math.max(0, currentIndex + direction));
const target = navigationItems[nextIndex];
rememberHistoryResult(target);
selectedItemRef.current = target;
setSelectedItem(target);
};
const rememberHistoryResult = (target: SidebarSelection) => {
const item = sidebarSearch.items.find((item) => item.type === target.kind && item.id === target.id);
if (!item) return;
selectedHistoryItemRef.current = item;
const { chats: matchedChats, searches: matchedSearches } = splitWorkspaceItems([item]);
if (matchedChats.length) setChats((current) => [...matchedChats, ...current.filter((chat) => chat.id !== item.id)]);
if (matchedSearches.length) setSearches((current) => [...matchedSearches, ...current.filter((search) => search.id !== item.id)]);
setWorkspaceItems((current) => upsertWorkspaceItem(current, item, false));
};
useEffect(() => {
if (!isAuthenticated) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (chatSettingsDialogRef.current?.open) return;
const hasPrimaryModifier = event.metaKey || event.ctrlKey;
if (!hasPrimaryModifier || event.altKey) return;
@@ -1968,11 +2014,15 @@ export default function App() {
if (selectedChat?.id === chatId) return getChatTitle(selectedChat, selectedChat.messages);
const summary = chats.find((chat) => chat.id === chatId);
if (summary) return getChatTitle(summary);
const sidebarItem = sidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
const sidebarItem = knownSidebarItems.find((item) => item.kind === "chat" && item.id === chatId);
return sidebarItem?.title ?? "New chat";
};
const applyChatSummary = (updatedChat: ChatSummary, moveToFront = true) => {
setSidebarSearchRevision((current) => current + 1);
if (selectedHistoryItemRef.current?.type === "chat" && selectedHistoryItemRef.current.id === updatedChat.id) {
selectedHistoryItemRef.current = chatWorkspaceItem(updatedChat);
}
supersedeCollectionLoad();
setChats((current) => {
const withoutExisting = current.filter((chat) => chat.id !== updatedChat.id);
@@ -2005,6 +2055,10 @@ export default function App() {
};
const applySearchSummary = (updatedSearch: SearchSummary, moveToFront = true) => {
setSidebarSearchRevision((current) => current + 1);
if (selectedHistoryItemRef.current?.type === "search" && selectedHistoryItemRef.current.id === updatedSearch.id) {
selectedHistoryItemRef.current = searchWorkspaceItem(updatedSearch);
}
supersedeCollectionLoad();
setSearches((current) => {
const withoutExisting = current.filter((search) => search.id !== updatedSearch.id);
@@ -2191,7 +2245,7 @@ export default function App() {
const family = getChatFamilyItems(chatId);
const root = family.find((item) => item.parentChatId === null) ?? family[0];
if (!root) return;
applyChatSummary(await updateChatStar(root.id, !root.starred), false);
applyChatSummary(await updateChatStar(root.parentChatId ?? root.id, !root.starred), false);
};
const handleToggleStar = async (target: SidebarSelection) => {
@@ -2202,7 +2256,7 @@ export default function App() {
if (target.kind === "chat") {
await toggleChatFamilyStar(target.id);
} else {
const current = sidebarItems.find((item) => item.kind === "search" && item.id === target.id);
const current = knownSidebarItems.find((item) => item.kind === "search" && item.id === target.id);
const updatedSearch = await updateSearchStar(target.id, !current?.starred);
applySearchSummary(updatedSearch, false);
}
@@ -2294,6 +2348,11 @@ export default function App() {
} else {
await deleteSearch(target.id);
}
const retained = selectedHistoryItemRef.current;
if (retained && ((retained.type === target.kind && retained.id === target.id) ||
(retained.type === "chat" && target.kind === "chat" && retained.parentChatId === target.id))) {
selectedHistoryItemRef.current = null;
}
await refreshCollections({ selectFallback: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -2331,16 +2390,16 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [renameChatDialog]);
useEffect(() => {
useLayoutEffect(() => {
if (!isChatSettingsOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || isSavingChatSettings) return;
event.preventDefault();
setIsChatSettingsOpen(false);
const dialog = chatSettingsDialogRef.current;
const previouslyFocused = document.activeElement;
dialog?.showModal();
return () => {
dialog?.close();
if (previouslyFocused instanceof HTMLElement) previouslyFocused.focus({ preventScroll: true });
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [isChatSettingsOpen, isSavingChatSettings]);
}, [isChatSettingsOpen]);
useEffect(() => {
if (!isQuickQuestionOpen) return;
@@ -3433,7 +3492,7 @@ export default function App() {
return (
<div className="app-grid-surface app-safe-frame h-full">
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent md:gap-2">
<div className="workspace-shell flex h-full w-full overflow-hidden bg-transparent">
{isMobileSidebarOpen ? (
<button
type="button"
@@ -3445,23 +3504,17 @@ export default function App() {
<aside
className={cn(
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none md:rounded-2xl md:border",
"workspace-sidebar glass-panel fixed inset-y-0 left-0 z-40 flex w-[86vw] max-w-80 shrink-0 flex-col border-r border-violet-300/18 transition-transform md:static md:z-auto md:w-80 md:max-w-none",
isMobileSidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
)}
>
<div className="relative min-h-24 px-4 pb-4 pt-5">
<div className="pr-24">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL
</div>
<p className="mt-2 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
Sybil Web{authMode ? ` (${authMode === "open" ? "open mode" : "token mode"})` : ""}
</p>
<div className="relative min-h-24 px-4 pb-4 pt-5 flex items-center">
<div className="sybil-wordmark bg-[linear-gradient(90deg,#ff8df8,#9a6dff_54%,#67dfff)] bg-clip-text text-3xl text-transparent">
SYBIL
</div>
<SybilCharacter
className="absolute right-4 top-3 h-[calc(100%-1.5rem)]"
isBusy={isActiveSelectionSending || isQuickQuestionSending}
isBusy={isActiveSelectionSending || isQuickQuestionSending || isAgentBusy}
/>
</div>
@@ -3497,38 +3550,96 @@ export default function App() {
Shift {primaryShortcutModifier} J
</span>
</Button>
<button
type="button"
data-testid="agent-sidebar-item"
aria-current={isAgentMode ? "page" : undefined}
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
onClick={() => {
setContextMenu(null);
setError(null);
setIsChatSettingsOpen(false);
draftKindRef.current = null;
setDraftKind(null);
const target: SidebarSelection = { kind: "agent", id: "root" };
selectedItemRef.current = target;
setSelectedItem(target);
setIsMobileSidebarOpen(false);
}}
className={cn(
"flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
isAgentMode
? "border-violet-300/45 bg-[linear-gradient(135deg,hsl(258_86%_52%_/_0.58),hsl(277_78%_28%_/_0.55))] text-violet-50"
: "border-transparent text-violet-100/78 hover:border-violet-300/18 hover:bg-violet-400/10"
)}
>
<span className={cn("flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border", isAgentMode ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/20 bg-violet-400/5 text-violet-200")}>
<Bot className="h-[18px] w-[18px]" />
</span>
<span className="min-w-0 flex-1">
<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 ? (
<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}
</span>
) : null}
</button>
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-violet-200/58" />
<input
value={sidebarQuery}
onInput={(event) => setSidebarQuery(event.currentTarget.value)}
placeholder="Search chats"
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
placeholder="Search chats and web searches"
aria-label="Search conversation history and saved web searches"
autoComplete="off"
spellcheck={false}
onKeyDown={(event) => {
if (event.key === "Escape") setSidebarQuery("");
}}
className="h-10 w-full rounded-lg border border-violet-300/18 bg-background/66 pl-9 pr-9 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.05)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
/>
{sidebarQuery ? (
<button type="button" onClick={() => setSidebarQuery("")} aria-label="Clear history search"
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-violet-200/70 hover:text-violet-50">
<X className="h-4 w-4" />
</button>
) : null}
</div>
</div>
<Separator className="bg-violet-300/10" />
<div className="flex-1 overflow-y-auto px-2 py-3">
{isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isLoadingCollections && sidebarItems.length === 0 ? (
{!isSidebarSearchActive && isLoadingCollections && sidebarItems.length === 0 ? <p className="px-2 py-3 text-sm text-muted-foreground">Loading conversations...</p> : null}
{!isSidebarSearchActive && !isLoadingCollections && sidebarItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-2 p-5 text-center text-sm text-muted-foreground">
<MessageSquare className="h-5 w-5" />
Start a chat or run your first search.
</div>
) : null}
{!isLoadingCollections && sidebarItems.length > 0 && filteredSidebarItems.length === 0 ? (
<p className="px-2 py-3 text-sm text-muted-foreground">No chats found.</p>
{isSidebarSearchActive ? (
<div aria-live="polite" role="status">
{sidebarSearch.isLoading ? <p className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground"><LoaderCircle className="h-4 w-4 animate-spin" />Searching history…</p> : null}
{sidebarSearch.error ? (
<div className="space-y-2 px-3 py-3 text-sm">
<p className="text-red-300">{sidebarSearch.error}</p>
<Button variant="secondary" onClick={() => setSidebarSearchRevision((current) => current + 1)}>Try again</Button>
</div>
) : !sidebarSearch.isLoading && searchSidebarItems.length === 0 ? (
<p className="px-3 py-3 text-sm text-muted-foreground">No matching chats or web searches. Try a title, query, or words from a conversation.</p>
) : null}
</div>
) : null}
{sidebarSections.map((section) => (
<div key={section.label} className="mb-4">
<p className="px-3 pb-2 text-[11px] font-semibold text-violet-200/48">{section.label}</p>
{section.items.map((item) => {
const active = selectedItem?.kind === item.kind && selectedItem.id === item.id;
const isForkChild = item.kind === "chat" && item.parentChatId !== null;
const familyItems = item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
const isForkChild = !isSidebarSearchActive && item.kind === "chat" && item.parentChatId !== null;
const familyItems = !isSidebarSearchActive && item.kind === "chat" ? getChatFamilyItems(item.id) : [item];
const itemIsRunning = familyItems.some((familyItem) => isItemRunning(familyItem));
const itemIsStarred =
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? false;
familyItems.find((familyItem) => familyItem.parentChatId === null)?.starred ?? item.starred;
const itemUpdatedAt = familyItems.reduce(
(newest, familyItem) =>
new Date(familyItem.updatedAt).getTime() > new Date(newest).getTime()
@@ -3554,6 +3665,7 @@ export default function App() {
draftKindRef.current = null;
setDraftKind(null);
const target = { kind: item.kind, id: item.id } as SidebarSelection;
rememberHistoryResult(target);
selectedItemRef.current = target;
setSelectedItem(target);
setIsMobileSidebarOpen(false);
@@ -3572,7 +3684,7 @@ export default function App() {
active ? "border-cyan-200/35 bg-cyan-300/12 text-cyan-100" : "border-violet-300/18 text-violet-200/70"
)}
>
{item.kind === "chat" ? <MessageSquare className="h-3.5 w-3.5" /> : <Search className="h-3.5 w-3.5" />}
{item.kind === "chat" ? (item.parentChatId ? <GitFork className="h-3.5 w-3.5" aria-label="Fork" /> : <MessageSquare className="h-3.5 w-3.5" />) : <Search className="h-3.5 w-3.5" />}
</span>
<span className="flex min-w-0 flex-1 items-center gap-1.5">
<span className="truncate text-sm font-semibold">{item.title}</span>
@@ -3597,6 +3709,9 @@ export default function App() {
</span>
) : null}
</span>
{isSidebarSearchActive ? (
<SearchMatchLabel text={item.searchSnippet || "Title match"} query={item.searchSnippet ? sidebarQuery : ""} />
) : null}
</div>
)}
</button>
@@ -3604,16 +3719,23 @@ export default function App() {
})}
</div>
))}
{isSidebarSearchActive && sidebarSearch.hasMore ? (
<p className="px-3 py-2 text-xs text-muted-foreground">Showing the 100 most recent matches. Refine your search to find more.</p>
) : null}
</div>
{authMode === "token" ? <AgentNotifications onLogout={logout} /> : null}
</aside>
<main
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden border-violet-300/18 md:touch-auto md:rounded-2xl md:border"
className="workspace-content glass-panel relative flex min-w-0 flex-1 touch-pan-y flex-col overflow-hidden md:touch-auto"
onTouchStart={handleMobileWorkspaceTouchStart}
onTouchMove={handleMobileWorkspaceTouchMove}
onTouchEnd={handleMobileWorkspaceTouchEnd}
onTouchCancel={handleMobileWorkspaceTouchCancel}
>
{isAgentMode ? (
<AgentConversation room={agentRoom} onOpenSidebar={() => setIsMobileSidebarOpen(true)} isVisible={!isMobileSidebarOpen && !isQuickQuestionOpen} />
) : <>
<header className="flex items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex min-w-0 items-center gap-2">
<Button
@@ -3751,6 +3873,7 @@ export default function App() {
/>
</div>
</footer>
</>}
</main>
</div>
{contextMenu ? (
@@ -3822,44 +3945,66 @@ export default function App() {
</div>
) : null}
{isChatSettingsOpen ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
<dialog
ref={chatSettingsDialogRef}
aria-labelledby="chat-settings-title"
className="fixed inset-0 m-0 flex h-dvh max-h-none w-full max-w-none items-center justify-center overflow-hidden bg-transparent p-0 text-foreground backdrop:bg-black/70 backdrop:backdrop-blur-md md:p-6"
onCancel={(event) => {
event.preventDefault();
if (!isSavingChatSettings) setIsChatSettingsOpen(false);
}}
onMouseDown={(event) => {
if (event.target === event.currentTarget && !isSavingChatSettings) setIsChatSettingsOpen(false);
}}
>
<form
role="dialog"
aria-modal="true"
aria-labelledby="chat-settings-title"
className="glass-panel flex max-h-[88vh] w-full max-w-2xl flex-col rounded-2xl border border-violet-300/24 p-4 shadow-2xl shadow-black/45 md:p-5"
className="glass-panel flex h-full min-h-0 w-full flex-col overflow-hidden md:h-auto md:max-h-full md:max-w-2xl md:rounded-2xl md:border md:border-violet-300/24"
onSubmit={(event) => void handleChatSettingsSubmit(event)}
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="chat-settings-header grid shrink-0 grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-2 border-b border-violet-300/16 md:flex md:justify-between md:gap-3">
<Button
type="button"
variant="ghost"
className="h-11 justify-self-start px-2 text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings}
>
Cancel
</Button>
<div className="min-w-0">
<h2 id="chat-settings-title" className="text-sm font-semibold text-violet-50">
<h2 id="chat-settings-title" className="text-base font-semibold text-violet-50 md:text-sm">
Chat settings
</h2>
<p className="mt-1 truncate text-xs text-muted-foreground">{chatSettingsTitleDraft.trim() || "New chat"}</p>
<p className="mt-1 hidden truncate text-xs text-muted-foreground md:block">{chatSettingsTitleDraft.trim() || "New chat"}</p>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8"
className="hidden h-8 w-8 shrink-0 md:inline-flex"
onClick={() => setIsChatSettingsOpen(false)}
disabled={isSavingChatSettings}
aria-label="Close chat settings"
>
<X className="h-4 w-4" />
</Button>
<Button
type="submit"
variant="ghost"
className="h-11 justify-self-end gap-1.5 px-2 font-semibold text-violet-200 hover:bg-violet-400/10 hover:text-violet-100 md:hidden"
disabled={isSavingChatSettings}
>
{isSavingChatSettings ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
Save
</Button>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
<div className="chat-settings-content min-h-0 flex-1 space-y-5 overflow-y-auto overscroll-contain md:space-y-4">
<div>
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</span>
<label for="chat-settings-chat-title" className="mb-1.5 block text-xs font-semibold text-violet-100/72">Chat title</label>
<div className="flex items-center gap-2">
<input
id="chat-settings-chat-title"
value={chatSettingsTitleDraft}
onInput={(event) => {
setChatSettingsTitleDraft(event.currentTarget.value);
@@ -3867,7 +4012,7 @@ export default function App() {
}}
maxLength={120}
placeholder={draftKind === null && selectedItem?.kind === "chat" ? "Chat title" : "Optional title"}
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
className="h-11 min-w-0 flex-1 rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] placeholder:text-muted-foreground focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:text-sm"
disabled={isSavingChatSettings}
/>
{chatSettingsChatId ? (
@@ -3895,7 +4040,7 @@ export default function App() {
<label className="block">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Provider</span>
<select
className="h-10 w-full rounded-lg border border-violet-300/22 bg-background/72 px-3 text-sm text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70"
className="h-11 w-full rounded-lg border border-violet-300/22 bg-background/70 px-3 text-base text-violet-50 outline-none shadow-[inset_0_1px_0_hsl(255_100%_92%_/_0.06)] focus:border-violet-300/45 focus:ring-1 focus:ring-ring/70 md:h-10 md:text-sm"
value={chatSettingsProviderDraft}
onChange={(event) => {
const nextProvider = event.currentTarget.value as Provider;
@@ -3917,6 +4062,7 @@ export default function App() {
<label className="block min-w-0">
<span className="mb-1.5 block text-xs font-semibold text-violet-100/72">Model</span>
<ModelCombobox
className="h-11 min-w-0 bg-background/70 text-base md:h-10 md:text-sm"
options={chatSettingsProviderModelOptions}
value={chatSettingsModelDraft}
disabled={isSavingChatSettings}
@@ -3938,7 +4084,7 @@ export default function App() {
if (chatSettingsError) setChatSettingsError(null);
}}
placeholder="Add per-chat instructions"
className="min-h-32 resize-y border-violet-300/24 bg-background/72 text-sm text-violet-50 placeholder:text-violet-200/45"
className="min-h-32 resize-y border-violet-300/24 bg-background/70 text-base text-violet-50 placeholder:text-violet-200/45 md:text-sm"
disabled={isSavingChatSettings}
/>
</label>
@@ -3952,6 +4098,7 @@ export default function App() {
type="button"
size="sm"
variant="secondary"
className="h-11 gap-1.5 md:h-8"
onClick={() => setChatSettingsEnabledToolsDraft(getDefaultEnabledTools(availableChatTools))}
disabled={isSavingChatSettings}
>
@@ -3962,6 +4109,7 @@ export default function App() {
type="button"
size="sm"
variant="secondary"
className="h-11 gap-1.5 md:h-8"
onClick={() => setChatSettingsEnabledToolsDraft([])}
disabled={isSavingChatSettings}
>
@@ -3971,31 +4119,30 @@ export default function App() {
</div>
) : null}
</div>
<div className="space-y-2">
<div className="grid gap-2 md:grid-cols-2">
{availableChatTools.length ? (
availableChatTools.map((tool) => {
const checked = chatSettingsEnabledToolsDraft.includes(tool.name);
return (
<label
key={tool.name}
className="flex cursor-pointer items-start gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8"
title={tool.description}
className="flex min-h-11 min-w-0 cursor-pointer items-center gap-3 rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 transition hover:border-violet-300/34 hover:bg-violet-400/8 focus-within:border-violet-300/45 focus-within:ring-1 focus-within:ring-ring/70"
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleChatSettingsTool(tool.name)}
className="mt-1 h-4 w-4 rounded border-violet-300/35 bg-background/80 accent-violet-400"
className="h-4 w-4 shrink-0 rounded border-violet-300/35 bg-background/80 accent-violet-400"
aria-description={tool.description}
disabled={isSavingChatSettings}
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
<span className="mt-0.5 block text-xs leading-5 text-muted-foreground">{tool.description}</span>
</span>
<span className="min-w-0 break-words text-sm font-medium text-violet-50">{getToolLabel(tool.name)}</span>
</label>
);
})
) : (
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground">
<p className="rounded-lg border border-violet-300/18 bg-background/44 px-3 py-2.5 text-sm text-muted-foreground md:col-span-2">
No chat tools are available.
</p>
)}
@@ -4003,8 +4150,8 @@ export default function App() {
</section>
</div>
{chatSettingsError ? <p className="mt-3 text-sm text-rose-300">{chatSettingsError}</p> : null}
<div className="mt-4 flex justify-end gap-2">
{chatSettingsError ? <p role="alert" className="chat-settings-error shrink-0 break-words border-t border-violet-300/16 text-sm text-rose-300">{chatSettingsError}</p> : null}
<div className="hidden shrink-0 justify-end gap-2 border-t border-violet-300/16 p-5 md:flex">
<Button type="button" variant="secondary" onClick={() => setIsChatSettingsOpen(false)} disabled={isSavingChatSettings}>
Cancel
</Button>
@@ -4014,7 +4161,7 @@ export default function App() {
</Button>
</div>
</form>
</div>
</dialog>
) : null}
{renameChatDialog ? (
<div
@@ -4073,7 +4220,7 @@ export default function App() {
) : null}
{isQuickQuestionOpen ? (
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/72 p-3 backdrop-blur-md md:p-6"
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 p-3 md:p-6"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setIsQuickQuestionOpen(false);
}}
@@ -4151,7 +4298,7 @@ export default function App() {
const nextProvider = event.currentTarget.value as Provider;
setQuickProvider(nextProvider);
const options = getModelOptions(modelCatalog, nextProvider);
setQuickModel(pickProviderModel(options, quickProviderModelPreferences[nextProvider]));
setQuickModel(pickQuickQuestionModel(nextProvider, options, quickProviderModelPreferences[nextProvider]));
}}
disabled={isQuickQuestionSending || isConvertingQuickQuestion}
aria-label="Quick question provider"
@@ -0,0 +1,39 @@
import { 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> };
/** Authenticated bytes only; no native credential, remote href, or public file serving. */
export function AgentAttachmentCard({ file, download }: 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(); }, []);
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
const retrieve = async (show: boolean) => {
if (busy) return;
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
try {
const blob = await download(file, request.signal); if (request.signal.aborted) return;
const url = URL.createObjectURL(blob);
if (show && file.kind === "image" && ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(blob.type)) setPreview(url);
else {
const anchor = document.createElement("a"); anchor.href = url; anchor.download = file.filename; anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
finally { if (!request.signal.aborted) setBusy(false); }
};
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}
<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}
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
</div>
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
</div>;
}
@@ -0,0 +1,478 @@
.agent-room {
height:100%;
min-height:0;
min-width:0;
display:flex;
flex-direction:column;
background:radial-gradient(ellipse at 80% 0%,hsl(266 80% 30%/.12),transparent 42%),hsl(237 43% 6%/.72);
color:hsl(258 36% 96%);
}
.agent-room__body {
position:relative;
display:flex;
flex:1;
min-height:0;
min-width:0;
}
.agent-room__scroll {
width:100%;
overflow:auto;
overscroll-behavior:contain;
scrollbar-gutter:stable;
outline:none;
overflow-anchor:none;
}
.agent-room__scroll:focus-visible {
outline:1px solid hsl(264 80% 74%/.48);
outline-offset:-2px;
}
.agent-room__timeline {
display:flex;
flex-direction:column;
justify-content:flex-end;
gap:15px;
max-width:848px;
min-height:100%;
margin:0 auto;
padding:24px 30px 22px;
box-sizing:border-box;
}
.agent-room__entry {
flex-shrink:0;
min-width:0;
}
.agent-room__day {
display:flex;
align-items:center;
justify-content:center;
gap:18px;
margin:17px 0 24px;
color:hsl(255 19% 64%);
font-size:10px;
letter-spacing:.05em;
}
.agent-room__day:before,.agent-room__day:after {
content:"";
height:1px;
width:48px;
background:linear-gradient(90deg,transparent,hsl(260 48% 65%/.19));
}
.agent-room__day:after {
transform:rotate(180deg);
}
.agent-room__message {
display:flex;
align-items:flex-end;
gap:9px;
min-width:0;
max-width:89%;
}
.agent-room__message[data-animation="enter"] {
animation:agent-room-message-enter 240ms cubic-bezier(.2,.7,.25,1) both;
}
.agent-room__message.is-own {
margin-left:auto;
justify-content:flex-end;
max-width:78%;
}
.agent-room__message-avatar {
display:grid;
place-items:center;
flex:none;
align-self:flex-end;
width:26px;
height:26px;
margin-bottom:23px;
border:1px solid hsl(267 75% 73%/.2);
border-radius:9px;
background:hsl(261 52% 20%/.56);
color:hsl(269 72% 80%);
overflow:hidden;
}
.agent-room__message-avatar img {
display:block;
width:100%;
height:100%;
object-fit:cover;
}
.agent-room__message-content {
min-width:0;
}
.agent-room__plain-message {
margin:0;
white-space:pre-wrap;
}
.agent-room__message-meta {
display:flex;
align-items:center;
gap:7px;
margin:5px 3px 0;
color:hsl(253 18% 55%);
font-size:9px;
line-height:1.4;
}
.agent-room__message.is-own .agent-room__message-meta {
justify-content:flex-end;
}
.agent-room__message-meta>time:before {
content:"·";
margin-right:7px;
opacity:.6;
}
.agent-room__delivery {
display:inline-flex;
align-items:center;
gap:3px;
color:hsl(272 52% 77%);
white-space:nowrap;
}
.agent-room__delivery.is-error {
color:hsl(349 70% 78%);
}
.agent-room__delivery button {
border:0;
padding:0 0 0 3px;
background:transparent;
color:inherit;
font:inherit;
text-decoration:underline;
cursor:pointer;
}
.agent-room .md-content p {
margin-top:.55em;
margin-bottom:.55em;
}
.agent-room .md-content>:first-child {
margin-top:0;
}
.agent-room .md-content>:last-child {
margin-bottom:0;
}
.agent-room .md-content pre {
max-width:100%;
font-size:12px;
}
.agent-room .md-content h1 {
font-size:1.25em;
}
.agent-room .md-content h2 {
font-size:1.15em;
}
.agent-room .md-content h3 {
font-size:1.05em;
}
.agent-room .md-content a {
color:hsl(189 79% 75%);
}
.agent-room__typing,.agent-room__waiting {
display:flex;
align-items:center;
gap:9px;
flex:none;
min-height:20px;
margin-left:36px;
color:hsl(254 22% 63%);
font-size:11px;
}
.agent-room__typing-dots {
display:flex;
align-items:center;
gap:3px;
padding:6px 8px;
border-radius:9px;
background:hsl(258 43% 22%/.42);
}
.agent-room__typing-dots i {
width:3px;
height:3px;
border-radius:50%;
background:hsl(268 72% 78%);
animation:agent-room-dot 1.4s ease-in-out infinite;
}
.agent-room__typing-dots i:nth-child(2) {
animation-delay:.16s;
}
.agent-room__typing-dots i:nth-child(3) {
animation-delay:.32s;
}
.agent-room__worker-count {
padding:2px 5px;
border-radius:5px;
background:hsl(265 58% 27%/.3);
color:hsl(268 58% 76%);
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);
padding:16px 30px max(13px,var(--safe-area-bottom));
background:linear-gradient(180deg,hsl(240 41% 7%/.72),hsl(240 43% 7%/.97));
}
.agent-room__composer-inner {
max-width:788px;
margin:0 auto;
}
.agent-room__composer {
display:flex;
align-items:flex-end;
gap:10px;
padding:7px 8px 7px 5px;
border:1px solid hsl(263 51% 58%/.3);
border-radius:17px;
background:hsl(247 35% 13%/.9);
box-shadow:inset 0 1px 0 hsl(266 80% 92%/.04),0 6px 24px hsl(239 80% 2%/.12);
transition:border-color .15s,box-shadow .15s;
}
.agent-room__composer:focus-within {
border-color:hsl(265 74% 69%/.65);
box-shadow:0 0 0 3px hsl(266 69% 55%/.08);
}
.agent-room__composer textarea {
display:block;
flex:1;
min-width:0;
min-height:46px;
max-height:156px;
margin:0;
resize:none;
overflow:auto;
border:0;
outline:0;
background:transparent;
padding:13px 11px;
font-size:14px;
line-height:20px;
color:hsl(259 44% 96%);
box-shadow:none;
}
.agent-room__composer textarea::placeholder {
color:hsl(255 23% 53%);
}
.agent-room__send {
display:grid;
place-items:center;
flex:none;
width:38px;
height:38px;
margin-bottom:4px;
border:1px solid hsl(265 86% 79%/.32);
border-radius:11px;
background:linear-gradient(140deg,hsl(264 80% 59%),hsl(270 75% 49%));
color:hsl(265 65% 98%);
box-shadow:0 3px 12px hsl(264 75% 45%/.22);
cursor:pointer;
transition:filter .15s,opacity .15s;
}
.agent-room__send:hover:not(:disabled) {
filter:brightness(1.17);
}
.agent-room__send:disabled {
opacity:.34;
box-shadow:none;
cursor:default;
}
.agent-room__error {
display:flex;
align-items:center;
gap:9px;
margin-bottom:11px;
padding:10px 12px;
border:1px solid hsl(350 62% 62%/.2);
border-radius:11px;
background:hsl(340 45% 22%/.22);
color:hsl(349 70% 85%);
font-size:11px;
}
.agent-room__error>span {
min-width:0;
flex:1;
}
.agent-room__error>svg {
flex:none;
}
.agent-room__error button {
flex:none;
border:1px solid hsl(350 57% 66%/.28);
border-radius:6px;
padding:4px 8px;
color:hsl(349 66% 90%);
font-size:10px;
cursor:pointer;
}
.agent-room__empty {
flex:none;
margin:auto;
max-width:360px;
padding:48px 15px;
text-align:center;
color:hsl(254 25% 65%);
}
.agent-room button:focus-visible,.agent-room summary:focus-visible {
outline:2px solid hsl(188 82% 71%);
outline-offset:3px;
}
@keyframes agent-room-dot {
0%,70%,100% {
opacity:.35;
transform:translateY(0);
}
35% {
opacity:1;
transform:translateY(-2px);
}
}
@keyframes agent-room-message-enter {
from {
opacity:0;
transform:translateY(10px);
}
to {
opacity:1;
transform:translateY(0);
}
}
@media(max-width:767px) {
.agent-room__timeline {
padding:17px 14px 20px;
gap:14px;
}
.agent-room__message {
max-width:96%;
gap:7px;
}
.agent-room__message.is-own {
max-width:87%;
}
.agent-room__message-avatar {
width:23px;
height:23px;
border-radius:8px;
}
.agent-room__typing,.agent-room__waiting {
margin-left:30px;
font-size:10px;
}
.agent-room__composer-area {
padding:12px 13px max(11px,var(--safe-area-bottom));
}
.agent-room__composer textarea {
font-size:16px;
line-height:22px;
}
.agent-room__empty {
max-width:300px;
}
}
@media(prefers-reduced-motion:reduce) {
.agent-room__message[data-animation="enter"] {
animation:none;
}
.agent-room__typing-dots i {
animation:none;
opacity:.7;
}
.agent-room * {
scroll-behavior:auto!important;
transition:none!important;
}
}
.agent-room__attach { display: grid; place-items: center; align-self: end; width: 38px; height: 46px; color: #c4b5fd; border-radius: 12px; }
.agent-room__attach:hover { background: #a78bfa22; }
.agent-room__uploads { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.agent-room__upload { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; max-width: 100%; padding: 9px 12px; border: 1px solid #a78bfa44; border-radius: 12px; background: #211b36; font-size: 12px; }
.agent-room__upload strong { display: block; overflow-wrap: anywhere; font-weight: 500; }
.agent-room__upload small { color: #c4b5fd; }
.agent-room__upload button { padding: 5px; color: #ddd6fe; }
.agent-room__upload-path { flex-basis: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 10px; color: #b9a7d1; }
.agent-attachment { margin-top: 10px; padding: 10px; border: 1px solid #a78bfa44; border-radius: 10px; background: #0e0a1933; font-size: 12px; min-width: 0; }
.agent-attachment__title { display: flex; align-items: center; gap: 6px; }
.agent-attachment__title span { overflow-wrap: anywhere; }
.agent-attachment__title small { margin-left: auto; white-space: nowrap; opacity: .7; }
.agent-attachment__path { display: flex; gap: 6px; margin-top: 6px; }
.agent-attachment__path code { min-width: 0; overflow-wrap: anywhere; font-size: 10px; opacity: .75; }
.agent-attachment__path button { flex-shrink: 0; }
.agent-attachment__actions { display: flex; gap: 14px; margin-top: 8px; }
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
.agent-attachment__actions button:disabled { opacity: .5; }
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
@@ -0,0 +1,364 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import { ArrowDown, Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
import type { Message } from "@/lib/api";
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
import { MarkdownContent } from "@/components/markdown/markdown-content";
import { Button } from "@/components/ui/button";
import { agentAttachments, attachmentSize } from "@/lib/agent-attachments";
import { AgentAttachmentCard } from "./agent-attachment";
import "./agent-conversation.css";
type Props = {
room: AgentRoomHookResult;
onOpenSidebar: () => void;
/** False while a mobile navigation overlay covers this pane. */
isVisible?: boolean;
};
type ReadingAnchor = { id: string; offset: number };
const DRAFT_KEY = "sybil.pi-root.draft";
const timeFormat = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
const dayFormat = new Intl.DateTimeFormat(undefined, { weekday: "short", month: "short", day: "numeric" });
function savedDraft() {
try { return sessionStorage.getItem(DRAFT_KEY) || ""; } catch { return ""; }
}
function saveDraft(value: string) {
try { if (value) sessionStorage.setItem(DRAFT_KEY, value); else sessionStorage.removeItem(DRAFT_KEY); } catch { /* A blocked browser store does not prevent chatting. */ }
}
function metadata(message: Message): Record<string, unknown> {
return message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
}
function dateOf(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) || date.getFullYear() < 2000 ? null : date;
}
function dayKey(date: Date | null) {
return date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : "older";
}
function dayLabel(date: Date | null) {
if (!date) return "Earlier";
const today = new Date();
if (dayKey(date) === dayKey(today)) return "Today";
today.setDate(today.getDate() - 1);
return dayKey(date) === dayKey(today) ? "Yesterday" : dayFormat.format(date);
}
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 own = message.role === "user";
const info = metadata(message);
const status = typeof info.status === "string" ? info.status : "complete";
const failed = status === "failed" || status === "unconfirmed";
const date = dateOf(message.createdAt);
return (
<article className={`agent-room__message ${own ? "is-own" : "is-agent"}`} data-agent-message-id={message.id} data-message-status={status} data-animation={animate ? "enter" : undefined} aria-label={own ? "Your message" : "Sybil's 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} />)}
</div>
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
<div className="agent-room__message-meta">
<span>{own ? "You" : "Sybil"}</span>
{date ? <time dateTime={message.createdAt} title={date.toLocaleString()}>{timeFormat.format(date)}</time> : null}
{own && status === "sending" ? <span className="agent-room__delivery" role="status">Sending…</span> : null}
{own && status === "queued" ? <span className="agent-room__delivery" role="status"><Clock3 size={11} aria-hidden="true" /> Queued</span> : null}
{own && failed ? <span className="agent-room__delivery is-error" title={typeof info.error === "string" ? info.error : undefined} role="status">
{status === "unconfirmed" ? "Unconfirmed" : "Not sent"}
<button type="button" onClick={() => onRetry(message.id)} aria-label="Retry message">Retry</button>
</span> : null}
</div>
</div>
</article>
);
}
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
const [draft, setDraft] = useState(savedDraft);
const draftRef = useRef(draft);
const [uploadError, setUploadError] = useState<string>();
const pickerRef = useRef<HTMLInputElement>(null);
const reselectRef = useRef<HTMLInputElement>(null);
const reselectId = useRef<string>();
const uploads = room.uploadQueue.snapshot();
const pendingUploads = uploads.some(file => file.status !== "ready");
const addFiles = (files: Iterable<File>) => {
try { room.uploadQueue.add(files); setUploadError(undefined); } catch (error) { setUploadError(error instanceof Error ? error.message : String(error)); }
};
const removeFile = (id: string) => { try { room.uploadQueue.remove(id); setUploadError(undefined); } catch (error) { setUploadError(String(error)); } };
const seenMessageIdsRef = useRef(new Set(room.messages.map(message => message.id)));
const enteringMessageIdsRef = useRef(new Set<string>());
const animatedEntryIdsRef = useRef(new Set<string>());
const entryAnimationsRef = useRef(new Set<Animation>());
const typingHeightRef = useRef(0);
const historyReadyRef = useRef(!room.isLoading);
const [atBottom, setAtBottom] = useState(true);
const scrollerRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const pinnedRef = useRef(true);
const restoredScrollTopRef = useRef<number | null>(null);
const anchorRef = useRef<ReadingAnchor | null>(null);
const markReadRef = useRef(room.markRead);
const visibleRef = useRef(isVisible);
const loadingRef = useRef(room.isLoading);
const readFrameRef = useRef<number | null>(null);
const mountedRef = useRef(true);
markReadRef.current = room.markRead;
visibleRef.current = isVisible;
loadingRef.current = room.isLoading;
const distanceFromBottom = () => {
const element = scrollerRef.current;
return element ? element.scrollHeight - element.scrollTop - element.clientHeight : Infinity;
};
const scheduleRead = () => {
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
readFrameRef.current = requestAnimationFrame(() => {
readFrameRef.current = null;
if (mountedRef.current && visibleRef.current && !loadingRef.current && entryAnimationsRef.current.size === 0 && document.visibilityState === "visible" && document.hasFocus() && distanceFromBottom() <= 24) {
markReadRef.current();
}
});
};
const rememberAnchor = () => {
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;
};
const restoreViewport = () => {
const element = scrollerRef.current;
if (!element) return;
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;
}
restoredScrollTopRef.current = element.scrollTop;
rememberAnchor();
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;
}
scheduleRead();
};
useLayoutEffect(() => {
const timeline = timelineRef.current;
if (!timeline) return;
const gap = parseFloat(getComputedStyle(timeline).rowGap) || 0;
const typing = timeline.querySelector<HTMLElement>(".agent-room__typing");
// Transfer the departing indicator's space to the reply before it grows.
// Removing that space first would briefly move the transcript downward.
let replacedTypingHeight = typing ? 0 : typingHeightRef.current;
typingHeightRef.current = typing ? typing.offsetHeight + (typing.previousElementSibling ? gap : 0) : 0;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
for (const entry of timeline.querySelectorAll<HTMLElement>("[data-agent-entry-id]")) {
const id = entry.dataset.agentEntryId!;
if (animatedEntryIdsRef.current.has(id) || !enteringMessageIdsRef.current.has(id)) continue;
animatedEntryIdsRef.current.add(id);
if (reduceMotion) continue;
const height = entry.getBoundingClientRect().height;
const spacing = entry.previousElementSibling ? gap : 0;
const isReply = Boolean(entry.querySelector(".agent-room__message.is-agent"));
const initialHeight = isReply ? Math.min(replacedTypingHeight, height + spacing) : 0;
if (isReply) replacedTypingHeight = 0;
// Include the inter-message gap in the reveal. Clipping keeps the bubble's
// positive-Y transform from prematurely extending the scrollable area.
entry.style.overflow = "clip";
const animation = entry.animate([
{ height: `${initialHeight}px`, marginTop: `${-spacing}px` },
{ height: `${height}px`, marginTop: "0px" },
], { duration: 240, easing: "cubic-bezier(.2,.7,.25,1)" });
entryAnimationsRef.current.add(animation);
animation.onfinish = () => {
entry.style.overflow = "";
entryAnimationsRef.current.delete(animation);
restoreViewport();
};
}
}, [room.messages, room.isTyping]);
useLayoutEffect(() => {
restoreViewport();
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
useEffect(() => {
mountedRef.current = true;
const observer = new ResizeObserver(restoreViewport);
if (scrollerRef.current) observer.observe(scrollerRef.current);
if (timelineRef.current) observer.observe(timelineRef.current);
const onVisibility = () => scheduleRead();
window.addEventListener("focus", onVisibility);
document.addEventListener("visibilitychange", onVisibility);
return () => {
mountedRef.current = false;
observer.disconnect();
for (const animation of entryAnimationsRef.current) animation.cancel();
entryAnimationsRef.current.clear();
window.removeEventListener("focus", onVisibility);
document.removeEventListener("visibilitychange", onVisibility);
if (readFrameRef.current !== null) cancelAnimationFrame(readFrameRef.current);
};
}, []);
useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
textarea.style.height = "0px";
textarea.style.height = `${Math.min(156, Math.max(46, textarea.scrollHeight))}px`;
}, [draft]);
const updateDraft = (value: string) => {
draftRef.current = value;
saveDraft(value);
setDraft(value);
};
const retryMessage = (id: string) => {
// Admission errors remain attached to their own bubble.
void room.retryMessage(id).catch(() => {});
};
const submit = () => {
const content = draftRef.current.trim();
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
let attachments;
try { attachments = room.uploadQueue.ready(); } catch (error) { setUploadError(String(error)); return; }
const delivery = room.sendMessage(content, attachments, () => { updateDraft(""); setUploadError(undefined); jumpToLatest(); });
void delivery.catch(error => { if (draftRef.current === content) setUploadError(error instanceof Error ? error.message : String(error)); });
textareaRef.current?.focus();
};
const messages = useMemo(() => {
const transcript = agentTranscriptMessages(room.messages);
let previousDay = "";
for (const message of transcript) {
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
enteringMessageIdsRef.current.add(message.id);
}
seenMessageIdsRef.current.add(message.id);
}
if (!room.isLoading) historyReadyRef.current = true;
return transcript.map(message => {
const date = dateOf(message.createdAt), day = dayKey(date), separator = previousDay !== day;
previousDay = day;
return (
<div className="agent-room__entry" key={message.id} data-agent-entry-id={message.id}>
{separator ? <div className="agent-room__day"><span>{dayLabel(date)}</span></div> : null}
<AgentBubble message={message} animate={enteringMessageIdsRef.current.has(message.id)} onRetry={retryMessage} download={room.downloadAttachment} />
</div>
);
});
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
const activity = room.activity;
const running = room.isTyping;
const waiting = activity?.state === "waiting";
const connected = room.connection === "connected";
const connectionLabel = connected ? "Connected" : room.connection === "offline" ? "Offline" : room.connection === "reconnecting" ? "Reconnecting" : "Connecting";
const activeTool = activity?.tools?.find(tool => tool.status !== "done");
const workingLabel = activeTool ? `Using ${toolName(activeTool.name)}`
: activity?.isThinking ? "Sybil is thinking" : (activity?.backgroundWorkers || 0) > 0 ? "Working in the background" : "";
return (
<section className="agent-room" aria-label="Sybil Agent conversation">
<header className="agent-room__header flex shrink-0 items-center justify-between gap-2 border-b border-violet-300/12 bg-[linear-gradient(180deg,hsl(243_48%_10%_/_0.86),hsl(236_48%_6%_/_0.66))] px-4 py-3 md:gap-3 md:px-7">
<div className="flex min-w-0 items-center gap-2">
<Button type="button" size="icon" variant="ghost" className="workspace-sidebar-trigger -ml-1 h-8 w-8 md:hidden" onClick={onOpenSidebar} aria-label="Open sidebar">
<Menu className="h-4 w-4" />
</Button>
<h1 className="truncate text-sm font-semibold text-violet-50 md:text-base">Sybil Agent</h1>
</div>
<div className="flex h-10 shrink-0 items-center justify-end gap-2">
<span className="sr-only" role="status">{connectionLabel}</span>
{!connected ? <Button type="button" size="icon" variant="ghost" onClick={room.retry} aria-label="Reconnect to Sybil Agent" title={connectionLabel}><RefreshCw className="h-4 w-4" /></Button> : null}
</div>
</header>
<div className="agent-room__body">
<div
ref={scrollerRef}
className="agent-room__scroll"
tabIndex={0}
aria-label="Conversation messages"
onScroll={() => {
// A scroll event from our last resize correction can arrive after
// the next animation frame has already grown a tall reply. That
// temporary bottom distance does not mean the reader scrolled up.
if (restoredScrollTopRef.current !== null && scrollerRef.current
&& 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}
{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"
onDragOver={event => { if (room.attachmentCapability && event.dataTransfer?.types.includes("Files")) event.preventDefault(); }}
onDrop={event => { if (room.attachmentCapability && event.dataTransfer?.files.length) { event.preventDefault(); addFiles(event.dataTransfer.files); } }}>
<div className="agent-room__composer-inner">
{room.error ? <div className="agent-room__error" role="alert"><WifiOff size={15} /><span>{room.error}</span><button onClick={room.retry}>Retry</button></div> : null}
{uploadError ? <div className="agent-room__error" role="alert">{uploadError}</div> : null}
<input ref={reselectRef} type="file" hidden aria-label="Reselect attachment" onChange={event => {
const file = event.currentTarget.files?.[0], id = reselectId.current; event.currentTarget.value = "";
if (file && id) void room.uploadQueue.reselect(id, file).then(() => setUploadError(undefined)).catch(error => setUploadError(error instanceof Error ? error.message : String(error)));
}} />
{uploads.length ? <div className="agent-room__uploads" aria-label="Selected attachments">{uploads.map(row => <div className="agent-room__upload" key={row.uploadId}>
<div><strong>{row.filename}</strong><small>{attachmentSize(row.sizeBytes)} · <span role="status">{row.status === "ready" ? "Uploaded" : row.status === "uploading" ? "Uploading…" : row.status === "waiting" ? "Waiting…" : row.status === "reselect" ? "Reselect file" : "Upload failed"}</span></small></div>
{row.status === "reselect" ? <button type="button" onClick={() => { reselectId.current = row.uploadId; reselectRef.current?.click(); }}>Reselect file</button> : null}
{row.status === "failed" ? <button type="button" onClick={() => room.uploadQueue.retry(row.uploadId)}>Retry upload</button> : null}
<button type="button" aria-label={`Remove ${row.filename}`} onClick={() => removeFile(row.uploadId)}><X size={15} /></button>
{row.error ? <span className="agent-attachment__error" role="alert">{row.error}</span> : null}
{row.attachment?.workspacePath ? <code className="agent-room__upload-path" title={row.attachment.workspacePath}>{row.attachment.workspacePath}</code> : null}
</div>)}</div> : null}
<form className="agent-room__composer" onSubmit={event => { event.preventDefault(); submit(); }}>
{room.attachmentCapability ? <>
<input ref={pickerRef} type="file" multiple hidden aria-label="Choose attachments" onChange={event => { if (event.currentTarget.files) addFiles(event.currentTarget.files); event.currentTarget.value = ""; }} />
<button type="button" className="agent-room__attach" aria-label="Attach files" title="Attach files (8 files, 32 MiB total)" onClick={() => pickerRef.current?.click()}><Paperclip size={19} /></button>
</> : null}
<textarea
ref={textareaRef}
value={draft}
rows={1}
maxLength={40000}
onPaste={event => { if (room.attachmentCapability && event.clipboardData?.files.length) { event.preventDefault(); addFiles(event.clipboardData.files); } }}
onInput={event => updateDraft(event.currentTarget.value)}
onKeyDown={event => { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } }}
placeholder="Message Sybil Agent…"
aria-label="Message Sybil Agent"
enterkeyhint="send"
/>
<button type="submit" className="agent-room__send" disabled={(!draft.trim() && !uploads.length) || pendingUploads || Boolean(uploads.length && !room.attachmentCapability)} aria-label="Send message"><SendHorizontal size={19} /></button>
</form>
</div>
</div>
</section>
);
}
@@ -0,0 +1,22 @@
import { useEffect, useState } from "preact/hooks";
import { Bell } from "lucide-preact";
import { Button } from "@/components/ui/button";
import { disableAgentBrowserPush, enableAgentBrowserPush, getAgentPushState, observeAgentPush, refreshAgentBrowserPush } from "@/lib/agent-push-browser";
export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void> }) {
const [state, setState] = useState(getAgentPushState), [signingOut, setSigningOut] = useState(false);
useEffect(() => observeAgentPush(setState), []);
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
<div className="space-y-2 pb-1 pt-2">
<p role="status">{state.message}</p>
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
<div className="flex flex-wrap gap-2">
{state.enabled ? <Button className="h-8 text-xs" variant="secondary" disabled={state.busy} onClick={() => void disableAgentBrowserPush()}>Turn off notifications</Button>
: <Button className="h-8 text-xs" variant="secondary" disabled={!state.canEnable} onClick={() => void enableAgentBrowserPush()}>Enable notifications</Button>}
<Button className="h-8 text-xs" variant="ghost" disabled={state.busy} onClick={() => void refreshAgentBrowserPush()}>Refresh</Button>
<Button className="h-8 text-xs" variant="ghost" disabled={signingOut} onClick={() => { setSigningOut(true); void onLogout().finally(() => setSigningOut(false)); }}>Sign out</Button>
</div>
</div>
</details>;
}
+7 -8
View File
@@ -26,7 +26,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
</div>
<div>
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
<p className="mt-1 text-sm text-muted-foreground">Enter your admin token.</p>
</div>
</div>
@@ -34,28 +34,27 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void onSignIn(authTokenInput.trim() || null);
const token = authTokenInput.trim();
if (token) void onSignIn(token);
}}
>
<Input
type="password"
autoComplete="off"
aria-label="Admin token"
placeholder="ADMIN_TOKEN"
required
value={authTokenInput}
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
disabled={isSigningIn}
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
/>
<Button className="w-full" type="submit" disabled={isSigningIn}>
<Button className="w-full" type="submit" disabled={isSigningIn || !authTokenInput.trim()}>
{isSigningIn ? "Signing in..." : "Sign in"}
</Button>
<Button className="w-full" type="button" variant="secondary" disabled={isSigningIn} onClick={() => void onSignIn(null)}>
Continue without token
</Button>
</form>
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
{authError ? <p role="alert" className="mt-3 text-sm text-red-600">{authError}</p> : null}
</div>
</div>
);
@@ -16,7 +16,7 @@ function getTextPreview(value: string) {
function getSurfaceClasses(tone: Props["tone"]) {
if (tone === "user") {
return "border-white/12 bg-black/16 text-fuchsia-50";
return "border-white/12 bg-black/16 text-inherit";
}
if (tone === "assistant") {
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
@@ -378,13 +378,13 @@ function ToolCallStack({
);
})}
{!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
@@ -601,10 +601,10 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] space-y-3",
"min-w-0 space-y-3",
isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
: "max-w-[85%] text-base leading-7 text-violet-50"
)}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
@@ -625,7 +625,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
/>
) : null}
</div>
@@ -0,0 +1,96 @@
/// <reference lib="es2022.intl" />
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
const text = source.replace(/\s+/gu, " ").trim();
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const ellipsisRef = useRef<HTMLSpanElement>(null);
const [kept, setKept] = useState<number | null>(null);
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
const runs = useMemo(() => {
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
const result: Array<{ start: number; end: number; match: boolean }> = [];
let cursor = 0;
for (const word of text.matchAll(words)) {
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
cursor = word.index + word[0].length;
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
}
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
return result;
}, [text, query]);
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
if (run.end <= start || run.start >= end) return [];
return [run.match
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
: <span key={`${start}:${run.start}`}>{value}</span>];
});
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
const ellipsis = ellipsisRef.current;
if (!container || !measure || !ellipsis) return;
let disposed = false;
const update = () => {
if (disposed) return;
const width = container.getBoundingClientRect().width;
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
// Measure the styled DOM, including bold runs, instead of guessing a
// character limit. Grapheme boundaries keep accents and emoji intact.
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
const range = document.createRange();
const measureRange = (start: number, end: number) => {
if (start >= end) return 0;
let offset = 0;
let started = false;
for (const node of nodes) {
const next = offset + node.length;
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
if (end <= next) { range.setEnd(node, end - offset); break; }
offset = next;
}
return range.getBoundingClientRect().width;
};
const count = boundaries.length - 1;
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
let low = 0;
let high = count - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const prefix = boundaries[Math.ceil(middle / 2)];
const suffix = boundaries[count - Math.floor(middle / 2)];
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
else high = middle - 1;
}
setKept(low);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
void document.fonts.ready.then(update);
document.fonts.addEventListener("loadingdone", update);
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
}, [text, runs, boundaries]);
const count = boundaries.length - 1;
const truncated = kept !== null && kept < count;
return (
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
</span>
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
</span>
</span>
);
}
+269
View File
@@ -0,0 +1,269 @@
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,
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
type AgentActivity, type AgentRoomState,
} from "@/lib/agent-room";
import { AGENT_OUTBOX_STORAGE_KEY, createAgentOptimisticOutbox } from "@/lib/agent-optimistic-outbox";
import { createAgentRoomPresenter } from "@/lib/agent-room-presentation";
import { getAgentPresenceSessionId, syncAgentNotificationRead } from "@/lib/agent-push-browser";
import { AGENT_ATTACHMENT_DRAFT_KEY, createAgentUploadQueue, type AgentAttachment, type AgentAttachmentCapability } from "@/lib/agent-attachments";
export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" | "offline";
export type AgentRoomHookResult = {
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
attachmentCapability?: AgentAttachmentCapability;
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
uploadQueue: ReturnType<typeof createAgentUploadQueue>;
sendMessage(content: string, attachments?: AgentAttachment[], onPersisted?: () => void): Promise<void>; retryMessage(messageId: string): Promise<void>; markRead(): void; retry(): void;
};
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
/** Mount once in App; selection controls reading, never the lifetime of this subscription. */
export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
const [view, setView] = useState(() => ({ room: createAgentRoomState(), isTyping: false }));
const room = view.room;
const current = useRef(room);
const [connection, setConnection] = useState<AgentRoomConnection>("offline");
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
const [error, setError] = useState<string | null>(null);
const [isLoading, setLoading] = useState(enabled);
const [revision, setRevision] = useState(0);
const client = useRef<AgentRoomClient | null>(null);
const [, renderUploads] = useState(0);
const capabilityRef = useRef(attachmentCapability); capabilityRef.current = enabled ? attachmentCapability : undefined;
const uploadQueue = useRef<ReturnType<typeof createAgentUploadQueue> | null>(null);
if (!uploadQueue.current) uploadQueue.current = createAgentUploadQueue({
capability: () => capabilityRef.current,
upload: (file, id, signal) => { if (!client.current) throw new Error("Agent connection is unavailable."); return client.current.upload(file, id, signal); },
changed: () => renderUploads(n => n + 1),
storage: {
read: () => { try { return JSON.parse(sessionStorage.getItem(AGENT_ATTACHMENT_DRAFT_KEY) || "null"); } catch { return null; } },
write: rows => { try { sessionStorage.setItem(AGENT_ATTACHMENT_DRAFT_KEY, JSON.stringify(rows)); } catch { throw new Error("Could not save attachment retry details. Allow session storage before uploading."); } },
},
});
useEffect(() => () => uploadQueue.current?.dispose(), []);
const mark = useRef<((cursor: string, sourceId: string | null | undefined) => void) | null>(null);
const sourceMismatch = useRef(false);
const [outboxRevision, setOutboxRevision] = useState(0);
const update = useCallback((change: (state: AgentRoomState) => AgentRoomState) => {
const next = change(current.current); current.current = next; setView(previous => ({ ...previous, room: next }));
}, []);
const outbox = useRef<ReturnType<typeof createAgentOptimisticOutbox> | null>(null);
if (!outbox.current) {
const stored = (key: string) => { try { return JSON.parse(sessionStorage.getItem(key) || "null"); } catch { return null; } };
outbox.current = createAgentOptimisticOutbox({
submit: (content, requestId, attachments) => {
if (!client.current) throw new Error("Agent connection is unavailable.");
return client.current.submit(content, requestId, attachments);
},
changed: () => setOutboxRevision(value => value + 1),
storage: {
read: () => stored(AGENT_OUTBOX_STORAGE_KEY),
legacyRows: () => stored("sybil.pi-root.outbox"),
legacyIds: () => stored(AGENT_ADMISSION_STORAGE_KEY),
write: rows => {
try {
// Keep [] as a migration marker; legacy keys are retired only once
// their replacement has been saved successfully.
sessionStorage.setItem(AGENT_OUTBOX_STORAGE_KEY, JSON.stringify(rows));
sessionStorage.removeItem("sybil.pi-root.outbox");
sessionStorage.removeItem(AGENT_ADMISSION_STORAGE_KEY);
} catch { throw new Error("Could not save this message for retry. Allow session storage or free browser storage, then retry."); }
},
},
});
}
useEffect(() => {
if (!enabled) {
sourceMismatch.current = false;
client.current = null; mark.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
let active = true;
let sourceChanged = false;
let socket: WebSocket | undefined;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
let socketTimeout: ReturnType<typeof setTimeout> | undefined;
let reconnectAttempt = 0;
let started = false;
let maintaining = false;
let lastPacket = Date.now();
let socketAuthenticated = false;
let authFailed = false;
const controller = new AbortController();
const api = new AgentRoomClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
client.current = api;
const presenter = createAgentRoomPresenter({ initial: current.current, reduceEvent: applyAgentEvent,
publish: (next, isTyping) => {
if (!active || sourceChanged) return;
outbox.current?.reconcile(next);
current.current = next;
setView({ room: next, isTyping });
},
});
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
const readSession = createAgentReadSession({
initial: current.current,
save: (cursor, sourceId) => api.markRead(cursor, controller.signal, sourceId),
invalidated: failure => {
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
presenter.dispose(); controller.abort(); socket?.close();
setError(failure.message); setConnection("offline"); setLoading(false);
},
apply: read => {
void syncAgentNotificationRead(read);
if (active && !sourceChanged) presenter.update(state => applyAgentReadState(state, read));
},
});
mark.current = (cursor, sourceId) => { void readSession.request(cursor, sourceId).catch(reportError); };
const refreshRead = async () => {
const read = await api.readState(controller.signal);
if (active && !sourceChanged) readSession.receive(read);
};
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
if (active && !sourceChanged) presenter.push({ type: "messages", ...page }, { replay });
}, controller.signal);
const refreshActivity = async () => {
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 }); }
};
const connect = () => {
if (!active || authFailed || sourceChanged) return;
clearTimeout(reconnectTimer); clearTimeout(socketTimeout);
setConnection(reconnectAttempt ? "reconnecting" : "connecting");
socketAuthenticated = false;
const next = new WebSocket(api.websocketUrl(window.location.href));
socket = next;
socketTimeout = setTimeout(() => { if (!socketAuthenticated) next.close(); }, 12_000);
next.onopen = () => {
if (!active || sourceChanged || socket !== next) { next.close(); return; }
lastPacket = Date.now();
next.send(agentAuthFrame(getAuthToken(), current.current.cursor, getAgentPresenceSessionId()));
};
next.onmessage = ({ data }) => {
if (!active || sourceChanged || socket !== next) return;
lastPacket = Date.now();
try {
const event = parseAgentRoomEvent(String(data));
if (!event) return;
if (event.type === "error") { reportError(new Error(event.error || event.message || "Agent connection error.")); return; }
if (event.type === "ready") {
clearTimeout(socketTimeout); socketAuthenticated = true; reconnectAttempt = 0;
setConnection("connected"); setError(null); return;
}
if (event.type === "read_state") {
readSession.receive(event); return;
}
presenter.push(event);
} catch (failure) {
reportError(failure);
// A malformed message must not advance the replay cursor. Reconnect
// from the last applied page, rather than silently skipping that input.
next.close();
}
};
next.onerror = () => { if (active && !sourceChanged) setConnection("reconnecting"); };
next.onclose = event => {
if (!active || sourceChanged || socket !== next) return;
clearTimeout(socketTimeout); socketAuthenticated = false;
if (event.code === 4401) {
authFailed = true; setConnection("offline"); setError("Agent authentication failed. Sign in again or retry."); return;
}
setConnection("reconnecting");
const wait = Math.min(30_000, 1000 * 2 ** Math.min(reconnectAttempt++, 5));
reconnectTimer = setTimeout(connect, wait);
};
};
const start = async () => {
try {
await Promise.all([refreshRead(), refreshActivity()]);
// The first start begins at zero, not the latest page. Unread counts
// therefore include older public replies even after a long absence.
await catchUp(!started);
if (!active || sourceChanged) return;
presenter.flush();
started = true; setLoading(false); connect();
} catch (failure) {
if (!active || sourceChanged) return;
reportError(failure); setLoading(false); setConnection("offline");
startupTimer = setTimeout(() => { void start(); }, 10_000);
}
};
const maintain = async (foreground = false) => {
if (!active || sourceChanged || !started || maintaining || authFailed) return;
maintaining = true;
try {
await refreshRead();
await readSession.flush();
if (!socketAuthenticated) {
await Promise.all([catchUp(), refreshActivity()]);
} else if (foreground) await catchUp();
if (socket?.readyState === WebSocket.OPEN) {
if (Date.now() - lastPacket > 60_000) socket.close();
else socket.send(JSON.stringify({ type: "ping" }));
}
} catch (failure) { reportError(failure); }
finally { maintaining = false; }
};
const focus = () => { if (document.visibilityState === "visible") void maintain(true); };
const interval = setInterval(() => { void maintain(); }, 15_000);
window.addEventListener("focus", focus);
document.addEventListener("visibilitychange", focus);
void start();
return () => {
active = false; readSession.cancel(); controller.abort();
presenter.dispose();
clearInterval(interval); clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
socket?.close();
if (client.current === api) client.current = null;
mark.current = null;
};
}, [enabled, revision, update]);
const renderedCursor = room.cursor;
const renderedSourceId = room.sourceId;
const markRead = useCallback(() => {
if (!enabled || isLoading) return;
// This callback belongs to this rendered snapshot. Reading current.current
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor, renderedSourceId);
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
}, [enabled]);
const uploadAttachment = useCallback((file: File, uploadId: string, signal: AbortSignal) => {
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
return client.current.upload(file, uploadId, signal);
}, [enabled]);
const downloadAttachment = useCallback((file: AgentAttachment, signal?: AbortSignal) => {
if (!enabled || !client.current) return Promise.reject(new Error("Agent connection is unavailable."));
return client.current.download(file, signal);
}, [enabled]);
const retryMessage = useCallback(async (messageId: string) => {
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
await outbox.current.retry(messageId);
}, [enabled]);
const retry = useCallback(() => {
if (sourceMismatch.current) { window.location.reload(); return; }
setRevision(value => value + 1);
}, []);
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
}
+25 -6
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "preact/hooks";
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
const TOKEN_STORAGE_KEY = "sybil_admin_token";
@@ -18,8 +19,11 @@ function persistToken(token: string | null) {
}
function normalizeAuthError(message: string) {
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
if (message.includes("invalid bearer token")) {
return "Incorrect token. Please try again.";
}
if (message.includes("missing bearer token")) {
return "Enter your admin token.";
}
return message;
}
@@ -43,10 +47,11 @@ export function useSessionAuth() {
persistToken(tokenCandidate);
};
const handleAuthFailure = (message: string) => {
const handleAuthFailure = (message: string | null) => {
void revokeAgentBrowserPush();
setIsAuthenticated(false);
setAuthMode(null);
setAuthError(normalizeAuthError(message));
setAuthError(message ? normalizeAuthError(message) : null);
setAuthToken(null);
persistToken(null);
};
@@ -66,7 +71,8 @@ export function useSessionAuth() {
}
};
const logout = () => {
const logout = async () => {
await revokeAgentBrowserPush();
setAuthToken(null);
persistToken(null);
setIsAuthenticated(false);
@@ -74,6 +80,18 @@ export function useSessionAuth() {
setAuthError(null);
};
useEffect(() => {
if (!isAuthenticated || authMode !== "token") return;
return startAgentBrowserSession();
}, [isAuthenticated, authMode]);
useEffect(() => {
// Signing out in one tab retires the others instead of using a stale token.
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
window.addEventListener("storage", signedOut);
return () => window.removeEventListener("storage", signedOut);
}, []);
useEffect(() => {
const token = readStoredToken() ?? getConfiguredToken();
void (async () => {
@@ -81,7 +99,8 @@ export function useSessionAuth() {
await completeSessionCheck(token);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
handleAuthFailure(message);
// A first visit without a token is an invitation to sign in, not a failed attempt.
handleAuthFailure(!token && message.includes("missing bearer token") ? null : message);
} finally {
setIsCheckingSession(false);
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "preact/hooks";
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
const EMPTY_ITEMS: WorkspaceItem[] = [];
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
const trimmed = query.trim();
const [state, setState] = useState<SearchState | null>(null);
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
useEffect(() => {
setState(null);
if (!enabled || !trimmed || validationError) return;
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
(result) => setState({ ...result, query: trimmed, revision, error: null }),
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
);
}, [trimmed, enabled, revision, validationError]);
// Hide obsolete results immediately, even before effect cleanup runs.
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
return {
items: current?.items ?? EMPTY_ITEMS,
hasMore: current?.hasMore ?? false,
error: validationError ?? current?.error ?? null,
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
};
}
+72 -26
View File
@@ -31,7 +31,7 @@
--secondary-foreground: 254 38% 94%;
--accent: 188 86% 50%;
--accent-foreground: 230 45% 8%;
--radius: 0.5rem;
--radius: 0px;
}
* {
@@ -72,6 +72,7 @@ select,
textarea {
font: inherit;
color-scheme: dark;
border-radius: var(--radius);
}
::selection {
@@ -113,13 +114,39 @@ textarea {
max(0.75rem, var(--safe-area-left));
}
.chat-settings-header {
padding:
calc(0.5rem + var(--safe-area-top))
max(0.75rem, var(--safe-area-right))
0.5rem
max(0.75rem, var(--safe-area-left));
}
.chat-settings-content {
padding:
1rem
max(1rem, var(--safe-area-right))
max(1.5rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
scroll-padding-block: 1rem;
}
.chat-settings-error {
padding:
0.75rem
max(1rem, var(--safe-area-right))
max(0.75rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
}
@media (min-width: 768px) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
.chat-settings-header,
.chat-settings-content {
padding: 1.25rem;
}
.chat-settings-error {
padding: 0.75rem 1.25rem;
}
.app-search-safe-pad {
@@ -132,19 +159,11 @@ textarea {
}
@media (horizontal-viewport-segments: 2) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
}
.workspace-shell {
display: grid;
grid-template-columns:
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
}
@@ -153,14 +172,10 @@ textarea {
width: 100%;
max-width: none;
transform: none;
border-width: 1px;
border-radius: 1rem;
}
.workspace-content {
min-width: 0;
border-width: 1px;
border-radius: 1rem;
touch-action: auto;
}
@@ -179,6 +194,37 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28);
}
/* Shared by regular chats and the persistent agent transcript. */
.message-bubble {
min-width: 0;
padding: 13px 17px;
border: 1px solid hsl(256 37% 57% / .18);
border-radius: 18px 18px 18px 5px;
background: linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93));
box-shadow: 0 3px 12px hsl(239 55% 2% / .13);
font-size: 14px;
line-height: 1.68;
overflow-wrap: anywhere;
}
.message-bubble--user {
border-color: hsl(266 80% 72% / .3);
border-radius: 18px 18px 5px 18px;
background: linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9));
color: hsl(265 55% 97%);
}
.message-bubble .md-content {
font-size: inherit;
line-height: inherit;
}
@media (max-width: 767px) {
.message-bubble {
padding: 11px 14px;
}
}
.tool-call-stack-shell {
perspective: 900px;
transform-style: preserve-3d;
@@ -430,7 +476,7 @@ textarea {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem;
border-radius: var(--radius);
background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
}
@@ -449,7 +495,7 @@ textarea {
}
.md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
border-radius: var(--radius);
background: hsl(263 78% 72% / 0.34);
}
@@ -561,7 +607,7 @@ textarea {
.md-content code {
background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem;
border-radius: var(--radius);
padding: 0.05rem 0.3rem;
font-size: 0.86em;
box-decoration-break: clone;
@@ -571,7 +617,7 @@ textarea {
.md-content pre {
overflow-x: auto;
border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem;
border-radius: var(--radius);
background: hsl(249 40% 10% / 0.82);
padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
@@ -595,7 +641,7 @@ textarea {
.md-cite-token {
display: inline-flex;
align-items: center;
border-radius: 9999px;
border-radius: var(--radius);
border: 1px solid hsl(274 40% 55%);
background: hsl(277 33% 21%);
color: hsl(304 42% 96%);
+117
View File
@@ -0,0 +1,117 @@
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
export type AgentAttachment = {
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
sizeBytes: number; sha256: string; workspacePath?: string;
};
export type AgentAttachmentCapability = {
version: 1; maxCount: number; maxTotalBytes: number; maxImageBytes: number; maxFileBytes: number;
imageMimeTypes: string[]; quotaBytes: number; usedBytes: number; remainingBytes: number;
maxConcurrentUploads: number; maxConcurrentDecodes: number;
};
export function parseAgentAttachment(value: unknown): AgentAttachment {
const item = value as AgentAttachment | null;
if (!item || !/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item.id)
|| !["image", "text", "file"].includes(item.kind) || typeof item.filename !== "string" || !item.filename
|| typeof item.mimeType !== "string" || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > 8 * 1024 * 1024
|| typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)
|| (item.workspacePath !== undefined && (typeof item.workspacePath !== "string" || !item.workspacePath.startsWith("/"))))
throw new Error("Agent returned an invalid attachment descriptor.");
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
}
export function agentAttachments(metadata: unknown): AgentAttachment[] {
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
if (!Array.isArray(values) || values.length > 8) return [];
try { return values.map(parseAgentAttachment); } catch { return []; }
}
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
}
export function attachmentSize(bytes: number): string { return bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.ceil(bytes / 1024)} KiB` : `${(bytes / 1024 ** 2).toFixed(1)} MiB`; }
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1";
export type AgentUpload = {
uploadId: string; filename: string; sizeBytes: number; mimeType: string; sha256?: string; file?: File;
status: "waiting" | "uploading" | "ready" | "failed" | "reselect"; attachment?: AgentAttachment; error?: string;
};
export type AgentUploadStorage = { read(): unknown; write(rows: Omit<AgentUpload, "file">[]): void };
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
export function createAgentUploadQueue(options: {
capability(): AgentAttachmentCapability | undefined;
upload(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
changed(): void; storage?: AgentUploadStorage; newId?: () => string;
}) {
const rows = new Map<string, AgentUpload>();
let running: { id: string; controller: AbortController } | undefined, disposed = false;
const persist = () => options.storage?.write([...rows.values()].map(({ file: _file, ...row }) => ({ ...row })));
const publish = () => { if (!disposed) options.changed(); };
const save = () => { if (disposed) return; try { persist(); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish(); };
const restored = options.storage?.read();
if (Array.isArray(restored)) for (const value of restored.slice(0, 8)) {
if (!value || typeof value.uploadId !== "string" || !/^[a-f0-9-]{36}$/i.test(value.uploadId)
|| typeof value.filename !== "string" || typeof value.mimeType !== "string" || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || value.sizeBytes > 8 * 1024 * 1024) continue;
try {
const attachment = value.attachment ? parseAgentAttachment(value.attachment) : undefined;
rows.set(value.uploadId, { uploadId: value.uploadId, filename: value.filename, sizeBytes: value.sizeBytes, mimeType: value.mimeType,
...(typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) ? { sha256: value.sha256 } : {}),
...(attachment ? { attachment, status: "ready" } : { status: "reselect", error: "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
}
const digest = async (file: File) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))].map(n => n.toString(16).padStart(2, "0")).join("");
const pump = () => {
if (disposed || running) return;
const row = [...rows.values()].find(row => row.status === "waiting" && row.file);
if (!row) return;
const controller = new AbortController(); running = { id: row.uploadId, controller }; row.status = "uploading"; publish();
void (async () => {
row.sha256 ||= await digest(row.file!);
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
// Persist the upload identity AND original byte hash before any network request.
persist();
const value = await options.upload(row.file!, row.uploadId, controller.signal);
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
const attachment = parseAgentAttachment(value);
if (attachment.sha256 !== row.sha256 || attachment.sizeBytes !== row.sizeBytes || attachment.filename !== row.filename.normalize("NFC"))
throw new Error("The uploaded file receipt does not match this file. Retry to confirm.");
row.attachment = attachment; row.status = "ready"; delete row.error;
})().catch(error => {
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
row.status = "failed"; row.error = error instanceof Error ? error.message : String(error);
}).finally(() => { running = undefined; save(); pump(); });
};
return {
add(files: Iterable<File>) {
const cap = options.capability(); if (!cap || cap.version !== 1) throw new Error("Attachments are not available on this Agent.");
const selected = Array.from(files);
if (rows.size + selected.length > Math.min(8, cap.maxCount)) throw new Error("Attach at most 8 files per message.");
if ([...rows.values()].reduce((n, r) => n + r.sizeBytes, 0) + selected.reduce((n, f) => n + f.size, 0) > Math.min(32 * 1024 * 1024, cap.maxTotalBytes))
throw new Error("Attachments exceed 32 MiB per message.");
for (const file of selected) {
const image = cap.imageMimeTypes.includes(file.type);
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
}
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
for (const row of added) rows.set(row.uploadId, row);
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
publish(); pump();
},
async reselect(id: string, file: File) {
const row = rows.get(id); if (!row || row.status !== "reselect") return;
if (file.name !== row.filename || file.size !== row.sizeBytes || file.type !== row.mimeType) throw new Error("Choose the same filename, type and size to resume this upload.");
const hash = await digest(file);
if (row.sha256 && row.sha256 !== hash) throw new Error("The selected file has different contents. Choose the original file or remove this attachment.");
if (rows.get(id) !== row) return;
row.sha256 = hash; row.file = file; row.status = "waiting"; delete row.error; persist(); publish(); pump();
},
remove(id: string) { const row = rows.get(id); rows.delete(id); try { persist(); } catch (error) { if (row) rows.set(id, row); throw error; } if (running?.id === id) running.controller.abort(); publish(); },
retry(id: string) { const row = rows.get(id); if (row?.status === "failed") { row.status = row.file ? "waiting" : "reselect"; delete row.error; save(); pump(); } },
snapshot(): AgentUpload[] { return [...rows.values()].map(row => ({ ...row })); },
ready(): AgentAttachment[] {
if ([...rows.values()].some(row => row.status !== "ready")) throw new Error("Wait for uploads, reselect or retry failed files, or remove them before sending.");
return [...rows.values()].map(row => row.attachment!);
},
clear() { rows.clear(); running?.controller.abort(); save(); },
dispose() { disposed = true; running?.controller.abort(); try { persist(); } catch { /* Existing stored retry IDs remain usable. */ } },
};
}
+172
View File
@@ -0,0 +1,172 @@
import type { Message } from "./api";
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
export type AgentOutboxRow = {
id: string; requestId: string; content: string; createdAt: string;
attachments?: AgentAttachment[]; status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
};
export type AgentOutboxStorage = {
read(): unknown; write(rows: AgentOutboxRow[]): void;
legacyRows?(): unknown; legacyIds?(): unknown;
};
type Receipt = { status: string; messageId?: string };
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
/** Local rows use the server's identity from their first render through final echo. */
export function createAgentOptimisticOutbox(options: {
submit(content: string, requestId: string, attachments?: AgentAttachment[]): Promise<Receipt>;
storage: AgentOutboxStorage;
changed(): void;
newId?: () => string;
now?: () => number;
}) {
const newId = options.newId || (() => crypto.randomUUID());
const now = options.now || Date.now;
const rows = new Map<string, AgentOutboxRow>();
const inFlight = new Map<string, Promise<void>>();
const rejectedStorage = new Set<string>();
const confirmedEchoes = new Map<string, Message>();
let latestTime = 0;
const timestamp = () => {
latestTime = Math.max(now(), latestTime + 1);
return new Date(latestTime).toISOString();
};
const restored = options.storage.read();
if (Array.isArray(restored)) {
for (const value of restored.slice(0, 100)) {
if (!value || typeof value.content !== "string" || (!value.content.trim() && !value.attachments?.length) || !validRequestId(value.requestId)) continue;
let attachments: AgentAttachment[] = [];
try { if (value.attachments !== undefined) { if (!Array.isArray(value.attachments) || value.attachments.length > 8) continue; attachments = value.attachments.map(parseAgentAttachment); } } catch { continue; }
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
latestTime = Math.max(latestTime, Date.parse(time));
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
...(attachments.length ? { attachments } : {}), status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
rows.set(row.id, row);
}
} else {
// Join the former UI-only failed cards to the old sender's receipt keys.
// Neither representation is retried automatically during migration.
const oldRows = options.storage.legacyRows?.();
const oldIds = options.storage.legacyIds?.();
const ids = new Map<string, string>();
if (Array.isArray(oldIds)) for (const value of oldIds) {
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
}
const contents = new Map<string, { content: string; status?: string; error?: string }>();
if (Array.isArray(oldRows)) for (const value of oldRows) {
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
}
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
for (const value of [...contents.values()].slice(0, 100)) {
const requestId = ids.get(value.content) || newId();
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
}
}
const persist = () => {
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
options.storage.write(pending.map(row => ({ ...row })));
};
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
const publish = () => options.changed();
const matches = (row: AgentOutboxRow, message: Message) => !row.attachments?.length || JSON.stringify(agentAttachments(message.metadata).map(file => file.id)) === JSON.stringify(row.attachments.map(file => file.id));
const dispatch = (row: AgentOutboxRow, onPersisted?: () => void): Promise<void> => {
const running = inFlight.get(row.id);
if (running) return running;
if (row.confirmed || row.seenOnServer) return Promise.resolve();
row.status = "sending"; delete row.error;
rejectedStorage.delete(row.id);
try { persist(); }
catch (error) {
// A refused new draft must not prevent older persisted drafts from
// draining their outbox. The refused row remains visibly retryable.
rejectedStorage.add(row.id);
row.status = "failed"; row.error = errorText(error); publish();
return Promise.reject(error);
}
onPersisted?.();
// Both durable local storage and the visible row exist before POST begins.
publish();
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId, row.attachments)).then(receipt => {
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
if (!row.confirmed) {
row.seenOnServer = true;
row.status = receipt.status === "queued" ? "queued" : "complete";
delete row.error; saveBestEffort(); publish();
}
}).catch(error => {
// A canonical echo wins over a lost HTTP response. The message has already
// been admitted, so a transport error must not paint it failed or resend it.
if (row.confirmed || row.seenOnServer) return;
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
throw error;
}).finally(() => { inFlight.delete(row.id); });
inFlight.set(row.id, pending);
return pending;
};
// Complete migration even when the user leaves restored drafts untouched:
// their newly assigned identity/time must survive the next reload too.
saveBestEffort();
return {
send(content: string, attachments: AgentAttachment[] = [], onPersisted?: () => void): Promise<void> {
if (!content.trim() && !attachments.length) return Promise.reject(new Error("Enter a message."));
const requestId = newId();
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, ...(attachments.length ? { attachments: attachments.map(parseAgentAttachment) } : {}), createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
rows.set(row.id, row);
return dispatch(row, onPersisted);
},
retry(messageId: string): Promise<void> {
const row = rows.get(messageId);
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
},
reconcile(room: RoomMessages): void {
const compatible = (message: Message) => { const row = rows.get(message.id); return !row || matches(row, message); };
const committed = new Map(room.messages.filter(compatible).map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).filter(compatible).map(message => message.id));
let changed = false;
for (const row of rows.values()) {
if (committed.has(row.id)) {
confirmedEchoes.set(row.id, committed.get(row.id)!);
if (!row.confirmed || row.status !== "complete" || row.error) {
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
}
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
}
}
if (changed) { saveBestEffort(); publish(); }
},
display(serverMessages: readonly Message[]): Message[] {
const displayed = new Map(serverMessages.filter(message => { const row = rows.get(message.id); return !row || matches(row, message); }).map(message => {
const row = rows.get(message.id);
if (!row) return [message.id, message] as const;
const serverInfo = info(message);
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
return [message.id, { ...message, createdAt: row.createdAt,
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
}));
for (const row of rows.values()) if (!displayed.has(row.id)) {
const echo = confirmedEchoes.get(row.id);
if (row.confirmed && !echo) continue;
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
id: row.id, createdAt: row.createdAt, role: "user", content: row.attachments?.length ? attachmentCaption(row.content, row.attachments) : row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
};
}
+158
View File
@@ -0,0 +1,158 @@
import { getApiBaseUrl, getAuthToken } from "./api";
import { ensureServiceWorker } from "../pwa";
import {
AGENT_PUSH_STORAGE_KEY, AgentPushClient, agentPushAuthorityScope, agentPushCursor, agentPushSourceId,
createAgentPresenceReporter, createAgentPushController, parseAgentPushConsent, type AgentPushView, type PushSubscriptionHandle,
} from "./agent-push.ts";
let sessionId: string | undefined;
function browserUuid() {
if (typeof globalThis.crypto?.randomUUID === "function") return crypto.randomUUID();
if (typeof globalThis.crypto?.getRandomValues !== "function") throw new Error("Secure browser identifiers are unavailable.");
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = bytes[6] & 15 | 64; bytes[8] = bytes[8] & 63 | 128;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, "0")).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/** A document UUID avoids duplicated sessionStorage IDs when the user duplicates a tab. */
export function getAgentPresenceSessionId() { try { return sessionId ||= browserUuid(); } catch { return undefined; } }
const api = new AgentPushClient({ baseUrl: getApiBaseUrl(), token: getAuthToken });
async function consentScope() {
const token = getAuthToken(), base = new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href;
const digest = await agentPushAuthorityScope(base, token);
if (token !== getAuthToken() || base !== new URL(getApiBaseUrl().replace(/\/+$/, ""), window.location.href).href) return null;
return digest;
}
const listeners = new Set<(state: AgentPushView) => void>();
function bounded<T>(promise: Promise<T>, timeoutMs = 10000): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error("Browser notification operation timed out.")), timeoutMs);
promise.then(value => { clearTimeout(timeout); resolve(value); }, () => { clearTimeout(timeout); reject(new Error("Browser notification operation failed.")); });
});
}
const subscriptionHandle = (subscription: PushSubscription): PushSubscriptionHandle => ({
toJSON: () => subscription.toJSON(), unsubscribe: () => bounded(subscription.unsubscribe()),
});
const readConsent = () => { try { return parseAgentPushConsent(localStorage.getItem(AGENT_PUSH_STORAGE_KEY)); } catch { return null; } };
const support = () => {
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
const standalone = window.matchMedia("(display-mode: standalone)").matches || window.matchMedia("(display-mode: fullscreen)").matches
|| (navigator as Navigator & { standalone?: boolean }).standalone === true;
if (ios && !standalone) return "install" as const;
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window && !!navigator.locks
? "supported" as const : "unsupported" as const;
};
async function postWorker(message: unknown) {
if (!("serviceWorker" in navigator)) return;
// No registration from background sync. A page worker can safely receive only sanitized IDs.
const registration = await navigator.serviceWorker.getRegistration("/");
const worker = registration?.active;
if (!worker) return;
await new Promise<void>((resolve) => {
const channel = new MessageChannel(), timeout = setTimeout(() => { channel.port1.close(); resolve(); }, 1500);
channel.port1.onmessage = () => { clearTimeout(timeout); channel.port1.close(); resolve(); };
worker.postMessage(message, [channel.port2]);
});
}
export async function syncAgentNotificationRead(read: { sourceId?: string | null; cursor: string }) {
if (!getAuthToken() || !agentPushSourceId(read.sourceId) || !agentPushCursor(read.cursor)) return;
await postWorker({ type: "sybil-agent-read", version: 1, sourceId: read.sourceId, cursor: BigInt(read.cursor).toString() }).catch(() => undefined);
}
function decodePublicKey(value: string): Uint8Array<ArrayBuffer> {
const binary = atob(value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4));
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
const controller = createAgentPushController({
api, read: readConsent, scope: consentScope,
write: value => { localStorage.setItem(AGENT_PUSH_STORAGE_KEY, JSON.stringify(value)); },
remove: () => localStorage.removeItem(AGENT_PUSH_STORAGE_KEY),
uuid: browserUuid,
lock: async operation => {
if (!navigator.locks) return await operation(); // Cleanup only; support() disables opt-in.
const controller = new AbortController(), timeout = setTimeout(() => controller.abort(), 12000);
try { return await navigator.locks.request("sybil-agent-push-installation-v1", { signal: controller.signal }, operation); }
finally { clearTimeout(timeout); }
},
changed: state => { for (const listener of listeners) listener(state); },
platform: {
support, permission: () => "Notification" in window ? Notification.permission : "denied",
requestPermission: () => Notification.requestPermission(),
subscription: async () => {
if (!("serviceWorker" in navigator)) return null;
const registration = await bounded(navigator.serviceWorker.getRegistration("/"));
const subscription = registration ? await bounded(registration.pushManager.getSubscription()) : null;
return subscription ? subscriptionHandle(subscription) : null;
},
subscribe: async key => subscriptionHandle(await bounded((await ensureServiceWorker()).pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: decodePublicKey(key) }))),
clearNotifications: () => postWorker({ type: "sybil-agent-logout", version: 1 }),
},
});
export const getAgentPushState = () => controller.state();
export function observeAgentPush(listener: (state: AgentPushView) => void) { listeners.add(listener); listener(controller.state()); return () => { listeners.delete(listener); }; }
export const enableAgentBrowserPush = () => controller.enable();
export const disableAgentBrowserPush = () => controller.disable();
export const refreshAgentBrowserPush = () => controller.refresh();
const presence = createAgentPresenceReporter({
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
});
let stopSession: (() => void) | undefined;
export async function revokeAgentBrowserPush() {
stopSession?.();
// Revoke/mark the durable tombstone before the caller clears the bearer token.
const id = getAgentPresenceSessionId();
await Promise.allSettled([readConsent() ? controller.disable() : postWorker({ type: "sybil-agent-logout", version: 1 }), ...(id ? [api.retirePresence(id)] : [])]);
}
/** Mounted from shared authentication so /search, every chat and every tab participate. */
export function startAgentBrowserSession() {
stopSession?.();
let active = true, refreshing = false, interactionTimer: ReturnType<typeof setTimeout> | undefined;
const report = (keepalive = false, hidden = false) => { if (active) void presence.report(keepalive, hidden).catch(() => undefined); };
const refresh = async () => {
if (!active || refreshing) return;
refreshing = true;
try {
await controller.refresh();
if (active && document.visibilityState === "visible") {
const read = await api.readState();
if (active && read) await syncAgentNotificationRead(read);
}
} catch { /* Absence of an acknowledged source/read never closes notifications. */ }
finally { refreshing = false; }
};
const attention = () => { report(); if (document.visibilityState === "visible") void refresh(); };
const interaction = (event: Event) => {
if (!event.isTrusted) return;
presence.interact();
if (!interactionTimer) {
report();
interactionTimer = setTimeout(() => { interactionTimer = undefined; report(); }, 1000);
}
};
const hide = () => report(true, true);
const storage = (event: StorageEvent) => { if (event.key === AGENT_PUSH_STORAGE_KEY) void refresh(); };
const workerMessage = (event: MessageEvent) => { if (["sybil-push-subscription-changed", "sybil-push-shown"].includes(event.data?.type)) void refresh(); };
const heartbeat = setInterval(() => { report(); void refresh(); }, 15000);
window.addEventListener("focus", attention); window.addEventListener("blur", attention);
window.addEventListener("pageshow", attention); window.addEventListener("online", attention);
window.addEventListener("pagehide", hide); window.addEventListener("storage", storage);
document.addEventListener("visibilitychange", attention);
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.addEventListener(type, interaction, { passive: true, capture: true });
navigator.serviceWorker?.addEventListener("message", workerMessage);
report(); void refresh();
const stop = () => {
if (!active) return;
active = false; clearInterval(heartbeat); clearTimeout(interactionTimer);
window.removeEventListener("focus", attention); window.removeEventListener("blur", attention);
window.removeEventListener("pageshow", attention); window.removeEventListener("online", attention);
window.removeEventListener("pagehide", hide); window.removeEventListener("storage", storage);
document.removeEventListener("visibilitychange", attention);
for (const type of ["pointerdown", "keydown", "touchstart", "wheel", "input"]) window.removeEventListener(type, interaction, true);
navigator.serviceWorker?.removeEventListener("message", workerMessage);
if (stopSession === stop) stopSession = undefined;
// On unmount preserve the final lease until expiry; a late PUT cannot undo a DELETE.
};
stopSession = stop;
return stop;
}

Some files were not shown because too many files have changed in this diff Show More