Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80748f764f | ||
|
|
2e62bfa736 | ||
|
|
844f48e9e0 | ||
|
|
8b125c13a5 | ||
|
|
4cbab487d0 | ||
|
|
56279fba7f | ||
|
|
153aa5d2fa | ||
|
|
4389f805f4 | ||
|
|
e83281c400 | ||
|
|
ef3d79c428 | ||
|
|
dd6cd1ea7b | ||
|
|
3f9c1409fe | ||
|
|
411a8cebc7 | ||
|
|
14191de8f4 | ||
|
|
4ef48fad82 | ||
|
|
21beab26f1 | ||
|
|
050a46d7a8 | ||
|
|
3217c3f66e | ||
|
|
3a9c9bb74a | ||
|
|
04ac39499f |
Vendored
+7
-1
@@ -1,3 +1,8 @@
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
"" close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -19,7 +24,8 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Connection "";
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
}
|
||||
|
||||
location = /sw.js {
|
||||
|
||||
+137
-2
@@ -23,6 +23,8 @@ Chat upload limits:
|
||||
|
||||
### `GET /v1/auth/session`
|
||||
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
|
||||
- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`).
|
||||
- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass.
|
||||
|
||||
## Models
|
||||
|
||||
@@ -31,7 +33,7 @@ Chat upload limits:
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openai": { "models": ["gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"openai": { "models": ["chat-latest", "gpt-4.1-mini"], "loadedAt": "2026-02-14T00:00:00.000Z", "error": null },
|
||||
"anthropic": { "models": ["claude-3-5-sonnet-latest"], "loadedAt": null, "error": null },
|
||||
"xai": { "models": ["grok-3-mini"], "loadedAt": null, "error": null },
|
||||
"gemini": { "models": ["gemini-3.5-flash"], "loadedAt": null, "error": null },
|
||||
@@ -39,7 +41,7 @@ Chat upload limits:
|
||||
}
|
||||
}
|
||||
```
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation.
|
||||
- OpenAI model lists are filtered to models that are expected to work with the backend's Responses API implementation, including the exact `chat-latest` alias when returned by OpenAI. Audio, image-generation, embedding, moderation, and other specialized model ids remain excluded. See [OpenAI's Chat Latest documentation](https://developers.openai.com/api/docs/models/chat-latest) for supported capabilities.
|
||||
- Gemini model lists are loaded from Google's native Models API and filtered to Gemini `generateContent` model ids.
|
||||
- `hermes-agent` is included only when `HERMES_AGENT_API_KEY` is configured. Set it to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth. `HERMES_AGENT_API_BASE_URL` defaults to `http://127.0.0.1:8642/v1`; set `HERMES_AGENT_MODEL` only when you need an additional fallback/override model id.
|
||||
- The backend loads provider model lists at startup and refreshes them about once every 24 hours. If a later provider refresh fails, the response keeps the last loaded model list for that provider and sets `error` to the latest failure message.
|
||||
@@ -81,6 +83,8 @@ Behavior notes:
|
||||
|
||||
### `GET /v1/workspace-items`
|
||||
- Response: `{ "items": WorkspaceItem[] }`
|
||||
- Optional query: `q` (trimmed string, up to 200 characters). Omit or leave blank for the recent-items list.
|
||||
- With nonblank `q`, response: `{ "items": WorkspaceItem[], "hasMore": boolean }`; each item also has `searchSnippet: string | null` (plain text, no HTML).
|
||||
- `WorkspaceItem` is a discriminated union sorted by `updatedAt` descending:
|
||||
```json
|
||||
{
|
||||
@@ -123,6 +127,15 @@ Behavior notes:
|
||||
- Root chats have `parentChatId: null`. Every fork points directly to its single root chat, including a fork created from another fork, so clients can group rows without traversing a fork chain.
|
||||
- `starred`/`starredAt` are backed by membership in a reserved `Project` with id `starred`; future project folders can reuse the same project item model.
|
||||
|
||||
History search (`q`) notes:
|
||||
- Searches all saved history, including items outside the recent-items window. Chats match their title or the text of a user/assistant message; saved web searches match their title, query, or generated answer. System prompts, tool logs, attachments, and raw web-result payloads are excluded.
|
||||
- Case- and diacritic-insensitive Unicode word-prefix matching: `plan` matches `planning`. All query words must match within the title, a single message, or one saved web search (across its indexed fields). Punctuation separates words; quotes, `*`, and operators have no special query syntax. Punctuation-only queries return no matches. This is word-prefix search, not arbitrary substring matching.
|
||||
- Returns at most 100 distinct matching items, newest `updatedAt` first, with ties ordered by `type` then `id`. `hasMore` indicates additional matches; refine `q` to narrow them. Unmatched fork roots are not added in search mode. Selecting a fork result opens that fork directly. Search-mode fork results carry their root's `starred`/`starredAt`; clients should star/unstar the `parentChatId`, as in the grouped sidebar.
|
||||
- `searchSnippet` is a bounded excerpt from a matching message or saved web search; title-only chat matches have `null`. Clients should render it as plain text.
|
||||
- The web and iOS sidebar search fields use this endpoint across saved history. Both display matching excerpts; web title-only chat results display “Title match”.
|
||||
- Invalid/non-string queries or queries exceeding 200 characters return `400`. Authentication is unchanged.
|
||||
- SQLite FTS5 external-content indexes and prefix indexes serve these queries without scanning or transferring every transcript. The migration backfills existing content; transactional triggers maintain the indexes on inserts, updates, deletes, and cascades. Recent chat listing also has an `updatedAt` index.
|
||||
|
||||
## Chats
|
||||
|
||||
### `GET /v1/chats`
|
||||
@@ -551,3 +564,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
@@ -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 |
@@ -24,7 +24,8 @@ let package = Package(
|
||||
name: "Sybil",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
]),
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
.testTarget(
|
||||
name: "SybilTests",
|
||||
dependencies: ["Sybil"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"filename": "character-still.png",
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -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
@@ -5,6 +5,146 @@ Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
|
||||
project from `project.yml`. The Mac recipes use ad-hoc signing for local development;
|
||||
release/archive signing settings are unchanged.
|
||||
|
||||
The conversation sidebar includes **Search chats and web searches** on iPhone,
|
||||
iPad, and Mac. It searches all saved history by title, conversation text, and saved
|
||||
web-search query/answer, with single-line excerpts that bold matching words and
|
||||
truncate in the middle. Search waits briefly while typing,
|
||||
cancels superseded requests, and leaves the current conversation open. Clear the
|
||||
field to return to recent items. Searches use word prefixes (for example, `plan`
|
||||
finds `planning`); if more than 100 items match, refine the query.
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
The permanent **Sybil Agent** sidebar item opens the persistent room on both
|
||||
phone and iPad. It uses the configured Sybil API URL (including an explicit
|
||||
`/api` path) and token. The room stays subscribed while other chats are selected;
|
||||
foreground and network recovery replay from the last merged cursor. Shared unread
|
||||
counts include only public assistant replies and are acknowledged after the
|
||||
visible transcript reaches the bottom and its entrance animation finishes.
|
||||
|
||||
Multiple messages can be queued while the Agent works. The local outbox is saved
|
||||
atomically under Application Support before submission and is scoped to the API
|
||||
base URL. An interrupted send stays on its original bubble with **Retry**, retaining
|
||||
its request ID and timestamp across relaunch. A canonical server echo confirms it
|
||||
even if the HTTP acknowledgement is lost. Raw tool calls and results are hidden
|
||||
in the Agent transcript, including loaded history and live updates. History windows,
|
||||
date separators and scroll anchors use visible messages; raw merged history and
|
||||
cursors remain intact. Send failures, room errors and activity indicators remain
|
||||
visible. Model selection and tool cards continue to belong to ordinary chats.
|
||||
|
||||
Agent contracts remain in `../docs/api/rest.md` and
|
||||
`../docs/api/streaming-chat.md`. ROOT attachments consume the coordinated v1
|
||||
contract (2026-10-14), now documented in the integrated API sources above.
|
||||
Attachment readiness requires the coordinated backend/runtime and existing public
|
||||
API bearer authentication. This iOS change does not deploy or configure them.
|
||||
|
||||
### ROOT attachments
|
||||
|
||||
The Agent composer exposes Files and Photo Library only when the authenticated
|
||||
descriptor advertises supported `capabilities.attachments.version == 1`. Missing,
|
||||
malformed, or unsupported capability keeps the client text-only. Ordinary chat
|
||||
attachments retain their existing inline format and behavior.
|
||||
Open-proxy HTTP 503 / WebSocket 4503 failures disable attachment selection while
|
||||
retaining draft descriptors and outbox retry identities; reconnect checks support.
|
||||
|
||||
Files preserve their original bytes, including PDF, archives, text and HEIC files.
|
||||
Photo Library HEIC/HEIF selections are intentionally converted to bounded JPEG
|
||||
visual input with a matching `.jpg` filename. PNG/JPEG/GIF/WebP are validated as
|
||||
raster images; model input is static, with no claim of document or animation
|
||||
understanding. Safe original names supplied by the picker are retained; PhotosPicker
|
||||
may provide an export filename rather than the original Photo Library asset name.
|
||||
The production app does not request broad Photo Library access.
|
||||
|
||||
Selection order is stable. One preparation/upload runs at a time, with per-item
|
||||
loading, upload, error, retry and removal. Sending requires every item to be ready;
|
||||
an empty caption can send ready attachments. Advertised limits are checked against
|
||||
v1 hard ceilings (8 files, 32 MiB total, 6 MiB/image, 8 MiB/file, 50 million pixels,
|
||||
16,384 pixels/axis). Quota snapshots are decoded but do not reserve space. Server
|
||||
messages/codes, quota details and Retry-After survive failures, including 409, 413,
|
||||
429 and 507. Removal cancels local work and removes the reference; it does not
|
||||
delete an uploaded server original.
|
||||
|
||||
The caption, ordered upload UUIDs and ready descriptors are saved in a small atomic
|
||||
manifest under Application Support, scoped to the API base URL. Prepared bytes are
|
||||
staged in app-owned files (at most 8 MiB each and 32 MiB per draft); no inline bytes
|
||||
or tokens enter the outbox. Relaunch restores ready drafts and offers explicit retry
|
||||
for interrupted staged uploads with the same upload UUID. A selection interrupted
|
||||
before staging must be removed and selected again. Uploads suspend when the room
|
||||
stops; failed work does not automatically retry. Uploaded originals have no client
|
||||
expiration. After submission, the outbox owns the caption, ordered descriptors and
|
||||
stable request ID. A persisted draft identity recovers a crash during this handoff
|
||||
without creating a second send. Storage failure blocks submission rather than
|
||||
discarding a draft. Attachment receipts must acknowledge exactly the submitted IDs
|
||||
in order; matching canonical echoes still win ambiguous HTTP failures.
|
||||
|
||||
History and queued/live messages decode only `metadata.agentAttachments` for ROOT.
|
||||
Cards retain filename, MIME, size and the optional server-provided `workspacePath`,
|
||||
which can be copied. The client never constructs a runtime path or sends one as a
|
||||
reference; leading-dot display filenames remain unchanged. Captions remain public
|
||||
text, including the server's fallback for attachment-only messages.
|
||||
|
||||
Original downloads use the configured authenticated URLSession and API base
|
||||
(including `/api`), reject redirects, and verify exact size and SHA-256. At most two
|
||||
downloads run concurrently, with a 16 MiB in-memory cache discarded on configuration
|
||||
change. Queued/active cancellation and generation checks prevent stale previews
|
||||
from crossing configurations. Raster previews use bounded thumbnails; text previews
|
||||
are plain text, including HTML/SVG. Save/share exports exact original bytes to a
|
||||
temporary file removed when its card disappears or configuration changes. An app
|
||||
crash may leave a temporary export for normal OS temporary-directory cleanup.
|
||||
Image preview frames have fixed height to preserve scroll anchors during loading.
|
||||
Attachment modals track their own visibility without changing Quick Question state.
|
||||
|
||||
### Agent regression and Simulator checks
|
||||
|
||||
Run these commands from this worktree's `ios/` directory:
|
||||
|
||||
```sh
|
||||
just build
|
||||
just test
|
||||
just run
|
||||
just screenshot build/agent-evidence/simulator.png
|
||||
```
|
||||
|
||||
`just test` runs reducer, cursor, outbox, presentation, shared-read and lifecycle
|
||||
regressions along with attachment capability/metadata, multipart/auth/bytes/errors,
|
||||
HEIC conversion versus original-file preservation, ordered receipts, draft/outbox
|
||||
relaunch, cancellation, two-transfer concurrency and configuration isolation tests
|
||||
through Xcode on iPhone 16e.
|
||||
For interactive UI coverage, start the local fixture (Python with `aiohttp`):
|
||||
|
||||
```sh
|
||||
python3 Tests/Fixtures/agent_server.py
|
||||
```
|
||||
|
||||
In a second terminal, run:
|
||||
|
||||
```sh
|
||||
just generate
|
||||
xcodebuild test -scheme SybilAgentUI \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||
-parallel-testing-enabled NO \
|
||||
-resultBundlePath build/agent-evidence/phone-ui.xcresult
|
||||
```
|
||||
|
||||
The fixture binds only to `127.0.0.1:8799`, uses `fixture-token`, and never contacts
|
||||
external services. UI tests exercise the real REST/WebSocket client and retain
|
||||
screenshots in the result bundle. They cover queued submissions, uncertain
|
||||
admission/retry, tools, asynchronous replies, inactive sidebar unread counts,
|
||||
shared reads, reconnect/malformed replay, foreground recovery, long-history scroll
|
||||
anchors and multiline keyboard layout. The launch arguments select the fixture
|
||||
through ordinary API settings; no fixture mode is compiled into the application.
|
||||
Attachment cases exercise authenticated originals, safe text/raster display,
|
||||
history reload, capability absence and picker cancellation. The real Photo Library
|
||||
selection-to-send test is explicitly skipped: this iOS 26.5 XCTest environment has
|
||||
unreliable Photos authorization, callback isolation and synthetic-asset cleanup.
|
||||
Selection-to-send remains a manual/device pre-release check, not a validated UI
|
||||
path. The gate prevents photo mutations during the general UI suite. Synthetic
|
||||
HEIC conversion and original HEIC file preservation are covered by package tests.
|
||||
The fixture accepts multipart uploads, stable upload/message retries, structured
|
||||
errors and attachment receipts; it is not a runtime image-decoder or quota test.
|
||||
Use a fresh result-bundle path for each run. Check the same UI on iPad and with
|
||||
Simulator Reduce Motion enabled before release.
|
||||
|
||||
## Quick Question on macOS
|
||||
|
||||
While the Catalyst app is running, **Option+Space** toggles a floating Quick
|
||||
@@ -16,8 +156,13 @@ second **Option+Space**, the close button, or clicking outside dismisses it.
|
||||
Hiding the panel preserves the draft and answer and lets an in-flight answer finish.
|
||||
**Open in Chat** saves the question and answer in the regular workspace.
|
||||
|
||||
Quick Question defaults to OpenAI `chat-latest` on iPhone, iPad, and Mac. Its saved
|
||||
provider/model selection takes precedence and is separate from regular chat preferences.
|
||||
|
||||
The regular Mac chat/search composer uses the same Return/Shift+Return rules.
|
||||
Holding Return does not repeatedly submit. The iOS software keyboard is unchanged.
|
||||
Holding Return does not repeatedly submit. On iOS, Return submits from Quick
|
||||
Question, while the normal chat/search composer continues to use Return for a
|
||||
new line.
|
||||
|
||||
Closing the last Mac window leaves Sybil running, so Option+Space still works.
|
||||
Use **Sybil → Quit Sybil** or **Command+Q** to quit the application. The Catalyst
|
||||
@@ -66,7 +211,9 @@ action, and does not build or embed the macOS helper.
|
||||
answer remain available. Reopen the workspace from the panel, then explicitly
|
||||
quit with Command+Q. Rebuild and relaunch after changing lifecycle plist keys.
|
||||
- In both Mac and iOS Quick Question, reopen an existing prompt and type to replace
|
||||
it. In the Mac workspace, verify Return submits and Shift+Return adds a newline.
|
||||
it. On iOS, verify Return submits Quick Question while Return in the normal
|
||||
prompt adds a newline. In the Mac workspace, verify Return submits and
|
||||
Shift+Return adds a newline.
|
||||
- Check Command+N both with a workspace open and after closing every workspace.
|
||||
Command+Shift+N must open another window even when one already exists, and
|
||||
Command+Option+N must start a search without opening a window.
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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
@@ -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}}'; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -106,3 +106,27 @@ When `chatId` is provided to completion endpoints, you can send full conversatio
|
||||
- User accounts + per-device API keys
|
||||
- Postgres support + migrations for prod
|
||||
- Attachments + embeddings + semantic search
|
||||
|
||||
## Sybil Agent
|
||||
|
||||
Configure the backend with:
|
||||
|
||||
```dotenv
|
||||
SYBIL_AGENT_BASE_URL=http://sybil-agent:8788
|
||||
SYBIL_AGENT_API_KEY=<the Sybil Agent service API key>
|
||||
```
|
||||
|
||||
Use the same `SYBIL_AGENT_API_KEY` value configured on the agent service.
|
||||
The base URL defaults to `http://sybil-agent:8788` and has no `/v1` suffix. In Docker,
|
||||
attach Sybil's **server** container and Sybil Agent to the shared `agents` network so that
|
||||
`sybil-agent` resolves privately. Supply the key through a private server environment
|
||||
file; it is never a `VITE_*` variable or browser setting. Keep the existing Sybil
|
||||
`ADMIN_TOKEN` authentication for clients.
|
||||
|
||||
The Sybil Agent tab uses `/v1/agent`, `/v1/agent/messages`, and `/v1/agent/ws` to proxy the
|
||||
same persistent root conversation across devices. Sybil Agent remains the source of
|
||||
truth for messages and background replies. Sybil stores only the shared,
|
||||
monotonically increasing read cursor in SQLite through `/v1/agent/read-state`;
|
||||
its migration is applied automatically at startup. The bundled nginx configuration
|
||||
forwards WebSocket upgrades. See `docs/api/rest.md` and
|
||||
`docs/api/streaming-chat.md` at the repository root for the client contracts.
|
||||
|
||||
Generated
+33
@@ -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",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Readable } from 'node:stream';
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { requireAdmin } from './auth.js';
|
||||
import { env } from './env.js';
|
||||
|
||||
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
|
||||
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
|
||||
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
|
||||
export const AgentAttachmentDescriptor = z.object({
|
||||
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
|
||||
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
|
||||
});
|
||||
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
|
||||
|
||||
function apiError(statusCode: number, code: string, message: string) {
|
||||
return Object.assign(new Error(message), { statusCode, code });
|
||||
}
|
||||
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
|
||||
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
|
||||
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
|
||||
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
|
||||
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
|
||||
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
|
||||
}
|
||||
|
||||
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
|
||||
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
|
||||
await app.register(async routes => {
|
||||
let uploads = 0, downloads = 0;
|
||||
type Transfer = { controller: AbortController; release(): void };
|
||||
const transfers = new WeakMap<FastifyRequest, Transfer>();
|
||||
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
|
||||
routes.addHook('onRequest', async (req, reply) => {
|
||||
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
|
||||
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
|
||||
const uploading = req.method === 'POST';
|
||||
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
|
||||
if (uploading) uploads++; else downloads++;
|
||||
const controller = new AbortController(); let released = false;
|
||||
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
|
||||
const release = () => {
|
||||
if (released) return; released = true;
|
||||
if (uploading) uploads--; else downloads--;
|
||||
clearTimeout(timeout); controller.abort();
|
||||
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
|
||||
};
|
||||
transfers.set(req, { controller, release });
|
||||
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
|
||||
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
|
||||
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
|
||||
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||
await requireAgentAttachments(options.proxyJson, controller.signal);
|
||||
controller.signal.throwIfAborted();
|
||||
});
|
||||
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
|
||||
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
|
||||
if (reply.raw.destroyed) return;
|
||||
if (_req.method === 'POST') reply.header('Connection', 'close');
|
||||
const statusCode = error.statusCode || 502;
|
||||
const code = typeof error.code === 'string' ? error.code : undefined;
|
||||
if (statusCode === 429) reply.header('Retry-After', '2');
|
||||
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
|
||||
statusCode, code: code || 'agent_unavailable' });
|
||||
});
|
||||
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
|
||||
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
|
||||
else done(null, payload);
|
||||
});
|
||||
const json = async (response: Response) => {
|
||||
let size = 0; const chunks: Uint8Array[] = [];
|
||||
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
|
||||
const reader = response.body.getReader();
|
||||
try {
|
||||
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
|
||||
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
|
||||
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
|
||||
finally { reader.releaseLock(); }
|
||||
};
|
||||
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
|
||||
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
|
||||
let oversized = false;
|
||||
const incoming = req.body;
|
||||
const { controller } = transfer(req);
|
||||
const body = Readable.from((async function* () {
|
||||
let count = 0;
|
||||
for await (const chunk of incoming) {
|
||||
count += chunk.length;
|
||||
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
|
||||
yield chunk;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
|
||||
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
|
||||
} as RequestInit & { duplex: string });
|
||||
const value = await json(response); controller.signal.throwIfAborted();
|
||||
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
|
||||
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
|
||||
if (response.status === 429) reply.header('Retry-After', '2');
|
||||
return reply.code(response.status).send(value);
|
||||
} catch (error) {
|
||||
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
|
||||
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
|
||||
} finally { body.destroy(); }
|
||||
});
|
||||
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
|
||||
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
|
||||
const { controller } = transfer(req);
|
||||
let response: Response;
|
||||
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
|
||||
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
|
||||
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
|
||||
controller.signal.throwIfAborted();
|
||||
if (!response.ok) return reply.code(response.status).send(await json(response));
|
||||
const length = Number(response.headers.get('content-length'));
|
||||
const disposition = response.headers.get('content-disposition');
|
||||
const type = response.headers.get('content-type') || '';
|
||||
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|
||||
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
|
||||
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
|
||||
}
|
||||
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
|
||||
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
|
||||
try {
|
||||
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
|
||||
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
|
||||
if (size !== length) throw new Error('Download length mismatch');
|
||||
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
|
||||
finally { reader.releaseLock(); }
|
||||
controller.signal.throwIfAborted();
|
||||
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
|
||||
return reply.send(Buffer.concat(chunks, size));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
|
||||
@@ -95,7 +95,7 @@ function isLikelyResponsesApiModel(model: string) {
|
||||
if (id.includes("audio") || id.includes("realtime") || id.includes("transcribe") || id.includes("tts")) return false;
|
||||
if (id.includes("image") || id.includes("dall-e") || id.includes("sora")) return false;
|
||||
if (id.includes("search") || id.includes("computer-use")) return false;
|
||||
return /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
return id === "chat-latest" || /^(gpt-|o\d|chatgpt-)/.test(id);
|
||||
}
|
||||
|
||||
function isLikelyGeminiChatModel(model: string) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { openaiClient } from "./llm/providers.js";
|
||||
import { serializeProviderFields, toPrismaProvider } from "./llm/provider-ids.js";
|
||||
import { exaClient } from "./search/exa.js";
|
||||
import { isFreshSearchCacheHit, normalizeSearchQuery } from "./search-cache.js";
|
||||
import { searchWorkspace } from "./workspace-search.js";
|
||||
import type { ChatAttachment } from "./llm/types.js";
|
||||
|
||||
const ProviderSchema = z.enum(["openai", "anthropic", "xai", "gemini", "hermes-agent"]);
|
||||
@@ -927,6 +928,37 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
|
||||
app.get("/v1/workspace-items", async (req) => {
|
||||
requireAdmin(req);
|
||||
const parsed = z.object({ q: z.string().trim().max(200).optional() }).safeParse(req.query);
|
||||
if (!parsed.success) throw app.httpErrors.badRequest("q must be a string of at most 200 characters");
|
||||
if (parsed.data.q) {
|
||||
const { hits, hasMore } = await searchWorkspace(parsed.data.q);
|
||||
const [chats, searches] = await Promise.all([
|
||||
prisma.chat.findMany({
|
||||
where: { id: { in: hits.filter((hit) => hit.type === "chat").map((hit) => hit.id) } },
|
||||
select: {
|
||||
...chatSummarySelect,
|
||||
parentChat: { select: { projectItems: starredProjectItemsSelect } },
|
||||
},
|
||||
}),
|
||||
prisma.search.findMany({
|
||||
where: { id: { in: hits.filter((hit) => hit.type === "search").map((hit) => hit.id) } },
|
||||
select: searchSummarySelect,
|
||||
}),
|
||||
]);
|
||||
const summaries = new Map<string, Record<string, unknown>>([
|
||||
...chats.map(({ parentChat, ...chat }) => [
|
||||
`chat:${chat.id}`,
|
||||
// Search omits unmatched roots, but sidebar fork stars belong to the root.
|
||||
{ ...serializeChatLike(chat), ...serializeStarFields(parentChat ?? chat) },
|
||||
] as const),
|
||||
...searches.map((search) => [`search:${search.id}`, serializeSearchLike(search)] as const),
|
||||
]);
|
||||
const items = hits.flatMap((hit) => {
|
||||
const summary = summaries.get(`${hit.type}:${hit.id}`);
|
||||
return summary ? [{ ...summary, ...hit }] : [];
|
||||
});
|
||||
return { items, hasMore };
|
||||
}
|
||||
return { items: await listWorkspaceItems() };
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./db.js";
|
||||
|
||||
export const WORKSPACE_SEARCH_LIMIT = 100;
|
||||
|
||||
// Treat input as literal Unicode words, never FTS operators/column expressions.
|
||||
// Prefix matching supports type-ahead, including short terms such as "AI".
|
||||
export function workspaceSearchExpression(query: string): string | null {
|
||||
const words = query.match(/[\p{L}\p{N}\p{M}\p{Co}]+/gu);
|
||||
return words?.map((word) => `"${word}"*`).join(" AND ") ?? null;
|
||||
}
|
||||
|
||||
export type WorkspaceSearchHit = {
|
||||
type: "chat" | "search";
|
||||
id: string;
|
||||
searchSnippet: string | null;
|
||||
};
|
||||
|
||||
export function workspaceSearchSQL(expression: string) {
|
||||
// Batch excerpts after limiting matches. Unary + prevents SQLite from using
|
||||
// the IN clause as repeated rowid lookups (and restarting FTS for every row).
|
||||
// FTS still uses MATCH and the rowid bounds; IN filters that single scan.
|
||||
return Prisma.sql`
|
||||
WITH chat_hits AS (
|
||||
SELECT c.id, NULL AS message_rowid
|
||||
FROM "ChatSearch" JOIN "Chat" c ON c.rowid = "ChatSearch".rowid
|
||||
WHERE "ChatSearch" MATCH ${expression}
|
||||
UNION ALL
|
||||
SELECT m.chatId AS id, MIN(m.rowid) AS message_rowid
|
||||
FROM "MessageSearch" JOIN "Message" m ON m.rowid = "MessageSearch".rowid
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
GROUP BY m.chatId
|
||||
), matches AS (
|
||||
SELECT 'chat' AS type, c.id, c.updatedAt, MIN(h.message_rowid) AS message_rowid,
|
||||
NULL AS search_rowid
|
||||
FROM chat_hits h JOIN "Chat" c ON c.id = h.id
|
||||
GROUP BY c.id
|
||||
UNION ALL
|
||||
SELECT 'search' AS type, s.id, s.updatedAt, NULL AS message_rowid, s.rowid AS search_rowid
|
||||
FROM "SavedSearchSearch" JOIN "Search" s ON s.rowid = "SavedSearchSearch".rowid
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
), limited AS MATERIALIZED (
|
||||
SELECT * FROM matches ORDER BY updatedAt DESC, type, id LIMIT ${WORKSPACE_SEARCH_LIMIT + 1}
|
||||
), message_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("MessageSearch", 0, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "MessageSearch"
|
||||
WHERE "MessageSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(message_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(message_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT message_rowid FROM limited)
|
||||
), search_excerpts AS MATERIALIZED (
|
||||
SELECT rowid, substr(snippet("SavedSearchSearch", -1, '', '', ' … ', 24), 1, 480) AS snippet
|
||||
FROM "SavedSearchSearch"
|
||||
WHERE "SavedSearchSearch" MATCH ${expression}
|
||||
AND rowid >= (SELECT MIN(search_rowid) FROM limited)
|
||||
AND rowid <= (SELECT MAX(search_rowid) FROM limited)
|
||||
AND +rowid IN (SELECT search_rowid FROM limited)
|
||||
)
|
||||
SELECT type, id, COALESCE(m.snippet, s.snippet) AS searchSnippet
|
||||
FROM limited
|
||||
LEFT JOIN message_excerpts m ON m.rowid = limited.message_rowid
|
||||
LEFT JOIN search_excerpts s ON s.rowid = limited.search_rowid
|
||||
ORDER BY updatedAt DESC, type, id
|
||||
`;
|
||||
}
|
||||
|
||||
export async function searchWorkspace(query: string) {
|
||||
const expression = workspaceSearchExpression(query);
|
||||
if (!expression) return { hits: [], hasMore: false };
|
||||
// Match via the inverted indexes first. Hydrate summaries and generate excerpts
|
||||
// only for the bounded result set, never fetch full transcripts into JS.
|
||||
const hits = await prisma.$queryRaw<WorkspaceSearchHit[]>(workspaceSearchSQL(expression));
|
||||
return { hits: hits.slice(0, WORKSPACE_SEARCH_LIMIT), hasMore: hits.length > WORKSPACE_SEARCH_LIMIT };
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -11,6 +11,7 @@ import sensible from "@fastify/sensible";
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-chat-forks-"));
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
|
||||
|
||||
@@ -1,6 +1,50 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { describeProviderChatBackend } from "../src/llm/provider-adapters.js";
|
||||
import { env } from "../src/env.js";
|
||||
import { describeProviderChatBackend, fetchProviderCatalogModels } from "../src/llm/provider-adapters.js";
|
||||
|
||||
for (const { name, listedModels, expectedModels } of [
|
||||
{
|
||||
name: "OpenAI catalog includes chat-latest while excluding non-chat models",
|
||||
listedModels: [
|
||||
"gpt-4.1-mini",
|
||||
"chat-latest",
|
||||
"o3",
|
||||
"chatgpt-4o-latest",
|
||||
"chat-latest",
|
||||
"text-embedding-3-small",
|
||||
"omni-moderation-latest",
|
||||
"gpt-4o-audio-preview",
|
||||
"gpt-realtime",
|
||||
"gpt-4o-transcribe",
|
||||
"gpt-4o-mini-tts",
|
||||
"gpt-image-1",
|
||||
"chatgpt-image-latest",
|
||||
"gpt-4o-search-preview",
|
||||
"computer-use-preview",
|
||||
"sora-2",
|
||||
],
|
||||
expectedModels: ["chat-latest", "chatgpt-4o-latest", "gpt-4.1-mini", "o3"],
|
||||
},
|
||||
{
|
||||
name: "OpenAI catalog does not invent models absent from the provider response",
|
||||
listedModels: ["gpt-4.1-mini"],
|
||||
expectedModels: ["gpt-4.1-mini"],
|
||||
},
|
||||
]) {
|
||||
test(name, async (t) => {
|
||||
const originalKey = env.OPENAI_API_KEY;
|
||||
env.OPENAI_API_KEY = "test-openai-key";
|
||||
t.after(() => { env.OPENAI_API_KEY = originalKey; });
|
||||
t.mock.method(globalThis, "fetch", async (input: RequestInfo | URL) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assert.equal(url, "https://api.openai.com/v1/models");
|
||||
return Response.json({ data: listedModels.map((id) => ({ id })) });
|
||||
});
|
||||
|
||||
assert.deepEqual(await fetchProviderCatalogModels("openai"), expectedModels);
|
||||
});
|
||||
}
|
||||
|
||||
test("provider backend registry selects chat protocol and managed-tool mode", () => {
|
||||
assert.deepEqual(describeProviderChatBackend("openai", []), {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { cpSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify from "fastify";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const serverRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const databaseDir = mkdtempSync(join(tmpdir(), "sybil-workspace-search-"));
|
||||
const migration = "20261007000000_add_workspace_full_text_search";
|
||||
process.env.DATABASE_URL = `file:${join(databaseDir, "test.db")}`;
|
||||
process.env.OPENAI_API_KEY = "";
|
||||
delete process.env.ADMIN_TOKEN;
|
||||
writeFileSync(join(databaseDir, "test.db"), "");
|
||||
cpSync(join(serverRoot, "prisma/schema.prisma"), join(databaseDir, "schema.prisma"));
|
||||
for (const name of readdirSync(join(serverRoot, "prisma/migrations"))) {
|
||||
if (name !== migration) cpSync(join(serverRoot, "prisma/migrations", name), join(databaseDir, "migrations", name), { recursive: true });
|
||||
}
|
||||
function migrate() {
|
||||
execFileSync(process.execPath, [join(serverRoot, "node_modules/prisma/build/index.js"), "migrate", "deploy", "--schema", join(databaseDir, "schema.prisma")], {
|
||||
cwd: serverRoot, env: process.env, stdio: "pipe",
|
||||
});
|
||||
}
|
||||
migrate();
|
||||
const { prisma } = await import("../src/db.js");
|
||||
const oldChat = await prisma.chat.create({ data: {
|
||||
title: "Old constellation notes",
|
||||
messages: { create: [
|
||||
{ role: "user", content: "We photographed a nebula before the migration." },
|
||||
{ role: "system", content: "secretmigrationprompt" },
|
||||
] },
|
||||
} });
|
||||
const oldSearch = await prisma.search.create({ data: { query: "constellation map", answerText: "The nebula is visible." } });
|
||||
cpSync(join(serverRoot, "prisma/migrations", migration), join(databaseDir, "migrations", migration), { recursive: true });
|
||||
migrate();
|
||||
|
||||
const { registerRoutes } = await import("../src/routes.js");
|
||||
const { workspaceSearchExpression, workspaceSearchSQL } = await import("../src/workspace-search.js");
|
||||
const app = Fastify({ logger: false });
|
||||
await app.register(sensible);
|
||||
await registerRoutes(app);
|
||||
await app.ready();
|
||||
|
||||
async function search(query: string) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?q=${encodeURIComponent(query)}` });
|
||||
assert.equal(response.statusCode, 200, response.body);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
await prisma.chat.deleteMany();
|
||||
await prisma.search.deleteMany();
|
||||
});
|
||||
test.after(async () => {
|
||||
await app.close();
|
||||
await prisma.$disconnect();
|
||||
rmSync(databaseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("migration indexes existing titles, messages, queries, and answers", async () => {
|
||||
assert.deepEqual(new Set((await search("nebula")).items.map((item: any) => item.id)), new Set([oldChat.id, oldSearch.id]));
|
||||
assert.equal((await search("constellation")).items.length, 2);
|
||||
assert.equal((await search("secretmigrationprompt")).items.length, 0);
|
||||
});
|
||||
|
||||
test("matches full history, deduplicates messages, and excludes hidden content", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "Travel notes", updatedAt: new Date("2000-01-01"),
|
||||
messages: { create: [
|
||||
{ role: "user", content: "Let's discuss café planning in Zürich" },
|
||||
{ role: "assistant", content: "CAFÉ PLANNING includes booking a table" },
|
||||
{ role: "system", content: "hiddensystemvalue" },
|
||||
{ role: "tool", content: "hiddentoolvalue" },
|
||||
] },
|
||||
} });
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 105 }, (_, i) => ({ title: `Recent ${i}` })) });
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.equal(recent.items.some((item: any) => item.id === chat.id), false);
|
||||
const result = await search("CAFE plan");
|
||||
assert.equal(result.hasMore, false);
|
||||
assert.deepEqual(result.items.map((item: any) => item.id), [chat.id]);
|
||||
assert.match(result.items[0].searchSnippet, /café planning/i);
|
||||
assert.equal(result.items[0].messages, undefined);
|
||||
assert.equal((await search("zuri")).items.length, 1);
|
||||
assert.equal((await search("hiddensystemvalue")).items.length, 0);
|
||||
assert.equal((await search("hiddentoolvalue")).items.length, 0);
|
||||
});
|
||||
|
||||
test("indexes edits, role changes, forks, rollbacks, and cascading deletes", async () => {
|
||||
const chat = await prisma.chat.create({ data: {
|
||||
title: "originaltitle", messages: { create: { role: "assistant", content: "originalbody" } },
|
||||
}, include: { messages: true } });
|
||||
const messageID = chat.messages[0].id;
|
||||
await prisma.chat.update({ where: { id: chat.id }, data: { title: "replacementtitle" } });
|
||||
await prisma.message.update({ where: { id: messageID }, data: { content: "replacementbody" } });
|
||||
assert.equal((await search("original")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "tool" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 0);
|
||||
await prisma.message.update({ where: { id: messageID }, data: { role: "user" } });
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
await assert.rejects(prisma.$transaction(async (tx) => {
|
||||
await tx.message.update({ where: { id: messageID }, data: { content: "rolledbackvalue" } });
|
||||
throw new Error("rollback");
|
||||
}));
|
||||
assert.equal((await search("rolledbackvalue")).items.length, 0);
|
||||
assert.equal((await search("replacementbody")).items.length, 1);
|
||||
const fork = await app.inject({ method: "POST", url: `/v1/chats/${chat.id}/fork`, payload: {} });
|
||||
assert.equal(fork.statusCode, 200, fork.body);
|
||||
assert.equal((await search("replacementbody")).items.length, 2);
|
||||
await prisma.message.delete({ where: { id: messageID } });
|
||||
const onlyFork = (await search("replacementbody")).items;
|
||||
assert.deepEqual(onlyFork.map((item: any) => item.id), [fork.json().chat.id]);
|
||||
const starResponse = await app.inject({ method: "PATCH", url: `/v1/chats/${chat.id}/star`, payload: { starred: true } });
|
||||
assert.equal(starResponse.statusCode, 200, starResponse.body);
|
||||
const starredFork = (await search("replacementbody")).items[0];
|
||||
assert.equal(starredFork.starred, true);
|
||||
assert.ok(starredFork.starredAt);
|
||||
assert.equal(starredFork.parentChat, undefined);
|
||||
await prisma.chat.delete({ where: { id: chat.id } });
|
||||
assert.equal((await search("replacement")).items.length, 0);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
await prisma.$executeRawUnsafe(`INSERT INTO "${table}" ("${table}", rank) VALUES ('integrity-check', 1)`);
|
||||
}
|
||||
});
|
||||
|
||||
test("saved searches match query and answer, and update/delete their index", async () => {
|
||||
const saved = await prisma.search.create({ data: { title: "Bookmarks", query: "AI design", answerText: "Use a nebula illustration" } });
|
||||
assert.equal((await search("ai des")).items[0].type, "search");
|
||||
assert.match((await search("nebula")).items[0].searchSnippet, /nebula/);
|
||||
await prisma.search.update({ where: { id: saved.id }, data: { title: "New title", query: "newquery", answerText: "newanswer" } });
|
||||
assert.equal((await search("nebula")).items.length, 0);
|
||||
assert.equal((await search("design")).items.length, 0);
|
||||
assert.equal((await search("Bookmarks")).items.length, 0);
|
||||
assert.equal((await search("newanswer")).items.length, 1);
|
||||
await prisma.search.delete({ where: { id: saved.id } });
|
||||
assert.equal((await search("new")).items.length, 0);
|
||||
});
|
||||
|
||||
test("literal queries, validation, blank queries, and bounded snippets", async () => {
|
||||
await prisma.chat.create({ data: { title: "AI OR safety", messages: { create: { role: "user", content: "longword".repeat(1000) } } } });
|
||||
assert.equal((await search('"AI" OR safety*')).items.length, 1);
|
||||
assert.equal((await search("AI OR other")).items.length, 0);
|
||||
assert.equal((await search("AI")).items.length, 1);
|
||||
assert.equal((await search("*** : () --")).items.length, 0);
|
||||
assert.equal((await search("' UNION SELECT * FROM Chat; --")).items.length, 0);
|
||||
assert.equal((await search("longword")).items[0].searchSnippet.length, 480);
|
||||
const recent = (await app.inject({ method: "GET", url: "/v1/workspace-items" })).json();
|
||||
assert.deepEqual(await search(" "), recent);
|
||||
for (const query of ["q=" + "x".repeat(201), "q=one&q=two"]) {
|
||||
const response = await app.inject({ method: "GET", url: `/v1/workspace-items?${query}` });
|
||||
assert.equal(response.statusCode, 400, response.body);
|
||||
}
|
||||
});
|
||||
|
||||
test("caps combined results at 100 with deterministic newest-first ordering", async () => {
|
||||
const date = new Date("2026-01-01");
|
||||
await prisma.chat.createMany({ data: Array.from({ length: 100 }, (_, i) => ({ id: `chat-${String(i).padStart(3, "0")}`, title: "commonword", updatedAt: date })) });
|
||||
await prisma.search.create({ data: { id: "saved", query: "commonword", updatedAt: new Date("2026-02-01") } });
|
||||
const result = await search("common");
|
||||
assert.equal(result.items.length, 100);
|
||||
assert.equal(result.hasMore, true);
|
||||
assert.equal(result.items[0].id, "saved");
|
||||
assert.equal(result.items[1].id, "chat-000");
|
||||
assert.equal(result.items.at(-1).id, "chat-098");
|
||||
});
|
||||
|
||||
test("query plan uses all three FTS indexes", async () => {
|
||||
const plan = await prisma.$queryRaw<Array<{ detail: string }>>(Prisma.sql`EXPLAIN QUERY PLAN ${workspaceSearchSQL(workspaceSearchExpression("needle")!)}`);
|
||||
for (const table of ["ChatSearch", "MessageSearch", "SavedSearchSearch"]) {
|
||||
assert.ok(plan.some((row) => row.detail.includes(table) && /VIRTUAL TABLE INDEX.*M\d/.test(row.detail)), plan.map((row) => row.detail).join("\n"));
|
||||
}
|
||||
assert.equal(plan.some((row) => row.detail.includes("CORRELATED SCALAR SUBQUERY")), false, "Excerpts must not restart FTS for each result");
|
||||
});
|
||||
|
||||
test("vacuum preserves external-content index row identities", async () => {
|
||||
const removed = await prisma.chat.create({ data: { title: "removed" } });
|
||||
const kept = await prisma.chat.create({ data: { title: "kepttitle", messages: { create: { role: "user", content: "keptbody" } } } });
|
||||
await prisma.chat.delete({ where: { id: removed.id } });
|
||||
await prisma.$executeRaw`VACUUM`;
|
||||
assert.equal((await search("kepttitle")).items[0].id, kept.id);
|
||||
assert.equal((await search("kepttitle")).items[0].searchSnippet, null);
|
||||
assert.equal((await search("keptbody")).items[0].searchSnippet, "keptbody");
|
||||
});
|
||||
@@ -34,6 +34,13 @@ Default dev URL: `http://localhost:5173`
|
||||
## UI
|
||||
|
||||
- Left panel: mixed list of chat conversations and Exa searches.
|
||||
- Sidebar search uses `GET /v1/workspace-items?q=…` to search all saved titles,
|
||||
conversation messages, and web-search queries/answers. It waits 300 ms while
|
||||
typing and cancels superseded requests. Results include a single-line matching excerpt
|
||||
with matching words in bold and middle truncation
|
||||
(or “Title match” for title-only chat matches), with up to 100 newest matches.
|
||||
Clear the field or press Escape to return to recent items. Matching uses word
|
||||
prefixes, so `plan` also finds `planning`.
|
||||
- Right panel:
|
||||
- Chat mode: transcript + provider/model controls.
|
||||
- Search mode: top AI answer block + Google-style Exa results view.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+3
-15
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-violet-50">Sign in to Sybil</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Use your backend admin token.</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Enter your admin token.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,28 +34,27 @@ export function AuthScreen({ authTokenInput, setAuthTokenInput, isSigningIn, aut
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void onSignIn(authTokenInput.trim() || null);
|
||||
const token = authTokenInput.trim();
|
||||
if (token) void onSignIn(token);
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
aria-label="Admin token"
|
||||
placeholder="ADMIN_TOKEN"
|
||||
required
|
||||
value={authTokenInput}
|
||||
onInput={(event) => setAuthTokenInput(event.currentTarget.value)}
|
||||
disabled={isSigningIn}
|
||||
className="bg-[hsl(235_48%_6%_/_0.84)] text-violet-50"
|
||||
/>
|
||||
<Button className="w-full" type="submit" disabled={isSigningIn}>
|
||||
<Button className="w-full" type="submit" disabled={isSigningIn || !authTokenInput.trim()}>
|
||||
{isSigningIn ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
<Button className="w-full" type="button" variant="secondary" disabled={isSigningIn} onClick={() => void onSignIn(null)}>
|
||||
Continue without token
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{authError ? <p className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
||||
<p className="mt-3 text-xs text-muted-foreground">If `ADMIN_TOKEN` is set in `/server/.env`, token login is required.</p>
|
||||
{authError ? <p role="alert" className="mt-3 text-sm text-red-600">{authError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ function getTextPreview(value: string) {
|
||||
|
||||
function getSurfaceClasses(tone: Props["tone"]) {
|
||||
if (tone === "user") {
|
||||
return "border-white/12 bg-black/16 text-fuchsia-50";
|
||||
return "border-white/12 bg-black/16 text-inherit";
|
||||
}
|
||||
if (tone === "assistant") {
|
||||
return "border-violet-300/16 bg-violet-400/8 text-violet-50";
|
||||
|
||||
@@ -378,13 +378,13 @@ function ToolCallStack({
|
||||
);
|
||||
})}
|
||||
{!expanded && hiddenCount ? (
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-full border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
<span className="absolute bottom-1 right-10 z-20 rounded-none border border-cyan-300/30 bg-slate-950/86 px-2 py-0.5 text-[10px] font-semibold leading-none text-cyan-100 shadow-sm">
|
||||
+{hiddenCount}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-full"
|
||||
className="tool-call-stack-toggle absolute right-0 top-2 z-20 flex h-8 w-8 items-center justify-center rounded-none"
|
||||
aria-expanded={expanded ? "true" : "false"}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
title={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
|
||||
@@ -601,10 +601,10 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
<div key={message.id} className={cn("flex", isUser ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[85%] space-y-3",
|
||||
"min-w-0 space-y-3",
|
||||
isUser
|
||||
? "rounded-xl border border-violet-300/24 bg-[linear-gradient(135deg,hsl(258_86%_48%_/_0.86),hsl(278_72%_29%_/_0.86))] px-4 py-3 text-sm leading-6 text-fuchsia-50 shadow-sm"
|
||||
: "text-base leading-7 text-violet-50"
|
||||
? "message-bubble message-bubble--user max-w-[87%] md:max-w-[78%]"
|
||||
: "max-w-[85%] text-base leading-7 text-violet-50"
|
||||
)}
|
||||
onContextMenu={
|
||||
message.role === "assistant" && !message.id.startsWith("temp-") && onMessageContextMenu
|
||||
@@ -625,7 +625,7 @@ export function ChatMessagesPanel({ messages, isLoading, isSending, onMessageCon
|
||||
<MarkdownContent
|
||||
markdown={message.content}
|
||||
openLinksInNewTab
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", isUser ? "leading-[1.78] text-fuchsia-50" : "leading-[1.82] text-violet-50")}
|
||||
className={cn("[&_a]:text-inherit [&_a]:underline", !isUser && "leading-[1.82] text-violet-50")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/// <reference lib="es2022.intl" />
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
|
||||
const words = /[\p{L}\p{N}\p{M}\p{Co}]+/gu;
|
||||
const fold = (text: string) => text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
|
||||
const graphemes = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
export function SearchMatchLabel({ text: source, query }: { text: string; query: string }) {
|
||||
const text = source.replace(/\s+/gu, " ").trim();
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const ellipsisRef = useRef<HTMLSpanElement>(null);
|
||||
const [kept, setKept] = useState<number | null>(null);
|
||||
const boundaries = useMemo(() => [0, ...Array.from(graphemes.segment(text), ({ index, segment }) => index + segment.length)], [text]);
|
||||
const runs = useMemo(() => {
|
||||
const terms = (query.match(words) ?? []).map(fold).filter(Boolean);
|
||||
const result: Array<{ start: number; end: number; match: boolean }> = [];
|
||||
let cursor = 0;
|
||||
for (const word of text.matchAll(words)) {
|
||||
if (word.index > cursor) result.push({ start: cursor, end: word.index, match: false });
|
||||
cursor = word.index + word[0].length;
|
||||
result.push({ start: word.index, end: cursor, match: terms.some((term) => fold(word[0]).startsWith(term)) });
|
||||
}
|
||||
if (cursor < text.length) result.push({ start: cursor, end: text.length, match: false });
|
||||
return result;
|
||||
}, [text, query]);
|
||||
|
||||
const renderRange = (start: number, end: number) => runs.flatMap((run) => {
|
||||
const value = text.slice(Math.max(start, run.start), Math.min(end, run.end));
|
||||
if (run.end <= start || run.start >= end) return [];
|
||||
return [run.match
|
||||
? <strong key={`${start}:${run.start}`} className="font-semibold text-violet-100/95">{value}</strong>
|
||||
: <span key={`${start}:${run.start}`}>{value}</span>];
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
const ellipsis = ellipsisRef.current;
|
||||
if (!container || !measure || !ellipsis) return;
|
||||
let disposed = false;
|
||||
const update = () => {
|
||||
if (disposed) return;
|
||||
const width = container.getBoundingClientRect().width;
|
||||
if (measure.getBoundingClientRect().width <= width) { setKept(null); return; }
|
||||
// Measure the styled DOM, including bold runs, instead of guessing a
|
||||
// character limit. Grapheme boundaries keep accents and emoji intact.
|
||||
const nodes = Array.from(measure.children, (child) => child.firstChild as Text);
|
||||
const range = document.createRange();
|
||||
const measureRange = (start: number, end: number) => {
|
||||
if (start >= end) return 0;
|
||||
let offset = 0;
|
||||
let started = false;
|
||||
for (const node of nodes) {
|
||||
const next = offset + node.length;
|
||||
if (!started && start <= next) { range.setStart(node, start - offset); started = true; }
|
||||
if (end <= next) { range.setEnd(node, end - offset); break; }
|
||||
offset = next;
|
||||
}
|
||||
return range.getBoundingClientRect().width;
|
||||
};
|
||||
const count = boundaries.length - 1;
|
||||
const ellipsisWidth = ellipsis.getBoundingClientRect().width;
|
||||
let low = 0;
|
||||
let high = count - 1;
|
||||
while (low < high) {
|
||||
const middle = Math.ceil((low + high) / 2);
|
||||
const prefix = boundaries[Math.ceil(middle / 2)];
|
||||
const suffix = boundaries[count - Math.floor(middle / 2)];
|
||||
if (measureRange(0, prefix) + ellipsisWidth + measureRange(suffix, text.length) <= width - 1) low = middle;
|
||||
else high = middle - 1;
|
||||
}
|
||||
setKept(low);
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(container);
|
||||
void document.fonts.ready.then(update);
|
||||
document.fonts.addEventListener("loadingdone", update);
|
||||
return () => { disposed = true; observer.disconnect(); document.fonts.removeEventListener("loadingdone", update); };
|
||||
}, [text, runs, boundaries]);
|
||||
|
||||
const count = boundaries.length - 1;
|
||||
const truncated = kept !== null && kept < count;
|
||||
return (
|
||||
<span ref={containerRef} title={text} className="relative col-start-2 block min-w-0 overflow-hidden whitespace-nowrap text-xs leading-5 text-secondary-foreground/75">
|
||||
<span className="sr-only">{text}</span>
|
||||
<span aria-hidden="true">
|
||||
{truncated ? <>{renderRange(0, boundaries[Math.ceil(kept / 2)])}…{renderRange(boundaries[count - Math.floor(kept / 2)], text.length)}</> : renderRange(0, text.length)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="pointer-events-none invisible absolute left-0 top-0 whitespace-pre">
|
||||
<span ref={measureRef}>{renderRange(0, text.length)}</span><span ref={ellipsisRef}>…</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { searchWorkspaceItems, type WorkspaceItem, type WorkspaceSearchResponse } from "@/lib/api";
|
||||
import { scheduleWorkspaceSearch } from "@/lib/workspace-search";
|
||||
|
||||
type SearchState = WorkspaceSearchResponse & { query: string; revision: number; error: string | null };
|
||||
const EMPTY_ITEMS: WorkspaceItem[] = [];
|
||||
|
||||
export function useWorkspaceSearch(query: string, enabled: boolean, revision: number) {
|
||||
const trimmed = query.trim();
|
||||
const [state, setState] = useState<SearchState | null>(null);
|
||||
const validationError = trimmed.length > 200 ? "Use 200 characters or fewer." : null;
|
||||
|
||||
useEffect(() => {
|
||||
setState(null);
|
||||
if (!enabled || !trimmed || validationError) return;
|
||||
return scheduleWorkspaceSearch(trimmed, searchWorkspaceItems,
|
||||
(result) => setState({ ...result, query: trimmed, revision, error: null }),
|
||||
(error) => setState({ items: [], hasMore: false, query: trimmed, revision, error: error instanceof Error ? error.message : String(error) })
|
||||
);
|
||||
}, [trimmed, enabled, revision, validationError]);
|
||||
|
||||
// Hide obsolete results immediately, even before effect cleanup runs.
|
||||
const current = enabled && state?.query === trimmed && state.revision === revision ? state : null;
|
||||
return {
|
||||
items: current?.items ?? EMPTY_ITEMS,
|
||||
hasMore: current?.hasMore ?? false,
|
||||
error: validationError ?? current?.error ?? null,
|
||||
isLoading: enabled && Boolean(trimmed) && !validationError && !current,
|
||||
};
|
||||
}
|
||||
+72
-26
@@ -31,7 +31,7 @@
|
||||
--secondary-foreground: 254 38% 94%;
|
||||
--accent: 188 86% 50%;
|
||||
--accent-foreground: 230 45% 8%;
|
||||
--radius: 0.5rem;
|
||||
--radius: 0px;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -72,6 +72,7 @@ select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color-scheme: dark;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
::selection {
|
||||
@@ -113,13 +114,39 @@ textarea {
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.chat-settings-header {
|
||||
padding:
|
||||
calc(0.5rem + var(--safe-area-top))
|
||||
max(0.75rem, var(--safe-area-right))
|
||||
0.5rem
|
||||
max(0.75rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.chat-settings-content {
|
||||
padding:
|
||||
1rem
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(1.5rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
scroll-padding-block: 1rem;
|
||||
}
|
||||
|
||||
.chat-settings-error {
|
||||
padding:
|
||||
0.75rem
|
||||
max(1rem, var(--safe-area-right))
|
||||
max(0.75rem, var(--safe-area-bottom))
|
||||
max(1rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-safe-frame {
|
||||
padding:
|
||||
max(0.5rem, var(--safe-area-top))
|
||||
max(0.5rem, var(--safe-area-right))
|
||||
max(0.5rem, var(--safe-area-bottom))
|
||||
max(0.5rem, var(--safe-area-left));
|
||||
.chat-settings-header,
|
||||
.chat-settings-content {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.chat-settings-error {
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
.app-search-safe-pad {
|
||||
@@ -132,19 +159,11 @@ textarea {
|
||||
}
|
||||
|
||||
@media (horizontal-viewport-segments: 2) {
|
||||
.app-safe-frame {
|
||||
padding:
|
||||
max(0.5rem, var(--safe-area-top))
|
||||
max(0.5rem, var(--safe-area-right))
|
||||
max(0.5rem, var(--safe-area-bottom))
|
||||
max(0.5rem, var(--safe-area-left));
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
display: grid;
|
||||
grid-template-columns:
|
||||
calc(env(viewport-segment-width 0 0) - max(0.5rem, var(--safe-area-left)))
|
||||
calc(env(viewport-segment-width 1 0) - max(0.5rem, var(--safe-area-right)));
|
||||
calc(env(viewport-segment-width 0 0) - var(--safe-area-left))
|
||||
calc(env(viewport-segment-width 1 0) - var(--safe-area-right));
|
||||
column-gap: calc(env(viewport-segment-left 1 0) - env(viewport-segment-right 0 0));
|
||||
}
|
||||
|
||||
@@ -153,14 +172,10 @@ textarea {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
transform: none;
|
||||
border-width: 1px;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
min-width: 0;
|
||||
border-width: 1px;
|
||||
border-radius: 1rem;
|
||||
touch-action: auto;
|
||||
}
|
||||
|
||||
@@ -179,6 +194,37 @@ textarea {
|
||||
0 14px 36px hsl(240 80% 2% / 0.28);
|
||||
}
|
||||
|
||||
/* Shared by regular chats and the persistent agent transcript. */
|
||||
.message-bubble {
|
||||
min-width: 0;
|
||||
padding: 13px 17px;
|
||||
border: 1px solid hsl(256 37% 57% / .18);
|
||||
border-radius: 18px 18px 18px 5px;
|
||||
background: linear-gradient(150deg, hsl(251 36% 15% / .93), hsl(244 35% 11% / .93));
|
||||
box-shadow: 0 3px 12px hsl(239 55% 2% / .13);
|
||||
font-size: 14px;
|
||||
line-height: 1.68;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.message-bubble--user {
|
||||
border-color: hsl(266 80% 72% / .3);
|
||||
border-radius: 18px 18px 5px 18px;
|
||||
background: linear-gradient(135deg, hsl(264 59% 33% / .86), hsl(259 58% 26% / .9));
|
||||
color: hsl(265 55% 97%);
|
||||
}
|
||||
|
||||
.message-bubble .md-content {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.message-bubble {
|
||||
padding: 11px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tool-call-stack-shell {
|
||||
perspective: 900px;
|
||||
transform-style: preserve-3d;
|
||||
@@ -430,7 +476,7 @@ textarea {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid hsl(var(--border) / 0.86);
|
||||
border-radius: 0.625rem;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(246 34% 10% / 0.76);
|
||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.06);
|
||||
}
|
||||
@@ -449,7 +495,7 @@ textarea {
|
||||
}
|
||||
|
||||
.md-table-scroll::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(263 78% 72% / 0.34);
|
||||
}
|
||||
|
||||
@@ -561,7 +607,7 @@ textarea {
|
||||
|
||||
.md-content code {
|
||||
background: hsl(249 40% 10% / 0.78);
|
||||
border-radius: 0.3rem;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.05rem 0.3rem;
|
||||
font-size: 0.86em;
|
||||
box-decoration-break: clone;
|
||||
@@ -571,7 +617,7 @@ textarea {
|
||||
.md-content pre {
|
||||
overflow-x: auto;
|
||||
border: 1px solid hsl(253 31% 29% / 0.72);
|
||||
border-radius: 0.625rem;
|
||||
border-radius: var(--radius);
|
||||
background: hsl(249 40% 10% / 0.82);
|
||||
padding: 0.75rem;
|
||||
box-shadow: inset 0 1px 0 hsl(258 80% 88% / 0.05);
|
||||
@@ -595,7 +641,7 @@ textarea {
|
||||
.md-cite-token {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid hsl(274 40% 55%);
|
||||
background: hsl(277 33% 21%);
|
||||
color: hsl(304 42% 96%);
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/** Agent descriptors are independent of ordinary chat's inline attachment schema. */
|
||||
export type AgentAttachment = {
|
||||
id: string; kind: "image" | "text" | "file"; filename: string; mimeType: string;
|
||||
sizeBytes: number; sha256: string; workspacePath?: string;
|
||||
};
|
||||
export type AgentAttachmentCapability = {
|
||||
version: 1; maxCount: number; maxTotalBytes: number; maxImageBytes: number; maxFileBytes: number;
|
||||
imageMimeTypes: string[]; quotaBytes: number; usedBytes: number; remainingBytes: number;
|
||||
maxConcurrentUploads: number; maxConcurrentDecodes: number;
|
||||
};
|
||||
export function parseAgentAttachment(value: unknown): AgentAttachment {
|
||||
const item = value as AgentAttachment | null;
|
||||
if (!item || !/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item.id)
|
||||
|| !["image", "text", "file"].includes(item.kind) || typeof item.filename !== "string" || !item.filename
|
||||
|| typeof item.mimeType !== "string" || !Number.isSafeInteger(item.sizeBytes) || item.sizeBytes < 0 || item.sizeBytes > 8 * 1024 * 1024
|
||||
|| typeof item.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(item.sha256)
|
||||
|| (item.workspacePath !== undefined && (typeof item.workspacePath !== "string" || !item.workspacePath.startsWith("/"))))
|
||||
throw new Error("Agent returned an invalid attachment descriptor.");
|
||||
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
|
||||
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
|
||||
}
|
||||
export function agentAttachments(metadata: unknown): AgentAttachment[] {
|
||||
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
|
||||
if (!Array.isArray(values) || values.length > 8) return [];
|
||||
try { return values.map(parseAgentAttachment); } catch { return []; }
|
||||
}
|
||||
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
|
||||
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
|
||||
}
|
||||
export function attachmentSize(bytes: number): string { return bytes < 1024 ? `${bytes} B` : bytes < 1024 ** 2 ? `${Math.ceil(bytes / 1024)} KiB` : `${(bytes / 1024 ** 2).toFixed(1)} MiB`; }
|
||||
export const AGENT_ATTACHMENT_DRAFT_KEY = "sybil.agent.attachment-draft.v1";
|
||||
export type AgentUpload = {
|
||||
uploadId: string; filename: string; sizeBytes: number; mimeType: string; sha256?: string; file?: File;
|
||||
status: "waiting" | "uploading" | "ready" | "failed" | "reselect"; attachment?: AgentAttachment; error?: string;
|
||||
};
|
||||
export type AgentUploadStorage = { read(): unknown; write(rows: Omit<AgentUpload, "file">[]): void };
|
||||
|
||||
/** One active transfer. Durable identities/descriptors survive navigation and reload; bytes stay in memory. */
|
||||
export function createAgentUploadQueue(options: {
|
||||
capability(): AgentAttachmentCapability | undefined;
|
||||
upload(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
||||
changed(): void; storage?: AgentUploadStorage; newId?: () => string;
|
||||
}) {
|
||||
const rows = new Map<string, AgentUpload>();
|
||||
let running: { id: string; controller: AbortController } | undefined, disposed = false;
|
||||
const persist = () => options.storage?.write([...rows.values()].map(({ file: _file, ...row }) => ({ ...row })));
|
||||
const publish = () => { if (!disposed) options.changed(); };
|
||||
const save = () => { if (disposed) return; try { persist(); } catch { /* Pre-transfer persist below is mandatory. Already-uploaded identities remain in memory. */ } publish(); };
|
||||
const restored = options.storage?.read();
|
||||
if (Array.isArray(restored)) for (const value of restored.slice(0, 8)) {
|
||||
if (!value || typeof value.uploadId !== "string" || !/^[a-f0-9-]{36}$/i.test(value.uploadId)
|
||||
|| typeof value.filename !== "string" || typeof value.mimeType !== "string" || !Number.isSafeInteger(value.sizeBytes) || value.sizeBytes < 0 || value.sizeBytes > 8 * 1024 * 1024) continue;
|
||||
try {
|
||||
const attachment = value.attachment ? parseAgentAttachment(value.attachment) : undefined;
|
||||
rows.set(value.uploadId, { uploadId: value.uploadId, filename: value.filename, sizeBytes: value.sizeBytes, mimeType: value.mimeType,
|
||||
...(typeof value.sha256 === "string" && /^[a-f0-9]{64}$/.test(value.sha256) ? { sha256: value.sha256 } : {}),
|
||||
...(attachment ? { attachment, status: "ready" } : { status: "reselect", error: "Reselect the same file to resume this upload. Its retry ID has been retained." }) });
|
||||
} catch { /* Ignore invalid storage, never turn it into a server path/reference. */ }
|
||||
}
|
||||
const digest = async (file: File) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", await file.arrayBuffer()))].map(n => n.toString(16).padStart(2, "0")).join("");
|
||||
const pump = () => {
|
||||
if (disposed || running) return;
|
||||
const row = [...rows.values()].find(row => row.status === "waiting" && row.file);
|
||||
if (!row) return;
|
||||
const controller = new AbortController(); running = { id: row.uploadId, controller }; row.status = "uploading"; publish();
|
||||
void (async () => {
|
||||
row.sha256 ||= await digest(row.file!);
|
||||
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
||||
// Persist the upload identity AND original byte hash before any network request.
|
||||
persist();
|
||||
const value = await options.upload(row.file!, row.uploadId, controller.signal);
|
||||
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
||||
const attachment = parseAgentAttachment(value);
|
||||
if (attachment.sha256 !== row.sha256 || attachment.sizeBytes !== row.sizeBytes || attachment.filename !== row.filename.normalize("NFC"))
|
||||
throw new Error("The uploaded file receipt does not match this file. Retry to confirm.");
|
||||
row.attachment = attachment; row.status = "ready"; delete row.error;
|
||||
})().catch(error => {
|
||||
if (rows.get(row.uploadId) !== row || controller.signal.aborted) return;
|
||||
row.status = "failed"; row.error = error instanceof Error ? error.message : String(error);
|
||||
}).finally(() => { running = undefined; save(); pump(); });
|
||||
};
|
||||
return {
|
||||
add(files: Iterable<File>) {
|
||||
const cap = options.capability(); if (!cap || cap.version !== 1) throw new Error("Attachments are not available on this Agent.");
|
||||
const selected = Array.from(files);
|
||||
if (rows.size + selected.length > Math.min(8, cap.maxCount)) throw new Error("Attach at most 8 files per message.");
|
||||
if ([...rows.values()].reduce((n, r) => n + r.sizeBytes, 0) + selected.reduce((n, f) => n + f.size, 0) > Math.min(32 * 1024 * 1024, cap.maxTotalBytes))
|
||||
throw new Error("Attachments exceed 32 MiB per message.");
|
||||
for (const file of selected) {
|
||||
const image = cap.imageMimeTypes.includes(file.type);
|
||||
const limit = image ? Math.min(6 * 1024 * 1024, cap.maxImageBytes) : Math.min(8 * 1024 * 1024, cap.maxFileBytes);
|
||||
if (file.size > limit) throw new Error(`${file.name} exceeds the ${image ? "6" : "8"} MiB file limit.`);
|
||||
}
|
||||
const added = selected.map(file => ({ uploadId: (options.newId || (() => crypto.randomUUID()))(), file, filename: file.name, sizeBytes: file.size, mimeType: file.type, status: "waiting" as const }));
|
||||
for (const row of added) rows.set(row.uploadId, row);
|
||||
try { persist(); } catch (error) { for (const row of added) rows.delete(row.uploadId); throw error; }
|
||||
publish(); pump();
|
||||
},
|
||||
async reselect(id: string, file: File) {
|
||||
const row = rows.get(id); if (!row || row.status !== "reselect") return;
|
||||
if (file.name !== row.filename || file.size !== row.sizeBytes || file.type !== row.mimeType) throw new Error("Choose the same filename, type and size to resume this upload.");
|
||||
const hash = await digest(file);
|
||||
if (row.sha256 && row.sha256 !== hash) throw new Error("The selected file has different contents. Choose the original file or remove this attachment.");
|
||||
if (rows.get(id) !== row) return;
|
||||
row.sha256 = hash; row.file = file; row.status = "waiting"; delete row.error; persist(); publish(); pump();
|
||||
},
|
||||
remove(id: string) { const row = rows.get(id); rows.delete(id); try { persist(); } catch (error) { if (row) rows.set(id, row); throw error; } if (running?.id === id) running.controller.abort(); publish(); },
|
||||
retry(id: string) { const row = rows.get(id); if (row?.status === "failed") { row.status = row.file ? "waiting" : "reselect"; delete row.error; save(); pump(); } },
|
||||
snapshot(): AgentUpload[] { return [...rows.values()].map(row => ({ ...row })); },
|
||||
ready(): AgentAttachment[] {
|
||||
if ([...rows.values()].some(row => row.status !== "ready")) throw new Error("Wait for uploads, reselect or retry failed files, or remove them before sending.");
|
||||
return [...rows.values()].map(row => row.attachment!);
|
||||
},
|
||||
clear() { rows.clear(); running?.controller.abort(); save(); },
|
||||
dispose() { disposed = true; running?.controller.abort(); try { persist(); } catch { /* Existing stored retry IDs remain usable. */ } },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Message } from "./api";
|
||||
import { agentAttachments, attachmentCaption, parseAgentAttachment, type AgentAttachment } from "./agent-attachments.ts";
|
||||
|
||||
export const AGENT_OUTBOX_STORAGE_KEY = "sybil.agent.optimistic-outbox.v1";
|
||||
export type AgentDeliveryStatus = "sending" | "queued" | "complete" | "failed" | "unconfirmed";
|
||||
export type AgentOutboxRow = {
|
||||
id: string; requestId: string; content: string; createdAt: string;
|
||||
attachments?: AgentAttachment[]; status: AgentDeliveryStatus; error?: string; seenOnServer: boolean; confirmed: boolean;
|
||||
};
|
||||
export type AgentOutboxStorage = {
|
||||
read(): unknown; write(rows: AgentOutboxRow[]): void;
|
||||
legacyRows?(): unknown; legacyIds?(): unknown;
|
||||
};
|
||||
type Receipt = { status: string; messageId?: string };
|
||||
type RoomMessages = { messages: readonly Message[]; activity?: { queuedMessages?: readonly Message[] } | null };
|
||||
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error)).slice(0, 240);
|
||||
const info = (message: Message) => message.metadata && typeof message.metadata === "object" && !Array.isArray(message.metadata)
|
||||
? message.metadata as Record<string, unknown> : {};
|
||||
const idFor = (requestId: string) => `inbound:sybil:${requestId}`;
|
||||
const validRequestId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
|
||||
|
||||
/** Local rows use the server's identity from their first render through final echo. */
|
||||
export function createAgentOptimisticOutbox(options: {
|
||||
submit(content: string, requestId: string, attachments?: AgentAttachment[]): Promise<Receipt>;
|
||||
storage: AgentOutboxStorage;
|
||||
changed(): void;
|
||||
newId?: () => string;
|
||||
now?: () => number;
|
||||
}) {
|
||||
const newId = options.newId || (() => crypto.randomUUID());
|
||||
const now = options.now || Date.now;
|
||||
const rows = new Map<string, AgentOutboxRow>();
|
||||
const inFlight = new Map<string, Promise<void>>();
|
||||
const rejectedStorage = new Set<string>();
|
||||
const confirmedEchoes = new Map<string, Message>();
|
||||
let latestTime = 0;
|
||||
const timestamp = () => {
|
||||
latestTime = Math.max(now(), latestTime + 1);
|
||||
return new Date(latestTime).toISOString();
|
||||
};
|
||||
const restored = options.storage.read();
|
||||
if (Array.isArray(restored)) {
|
||||
for (const value of restored.slice(0, 100)) {
|
||||
if (!value || typeof value.content !== "string" || (!value.content.trim() && !value.attachments?.length) || !validRequestId(value.requestId)) continue;
|
||||
let attachments: AgentAttachment[] = [];
|
||||
try { if (value.attachments !== undefined) { if (!Array.isArray(value.attachments) || value.attachments.length > 8) continue; attachments = value.attachments.map(parseAgentAttachment); } } catch { continue; }
|
||||
const time = typeof value.createdAt === "string" && Number.isFinite(Date.parse(value.createdAt)) ? value.createdAt : timestamp();
|
||||
latestTime = Math.max(latestTime, Date.parse(time));
|
||||
const status: AgentDeliveryStatus = ["failed", "queued", "complete", "unconfirmed"].includes(value.status) ? value.status : "unconfirmed";
|
||||
const row: AgentOutboxRow = { id: idFor(value.requestId), requestId: value.requestId, content: value.content, createdAt: time,
|
||||
...(attachments.length ? { attachments } : {}), status, seenOnServer: Boolean(value.seenOnServer), confirmed: false,
|
||||
...(typeof value.error === "string" && value.error ? { error: value.error.slice(0, 240) } : {}) };
|
||||
rows.set(row.id, row);
|
||||
}
|
||||
} else {
|
||||
// Join the former UI-only failed cards to the old sender's receipt keys.
|
||||
// Neither representation is retried automatically during migration.
|
||||
const oldRows = options.storage.legacyRows?.();
|
||||
const oldIds = options.storage.legacyIds?.();
|
||||
const ids = new Map<string, string>();
|
||||
if (Array.isArray(oldIds)) for (const value of oldIds) {
|
||||
if (value && typeof value.content === "string" && validRequestId(value.requestId)) ids.set(value.content, value.requestId);
|
||||
}
|
||||
const contents = new Map<string, { content: string; status?: string; error?: string }>();
|
||||
if (Array.isArray(oldRows)) for (const value of oldRows) {
|
||||
if (value && typeof value.content === "string" && value.content.trim()) contents.set(value.content, value);
|
||||
}
|
||||
for (const content of ids.keys()) if (!contents.has(content)) contents.set(content, { content });
|
||||
for (const value of [...contents.values()].slice(0, 100)) {
|
||||
const requestId = ids.get(value.content) || newId();
|
||||
rows.set(idFor(requestId), { id: idFor(requestId), requestId, content: value.content, createdAt: timestamp(),
|
||||
status: value.status === "failed" ? "failed" : "unconfirmed", seenOnServer: false, confirmed: false,
|
||||
error: typeof value.error === "string" && value.error ? value.error.slice(0, 240)
|
||||
: ids.has(value.content) ? "Delivery was interrupted; retry to confirm." : "Restored draft; check the conversation before retrying." });
|
||||
}
|
||||
}
|
||||
const persist = () => {
|
||||
const pending = [...rows.values()].filter(row => !row.confirmed && !rejectedStorage.has(row.id));
|
||||
if (pending.length > 100 || JSON.stringify(pending).length > 2 * 1024 * 1024) throw new Error("Too many pending agent messages. Retry existing messages before sending more.");
|
||||
options.storage.write(pending.map(row => ({ ...row })));
|
||||
};
|
||||
const saveBestEffort = () => { try { persist(); } catch { /* Existing receipt IDs stay in memory; acknowledged messages remain sent. */ } };
|
||||
const publish = () => options.changed();
|
||||
const matches = (row: AgentOutboxRow, message: Message) => !row.attachments?.length || JSON.stringify(agentAttachments(message.metadata).map(file => file.id)) === JSON.stringify(row.attachments.map(file => file.id));
|
||||
const dispatch = (row: AgentOutboxRow, onPersisted?: () => void): Promise<void> => {
|
||||
const running = inFlight.get(row.id);
|
||||
if (running) return running;
|
||||
if (row.confirmed || row.seenOnServer) return Promise.resolve();
|
||||
row.status = "sending"; delete row.error;
|
||||
rejectedStorage.delete(row.id);
|
||||
try { persist(); }
|
||||
catch (error) {
|
||||
// A refused new draft must not prevent older persisted drafts from
|
||||
// draining their outbox. The refused row remains visibly retryable.
|
||||
rejectedStorage.add(row.id);
|
||||
row.status = "failed"; row.error = errorText(error); publish();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
onPersisted?.();
|
||||
// Both durable local storage and the visible row exist before POST begins.
|
||||
publish();
|
||||
const pending = Promise.resolve().then(() => options.submit(row.content, row.requestId, row.attachments)).then(receipt => {
|
||||
if (receipt.messageId && receipt.messageId !== row.id) throw new Error("Agent returned a different message identity.");
|
||||
if (!row.confirmed) {
|
||||
row.seenOnServer = true;
|
||||
row.status = receipt.status === "queued" ? "queued" : "complete";
|
||||
delete row.error; saveBestEffort(); publish();
|
||||
}
|
||||
}).catch(error => {
|
||||
// A canonical echo wins over a lost HTTP response. The message has already
|
||||
// been admitted, so a transport error must not paint it failed or resend it.
|
||||
if (row.confirmed || row.seenOnServer) return;
|
||||
row.status = "failed"; row.error = errorText(error); saveBestEffort(); publish();
|
||||
throw error;
|
||||
}).finally(() => { inFlight.delete(row.id); });
|
||||
inFlight.set(row.id, pending);
|
||||
return pending;
|
||||
};
|
||||
// Complete migration even when the user leaves restored drafts untouched:
|
||||
// their newly assigned identity/time must survive the next reload too.
|
||||
saveBestEffort();
|
||||
return {
|
||||
send(content: string, attachments: AgentAttachment[] = [], onPersisted?: () => void): Promise<void> {
|
||||
if (!content.trim() && !attachments.length) return Promise.reject(new Error("Enter a message."));
|
||||
const requestId = newId();
|
||||
const row: AgentOutboxRow = { id: idFor(requestId), requestId, content, ...(attachments.length ? { attachments: attachments.map(parseAgentAttachment) } : {}), createdAt: timestamp(), status: "sending", seenOnServer: false, confirmed: false };
|
||||
rows.set(row.id, row);
|
||||
return dispatch(row, onPersisted);
|
||||
},
|
||||
retry(messageId: string): Promise<void> {
|
||||
const row = rows.get(messageId);
|
||||
return row ? dispatch(row) : Promise.reject(new Error("This message is no longer pending."));
|
||||
},
|
||||
reconcile(room: RoomMessages): void {
|
||||
const compatible = (message: Message) => { const row = rows.get(message.id); return !row || matches(row, message); };
|
||||
const committed = new Map(room.messages.filter(compatible).map(message => [message.id, message]));
|
||||
const queued = new Set((room.activity?.queuedMessages || []).filter(compatible).map(message => message.id));
|
||||
let changed = false;
|
||||
for (const row of rows.values()) {
|
||||
if (committed.has(row.id)) {
|
||||
confirmedEchoes.set(row.id, committed.get(row.id)!);
|
||||
if (!row.confirmed || row.status !== "complete" || row.error) {
|
||||
row.confirmed = true; row.seenOnServer = true; row.status = "complete"; delete row.error; changed = true;
|
||||
}
|
||||
} else if (!row.confirmed && queued.has(row.id) && (row.status !== "queued" || !row.seenOnServer || row.error)) {
|
||||
row.seenOnServer = true; row.status = "queued"; delete row.error; changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) { saveBestEffort(); publish(); }
|
||||
},
|
||||
display(serverMessages: readonly Message[]): Message[] {
|
||||
const displayed = new Map(serverMessages.filter(message => { const row = rows.get(message.id); return !row || matches(row, message); }).map(message => {
|
||||
const row = rows.get(message.id);
|
||||
if (!row) return [message.id, message] as const;
|
||||
const serverInfo = info(message);
|
||||
const status = serverInfo.status === "complete" || row.confirmed ? "complete" : row.status;
|
||||
return [message.id, { ...message, createdAt: row.createdAt,
|
||||
metadata: { ...serverInfo, status, ...(row.error && status !== "complete" ? { error: row.error } : {}) } }] as const;
|
||||
}));
|
||||
for (const row of rows.values()) if (!displayed.has(row.id)) {
|
||||
const echo = confirmedEchoes.get(row.id);
|
||||
if (row.confirmed && !echo) continue;
|
||||
displayed.set(row.id, echo ? { ...echo, createdAt: row.createdAt } : {
|
||||
id: row.id, createdAt: row.createdAt, role: "user", content: row.attachments?.length ? attachmentCaption(row.content, row.attachments) : row.content, name: "buzzert",
|
||||
metadata: { kind: "message", status: row.status, channel: "sybil", requestId: row.requestId, ...(row.attachments?.length ? { agentAttachments: row.attachments } : {}), ...(row.error ? { error: row.error } : {}) },
|
||||
});
|
||||
}
|
||||
return [...displayed.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
||||
},
|
||||
snapshot(): AgentOutboxRow[] { return [...rows.values()].map(row => ({ ...row })); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,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.
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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)",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer } from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, extname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Run after npm run build, with an existing Playwright/Chromium installation:
|
||||
// PLAYWRIGHT_MODULE=/path/to/playwright-core/index.mjs CHROME_EXECUTABLE=/path/to/chrome
|
||||
// node tests/pwa.browser.mjs
|
||||
// This serves the real production bundle and service worker on loopback. No live API is used.
|
||||
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright-core');
|
||||
const root = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||
await readFile(resolve(root, 'sw.js'));
|
||||
const types = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webmanifest': 'application/manifest+json', '.png': 'image/png', '.ttf': 'font/ttf' };
|
||||
let sessionMode = 'token';
|
||||
const server = createServer(async (request, response) => {
|
||||
const path = new URL(request.url, 'http://localhost').pathname;
|
||||
if (path.startsWith('/api/')) {
|
||||
let status = 200;
|
||||
let body;
|
||||
if (sessionMode === 'unavailable') {
|
||||
status = 503;
|
||||
body = { message: 'Service temporarily unavailable' };
|
||||
} else if (sessionMode !== 'open' && request.headers.authorization !== 'Bearer local-login-fixture') {
|
||||
status = request.headers.authorization ? 403 : 401;
|
||||
body = { message: status === 401 ? 'missing bearer token' : 'invalid bearer token' };
|
||||
} else {
|
||||
body = {
|
||||
'/api/v1/auth/session': { authenticated: true, mode: sessionMode },
|
||||
'/api/v1/workspace-items': { items: [] },
|
||||
'/api/v1/models': { providers: {} },
|
||||
'/api/v1/chat-tools': { tools: [] },
|
||||
'/api/v1/active-runs': { chats: [], searches: [] },
|
||||
}[path] ?? {};
|
||||
}
|
||||
response.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
response.end(JSON.stringify(body));
|
||||
return;
|
||||
}
|
||||
const file = resolve(root, '.' + (path === '/' ? '/index.html' : path));
|
||||
if (!file.startsWith(root.endsWith(sep) ? root : root + sep)) { response.writeHead(404); response.end(); return; }
|
||||
try {
|
||||
const bytes = await readFile(file);
|
||||
response.writeHead(200, { 'Content-Type': types[extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-store' });
|
||||
response.end(bytes);
|
||||
} catch { response.writeHead(404); response.end(); }
|
||||
});
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const browser = await chromium.launch({ executablePath: process.env.CHROME_EXECUTABLE || undefined, headless: true,
|
||||
...(process.env.BROWSER_NO_SANDBOX === '1' ? { args: ['--no-sandbox'] } : {}) });
|
||||
try {
|
||||
const context = await browser.newContext({ serviceWorkers: 'allow' });
|
||||
await context.route('https://fonts.googleapis.com/**', route => route.abort());
|
||||
await context.route('https://fonts.gstatic.com/**', route => route.abort());
|
||||
const page = await context.newPage();
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/`, { waitUntil: 'domcontentloaded', timeout: 15_000 });
|
||||
// Activation must settle before a worker-controlled navigation can finish.
|
||||
await page.waitForFunction(() => navigator.serviceWorker.controller?.state === 'activated', undefined, { timeout: 15_000 });
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 });
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: 'Continue without token' }).count(), 0);
|
||||
assert.equal(await page.getByText(/If.*ADMIN_TOKEN/).count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isDisabled(), true);
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').fill('incorrect-login-fixture');
|
||||
assert.equal(await page.getByRole('button', { name: 'Sign in', exact: true }).isEnabled(), true);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.');
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 10_000 });
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').waitFor({ timeout: 5_000 });
|
||||
assert.equal(await page.getByRole('alert').count(), 0);
|
||||
assert.equal(await page.evaluate(() => navigator.serviceWorker.controller?.state), 'activated');
|
||||
|
||||
// Invalid saved credentials are a real failure, unlike a first visit without credentials.
|
||||
await page.evaluate(() => localStorage.setItem('sybil_admin_token', 'outdated-login-fixture'));
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.equal(await page.getByRole('alert').textContent(), 'Incorrect token. Please try again.');
|
||||
await page.getByPlaceholder('ADMIN_TOKEN').fill('local-login-fixture');
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await page.locator('.workspace-shell').waitFor();
|
||||
assert.equal(await page.evaluate(() => localStorage.getItem('sybil_admin_token')), 'local-login-fixture');
|
||||
|
||||
await page.evaluate(() => localStorage.removeItem('sybil_admin_token'));
|
||||
sessionMode = 'open';
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.workspace-shell').waitFor();
|
||||
assert.equal(await page.getByPlaceholder('ADMIN_TOKEN').count(), 0);
|
||||
|
||||
// Suppress only the expected missing-token response, never connectivity/server failures.
|
||||
sessionMode = 'unavailable';
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('alert').waitFor();
|
||||
assert.equal(await page.getByRole('alert').textContent(), 'Service temporarily unavailable');
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS service-worker activation/reload, neutral first sign-in, rejected tokens, valid sign-in, automatic open-mode entry, and visible server failures');
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.closeAllConnections();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
@@ -1,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,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),
|
||||
|
||||
@@ -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
@@ -6,7 +6,7 @@
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
|
||||
Reference in New Issue
Block a user