Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e33adb8bbf | ||
|
|
60a35509d4 | ||
|
|
a1c0264d36 | ||
|
|
80748f764f | ||
|
|
2e62bfa736 | ||
|
|
844f48e9e0 | ||
|
|
8b125c13a5 | ||
|
|
4cbab487d0 | ||
|
|
56279fba7f | ||
|
|
153aa5d2fa | ||
|
|
4389f805f4 | ||
|
|
e83281c400 | ||
|
|
ef3d79c428 | ||
|
|
dd6cd1ea7b | ||
|
|
3f9c1409fe | ||
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f |
Vendored
+7
-1
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 |
@@ -1,6 +1,7 @@
|
||||
import Sybil
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
@main
|
||||
struct SybilApp: App
|
||||
@@ -18,11 +19,13 @@ struct SybilApp: App
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
final class SybilAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
SybilPushNotifications.launchFixtureIfRequested()
|
||||
SybilHomeScreenQuickActionHandler.configureQuickActions()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
SybilMacQuickQuestionController.shared.start()
|
||||
@@ -30,6 +33,31 @@ final class SybilAppDelegate: NSObject, UIApplicationDelegate {
|
||||
return true
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
SybilPushNotifications.didRegister(deviceToken: deviceToken)
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
||||
SybilPushNotifications.didFailToRegister()
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
SybilPushNotifications.receiveBackground(userInfo: userInfo) { result in completionHandler(result) }
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
|
||||
let event = SybilPushNotifications.Event(userInfo: notification.request.content.userInfo)
|
||||
return await SybilPushNotifications.presentationOptions(event: event)
|
||||
}
|
||||
|
||||
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
|
||||
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
let event = SybilPushNotifications.Event(userInfo: response.notification.request.content.userInfo)
|
||||
await SybilPushNotifications.didRespond(event: event, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
func applicationWillTerminate(_ application: UIApplication) {
|
||||
SybilMacQuickQuestionController.shared.stop()
|
||||
@@ -73,6 +101,9 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
|
||||
if let shortcutItem = connectionOptions.shortcutItem {
|
||||
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
|
||||
}
|
||||
if let response = connectionOptions.notificationResponse, response.actionIdentifier == UNNotificationDefaultActionIdentifier {
|
||||
SybilPushNotifications.didRespond(userInfo: response.notification.request.content.userInfo, identifier: response.notification.request.identifier)
|
||||
}
|
||||
}
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,6 +5,12 @@ targets:
|
||||
- iOS
|
||||
- macCatalyst
|
||||
deploymentTarget: "18.0"
|
||||
attributes:
|
||||
SystemCapabilities:
|
||||
com.apple.Push:
|
||||
enabled: 1
|
||||
com.apple.BackgroundModes:
|
||||
enabled: 1
|
||||
sources:
|
||||
- Sources
|
||||
- Resources
|
||||
@@ -25,6 +31,9 @@ targets:
|
||||
PRODUCT_MODULE_NAME: SybilApp
|
||||
DEVELOPMENT_TEAM: DQQH5H6GBD
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Apps/Sybil/Sybil.entitlements
|
||||
"CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]": Apps/Sybil/Sybil.entitlements
|
||||
INFOPLIST_KEY_SybilAPNSEnvironment: "$(SYBIL_APNS_ENVIRONMENT)"
|
||||
SWIFT_VERSION: 6.0
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
|
||||
TARGETED_DEVICE_FAMILY: "1,2,6"
|
||||
@@ -41,7 +50,10 @@ targets:
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
|
||||
configs:
|
||||
Debug:
|
||||
SYBIL_APNS_ENVIRONMENT: development
|
||||
Release:
|
||||
SYBIL_APNS_ENVIRONMENT: production
|
||||
CODE_SIGN_STYLE: Manual
|
||||
CODE_SIGN_IDENTITY: Apple Distribution
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "character-still.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
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("<script>"))
|
||||
}
|
||||
|
||||
@Test func literalLineBreaksAndParagraphsStayDistinct() {
|
||||
let content = SybilMessageText.literalContent("First\nSecond\n\nThird")
|
||||
#expect(content.renderPlainText() == "First\nSecond\n\nThird")
|
||||
#expect(content.renderHTML() == "<p>First\nSecond</p>\n<p>Third</p>\n")
|
||||
// Empty paragraphs retain layout space instead of disappearing.
|
||||
#expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "<p>").count == 6)
|
||||
}
|
||||
|
||||
@Test func pastedParagraphBoundariesMatchMarkdown() {
|
||||
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
|
||||
#expect(SybilMessageText.literalContent(source).renderHTML() == "<p>First</p>\n<p>Second</p>\n")
|
||||
}
|
||||
#expect(SybilMessageText.literalContent("First\r\nSecond").renderPlainText() == "First\nSecond")
|
||||
#expect(SybilMessageText.literalContent(" **literal** \n\nLast").renderPlainText() == " **literal** \n\nLast")
|
||||
#expect(SybilMessageText.literalContent("First\r\n \r\n\t\r\n\r\nLast") == SybilMessageText.literalContent("First\n\n\n\nLast"))
|
||||
}
|
||||
|
||||
@Test func bothFormatsShareLineParagraphMetricsAndDynamicType() {
|
||||
func height(_ content: String, _ format: SybilMessageText.Format, _ size: DynamicTypeSize) -> CGFloat {
|
||||
let host = UIHostingController(rootView: SybilMessageText(content: content, format: format)
|
||||
.environment(\.dynamicTypeSize, size))
|
||||
return host.sizeThatFits(in: CGSize(width: 320, height: 10_000)).height
|
||||
}
|
||||
let literal = "First line\nSecond line\n\nAnother paragraph"
|
||||
let markdown = "First line \nSecond line\n\nAnother paragraph"
|
||||
for size in [DynamicTypeSize.large, .accessibility3] {
|
||||
#expect(abs(height(literal, .literal, size) - height(markdown, .markdown, size)) < 1)
|
||||
}
|
||||
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
|
||||
#expect(abs(height(source, .literal, .large) - height(source, .markdown, .large)) < 1)
|
||||
}
|
||||
#expect(height(literal, .literal, .accessibility3) > height(literal, .literal, .large) * 1.5)
|
||||
#expect(height(markdown, .markdown, .accessibility3) > height(markdown, .markdown, .large) * 1.5)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+181
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { createECDH } from 'node:crypto';
|
||||
import { env } from './env.js';
|
||||
import { validateAPNsKey } from './agent-push-contract.js';
|
||||
|
||||
export type PushConfig = { enabled: boolean; adminToken?: string; apns?: { key: string; keyId: string; teamId: string; topic: string; environment: 'sandbox' | 'production' };
|
||||
web?: { publicKey: string; privateKey: string; subject: string } };
|
||||
function secretFile(path: string): string {
|
||||
const stat = statSync(path);
|
||||
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error('Unsafe push secret file');
|
||||
return readFileSync(path, 'utf8').trim();
|
||||
}
|
||||
/** Never log config objects or secret-file read/crypto errors. Invalid credentials disable their adapter. */
|
||||
export function loadPushConfig(): PushConfig {
|
||||
const result: PushConfig = { enabled: env.AGENT_PUSH_ENABLED && Boolean(env.ADMIN_TOKEN), adminToken: env.ADMIN_TOKEN };
|
||||
if (!result.enabled) return result;
|
||||
try {
|
||||
if (env.AGENT_PUSH_APNS_KEY_FILE && env.AGENT_PUSH_APNS_KEY_ID && env.AGENT_PUSH_APNS_TEAM_ID) {
|
||||
const key = secretFile(env.AGENT_PUSH_APNS_KEY_FILE); validateAPNsKey(key);
|
||||
result.apns = { key, keyId: env.AGENT_PUSH_APNS_KEY_ID, teamId: env.AGENT_PUSH_APNS_TEAM_ID,
|
||||
topic: env.AGENT_PUSH_APNS_TOPIC, environment: env.AGENT_PUSH_APNS_ENVIRONMENT };
|
||||
}
|
||||
} catch { /* Unavailable, fail closed. Public config exposes only the availability bit. */ }
|
||||
try {
|
||||
if (env.AGENT_PUSH_VAPID_FILE) {
|
||||
const value = JSON.parse(secretFile(env.AGENT_PUSH_VAPID_FILE));
|
||||
const curve = createECDH('prime256v1'); curve.setPrivateKey(Buffer.from(value.privateKey, 'base64url'));
|
||||
if (curve.getPublicKey().toString('base64url') !== value.publicKey || typeof value.subject !== 'string'
|
||||
|| !/^(mailto:[^\s@]+@[^\s@]+|https:\/\/[^\s]+)$/.test(value.subject)) throw new Error();
|
||||
result.web = { publicKey: value.publicKey, privateKey: value.privateKey, subject: value.subject };
|
||||
}
|
||||
} catch { /* Unavailable; never emit credential parse errors. */ }
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createHash, createPublicKey, ECDH } from 'node:crypto';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 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');
|
||||
}
|
||||
@@ -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' };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() };
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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", []), {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { getConfiguredToken, setAuthToken, verifySession } from "@/lib/api";
|
||||
import { revokeAgentBrowserPush, startAgentBrowserSession } from "@/lib/agent-push-browser";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "sybil_admin_token";
|
||||
|
||||
@@ -18,8 +19,11 @@ function persistToken(token: string | null) {
|
||||
}
|
||||
|
||||
function normalizeAuthError(message: string) {
|
||||
if (message.includes("missing bearer token") || message.includes("invalid bearer token")) {
|
||||
return "Authentication failed. Enter the ADMIN_TOKEN configured in server/.env.";
|
||||
if (message.includes("invalid bearer token")) {
|
||||
return "Incorrect token. Please try again.";
|
||||
}
|
||||
if (message.includes("missing bearer token")) {
|
||||
return "Enter your admin token.";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
@@ -43,10 +47,11 @@ export function useSessionAuth() {
|
||||
persistToken(tokenCandidate);
|
||||
};
|
||||
|
||||
const handleAuthFailure = (message: string) => {
|
||||
const handleAuthFailure = (message: string | null) => {
|
||||
void revokeAgentBrowserPush();
|
||||
setIsAuthenticated(false);
|
||||
setAuthMode(null);
|
||||
setAuthError(normalizeAuthError(message));
|
||||
setAuthError(message ? normalizeAuthError(message) : null);
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
};
|
||||
@@ -66,7 +71,8 @@ export function useSessionAuth() {
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const logout = async () => {
|
||||
await revokeAgentBrowserPush();
|
||||
setAuthToken(null);
|
||||
persistToken(null);
|
||||
setIsAuthenticated(false);
|
||||
@@ -74,6 +80,18 @@ export function useSessionAuth() {
|
||||
setAuthError(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || authMode !== "token") return;
|
||||
return startAgentBrowserSession();
|
||||
}, [isAuthenticated, authMode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Signing out in one tab retires the others instead of using a stale token.
|
||||
const signedOut = (event: StorageEvent) => { if (event.key === TOKEN_STORAGE_KEY && !event.newValue) handleAuthFailure(null); };
|
||||
window.addEventListener("storage", signedOut);
|
||||
return () => window.removeEventListener("storage", signedOut);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = readStoredToken() ?? getConfiguredToken();
|
||||
void (async () => {
|
||||
@@ -81,7 +99,8 @@ export function useSessionAuth() {
|
||||
await completeSessionCheck(token);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
handleAuthFailure(message);
|
||||
// A first visit without a token is an invitation to sign in, not a failed attempt.
|
||||
handleAuthFailure(!token && message.includes("missing bearer token") ? null : message);
|
||||
} finally {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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
@@ -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%);
|
||||
|
||||
@@ -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. */ } },
|
||||
};
|
||||
}
|
||||
@@ -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 })); },
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user