Compare commits

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

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

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

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

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
2026-10-14 12:20:11 -07:00
buzzert dd6cd1ea7b Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
2026-10-07 22:05:49 -07:00
buzzert 3f9c1409fe ios: remove animated workspace character
TestFlight / Build and upload (push) Successful in 1m40s
2026-10-04 21:48:45 -07:00
buzzert 411a8cebc7 Expose chat-latest and default Quick Question to it
TestFlight / Build and upload (push) Successful in 1m54s
2026-10-02 13:56:44 -07:00
buzzert 14191de8f4 No rounded corners 2026-09-30 21:30:54 -07:00
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
buzzert 3a9c9bb74a ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s
2026-09-06 14:03:30 -07:00
buzzert 04ac39499f ios: submit quick questions with return 2026-09-06 13:36:18 -07:00
91 changed files with 9572 additions and 474 deletions
+7 -1
View File
@@ -1,3 +1,8 @@
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
listen 80;
server_name _;
@@ -19,7 +24,8 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location = /sw.js {
+137 -2
View File
@@ -23,6 +23,8 @@ Chat upload limits:
### `GET /v1/auth/session`
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`).
- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass.
## Models
@@ -31,7 +33,7 @@ Chat upload limits:
```json
{
"providers": {
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
@@ -39,7 +41,7 @@ Chat upload limits:
}
}
```
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
@@ -81,6 +83,8 @@ Behavior notes:
### `GET /v1/workspace-items`
- Response: `{ "items": WorkspaceItem[] }`
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
```json
{
@@ -123,6 +127,15 @@ Behavior notes:
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
History search (`q`) notes:
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
## Chats
### `GET /v1/chats`
@@ -551,3 +564,125 @@ 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" }`.
Initially: `{ "cursor": "0", "updatedAt": null }`. PUT takes
`{ "cursor": "43" }` and returns the same 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`.
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.
+100 -1
View File
@@ -1,6 +1,6 @@
# Streaming Chat API Contract
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions.
This document defines the server-sent events (SSE) contract for chat completions and Quick Questions, and the WebSocket contract for the persistent Agent room.
Endpoint:
- `POST /v1/chat-completions/stream`
@@ -15,6 +15,7 @@ Transport:
Authentication:
- Same as REST endpoints (`Authorization: Bearer <token>` when token mode is enabled)
- The web client checks [`GET /v1/auth/session`](rest.md#get-v1authsession) before opening streams. An initial missing-token response prompts sign-in without an error; an incorrect supplied token is an authentication failure. The web sign-in form has no tokenless bypass; open development servers still authenticate automatically. Stream authorization requirements are unchanged.
## Chat Completion Request Body
@@ -109,6 +110,7 @@ Request body:
```
Behavior notes:
- `provider` and `model` are required. Web and native clients default Quick Question to `provider: "openai"` and `model: "chat-latest"` when no Quick Question selection has been saved. Saved Quick Question choices take precedence over this default and are separate from regular chat preferences.
- `question` is required, trimmed by the server, and must not be empty.
- The server prepends a Quick Question system prompt that asks for a succinct, direct, self-contained answer without follow-up questions. Clients do not send or maintain this prompt.
- Quick Questions are always non-persistent. The endpoint does not create a chat or store messages, tool-call logs, assistant output, or `LlmCall` metadata.
@@ -274,3 +276,100 @@ 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+2 -1
View File
@@ -24,7 +24,8 @@ let package = Package(
name: "Sybil",
dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui")
]),
],
resources: [.process("Resources")]),
.testTarget(
name: "SybilTests",
dependencies: ["Sybil"]
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "character-still.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -106,6 +106,9 @@ public struct SplitView: View {
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: isQuickQuestionPresented) { _, presented in
viewModel.agent.isModalPresented = presented
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -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 {
private let configuration: APIConfiguration
private let session: URLSession
@@ -40,6 +41,115 @@ 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 agentMarkRead(cursor: String) async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(["cursor": try AgentCursor.normalize(cursor)]), responseType: AgentReadState.self)
}
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 +159,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 +193,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 +409,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 +423,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 +437,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 +468,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 +495,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 +506,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 +843,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,239 @@
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: View {
@Bindable var room: SybilAgentRoom
@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)
}
if room.attachmentDraft.supportsAttachments {
Button { pickerSession = room.attachmentSessionID; showChoices = true } label: { Label("Attach", systemImage: "paperclip") }
.font(.sybil(.caption, weight: .semibold)).accessibilityIdentifier("agent-attach")
.disabled(room.attachmentDraft.items.count >= (room.attachmentDraft.capability?.countLimit ?? 0))
}
}
.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,263 @@
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?
}
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 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 "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,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,451 @@
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 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) }
}
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 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)
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
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(); if isActive { start() } }
private func start() {
guard 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.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 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 {
let before = pending
var next = pending
switch event {
case let .messages(page): try next.apply(page)
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 .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 {
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, read.cursor)
pending.readCursor = acknowledgedRead
schedulePublish()
}
func markRead(renderedCursor: String) {
guard hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
private func flushRead() {
guard let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
let run = generation
readTask = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.readTask = nil } }
do {
while self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
let captured = self.desiredRead
let read = try await client.agentMarkRead(cursor: captured)
guard self.generation == run, !Task.isCancelled else { return }
guard !AgentCursor.less(try AgentCursor.normalize(read.cursor), captured) else {
throw AgentFailure(message: "Agent read state was not acknowledged.")
}
try self.applyRead(read)
}
} catch { if self.generation == run { 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 {
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 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,90 @@
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 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"),
"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 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,326 @@
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 {
VStack(alignment: .leading, spacing: 8) {
AgentAttachmentComposer(room: room)
HStack(alignment: .bottom, spacing: 10) {
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,19 @@ 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)
if isUser {
Text(message.content)
.font(.sybil(.body))
} else {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
}
}
.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 +253,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)
@@ -562,7 +611,7 @@ private struct ToolCallStackCard: View {
}
}
private struct ToolCallActivityChip: View {
struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
@@ -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]
}
@@ -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
}
@@ -64,6 +66,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 +90,10 @@ 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: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -130,7 +137,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 +249,7 @@ struct SybilPhoneShellView: View {
}
private func showRoute(_ nextRoute: PhoneRoute) {
dismissSidebarKeyboard()
let update = {
route = nextRoute
}
@@ -268,12 +279,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 +529,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 +608,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 +617,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 +638,8 @@ private struct SybilPhoneDestinationView: View {
return
}
viewModel.select(.search(searchID))
case .agent:
viewModel.select(.agent)
case .draftChat:
viewModel.startNewChat()
case .draftSearch:
@@ -0,0 +1,62 @@
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")
}
}
@@ -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 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,7 @@ private actor SearchStreamStatus {
@Observable
final class SybilViewModel {
let settings: SybilSettingsStore
let agent = SybilAgentRoom()
var isCheckingSession = true
var isAuthenticated = false
@@ -161,6 +166,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 +184,7 @@ final class SybilViewModel {
var isLoadingCollections = false
var isLoadingSelection = false
var isCreatingSearchChat = false
var isForkingChat = false
var chatBottomPinRequestID = 0
var errorMessage: String?
@@ -221,7 +236,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"],
@@ -328,6 +343,8 @@ final class SybilViewModel {
}
return "Search"
case .agent:
return "Sybil Agent"
case .settings:
return "Settings"
}
@@ -367,6 +384,7 @@ final class SybilViewModel {
}
var showsComposer: Bool {
if selectedItem == .agent { return false }
if case .settings = selectedItem {
return false
}
@@ -481,7 +499,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 +525,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 +537,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 +550,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 +624,7 @@ final class SybilViewModel {
switch selectedItem {
case .chat, .search:
return true
case .settings:
case .settings, .agent:
return false
}
}
@@ -558,6 +640,8 @@ final class SybilViewModel {
}
func reconnect() async {
agent.disconnect()
setSidebarSearchQuery("")
isCheckingSession = true
authError = nil
errorMessage = nil
@@ -595,6 +679,10 @@ final class SybilViewModel {
)
await loadInitialData(using: client)
if isAuthenticated, let agentClient = client as? any AgentAPIClienting, let baseURL = settings.normalizedAPIBaseURL {
agent.configure(client: agentClient, storage: .file(baseURL: baseURL), draftStorage: .file(baseURL: baseURL))
agent.setActive(isAppActive)
}
startActiveRunPolling()
} catch {
stopActiveRunPolling()
@@ -864,6 +952,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 +993,7 @@ final class SybilViewModel {
selectedChat = nil
composerAttachments = []
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
return nil
@@ -914,7 +1011,7 @@ final class SybilViewModel {
}
private func selectAdjacentSidebarItem(offset: Int) {
let items = sidebarItems
let items = visibleSidebarItems
guard !items.isEmpty else {
return
}
@@ -934,6 +1031,7 @@ final class SybilViewModel {
}
func deleteItem(_ selection: SidebarSelection) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -946,10 +1044,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 +1057,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 +1104,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 +1112,7 @@ final class SybilViewModel {
}
func setItemStarred(_ selection: SidebarSelection, starred: Bool) async {
guard selection != .agent else { return }
guard isAuthenticated else {
return
}
@@ -999,7 +1130,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 {
@@ -1022,12 +1153,14 @@ final class SybilViewModel {
}
isAppActive = false
agent.setActive(false)
appLifecycleGeneration += 1
SybilLog.debug(SybilLog.app, "App became inactive for network lifecycle generation \(appLifecycleGeneration)")
}
func markAppActiveForNetwork() {
isAppActive = true
agent.setActive(true)
}
func refreshAfterAppBecameActive(refreshCollections shouldRefreshCollections: Bool, refreshSelection shouldRefreshSelection: Bool) async {
@@ -1074,6 +1207,10 @@ final class SybilViewModel {
}
func refreshSidebarCollectionsFromPullToRefresh() async {
if isSidebarSearchActive {
await refreshSidebarSearch()
return
}
guard isAuthenticated, !isCheckingSession else {
return
}
@@ -1351,7 +1488,7 @@ final class SybilViewModel {
if let nextSelection {
switch nextSelection {
case .settings:
case .settings, .agent:
selectedChat = nil
selectedSearch = nil
case .chat, .search:
@@ -1414,7 +1551,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 +1737,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 +1765,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 +1799,7 @@ final class SybilViewModel {
await self?.attachToActiveSearchStream(searchID: searchID)
}
case .settings, nil:
case .settings, .agent, nil:
return
}
}
@@ -1855,12 +2008,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 +2056,7 @@ final class SybilViewModel {
selectedSearch = search
selectedChat = nil
case .settings:
case .settings, .agent:
break
}
errorMessage = nil
@@ -2618,7 +2772,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
@@ -589,47 +613,19 @@ struct SybilWorkspaceView: View {
.accessibilityLabel("Attach file")
}
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 +1346,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,482 @@
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 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)
}
func pauseRead() { holdRead = true }
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read)); heldRead = nil }
func hasHeldRead() -> Bool { heldRead != nil }
func agentMarkRead(cursor: String) throws -> AgentReadState {
if failRead { throw AgentFailure(message: "Offline") }
read = try AgentCursor.advance(read, cursor)
return AgentReadState(cursor: read)
}
func 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 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)
}
}
@@ -9,10 +9,14 @@ import UIKit
private struct MockClientCallSnapshot: Sendable {
var listWorkspaceItems = 0
var workspaceSearchQueries: [String] = []
var listChats = 0
var listSearches = 0
var createChat = 0
var getChat = 0
var forkChat = 0
var lastForkChatID: String?
var lastForkMessageID: String?
var updateChatTitle = 0
var suggestChatTitle = 0
var updateChatStar = 0
@@ -40,14 +44,17 @@ private actor MockSybilClient: SybilAPIClienting {
private let chatsResponse: [ChatSummary]
private let searchesResponse: [SearchSummary]
private let workspaceItemsResponse: [WorkspaceItem]
private let workspaceSearchHandler: @Sendable (String) async throws -> WorkspaceSearchResponse
private let chatDetails: [String: ChatDetail]
private let searchDetails: [String: SearchDetail]
private let createChatResponse: ChatSummary?
private let forkChatResponse: ChatSummary?
private let updateChatTitleResponses: [String: ChatSummary]
private let suggestChatTitleResponses: [String: ChatSummary]
private let updateChatStarResponses: [String: ChatSummary]
private let updateSearchStarResponses: [String: SearchSummary]
private let activeRunsResponse: ActiveRunsResponse
private let modelCatalogResponse: ModelCatalogResponse
private var snapshot = MockClientCallSnapshot()
private var lastCreateChatCall: ChatCreateCallSnapshot?
@@ -74,24 +81,32 @@ private actor MockSybilClient: SybilAPIClienting {
chatDetails: [String: ChatDetail] = [:],
searchDetails: [String: SearchDetail] = [:],
createChatResponse: ChatSummary? = nil,
forkChatResponse: ChatSummary? = nil,
updateChatTitleResponses: [String: ChatSummary] = [:],
suggestChatTitleResponses: [String: ChatSummary] = [:],
updateChatStarResponses: [String: ChatSummary] = [:],
updateSearchStarResponses: [String: SearchSummary] = [:],
activeRunsResponse: ActiveRunsResponse = ActiveRunsResponse(),
workspaceItemsResponse: [WorkspaceItem]? = nil
workspaceItemsResponse: [WorkspaceItem]? = nil,
workspaceSearchHandler: @escaping @Sendable (String) async throws -> WorkspaceSearchResponse = { _ in
WorkspaceSearchResponse(items: [], hasMore: false)
},
modelCatalogResponse: ModelCatalogResponse = ModelCatalogResponse(providers: [:])
) {
self.chatsResponse = chatsResponse
self.searchesResponse = searchesResponse
self.workspaceItemsResponse = workspaceItemsResponse ?? Self.makeWorkspaceItems(chats: chatsResponse, searches: searchesResponse)
self.workspaceSearchHandler = workspaceSearchHandler
self.chatDetails = chatDetails
self.searchDetails = searchDetails
self.createChatResponse = createChatResponse
self.forkChatResponse = forkChatResponse
self.updateChatTitleResponses = updateChatTitleResponses
self.suggestChatTitleResponses = suggestChatTitleResponses
self.updateChatStarResponses = updateChatStarResponses
self.updateSearchStarResponses = updateSearchStarResponses
self.activeRunsResponse = activeRunsResponse
self.modelCatalogResponse = modelCatalogResponse
}
private static func makeWorkspaceItems(chats: [ChatSummary], searches: [SearchSummary]) -> [WorkspaceItem] {
@@ -177,6 +192,11 @@ private actor MockSybilClient: SybilAPIClienting {
return workspaceItemsResponse
}
func searchWorkspaceItems(query: String) async throws -> WorkspaceSearchResponse {
snapshot.workspaceSearchQueries.append(query)
return try await workspaceSearchHandler(query)
}
func listChats() async throws -> [ChatSummary] {
snapshot.listChats += 1
if listChatsDelayNanoseconds > 0 {
@@ -220,6 +240,16 @@ private actor MockSybilClient: SybilAPIClienting {
return detail
}
func forkChat(chatID: String, messageID: String?) async throws -> ChatSummary {
snapshot.forkChat += 1
snapshot.lastForkChatID = chatID
snapshot.lastForkMessageID = messageID
guard let forkChatResponse else {
throw UnexpectedClientCall()
}
return forkChatResponse
}
func updateChatTitle(chatID: String, title: String) async throws -> ChatSummary {
snapshot.updateChatTitle += 1
guard let summary = updateChatTitleResponses[chatID] else {
@@ -290,7 +320,7 @@ private actor MockSybilClient: SybilAPIClienting {
}
func listModels() async throws -> ModelCatalogResponse {
ModelCatalogResponse(providers: [:])
modelCatalogResponse
}
func getActiveRuns() async throws -> ActiveRunsResponse {
@@ -775,6 +805,63 @@ private func makeCompletionToolCall(
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func wholeChatForkAddsReturnedChatToWorkspace() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_160)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "chat-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of Chat chat-source"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
viewModel.selectedItem = .chat(source.id)
let result = await viewModel.forkChat(chatID: source.id)
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == nil)
#expect(result == forked)
#expect(viewModel.chats.first == forked)
#expect(viewModel.workspaceItems.first == WorkspaceItem(chat: forked))
#expect(viewModel.selectedItem == .chat(source.id))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func responseForkPassesAssistantMessageID() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_165)
let source = makeChatSummary(id: "chat-source", date: date)
var forked = makeChatSummary(id: "response-fork", date: date.addingTimeInterval(10))
forked.title = "Fork of 'A useful response'"
forked.parentChatId = source.id
forked.titleGenerationPending = true
let client = MockSybilClient(forkChatResponse: forked)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.chats = [source]
viewModel.workspaceItems = [WorkspaceItem(chat: source)]
let result = await viewModel.forkChat(chatID: source.id, messageID: "assistant-message")
let snapshot = await client.currentSnapshot()
#expect(snapshot.forkChat == 1)
#expect(snapshot.lastForkChatID == source.id)
#expect(snapshot.lastForkMessageID == "assistant-message")
#expect(result == forked)
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat(forked.id) }))
#expect(!viewModel.isForkingChat)
#expect(viewModel.errorMessage == nil)
}
@MainActor
@Test func starringItemsUpdatesSidebarState() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_175)
@@ -1184,6 +1271,7 @@ private func makeCompletionToolCall(
#expect(snapshot.runQuickQuestionStream == 1)
#expect(snapshot.runCompletionStream == 0)
#expect(body?.provider == .openai)
#expect(body?.model == "chat-latest")
#expect(body?.question == "How do I reset my password?")
#expect(viewModel.quickQuestionAnswerText == "Reset it from Settings.")
#expect(!viewModel.isQuickQuestionSending)
@@ -1297,6 +1385,42 @@ private func makeCompletionToolCall(
#expect(viewModel.quickQuestionPrompt.isEmpty)
}
@MainActor
@Test func quickQuestionDefaultsToChatLatestIndependentlyOfRegularChatPreferences() async {
let defaults = UserDefaults(suiteName: #function)!
defaults.removePersistentDomain(forName: #function)
defer { defaults.removePersistentDomain(forName: #function) }
defaults.set("anthropic", forKey: "sybil.ios.preferredProvider")
defaults.set("gpt-4o", forKey: "sybil.ios.preferredOpenAIModel")
let settings = SybilSettingsStore(defaults: defaults)
let client = MockSybilClient(modelCatalogResponse: ModelCatalogResponse(providers: [
.openai: ProviderModelInfo(models: ["gpt-4.1-mini", "chat-latest"], loadedAt: nil, error: nil),
.anthropic: ProviderModelInfo(models: ["claude-3-5-sonnet-latest"], loadedAt: nil, error: nil)
]))
let viewModel = SybilViewModel(settings: settings) { _ in client }
#expect(viewModel.provider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
#expect(viewModel.quickQuestionProvider == .openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions.contains("chat-latest"))
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
await viewModel.bootstrap()
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(viewModel.quickQuestionProviderModelOptions == ["gpt-4.1-mini", "chat-latest"])
viewModel.setQuickQuestionProvider(.anthropic)
viewModel.setQuickQuestionProvider(.openai)
#expect(viewModel.quickQuestionModel == "chat-latest")
#expect(SybilSettingsStore(defaults: defaults).quickQuestionPreferredModelByProvider[.openai] == "chat-latest")
#expect(settings.preferredProvider == .anthropic)
#expect(settings.preferredModelByProvider[.openai] == "gpt-4o")
}
@MainActor
@Test func quickQuestionProviderAndModelSelectionPersistSeparately() async throws {
let defaults = UserDefaults(suiteName: #function)!
@@ -1885,3 +2009,173 @@ private final class MockQuickQuestionLifecycle {
controller.stop()
}
#endif
// Deliberately ignores cancellation so stale-response protection is exercised.
private actor WorkspaceSearchGate {
private var pending: [String: CheckedContinuation<WorkspaceSearchResponse, any Error>] = [:]
private var observers: [String: CheckedContinuation<Void, Never>] = [:]
func response(for query: String) async throws -> WorkspaceSearchResponse {
try await withCheckedThrowingContinuation { continuation in
pending[query] = continuation
observers.removeValue(forKey: query)?.resume()
}
}
func waitUntilRequested(_ query: String) async {
if pending[query] != nil { return }
await withCheckedContinuation { observers[query] = $0 }
}
func complete(_ query: String, with response: WorkspaceSearchResponse) {
pending.removeValue(forKey: query)?.resume(returning: response)
}
func fail(_ query: String) {
pending.removeValue(forKey: query)?.resume(throwing: APIError.networkError(message: "Offline"))
}
}
@MainActor
@Test func historySearchKeepsRecentItemsAndOpensOlderChat() async throws {
let date = Date(timeIntervalSince1970: 1_700_000_000)
let recent = makeChatSummary(id: "recent", date: date)
let old = makeChatSummary(id: "old", date: date.addingTimeInterval(-100))
var match = WorkspaceItem(chat: old)
match.searchSnippet = "The nebula we discussed"
let response = WorkspaceSearchResponse(items: [match, WorkspaceItem(search: makeSearchSummary(id: "web", date: date))], hasMore: true)
let client = MockSybilClient(
chatsResponse: [recent],
chatDetails: ["old": makeChatDetail(id: "old", date: date, body: "The nebula we discussed")],
workspaceSearchHandler: { _ in response }
)
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.isCheckingSession = false
viewModel.workspaceItems = [WorkspaceItem(chat: recent)]
viewModel.chats = [recent]
viewModel.selectedItem = .chat("recent")
viewModel.setSidebarSearchQuery(" nebula ")
await viewModel.refreshSidebarSearch()
#expect(viewModel.selectedItem == .chat("recent"))
#expect(viewModel.workspaceItems == [WorkspaceItem(chat: recent)])
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("old"), .search("web")])
#expect(viewModel.visibleSidebarItems.first?.searchSnippet == "The nebula we discussed")
#expect(viewModel.sidebarSearchHasMore)
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
await viewModel.selectForNavigation(.chat("old"))
#expect(viewModel.selectedChat?.id == "old")
viewModel.setSidebarSearchQuery("")
await viewModel.refreshVisibleContent(refreshCollections: true, refreshSelection: false)
#expect(viewModel.selectedItem == .chat("old"))
#expect(viewModel.selectedChat?.id == "old")
#expect(viewModel.sidebarItems.contains(where: { $0.selection == .chat("old") }))
}
@MainActor
@Test func historySearchDebouncesAndClearingRestoresRecentItems() async throws {
let client = MockSybilClient()
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
let recent = WorkspaceItem(chat: makeChatSummary(id: "recent", date: Date()))
viewModel.workspaceItems = [recent]
viewModel.setSidebarSearchQuery("n")
viewModel.setSidebarSearchQuery("ne")
viewModel.setSidebarSearchQuery("nebula")
#expect(viewModel.isSearchingSidebar)
#expect(await client.currentSnapshot().workspaceSearchQueries.isEmpty)
try await Task.sleep(for: .milliseconds(600))
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula"])
#expect(viewModel.visibleSidebarItems.isEmpty)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery(" ")
#expect(viewModel.visibleSidebarItems.map(\.selection) == [.chat("recent")])
#expect(!viewModel.isSidebarSearchActive)
#expect(viewModel.sidebarSearchError == nil)
}
@MainActor
@Test func historySearchDiscardsOutOfOrderAndClearedResponses() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.setSidebarSearchQuery("first")
let first = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("first")
viewModel.setSidebarSearchQuery("second")
let second = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("second")
let match = WorkspaceItem(chat: makeChatSummary(id: "second", date: Date()))
await gate.complete("second", with: WorkspaceSearchResponse(items: [match], hasMore: false))
await second.value
await gate.fail("first")
await first.value
#expect(viewModel.sidebarSearchResults == [match])
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
viewModel.setSidebarSearchQuery("third")
let third = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("third")
viewModel.setSidebarSearchQuery("")
await gate.complete("third", with: WorkspaceSearchResponse(items: [match], hasMore: true))
await third.value
#expect(viewModel.sidebarSearchResults.isEmpty)
#expect(!viewModel.sidebarSearchHasMore)
#expect(!viewModel.isSearchingSidebar)
}
@MainActor
@Test func historySearchFailureCanRetryWithoutChangingSelection() async throws {
let gate = WorkspaceSearchGate()
let client = MockSybilClient(workspaceSearchHandler: { try await gate.response(for: $0) })
let viewModel = SybilViewModel(settings: testSettings(named: #function)) { _ in client }
viewModel.isAuthenticated = true
viewModel.selectedItem = .chat("current")
viewModel.setSidebarSearchQuery("nebula")
let failed = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.fail("nebula")
await failed.value
#expect(viewModel.sidebarSearchError != nil)
#expect(viewModel.selectedItem == .chat("current"))
let retry = Task { await viewModel.refreshSidebarSearch() }
await gate.waitUntilRequested("nebula")
await gate.complete("nebula", with: WorkspaceSearchResponse(items: [], hasMore: false))
await retry.value
#expect(viewModel.sidebarSearchError == nil)
#expect(!viewModel.isSearchingSidebar)
#expect(viewModel.selectedItem == .chat("current"))
viewModel.setSidebarSearchQuery(String(repeating: "a", count: 201))
#expect(viewModel.sidebarSearchError == "Use 200 characters or fewer.")
#expect(await client.currentSnapshot().workspaceSearchQueries == ["nebula", "nebula"])
}
private final class HistorySearchURLProtocol: URLProtocol, @unchecked Sendable {
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
let url = request.url!
#expect(url.path == "/api/v1/workspace-items")
#expect(request.httpMethod == "GET")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer test-token")
#expect(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems == [URLQueryItem(name: "q", value: "café + AI & #ideas?")])
let json = #"{"items":[{"type":"chat","id":"old","title":"Notes","createdAt":"2026-01-01T00:00:00.000Z","updatedAt":"2026-01-01T00:00:00.000Z","searchSnippet":"Some café ideas"}],"hasMore":false}"#
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data(json.utf8))
client?.urlProtocolDidFinishLoading(self)
}
override func stopLoading() {}
}
@Test func historySearchEncodesQueryAndDecodesSnippet() async throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [HistorySearchURLProtocol.self]
let session = URLSession(configuration: configuration)
defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: APIConfiguration(baseURL: URL(string: "https://example.com/api")!, authToken: "test-token"), session: session)
let response = try await client.searchWorkspaceItems(query: "café + AI & #ideas?")
#expect(response.items.first?.searchSnippet == "Some café ideas")
#expect(!response.hasMore)
}
+149 -2
View File
@@ -5,6 +5,146 @@ Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
release/archive signing settings are unchanged.
The conversation sidebar includes **Search chats and web searches** on iPhone,
iPad, and Mac. It searches all saved history by title, conversation text, and saved
web-search query/answer, with single-line excerpts that bold matching words and
truncate in the middle. Search waits briefly while typing,
cancels superseded requests, and leaves the current conversation open. Clear the
field to return to recent items. Searches use word prefixes (for example, `plan`
finds `planning`); if more than 100 items match, refine the query.
## Sybil Agent
The permanent **Sybil Agent** sidebar item opens the persistent room on both
phone and iPad. It uses the configured Sybil API URL (including an explicit
`/api` path) and token. The room stays subscribed while other chats are selected;
foreground and network recovery replay from the last merged cursor. Shared unread
counts include only public assistant replies and are acknowledged after the
visible transcript reaches the bottom and its entrance animation finishes.
Multiple messages can be queued while the Agent works. The local outbox is saved
atomically under Application Support before submission and is scoped to the API
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
its request ID and timestamp across relaunch. A canonical server echo confirms it
even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
in the Agent transcript, including loaded history and live updates. History windows,
date separators and scroll anchors use visible messages; raw merged history and
cursors remain intact. Send failures, room errors and activity indicators remain
visible. Model selection and tool cards continue to belong to ordinary chats.
Agent contracts remain in `../docs/api/rest.md` and
`../docs/api/streaming-chat.md`. ROOT attachments consume the coordinated v1
contract (2026-10-14), now documented in the integrated API sources above.
Attachment readiness requires the coordinated backend/runtime and existing public
API bearer authentication. This iOS change does not deploy or configure them.
### ROOT attachments
The Agent composer exposes Files and Photo Library only when the authenticated
descriptor advertises supported `capabilities.attachments.version == 1`. Missing,
malformed, or unsupported capability keeps the client text-only. Ordinary chat
attachments retain their existing inline format and behavior.
Open-proxy HTTP 503 / WebSocket 4503 failures disable attachment selection while
retaining draft descriptors and outbox retry identities; reconnect checks support.
Files preserve their original bytes, including PDF, archives, text and HEIC files.
Photo Library HEIC/HEIF selections are intentionally converted to bounded JPEG
visual input with a matching `.jpg` filename. PNG/JPEG/GIF/WebP are validated as
raster images; model input is static, with no claim of document or animation
understanding. Safe original names supplied by the picker are retained; PhotosPicker
may provide an export filename rather than the original Photo Library asset name.
The production app does not request broad Photo Library access.
Selection order is stable. One preparation/upload runs at a time, with per-item
loading, upload, error, retry and removal. Sending requires every item to be ready;
an empty caption can send ready attachments. Advertised limits are checked against
v1 hard ceilings (8 files, 32 MiB total, 6 MiB/image, 8 MiB/file, 50 million pixels,
16,384 pixels/axis). Quota snapshots are decoded but do not reserve space. Server
messages/codes, quota details and Retry-After survive failures, including 409, 413,
429 and 507. Removal cancels local work and removes the reference; it does not
delete an uploaded server original.
The caption, ordered upload UUIDs and ready descriptors are saved in a small atomic
manifest under Application Support, scoped to the API base URL. Prepared bytes are
staged in app-owned files (at most 8 MiB each and 32 MiB per draft); no inline bytes
or tokens enter the outbox. Relaunch restores ready drafts and offers explicit retry
for interrupted staged uploads with the same upload UUID. A selection interrupted
before staging must be removed and selected again. Uploads suspend when the room
stops; failed work does not automatically retry. Uploaded originals have no client
expiration. After submission, the outbox owns the caption, ordered descriptors and
stable request ID. A persisted draft identity recovers a crash during this handoff
without creating a second send. Storage failure blocks submission rather than
discarding a draft. Attachment receipts must acknowledge exactly the submitted IDs
in order; matching canonical echoes still win ambiguous HTTP failures.
History and queued/live messages decode only `metadata.agentAttachments` for ROOT.
Cards retain filename, MIME, size and the optional server-provided `workspacePath`,
which can be copied. The client never constructs a runtime path or sends one as a
reference; leading-dot display filenames remain unchanged. Captions remain public
text, including the server's fallback for attachment-only messages.
Original downloads use the configured authenticated URLSession and API base
(including `/api`), reject redirects, and verify exact size and SHA-256. At most two
downloads run concurrently, with a 16 MiB in-memory cache discarded on configuration
change. Queued/active cancellation and generation checks prevent stale previews
from crossing configurations. Raster previews use bounded thumbnails; text previews
are plain text, including HTML/SVG. Save/share exports exact original bytes to a
temporary file removed when its card disappears or configuration changes. An app
crash may leave a temporary export for normal OS temporary-directory cleanup.
Image preview frames have fixed height to preserve scroll anchors during loading.
Attachment modals track their own visibility without changing Quick Question state.
### Agent regression and Simulator checks
Run these commands from this worktree's `ios/` directory:
```sh
just build
just test
just run
just screenshot build/agent-evidence/simulator.png
```
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
regressions along with attachment capability/metadata, multipart/auth/bytes/errors,
HEIC conversion versus original-file preservation, ordered receipts, draft/outbox
relaunch, cancellation, two-transfer concurrency and configuration isolation tests
through Xcode on iPhone 16e.
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
```sh
python3 Tests/Fixtures/agent_server.py
```
In a second terminal, run:
```sh
just generate
xcodebuild test -scheme SybilAgentUI \
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
-parallel-testing-enabled NO \
-resultBundlePath build/agent-evidence/phone-ui.xcresult
```
The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts
external services. UI tests exercise the real REST/WebSocket client and retain
screenshots in the result bundle. They cover queued submissions, uncertain
admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
anchors and multiline keyboard layout. The launch arguments select the fixture
through ordinary API settings; no fixture mode is compiled into the application.
Attachment cases exercise authenticated originals, safe text/raster display,
history reload, capability absence and picker cancellation. The real Photo Library
selection-to-send test is explicitly skipped: this iOS 26.5 XCTest environment has
unreliable Photos authorization, callback isolation and synthetic-asset cleanup.
Selection-to-send remains a manual/device pre-release check, not a validated UI
path. The gate prevents photo mutations during the general UI suite. Synthetic
HEIC conversion and original HEIC file preservation are covered by package tests.
The fixture accepts multipart uploads, stable upload/message retries, structured
errors and attachment receipts; it is not a runtime image-decoder or quota test.
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
Simulator Reduce Motion enabled before release.
## Quick Question on macOS
While the Catalyst app is running, **Option+Space** toggles a floating Quick
@@ -16,8 +156,13 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
**Open in Chat** saves the question and answer in the regular workspace.
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
provider/model selection takes precedence and is separate from regular chat preferences.
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
Question, while the normal chat/search composer continues to use Return for a
new line.
Closing the last Mac window leaves Sybil running, so Option+Space still works.
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
@@ -66,7 +211,9 @@ action, and does not build or embed the macOS helper.
answer remain available. Reopen the workspace from the panel, then explicitly
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
it. On iOS, verify Return submits Quick Question while Return in the normal
prompt adds a newline. In the Mac workspace, verify Return submits and
Shift+Return adds a newline.
- Check Command+N both with a workspace open and after closing every workspace.
Command+Shift+N must open another window even when one already exists, and
Command+Option+N must start a search without opening a window.
+293
View File
@@ -0,0 +1,293 @@
#!/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
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
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
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
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
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))
if path == '/api/v1/auth/session':
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/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()))
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)
+350
View File
@@ -0,0 +1,350 @@
import XCTest
import UIKit
import Photos
@MainActor
final class AgentUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
try await control(["action": "reset", "count": 3])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
openAgent()
try clearComposer()
}
override func tearDown() async throws { app.terminate() }
private func openAgent() {
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5) || app.textViews["agent-composer"].exists)
}
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private func clearComposer() throws {
guard let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" else { return }
composer.tap(); composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
let value = composer.value as? String ?? ""
_ = try XCTUnwrap(value.isEmpty || value == "Enter Prompt" ? true : nil, "Clear the synthetic fixture caption before the next test")
}
private func send(_ text: String) {
composer.tap(); composer.typeText(text); app.buttons["agent-send"].tap()
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot()); attachment.name = name; attachment.lifetime = .keepAlways
add(attachment)
}
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func uploadOriginal(name: String, mime: String, bytes: Data) async throws -> [String: Any] {
let boundary = "Fixture-" + UUID().uuidString
let uploadID = UUID().uuidString
var body = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(name)\"\r\nContent-Type: \(mime)\r\n\r\n".utf8)
body.append(bytes); body.append(Data("\r\n--\(boundary)--\r\n".utf8))
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/api/v1/agent/attachments")!)
request.httpMethod = "POST"; request.httpBody = body
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer fixture-token", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 201)
let json = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
return try XCTUnwrap(json["attachment"] as? [String: Any])
}
func testAttachmentHistoryDownloadAndReload() async throws {
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
let image = UIGraphicsImageRenderer(size: CGSize(width: 64, height: 48)).image { context in
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 64, height: 48))
}
let photo = try await uploadOriginal(name: "fixture-image.png", mime: "image/png", bytes: try XCTUnwrap(image.pngData()))
let ids = [try XCTUnwrap(file["id"] as? String), try XCTUnwrap(photo["id"] as? String)]
try await control(["action": "attachment-history", "ids": ids, "content": "Synthetic attachments caption"])
XCTAssertTrue(app.staticTexts["Synthetic attachments caption"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertTrue(app.staticTexts["fixture-image.png"].exists)
let path = try XCTUnwrap(file["workspacePath"] as? String)
XCTAssertTrue(app.buttons["Copy workspace path: " + path].exists)
let download = app.buttons["agent-download"].firstMatch
if !download.isHittable { app.scrollViews.firstMatch.swipeDown() }
XCTAssertTrue(download.waitForExistence(timeout: 5)); download.tap()
XCTAssertTrue(app.buttons["Save or share original"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "<script>plain text only</script>")).firstMatch.exists)
capture("agent-attachment-original-and-preview")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["fixture-image.png"].waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts["fixture-original.txt"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
capture("agent-attachment-reloaded")
// The real app's authenticated content request is recorded by the loopback fixture.
let state = try await control(["action": "state"])
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
XCTAssertTrue(log.contains { ($0["path"] as? String) == "/api/v1/agent/attachments/\(ids[0])/content" })
}
func testAttachmentCapabilityAndPickerCancellation() async throws {
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
composer.tap(); composer.typeText("Preserved attachment draft caption")
app.buttons["agent-attach"].tap()
app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
app.buttons["Cancel"].firstMatch.tap()
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
capture("agent-attachment-pickers-cancelled")
try await control(["action": "capability", "enabled": false])
app.terminate(); app.launch(); openAgent()
XCTAssertFalse(app.buttons["agent-attach"].exists)
XCTAssertEqual(composer.value as? String, "Preserved attachment draft caption")
try clearComposer()
capture("agent-text-only-capability")
}
func testPhotoSelectionToAttachmentOnlySend() async throws {
try XCTSkipIf(true, "Manual/device pre-release check: iOS 26.5 XCTest Photos authorization, callback isolation and cleanup are unreliable in this fixture. Selection-to-send is NOT validated; keep the general suite free of photo mutations.")
// Only synthetic assets are touched; no unrelated photo bytes are read.
if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .authorized {
// XCTest's synchronous UI waits must not block the authorization request.
let permission = Task.detached { await PHPhotoLibrary.requestAuthorization(for: .readWrite) }
try await Task.sleep(for: .milliseconds(300))
let allow = XCUIApplication(bundleIdentifier: "com.apple.springboard").buttons.matching(
NSPredicate(format: "label IN %@", ["Allow Full Access", "Allow Access to All Photos", "Allow Access"])).firstMatch
if allow.waitForExistence(timeout: 5) { allow.tap(); _ = await permission.value }
else if PHPhotoLibrary.authorizationStatus(for: .readWrite) != .notDetermined { _ = await permission.value }
}
try XCTSkipUnless(PHPhotoLibrary.authorizationStatus(for: .readWrite) == .authorized,
"Simulator Photos authorization \(PHPhotoLibrary.authorizationStatus(for: .readWrite).rawValue) is unavailable to \(Bundle.main.bundleIdentifier ?? "test runner")")
// Recover only fixtures from an interrupted test, identified by BOTH our unique
// date and original-filename prefix, before creating this run's asset.
let options = PHFetchOptions()
options.predicate = NSPredicate(format: "creationDate == %@", ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")! as NSDate)
let previous = PHAsset.fetchAssets(with: .image, options: options)
let owned = previous.objects(at: IndexSet(integersIn: 0..<previous.count)).filter {
PHAssetResource.assetResources(for: $0).contains { $0.originalFilename.hasPrefix("sybil-attachment-ui-") }
}.map(\.localIdentifier)
if !owned.isEmpty { try await removeSyntheticPhotos(owned) }
let name = "sybil-attachment-ui-" + UUID().uuidString + ".png"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
let photo = UIGraphicsImageRenderer(size: CGSize(width: 96, height: 64)).image { context in
UIColor.systemPurple.setFill(); context.fill(CGRect(x: 0, y: 0, width: 96, height: 64))
}
try XCTUnwrap(photo.pngData()).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
nonisolated(unsafe) var assetID: String?
try await PHPhotoLibrary.shared().performChanges { @Sendable in
let creation = PHAssetCreationRequest.forAsset()
// A unique synthetic date lets us find this asset without enumerating other photos.
creation.creationDate = ISO8601DateFormatter().date(from: "2099-12-31T20:00:00Z")
let options = PHAssetResourceCreationOptions(); options.originalFilename = name
creation.addResource(with: .photo, fileURL: url, options: options)
assetID = creation.placeholderForCreatedAsset?.localIdentifier
}
let ownedID = try XCTUnwrap(assetID)
let identity = XCTAttachment(string: ownedID); identity.name = "synthetic-photo-owned-identifier"; identity.lifetime = .keepAlways; add(identity)
addTeardownBlock { try await Self.eraseSyntheticPhotos([ownedID]) }
do { try await exercisePhotoSelection() }
catch {
try await removeSyntheticPhotos([ownedID])
throw error
}
try await removeSyntheticPhotos([ownedID])
}
private func removeSyntheticPhotos(_ identifiers: [String]) async throws {
try await Self.eraseSyntheticPhotos(identifiers)
let evidence = XCTAttachment(string: "Removed \(identifiers.count) synthetic asset(s) by owned identifier; remaining count 0.")
evidence.name = "synthetic-photo-cleanup"; evidence.lifetime = .keepAlways; add(evidence)
}
nonisolated private static func eraseSyntheticPhotos(_ identifiers: [String]) async throws {
try await Task.detached {
let owned = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil)
if owned.count > 0 { try await PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.deleteAssets(owned) } }
let remaining = PHAsset.fetchAssets(withLocalIdentifiers: identifiers, options: nil).count
XCTAssertEqual(remaining, 0)
print("Synthetic photo cleanup: \(identifiers.count) owned identifier(s), \(remaining) remaining.")
}.value
}
private func exercisePhotoSelection() async throws {
app.activate()
XCTAssertFalse(app.buttons["agent-send"].isEnabled)
app.buttons["agent-attach"].tap(); app.buttons["Photo Library"].tap()
let cell = app.descendants(matching: .any).matching(NSPredicate(format:
"label BEGINSWITH %@ AND NOT label CONTAINS %@", "Photo, December 31, 2099", ", Photo,")).firstMatch
XCTAssertTrue(cell.waitForExistence(timeout: 8))
cell.tap()
app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Add")).firstMatch.tap()
let ready = XCTNSPredicateExpectation(predicate: NSPredicate(format: "enabled == true"), object: app.buttons["agent-send"])
await fulfillment(of: [ready], timeout: 15)
XCTAssertFalse(app.buttons["agent-upload-retry"].exists)
capture("agent-photo-upload-ready")
app.buttons["agent-send"].tap()
let fallback = app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch
XCTAssertTrue(fallback.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
let state = try await control(["action": "state"])
let requests = try XCTUnwrap(state["requests"] as? [String: [String: Any]])
XCTAssertEqual(requests.count, 1)
let submission = try XCTUnwrap(requests.values.first)
XCTAssertEqual((submission["_payload"] as? [Any])?.first as? String, "")
XCTAssertEqual((submission["attachmentIds"] as? [String])?.count, 1)
capture("agent-photo-attachment-only-canonical")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label BEGINSWITH %@", "Attached: ")).firstMatch.waitForExistence(timeout: 8))
XCTAssertFalse(app.buttons["agent-retry"].exists)
capture("agent-photo-attachment-only-reloaded")
}
func testSendQueueToolsAndRetry() async throws {
capture("agent-history")
send("first fixture")
send("second fixture")
XCTAssertTrue(app.staticTexts["second fixture"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: second fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.otherElements["agent-typing"].exists)
capture("agent-replies-keyboard")
app.swipeDown()
send("retry fixture")
XCTAssertTrue(app.buttons["agent-retry"].waitForExistence(timeout: 5))
capture("agent-retry")
app.buttons["agent-retry"].tap()
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: retry fixture")).firstMatch.waitForExistence(timeout: 12))
send("echo wins fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: echo wins fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.buttons["agent-retry"].exists)
composer.tap(); app.swipeDown()
XCTAssertFalse(app.buttons["agent-tool-fixture_search"].exists)
XCTAssertFalse(app.buttons["agent-tool-fixture_error"].exists)
XCTAssertFalse(app.staticTexts["Inspecting the fixture"].exists)
XCTAssertFalse(app.staticTexts["Fixture unavailable"].exists)
capture("agent-hidden-tools")
let state = try await control(["action": "state"])
XCTAssertEqual((state["requests"] as? [String: Any])?.count, 4)
}
func testReduceMotionSend() async throws {
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
send("reduce motion fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: reduce motion fixture")).firstMatch.waitForExistence(timeout: 12))
XCTAssertFalse(app.otherElements["agent-typing"].exists)
capture("agent-reduce-motion")
}
func testToolResultsStayHiddenLiveAndAfterReload() async throws {
try await control(["action": "long-tool"])
try await control(["action": "reply", "content": "Reply after a hidden tool result"])
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].isHittable)
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
capture("agent-hidden-tool-live")
app.terminate(); app.launch(); openAgent()
XCTAssertTrue(app.staticTexts["Reply after a hidden tool result"].waitForExistence(timeout: 5))
XCTAssertFalse(app.buttons["agent-tool-fixture_long"].exists)
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
XCTAssertFalse(app.staticTexts["agent-tool-result"].exists)
capture("agent-hidden-tool-history")
}
func testTabletSidebarAndInactiveChat() async throws {
try XCTSkipUnless(UIDevice.current.userInterfaceIdiom == .pad, "iPad layout check")
XCUIDevice.shared.orientation = .landscapeLeft
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
XCTAssertTrue(app.buttons["agent-sidebar"].isHittable)
app.buttons["New chat"].tap()
try await control(["action": "busy", "value": true])
try await control(["action": "reply", "content": "iPad inactive chat reply"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread replies"), object: app.buttons["agent-sidebar"])
await fulfillment(of: [unread], timeout: 5)
capture("agent-ipad-sidebar")
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.staticTexts["iPad inactive chat reply"].waitForExistence(timeout: 5))
capture("agent-ipad-room-before-send")
send("iPad fixture")
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: iPad fixture")).firstMatch.waitForExistence(timeout: 12))
capture("agent-ipad-room")
XCUIDevice.shared.orientation = .portrait
}
func testScrollPreservationAndKeyboard() async throws {
app.terminate()
try await control(["action": "reset", "count": 160])
app.launch(); openAgent()
let scroll = app.scrollViews.firstMatch
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown()
scroll.swipeDown()
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
let before = anchor.frame.minY
try await control(["action": "reply", "content": "Reply while reading earlier history"])
try await Task.sleep(for: .milliseconds(500))
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
capture("agent-reading-anchor")
for _ in 0..<25 {
if app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
app.buttons["Load earlier messages"].tap()
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
capture("agent-older-history-anchor")
app.buttons["agent-jump-latest"].tap()
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
capture("agent-multiline-keyboard")
}
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
app.buttons["Show sidebar"].tap()
try await control(["action": "busy", "value": true])
try await control(["action": "reply", "content": "Late asynchronous fixture reply"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "1 unread replies"), object: app.buttons["agent-sidebar"])
await fulfillment(of: [unread], timeout: 5)
capture("agent-unread-sidebar")
let covered = try await control(["action": "state"])
XCTAssertLessThan(covered["readCursor"] as? Int ?? 0, covered["cursor"] as? Int ?? 0)
try await control(["action": "read"])
let gone = NSPredicate(format: "NOT (label CONTAINS %@)", "unread replies")
let cleared = XCTNSPredicateExpectation(predicate: gone, object: app.buttons["agent-sidebar"])
await fulfillment(of: [cleared], timeout: 20)
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.staticTexts["Late asynchronous fixture reply"].waitForExistence(timeout: 5))
try await control(["action": "disconnect"])
try await control(["action": "reply", "content": "Reconnect fixture reply"])
XCTAssertTrue(app.staticTexts["Reconnect fixture reply"].waitForExistence(timeout: 10))
try await control(["action": "malformed"])
try await control(["action": "reply", "content": "Recovered malformed frame"])
XCTAssertTrue(app.staticTexts["Recovered malformed frame"].waitForExistence(timeout: 12))
XCUIDevice.shared.press(.home)
try await control(["action": "reply", "content": "Foreground fixture reply"])
app.activate()
XCTAssertTrue(app.staticTexts["Foreground fixture reply"].waitForExistence(timeout: 12))
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "Foreground fixture reply")).count, 1)
capture("agent-foreground-replay")
}
}
@@ -0,0 +1,138 @@
import XCTest
import UIKit
/// Loopback-only regressions. No picker selection or external backend writes.
@MainActor
final class TranscriptFadeUITests: XCTestCase {
private let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
try await control(["action": "reset", "count": 160])
try await control(["action": "transcript-comparison"])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launch()
}
override func tearDown() async throws { app.terminate() }
private func showSidebar() {
if !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["agent-sidebar"].waitForExistence(timeout: 5))
}
private func openAgent() {
showSidebar()
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 8) || app.textViews["agent-composer"].exists)
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].waitForExistence(timeout: 8))
}
private var composer: XCUIElement {
app.textFields["agent-composer"].exists ? app.textFields["agent-composer"] : app.textViews["agent-composer"]
}
private var transcript: XCUIElement {
// The tablet sidebar is also a scroll view; select the detail by its content.
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
@discardableResult private func control(_ value: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: value)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
func testSharedHeaderFadeGeometry() async throws {
showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
// Normal chat is lazy and can open at either end on phone/tablet.
let normal = app.scrollViews.containing(NSPredicate(format: "label BEGINSWITH %@", "History message ")).firstMatch
XCTAssertTrue(normal.waitForExistence(timeout: 8))
let normalTop = normal.frame.minY
let first = app.staticTexts["History message 1: local simulator fixture."]
if first.exists && first.isHittable { normal.swipeUp(velocity: .slow) }
else { normal.swipeDown(velocity: .slow) }
capture("normal-chat-content-crossing-shared-fade")
XCTAssertTrue(app.buttons["Attach file"].isHittable)
openAgent()
let scroll = transcript
XCTAssertTrue(scroll.exists)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
capture("agent-content-crossing-shared-fade")
XCTAssertTrue(composer.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
XCTAssertTrue(app.buttons["agent-send"].exists)
// A viewport padded below the header clips bubbles before they reach its fade.
XCTAssertEqual(scroll.frame.minY, normalTop, accuracy: 1,
"Agent must scroll beneath the same workspace header as normal chat")
}
func testReadingPositionUnreadAndKeyboardUnderSharedFade() async throws {
openAgent()
let scroll = transcript
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
scroll.swipeDown(velocity: .slow)
scroll.swipeDown(velocity: .slow)
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
// Stay below the header's content clearance, where text is readable.
let visibleTop = scroll.frame.minY + 100
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
$0.isHittable && $0.frame.minY > visibleTop && $0.frame.maxY < composer.frame.minY
})
let before = anchor.frame.minY
let stateBefore = try await control(["action": "state"])
try await control(["action": "reply", "content": "Fade regression reply while reading"])
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
await fulfillment(of: [unread], timeout: 8)
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
let stateAfter = try await control(["action": "state"])
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
XCTAssertLessThan(try XCTUnwrap(stateAfter["readCursor"] as? Int), try XCTUnwrap(stateAfter["cursor"] as? Int))
capture("agent-fade-reading-position-and-unread")
for _ in 0..<30 {
if app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
let load = app.buttons["Load earlier messages"]
XCTAssertTrue(load.isHittable)
let first = app.staticTexts["History message 61: local simulator fixture."]
let beforeLoad = first.frame.minY
load.tap()
capture("agent-fade-prepend-reading-position")
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
"Prepending must preserve the readable row's position beneath the header")
app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"]
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5)
composer.tap()
XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard")
// No send: this only changes a local draft, then clears it.
composer.typeText("Fade draft\nsecond line\nthird line")
XCTAssertTrue(latest.isHittable)
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "Fade draft\nsecond line\nthird line".count))
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ generate:
xcodegen --spec project.yml
build: generate
if command -v xcbeautify >/dev/null 2>&1; then \
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
else \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
+28
View File
@@ -8,3 +8,31 @@ packages:
include:
- Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
targets:
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:
SybilAgentUI:
build:
targets:
SybilApp: all
SybilAgentUITests: [test]
test:
config: Debug
targets:
- SybilAgentUITests
+24
View File
@@ -106,3 +106,27 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
- User accounts + per-device API keys
- Postgres support + migrations for prod
- Attachments + embeddings + semantic search
## Sybil Agent
Configure the backend with:
```dotenv
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
```
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
`sybil-agent` resolves privately. Supply the key through a private server environment
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
`ADMIN_TOKEN` authentication for clients.
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
same persistent root conversation across devices. Sybil Agent remains the source of
truth for messages and background replies. Sybil stores only the shared,
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
its migration is applied automatically at startup. The bundled nginx configuration
forwards WebSocket upgrades. See `docs/api/rest.md` and
`docs/api/streaming-chat.md` at the repository root for the client contracts.
+33
View File
@@ -22,10 +22,12 @@
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -876,6 +878,16 @@
"undici-types": "~7.16.0"
}
},
"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",
@@ -2241,6 +2253,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",
+2
View File
@@ -31,10 +31,12 @@
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@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
);
+9
View File
@@ -76,6 +76,7 @@ model Chat {
@@index([userId])
@@index([parentChatId])
@@index([updatedAt])
}
model Message {
@@ -214,3 +215,11 @@ 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 {
id String @id
cursor String @default("0")
updatedAt DateTime @updatedAt
}
+141
View File
@@ -0,0 +1,141 @@
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
export const AgentAttachmentDescriptor = z.object({
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
});
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
await app.register(async routes => {
let uploads = 0, downloads = 0;
type Transfer = { controller: AbortController; release(): void };
const transfers = new WeakMap<FastifyRequest, Transfer>();
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
routes.addHook('onRequest', async (req, reply) => {
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
const uploading = req.method === 'POST';
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
if (uploading) uploads++; else downloads++;
const controller = new AbortController(); let released = false;
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
const release = () => {
if (released) return; released = true;
if (uploading) uploads--; else downloads--;
clearTimeout(timeout); controller.abort();
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
};
transfers.set(req, { controller, release });
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
if (reply.raw.destroyed) return;
if (_req.method === 'POST') reply.header('Connection', 'close');
const statusCode = error.statusCode || 502;
const code = typeof error.code === 'string' ? error.code : undefined;
if (statusCode === 429) reply.header('Retry-After', '2');
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
statusCode, code: code || 'agent_unavailable' });
});
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
else done(null, payload);
});
const json = async (response: Response) => {
let size = 0; const chunks: Uint8Array[] = [];
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
const reader = response.body.getReader();
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
finally { reader.releaseLock(); }
};
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
let oversized = false;
const incoming = req.body;
const { controller } = transfer(req);
const body = Readable.from((async function* () {
let count = 0;
for await (const chunk of incoming) {
count += chunk.length;
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
yield chunk;
}
})());
try {
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
} as RequestInit & { duplex: string });
const value = await json(response); controller.signal.throwIfAborted();
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
if (response.status === 429) reply.header('Retry-After', '2');
return reply.code(response.status).send(value);
} catch (error) {
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
} finally { body.destroy(); }
});
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
const { controller } = transfer(req);
let response: Response;
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
controller.signal.throwIfAborted();
if (!response.ok) return reply.code(response.status).send(await json(response));
const length = Number(response.headers.get('content-length'));
const disposition = response.headers.get('content-disposition');
const type = response.headers.get('content-type') || '';
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
}
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
if (size !== length) throw new Error('Download length mismatch');
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
finally { reader.releaseLock(); }
controller.signal.throwIfAborted();
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
return reply.send(Buffer.concat(chunks, size));
});
});
}
+195
View File
@@ -0,0 +1,195 @@
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 });
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.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;
};
async function readState() {
const value = await prisma.agentReadState.findUnique({ where: { id: 'root' } });
return { cursor: value?.cursor || '0', updatedAt: value?.updatedAt.toISOString() || null };
}
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
// An open development proxy must not expose a token-protected attachment runtime's
// history/model context using its server-side credential.
const guardOpenProxy = async (signal?: AbortSignal) => {
if (env.ADMIN_TOKEN) return;
try {
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
});
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: { 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);
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 readState();
});
app.put('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const { cursor } = parse(ReadBody, req.body);
// One SQLite statement makes concurrent tabs monotonic. Never write an older
// cursor over one saved by another connection between a read and an update.
await prisma.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP)
ON CONFLICT("id") DO UPDATE SET
"cursor" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN excluded."cursor" ELSE "AgentReadState"."cursor" END,
"updatedAt" = CASE WHEN CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER) THEN CURRENT_TIMESTAMP ELSE "AgentReadState"."updatedAt" END`;
return readState();
});
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy);
}
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<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;
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; });
const dispose = () => {
if (disposed) return;
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
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);
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
upstream.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
View File
@@ -70,6 +70,8 @@ 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,
EXA_API_KEY: z.string().optional(),
BRAVE_SEARCH_API_KEY: OptionalTrimmedStringSchema,
+4 -1
View File
@@ -6,6 +6,7 @@ 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";
const app = Fastify({
@@ -26,7 +27,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, {
@@ -78,6 +79,7 @@ app.setErrorHandler((err, req, reply) => {
error: true,
message: e.message ?? String(e),
statusCode,
...(typeof e.code === 'string' ? { code: e.code } : {}),
});
});
@@ -86,6 +88,7 @@ app.addHook("onClose", async () => {
});
await registerRoutes(app);
await registerAgentRoutes(app);
await app.listen({ port: env.PORT, host: env.HOST });
app.log.info(`listening on http://${env.HOST}:${env.PORT}`);
+1 -1
View File
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
if (id.includes("search") || id.includes("computer-use")) return false;
return /^(gpt-|o\d|chatgpt-)/.test(id);
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
}
function isLikelyGeminiChatModel(model: string) {
+32
View File
@@ -16,6 +16,7 @@ import { openaiClient } from "./llm/providers.js";
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
import { exaClient } from "./search/exa.js";
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
import { searchWorkspace } from "./workspace-search.js";
import type { ChatAttachment } from "./llm/types.js";
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
@@ -927,6 +928,37 @@ export async function registerRoutes(app: FastifyInstance) {
app.get("/v1/workspace-items", async (req) => {
requireAdmin(req);
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
if (parsed.data.q) {
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
const [chats, searches] = await Promise.all([
prisma.chat.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
select: {
...chatSummarySelect,
parentChat: { select: { projectItems: starredProjectItemsSelect } },
},
}),
prisma.search.findMany({
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
select: searchSummarySelect,
}),
]);
const summaries = new Map<string, Record<string, unknown>>([
...chats.map(({ parentChat, ...chat }) => [
`chat:${chat.id}`,
// Search omits unmatched roots, but sidebar fork stars belong to the root.
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
] as const),
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
]);
const items = hits.flatMap((hit) => {
const summary = summaries.get(`${hit.type}:${hit.id}`);
return summary ? [{ ...summary, ...hit }] : [];
});
return { items, hasMore };
}
return { items: await listWorkspaceItems() };
});
+74
View File
@@ -0,0 +1,74 @@
import { Prisma } from "@prisma/client";
import { prisma } from "./db.js";
export const WORKSPACE_SEARCH_LIMIT = 100;
// Treat input as literal Unicode words, never FTS operators/column expressions.
// Prefix matching supports type-ahead, including short terms such as "AI".
export function workspaceSearchExpression(query: string): string | null {
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
}
export type WorkspaceSearchHit = {
type: "chat" | "search";
id: string;
searchSnippet: string | null;
};
export function workspaceSearchSQL(expression: string) {
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
return Prisma.sql`
WITH chat_hits AS (
SELECT c.id, NULL AS message_rowid
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
WHERE "ChatSearch" MATCH ${expression}
UNION ALL
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
WHERE "MessageSearch" MATCH ${expression}
GROUP BY m.chatId
), matches AS (
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
NULL AS search_rowid
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
GROUP BY c.id
UNION ALL
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
WHERE "SavedSearchSearch" MATCH ${expression}
), limited AS MATERIALIZED (
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
), message_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
FROM "MessageSearch"
WHERE "MessageSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
AND +rowid IN (SELECT message_rowid FROM limited)
), search_excerpts AS MATERIALIZED (
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
FROM "SavedSearchSearch"
WHERE "SavedSearchSearch" MATCH ${expression}
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
AND +rowid IN (SELECT search_rowid FROM limited)
)
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
FROM limited
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
ORDER BY updatedAt DESC, type, id
`;
}
export async function searchWorkspace(query: string) {
const expression = workspaceSearchExpression(query);
if (!expression) return { hits: [], hasMore: false };
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
// only for the bounded result set, never fetch full transcripts into JS.
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
}
+85
View File
@@ -0,0 +1,85 @@
import assert from 'node:assert/strict';
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.ADMIN_TOKEN = 'attachments-browser-token-test';
process.env.DATABASE_URL = 'file:/tmp/sybil-attachment-proxy-unused.sqlite';
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()));});
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);
});
+184
View File
@@ -0,0 +1,184 @@
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.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.at(-1)!.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);
assert.ok(!JSON.stringify(frames).includes(privateKey));
browser.send(JSON.stringify({ type: 'subscribe', after: '43' }));
await eventually(() => subscriptions.at(-1)?.after === '43');
browser.close(); await once(browser, 'close');
await eventually(() => sourceSockets.clients.size === 0);
});
test('open-mode WebSocket authentication accepts null and still keeps the Sybil Agent token server-side', async () => {
const saved = env.ADMIN_TOKEN; env.ADMIN_TOKEN = undefined;
try {
const browser = new WebSocket(wsUrl); const frames: any[] = [];
browser.on('message', raw => frames.push(JSON.parse(raw.toString())));
await once(browser, 'open'); browser.send(JSON.stringify({ type: 'auth', token: null, after: '0' }));
await eventually(() => frames.some(frame => frame.type === 'ready'));
browser.close(); await once(browser, 'close');
} finally { env.ADMIN_TOKEN = saved; }
});
+2 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
@@ -11,6 +11,7 @@ import sensible from "@fastify/sensible";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
writeFileSync(join(databaseDir, "test.db"), "");
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
+45 -1
View File
@@ -1,6 +1,50 @@
import assert from "node:assert/strict";
import test from "node:test";
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
import { env } from "../src/env.js";
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
for (const { name, listedModels, expectedModels } of [
{
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
listedModels: [
"gpt-4.1-mini",
"chat-latest",
"o3",
"chatgpt-4o-latest",
"chat-latest",
"text-embedding-3-small",
"omni-moderation-latest",
"gpt-4o-audio-preview",
"gpt-realtime",
"gpt-4o-transcribe",
"gpt-4o-mini-tts",
"gpt-image-1",
"chatgpt-image-latest",
"gpt-4o-search-preview",
"computer-use-preview",
"sora-2",
],
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
},
{
name: "OpenAI catalog does not invent models absent from the provider response",
listedModels: ["gpt-4.1-mini"],
expectedModels: ["gpt-4.1-mini"],
},
]) {
test(name, async (t) => {
const originalKey = env.OPENAI_API_KEY;
env.OPENAI_API_KEY = "test-openai-key";
t.after(() => { env.OPENAI_API_KEY = originalKey; });
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
assert.equal(url, "https://api.openai.com/v1/models");
return Response.json({ data: listedModels.map((id) => ({ id })) });
});
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
});
}
test("provider backend registry selects chat protocol and managed-tool mode", () => {
assert.deepEqual(describeProviderChatBackend("openai", []), {
+188
View File
@@ -0,0 +1,188 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import sensible from "@fastify/sensible";
import { Prisma } from "@prisma/client";
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
const migration = "20261007000000_add_workspace_full_text_search";
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
process.env.OPENAI_API_KEY = "";
delete process.env.ADMIN_TOKEN;
writeFileSync(join(databaseDir, "test.db"), "");
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
}
function migrate() {
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
cwd: serverRoot, env: process.env, stdio: "pipe",
});
}
migrate();
const { prisma } = await import("../src/db.js");
const oldChat = await prisma.chat.create({ data: {
title: "Old constellation notes",
messages: { create: [
{ role: "user", content: "We photographed a nebula before the migration." },
{ role: "system", content: "secretmigrationprompt" },
] },
} });
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
migrate();
const { registerRoutes } = await import("../src/routes.js");
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
const app = Fastify({ logger: false });
await app.register(sensible);
await registerRoutes(app);
await app.ready();
async function search(query: string) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
assert.equal(response.statusCode, 200, response.body);
return response.json();
}
test.afterEach(async () => {
await prisma.chat.deleteMany();
await prisma.search.deleteMany();
});
test.after(async () => {
await app.close();
await prisma.$disconnect();
rmSync(databaseDir, { recursive: true, force: true });
});
test("migration indexes existing titles, messages, queries, and answers", async () => {
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
assert.equal((await search("constellation")).items.length, 2);
assert.equal((await search("secretmigrationprompt")).items.length, 0);
});
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
const chat = await prisma.chat.create({ data: {
title: "Travel notes", updatedAt: new Date("2000-01-01"),
messages: { create: [
{ role: "user", content: "Let's discuss café planning in Zürich" },
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
{ role: "system", content: "hiddensystemvalue" },
{ role: "tool", content: "hiddentoolvalue" },
] },
} });
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
const result = await search("CAFE plan");
assert.equal(result.hasMore, false);
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
assert.match(result.items[0].searchSnippet, /café planning/i);
assert.equal(result.items[0].messages, undefined);
assert.equal((await search("zuri")).items.length, 1);
assert.equal((await search("hiddensystemvalue")).items.length, 0);
assert.equal((await search("hiddentoolvalue")).items.length, 0);
});
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
const chat = await prisma.chat.create({ data: {
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
}, include: { messages: true } });
const messageID = chat.messages[0].id;
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
assert.equal((await search("original")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
assert.equal((await search("replacementbody")).items.length, 0);
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
assert.equal((await search("replacementbody")).items.length, 1);
await assert.rejects(prisma.$transaction(async (tx) => {
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
throw new Error("rollback");
}));
assert.equal((await search("rolledbackvalue")).items.length, 0);
assert.equal((await search("replacementbody")).items.length, 1);
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
assert.equal(fork.statusCode, 200, fork.body);
assert.equal((await search("replacementbody")).items.length, 2);
await prisma.message.delete({ where: { id: messageID } });
const onlyFork = (await search("replacementbody")).items;
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
assert.equal(starResponse.statusCode, 200, starResponse.body);
const starredFork = (await search("replacementbody")).items[0];
assert.equal(starredFork.starred, true);
assert.ok(starredFork.starredAt);
assert.equal(starredFork.parentChat, undefined);
await prisma.chat.delete({ where: { id: chat.id } });
assert.equal((await search("replacement")).items.length, 0);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
}
});
test("saved searches match query and answer, and update/delete their index", async () => {
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
assert.equal((await search("ai des")).items[0].type, "search");
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
assert.equal((await search("nebula")).items.length, 0);
assert.equal((await search("design")).items.length, 0);
assert.equal((await search("Bookmarks")).items.length, 0);
assert.equal((await search("newanswer")).items.length, 1);
await prisma.search.delete({ where: { id: saved.id } });
assert.equal((await search("new")).items.length, 0);
});
test("literal queries, validation, blank queries, and bounded snippets", async () => {
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
assert.equal((await search('"AI" OR safety*')).items.length, 1);
assert.equal((await search("AI OR other")).items.length, 0);
assert.equal((await search("AI")).items.length, 1);
assert.equal((await search("*** : () --")).items.length, 0);
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
assert.deepEqual(await search(" "), recent);
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
assert.equal(response.statusCode, 400, response.body);
}
});
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
const date = new Date("2026-01-01");
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
const result = await search("common");
assert.equal(result.items.length, 100);
assert.equal(result.hasMore, true);
assert.equal(result.items[0].id, "saved");
assert.equal(result.items[1].id, "chat-000");
assert.equal(result.items.at(-1).id, "chat-098");
});
test("query plan uses all three FTS indexes", async () => {
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
}
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
});
test("vacuum preserves external-content index row identities", async () => {
const removed = await prisma.chat.create({ data: { title: "removed" } });
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
await prisma.chat.delete({ where: { id: removed.id } });
await prisma.$executeRaw`VACUUM`;
assert.equal((await search("kepttitle")).items[0].id, kept.id);
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
});
+7
View File
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
## UI
- Left panel: mixed list of chat conversations and Exa searches.
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
conversation messages, and web-search queries/answers. It waits 300 ms while
typing and cancels superseded requests. Results include a single-line matching excerpt
with matching words in bold and middle truncation
(or “Title match” for title-only chat matches), with up to 100 newest matches.
Clear the field or press Escape to return to recent items. Matching uses word
prefixes, so `plan` also finds `planning`.
- Right panel:
- Chat mode: transcript + provider/model controls.
- Search mode: top AI answer block + Google-style Exa results view.
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+3 -15
View File
@@ -3,21 +3,9 @@ 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) => {
+242 -98
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
import type { TargetedTouchEvent } from "preact";
import {
Check,
Bot,
ChevronDown,
GitFork,
Globe2,
@@ -25,7 +26,9 @@ 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 { 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 +69,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 +100,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 +144,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 +161,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 +663,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 +720,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 +852,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 +870,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,
@@ -948,12 +965,12 @@ export default function App() {
isCheckingSession,
isSigningIn,
isAuthenticated,
authMode,
authError,
handleAuthFailure: baseHandleAuthFailure,
handleSignIn,
logout,
} = useSessionAuth();
const agentRoom = useAgentRoom(isAuthenticated);
const [chats, setChats] = useState<ChatSummary[]>([]);
const [searches, setSearches] = useState<SearchSummary[]>([]);
@@ -986,7 +1003,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 +1035,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 +1064,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 +1133,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 +1161,8 @@ export default function App() {
setChats([]);
setSearches([]);
setWorkspaceItems([]);
setSidebarQuery("");
selectedHistoryItemRef.current = null;
setSelectedItem(null);
setSelectedChat(null);
setSelectedSearch(null);
@@ -1193,6 +1214,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 +1230,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 +1470,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 +1522,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 +1535,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 +1553,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 +1586,10 @@ export default function App() {
setSelectedSearch(null);
return;
}
if (!selectedItem) {
if (!selectedItem || selectedItem.kind === "agent") {
setSelectedChat(null);
setSelectedSearch(null);
setIsLoadingSelection(false);
return;
}
@@ -1718,6 +1749,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 +1762,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 +1937,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 +1946,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 +2012,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 +2053,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 +2243,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 +2254,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 +2346,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 +2388,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 +3490,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 +3502,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 +3548,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 +3663,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 +3682,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 +3707,9 @@ export default function App() {
</span>
) : null}
</span>
{isSidebarSearchActive ? (
<SearchMatchLabel text={item.searchSnippet || "Title match"} query={item.searchSnippet ? sidebarQuery : ""} />
) : null}
</div>
)}
</button>
@@ -3604,16 +3717,22 @@ 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>
</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 +3870,7 @@ export default function App() {
/>
</div>
</footer>
</>}
</main>
</div>
{contextMenu ? (
@@ -3822,44 +3942,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 +4009,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 +4037,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 +4059,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 +4081,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 +4095,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 +4106,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 +4116,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 +4147,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 +4158,7 @@ export default function App() {
</Button>
</div>
</form>
</div>
</dialog>
) : null}
{renameChatDialog ? (
<div
@@ -4073,7 +4217,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 +4295,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>
);
}
+7 -8
View File
@@ -26,7 +26,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
</div>
<div>
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
<p className="mt-1 text-sm text-muted-foreground">Enter your admin token.</p>
</div>
</div>
@@ -34,28 +34,27 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
className="space-y-3"
onSubmit={(event) => {
event.preventDefault();
void onSignIn(authTokenInput.trim() || null);
const token = authTokenInput.trim();
if (token) void onSignIn(token);
}}
>
<Input
type="password"
autoComplete="off"
aria-label="Admin token"
placeholder="ADMIN_TOKEN"
required
value={authTokenInput}
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
disabled={isSigningIn}
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
/>
<Button className="w-full" type="submit" disabled={isSigningIn}>
<Button className="w-full" type="submit" disabled={isSigningIn || !authTokenInput.trim()}>
{isSigningIn ? "Signing in..." : "Sign in"}
</Button>
<Button className="w-full" type="button" variant="secondary" disabled={isSigningIn} onClick={() => void onSignIn(null)}>
Continue without token
</Button>
</form>
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
{authError ? <p role="alert" className="mt-3 text-sm text-red-600">{authError}</p> : null}
</div>
</div>
);
@@ -16,7 +16,7 @@ function getTextPreview(value: string) {
function getSurfaceClasses(tone: Props["tone"]) {
if (tone === "user") {
return "border-white/12 bg-black/16 text-fuchsia-50";
return "border-white/12 bg-black/16 text-inherit";
}
if (tone === "assistant") {
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
@@ -378,13 +378,13 @@ function ToolCallStack({
);
})}
{!expanded && hiddenCount ? (
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
+{hiddenCount}
</span>
) : null}
<button
type="button"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
aria-expanded={expanded ? "true" : "false"}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
@@ -601,10 +601,10 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
<div
className={cn(
"max-w-[85%] space-y-3",
"min-w-0 space-y-3",
isUser
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
: "text-base leading-7 text-violet-50"
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
: "max-w-[85%] text-base leading-7 text-violet-50"
)}
onContextMenu={
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
@@ -625,7 +625,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
<MarkdownContent
markdown={message.content}
openLinksInNewTab
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
/>
) : null}
</div>
@@ -0,0 +1,96 @@
/// <reference lib="es2022.intl" />
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
const text = source.replace(/\s+/gu, " ").trim();
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const ellipsisRef = useRef<HTMLSpanElement>(null);
const [kept, setKept] = useState<number | null>(null);
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
const runs = useMemo(() => {
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
const result: Array<{ start: number; end: number; match: boolean }> = [];
let cursor = 0;
for (const word of text.matchAll(words)) {
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
cursor = word.index + word[0].length;
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
}
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
return result;
}, [text, query]);
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
if (run.end <= start || run.start >= end) return [];
return [run.match
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
: <span key={`${start}:${run.start}`}>{value}</span>];
});
useLayoutEffect(() => {
const container = containerRef.current;
const measure = measureRef.current;
const ellipsis = ellipsisRef.current;
if (!container || !measure || !ellipsis) return;
let disposed = false;
const update = () => {
if (disposed) return;
const width = container.getBoundingClientRect().width;
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
// Measure the styled DOM, including bold runs, instead of guessing a
// character limit. Grapheme boundaries keep accents and emoji intact.
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
const range = document.createRange();
const measureRange = (start: number, end: number) => {
if (start >= end) return 0;
let offset = 0;
let started = false;
for (const node of nodes) {
const next = offset + node.length;
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
if (end <= next) { range.setEnd(node, end - offset); break; }
offset = next;
}
return range.getBoundingClientRect().width;
};
const count = boundaries.length - 1;
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
let low = 0;
let high = count - 1;
while (low < high) {
const middle = Math.ceil((low + high) / 2);
const prefix = boundaries[Math.ceil(middle / 2)];
const suffix = boundaries[count - Math.floor(middle / 2)];
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
else high = middle - 1;
}
setKept(low);
};
update();
const observer = new ResizeObserver(update);
observer.observe(container);
void document.fonts.ready.then(update);
document.fonts.addEventListener("loadingdone", update);
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
}, [text, runs, boundaries]);
const count = boundaries.length - 1;
const truncated = kept !== null && kept < count;
return (
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
<span className="sr-only">{text}</span>
<span aria-hidden="true">
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
</span>
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
</span>
</span>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks";
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
import {
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentReadState,
createAgentReadWriter, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
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 { 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) => void) | null>(null);
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) {
client.current = null; mark.current = null; setAttachmentCapability(undefined);
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
return;
}
let active = true;
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) 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)); };
// Read acknowledgments may be ahead of the batched UI for ~100ms. The
// writer must observe that acknowledged cursor immediately to avoid a
// repeated PUT loop while the presenter waits to publish its batch.
let acknowledgedReadCursor = current.current.readCursor;
const readWriter = createAgentReadWriter({
current: () => acknowledgedReadCursor,
save: cursor => api.markRead(cursor, controller.signal),
apply: read => {
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
},
});
mark.current = cursor => { void readWriter.request(cursor).catch(reportError); };
const refreshRead = async () => {
const read = await api.readState(controller.signal);
acknowledgedReadCursor = advanceAgentCursor(acknowledgedReadCursor, read.cursor);
if (active) presenter.update(state => applyAgentReadState(state, read));
};
const catchUp = async (replay = false) => syncAgentHistory(api, current.current.cursor, page => {
if (active) presenter.push({ type: "messages", ...page }, { replay });
}, controller.signal);
const refreshActivity = async () => {
const descriptor = await api.descriptor(controller.signal);
if (active) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
};
const connect = () => {
if (!active || authFailed) 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 || socket !== next) { next.close(); return; }
lastPacket = Date.now();
next.send(agentAuthFrame(getAuthToken(), current.current.cursor));
};
next.onmessage = ({ data }) => {
if (!active || 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;
}
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) setConnection("reconnecting"); };
next.onclose = event => {
if (!active || 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) return;
presenter.flush();
started = true; setLoading(false); connect();
} catch (failure) {
if (!active) return;
reportError(failure); setLoading(false); setConnection("offline");
startupTimer = setTimeout(() => { void start(); }, 10_000);
}
};
const maintain = async (foreground = false) => {
if (!active || !started || maintaining || authFailed) return;
maintaining = true;
try {
await refreshRead();
await readWriter.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; 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 markRead = useCallback(() => {
if (!enabled || isLoading) return;
// This callback belongs to this rendered snapshot. Reading current.current
// here would incorrectly mark a socket message that has not rendered yet.
mark.current?.(renderedCursor);
}, [enabled, isLoading, renderedCursor]);
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(() => { 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 };
}
+9 -5
View File
@@ -18,8 +18,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 +46,10 @@ export function useSessionAuth() {
persistToken(tokenCandidate);
};
const handleAuthFailure = (message: string) => {
const handleAuthFailure = (message: string | null) => {
setIsAuthenticated(false);
setAuthMode(null);
setAuthError(normalizeAuthError(message));
setAuthError(message ? normalizeAuthError(message) : null);
setAuthToken(null);
persistToken(null);
};
@@ -81,7 +84,8 @@ export function useSessionAuth() {
await completeSessionCheck(token);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
handleAuthFailure(message);
// A first visit without a token is an invitation to sign in, not a failed attempt.
handleAuthFailure(!token && message.includes("missing bearer token") ? null : message);
} finally {
setIsCheckingSession(false);
}
+30
View File
@@ -0,0 +1,30 @@
import { useEffect, useState } from "preact/hooks";
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
const EMPTY_ITEMS: WorkspaceItem[] = [];
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
const trimmed = query.trim();
const [state, setState] = useState<SearchState | null>(null);
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
useEffect(() => {
setState(null);
if (!enabled || !trimmed || validationError) return;
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
(result) => setState({ ...result, query: trimmed, revision, error: null }),
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
);
}, [trimmed, enabled, revision, validationError]);
// Hide obsolete results immediately, even before effect cleanup runs.
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
return {
items: current?.items ?? EMPTY_ITEMS,
hasMore: current?.hasMore ?? false,
error: validationError ?? current?.error ?? null,
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
};
}
+72 -26
View File
@@ -31,7 +31,7 @@
--secondary-foreground: 254 38% 94%;
--accent: 188 86% 50%;
--accent-foreground: 230 45% 8%;
--radius: 0.5rem;
--radius: 0px;
}
* {
@@ -72,6 +72,7 @@ select,
textarea {
font: inherit;
color-scheme: dark;
border-radius: var(--radius);
}
::selection {
@@ -113,13 +114,39 @@ textarea {
max(0.75rem, var(--safe-area-left));
}
.chat-settings-header {
padding:
calc(0.5rem + var(--safe-area-top))
max(0.75rem, var(--safe-area-right))
0.5rem
max(0.75rem, var(--safe-area-left));
}
.chat-settings-content {
padding:
1rem
max(1rem, var(--safe-area-right))
max(1.5rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
scroll-padding-block: 1rem;
}
.chat-settings-error {
padding:
0.75rem
max(1rem, var(--safe-area-right))
max(0.75rem, var(--safe-area-bottom))
max(1rem, var(--safe-area-left));
}
@media (min-width: 768px) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
.chat-settings-header,
.chat-settings-content {
padding: 1.25rem;
}
.chat-settings-error {
padding: 0.75rem 1.25rem;
}
.app-search-safe-pad {
@@ -132,19 +159,11 @@ textarea {
}
@media (horizontal-viewport-segments: 2) {
.app-safe-frame {
padding:
max(0.5rem, var(--safe-area-top))
max(0.5rem, var(--safe-area-right))
max(0.5rem, var(--safe-area-bottom))
max(0.5rem, var(--safe-area-left));
}
.workspace-shell {
display: grid;
grid-template-columns:
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
}
@@ -153,14 +172,10 @@ textarea {
width: 100%;
max-width: none;
transform: none;
border-width: 1px;
border-radius: 1rem;
}
.workspace-content {
min-width: 0;
border-width: 1px;
border-radius: 1rem;
touch-action: auto;
}
@@ -179,6 +194,37 @@ textarea {
0 14px 36px hsl(240 80% 2% / 0.28);
}
/* Shared by regular chats and the persistent agent transcript. */
.message-bubble {
min-width: 0;
padding: 13px 17px;
border: 1px solid hsl(256 37% 57% / .18);
border-radius: 18px 18px 18px 5px;
background: linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93));
box-shadow: 0 3px 12px hsl(239 55% 2% / .13);
font-size: 14px;
line-height: 1.68;
overflow-wrap: anywhere;
}
.message-bubble--user {
border-color: hsl(266 80% 72% / .3);
border-radius: 18px 18px 5px 18px;
background: linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9));
color: hsl(265 55% 97%);
}
.message-bubble .md-content {
font-size: inherit;
line-height: inherit;
}
@media (max-width: 767px) {
.message-bubble {
padding: 11px 14px;
}
}
.tool-call-stack-shell {
perspective: 900px;
transform-style: preserve-3d;
@@ -430,7 +476,7 @@ textarea {
overflow-x: auto;
overflow-y: hidden;
border: 1px solid hsl(var(--border) / 0.86);
border-radius: 0.625rem;
border-radius: var(--radius);
background: hsl(246 34% 10% / 0.76);
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
}
@@ -449,7 +495,7 @@ textarea {
}
.md-table-scroll::-webkit-scrollbar-thumb {
border-radius: 9999px;
border-radius: var(--radius);
background: hsl(263 78% 72% / 0.34);
}
@@ -561,7 +607,7 @@ textarea {
.md-content code {
background: hsl(249 40% 10% / 0.78);
border-radius: 0.3rem;
border-radius: var(--radius);
padding: 0.05rem 0.3rem;
font-size: 0.86em;
box-decoration-break: clone;
@@ -571,7 +617,7 @@ textarea {
.md-content pre {
overflow-x: auto;
border: 1px solid hsl(253 31% 29% / 0.72);
border-radius: 0.625rem;
border-radius: var(--radius);
background: hsl(249 40% 10% / 0.82);
padding: 0.75rem;
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
@@ -595,7 +641,7 @@ textarea {
.md-cite-token {
display: inline-flex;
align-items: center;
border-radius: 9999px;
border-radius: var(--radius);
border: 1px solid hsl(274 40% 55%);
background: hsl(277 33% 21%);
color: hsl(304 42% 96%);
+117
View File
@@ -0,0 +1,117 @@
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
export type AgentAttachment = {
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
sizeBytes: number; sha256: string; workspacePath?: string;
};
export type AgentAttachmentCapability = {
version: 1; maxCount: number; maxTotalBytes: number; maxImageBytes: number; maxFileBytes: number;
imageMimeTypes: string[]; quotaBytes: number; usedBytes: number; remainingBytes: number;
maxConcurrentUploads: number; maxConcurrentDecodes: number;
};
export function parseAgentAttachment(value: unknown): AgentAttachment {
const item = value as AgentAttachment | null;
if (!item || !/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item.id)
|| !["image", "text", "file"].includes(item.kind) || typeof item.filename !== "string" || !item.filename
|| typeof item.mimeType !== "string" || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > 8 * 1024 * 1024
|| typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)
|| (item.workspacePath !== undefined && (typeof item.workspacePath !== "string" || !item.workspacePath.startsWith("/"))))
throw new Error("Agent returned an invalid attachment descriptor.");
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
}
export function agentAttachments(metadata: unknown): AgentAttachment[] {
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
if (!Array.isArray(values) || values.length > 8) return [];
try { return values.map(parseAgentAttachment); } catch { return []; }
}
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
}
export function attachmentSize(bytes: number): string { return bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.ceil(bytes / 1024)} KiB` : `${(bytes / 1024 ** 2).toFixed(1)} MiB`; }
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1";
export type AgentUpload = {
uploadId: string; filename: string; sizeBytes: number; mimeType: string; sha256?: string; file?: File;
status: "waiting" | "uploading" | "ready" | "failed" | "reselect"; attachment?: AgentAttachment; error?: string;
};
export type AgentUploadStorage = { read(): unknown; write(rows: Omit<AgentUpload, "file">[]): void };
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
export function createAgentUploadQueue(options: {
capability(): AgentAttachmentCapability | undefined;
upload(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
changed(): void; storage?: AgentUploadStorage; newId?: () => string;
}) {
const rows = new Map<string, AgentUpload>();
let running: { id: string; controller: AbortController } | undefined, disposed = false;
const persist = () => options.storage?.write([...rows.values()].map(({ file: _file, ...row }) => ({ ...row })));
const publish = () => { if (!disposed) options.changed(); };
const save = () => { if (disposed) return; try { persist(); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish(); };
const restored = options.storage?.read();
if (Array.isArray(restored)) for (const value of restored.slice(0, 8)) {
if (!value || typeof value.uploadId !== "string" || !/^[a-f0-9-]{36}$/i.test(value.uploadId)
|| typeof value.filename !== "string" || typeof value.mimeType !== "string" || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || value.sizeBytes > 8 * 1024 * 1024) continue;
try {
const attachment = value.attachment ? parseAgentAttachment(value.attachment) : undefined;
rows.set(value.uploadId, { uploadId: value.uploadId, filename: value.filename, sizeBytes: value.sizeBytes, mimeType: value.mimeType,
...(typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) ? { sha256: value.sha256 } : {}),
...(attachment ? { attachment, status: "ready" } : { status: "reselect", error: "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
}
const digest = async (file: File) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))].map(n => n.toString(16).padStart(2, "0")).join("");
const pump = () => {
if (disposed || running) return;
const row = [...rows.values()].find(row => row.status === "waiting" && row.file);
if (!row) return;
const controller = new AbortController(); running = { id: row.uploadId, controller }; row.status = "uploading"; publish();
void (async () => {
row.sha256 ||= await digest(row.file!);
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
// Persist the upload identity AND original byte hash before any network request.
persist();
const value = await options.upload(row.file!, row.uploadId, controller.signal);
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
const attachment = parseAgentAttachment(value);
if (attachment.sha256 !== row.sha256 || attachment.sizeBytes !== row.sizeBytes || attachment.filename !== row.filename.normalize("NFC"))
throw new Error("The uploaded file receipt does not match this file. Retry to confirm.");
row.attachment = attachment; row.status = "ready"; delete row.error;
})().catch(error => {
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
row.status = "failed"; row.error = error instanceof Error ? error.message : String(error);
}).finally(() => { running = undefined; save(); pump(); });
};
return {
add(files: Iterable<File>) {
const cap = options.capability(); if (!cap || cap.version !== 1) throw new Error("Attachments are not available on this Agent.");
const selected = Array.from(files);
if (rows.size + selected.length > Math.min(8, cap.maxCount)) throw new Error("Attach at most 8 files per message.");
if ([...rows.values()].reduce((n, r) => n + r.sizeBytes, 0) + selected.reduce((n, f) => n + f.size, 0) > Math.min(32 * 1024 * 1024, cap.maxTotalBytes))
throw new Error("Attachments exceed 32 MiB per message.");
for (const file of selected) {
const image = cap.imageMimeTypes.includes(file.type);
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
}
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
for (const row of added) rows.set(row.uploadId, row);
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
publish(); pump();
},
async reselect(id: string, file: File) {
const row = rows.get(id); if (!row || row.status !== "reselect") return;
if (file.name !== row.filename || file.size !== row.sizeBytes || file.type !== row.mimeType) throw new Error("Choose the same filename, type and size to resume this upload.");
const hash = await digest(file);
if (row.sha256 && row.sha256 !== hash) throw new Error("The selected file has different contents. Choose the original file or remove this attachment.");
if (rows.get(id) !== row) return;
row.sha256 = hash; row.file = file; row.status = "waiting"; delete row.error; persist(); publish(); pump();
},
remove(id: string) { const row = rows.get(id); rows.delete(id); try { persist(); } catch (error) { if (row) rows.set(id, row); throw error; } if (running?.id === id) running.controller.abort(); publish(); },
retry(id: string) { const row = rows.get(id); if (row?.status === "failed") { row.status = row.file ? "waiting" : "reselect"; delete row.error; save(); pump(); } },
snapshot(): AgentUpload[] { return [...rows.values()].map(row => ({ ...row })); },
ready(): AgentAttachment[] {
if ([...rows.values()].some(row => row.status !== "ready")) throw new Error("Wait for uploads, reselect or retry failed files, or remove them before sending.");
return [...rows.values()].map(row => row.attachment!);
},
clear() { rows.clear(); running?.controller.abort(); save(); },
dispose() { disposed = true; running?.controller.abort(); try { persist(); } catch { /* Existing stored retry IDs remain usable. */ } },
};
}
+172
View File
@@ -0,0 +1,172 @@
import type { Message } from "./api";
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
export type AgentOutboxRow = {
id: string; requestId: string; content: string; createdAt: string;
attachments?: AgentAttachment[]; status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
};
export type AgentOutboxStorage = {
read(): unknown; write(rows: AgentOutboxRow[]): void;
legacyRows?(): unknown; legacyIds?(): unknown;
};
type Receipt = { status: string; messageId?: string };
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
? message.metadata as Record<string, unknown> : {};
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
/** Local rows use the server's identity from their first render through final echo. */
export function createAgentOptimisticOutbox(options: {
submit(content: string, requestId: string, attachments?: AgentAttachment[]): Promise<Receipt>;
storage: AgentOutboxStorage;
changed(): void;
newId?: () => string;
now?: () => number;
}) {
const newId = options.newId || (() => crypto.randomUUID());
const now = options.now || Date.now;
const rows = new Map<string, AgentOutboxRow>();
const inFlight = new Map<string, Promise<void>>();
const rejectedStorage = new Set<string>();
const confirmedEchoes = new Map<string, Message>();
let latestTime = 0;
const timestamp = () => {
latestTime = Math.max(now(), latestTime + 1);
return new Date(latestTime).toISOString();
};
const restored = options.storage.read();
if (Array.isArray(restored)) {
for (const value of restored.slice(0, 100)) {
if (!value || typeof value.content !== "string" || (!value.content.trim() && !value.attachments?.length) || !validRequestId(value.requestId)) continue;
let attachments: AgentAttachment[] = [];
try { if (value.attachments !== undefined) { if (!Array.isArray(value.attachments) || value.attachments.length > 8) continue; attachments = value.attachments.map(parseAgentAttachment); } } catch { continue; }
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
latestTime = Math.max(latestTime, Date.parse(time));
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
...(attachments.length ? { attachments } : {}), status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
rows.set(row.id, row);
}
} else {
// Join the former UI-only failed cards to the old sender's receipt keys.
// Neither representation is retried automatically during migration.
const oldRows = options.storage.legacyRows?.();
const oldIds = options.storage.legacyIds?.();
const ids = new Map<string, string>();
if (Array.isArray(oldIds)) for (const value of oldIds) {
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
}
const contents = new Map<string, { content: string; status?: string; error?: string }>();
if (Array.isArray(oldRows)) for (const value of oldRows) {
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
}
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
for (const value of [...contents.values()].slice(0, 100)) {
const requestId = ids.get(value.content) || newId();
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
}
}
const persist = () => {
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
options.storage.write(pending.map(row => ({ ...row })));
};
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
const publish = () => options.changed();
const matches = (row: AgentOutboxRow, message: Message) => !row.attachments?.length || JSON.stringify(agentAttachments(message.metadata).map(file => file.id)) === JSON.stringify(row.attachments.map(file => file.id));
const dispatch = (row: AgentOutboxRow, onPersisted?: () => void): Promise<void> => {
const running = inFlight.get(row.id);
if (running) return running;
if (row.confirmed || row.seenOnServer) return Promise.resolve();
row.status = "sending"; delete row.error;
rejectedStorage.delete(row.id);
try { persist(); }
catch (error) {
// A refused new draft must not prevent older persisted drafts from
// draining their outbox. The refused row remains visibly retryable.
rejectedStorage.add(row.id);
row.status = "failed"; row.error = errorText(error); publish();
return Promise.reject(error);
}
onPersisted?.();
// Both durable local storage and the visible row exist before POST begins.
publish();
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId, row.attachments)).then(receipt => {
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
if (!row.confirmed) {
row.seenOnServer = true;
row.status = receipt.status === "queued" ? "queued" : "complete";
delete row.error; saveBestEffort(); publish();
}
}).catch(error => {
// A canonical echo wins over a lost HTTP response. The message has already
// been admitted, so a transport error must not paint it failed or resend it.
if (row.confirmed || row.seenOnServer) return;
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
throw error;
}).finally(() => { inFlight.delete(row.id); });
inFlight.set(row.id, pending);
return pending;
};
// Complete migration even when the user leaves restored drafts untouched:
// their newly assigned identity/time must survive the next reload too.
saveBestEffort();
return {
send(content: string, attachments: AgentAttachment[] = [], onPersisted?: () => void): Promise<void> {
if (!content.trim() && !attachments.length) return Promise.reject(new Error("Enter a message."));
const requestId = newId();
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, ...(attachments.length ? { attachments: attachments.map(parseAgentAttachment) } : {}), createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
rows.set(row.id, row);
return dispatch(row, onPersisted);
},
retry(messageId: string): Promise<void> {
const row = rows.get(messageId);
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
},
reconcile(room: RoomMessages): void {
const compatible = (message: Message) => { const row = rows.get(message.id); return !row || matches(row, message); };
const committed = new Map(room.messages.filter(compatible).map(message => [message.id, message]));
const queued = new Set((room.activity?.queuedMessages || []).filter(compatible).map(message => message.id));
let changed = false;
for (const row of rows.values()) {
if (committed.has(row.id)) {
confirmedEchoes.set(row.id, committed.get(row.id)!);
if (!row.confirmed || row.status !== "complete" || row.error) {
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
}
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
}
}
if (changed) { saveBestEffort(); publish(); }
},
display(serverMessages: readonly Message[]): Message[] {
const displayed = new Map(serverMessages.filter(message => { const row = rows.get(message.id); return !row || matches(row, message); }).map(message => {
const row = rows.get(message.id);
if (!row) return [message.id, message] as const;
const serverInfo = info(message);
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
return [message.id, { ...message, createdAt: row.createdAt,
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
}));
for (const row of rows.values()) if (!displayed.has(row.id)) {
const echo = confirmedEchoes.get(row.id);
if (row.confirmed && !echo) continue;
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
id: row.id, createdAt: row.createdAt, role: "user", content: row.attachments?.length ? attachmentCaption(row.content, row.attachments) : row.content, name: "buzzert",
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
});
}
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
};
}
+125
View File
@@ -0,0 +1,125 @@
import type { AgentRoomEvent, AgentRoomState } from './agent-room';
import type { Message } from './api';
/** Select transcript rows only; keep tool entries in room state for replay and activity. */
export function agentTranscriptMessages(messages: readonly Message[]): Message[] {
return messages.filter(message => message.role !== 'system' && message.role !== 'tool');
}
type Scheduler = {
now(): number;
schedule(callback: () => void, delay: number): unknown;
cancel(handle: unknown): void;
};
export type AgentRoomPresenterOptions = {
initial: AgentRoomState;
reduceEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState;
/** Publish room and transcript typing together in one UI state update. */
publish(state: AgentRoomState, isTyping: boolean): void;
batchMs?: number;
typingDelayMs?: number;
clock?: Scheduler;
};
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
const entryId = (message: Message): bigint | undefined => {
const value = data(message).entryId;
return (typeof value === 'string' && /^\d+$/.test(value)) || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0)
? BigInt(value) : undefined;
};
const toolId = (message: Message) => typeof data(message).toolCallId === 'string' ? String(data(message).toolCallId) : message.id;
/**
* Sybil Agent sends messages and activity as separate frames. This adapter publishes them
* as one room snapshot and latches transcript typing off when a new public reply
* arrives. An unchanged busy frame cannot restore the old turn's indicator.
* Actual activity remains untouched for the sidebar, workers, and queue display.
*/
export function createAgentRoomPresenter(options: AgentRoomPresenterOptions) {
const clock: Scheduler = options.clock || {
now: () => Date.now(),
schedule: (callback, delay) => setTimeout(callback, delay),
cancel: handle => clearTimeout(handle as ReturnType<typeof setTimeout>),
};
const batchMs = options.batchMs ?? 100;
const typingDelayMs = options.typingDelayMs ?? 250;
let state = options.initial;
let stopped = false;
let batchTimer: unknown;
let typingTimer: unknown;
let suppressTyping = false;
let candidateSince: number | undefined;
const seenTools = new Set(state.messages.filter(message => message.role === 'tool').map(toolId));
for (const tool of state.activity?.tools || []) seenTools.add(tool.id);
const reconcile = () => {
const work = Boolean(state.activity?.isThinking || (state.activity?.backgroundWorkers || 0) > 0);
if (!work || suppressTyping) candidateSince = undefined;
else if (candidateSince === undefined) candidateSince = clock.now();
};
const clearTypingTimer = () => { if (typingTimer !== undefined) clock.cancel(typingTimer); typingTimer = undefined; };
const flush = () => {
if (stopped) return;
if (batchTimer !== undefined) clock.cancel(batchTimer);
batchTimer = undefined; clearTypingTimer();
reconcile();
const isTyping = candidateSince !== undefined && clock.now() - candidateSince >= typingDelayMs;
options.publish(state, isTyping);
if (candidateSince !== undefined && !isTyping) typingTimer = clock.schedule(flush, Math.max(0, typingDelayMs - (clock.now() - candidateSince)));
};
const schedule = () => {
reconcile();
// A fixed maximum batch window avoids starving publication during bursts.
if (batchTimer === undefined) batchTimer = clock.schedule(flush, batchMs);
};
const release = () => { suppressTyping = false; };
const latch = () => {
suppressTyping = true;
candidateSince = undefined;
clearTypingTimer();
};
return {
/** replay:true is for initial historical loading, not ongoing catch-up. */
push(event: AgentRoomEvent, hints: { replay?: boolean } = {}) {
if (stopped) return;
const before = state;
const next = options.reduceEvent(before, event); // Validate/reduce before touching presentation state.
state = next;
if (event.type === 'messages') {
const existing = new Set(before.messages.map(message => message.id));
const fresh = event.messages.filter(message => !existing.has(message.id) && (entryId(message) ?? -1n) > BigInt(before.cursor))
.sort((left, right) => (entryId(left) || 0n) < (entryId(right) || 0n) ? -1 : 1);
if (!hints.replay) for (const message of fresh) {
const info = data(message);
if (message.role === 'assistant' && info.kind === 'message' && message.content.trim()) latch();
else if (suppressTyping && message.role === 'user') release();
else if (suppressTyping && message.role === 'tool' && info.status === 'initiated'
&& !quietTools.has(String(info.toolName || message.name)) && !seenTools.has(toolId(message))) release();
if (message.role === 'tool') seenTools.add(toolId(message));
}
for (const message of event.messages) if (message.role === 'tool') seenTools.add(toolId(message));
} else if (event.type === 'activity') {
const activity = event.activity;
if (suppressTyping && (!activity.isThinking
|| activity.tools.some(tool => !seenTools.has(tool.id) && !quietTools.has(tool.name)))) release();
for (const tool of activity.tools) seenTools.add(tool.id);
}
schedule();
},
/** Read-position updates share the pending snapshot; they never lose queued frames. */
update(change: (state: AgentRoomState) => AgentRoomState) {
if (stopped) return;
state = change(state); schedule();
},
flush,
dispose() {
stopped = true;
if (batchTimer !== undefined) clock.cancel(batchTimer);
batchTimer = undefined; clearTypingTimer();
// Unpublished input is deliberately discarded; reconnect replays from the
// last published cursor, so neither delivery nor read tracking gets ahead.
},
};
}
+232
View File
@@ -0,0 +1,232 @@
import type { Message } from "./api";
import { parseAgentAttachment, type AgentAttachment, type AgentAttachmentCapability } from "./agent-attachments.ts";
export type AgentActivity = {
state: "idle" | "thinking" | "tool" | "waiting" | "error";
isThinking: boolean;
queued: number;
backgroundWorkers: number;
tools: Array<{ id: string; name: string; status: string; output?: string }>;
queuedMessages: Message[];
wait?: { due: number; reason?: string };
error?: string;
};
export type AgentMessagesPage = {
conversationId: string; messages: Message[]; cursor: string | null;
beforeCursor: string | null; hasMore: boolean;
};
export type AgentDescriptor = {
id: string; title: string; model: string; activity: AgentActivity;
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
};
export type AgentReadState = { cursor: string; updatedAt: string | null };
export type AgentRoomState = {
conversationId: string | null; messages: Message[]; cursor: string;
readCursor: string; activity: AgentActivity | null;
};
export type AgentRoomEvent =
| ({ type: "messages" } & AgentMessagesPage)
| { type: "activity"; conversationId: string; activity: AgentActivity }
| { type: "ready"; conversationId?: string }
| { type: "pong" | "heartbeat" }
| { type: "error"; error?: string; message?: string };
const object = (value: unknown): Record<string, unknown> | undefined => value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown> : undefined;
const defined = (value: Record<string, unknown>) => Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
export function normalizeAgentCursor(value: unknown): string {
if (typeof value !== "string" || !/^\d+$/.test(value)) throw new Error("Invalid agent message cursor.");
return BigInt(value).toString();
}
export function advanceAgentCursor(current: string, incoming: string): string {
const a = normalizeAgentCursor(current), b = normalizeAgentCursor(incoming);
return BigInt(a) >= BigInt(b) ? a : b;
}
function metadata(message: Message) { return object(message.metadata); }
function messageCursor(message: Message): string | null {
const entryId = metadata(message)?.entryId;
if (typeof entryId === "number" && Number.isSafeInteger(entryId) && entryId >= 0) return String(entryId);
return typeof entryId === "string" && /^\d+$/.test(entryId) ? normalizeAgentCursor(entryId) : null;
}
function mergeMessage(previous: Message, incoming: Message): Message {
const prior = metadata(previous), next = metadata(incoming);
// A delayed activity snapshot must not demote an already placed user message.
if (prior?.status === "complete" && next?.status === "queued") return previous;
if (prior?.kind !== "tool_call" || next?.kind !== "tool_call") return { ...previous, ...incoming };
const terminal = prior.status === "completed" || prior.status === "failed";
const staleStart = terminal && next.status === "initiated";
const merged = staleStart ? { ...defined(next), ...defined(prior) } : { ...defined(prior), ...defined(next) };
if (!next.startedAt && prior.startedAt) merged.startedAt = prior.startedAt;
const oldArgs = object(prior.args), newArgs = object(next.args);
if (oldArgs && (!newArgs || Object.keys(newArgs).length === 0)) merged.args = oldArgs;
return { ...previous, ...incoming, ...(staleStart ? { content: previous.content, name: previous.name } : {}),
createdAt: previous.createdAt < incoming.createdAt ? previous.createdAt : incoming.createdAt, metadata: merged };
}
/** Replays and separate tool-start/result pages update stable rows instead of duplicating them. */
export function mergeAgentMessages(previous: readonly Message[], incoming: readonly Message[]): Message[] {
const messages = new Map(previous.map(message => [message.id, message]));
for (const message of incoming) {
const prior = messages.get(message.id);
messages.set(message.id, prior ? mergeMessage(prior, message) : message);
}
return [...messages.values()].sort((a, b) => {
const byTime = a.createdAt.localeCompare(b.createdAt);
if (byTime) return byTime;
const left = messageCursor(a), right = messageCursor(b);
if (left !== null && right !== null && left !== right) return BigInt(left) < BigInt(right) ? -1 : 1;
return a.id.localeCompare(b.id);
});
}
export const createAgentRoomState = (): AgentRoomState => ({
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
});
export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage): AgentRoomState {
if (state.conversationId !== null && state.conversationId !== page.conversationId) throw new Error("Agent conversation changed; reload the page.");
// Validate before changing state. Failed delivery/decoding cannot advance replay position.
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
}
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
return { ...state, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
}
export function applyAgentEvent(state: AgentRoomState, event: AgentRoomEvent): AgentRoomState {
if (event.type === "messages") return applyAgentPage(state, event);
if (event.type !== "activity") return state;
if (state.conversationId !== null && state.conversationId !== event.conversationId) throw new Error("Agent activity belongs to a different conversation.");
return { ...state, conversationId: event.conversationId, activity: event.activity };
}
export function agentUnreadCount(state: AgentRoomState): number {
return state.messages.filter(message => {
if (message.role !== "assistant" || metadata(message)?.kind !== "message") return false;
const cursor = messageCursor(message);
return cursor !== null && BigInt(cursor) > BigInt(state.readCursor);
}).length;
}
export function agentDisplayMessages(state: AgentRoomState): Message[] {
const committed = new Set(state.messages.map(message => message.id));
return mergeAgentMessages(state.messages, (state.activity?.queuedMessages || []).filter(message => !committed.has(message.id)));
}
export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
const event = object(JSON.parse(raw));
if (!event || typeof event.type !== "string") throw new Error("Invalid agent room frame.");
if (!["messages", "activity", "ready", "pong", "heartbeat", "error"].includes(event.type)) return null;
if (event.type === "messages") {
if (typeof event.conversationId !== "string" || !Array.isArray(event.messages) || typeof event.hasMore !== "boolean") throw new Error("Invalid agent messages frame.");
if (event.cursor !== null) normalizeAgentCursor(event.cursor);
for (const item of event.messages) {
const message = object(item);
if (!message || typeof message.id !== "string" || typeof message.content !== "string" || typeof message.createdAt !== "string"
|| !["system", "user", "assistant", "tool"].includes(String(message.role))) throw new Error("Invalid agent message.");
}
}
if (event.type === "activity" && (typeof event.conversationId !== "string" || !object(event.activity))) throw new Error("Invalid agent activity frame.");
return event as AgentRoomEvent;
}
export function agentAuthFrame(token: string | null, cursor: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
export type AgentRoomClientOptions = { baseUrl?: string; token?: () => string | null; fetch?: typeof globalThis.fetch };
export class AgentRoomClient {
private readonly baseUrl: string;
private readonly fetcher: typeof globalThis.fetch;
private readonly token: () => string | null;
constructor(options: AgentRoomClientOptions = {}) {
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
this.token = options.token || (() => null);
}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
if (init.body !== undefined && !(init.body instanceof FormData)) headers.set("Content-Type", "application/json");
const response = await this.fetcher(`${this.baseUrl}/v1/agent${path}`, { ...init, headers });
let value: unknown;
try { value = await response.json(); }
catch { throw new Error(`Agent API returned ${response.status} without a JSON response.`); }
if (!response.ok) {
const error = object(value);
throw new Error(typeof error?.message === "string" ? error.message : typeof error?.error === "string" ? error.error : `Agent API returned ${response.status}.`);
}
return value as T;
}
descriptor(signal?: AbortSignal): Promise<AgentDescriptor> { return this.request("", { signal }); }
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
}
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
markRead(cursor: string, signal?: AbortSignal): Promise<AgentReadState> {
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor) }), signal });
}
async submit(content: string, requestId: string, attachments: AgentAttachment[] = []): Promise<AgentSubmission> {
const receipt = object(await this.request<unknown>("/messages", { method: "POST", body: JSON.stringify({ content, requestId, ...(attachments.length ? { attachments: attachments.map(({ id }) => ({ id })) } : {}) }) }));
// An HTTP success alone does not acknowledge delivery. Keep the original
// outbox receipt key retryable if a proxy or upstream returns malformed data.
if (!receipt || typeof receipt.id !== "number" || !Number.isSafeInteger(receipt.id) || receipt.id <= 0
|| typeof receipt.status !== "string" || !["queued", "placed", "done", "unanswered"].includes(receipt.status)
|| receipt.messageId !== `inbound:sybil:${requestId}`
|| (attachments.length && JSON.stringify(receipt.attachmentIds) !== JSON.stringify(attachments.map(file => file.id)))) throw new Error("Agent API returned an invalid message receipt. Retry to confirm delivery.");
return receipt as AgentSubmission;
}
async upload(file: File, uploadId: string, signal?: AbortSignal): Promise<AgentAttachment> {
const body = new FormData(); body.append("uploadId", uploadId); body.append("file", file, file.name);
const value = await this.request<{ attachment: unknown }>("/attachments", { method: "POST", body, signal });
return parseAgentAttachment(value.attachment);
}
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
if (token) headers.set("Authorization", `Bearer ${token}`);
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); }
const blob = await response.blob();
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete.");
return blob;
}
websocketUrl(pageUrl: string): string {
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
}
/** Read every newer page, including pages containing only invisible internal entries. */
export async function syncAgentHistory(client: Pick<AgentRoomClient, "messages">, from: string,
apply: (page: AgentMessagesPage) => void, signal?: AbortSignal): Promise<void> {
let cursor = normalizeAgentCursor(from);
while (!signal?.aborted) {
const page = await client.messages(cursor, signal);
if (signal?.aborted) return;
const next = normalizeAgentCursor(page.cursor ?? cursor);
if (BigInt(next) < BigInt(cursor) || (page.hasMore && next === cursor)) throw new Error("Agent history did not advance its cursor.");
apply(page);
cursor = next;
if (!page.hasMore) return;
}
}
// Retained only to migrate receipt IDs from the former admission sender.
export const AGENT_ADMISSION_STORAGE_KEY = "sybil.agent.pending-admissions.v1";
/** Only successfully persisted, explicitly observed cursors become read state. */
export function createAgentReadWriter(options: {
current: () => string; save: (cursor: string) => Promise<AgentReadState>; apply: (read: AgentReadState) => void;
}) {
let desired = "0";
let running: Promise<void> | undefined;
const flush = (): Promise<void> => {
if (running) return running;
running = (async () => {
while (BigInt(desired) > BigInt(options.current())) {
const captured = desired;
const saved = await options.save(captured);
if (BigInt(normalizeAgentCursor(saved.cursor)) < BigInt(captured)) throw new Error("Agent read state did not acknowledge the seen messages.");
options.apply(saved);
}
})().finally(() => { running = undefined; });
return running;
};
return { request(cursor: string) { desired = advanceAgentCursor(desired, cursor); return flush(); }, flush };
}
+15 -1
View File
@@ -33,7 +33,9 @@ export type SearchWorkspaceItem = SearchSummary & {
type: "search";
};
export type WorkspaceItem = ChatWorkspaceItem | SearchWorkspaceItem;
export type WorkspaceItem = (ChatWorkspaceItem | SearchWorkspaceItem) & { searchSnippet?: string | null };
export type WorkspaceSearchResponse = { items: WorkspaceItem[]; hasMore: boolean };
export type Message = {
id: string;
@@ -228,6 +230,14 @@ export function getConfiguredToken() {
return ENV_ADMIN_TOKEN;
}
export function getAuthToken() {
return authToken;
}
export function getApiBaseUrl() {
return API_BASE_URL;
}
export function setAuthToken(token: string | null) {
authToken = token?.trim() || null;
}
@@ -272,6 +282,10 @@ export async function listWorkspaceItems() {
return data.items;
}
export function searchWorkspaceItems(query: string, signal?: AbortSignal) {
return api<WorkspaceSearchResponse>(`/v1/workspace-items?${new URLSearchParams({ q: query })}`, { signal });
}
export async function verifySession() {
return api<{ authenticated: true; mode: "open" | "token" }>("/v1/auth/session");
}
+8
View File
@@ -1,5 +1,13 @@
import type { Provider } from "./api";
export const QUICK_QUESTION_DEFAULT_MODEL = "chat-latest";
export function pickQuickQuestionModel(provider: Provider, options: string[], preferred: string | null = null) {
if (preferred?.trim()) return preferred.trim();
if (provider === "openai") return QUICK_QUESTION_DEFAULT_MODEL;
return options[0] ?? "";
}
export function buildQuickQuestionRequest({
provider,
model,
+15 -2
View File
@@ -1,6 +1,17 @@
export type SidebarSelection = { kind: "chat" | "search"; id: string };
export type HistorySelection = { kind: "chat" | "search"; id: string };
export type SidebarSelection = HistorySelection | { kind: "agent"; id: "root" };
type WorkspaceSelectionItem = { type: SidebarSelection["kind"]; id: string };
type WorkspaceSelectionItem = { type: HistorySelection["kind"]; id: string };
export function retainSelectedHistoryItem<T extends WorkspaceSelectionItem>(
items: T[], selection: SidebarSelection | null, retained: T | null
): T[] {
if (retained && selection?.kind === retained.type && selection.id === retained.id &&
!items.some((item) => item.type === retained.type && item.id === retained.id)) {
return [...items, retained];
}
return items;
}
type ResolveSidebarSelectionOptions = {
initialSelection?: SidebarSelection;
@@ -14,6 +25,8 @@ export function resolveSidebarSelectionAfterRefresh(
): SidebarSelection | null {
const hasItem = (candidate: SidebarSelection | null | undefined) => {
if (!candidate) return false;
// The persistent agent is always available, independent of saved history.
if (candidate.kind === "agent") return true;
return workspaceItems.some((item) => item.type === candidate.kind && item.id === candidate.id);
};
+24
View File
@@ -0,0 +1,24 @@
import type { WorkspaceSearchResponse } from "./api";
// Cleanup cancels both the debounce and fetch, and also rejects late responses
// from transports that finish after cancellation.
export function scheduleWorkspaceSearch(
query: string,
request: (query: string, signal: AbortSignal) => Promise<WorkspaceSearchResponse>,
onResult: (result: WorkspaceSearchResponse) => void,
onError: (error: unknown) => void
) {
const controller = new AbortController();
const timer = setTimeout(async () => {
try {
const result = await request(query, controller.signal);
if (!controller.signal.aborted) onResult(result);
} catch (error) {
if (!controller.signal.aborted) onError(error);
}
}, 300);
return () => {
clearTimeout(timer);
controller.abort();
};
}
+7 -2
View File
@@ -29,9 +29,14 @@ const config: Config = {
},
},
borderRadius: {
// Keep surfaces square at every size; rounded-full is reserved for dots.
DEFAULT: "var(--radius)",
sm: "var(--radius)",
md: "var(--radius)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
xl: "var(--radius)",
"2xl": "var(--radius)",
"3xl": "var(--radius)",
},
},
},
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
// Run against a local Vite server with an existing Playwright/Chromium installation:
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
// WEB_TEST_URL=http://127.0.0.1:5192 node tests/agent-attachments.browser.mjs
// Every API and Agent socket is mocked; this does not contact the deployed runtime.
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5192/');
assert.ok(target.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(target.hostname), 'Use a loopback development server');
const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABLE || undefined,headless:true,
...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})});
const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']});
await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token'));
const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[];
page.on('pageerror',e=>errors.push(e.stack || e.message));
const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1};
const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]};
let failedUpload=true, failedSend=true, live, cursor=0;const history=[];
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64');
const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`});
await page.route('**/api/**',async route=>{
const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value;
if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token');
if(p.endsWith('/attachments')&&m==='POST'){
const body=req.postDataBuffer().toString();uploads.push(body);
if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;}
await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return;
}
if(p.endsWith('/content')){downloads.push(req.url());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});return;}
if(p.endsWith('/agent/messages')&&m==='POST'){
const body=req.postDataJSON();sends.push(body);
if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;}
const files=(body.attachments||[]).map(f=>attachment(f.id.endsWith('abd')?'picture.png':'notes.txt'));
const row={id:`inbound:sybil:${body.requestId}`,role:'user',createdAt:new Date().toISOString(),content:body.content||`Attached: ${files.map(f=>f.filename).join(', ')}.`,metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:files}};history.push(row);
live?.send(JSON.stringify({type:'messages',conversationId:'1',messages:[row],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
await route.fulfill({status:202,json:{id:cursor,status:'queued',messageId:row.id,attachmentIds:files.map(f=>f.id)}});return;
}
if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false};
else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null};
else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
else if(p.includes('session'))value={mode:'token',authenticated:true};
else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]};
else if(p.endsWith('/models'))value={models:[],providers:{}};
else if(p.includes('stream'))value=[];
else value=[];
await route.fulfill({json:value});
});
await page.routeWebSocket('**/api/v1/agent/ws',socket=>{live=socket;socket.onMessage(raw=>{if(JSON.parse(String(raw)).type==='auth')socket.send(JSON.stringify({type:'ready',conversationId:'1'}));});});
try{
await page.goto(target.href,{waitUntil:'networkidle'});
await page.locator('[data-testid="agent-sidebar-item"]').click();
await page.getByRole('button',{name:'Attach files',exact:true}).waitFor();
const picker=page.getByLabel('Choose attachments');
await picker.setInputFiles({name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')});
await page.getByText('Storage quota reached',{exact:true}).waitFor();
assert.equal(await page.getByRole('button',{name:'Send message',exact:true}).isDisabled(),true);
await page.getByRole('button',{name:'Retry upload',exact:true}).click();
await page.getByText('Uploaded',{exact:true}).waitFor();
await page.getByRole('button',{name:/^New chat/}).first().click();await page.locator('.agent-room__composer-area').waitFor({state:'detached'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByText('Uploaded',{exact:true}).waitFor();
const uuid=body=>body.match(/name="uploadId"\r\n\r\n([^\r]+)/)?.[1];assert.equal(uuid(uploads[0]),uuid(uploads[1]));
await page.getByRole('button',{name:'Remove notes.txt'}).click();assert.equal(await page.locator('.agent-room__upload').count(),0);
await picker.setInputFiles([{name:'notes.txt',mimeType:'text/plain',buffer:Buffer.from('data')},{name:'picture.png',mimeType:'image/png',buffer:image}]);
await page.waitForFunction(()=>document.querySelectorAll('.agent-room__upload [role="status"]').length===2&&Array.from(document.querySelectorAll('.agent-room__upload [role="status"]')).every(e=>e.textContent==='Uploaded'));
await page.getByRole('button',{name:'Send message',exact:true}).click();await page.getByRole('button',{name:'Retry message',exact:true}).waitFor();
assert.deepEqual(sends[0].attachments,[{id:attachment().id},{id:attachment('picture.png').id}]);assert.equal(sends[0].content,'');
await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();await page.getByRole('button',{name:'Retry message',exact:true}).click();
await page.getByText('Attached: notes.txt, picture.png.',{exact:true}).waitFor();assert.equal(sends[1].requestId,sends[0].requestId);
await page.getByRole('button',{name:'Preview',exact:true}).click();await page.locator('.agent-attachment__preview').waitFor();assert.ok((await page.locator('.agent-attachment__preview').getAttribute('src')).startsWith('blob:'));
await page.getByRole('button',{name:'Hide preview',exact:true}).click();assert.equal(await page.locator('.agent-attachment__preview').count(),0);
const download=page.waitForEvent('download');await page.getByRole('button',{name:'Download',exact:true}).first().click();assert.equal((await download).suggestedFilename(),'notes.txt');assert.ok(downloads.every(url=>!url.includes('token')));
await page.getByRole('button',{name:'Copy path for notes.txt'}).click();
// Real paste/drop DOM events feed the same upload queue.
await page.getByRole('textbox',{name:'Message Sybil Agent'}).evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new ClipboardEvent('paste',{clipboardData:data,bubbles:true,cancelable:true}));});
await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click();
await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));});
await page.getByText('Uploaded',{exact:true}).waitFor();
await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT});
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, mobile layout');
}finally{await context.close();await browser.close();}
+101
View File
@@ -0,0 +1,101 @@
import test from 'node:test';
import { createHash, randomUUID } from 'node:crypto';
import assert from 'node:assert/strict';
import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '../src/lib/agent-attachments.ts';
import { AgentRoomClient } from '../src/lib/agent-room.ts';
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
const tick=()=>new Promise(r=>setTimeout(r,0));
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
let capability, attempts=[], next=0, changed=0, finish;
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
const input=new File(['data'],'notes.txt',{type:'text/plain'});
assert.throws(()=>queue.add([input]),/not available/);capability=cap;
assert.throws(()=>queue.add(Array(9).fill(input)),/8 files/);
assert.throws(()=>queue.add([new File([new Uint8Array(6*1024*1024+1)],'big.png',{type:'image/png'})]),/6 MiB/);
assert.throws(()=>queue.add(Array(5).fill(new File([new Uint8Array(7*1024*1024)],'large.bin'))),/32 MiB/);
queue.add([input]);assert.throws(()=>queue.ready(),/Wait/);await waitFor(()=>queue.snapshot()[0].status==='failed');
assert.equal(queue.snapshot()[0].status,'failed');assert.match(queue.snapshot()[0].error,/Quota/);
queue.retry('upload-1');await waitFor(()=>attempts.length===2);assert.deepEqual(attempts.map(a=>a.id),['upload-1','upload-1']);
queue.remove('upload-1');assert.equal(attempts.at(-1).signal.aborted,true);finish(file);await tick();assert.deepEqual(queue.ready(),[]);
assert.ok(changed>0);queue.dispose();
});
test('composer serializes transfers, keeps ready descriptors, and clears without orphaning pending state',async()=>{
const done=[];let count=0;
const queue=createAgentUploadQueue({capability:()=>cap,changed:()=>{},newId:()=>`id-${++count}`,upload:async()=>new Promise(r=>done.push(r))});
queue.add([new File(['data'],'notes.txt'),new File(['data'],'notes.txt')]);await waitFor(()=>done.length===1);assert.deepEqual(queue.snapshot().map(r=>r.status),['uploading','waiting']);
done[0](file);await waitFor(()=>done.length===2);assert.deepEqual(queue.snapshot().map(r=>r.status),['ready','uploading']);done[1]({...file,id:file.id.replace('12345678','12345679')});await waitFor(()=>queue.snapshot().every(row=>row.status==='ready'));assert.equal(queue.ready().length,2);queue.clear();assert.deepEqual(queue.ready(),[]);queue.dispose();
});
test('transport uses auth headers/FormData, ID-only send and checked receipt, authenticated download with no token URLs',async()=>{
const calls=[];let acknowledge=true;
const client=new AgentRoomClient({baseUrl:'/api',token:()=> 'browser-token',fetch:async(url,init)=>{
calls.push({url,init});assert.ok(!url.includes('token'));assert.equal(init.headers.get('Authorization'),'Bearer browser-token');
if(url.endsWith('/attachments')){assert.ok(init.body instanceof FormData);assert.equal(init.headers.get('Content-Type'),null);assert.equal(init.body.get('uploadId'),'uuid');return Response.json({attachment:file},{status:201});}
if(url.endsWith('/content'))return new Response('data',{headers:{'content-type':'text/plain'}});
const body=JSON.parse(init.body);assert.deepEqual(body,{content:'',requestId:'send-id',attachments:[{id:file.id}]});
return Response.json({id:1,status:'queued',messageId:'inbound:sybil:send-id',...(acknowledge?{attachmentIds:[file.id]}:{})});
}});
assert.deepEqual(await client.upload(new File(['data'],'notes.txt'),'uuid'),file);
assert.equal((await client.submit('','send-id',[file])).status,'queued');acknowledge=false;await assert.rejects(client.submit('','send-id',[file]),/invalid message receipt/);
assert.equal(await (await client.download(file)).text(),'data');assert.equal(calls.at(-1).init.cache,'no-store');assert.equal(calls.at(-1).init.redirect,'error');
});
test('file-only outbox survives reload and retry without sending paths, confirms matching attachment echoes and retains tool filtering',async()=>{
let saved, call, fail=true;
const options={storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},changed:()=>{},newId:()=> 'request',submit:async(content,requestId,attachments)=>{call={content,requestId,attachments};if(fail)throw new Error('lost response');return {status:'queued',messageId:'inbound:sybil:request'};}};
let outbox=createAgentOptimisticOutbox(options);await assert.rejects(outbox.send('',[file]));assert.deepEqual(saved[0].attachments,[file]);
outbox=createAgentOptimisticOutbox(options);assert.equal(outbox.snapshot().length,1);fail=false;await outbox.retry('inbound:sybil:request');assert.equal(call.content,'');assert.deepEqual(call.attachments,[file]);
const row=outbox.display([])[0];assert.equal(row.content,'Attached: notes.txt.');assert.deepEqual(agentAttachments(row.metadata),[file]);
outbox.reconcile({messages:[{...row,metadata:{kind:'message',status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,false);
const mismatch=outbox.display([{...row,metadata:{kind:'message',status:'complete'}}])[0];assert.deepEqual(agentAttachments(mismatch.metadata),[file]);assert.equal(mismatch.metadata.status,'queued');
outbox.reconcile({messages:[{...row,metadata:{...row.metadata,status:'complete'}}]});assert.equal(outbox.snapshot()[0].confirmed,true);
const raw=[row,{...row,id:'tool',role:'tool',metadata:{kind:'tool_call'}}];assert.equal(agentTranscriptMessages(raw).length,1);assert.equal(raw.length,2);
assert.throws(()=>parseAgentAttachment({...file,workspacePath:'https://bad'}));
});
test('attachment drafts preserve completed receipts and unresolved IDs/hash across disposal/reload, verified reselection resumes without a new ID',async()=>{
let saved=[], attempts=[], complete;
const storage={read:()=>saved,write:rows=>saved=structuredClone(rows)};
const options={capability:()=>cap,storage,changed:()=>{},upload:async(_file,id,signal)=>{attempts.push({id,signal});return new Promise(r=>complete=r);}};
let queue=createAgentUploadQueue(options);queue.add([new File(['data'],'notes.txt',{type:'text/plain'})]);
await waitFor(()=>Boolean(complete));const original=attempts[0].id;assert.equal(saved[0].sha256,file.sha256);
queue.dispose();assert.equal(attempts[0].signal.aborted,true);
queue=createAgentUploadQueue(options);assert.equal(queue.snapshot()[0].status,'reselect');assert.equal(queue.snapshot()[0].uploadId,original);assert.throws(()=>queue.ready(),/Wait/);
await assert.rejects(queue.reselect(original,new File(['evil'],'notes.txt',{type:'text/plain'})),/different contents/);assert.equal(attempts.length,1);
await queue.reselect(original,new File(['data'],'notes.txt',{type:'text/plain'}));await waitFor(()=>attempts.length===2);assert.equal(attempts[1].id,original);
complete(file);await tick();assert.equal(queue.ready()[0].id,file.id);queue.dispose();
const restored=createAgentUploadQueue(options);assert.deepEqual(restored.ready(),[file]);assert.equal(attempts.length,2,'completed upload is never repeated');restored.clear();assert.deepEqual(saved,[]);
});
test('mismatched complete echoes cannot hide local attachment errors or the retry action',async()=>{
const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{}},changed:()=>{},newId:()=> 'mismatch',submit:async()=>{throw new Error('quota failure');}});
await assert.rejects(box.send('caption',[file]));const initial=box.display([])[0];
for(const attachments of [undefined,[{...file,id:file.id.replace('12345678','12345679')}]]){
const echo={...initial,metadata:{kind:'message',status:'complete',...(attachments?{agentAttachments:attachments}:{})}};
box.reconcile({messages:[echo]});const shown=box.display([echo])[0];assert.equal(shown.metadata.status,'failed');assert.equal(shown.metadata.error,'quota failure');assert.deepEqual(shown.metadata.agentAttachments,[file]);assert.equal(box.snapshot()[0].confirmed,false);
}
});
test('storage refusal prevents uploads and outbox handoff; no attachment draft is cleared before persistence',async()=>{
let uploads=0;const queue=createAgentUploadQueue({capability:()=>cap,storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},upload:async()=>{uploads++;return file;}});
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/storage full/);await tick();assert.equal(uploads,0);
let cleared=false;const box=createAgentOptimisticOutbox({storage:{read:()=>null,write:()=>{throw new Error('storage full');}},changed:()=>{},submit:async()=>({status:'queued'})});
await assert.rejects(box.send('draft',[file],()=>{cleared=true;}));assert.equal(cleared,false);
});
test('a late canceled queue completion cannot overwrite a restored draft',async()=>{
let saved=[];const pending=[];
const options={capability:()=>cap,changed:()=>{},storage:{read:()=>saved,write:rows=>saved=structuredClone(rows)},upload:async()=>new Promise(resolve=>pending.push(resolve))};
const old=createAgentUploadQueue(options);old.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===1);
const oldId=old.snapshot()[0].uploadId;old.dispose();
const restored=createAgentUploadQueue(options);restored.remove(oldId);restored.add([new File(['data'],'notes.txt',{type:'text/plain'})]);await waitFor(()=>pending.length===2);
pending[1](file);await tick();const current=structuredClone(saved);assert.equal(current[0].status,'ready');assert.notEqual(current[0].uploadId,oldId);
pending[0](file);await tick();assert.deepEqual(saved,current);assert.deepEqual(restored.ready(),[file]);restored.dispose();
});
+139
View File
@@ -0,0 +1,139 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
const canonical = (id, content = "Hello", time = "2026-10-14T12:01:00.000Z") => ({
id, content, createdAt: time, role: "user", name: "buzzert",
metadata: { kind: "message", entryId: 123, status: "complete", channel: "sybil" },
});
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
function fixture(submit, legacy = {}) {
let stored = null;
let serial = 0;
const changes = [];
let box;
const storage = { read: () => structuredClone(stored), write: rows => { stored = structuredClone(rows); }, ...legacy };
const open = () => {
box = createAgentOptimisticOutbox({ submit, storage, newId: () => `request-${++serial}`, now: () => Date.parse("2026-10-14T12:00:00.000Z"), changed: () => changes.push(box?.snapshot()) });
return box;
};
return { open, changes, stored: () => stored, storage };
}
test("send publishes and persists one canonical row before POST, retaining it through receipt and echo", async () => {
const network = deferred(); let called = false;
const state = fixture(async (_content, requestId) => { called = true; assert.equal(requestId, "request-1"); return network.promise; });
const box = state.open();
const done = box.send("Hello");
const first = box.display([])[0];
assert.equal(called, false, "network starts after synchronous local publication");
assert.equal(first.id, "inbound:sybil:request-1");
assert.equal(first.metadata.status, "sending");
assert.equal(state.stored()[0].id, first.id);
assert.equal(state.changes.length, 1);
network.resolve({ status: "queued", messageId: first.id }); await done;
assert.equal(box.display([])[0].metadata.status, "queued");
assert.equal(box.display([])[0].createdAt, first.createdAt);
const echo = canonical(first.id);
box.reconcile({ messages: [echo] });
const displayed = box.display([echo]);
assert.equal(displayed.length, 1);
assert.equal(displayed[0].id, first.id);
assert.equal(displayed[0].createdAt, first.createdAt, "server receipt time cannot reorder the optimistic bubble");
assert.equal(displayed[0].metadata.status, "complete");
assert.deepEqual(state.stored(), []);
assert.equal(box.display([]).length, 1, "even a stale display snapshot cannot create an acknowledgment gap");
});
test("canonical echo before an HTTP failure wins and never paints a delivered bubble failed", async () => {
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
const done = box.send("Hello"); const row = box.display([])[0];
const echo = canonical(row.id);
box.reconcile({ messages: [echo] });
network.reject(new Error("response lost"));
await done;
assert.equal(box.display([echo])[0].metadata.status, "complete");
assert.equal(box.display([echo])[0].metadata.error, undefined);
assert.deepEqual(state.stored(), []);
});
test("queue echo before HTTP acknowledgment preserves one row when the queue snapshot disappears", async () => {
const network = deferred(); const state = fixture(() => network.promise); const box = state.open();
const done = box.send("Hello"); const row = box.display([])[0];
const queued = { ...canonical(row.id), metadata: { kind: "message", status: "queued" } };
box.reconcile({ messages: [], activity: { queuedMessages: [queued] } });
assert.equal(box.display([queued]).length, 1);
box.reconcile({ messages: [], activity: { queuedMessages: [] } });
assert.equal(box.display([])[0].id, row.id, "queue-to-history transfer must not hide the bubble");
network.reject(new Error("HTTP acknowledgment lost")); await done;
assert.equal(box.display([])[0].metadata.status, "queued");
});
test("failed retry and reload preserve the message ID, timestamp, and original receipt key", async () => {
const requests = []; let fail = true;
const state = fixture(async (content, requestId) => {
requests.push({ content, requestId });
if (fail) throw new Error("network down");
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
});
let box = state.open();
await assert.rejects(box.send("Hello"), /network down/);
const failed = box.display([])[0];
assert.equal(failed.metadata.status, "failed");
assert.equal(failed.metadata.error, "network down");
box = state.open();
assert.equal(requests.length, 1, "restoring drafts never sends automatically");
assert.equal(box.display([])[0].metadata.status, "failed");
assert.equal(box.display([])[0].createdAt, failed.createdAt);
fail = false;
const retry = box.retry(failed.id);
assert.equal(box.display([])[0].metadata.status, "sending");
await retry;
assert.deepEqual(requests.map(row => row.requestId), ["request-1", "request-1"]);
assert.equal(box.display([]).length, 1);
assert.equal(box.display([])[0].metadata.status, "complete");
assert.equal(box.display([])[0].createdAt, failed.createdAt);
});
test("in-flight reload becomes unconfirmed and two identical sends remain independent rows", async () => {
const first = deferred(), second = deferred(); let calls = 0;
const state = fixture(() => ++calls === 1 ? first.promise : second.promise); const box = state.open();
const send1 = box.send("Same content"), send2 = box.send("Same content");
const rows = box.display([]);
assert.equal(rows.length, 2);
assert.notEqual(rows[0].id, rows[1].id);
assert.ok(rows[0].createdAt < rows[1].createdAt, "same-millisecond sends retain insertion order");
const reloaded = state.open();
assert.deepEqual(reloaded.display([]).map(row => row.metadata.status), ["unconfirmed", "unconfirmed"]);
assert.deepEqual(reloaded.display([]).map(row => row.id), rows.map(row => row.id));
first.resolve({ status: "queued" }); second.resolve({ status: "queued" });
await Promise.all([send1, send2]);
});
test("legacy failed cards join their saved receipt keys without automatic delivery", () => {
const state = fixture(() => assert.fail("migration must not submit"), {
legacyRows: () => [{ content: "old message", status: "failed", error: "old error" }],
legacyIds: () => [{ content: "old message", requestId: "old-request" }],
});
const box = state.open();
assert.equal(box.display([])[0].id, "inbound:sybil:old-request");
assert.equal(box.display([])[0].metadata.status, "failed");
assert.equal(box.display([])[0].metadata.error, "old error");
const migrated = state.stored()[0];
assert.equal(migrated.id, "inbound:sybil:old-request");
assert.equal(state.open().display([])[0].createdAt, migrated.createdAt, "migration is saved before any retry");
});
test("storage failures still leave a failed inline row and prevent untracked POST", async () => {
let calls = 0, visible = 0;
const box = createAgentOptimisticOutbox({ submit: async () => { calls++; return { status: "queued" }; },
storage: { read: () => null, write: () => { throw new Error("storage unavailable"); } },
newId: () => "request", changed: () => visible++,
});
const sending = box.send("Do not lose this text");
assert.equal(box.display([])[0].metadata.status, "failed");
assert.equal(box.display([])[0].content, "Do not lose this text");
assert.equal(visible, 1);
await assert.rejects(sending, /storage unavailable/);
assert.equal(calls, 0);
});
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { agentDisplayMessages, agentUnreadCount, applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentRoomState, parseAgentRoomEvent } from '../src/lib/agent-room.ts';
import { agentTranscriptMessages, createAgentRoomPresenter } from '../src/lib/agent-room-presentation.ts';
const message = (entry, role = 'assistant', extra = {}) => ({ id: `entry:${entry}`, createdAt: '2026-10-14T00:00:00.000Z', role,
content: `Message ${entry}`, metadata: { kind: 'message', entryId: entry, status: 'complete' }, ...extra });
const page = (cursor, messages) => ({ type: 'messages', conversationId: '1', messages, cursor: String(cursor), beforeCursor: '0', hasMore: false });
const activity = (isThinking, extra = {}) => ({ type: 'activity', conversationId: '1', activity: {
state: isThinking ? 'thinking' : 'idle', isThinking, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [], ...extra,
} });
function fixture(initial = createAgentRoomState()) {
let now = 0, sequence = 0;
const tasks = new Map();
const frames = [];
const clock = { now: () => now, schedule(callback, delay) { const id = ++sequence; tasks.set(id, { at: now + delay, callback }); return id; }, cancel(id) { tasks.delete(id); } };
const presenter = createAgentRoomPresenter({ initial, reduceEvent: applyAgentEvent, publish: (room, isTyping) => frames.push({ room, isTyping }), clock });
const advance = ms => {
const end = now + ms;
while (true) {
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
if (!next || next[1].at > end) break;
now = next[1].at; tasks.delete(next[0]); next[1].callback();
}
now = end;
};
return { presenter, advance, frames, last: () => frames.at(-1) };
}
test('loaded history hides tool calls and results without removing records or changing normal messages', () => {
const user = message(1, 'user', { content: 'Explain this tool_call and tool_result JSON.' });
const reply = message(5, 'assistant', { content: '**Result**\n\nThe lookup failed; please try another source.' });
const tools = ['initiated', 'completed', 'failed'].map((status, index) => message(index + 2, 'tool', {
id: `tool:1:lookup-${index}`, name: 'lookup', content: 'Raw tool details',
metadata: { kind: 'tool_call', entryId: index + 2, toolCallId: `lookup-${index}`, status,
args: { query: 'raw input' }, resultPreview: 'raw output', error: status === 'failed' ? 'raw error' : null },
}));
const room = applyAgentPage(createAgentRoomState(), page(5, [message(0, 'system'), user, ...tools, reply]));
const original = structuredClone(room);
const transcript = agentTranscriptMessages(agentDisplayMessages(room));
assert.deepEqual(transcript, [user, reply]);
assert.strictEqual(transcript[0], user);
assert.strictEqual(transcript[1], reply);
assert.deepEqual(room, original, 'presentation must leave tool records and cursor intact');
assert.equal(room.messages.length, 6);
assert.equal(agentUnreadCount(room), 1);
});
test('tool-only history produces no transcript rows regardless of metadata or status', () => {
const messages = [null, {}, [], 'legacy metadata', { kind: 'tool_result' }, { kind: 'tool_call', status: 'failed' }]
.map((metadata, index) => message(index + 1, 'tool', { metadata, createdAt: `2026-10-${10 + index}T00:00:00.000Z` }));
assert.deepEqual(agentTranscriptMessages(Object.freeze(messages)), []);
assert.deepEqual(agentTranscriptMessages([]), []);
});
test('user delivery errors, queued messages, and assistant content remain visible even with tool-like metadata', () => {
const users = ['sending', 'queued', 'failed', 'unconfirmed'].map((status, index) => message(index + 1, 'user', {
metadata: { kind: 'message', status, error: 'Could not send; retry this message.' },
}));
const reply = message(5, 'assistant', { content: 'The tool_result contained an error. **Please retry.**',
metadata: { kind: 'tool_call', status: 'failed', error: 'Keep this assistant message.' },
});
const room = applyAgentEvent(applyAgentPage(createAgentRoomState(), page(5, [users[0], ...users.slice(2), reply])),
activity(false, { queued: 1, queuedMessages: [users[1]] }));
const transcript = agentTranscriptMessages(agentDisplayMessages(room));
assert.deepEqual(transcript, [...users, reply]);
for (const [index, original] of [...users, reply].entries()) assert.strictEqual(transcript[index], original);
});
for (const status of ['completed', 'failed']) {
test(`live ${status} tool updates and replay stay hidden while progress, replies, and cursors survive`, () => {
const f = fixture();
const user = message(1, 'user');
const started = message(2, 'tool', { id: 'tool:1:lookup', name: 'lookup',
metadata: { kind: 'tool_call', toolCallId: 'lookup', toolName: 'lookup', entryId: 2, status: 'initiated', args: { query: 'input' } },
});
const finished = { ...started, content: 'Raw tool output',
metadata: { kind: 'tool_call', toolCallId: 'lookup', entryId: 3, status, resultPreview: 'output', error: status === 'failed' ? 'Lookup failed' : null },
};
const progress = activity(true, { tools: [{ id: 'lookup', name: 'lookup', status: 'running' }], backgroundWorkers: 1 });
const receive = event => f.presenter.push(parseAgentRoomEvent(JSON.stringify(event)));
f.presenter.push(page(1, [user]), { replay: true });
receive(progress); f.advance(250);
for (const event of [page(2, [started]), page(3, [finished]), page(2, [started])]) {
receive(event); f.advance(100);
const { room, isTyping } = f.last();
assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(room)), [user]);
assert.equal(isTyping, true, 'tool visibility must not suppress ongoing progress');
assert.deepEqual(room.activity, progress.activity);
assert.equal(room.messages.length, 2, 'tool records still merge by their stable ID');
}
assert.equal(f.last().room.cursor, '3');
assert.equal(f.last().room.readCursor, '0');
assert.equal(agentUnreadCount(f.last().room), 0);
assert.equal(f.last().room.messages[1].metadata.status, status);
assert.deepEqual(f.last().room.messages[1].metadata.args, { query: 'input' });
assert.equal(f.last().room.messages[1].metadata.resultPreview, 'output');
const reply = message(4, 'assistant', { content: status === 'failed' ? 'The lookup failed. Please try again.' : 'Here is the answer.' });
receive(page(4, [reply])); f.advance(100);
assert.deepEqual(agentTranscriptMessages(agentDisplayMessages(f.last().room)), [user, reply]);
assert.equal(f.last().isTyping, false, 'the answer still clears the old typing indicator');
assert.equal(f.last().room.cursor, '4');
assert.equal(agentUnreadCount(f.last().room), 1);
f.presenter.update(room => applyAgentReadState(room, { cursor: '4', updatedAt: null }));
f.presenter.flush();
assert.equal(agentUnreadCount(f.last().room), 0);
assert.equal(f.last().room.messages.length, 3);
f.presenter.dispose();
});
}
test('a reply and removal of its old thinking indicator publish together even when idle is delayed', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(2, [message(2)])); f.advance(100);
assert.equal(f.last().room.messages.length, 1); assert.equal(f.last().isTyping, false);
assert.equal(f.last().room.activity.isThinking, true, 'raw sidebar activity remains unchanged');
f.presenter.push(activity(true)); f.advance(1000); assert.equal(f.last().isTyping, false, 'identical busy snapshots cannot revive stale typing');
f.presenter.push(activity(false)); f.advance(100);
assert.ok(f.frames.every(frame => !frame.room.messages.length || !frame.isTyping));
});
test('split messages/activity frames batch without reply-plus-thinking flashes or transient typing starts', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(50);
f.presenter.push(page(2, [message(1, 'user'), message(2)])); f.advance(20);
f.presenter.push(activity(false)); f.advance(30);
assert.equal(f.frames.length, 1); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().isTyping, false);
f.advance(1000); assert.equal(f.frames.length, 1);
});
test('new user work and new tools can start typing again, but delivery tools cannot', () => {
const f = fixture();
f.presenter.push(activity(true)); f.advance(250);
f.presenter.push(page(2, [message(2)])); f.advance(100);
f.presenter.push(activity(true, { tools: [{ id: 'send-reply', name: 'user_message', status: 'running' }] }));
f.advance(300); assert.equal(f.last().isTyping, false);
f.presenter.push(page(3, [message(3, 'user')])); f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(4, [message(4)])); f.advance(100); assert.equal(f.last().isTyping, false);
f.presenter.push(activity(true, { tools: [{ id: 'new-browser', name: 'browser', status: 'running' }] }));
f.advance(250); assert.equal(f.last().isTyping, true);
});
test('initial history and duplicate replay do not suppress genuine new activity', () => {
const f = fixture();
f.presenter.push(activity(true));
f.presenter.push(page(7, [message(5), message(7)]), { replay: true });
f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(7, [message(5), message(7)])); f.advance(100);
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.messages.length, 2);
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, false);
f.presenter.push(activity(false)); f.advance(100);
f.presenter.push(activity(true)); f.advance(250); assert.equal(f.last().isTyping, true);
f.presenter.push(page(9, [message(9)])); f.advance(100); assert.equal(f.last().isTyping, true);
});
test('independent background work remains visible after the root yields', () => {
const f = fixture();
f.presenter.push(activity(true, { backgroundWorkers: 1 })); f.advance(250);
f.presenter.push(page(2, [message(2)])); f.advance(100); assert.equal(f.last().isTyping, false);
f.presenter.push(activity(false, { backgroundWorkers: 1 })); f.advance(250);
assert.equal(f.last().isTyping, true); assert.equal(f.last().room.activity.isThinking, false);
});
test('bursts preserve users, tools, queued messages, cursor, and read state in bounded publications', () => {
const f = fixture();
f.presenter.push(page(1, [message(1, 'user')])); f.advance(50);
const tool = message(2, 'tool', { metadata: { kind: 'tool_call', toolCallId: 'browser-1', toolName: 'browser', status: 'initiated', entryId: 2 } });
f.presenter.push(page(2, [tool])); f.advance(40);
const queued = message(3, 'user', { metadata: { kind: 'message', status: 'queued' } });
f.presenter.push(activity(true, { queued: 1, queuedMessages: [queued] }));
f.presenter.update(state => applyAgentReadState(state, { cursor: '1', updatedAt: null })); f.advance(10);
assert.equal(f.frames.length, 1); assert.equal(f.last().room.cursor, '2'); assert.equal(f.last().room.readCursor, '1');
assert.equal(f.last().room.messages.length, 2); assert.deepEqual(f.last().room.activity.queuedMessages, [queued]);
f.presenter.push(page(4, [{ ...tool, metadata: { ...tool.metadata, status: 'completed', entryId: 4 } }]));
f.presenter.flush(); assert.equal(f.last().room.messages.length, 2); assert.equal(f.last().room.cursor, '4');
});
test('disposing an unpublished batch leaves delivery and read cursors at the last rendered snapshot', () => {
const f = fixture(); f.presenter.push(page(2, [message(2)])); f.presenter.dispose(); f.advance(1000);
assert.equal(f.frames.length, 0);
});
+210
View File
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
AgentRoomClient, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadWriter,
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
} from "../src/lib/agent-room.ts";
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
const message = (id, role = "assistant", extra = {}) => ({
id: `entry:${id}`, createdAt: `2026-10-14T10:00:${String(id % 60).padStart(2, "0")}.000Z`,
role, content: `message ${id}`, name: role === "assistant" ? "Sybil Agent" : "buzzert",
metadata: { kind: "message", entryId: id, status: "complete" }, ...extra,
});
const page = (cursor, messages = [], hasMore = false) => ({
conversationId: "1", messages, cursor, beforeCursor: messages.length ? String(messages[0].metadata.entryId) : cursor, hasMore,
});
const activity = (queuedMessages = []) => ({ state: "idle", isThinking: false, queued: queuedMessages.length, backgroundWorkers: 0, tools: [], queuedMessages });
test("tool starts and results merge across replay without downgrading completed cards", () => {
const start = message(2, "tool", { id: "tool:1:call1", metadata: { kind: "tool_call", entryId: 2, status: "initiated", args: { query: "cookie" }, startedAt: "2026-10-14T10:00:00Z" } });
const done = message(3, "tool", { id: start.id, metadata: { kind: "tool_call", entryId: 3, status: "completed", resultPreview: "found" } });
const messages = mergeAgentMessages(mergeAgentMessages([], [start]), [done, start]);
assert.equal(messages.length, 1);
assert.equal(messages[0].metadata.status, "completed");
assert.equal(messages[0].metadata.resultPreview, "found");
assert.deepEqual(messages[0].metadata.args, { query: "cookie" });
assert.equal(messages[0].metadata.entryId, 3);
assert.equal(messages[0].createdAt, start.createdAt);
});
test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => {
const requests = [];
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
let state = createAgentRoomState();
await syncAgentHistory({ messages: async after => { requests.push(after); return responses.shift(); } }, "0", update => { state = applyAgentPage(state, update); });
assert.deepEqual(requests, ["0", "2", "4"]);
assert.equal(state.cursor, "7");
assert.equal(agentUnreadCount(state), 3);
state = applyAgentReadState(state, { cursor: "5", updatedAt: null });
assert.equal(agentUnreadCount(state), 1);
state = applyAgentReadState(state, { cursor: "1", updatedAt: null });
assert.equal(state.readCursor, "5", "a stale tab response cannot regress read state");
assert.equal(agentUnreadCount(state), 1);
});
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
assert.equal(agentDisplayMessages(state).length, 1);
assert.equal(state.cursor, "0", "pending rows never advance replay or read cursors");
assert.equal(agentUnreadCount(state), 0);
state = applyAgentPage(state, page("4", [message(4, "user", { id: pending.id })]));
assert.equal(agentDisplayMessages(state).length, 1);
assert.equal(agentDisplayMessages(state)[0].metadata.status, "complete");
state = applyAgentEvent(state, { type: "activity", conversationId: "1", activity: activity() });
assert.equal(agentDisplayMessages(state).length, 1, "committed rows survive queue removal");
const onlyPending = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
assert.deepEqual(agentDisplayMessages(applyAgentEvent(onlyPending, { type: "activity", conversationId: "1", activity: activity() })), []);
});
test("failed or stalled page delivery preserves the last applied cursor for replay", async () => {
let state = createAgentRoomState();
let calls = 0;
await assert.rejects(syncAgentHistory({ messages: async () => {
if (++calls === 1) return page("10", [message(10)], true);
throw new Error("network unavailable");
} }, "0", next => { state = applyAgentPage(state, next); }), /network unavailable/);
assert.equal(state.cursor, "10");
await assert.rejects(syncAgentHistory({ messages: async () => page("10", [], true) }, state.cursor, next => { state = applyAgentPage(state, next); }), /did not advance/);
assert.equal(state.cursor, "10");
assert.throws(() => applyAgentPage(state, page("bad", [message(11)])), /cursor/);
assert.equal(state.messages.length, 1);
assert.throws(() => applyAgentPage(state, { ...page("11"), conversationId: "different" }), /conversation changed/);
assert.equal(advanceAgentCursor("9007199254740993", "9007199254740992"), "9007199254740993");
assert.throws(() => normalizeAgentCursor("-1"), /cursor/);
});
test("read acknowledgments use only the rendered cursor and failed persistence retains unread replies", async () => {
let state = applyAgentPage(createAgentRoomState(), page("5", [message(5)]));
const renderedCursor = state.cursor;
state = applyAgentPage(state, page("6", [message(6)]));
const saved = [];
let fail = true;
const writer = createAgentReadWriter({
current: () => state.readCursor,
save: async cursor => { saved.push(cursor); if (fail) throw new Error("database offline"); return { cursor, updatedAt: null }; },
apply: read => { state = applyAgentReadState(state, read); },
});
await assert.rejects(writer.request(renderedCursor), /database offline/);
assert.equal(state.readCursor, "0");
assert.equal(agentUnreadCount(state), 2);
fail = false;
await writer.flush();
assert.deepEqual(saved, ["5", "5"]);
assert.equal(state.readCursor, "5");
assert.equal(agentUnreadCount(state), 1, "message6 arrived after render and must remain unread");
});
test("read acknowledgments coalesce concurrent seen snapshots and reject unacknowledged cursors", async () => {
let cursor = "0";
let release;
const gate = new Promise(resolve => { release = resolve; });
const saves = [];
const writer = createAgentReadWriter({ current: () => cursor, save: async value => {
saves.push(value); if (value === "3") await gate; return { cursor: value, updatedAt: null };
}, apply: read => { cursor = read.cursor; } });
const first = writer.request("3"), second = writer.request("7");
release(); await Promise.all([first, second]);
assert.deepEqual(saves, ["3", "7"]);
assert.equal(cursor, "7");
const broken = createAgentReadWriter({ current: () => "0", save: async () => ({ cursor: "0", updatedAt: null }), apply: () => assert.fail("must not apply a failed acknowledgment") });
await assert.rejects(broken.request("2"), /did not acknowledge/);
});
test("failed persistence after acknowledgment retains the original receipt key across reload", async () => {
let stored = [], writes = 0;
const storage = { read: () => structuredClone(stored), write: rows => {
if (++writes === 3) throw new Error("storage unavailable after acknowledgment");
stored = structuredClone(rows);
} };
const requests = [];
const open = () => createAgentOptimisticOutbox({
storage, changed: () => {}, newId: () => "stable-request",
submit: async (_content, requestId) => {
requests.push(requestId);
return { status: "placed", messageId: `inbound:sybil:${requestId}` };
},
});
const original = open();
await original.send("retry me");
const accepted = original.display([])[0];
assert.equal(accepted.metadata.status, "complete", "a storage error cannot undo server acceptance");
assert.equal(stored[0].status, "sending", "the pre-admission receipt remains durable if its update fails");
const restored = open();
assert.deepEqual(requests, ["stable-request"], "reload never retries automatically");
const uncertain = restored.display([])[0];
assert.equal(uncertain.metadata.status, "unconfirmed");
assert.equal(uncertain.id, accepted.id);
assert.equal(uncertain.createdAt, accepted.createdAt);
await restored.retry(uncertain.id);
assert.deepEqual(requests, ["stable-request", "stable-request"], "manual retry cannot create a second admission");
restored.reconcile({ messages: [message(1, "user", { id: accepted.id, content: "retry me" })] });
assert.deepEqual(stored, [], "canonical echo finishes local cleanup");
});
test("client uses Sybil auth and routes while websocket credentials remain in the first frame", async () => {
const calls = [];
let token = "first-session-token";
const client = new AgentRoomClient({ baseUrl: "https://sybil.example/api/", token: () => token, fetch: async (url, init) => {
calls.push({ url, init }); return new Response(JSON.stringify(init.method === "POST"
? { id: 1, status: "queued", messageId: "inbound:sybil:request-1" } : { cursor: "0", updatedAt: null }), { status: 200 });
} });
await client.messages("0");
token = "second-session-token";
await client.markRead("42");
await client.submit("hi", "request-1");
assert.equal(calls[0].url, "https://sybil.example/api/v1/agent/messages?after=0&limit=200");
assert.equal(calls[0].init.headers.get("Authorization"), "Bearer first-session-token");
assert.equal(calls[1].init.headers.get("Authorization"), "Bearer second-session-token");
assert.equal(calls[1].init.method, "PUT");
assert.deepEqual(JSON.parse(calls[1].init.body), { cursor: "42" });
assert.deepEqual(JSON.parse(calls[2].init.body), { content: "hi", requestId: "request-1" });
const url = client.websocketUrl("https://sybil.example/");
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
assert.equal(new URL(url).search, "");
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
});
test("malformed successful receipts keep optimistic messages retryable under the original request ID", async () => {
const requests = [];
let response = {};
const client = new AgentRoomClient({ fetch: async (_url, init) => {
requests.push(JSON.parse(init.body));
return new Response(JSON.stringify(response), { status: 202 });
} });
const box = createAgentOptimisticOutbox({ submit: (content, requestId) => client.submit(content, requestId),
storage: { read: () => [], write: () => {} }, changed: () => {}, newId: () => "retry-id",
});
await assert.rejects(box.send("Keep this request"), /invalid message receipt/);
const row = box.display([])[0];
assert.equal(row.metadata.status, "failed");
assert.equal(box.snapshot()[0].seenOnServer, false);
const valid = { id: 1, status: "queued", messageId: row.id };
for (const malformed of [null, { error: "Invalid Sybil Agent response" }, { ...valid, id: 0 }, { ...valid, id: 1.5 },
{ ...valid, id: Number.MAX_SAFE_INTEGER + 1 }, { ...valid, status: "unknown" }, { ...valid, status: ["queued"] },
{ ...valid, messageId: "inbound:sybil:other" }]) {
response = malformed;
await assert.rejects(box.retry(row.id), /invalid message receipt/);
assert.equal(box.display([])[0].metadata.status, "failed");
}
response = valid;
await box.retry(row.id);
assert.equal(box.display([])[0].metadata.status, "queued");
assert.ok(requests.every(request => request.requestId === "retry-id"), "uncertain responses cannot mint a duplicate admission");
for (const status of ["placed", "done", "unanswered"]) {
response = { ...valid, status };
assert.equal((await client.submit("Keep this request", "retry-id")).status, status);
}
});
test("protocol ignores unknown frames and rejects malformed message cursors", () => {
assert.equal(parseAgentRoomEvent('{"type":"future-frame"}'), null);
assert.deepEqual(parseAgentRoomEvent('{"type":"pong"}'), { type: "pong" });
assert.throws(() => parseAgentRoomEvent(JSON.stringify({ type: "messages", ...page("oops") })), /cursor/);
const state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: { ...activity(), state: "thinking", isThinking: true } });
assert.equal(state.activity.isThinking, true);
assert.deepEqual(state.messages, [], "a thinking indicator never becomes a chat message");
});
+102
View File
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { readFile } from 'node:fs/promises';
import { resolve, extname, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
// Run after npm run build, with an existing Playwright/Chromium installation:
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
// node tests/pwa.browser.mjs
// This serves the real production bundle and service worker on loopback. No live API is used.
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
const root = fileURLToPath(new URL('../dist/', import.meta.url));
await readFile(resolve(root, 'sw.js'));
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.ttf': 'font/ttf' };
let sessionMode = 'token';
const server = createServer(async (request, response) => {
const path = new URL(request.url, 'http://localhost').pathname;
if (path.startsWith('/api/')) {
let status = 200;
let body;
if (sessionMode === 'unavailable') {
status = 503;
body = { message: 'Service temporarily unavailable' };
} else if (sessionMode !== 'open' && request.headers.authorization !== 'Bearer local-login-fixture') {
status = request.headers.authorization ? 403 : 401;
body = { message: status === 401 ? 'missing bearer token' : 'invalid bearer token' };
} else {
body = {
'/api/v1/auth/session': { authenticated: true, mode: sessionMode },
'/api/v1/workspace-items': { items: [] },
'/api/v1/models': { providers: {} },
'/api/v1/chat-tools': { tools: [] },
'/api/v1/active-runs': { chats: [], searches: [] },
}[path] ?? {};
}
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
response.end(JSON.stringify(body));
return;
}
const file = resolve(root, '.' + (path === '/' ? '/index.html' : path));
if (!file.startsWith(root.endsWith(sep) ? root : root + sep)) { response.writeHead(404); response.end(); return; }
try {
const bytes = await readFile(file);
response.writeHead(200, { 'Content-Type': types[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
response.end(bytes);
} catch { response.writeHead(404); response.end(); }
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
...(process.env.BROWSER_NO_SANDBOX === '1' ? { args: ['--no-sandbox'] } : {}) });
try {
const context = await browser.newContext({ serviceWorkers: 'allow' });
await context.route('https://fonts.googleapis.com/**', route => route.abort());
await context.route('https://fonts.gstatic.com/**', route => route.abort());
const page = await context.newPage();
const errors = []; page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${server.address().port}/`, { waitUntil: 'domcontentloaded', timeout: 15_000 });
// Activation must settle before a worker-controlled navigation can finish.
await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated', undefined, { timeout: 15_000 });
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 });
assert.equal(await page.getByRole('alert').count(), 0);
assert.equal(await page.getByRole('button', { name: 'Continue without token' }).count(), 0);
assert.equal(await page.getByText(/If.*ADMIN_TOKEN/).count(), 0);
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isDisabled(), true);
await page.getByPlaceholder('ADMIN_TOKEN').fill('incorrect-login-fixture');
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isEnabled(), true);
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.getByRole('alert').waitFor();
assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.');
await page.reload({ waitUntil: 'domcontentloaded', timeout: 10_000 });
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 });
assert.equal(await page.getByRole('alert').count(), 0);
assert.equal(await page.evaluate(() => navigator.serviceWorker.controller?.state), 'activated');
// Invalid saved credentials are a real failure, unlike a first visit without credentials.
await page.evaluate(() => localStorage.setItem('sybil_admin_token', 'outdated-login-fixture'));
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByRole('alert').waitFor();
assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.');
await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture');
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
await page.locator('.workspace-shell').waitFor();
assert.equal(await page.evaluate(() => localStorage.getItem('sybil_admin_token')), 'local-login-fixture');
await page.evaluate(() => localStorage.removeItem('sybil_admin_token'));
sessionMode = 'open';
await page.reload({ waitUntil: 'domcontentloaded' });
await page.locator('.workspace-shell').waitFor();
assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').count(), 0);
// Suppress only the expected missing-token response, never connectivity/server failures.
sessionMode = 'unavailable';
await page.reload({ waitUntil: 'domcontentloaded' });
await page.getByRole('alert').waitFor();
assert.equal(await page.getByRole('alert').textContent(), 'Service temporarily unavailable');
assert.deepEqual(errors, []);
console.log('PASS service-worker activation/reload, neutral first sign-in, rejected tokens, valid sign-in, automatic open-mode entry, and visible server failures');
} finally {
await browser.close();
server.closeAllConnections();
await new Promise(resolve => server.close(resolve));
}
+22 -1
View File
@@ -1,6 +1,27 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildQuickQuestionRequest } from "../src/lib/quick-question.ts";
import { buildQuickQuestionRequest, pickQuickQuestionModel } from "../src/lib/quick-question.ts";
test("quick questions default to chat-latest before and after the model catalog loads", () => {
for (const options of [[], ["gpt-4.1-mini"], ["gpt-4.1-mini", "chat-latest"]]) {
const model = pickQuickQuestionModel("openai", options);
assert.equal(model, "chat-latest");
assert.deepEqual(buildQuickQuestionRequest({ provider: "openai", model, content: "What is UTC?" }), {
provider: "openai",
model: "chat-latest",
question: "What is UTC?",
});
}
});
test("quick questions preserve saved models and use provider-specific choices when switching providers", () => {
assert.equal(pickQuickQuestionModel("openai", ["chat-latest"], " gpt-4.1-mini "), "gpt-4.1-mini");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini"], " "), "chat-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"]), "claude-3-5-sonnet-latest");
assert.equal(pickQuickQuestionModel("anthropic", ["claude-3-5-sonnet-latest"], "claude-3-haiku"), "claude-3-haiku");
assert.equal(pickQuickQuestionModel("anthropic", []), "");
assert.equal(pickQuickQuestionModel("openai", ["gpt-4.1-mini", "chat-latest"]), "chat-latest");
});
test("quick question requests use the dedicated server endpoint shape", () => {
const request = buildQuickQuestionRequest({
+8
View File
@@ -8,6 +8,14 @@ const workspaceItems = [
{ type: "search", id: "selected-search" },
];
test("persistent agent selection survives history refreshes and empty collections", () => {
const agent = { kind: "agent", id: "root" };
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, workspaceItems, { selectFallback: true }), agent);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(agent, []), agent);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(null, workspaceItems, { initialSelection: agent, selectFallback: true }), agent);
assert.deepEqual(resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems, { initialSelection: agent }), { kind: "chat", id: "selected-chat" });
});
test("a collection refresh preserves the current thread selection", () => {
assert.deepEqual(
resolveSidebarSelectionAfterRefresh({ kind: "chat", id: "selected-chat" }, workspaceItems),
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict";
import test from "node:test";
import { scheduleWorkspaceSearch } from "../src/lib/workspace-search.ts";
import { retainSelectedHistoryItem, resolveSidebarSelectionAfterRefresh } from "../src/lib/sidebar-selection.ts";
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
test("typing is debounced and superseded requests never start", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const queries = [];
const results = [];
const request = async (query) => { queries.push(query); return { items: [], hasMore: false }; };
const reportError = (error) => assert.fail(String(error));
const cancelFirst = scheduleWorkspaceSearch("ne", request, (result) => results.push(result), reportError);
t.mock.timers.tick(299);
assert.deepEqual(queries, []);
cancelFirst();
const cancelLast = scheduleWorkspaceSearch("nebula", request, (result) => results.push(result), reportError);
t.mock.timers.tick(300);
await flush();
assert.deepEqual(queries, ["nebula"]);
assert.deepEqual(results, [{ items: [], hasMore: false }]);
cancelLast();
});
test("cancellation aborts the transport and discards stale results and errors", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
let resolveFirst;
let rejectSecond;
let signal;
const results = [];
const errors = [];
const first = scheduleWorkspaceSearch("first", (_query, value) => {
signal = value;
return new Promise((resolve) => { resolveFirst = resolve; });
}, (result) => results.push(result), (error) => errors.push(error));
t.mock.timers.tick(300);
first();
assert.equal(signal.aborted, true);
const second = scheduleWorkspaceSearch("second", () => new Promise((_resolve, reject) => { rejectSecond = reject; }),
(result) => results.push(result), (error) => errors.push(error));
t.mock.timers.tick(300);
second();
resolveFirst({ items: [{ id: "stale" }], hasMore: true });
rejectSecond(new Error("stale failure"));
await flush();
assert.deepEqual(results, []);
assert.deepEqual(errors, []);
});
test("search results retain excerpts and truncation info; failures reach the UI", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const expected = { items: [{ type: "chat", id: "old", searchSnippet: "A nebula in the sky" }], hasMore: true };
let received;
let error;
const cancel = scheduleWorkspaceSearch("nebula", async () => expected, (result) => { received = result; }, assert.fail);
t.mock.timers.tick(300);
await flush();
assert.deepEqual(received, expected);
cancel();
const cancelFailure = scheduleWorkspaceSearch("nebula", async () => { throw new Error("Offline"); }, assert.fail, (result) => { error = result; });
t.mock.timers.tick(300);
await flush();
assert.equal(error.message, "Offline");
cancelFailure();
});
test("opening older chat/search results survives collection refresh without restoring deleted or unrelated items", () => {
const recent = [{ type: "chat", id: "recent" }];
for (const kind of ["chat", "search"]) {
const selection = { kind, id: "old" };
const retained = { type: kind, id: "old" };
const refreshed = retainSelectedHistoryItem(recent, selection, retained);
assert.deepEqual(resolveSidebarSelectionAfterRefresh(selection, refreshed), selection);
assert.equal(retainSelectedHistoryItem(refreshed, selection, retained), refreshed);
assert.equal(retainSelectedHistoryItem(recent, { kind, id: "different" }, retained), recent);
assert.equal(retainSelectedHistoryItem(recent, selection, null), recent);
assert.equal(retainSelectedHistoryItem(recent, null, retained), recent);
}
});
+1 -1
View File
@@ -6,7 +6,7 @@
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": false,
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,