Compare commits

..
Author SHA1 Message Date
buzzert 15efbcc205 ios: experiment with inverted Agent transcript layout
TestFlight / Build and upload (push) Successful in 1m58s
2026-10-16 14:27:01 -07:00
buzzert ff48a25186 ios: simplify Agent transcript positioning and entrances
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-16 13:58:04 -07:00
buzzert e681e9e05c ios: let native scrolling handle Agent keyboard resizing
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:37:04 -07:00
buzzert d5427926d5 ios: render Agent history lazily
TestFlight / Build and upload (push) Successful in 1m49s
2026-10-16 12:10:29 -07:00
buzzert bbaa5e8859 ios: isolate Agent input and read observation 2026-10-16 11:51:07 -07:00
buzzert 7510603b77 feat(agent): preview public replies in push notifications
TestFlight / Build and upload (push) Successful in 1m53s
2026-10-14 23:36:38 -07:00
buzzert b9a6f5dd7d ios: isolate Agent scrolling and activity rendering 2026-10-14 23:14:57 -07:00
buzzert 5833a0868e fix(agent): bound push suppression to observed browser attention 2026-10-14 23:06:34 -07:00
buzzert f534f76b23 ci(ios): remove completed push signing bootstrap 2026-10-14 22:45:41 -07:00
buzzert e33adb8bbf ci(ios): refresh scoped production push signing profile
TestFlight / Build and upload (push) Successful in 1m45s
2026-10-14 22:21:22 -07:00
buzzert 60a35509d4 ios: unify attachment controls and message typography 2026-10-14 22:03:21 -07:00
buzzert a1c0264d36 feat(agent): add opt-in push notifications 2026-10-14 22:03:21 -07:00
buzzert 80748f764f ios: add Agent attachments and shared transcript fade
TestFlight / Build and upload (push) Successful in 1m48s
2026-10-14 19:51:27 -07:00
buzzert 2e62bfa736 fix(web): simplify first-time token sign-in 2026-10-14 18:43:06 -07:00
buzzert 844f48e9e0 fix(web): avoid service-worker activation deadlock 2026-10-14 18:30:06 -07:00
buzzert 8b125c13a5 feat(agent): add authenticated durable uploads to backend and web 2026-10-14 17:25:52 -07:00
buzzert 4cbab487d0 ios: hide raw tool entries in Agent transcript
TestFlight / Build and upload (push) Successful in 1m59s
2026-10-14 15:40:37 -07:00
buzzert 56279fba7f web: hide tool entries in Sybil Agent transcript 2026-10-14 15:29:50 -07:00
buzzert 153aa5d2fa ios: share prompt field across chat and agent views
TestFlight / Build and upload (push) Successful in 1m44s
2026-10-14 15:08:34 -07:00
buzzert 4389f805f4 ios: add persistent Sybil Agent room
TestFlight / Build and upload (push) Successful in 5m29s
Integrate the shared Agent room, durable outbox, cursor replay and read state on iPhone and iPad. Reuse message rendering with native messaging layout and expandable tool details.

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

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

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

Validated with 51 web tests, 44 server tests, both production builds, desktop/mobile
bubble comparisons, and 11 browser animation and scrolling checks.
2026-10-14 12:20:11 -07:00
buzzert dd6cd1ea7b Add indexed history search across iOS and web
TestFlight / Build and upload (push) Successful in 1m55s
2026-10-07 22:05:49 -07:00
buzzert 3f9c1409fe ios: remove animated workspace character
TestFlight / Build and upload (push) Successful in 1m40s
2026-10-04 21:48:45 -07:00
buzzert 411a8cebc7 Expose chat-latest and default Quick Question to it
TestFlight / Build and upload (push) Successful in 1m54s
2026-10-02 13:56:44 -07:00
buzzert 14191de8f4 No rounded corners 2026-09-30 21:30:54 -07:00
buzzert 4ef48fad82 remove "open mode" extra text 2026-09-27 22:56:07 -07:00
buzzert 21beab26f1 Replace quick question backdrop blur with dimming 2026-09-27 00:51:38 -07:00
buzzert 050a46d7a8 Better mobile-optimized settings UI 2026-09-19 18:55:17 -07:00
buzzert 3217c3f66e ios: add copy session ID to chat menu
TestFlight / Build and upload (push) Successful in 1m45s
2026-09-06 14:17:42 -07:00
buzzert 3a9c9bb74a ios: add chat fork actions
TestFlight / Build and upload (push) Successful in 1m39s
2026-09-06 14:03:30 -07:00
buzzert 04ac39499f ios: submit quick questions with return 2026-09-06 13:36:18 -07:00
buzzert 26d917e96a Polish streaming thinking traces
TestFlight / Build and upload (push) Successful in 1m41s
2026-08-30 15:26:31 -07:00
buzzert 922172fc60 Add dedicated quick question API
TestFlight / Build and upload (push) Successful in 1m42s
2026-08-27 19:31:23 -07:00
buzzert 5f9fc82b86 adds quick question UI for mac catalyst target 2026-08-21 17:28:08 -07:00
buzzert eb2b0d3ca0 Add chat thread forking
TestFlight / Build and upload (push) Successful in 1m52s
2026-08-16 17:42:39 -07:00
buzzert 42022bf055 Improve chat composer typing performance 2026-08-13 00:08:15 -07:00
buzzert 1ef491f39a Add foldable split-pane layout 2026-08-06 23:28:42 -07:00
buzzert b540f01759 Fix Sybil release paths
TestFlight / Build and upload (push) Successful in 1m50s
2026-07-27 17:36:31 -07:00
buzzert e719cbb92d Simplify iOS TestFlight automation
TestFlight / Build and upload (push) Failing after 18s
2026-07-27 17:29:47 -07:00
buzzert cb5a973ac7 Preserve provider changes before sending 2026-07-26 17:44:45 -07:00
buzzert e2443162b0 Preserve thread selection during background updates 2026-07-26 17:37:39 -07:00
buzzert 048456b8a5 Preserve draft views during collection refreshes 2026-07-25 17:14:39 -07:00
buzzert 211fad972a avoid mobile focus on conversation selection 2026-07-23 20:29:53 -07:00
buzzert a69c641481 add mobile workspace gestures 2026-07-23 18:05:57 -07:00
buzzert 9229896ad7 harden PWA resume and title fallback 2026-07-23 17:29:02 -07:00
buzzert c5217b2710 make PWA chat resume idempotent 2026-07-23 17:12:11 -07:00
buzzert abc1124d27 fix for PWA background/foreground 2026-07-23 16:32:20 -07:00
buzzert 4721717022 server: respect Brave search rate limits 2026-07-19 21:56:35 -07:00
buzzert 87b7d9502f server: add Brave search provider 2026-07-19 21:35:47 -07:00
buzzert 1952f4f358 web: open links in chat in new tab 2026-07-19 16:08:38 -07:00
buzzert 622659f6ca ios: preserve chat scroll position on resume
TestFlight / testflight (push) Successful in 1m46s
2026-07-12 13:57:45 -07:00
buzzert 93ca8a76c3 adds gemini support
TestFlight / testflight (push) Successful in 1m53s
2026-07-11 14:16:21 -07:00
buzzertandClaude Fable 5 69f50064a3 ci: restore Ruby PATH export for fastlane step
TestFlight / testflight (push) Successful in 1m58s
act_runner does not carry setup-ruby's PATH changes into later steps,
so without this the step runs the toolcache default Ruby (3.3.11) and
bundler cannot find the gems installed for 3.1.7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:04:47 -07:00
buzzertandClaude Fable 5 e7b81f24bd ci: put CI keychain first in codesign search list
TestFlight / testflight (push) Failing after 19s
codesign resolves signing identities through the user keychain search
list (first match wins) and ignores --keychain for the lookup. This
runner hosts another project whose keychain holds the same Apple
Distribution identity, so when that keychain is locked, codesign fails
with errSecInternalComponent no matter how correctly our own keychain
is set up. Prepend the fresh CI keychain to the search list for the
build and always delete it afterward, which restores the original list.

Also drop the runner-diagnostics step, the GitHub-hosted Ruby PATH
export, and the duplicate CODE_SIGN_IDENTITY param.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:01:42 -07:00
buzzert 20a310a4b1 ci: use Ruby 3.1 for TestFlight upload
TestFlight / testflight (push) Failing after 46s
2026-07-11 12:01:27 -07:00
buzzert b6859706db ios: simplify CI signing keychain setup
TestFlight / testflight (push) Failing after 25s
2026-07-11 11:55:04 -07:00
buzzert ea148839d3 Revert "ios: pass match keychain to codesign"
This reverts commit a0e410155d.
2026-07-11 11:32:00 -07:00
buzzert a0e410155d ios: pass match keychain to codesign
TestFlight / testflight (push) Failing after 55s
2026-07-11 11:29:10 -07:00
buzzert b5a5d21767 docs: document iOS release workflow 2026-07-11 11:17:05 -07:00
buzzert 0ad96f4f99 ios: center transcript content on iPad
TestFlight / testflight (push) Failing after 52s
2026-07-11 11:15:01 -07:00
buzzert b0c2b74c16 remove temporary keychain (unneeded)
TestFlight / testflight (push) Successful in 1m46s
2026-07-06 09:25:24 -07:00
buzzert 14d2d8cb28 fix spacing between lists and paragraphs 2026-07-05 19:54:34 -07:00
buzzert 03836bbc4c Raise Anthropic default max tokens 2026-07-05 11:53:17 -07:00
buzzert 76ce62025a bullets in ul 2026-07-04 20:22:13 -07:00
buzzert b15473d24e ios: stamp release version into XcodeGen spec
TestFlight / testflight (push) Successful in 1m43s
2026-06-26 01:29:54 -07:00
buzzert a512a65844 ios: accept release tag namespace in Fastfile
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:18:02 -07:00
buzzert 207b44f67f ios: ci: actually release/ios/v*.
TestFlight / testflight (push) Failing after 19s
2026-06-26 01:15:08 -07:00
buzzert c5ccd212c9 ios: ci: testflight only on release/v*
TestFlight / testflight (push) Successful in 1m55s
2026-06-26 01:08:47 -07:00
buzzertandClaude Opus 4.8 ee990dde5d ios: simplify Fastfile signing now that the runner has a real session
With SessionCreate on the runner's launchd job, standard fastlane keychain
handling works, so drop the debugging-era workarounds: the manual
default-keychain / list-keychains search-list juggling, the login-keychain
restoration in cleanup, the verify_ci_signing re-unlock/partition/find-identity
step (match already imports the cert and sets the key partition list), and the
CODE_SIGN_KEYCHAIN / OTHER_CODE_SIGN_FLAGS xcargs. CI signing is now a single
create_keychain + match. No behavior change; validated end-to-end on TestFlight.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:52:36 -07:00
buzzertandClaude Opus 4.8 036743fe41 ios: source TestFlight build number from CI run number
TestFlight / testflight (push) Successful in 1m53s
App Store Connect requires CFBundleVersion to be unique and strictly
increasing app-wide. latest_testflight_build_number could return a stale
value (it missed an existing build 13) and produced colliding build
numbers, 409-ing every upload. Use the monotonic Gitea run number
(github.run_number -> SYBIL_BUILD_NUMBER) instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:41:37 -07:00
buzzertandClaude Opus 4.8 4e7ea7ab68 ios: get TestFlight CI signing working
TestFlight / testflight (push) Failing after 57s
Replace the old testflight-release workflow with a single
`testflight.yml` Gitea Actions workflow and rework the fastlane `beta`
lane: match-based app-store signing into a disposable CI keychain,
XcodeGen project generation, version/build-number bumping, and upload
to TestFlight. Pin Ruby to 3.1.7 for the runner.

Squashes the iterative CI signing debugging history into one commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 00:28:27 -07:00
buzzert f232013e5a ios: ci: deploy via fastlane
TestFlight Release / testflight (push) Failing after 9s
2026-06-25 19:30:58 -07:00
buzzert 27c425f664 supposedly better tool call animation 2026-06-14 19:10:56 -07:00
buzzert 297b053a91 big backend refactor 2026-06-13 12:02:22 -07:00
buzzert 7436544a69 ios: add tool call stacking 2026-06-12 00:26:21 -07:00
buzzert 95796646b1 web: tool stacking ui 2026-06-12 00:09:44 -07:00
buzzert d7214c88ad fix most web_fetches from getting blocked using a real user agent 2026-06-11 23:36:19 -07:00
buzzert 22aa652257 Fix iOS chat scroll pinning 2026-06-07 19:58:04 -07:00
buzzert 8f6e8c17a5 ios: add fastlane 2026-06-05 23:19:14 -07:00
buzzert fccc8110f4 Show in-progress tool calls 2026-06-05 22:20:56 -07:00
buzzert f71b69ca8b some ui tweaks 2026-05-30 18:33:58 -07:00
buzzert dda20955bb restore settings ui 2026-05-30 18:28:31 -07:00
Agent 4a2493c421 Add per-chat settings UI in web app for additional system prompt and tool checkboxes 2026-05-30 18:09:35 -07:00
Agent 0bf0f95a67 Augment system prompt with date and user location (default SF) 2026-05-30 17:59:26 -07:00
buzzert 600bc3befc search: cache results 2026-05-30 17:58:04 -07:00
buzzert 5b7ed25522 Serve web manifest with manifest MIME type 2026-05-30 00:35:46 -07:00
buzzert 39014eee18 Add fullscreen PWA support 2026-05-30 00:32:57 -07:00
buzzert a6c2ec664b adds ability to star chats 2026-05-28 22:47:58 -07:00
buzzert cb8ea935fa adds the ability to rename chats 2026-05-28 22:22:55 -07:00
buzzert f79e5e02c5 server: refresh model catalog daily 2026-05-20 22:09:11 -07:00
buzzert 411790ee04 introduces workspace items as combined search+chat model 2026-05-17 00:28:09 -07:00
buzzert a8e765e026 web: cell colors and fonts tweak 2026-05-15 01:14:56 -07:00
buzzert 29c6dce0e5 ios: show provider/model in subtitle 2026-05-09 21:19:34 -07:00
buzzert 5855b7edb8 ios: keyboard dismissal behavior 2026-05-09 20:49:27 -07:00
buzzert ac6d55f617 ios: 1.9 2026-05-06 22:35:00 -07:00
buzzert 1e045db7f4 ios: fix pull to refresh 2026-05-06 22:34:17 -07:00
198 changed files with 29035 additions and 2861 deletions
+39
View File
@@ -0,0 +1,39 @@
name: TestFlight
on:
push:
tags:
- "release/ios/v*.*.*"
jobs:
testflight:
name: Build and upload
runs-on: macos-arm64
timeout-minutes: 90
steps:
- name: Check out the release tag
uses: actions/checkout@v4
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3.11"
bundler-cache: true
working-directory: ios
- name: Install XcodeGen
run: command -v xcodegen >/dev/null 2>&1 || brew install xcodegen
- name: Build and upload to TestFlight
working-directory: ios
env:
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
ASC_KEY: ${{ secrets.ASC_KEY }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
MATCH_GIT_BASIC_AUTHORIZATION: ${{ secrets.MATCH_GIT_BASIC_AUTHORIZATION }}
CI: "true"
FASTLANE_SKIP_UPDATE_CHECK: "1"
FASTLANE_HIDE_CHANGELOG: "1"
run: bundle exec fastlane ios beta
+2 -1
View File
@@ -1,2 +1,3 @@
.env
ios/fastlane/README.md
ios/fastlane/report.xml
+37
View File
@@ -1,3 +1,8 @@
map $http_upgrade $connection_upgrade {
default upgrade;
"" close;
}
server {
listen 80;
server_name _;
@@ -12,12 +17,44 @@ server {
location /api/ {
proxy_pass http://server:8787/;
proxy_http_version 1.1;
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
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 Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location = /sw.js {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location = /manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location = /index.html {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri =404;
}
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable" always;
try_files $uri =404;
}
location / {
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
expires -1;
try_files $uri $uri/ /index.html;
}
}
+2
View File
@@ -12,10 +12,12 @@ services:
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
XAI_API_KEY: ${XAI_API_KEY:-}
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
HERMES_AGENT_API_BASE_URL: ${HERMES_AGENT_API_BASE_URL:-http://127.0.0.1:8642/v1}
HERMES_AGENT_API_KEY: ${HERMES_AGENT_API_KEY:-}
HERMES_AGENT_MODEL: ${HERMES_AGENT_MODEL:-}
EXA_API_KEY: ${EXA_API_KEY:-}
BRAVE_SEARCH_API_KEY: ${BRAVE_SEARCH_API_KEY:-}
CHAT_WEB_SEARCH_ENGINE: ${CHAT_WEB_SEARCH_ENGINE:-exa}
SEARXNG_BASE_URL: ${SEARXNG_BASE_URL:-}
CHAT_MAX_TOOL_ROUNDS: ${CHAT_MAX_TOOL_ROUNDS:-100}
+392 -19
View File
@@ -23,6 +23,8 @@ Chat upload limits:
### `GET /v1/auth/session`
- Response: `{ "authenticated": true, "mode": "open" | "token" }`
- In token mode, a missing bearer token returns `401` (`missing bearer token`); an incorrect token returns `403` (`invalid bearer token`).
- Web clients show a neutral sign-in form when the initial check has no token, and an error when a supplied token is rejected. Open development servers authenticate automatically; the sign-in form has no tokenless bypass.
## Models
@@ -31,15 +33,35 @@ 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 },
"hermes-agent": { "models": ["hermes-agent"], "loadedAt": null, "error": null }
}
}
```
- 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.
## Chat Tools
### `GET /v1/chat-tools`
- Response:
```json
{
"tools": [
{ "name": "web_search", "description": "..." },
{ "name": "fetch_url", "description": "..." }
]
}
```
Behavior notes:
- Lists Sybil-managed chat tools that can be enabled for `openai`, `anthropic`, `xai`, and `gemini` chat completions.
- Optional tools such as `codex_exec` and `shell_exec` appear only when enabled by server environment configuration.
## Active Runs
@@ -57,6 +79,63 @@ Behavior notes:
- Clients should use this after app start or page refresh to restore per-row generating indicators.
- The lists are not durable across server restarts.
## Workspace Items
### `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
{
"items": [
{
"type": "chat",
"id": "chat-id",
"title": "optional title",
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"starred": true,
"starredAt": "2026-02-14T01:00:00.000Z",
"initiatedProvider": "openai",
"initiatedModel": "gpt-4.1-mini",
"lastUsedProvider": "openai",
"lastUsedModel": "gpt-4.1-mini",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"]
},
{
"type": "search",
"id": "search-id",
"title": "optional title",
"query": "search query",
"createdAt": "2026-02-14T00:00:00.000Z",
"updatedAt": "2026-02-14T00:00:00.000Z",
"starred": false,
"starredAt": null
}
]
}
```
Behavior notes:
- This endpoint is intended for combined conversation/search lists such as sidebars.
- The legacy `GET /v1/chats` and `GET /v1/searches` endpoints remain available for clients that need separate collections.
- The response currently combines the 100 most recently updated chats, any additional root chats needed to group those forks, and up to 100 searches.
- 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`
@@ -67,8 +146,10 @@ Behavior notes:
```json
{
"title": "optional title",
"provider": "optional openai|anthropic|xai|hermes-agent",
"provider": "optional openai|anthropic|xai|gemini|hermes-agent",
"model": "optional model id",
"additionalSystemPrompt": "optional stored system prompt",
"enabledTools": ["web_search", "fetch_url"],
"messages": [
{
"role": "system|user|assistant|tool",
@@ -83,14 +164,47 @@ Behavior notes:
Behavior notes:
- `provider` and `model` must be supplied together when present.
- Newly created non-fork chats have `parentChatId: null` and `titleGenerationPending: false`.
- When `provider`/`model` are supplied, the new chat initializes `initiatedProvider`/`initiatedModel` and `lastUsedProvider`/`lastUsedModel`.
- `additionalSystemPrompt` is trimmed and stored on the chat; blank values are stored as `null`.
- `enabledTools` stores the enabled Sybil-managed tool names for future chat completions. Unknown tool names are ignored; omitted values default to all currently available tools.
- Optional `messages` are inserted as the initial transcript. Attachment metadata uses the same schema and limits as chat completion messages.
### `POST /v1/chats/:chatId/fork`
- Body: `{ "messageId"?: string }`
- Response: `{ "chat": ChatSummary }`
- Source chat not found: `404 { "message": "chat not found" }`
- Supplied message missing from the source chat: `404 { "message": "message not found in chat" }`
- Supplied message is not an assistant response: `400 { "message": "fork message must be an assistant response" }`
Behavior notes:
- With no `messageId`, the server copies the complete source transcript. With an assistant response `messageId`, it copies the transcript through that response, inclusive.
- The source chat is unchanged. Copied messages receive new ids while retaining their role, content, name, creation time, order, and metadata except for the source request's transport-only `clientRequestId`. Attachments and tool-call metadata are retained. LLM call logs, star/project memberships, and other child threads are not copied.
- The fork inherits the source chat's user, initiated/last-used provider and model, additional system prompt, and enabled tool settings.
- A fork of a root or another fork always sets `parentChatId` to the single root chat id. Root chats keep `parentChatId: null`.
- A whole-chat fork starts with `Fork of <source title>` (or `Fork of Untitled chat`). A message fork starts with `Fork of '<message snippet>'`.
- The placeholder title is returned with `titleGenerationPending: true`. After the first new prompt, call `POST /v1/chats/title/suggest`; the placeholder is eligible for replacement exactly once.
### `PATCH /v1/chats/:chatId`
- Body: `{ "title": string }`
- Body: any subset of `{ "title": string, "additionalSystemPrompt": string|null, "enabledTools": string[] }`
- Response: `{ "chat": ChatSummary }`
- Blank titles are rejected. The server trims surrounding whitespace before storing the title.
- Setting a title clears `titleGenerationPending`, preventing an in-flight or later automatic suggestion from replacing the manual title.
- `additionalSystemPrompt: null` clears the stored prompt. Blank string values are also stored as `null`.
- `enabledTools: []` disables Sybil-managed tools for this chat. Omitted settings are left unchanged.
- Updating chat fields changes the returned chat's `updatedAt`.
- Not found: `404 { "message": "chat not found" }`
### `PATCH /v1/chats/:chatId/star`
- Body: `{ "starred": boolean }`
- Response: `{ "chat": ChatSummary }`
- Not found: `404 { "message": "chat not found" }`
Behavior notes:
- Starring adds the chat to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the chat transcript or chat `updatedAt`.
### `POST /v1/chats/title/suggest`
- Body:
```json
@@ -102,12 +216,19 @@ Behavior notes:
- Response: `{ "chat": ChatSummary }`
Behavior notes:
- If the chat already has a non-empty title, server returns the existing chat unchanged.
- Server always uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- If the chat already has a non-empty title and `titleGenerationPending` is false, server returns the existing chat unchanged.
- A fork placeholder with `titleGenerationPending: true` is eligible for the same title-generation flow as an untitled original chat. A successful or fallback suggestion clears the flag.
- If a title is set while suggestion generation is in flight, server returns the current chat instead of overwriting that title.
- For an eligible untitled chat or pending fork placeholder, server uses OpenAI `gpt-4.1-mini` to generate a one-line title (up to ~4 words), updates the chat title, and returns the updated chat.
- If the title provider is unavailable or rejects the request, server still persists a deterministic title derived from the first line of `content` instead of leaving the chat untitled.
### `DELETE /v1/chats/:chatId`
- Response: `{ "deleted": true }`
- Not found: `404 { "message": "chat not found" }`
- Active chat or fork: `409 { "message": "chat or fork has an active stream" }`
- Concurrent family deletion: `409 { "message": "chat family deletion already in progress" }`
- Deleting a root chat also deletes its grouped forks. Deleting a fork leaves the root and sibling forks unchanged.
- A root cannot be deleted while it or any grouped fork has an active completion stream. A fork cannot be deleted while its own completion stream is active.
### `GET /v1/chats/:chatId`
- Response: `{ "chat": ChatDetail }`
@@ -154,7 +275,7 @@ Notes:
```json
{
"chatId": "optional-chat-id",
"provider": "openai|anthropic|xai|hermes-agent",
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string",
"messages": [
{
@@ -182,6 +303,8 @@ Notes:
]
}
],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2,
"maxTokens": 256
}
@@ -201,21 +324,28 @@ Notes:
Behavior notes:
- If `chatId` is present, server validates chat existence.
- For `chatId` calls, server stores only *new* non-assistant messages from provided history to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- Server persists final assistant output and call metadata (`LlmCall`) in DB.
- Server updates chat-level model metadata on each call: `lastUsedProvider`/`lastUsedModel`; first successful/failed call also initializes `initiatedProvider`/`initiatedModel` if unset.
- Attachments are optional and currently apply to `user` messages. Persisted chat history stores them under `message.metadata.attachments`.
- Images are forwarded inline to providers as multimodal image parts. Use PNG or JPEG for cross-provider compatibility.
- Text files are forwarded as explicit text blocks rather than provider-managed file references. Large text attachments should already be truncated client-side before submission.
- For `openai`, backend calls OpenAI's Responses API and enables internal tool use with an internal system instruction.
- For `anthropic`, backend calls Anthropic's Messages API and enables internal tool use with Anthropic `tool_use`/`tool_result` content blocks.
- For `xai`, backend calls xAI's OpenAI-compatible Chat Completions API and enables internal tool use with the same internal system instruction.
- For `gemini`, backend calls Google's native Gemini `generateContent` API and enables internal tool use with Gemini function calling.
- For `hermes-agent`, backend calls the configured Hermes Agent OpenAI-compatible Chat Completions API without adding Sybil-managed tool definitions; Hermes Agent handles its own tools server-side.
- For `openai`, image attachments are sent as Responses `input_image` items and text attachments are sent as `input_text` items.
- For `gemini`, image attachments are sent as native Gemini `inlineData` parts and text attachments are sent as text parts.
- For `xai` and `hermes-agent`, image attachments are sent as Chat Completions content parts alongside text.
- For `openai`, Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- For `anthropic`, image attachments are sent as Messages API `image` blocks using base64 source data; text attachments are added as `text` blocks.
- Available Sybil-managed tool calls for `openai` and `xai`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`.
- `fetch_url` fetches a URL and returns plaintext page content (HTML converted to text server-side).
- Available Sybil-managed tool calls for `openai`, `anthropic`, `xai`, and `gemini`: `web_search` and `fetch_url`. When `CHAT_CODEX_TOOL_ENABLED=true`, `codex_exec` is also available. When `CHAT_SHELL_TOOL_ENABLED=true`, `shell_exec` is also available.
- `web_search` returns ranked results with per-result summaries/snippets. Its backend engine is selected by `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`).
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `fetch_url` fetches a URL with browser-like navigation headers and returns plaintext page content (HTML converted to text server-side).
- `codex_exec` delegates coding, shell, repository inspection, and other complex software tasks to a persistent remote Codex CLI workspace over SSH. The server runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` on the configured devbox inside `CHAT_CODEX_REMOTE_WORKDIR`, with SSH stdin closed.
- `shell_exec` runs arbitrary non-interactive shell commands on the same configured devbox, starting in `CHAT_CODEX_REMOTE_WORKDIR`. It uses `bash -lc` when bash exists, otherwise `sh -lc`, closes SSH stdin, and does not run inside the Sybil server container.
- Devbox tool configuration:
@@ -230,8 +360,17 @@ Behavior notes:
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64=<base64-private-key>` (optional fallback when a volume mount is not practical)
- `CHAT_CODEX_EXEC_TIMEOUT_MS=600000` (optional)
- `CHAT_SHELL_EXEC_TIMEOUT_MS=120000` (optional)
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests persist each completed tool call as its SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
- `anthropic` currently runs without server-managed tool calls.
- When a tool call is executed, backend stores a chat `Message` with `role: "tool"` and tool metadata (`metadata.kind = "tool_call"`). Streaming requests emit an initiated SSE `tool_call` event before execution, then persist each completed or failed tool call as its terminal SSE `tool_call` event is emitted, then store the assistant output when the completion finishes.
## Streaming Chat
### `POST /v1/chat-completions/stream`
- The request accepts the chat-completion fields above plus optional `persist` and `clientRequestId` fields.
- `clientRequestId` is only valid for a persisted request with a `chatId`, may be up to 128 characters, and should be a stable unique value generated once per user submission.
- Retrying with the same `chatId` and `clientRequestId` replays the matching active or completed stream rather than starting a duplicate provider call.
- The server persists the ID in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- The complete request, SSE event, persistence, retry, and attach contracts are defined in `docs/api/streaming-chat.md`.
## Searches
@@ -239,8 +378,24 @@ Behavior notes:
- Response: `{ "searches": SearchSummary[] }`
### `POST /v1/searches`
- Body: `{ "title"?: string, "query"?: string }`
- Body: `{ "title"?: string, "query"?: string, "reuseByQuery"?: boolean }`
- Response: `{ "search": SearchSummary, "reused": boolean, "cacheHit": boolean }`
Behavior notes:
- `reuseByQuery` defaults to `false`, preserving the normal create-a-new-search behavior.
- When `reuseByQuery` is `true` and `query` is present, the backend normalizes the query with `trim().toLowerCase()` and returns the most recently updated existing search with that normalized query instead of creating a duplicate.
- `cacheHit` is `true` only when the reused search has persisted results or answer text, is not currently streaming, and was updated within the 24-hour search cache window. Clients can then fetch `GET /v1/searches/:searchId` and display it without running another search.
- If a matching search exists but `cacheHit` is `false`, clients may run the search again on the returned `search.id`; the run endpoints replace that search's persisted results and answer with the latest run.
### `PATCH /v1/searches/:searchId/star`
- Body: `{ "starred": boolean }`
- Response: `{ "search": SearchSummary }`
- Not found: `404 { "message": "search not found" }`
Behavior notes:
- Starring adds the search to the reserved `starred` project and sets `starredAt` to the membership creation time.
- Unstarring removes that membership and returns `starred: false`, `starredAt: null`.
- This does not modify the search results or search `updatedAt`.
### `DELETE /v1/searches/:searchId`
- Response: `{ "deleted": true }`
@@ -312,12 +467,18 @@ Behavior notes:
{
"id": "...",
"title": null,
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedModel": "string|null"
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"]
}
```
@@ -360,19 +521,25 @@ Behavior notes:
{
"id": "...",
"title": null,
"parentChatId": null,
"titleGenerationPending": false,
"createdAt": "...",
"updatedAt": "...",
"initiatedProvider": "openai|anthropic|xai|hermes-agent|null",
"starred": false,
"starredAt": null,
"initiatedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"initiatedModel": "string|null",
"lastUsedProvider": "openai|anthropic|xai|hermes-agent|null",
"lastUsedProvider": "openai|anthropic|xai|gemini|hermes-agent|null",
"lastUsedModel": "string|null",
"additionalSystemPrompt": null,
"enabledTools": ["web_search", "fetch_url"],
"messages": [Message]
}
```
`SearchSummary`
```json
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "..." }
{ "id": "...", "title": null, "query": null, "createdAt": "...", "updatedAt": "...", "starred": false, "starredAt": null }
```
`SearchDetail`
@@ -383,6 +550,8 @@ Behavior notes:
"query": "...",
"createdAt": "...",
"updatedAt": "...",
"starred": false,
"starredAt": null,
"requestId": "...",
"latencyMs": 123,
"error": null,
@@ -395,3 +564,207 @@ Behavior notes:
```
For streaming contracts, see `docs/api/streaming-chat.md`.
## Sybil Agent Room
The Sybil Agent tab connects to one persistent root conversation. The agent owns its messages,
background work, and history; these endpoints do not create ordinary Sybil chats or
copy the transcript into the Sybil message database. All endpoints use normal Sybil
REST authentication. The server connects privately to `SYBIL_AGENT_BASE_URL` (default
`http://sybil-agent:8788`) using `SYBIL_AGENT_API_KEY`. The agent key never reaches browsers.
### `GET /v1/agent`
Returns the Sybil Agent room descriptor: `id`, `title`, `type: "persistent-agent"`, `model`,
`protocol: "pi-room.v1"`, `activity`, and `websocket: "/v1/agent/ws"`. The socket path
is relative to the API base, so web deployments use `/api/v1/agent/ws`. Missing agent
configuration returns 503; an unreachable agent server or malformed upstream JSON
returns 502.
### `GET /v1/agent/messages`
Query parameters: optional `after` or `before` (mutually exclusive nonnegative
numeric cursor strings), and `limit` (1–200). Without a cursor, returns recent
history. `after` catches up forward; `before` loads earlier history.
```json
{
"conversationId": "1",
"messages": [{ "id": "outbound:43", "createdAt": "2026-10-14T18:00:00.000Z", "role": "assistant", "content": "The task finished.", "name": "Sybil Agent", "metadata": { "kind": "message", "entryId": 43, "status": "complete" } }],
"cursor": "43",
"beforeCursor": "43",
"hasMore": false
}
```
Messages use `user`, `assistant`, and `tool` roles. Tools use
`metadata.kind: "tool_call"` and stable IDs; merge by message `id`, since later
pages can update initiated tools to completed or failed. Internal worker reports,
event prompts, and model reasoning are excluded. Cursors refer to underlying
durable entries and can advance without visible messages. Use returned cursors;
do not derive them from message IDs. `limit` bounds source entries, not displayed
messages.
### `POST /v1/agent/messages`
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
Content is limited to 40,000 characters; it must contain non-whitespace text unless attachment IDs are included;
`requestId` is required (1–256 characters). Preserve the ID when retrying uncertain
network results. The proxy fixes `channel: "sybil"`, `whenBusy: "followUp"`, and
`wait: false`.
Returns the agent's `202 Accepted` receipt, including `id` (submission ID), `status`, and
`messageId` such as `inbound:sybil:client-generated-uuid`. This is admission, not
completion: watch the room for answers, including unsolicited later replies.
Multiple requests can be accepted while the agent works. Optional `attachments: [{"id":"att_…"}]` accepts uploaded files as specified below; no client paths or inline file bytes are accepted.
The proxy verifies successful receipts contain a positive submission ID, a known
status (`queued`, `placed`, `done`, or `unanswered`), and the expected `messageId`. Attached sends also require matching ordered `attachmentIds` in the receipt.
Invalid receipts return 502; delivery remains uncertain, so retry using the original
`requestId` to avoid creating a duplicate message.
### `GET /v1/agent/read-state`
### `PUT /v1/agent/read-state`
GET returns `{ "cursor": "43", "updatedAt": "2026-10-14T18:00:00.000Z", "sourceId": "00000000-0000-4000-8000-000000000001" }`.
Initially: `{ "cursor": "0", "updatedAt": null, "sourceId": null }` (after source discovery the zero row also has its source UUID).
PUT takes `{ "cursor": "43", "sourceId": "00000000-0000-4000-8000-000000000001" }`; `sourceId` is optional for old clients.
A supplied source must match the bound runtime or the server returns 409. Both operations return the same additive response shape. The cursor is shared across this
personal installation and persisted in SQLite. Atomic, monotonic updates prevent
a stale browser from lowering a cursor saved by another browser. Advance it only
after showing the corresponding history; poll it to reconcile unread indicators
across clients. Agent REST responses use `Cache-Control: no-store`.
Source-aware clients bind both history and queued acknowledgments to the first accepted read source. A missing/null source is a legacy binding once read state or history has been accepted, not permission to adopt a later UUID. A source mismatch, HTTP 409 or invalid/non-advancing PUT acknowledgment stops old read writers and requires an explicit fresh history load; old cursors must never be relabeled. Known-source writes include `sourceId`; legacy writes omit it. Client reload does not reset server read state. See the source-binding rules in the streaming contract.
See the persistent Agent WebSocket section in `streaming-chat.md` for live updates.
### Agent attachments (v1)
`POST /v1/agent/attachments`: multipart/form-data with exactly one `file` part (original UTF-8 filename in Content-Disposition) and one `uploadId` field (client-generated UUID, stable across retries). Either part order is accepted. Request limit 8 MiB + 64 KiB. 201 new / 200 identical retry:
```json
{"attachment":{"id":"att_12345678-1234-4234-8234-123456789abc","kind":"text","filename":"notes.txt","mimeType":"text/plain","sizeBytes":12,"sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","workspacePath":"/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"}}
```
`workspacePath` is OPTIONAL for clients, canonical absolute runtime path when supplied. Never a host path, URL, or request parameter. Descriptors must retain filename/mimeType/sizeBytes/sha256. `kind` is `image`, `text`, or `file`. IDs are opaque. UUID retries must match normalized filename, effective MIME type and bytes, otherwise 409 `idempotency_conflict`. Filename is NFC normalized, 1–255 UTF-8 bytes, no slash/backslash/control characters, not `.`/`..`. A leading dot becomes `_` in the on-disk basename only; display filename remains unchanged.
`POST /v1/agent/messages`:
```json
{"content":"Read these notes","requestId":"client-stable-id","attachments":[{"id":"att_12345678-1234-4234-8234-123456789abc"}]}
```
`content` is required, <=40,000 characters; empty/whitespace is allowed with attachments. `requestId` is 1–256 characters and must survive retries. Omit attachments for text-only requests. At most 8 distinct references, 32 MiB total original bytes. References contain ONLY `id`; filenames, paths, URLs, base64 and forged workspacePath are rejected. Root delivery uses channel sybil, followUp and wait=false. 202 receipt:
```json
{"id":123,"status":"queued","messageId":"inbound:sybil:client-stable-id","attachmentIds":["att_12345678-1234-4234-8234-123456789abc"]}
```
Receipt status also permits placed/done/unanswered. `attachmentIds` is present for attached input, in request order, and MUST be checked before treating delivery as acknowledged. Caption + ordered IDs are durably bound to requestId; conflicting replay 409. Native admission atomically stores the input payload, binding, quota charge and worker eligibility; a competing text-only input cannot falsely acknowledge attachment IDs. Network errors may be retried with the same request/upload IDs. Never auto-create a fresh ID on ambiguous failure.
History, replay and queued activity use the existing public message schema with `metadata.agentAttachments` containing these descriptors. `content` is the original caption, or `Attached: notes.txt.` for a file-only message. No model-only manifest, text preview or base64 is inserted into public message content. Ordinary chat's `metadata.attachments` schema is unchanged. Raw tool rows stay in the protocol; client transcript filters stay unchanged.
`GET /v1/agent/attachments/:id/content`: authenticated original bytes, Content-Length, Content-Disposition attachment (safe ASCII filename and UTF-8 filename*), Cache-Control private/no-store and nosniff. No URLs containing tokens, public static mount, redirects or inline serving. Browser previews use authenticated fetch + revocable blob URL. Native downloads hold one of four slots through response finish/close/error, with a 30-second outbound timeout and disconnect cleanup. Raster types retain their validated MIME type; text downloads use text/plain; other files application/octet-stream. Clients can display/copy workspacePath as text.
### Attachment capability and errors
Optional `GET /v1/agent` field:
```json
{"capabilities":{"attachments":{"version":1,"maxCount":8,"maxTotalBytes":33554432,"maxImageBytes":6291456,"maxFileBytes":8388608,"imageMimeTypes":["image/png","image/jpeg","image/gif","image/webp"],"maxImagePixels":50000000,"maxImageDimension":16384,"quotaBytes":1073741824,"usedBytes":0,"remainingBytes":1073741824,"maxConcurrentUploads":2,"maxConcurrentDecodes":1}}}
```
Quota values are a snapshot, not a reservation. Absence disables attachment selection and sending. Backend rejects attachments against an old/unsafe upstream rather than allowing dropped references. Sybil also requires ADMIN_TOKEN for attachments; text-only open development behavior remains compatible.
Errors: `{ "error": true, "message": "...", "statusCode": 507, "code": "attachment_quota_exceeded", "quotaBytes": 1073741824, "remainingBytes": 1200, "requiredBytes": 20000 }`. Quota numbers are included for 507; no host filesystem information. 400 invalid_upload/invalid_attachment; 401 authentication; 404 attachment_not_found; 409 idempotency_conflict; 413 attachment_limit_exceeded; 415 unsupported_media_type; 422 invalid_image/image_model_unsupported; 429 attachments_busy with Retry-After: 2; 503 attachments_unavailable; 507 attachment_quota_exceeded; 502 unavailable/invalid upstream at proxy. Clients show message and retain retry identity. Remove from composer does not delete completed durable uploads.
### Runtime access and release requirements
The runtime resolves IDs to persistent `WORKSPACE_DIR/uploads/<id>/<safe-original-basename>` files (default `/data/workspace/uploads/...`), backed by the existing `/data` mount. Images are native model image blocks, never remote links: supported PNG/JPEG/GIF/WebP originals are at most 6 MiB and are prepared as a bounded static first frame (orientation corrected, at most 2000×2000 and 4.5 MiB base64, further limited by the actual model). Reject unsupported image models; no arbitrary document/file provider blocks. Other files are at most 8 MiB and are available through actual workspace read/bash tools. Valid recognized UTF-8 text receives a bounded preview of 16,000 Unicode characters per file / 64,000 per input. PDF/archive/binary files are paths plus metadata. Original hash, size and confinement are validated during resolution. Root and workers share the same workspace; explicit `subagent` spawn/send `attachmentIds` persist in durable Reporter tasks and use the same resolver. Preparation saturation retries through a durable checkpoint after two seconds using the same Reporter request ID. Permanent admission failures, including quota exhaustion, reach the parent through the durable worker-report path. Uploads never auto-execute.
Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reservations cover originals, static renditions, staging, metadata and conservative attachment input copies. Quota is conservative accounting, not free disk space. No complete upload expiry or delete endpoint is enabled. Removed composer files stay stored; crashed/uncertain reservations stay charged across restart until deliberate operator reconciliation. Ordinary authorized workspace tool writes and unrelated conversation history are outside attachment-ingestion accounting. Runtime uploads default to two concurrently (`SYBIL_AGENT_ATTACHMENT_UPLOADS`, max8), image decodes one (`SYBIL_AGENT_ATTACHMENT_DECODES`, max4); bounded input preparation and proxy transfers return429 when saturated. Disconnected proxy transfers cancel upstream work and release slots.
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
## Agent push notifications (v1)
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
### `GET /v1/agent/push/config`
```json
{"version":1,"enabled":false,"apnsAvailable":false,"webPushAvailable":false,"sourceId":null,"quarantined":false,"policy":{"promptGraceMs":5000,"debounceMs":2000,"interactionGraceMs":5000,"inactiveGraceMs":5000,"unknownGraceMs":5000,"presenceHeartbeatMs":15000,"presenceLeaseMs":60000}}
```
`vapidPublicKey` is present only when Web Push is enabled and its credentials are valid. No private keys, tokens, paths or configured account identifiers are returned. Source discovery and browser presence run independently of transport enablement/installation consent. Clients bind local consent to a known source UUID as well as the authenticated backend; null/unknown source cannot opt in. A source change retires prior local consent/installation and requires a new UUID plus explicit user opt-in, rechecking the source after asynchronous permission/subscription/registration work. `sourceId` is the persisted bound runtime UUID or null before discovery. A changed runtime source or regressed durable cursor quarantines delivery and cancels pending alerts without resetting the canonical read cursor. There is deliberately no HTTP read-reset/rebaseline endpoint; an operator must reconcile replacement runtime data with source-aware clients before enabling a new baseline.
### `PUT /v1/agent/push/installations/:installationId`
Use a stable random UUID per local installation, authenticated with the current shared token. Registration is explicit user opt-in and requires enabled/configured transport. Native body:
```json
{"transport":"apns","enabled":true,"token":"hex-device-token","environment":"production","topic":"net.buzzert.sybil2"}
```
Device tokens must be even-length hexadecimal, 32–512 characters; use the exact token returned by the OS. The server pins permitted topic and environment. TestFlight/App Store normally use production; local development must match the signed `aps-environment` entitlement. Tokens rotate: upsert the current token after registration callbacks and each opted-in launch. App Store upload passwords cannot authenticate APNs.
Web body (the keys and endpoint come from the browser's actual `PushSubscription`):
```json
{"transport":"web","enabled":true,"subscription":{"endpoint":"https://web.push.apple.com/provider-path","expirationTime":null,"keys":{"p256dh":"base64url-P256-public-key","auth":"base64url-16-byte-auth-secret"}}}
```
Subscription keys are curve/size validated. Endpoints must use HTTPS, port 443, no userinfo/fragment, and the standard allowlisted Apple (`web.push.apple.com` or one-label `*.push.apple.com`), Google (`fcm.googleapis.com`) or Mozilla (`updates.push.services.mozilla.com`) provider hosts. HTTP redirects are never followed; DNS addresses are checked at connection time to reject private/reserved addresses. Other providers require a reviewed server allowlist change. There is no arbitrary push URL fetch.
Response: `{ "installationId":"uuid", "transport":"apns|web", "enabled":true, "version":1 }`. No endpoint/token/key is reflected. Identical registrations retain the same version. Rotation increments the version and cancels old pending deliveries; a provider rejection of an old version cannot disable a newer one. A single endpoint is active under at most one installation ID; rebinding it irreversibly retires every prior non-retired UUID for that endpoint, including provider-disabled and prior credential-generation registrations. A provider-invalid UUID may refresh only until a newer installation claims its endpoint. A PUT to an explicitly retired UUID returns 409. At most 64 opted-in installations are allowed (429 when full).
### `DELETE /v1/agent/push/installations/:installationId`
Idempotent authenticated 204. Irreversibly retires the UUID, including an unknown UUID (durable tombstone), removes stored transport payload, increments its generation once and cancels pending deliveries. Late/delayed PUTs cannot re-enable a retired UUID, including after restart. A new explicit user opt-in must mint a **new UUID**; rotations may continue using the still-active UUID. Provider-invalid disabled endpoints are separate from explicit retirement and may re-register their active UUID. Clients unregister before discarding their bearer on logout and unsubscribe locally. Offline logout must persist a local revocation tombstone for a later authenticated retry and must never silently re-enable consent. Retry that tombstone only against its original backend and credential authority; a 204 from a different backend does not acknowledge the old revocation. Persist authority digests and installation IDs, never raw credentials. Changing source under the same authority can still revoke the old UUID. A transport request already crossing the provider boundary cannot be recalled reliably.
### `PUT /v1/agent/presence/:sessionId`
A UUID per browser document/tab, independent of notification permission and selected workspace tab:
```json
{"sequence":12,"visibility":"visible","focused":true,"interacted":false}
```
Response `{ "expiresAt":"2026-10-15T03:00:00.000Z" }`. Sequence is a nonnegative signed 32-bit integer; stale/out-of-order heartbeats neither overwrite state nor extend the lease. Report every 15 seconds and on focus, blur, visibility changes, pagehide/pageshow and trusted pointer/key/touch/wheel/input events. Observe `document.visibilityState`, `document.hidden` and `document.hasFocus()`; pagehide explicitly reports hidden/unfocused. Times are assigned by the server. Each authenticated document's explicit lease lasts 60 seconds. Visible **and focused** blocks even while idle. Interaction reported while visible blocks for 5 seconds, including a subsequent blur/hide; hidden reports cannot create new interaction evidence. A live focused-to-hidden/unfocused transition adds at most 5 seconds of grace. Repeated hidden/unfocused heartbeats do not renew that transition grace. All authenticated workspace sessions participate independently, even without a socket or notification opt-in; at most 512 unexpired sessions are accepted. An inactive tab cannot override another active tab. Associate the same UUID and authenticated credential generation with WS auth as described in the streaming contract.
Unobserved authenticated clients (including a declared web UUID with no matching observation) share **one persisted 5-second introduction window per credential generation**, starting at the first such connection. Socket pings/pongs, reconnects, new unobserved UUIDs and backend restarts do not renew it. With no stable anonymous identity, subsequent raw connections cannot earn a new window: this deliberately favors eventual notification eligibility over indefinite suppression by uninstrumented clients. Fresh explicit document observations always retain their own veto. Expiring an observed lease does not itself create unknown attention. Connection liveness and web attention are separate; neither a socket nor its absence proves foreground state.
`DELETE /v1/agent/presence/:sessionId` returns 204 and releases focus/visibility best effort with the same short transition/interaction grace. It does not revoke push consent. Backend restarts still impose a separate 60-second safety warm-up, with explicit leases and the spent anonymous introduction window preserved across restart. If a page disappears without a successful hidden/blur receipt, its last explicit focus can block for the remaining 60-second lease.
### Scheduling, read reconciliation and delivery identities
Only complete user-visible root assistant messages enter the durable outbox. Tool output, model reasoning, worker envelopes and raw events do not. A runtime external event or timer may result in a later qualifying assistant message. First source discovery bootstraps at the current high-water cursor without historical alerts. Checkpoint advancement and enqueue are atomic. Latest accepted human input, including queued input and runtime-native channels, delays alerts for at least five seconds; backend prompt admission also records a durable five-second guard before proxying, covering a concurrent source snapshot (a failed submission only delays briefly). Bursts debounce for two seconds, with a 15-second burst cap subject to the prompt grace. Timer/external-event results and replies arriving at least 30 seconds after a prompt receive higher queue priority; coalescing keeps the latest message identity and preserves the burst's higher priority.
All web sessions must be inactive at dispatch, including the runtime-native browser. Before each provider request, the backend refreshes runtime read/prompt/presence, merges native read into the canonical shared read row, and transactionally rechecks opt-in/version, owner generation, source freshness, read cursor and all attention leases. Unknown/malformed/unavailable source data suppresses sends. Messages expire after five minutes; provider TTL is at most 60 seconds. The active outbox coalesces per installation; completed receipts are capped at 10,000 and expire after seven days, while revocation tombstones remain durable. There are at most four attempts, only after definite retryable provider rejections, with bounded backoff. A persisted send intent protects crash recovery: interrupted/ambiguous network sends are never automatically replayed. Exactly-once provider/device delivery is not promised; a final focus/read race after dispatch remains possible.
Each outbox row retains nullable `lastSuppressionReason`, `lastSuppressionClientKind` and server `lastSuppressedAt` for operator diagnosis, even after read cancellation or a later send. These fields record one evaluated veto, not a full session census, and do not retroactively explain older rows. Reasons are fixed codes for foreground focus, recent interaction, inactive/unknown grace, runtime active/unknown, prompt grace, startup warm-up or source/coordinator unavailability. Client kinds are `web`, `unknown`, `runtime_web`, `runtime_unknown` or `system`; no token, session/connection/installation identifier, endpoint or transcript is logged. They are internal database diagnostics, not additions to client responses. Runtime unknown also covers its independent restart/clock/source safety checks; it is not proof that a browser was active.
Alert payload identity is top-level `{version:1,sourceId,kind:"message",messageId,cursor}`. APNs adds `aps.alert` with title **Sybil Agent** and a native string `body` containing the actual eligible public assistant message preview, default sound and `thread-id:sybil-agent-root`. Web Push adds the same title/preview body, fixed `url:"/?agent=root"` and `tag:"sybil-agent:"+sourceId`. Enabling notifications includes message text by default, including on the lock screen when OS notification settings allow it; there is no separate preview opt-in. Explicit public assistant text is authorized content. Reasoning, tool results/arguments, worker messages, internal events, metadata and credentials are never alternative preview sources.
Markdown is flattened to readable text: formatting and HTML tags are removed, links retain their visible labels, images retain alt text, code retains text, and whitespace is collapsed. Link/image destinations are not fetched or appended. The body is bounded to 3,000 UTF-8 bytes including JSON escaping, with `…` on truncation at a Unicode code point boundary. The complete serialized APNs JSON is at most 4,096 bytes; Web Push JSON is at most 3,993 bytes, leaving 103 bytes for `aes128gcm` overhead within the 4,096-byte encrypted request budget. Identity/escaping overhead can further shorten the body. A successfully resolved message with no displayable text uses **You have a new message.**. Missing, malformed or unavailable public text suppresses sending until a valid lookup or normal expiry. Normal APNs alerts do not request critical/time-sensitive entitlements, and existing native clients can display the string body without a notification extension or app update.
Monotonic canonical read advances cancel pending alerts and broadcast `read_state` on authenticated WS. New read-aware clients pass the response's exact source/cursor to their notification manager; receiving/tapping a push does not itself mark history read. Native APNs clear payload is `{version:1,sourceId,kind:"read_state",readCursor,aps:{"content-available":1}}`, push type background/priority 5, coalesced to at most one request per minute per installation that has an earlier delivered/ambiguous alert. App foreground/background handlers reconcile delivered OS notifications by source/cursor. Silent pushes are best effort and cannot guarantee removal when an app is terminated or force-quit; foreground read sync remains required.
**No silent Web Push read-clear is sent.** Safari requires visible notifications for Web Push. Authenticated foreground pages forward source-bound read state to their service worker; it closes only owned notifications at/below that read cursor. Closing while every browser client is stopped is best effort only. The service worker must never receive/store the general bearer token. Notification click uses the fixed same-origin Agent link and waits for actual rendered history before acknowledging read.
The web service worker accepts bounded nonempty preview strings, preserving the fixed title, destination, tag and identity validation. It retains the latest preview alongside its existing per-source identity so a late delivery cannot pair older text with a newer cursor, including after worker restart. Legacy stored identities without text use the generic fallback on late deliveries. Generic payloads from older servers remain supported. Deploy the web worker before the preview-producing backend; previously installed generic-only workers must update to display actual-text payloads. The worker never fetches message content or linked resources.
### Server configuration and runtime source contract
Shipping is disabled by default: `AGENT_PUSH_ENABLED=false`. Native transport requires `AGENT_PUSH_APNS_KEY_FILE` (APNs-enabled ES256 `.p8`, regular secret file 0400/0600), `AGENT_PUSH_APNS_KEY_ID`, `AGENT_PUSH_APNS_TEAM_ID`, optional `AGENT_PUSH_APNS_TOPIC` (default `net.buzzert.sybil2`) and `AGENT_PUSH_APNS_ENVIRONMENT` (`production` by default, or `sandbox`; exactly one credential environment per deployment). Web Push requires `AGENT_PUSH_VAPID_FILE`, a private regular JSON file 0400/0600 containing `publicKey`, `privateKey` (base64url P-256) and `subject` (mailto/HTTPS contact). Invalid/missing/unsafe files leave only that adapter unavailable; files and parse errors are never exposed. This implementation neither creates production keys nor changes Apple portal capabilities. Correct native team/topic is `DQQH5H6GBD` / `net.buzzert.sybil2`; parent portal work, push entitlements/provisioning and real device registration are separate prerequisites. An environment-scoped production key cannot send sandbox notifications: sandbox registrations are rejected when production is configured. Concurrent sandbox/production credentials require a future explicit per-environment credential map; do not reuse one scoped key across environments.
Backend independently polls token-authenticated runtime `GET /api/notification-context?after=<cursor>&limit=200` and sends `X-Sybil-Client: backend-proxy-v1` on private proxy REST/WS traffic. The marker classifies the proxy for the native attention census and never replaces runtime authentication. The runtime source is version 1 with `{sourceId,conversationId,cursor,hasMore,latestUserMessageAt,nativeReadCursor,uncertain,webPresence:{active,unknown,observedAt},candidates:[{messageId,cursor,createdAt,trigger}]}`; trigger is `user|external_event|timer|worker|unknown`. No transcript text is included. `uncertain` and `webPresence.unknown` fail closed. Candidate pages preserve all candidates belonging to each consumed cursor (max 200 candidates/page), advancing across non-candidates; omitted `after` is high-water bootstrap with no old candidates. The source identity survives normal runtime restarts. If an incremental context request returns 409 because its cursor exceeds a replaced/restored runtime high-water mark, the backend makes exactly one authenticated no-after probe under the same 10-second deadline. Only a candidate-free, nonpaginated snapshot proving a different source or lower cursor is accepted, solely to quarantine and cancel old pending alerts. It never resets reads/checkpoints, rebaselines, or enumerates historical candidates. Failed/invalid probes and same-source nonregressions remain unavailable and suppress delivery. Runtime native presence/read is an acknowledgment input to the existing canonical backend read state, not a second independently authoritative read store.
For an otherwise dispatchable message only, the backend resolves its text through the existing authenticated root public endpoint: `GET /api/conversations/root/messages?after=<candidate.cursor-1>&limit=1&includeTools=false`. The URL/path is fixed to the configured runtime, redirects are rejected, the response is limited to 512 KiB and the request to 10 seconds. The page must match the observed root conversation and exact entry cursor; exactly one message must match the candidate ID, assistant role, `metadata.kind:"message"`, `status:"complete"` and entry ID. Only that message's string `content` is used. The runtime's existing root-candidate/provenance rules remain authoritative; a `worker` trigger qualifies only a later public root reply, never the worker's own output. Source/read/presence and all delivery conditions are rechecked after lookup and before committing send intent. Preview text is not persisted in backend outbox/receipt rows. No runtime change or database migration is required.
The runtime browser uses the same 15-second explicit heartbeat, 60-second lease and 5-second interaction/hidden/blur grace. Unobserved direct runtime sockets, event streams and REST pollers share one persisted 5-second introduction window per runtime source, beginning at their first establishment/observation. Polling and reconnects cannot renew it, including with fresh unobserved UUIDs. Existing source/clock uncertainty and the separate 60-second runtime restart safety interval still suppress; the backend never ignores a returned `unknown:true`.
+189 -10
View File
@@ -1,27 +1,30 @@
# Streaming Chat API Contract
This document defines the server-sent events (SSE) contract for chat completions.
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`
- `POST /v1/chats/:chatId/stream/attach`
- `POST /v1/quick-questions/stream`
Transport:
- HTTP response uses `Content-Type: text/event-stream; charset=utf-8`
- Events are emitted in SSE format (`event: ...`, `data: ...`)
- Request body is JSON
- Request body supports the same inline attachment schema and limits documented in `docs/api/rest.md`.
- Chat completion request bodies support the same inline attachment schema and limits documented in `docs/api/rest.md`. Quick Questions accept text only.
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.
## Request Body
## Chat Completion Request Body
```json
{
"chatId": "optional-chat-id",
"persist": true,
"provider": "openai|anthropic|xai|hermes-agent",
"clientRequestId": "optional-client-generated-id",
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string",
"messages": [
{
@@ -49,6 +52,8 @@ Authentication:
]
}
],
"additionalSystemPrompt": "optional one-off system prompt",
"enabledTools": ["web_search", "fetch_url"],
"temperature": 0.2,
"maxTokens": 256
}
@@ -59,13 +64,21 @@ Notes:
- If `persist` is `true` and `chatId` is omitted, backend creates a new chat.
- If `chatId` is provided, backend validates it exists.
- If `persist` is `false`, `chatId` must be omitted. Backend does not create a chat and does not persist input messages, tool-call messages, assistant output, or `LlmCall` metadata.
- `clientRequestId` is optional and is only valid for a persisted stream with a `chatId`. Clients should generate one stable, unique value per user submission and reuse it when retrying a disconnected request.
- A retry with the same `chatId` and `clientRequestId` attaches to and replays the matching active stream. If that submission already completed, the endpoint replays `meta` and `done` without invoking the provider again. This makes retrying the initial streaming `POST` idempotent.
- `clientRequestId` values may be up to 128 characters. The server stores the value in `metadata.clientRequestId` on the submitted user message and completed assistant message.
- For persisted streams, backend stores only new non-assistant input history rows to avoid duplicates.
- `additionalSystemPrompt`, when present directly or loaded from stored chat settings, is prepended to the provider request as a `system` message and is not inserted into the persisted chat transcript by this endpoint.
- `enabledTools` limits Sybil-managed tools for this request. When omitted for a saved chat, the stored chat setting is used; otherwise all available tools are enabled by default. An empty array disables Sybil-managed tools.
- `maxTokens` is optional. For `anthropic`, when omitted the backend requests the selected model's maximum output token limit from Anthropic's Models API and uses that as `max_tokens`; if the model limit cannot be loaded, the fallback is 128000. For other providers, omitted `maxTokens` is not sent as an explicit cap.
- Attachments are optional and are persisted under `message.metadata.attachments` on stored user messages when `persist` is `true`.
Persisted chat streams with a `chatId` are backend-owned active runs:
- Once started, the backend keeps the stream running even if the HTTP client disconnects or refreshes.
- The backend reserves the active run before persisting submitted messages, preventing a root or fork deletion from interleaving with stream setup.
- While running, `GET /v1/active-runs` includes the `chatId`.
- Starting a second persisted stream for the same active `chatId` returns `409`.
- Starting a second persisted stream for the same active `chatId` returns `409`, unless its `clientRequestId` matches the active submission, in which case the existing stream is replayed.
- Starting a persisted stream while that chat family is being deleted returns `409 { "message": "chat family deletion already in progress" }`.
- Clients can reattach with `POST /v1/chats/:chatId/stream/attach`.
## Attach Endpoint
@@ -79,6 +92,32 @@ Persisted chat streams with a `chatId` are backend-owned active runs:
This endpoint is intended for clients that restored an active `chatId` from `GET /v1/active-runs`, especially after browser refresh. Replayed `delta` events may include text that was originally emitted before the client attached.
## Quick Question Endpoint
`POST /v1/quick-questions/stream`
Request body:
```json
{
"provider": "openai|anthropic|xai|gemini|hermes-agent",
"model": "string",
"question": "What is the capital of France?",
"enabledTools": ["web_search", "fetch_url"],
"userLocation": "optional city, region, country",
"temperature": 0.2,
"maxTokens": 256
}
```
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.
- The response uses the same `meta`, `tool_call`, `delta`, `done`, and `error` SSE events as chat completion streams. The `meta` event has `chatId: null` and `callId: null`.
- `enabledTools`, `temperature`, and `maxTokens` are optional and behave as they do for chat completion streams. When `enabledTools` is omitted, all available Sybil-managed tools are enabled by default.
- User location is inferred from the same request headers as chat completion streams when `userLocation` is omitted.
## Event Stream Contract
Event order:
@@ -87,6 +126,8 @@ Event order:
3. Zero or more `delta`
4. Exactly one terminal event: `done` or `error`
Each tool invocation can emit multiple `tool_call` events with the same `toolCallId`. The backend emits `status: "initiated"` before the tool starts executing, then emits `status: "completed"` or `status: "failed"` when execution finishes. Clients should upsert by `toolCallId` instead of appending each event.
### `meta`
```json
@@ -111,6 +152,19 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
### `tool_call`
```json
{
"toolCallId": "call_123",
"name": "web_search",
"status": "initiated",
"summary": "Searching web for 'latest CPI release'.",
"args": { "query": "latest CPI release" },
"startedAt": "2026-03-02T10:00:00.000Z"
}
```
Terminal tool-call event:
```json
{
"toolCallId": "call_123",
@@ -121,11 +175,12 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
"startedAt": "2026-03-02T10:00:00.000Z",
"completedAt": "2026-03-02T10:00:00.820Z",
"durationMs": 820,
"error": null,
"resultPreview": "{\"ok\":true,...}"
}
```
`status` is one of `initiated`, `completed`, or `failed`. `completedAt` and `durationMs` are only present on terminal events. `error` is present on failed terminal events; `resultPreview` is present on terminal events when available.
### `done`
```json
@@ -151,18 +206,23 @@ For `persist: false` streams, `chatId` and `callId` are `null`.
## Provider Streaming Behavior
- `openai`: backend uses OpenAI's Responses API and may execute internal function tool calls (`web_search`, `fetch_url`, optional `codex_exec`, and optional `shell_exec`) before producing final text.
- `anthropic`: backend uses Anthropic's Messages API and may execute the same internal tools with `tool_use`/`tool_result` content blocks before producing final text.
- `xai`: backend uses xAI's OpenAI-compatible Chat Completions API and may execute the same internal tool calls before producing final text.
- `gemini`: backend uses Google's native Gemini `streamGenerateContent` API and may execute the same internal tool calls before producing final text.
- `fetch_url` sends browser-like navigation headers for outbound URL requests to reduce false 403s from sites that reject generic server clients.
- `hermes-agent`: backend uses the configured Hermes Agent OpenAI-compatible Chat Completions API. Sybil does not add its own tool definitions for this provider; Hermes Agent handles its own tools server-side. Custom Hermes stream events are normalized away unless they produce text deltas in this SSE contract.
- `openai`: image attachments are sent as Responses `input_image` items; text attachments are sent as `input_text` items.
- `gemini`: image attachments are sent as native Gemini `inlineData` parts; text attachments are inlined as text parts.
- `xai` and `hermes-agent`: image attachments are sent as Chat Completions content parts; text attachments are inlined as text parts.
- `openai`: Responses calls that can enter the server-managed tool loop use `store: true` so reasoning and function-call items can be passed between tool rounds.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE` (`exa` default, or `searxng` with `SEARXNG_BASE_URL` set). SearXNG mode requires the instance to allow `format=json`. This only affects chat-mode tool calls, not search-mode endpoints.
- `anthropic`: streamed via event stream; emits `delta` from `content_block_delta` with `text_delta`, and emits normalized `tool_call` SSE events when Anthropic `tool_use` blocks are executed. Image attachments are sent as base64 `image` blocks and text attachments are appended as `text` blocks.
- `web_search` uses `CHAT_WEB_SEARCH_ENGINE`: `exa` (default), `brave` (requires `BRAVE_SEARCH_API_KEY`), or `searxng` (requires `SEARXNG_BASE_URL`; the instance must allow `format=json`). This only affects chat-mode tool calls, not search-mode endpoints.
- Brave searches are queued and evenly paced according to the shortest window in Brave's `X-RateLimit-Policy` response header. The backend also honors `X-RateLimit-Remaining`/`X-RateLimit-Reset` and retries `429` responses up to three times with reset-aware exponential backoff; quota resets beyond the bounded retry window fail immediately.
- `codex_exec` is available only when `CHAT_CODEX_TOOL_ENABLED=true`. It SSHes to `CHAT_CODEX_REMOTE_HOST`, creates/uses `CHAT_CODEX_REMOTE_WORKDIR`, and runs `codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check <non-interactive wrapped prompt>` there with SSH stdin closed. Prefer `CHAT_CODEX_SSH_KEY_PATH` with a read-only mounted private key; `CHAT_CODEX_SSH_PRIVATE_KEY_B64` is also supported.
- `shell_exec` is available only when `CHAT_SHELL_TOOL_ENABLED=true`. It uses the same devbox SSH configuration, starts in `CHAT_CODEX_REMOTE_WORKDIR`, and runs non-interactive shell commands there with SSH stdin closed, not inside the Sybil server container.
- `CHAT_MAX_TOOL_ROUNDS` controls how many model/tool result cycles may occur before the backend returns a tool-call limit message; default is 100.
Tool-enabled streaming notes (`openai`/`xai`):
Tool-enabled streaming notes (`openai`/`anthropic`/`xai`/`gemini`):
- Stream still emits standard `meta`, `delta`, `done|error` events.
- Stream may emit `tool_call` events while tool calls are executed.
- `delta` events carry assistant text and are emitted incrementally for normal text rounds. The backend may buffer model-native text briefly while determining whether a provider round contains tool calls.
@@ -174,7 +234,8 @@ Backend database remains source of truth.
For persisted streams:
- Client may optimistically render accumulated `delta` text.
- Backend persists each completed tool call as a `tool` message before emitting its `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
- Backend emits initiated tool-call events without persisting them.
- Backend persists each completed or failed tool call as a `tool` message before emitting its terminal `tool_call` SSE event, so chat detail refreshes can show completed tool calls while the assistant response is still running.
On successful persisted completion:
- Backend persists assistant `Message` and updates `LlmCall` usage/latency in a transaction.
@@ -215,3 +276,121 @@ event: done
data: {"type":"done","text":"Hello world"}
```
## Sybil Agent WebSocket
`GET /v1/agent/ws` upgrades to a continuous WebSocket room subscription. Behind the
web proxy use `/api/v1/agent/ws`, with `ws:` or `wss:` matching the page. It remains
connected after each assistant turn and receives later background results. Sending
messages uses `POST /v1/agent/messages`.
The browser must send this first frame within five seconds:
```json
{ "type": "auth", "token": "Sybil token", "after": "43" }
```
In open mode, use `token: null`. Omit `after` for recent history; on reconnect supply
the last successfully merged cursor. Invalid credentials close with 4401, malformed
initial frames with 4400. Malformed upgrade URLs and query parameters are rejected
with HTTP 400; unknown upgrade paths return HTTP 404. Never put tokens in URLs.
Sybil adds its private Sybil Agent credential only to the upstream Authorization header;
browser credentials are not forwarded to the agent.
Native `pi-room.v1` frames are forwarded after authentication. This protocol
identifier is retained for compatibility with existing clients:
```json
{ "type": "ready", "protocol": "pi-room.v1", "conversationId": "1", "model": "..." }
```
```json
{
"type": "messages",
"conversationId": "1",
"messages": [{ "id": "outbound:44", "role": "assistant", "content": "The result is ready.", "createdAt": "2026-10-14T18:00:00.000Z", "metadata": { "kind": "message", "entryId": 44, "status": "complete" } }],
"cursor": "44",
"beforeCursor": "44",
"hasMore": false
}
```
```json
{ "type": "activity", "conversationId": "1", "activity": { "state": "thinking", "isThinking": true, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": [] } }
```
Activity is forwarded from Sybil Agent and also describes tools, queued messages, durable
waiting, errors, and workers. Use `isThinking` for the thinking indicator and
`backgroundWorkers` for ongoing delegated work; no model reasoning is exposed. Tools arrive as `role: "tool"` messages
with `metadata.kind: "tool_call"`, matching Sybil's tool renderer. REST pages and
socket messages are upserts keyed by stable `id`. Save each returned cursor after
applying its entire frame, even when it contains no visible messages.
Clients may send `{ "type": "ping" }` (receiving `{ "type": "pong" }`) or
`{ "type": "subscribe", "after": "44" }` to change the replay cursor. Transport
ping/pong heartbeats detect dead connections. Other writes belong on REST.
Errors use `{ "type": "error", "error": "..." }`. Upstream loss closes with 1011
or 1012; slow consumers may receive 1013. Reconnect with backoff and the last merged
cursor. Missing agent configuration closes with 4503.
The proxy closes upstream sockets when browsers leave or Sybil shuts down.
History remains durable in Sybil Agent. Shared read position is separate from delivery:
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
### Agent attachment history, queued activity and replay
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
```json
{
"id": "inbound:sybil:client-stable-id",
"role": "user",
"content": "Read these notes",
"createdAt": "2026-10-14T18:00:00.000Z",
"metadata": {
"kind": "message",
"channel": "sybil",
"status": "queued",
"agentAttachments": [{
"id": "att_12345678-1234-4234-8234-123456789abc",
"kind": "text",
"filename": "notes.txt",
"mimeType": "text/plain",
"sizeBytes": 12,
"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"workspacePath": "/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt"
}]
}
}
```
`workspacePath` is optional, authoritative runtime text for display/copy, never a download URL or send parameter. Only `attachments:[{id}]` goes into POST messages. Committed/replayed messages retain the same ordered descriptors and original caption; file-only content is `Attached: notes.txt.`. Internal model manifests/previews and native base64 are excluded from public user message content. Root/worker native inputs and raw authorized model endpoints may contain the real image blocks; native token auth must protect all of them.
Merge queued and committed messages by stable message ID. A matching ID alone does not confirm an attachment-bearing optimistic message: compare the ordered `metadata.agentAttachments[].id` against its local IDs as well. Retain descriptors, error and retry identity on a mismatch or uncertain receipt; show the error inline for touch clients. Matching canonical echoes can acknowledge delivery even if HTTP was lost. Preserve raw tool rows/cursors in client room state and existing transcript filtering. Old clients can ignore the additive metadata and still see a meaningful caption.
Retrieve bytes using authenticated `GET /v1/agent/attachments/:id/content`; fetch with an Authorization header and use/revoke browser blob URLs for previews. Never add credentials to URLs or use a public file route. Persist completed descriptors and pending upload IDs with the composer/outbox; navigation cannot silently turn an attached draft into a text-only send. Incomplete reloaded uploads require verified file reselection or explicit removal, per the REST contract.
Attached input admission and its request binding are atomic. Replay verifies the durable accepted payload, including the ordered attachment IDs, before acknowledging it. Queued attachment metadata survives restart even if placement did not run before the crash; normal queue recovery places the original input without duplicate charge or delivery. Worker preparation saturation retries through durable Reporter checkpoints; permanent admission failures appear through the existing worker-report path.
### Agent attention and shared read events (push v1)
Web auth frames always add `clientKind:"web"` to the existing `type`, `token`, and optional `after`. They also add `presenceSessionId:"per-document-uuid"` when secure UUID creation succeeds; omit only that field if unavailable. Report the same UUID to `PUT /v1/agent/presence/:sessionId` across the entire authenticated workspace. Native iOS clients add `clientKind:"native"`; no browser-attention lease is required for a native app. These declarations classify a client under the existing shared principal and do not grant new authority. Explicit document visibility/focus/interaction receipts, bound to that credential generation and session, determine attention independently of sockets. Every fresh visible/focused session suppresses, even while idle; hidden/blur transitions and recent visible interaction have 5-second grace, and unrenewed explicit observations expire after 60 seconds.
Old/unclassified authenticated sockets and web sockets with no matching observation share one persisted 5-second introduction grace per credential generation. Reconnects, missing/new UUIDs and transport heartbeat cannot extend or restart it. A spent anonymous window does not weaken any genuine document lease; an expired observed lease does not become unknown merely because its socket remains connected. Ping/pong updates only existing connection liveness, preserving the original kind/session and never recreating a removed row as unknown. The proxy classifies itself upstream using the authenticated `X-Sybil-Client: backend-proxy-v1` upgrade header.
The backend adds this optional server-to-client frame after a successful monotonic read advance, including a runtime-native browser acknowledgment merged into the shared row:
```json
{"type":"read_state","cursor":"44","updatedAt":"2026-10-15T03:00:00.000Z","sourceId":"00000000-0000-4000-8000-000000000001"}
```
`sourceId` is nullable before source discovery and is never silently rebound after runtime replacement. Source replacement quarantines push delivery; the backend does not reset old read cursors. Apply read cursors monotonically within their source, and only clear notifications whose payload source matches this frame and whose cursor is at/below it. Frames without a source remain usable by legacy unread indicators but cannot safely authorize notification clearing. Clients still poll `GET /v1/agent/read-state` on foreground/reconnect to recover missed frames; receiving history, activity, a push, or a notification tap does not acknowledge that history was viewed.
Distinguish an unbound room from one already bound to a legacy absent/null source or a source UUID. Bind on the first accepted read state; if history arrives first, pin the legacy state before publishing it. A different source in a later GET, stream frame or PUT acknowledgment, including null-to-UUID or UUID-to-null, must stop stale read writes and require an explicit fresh room load. Preserve the old snapshot and cursors until that reload, then fetch read state before fresh history; never attach a new UUID to an old cursor. Ordinary foreground/network recovery must not reset this stopped room. HTTP 409, an invalid acknowledgment cursor or an acknowledgment below the requested cursor also stops the writer. A transient transport failure can retry only within the same pinned source. None of these client reloads resets server read state.
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs and bounded anonymous grace per runtime source. The separate 60-second restart safety intervals and source/clock uncertainty checks remain in effect. No Web Push is used for invisible read synchronization.
Enabled notifications include a bounded plain-text preview of the eligible completed root public assistant message by default, including on the lock screen when OS settings allow it. The backend resolves only that candidate's exact public `content`, never activity/reasoning/tool/worker fields or linked resources. APNs uses a native string `aps.alert.body`; Web Push uses `body`. This does not change message IDs, stream frames, read acknowledgment or clearing semantics. See the REST contract for Markdown flattening, payload budgets and web worker update ordering.
+5
View File
@@ -0,0 +1,5 @@
ASC_KEY_ID=
ASC_ISSUER_ID=
ASC_KEY=
MATCH_PASSWORD=
MATCH_GIT_BASIC_AUTHORIZATION=
+10 -1
View File
@@ -1,2 +1,11 @@
*.xcodeproj
.env
.env.*
!.env.example
build/
*.ipa
*.dSYM.zip
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/
fastlane/test_output/
+7
View File
@@ -21,6 +21,12 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- To choose a screenshot path, run `just screenshot path=build/name.png`.
- The underlying screenshot command is `xcrun simctl io booted screenshot <path>` and requires a booted simulator.
## Release Workflow
- iOS release tags use the annotated tag namespace `release/ios/vX.Y.Z`; increment from the latest existing `release/ios/v*` tag.
- Tag message convention is `ios: X.Y.Z`, for example `git tag -a release/ios/v1.13.5 -m "ios: 1.13.5"`.
- Push the release commit and tag together with `git push origin <branch> release/ios/vX.Y.Z`.
- Fastlane derives the marketing version from the release tag and stamps `ios/Apps/Sybil/project.yml` during CI, so do not manually bump `MARKETING_VERSION` for normal tagged releases unless explicitly requested.
## App Structure
- App target entry: `/Users/buzzert/src/sybil-2/ios/Apps/Sybil/Sources/SybilApp.swift`
- Shared iOS app code lives in Swift package:
@@ -51,4 +57,5 @@ Instructions for work under `/Users/buzzert/src/sybil-2/ios`.
- OpenAI: `gpt-4.1-mini`
- Anthropic: `claude-3-5-sonnet-latest`
- xAI: `grok-3-mini`
- Gemini: `gemini-3.5-flash`
- Hermes Agent: `hermes-agent`
+7
View File
@@ -2,6 +2,13 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SybilAPNSEnvironment</key>
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
<key>UIBackgroundModes</key>
<array><string>remote-notification</string></array>
<!-- macOS-only: keep the global Quick Question shortcut available with no windows. -->
<key>NSSupportsAutomaticTermination</key>
<false/>
<key>UIApplicationShortcutItems</key>
<array>
<dict>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

+53 -2
View File
@@ -1,6 +1,7 @@
import Sybil
import SwiftUI
import UIKit
import UserNotifications
@main
struct SybilApp: App
@@ -8,7 +9,7 @@ struct SybilApp: App
@UIApplicationDelegateAdaptor(SybilAppDelegate.self) private var appDelegate
var body: some Scene {
WindowGroup {
WindowGroup(id: SybilCommands.workspaceWindowID) {
SplitView()
}
.commands {
@@ -18,15 +19,51 @@ struct SybilApp: App
}
@MainActor
final class SybilAppDelegate: NSObject, UIApplicationDelegate {
final class SybilAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
UNUserNotificationCenter.current().delegate = self
SybilPushNotifications.launchFixtureIfRequested()
SybilHomeScreenQuickActionHandler.configureQuickActions()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.start()
#endif
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
SybilPushNotifications.didRegister(deviceToken: deviceToken)
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
SybilPushNotifications.didFailToRegister()
}
func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
SybilPushNotifications.receiveBackground(userInfo: userInfo) { result in completionHandler(result) }
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
let event = SybilPushNotifications.Event(userInfo: notification.request.content.userInfo)
return await SybilPushNotifications.presentationOptions(event: event)
}
nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
if response.actionIdentifier == UNNotificationDefaultActionIdentifier {
let event = SybilPushNotifications.Event(userInfo: response.notification.request.content.userInfo)
await SybilPushNotifications.didRespond(event: event, identifier: response.notification.request.identifier)
}
}
#if targetEnvironment(macCatalyst)
func applicationWillTerminate(_ application: UIApplication) {
SybilMacQuickQuestionController.shared.stop()
}
#endif
func application(
_ application: UIApplication,
configurationForConnecting connectingSceneSession: UISceneSession,
@@ -56,11 +93,25 @@ final class SybilSceneDelegate: NSObject, UIWindowSceneDelegate {
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
#if targetEnvironment(macCatalyst)
if scene is UIWindowScene, session.role == .windowApplication {
SybilMacWindowCommands.shared.windowConnected(sessionID: session.persistentIdentifier)
}
#endif
if let shortcutItem = connectionOptions.shortcutItem {
_ = SybilHomeScreenQuickActionHandler.handle(shortcutItem)
}
if let response = connectionOptions.notificationResponse, response.actionIdentifier == UNNotificationDefaultActionIdentifier {
SybilPushNotifications.didRespond(userInfo: response.notification.request.content.userInfo, identifier: response.notification.request.identifier)
}
}
#if targetEnvironment(macCatalyst)
func sceneDidDisconnect(_ scene: UIScene) {
SybilMacWindowCommands.shared.windowDisconnected(sessionID: scene.session.persistentIdentifier)
}
#endif
func windowScene(
_ windowScene: UIWindowScene,
performActionFor shortcutItem: UIApplicationShortcutItem,
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>$(SYBIL_APNS_ENVIRONMENT)</string>
</dict>
</plist>
+28 -2
View File
@@ -5,12 +5,25 @@ targets:
- iOS
- macCatalyst
deploymentTarget: "18.0"
attributes:
SystemCapabilities:
com.apple.Push:
enabled: 1
com.apple.BackgroundModes:
enabled: 1
sources:
- Sources
- Resources
dependencies:
- package: Sybil
product: Sybil
- target: SybilMacQuickQuestion
destinationFilters: [macCatalyst]
embed: true
link: false
codeSign: true
copy:
destination: plugins
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2
@@ -18,20 +31,33 @@ targets:
PRODUCT_MODULE_NAME: SybilApp
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
"CODE_SIGN_ENTITLEMENTS[sdk=iphoneos*]": Apps/Sybil/Sybil.entitlements
"CODE_SIGN_ENTITLEMENTS[sdk=iphonesimulator*]": Apps/Sybil/Sybil.entitlements
INFOPLIST_KEY_SybilAPNSEnvironment: "$(SYBIL_APNS_ENVIRONMENT)"
SWIFT_VERSION: 6.0
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD: NO
TARGETED_DEVICE_FAMILY: "1,2,6"
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_FILE: Apps/Sybil/Info.plist
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
MARKETING_VERSION: 1.7
CURRENT_PROJECT_VERSION: 8
MARKETING_VERSION: "1.13.2"
CURRENT_PROJECT_VERSION: 11
INFOPLIST_KEY_CFBundleDisplayName: Sybil
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption: NO
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=macosx*]": YES
INFOPLIST_KEY_UILaunchScreen_Generation: YES
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight
configs:
Debug:
SYBIL_APNS_ENVIRONMENT: development
Release:
SYBIL_APNS_ENVIRONMENT: production
CODE_SIGN_STYLE: Manual
CODE_SIGN_IDENTITY: Apple Distribution
"CODE_SIGN_IDENTITY[sdk=iphoneos*]": Apple Distribution
PROVISIONING_PROFILE_SPECIFIER: Sybil AppStore CI
schemes:
Sybil:
@@ -0,0 +1,229 @@
import AppKit
import Carbon
// AppKit is unavailable to Catalyst targets. This native bundle uses only
// public AppKit/Carbon APIs and communicates with the client through Foundation.
@MainActor
@objc(SybilMacQuickQuestionPlugin)
final class MacQuickQuestionPlugin: NSObject, SybilQuickQuestionPanelBridging, NSWindowDelegate {
private weak var delegate: (any SybilQuickQuestionPanelDelegate)?
private var panel: QuickQuestionPanel?
private var panelView: QuickQuestionPanelView?
private var hotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
private var outsideClickMonitor: Any?
private var hotKeyState = SybilQuickQuestionHotKeyState()
private var isDismissing = false
private var shortcutAvailable = false
private var windowlessActivity: NSObjectProtocol?
private var latestState = SybilQuickQuestionPanelState()
private var previousApplication: NSRunningApplication?
required override init() {
super.init()
}
var isVisible: Bool { panel?.isVisible == true }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32 {
self.delegate = delegate
if windowlessActivity == nil {
// Keep the hotkey service alive with no windows. This option does
// not prevent sleep/App Nap or intercept an explicit Quit command.
windowlessActivity = ProcessInfo.processInfo.beginActivity(
options: .automaticTerminationDisabled,
reason: "Quick Question global shortcut"
)
}
guard eventHandler == nil else { return noErr }
var eventTypes = [
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)),
EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyReleased))
]
let context = Unmanaged.passUnretained(self).toOpaque()
let handlerStatus = InstallEventHandler(
GetApplicationEventTarget(),
{ _, event, context in
guard let event, let context else { return OSStatus(eventNotHandledErr) }
var identifier = EventHotKeyID()
let status = GetEventParameter(
event, EventParamName(kEventParamDirectObject), EventParamType(typeEventHotKeyID),
nil, MemoryLayout<EventHotKeyID>.size, nil, &identifier
)
guard status == noErr, identifier.signature == 0x53595151, identifier.id == 1 else {
return OSStatus(eventNotHandledErr)
}
let isKeyDown = GetEventKind(event) == UInt32(kEventHotKeyPressed)
let plugin = Unmanaged<MacQuickQuestionPlugin>.fromOpaque(context).takeUnretainedValue()
// Application event handlers run on the main event loop.
MainActor.assumeIsolated {
if plugin.hotKeyState.shouldToggle(isKeyDown: isKeyDown) {
plugin.delegate?.quickQuestionToggleRequested()
}
}
return noErr
},
eventTypes.count, &eventTypes, context, &eventHandler
)
guard handlerStatus == noErr else { return handlerStatus }
let status = RegisterEventHotKey(
UInt32(kVK_Space), UInt32(optionKey),
EventHotKeyID(signature: 0x53595151, id: 1),
GetApplicationEventTarget(), 0, &hotKey
)
shortcutAvailable = status == noErr
return status
}
func updateState(_ data: Data) {
guard let state = try? JSONDecoder().decode(SybilQuickQuestionPanelState.self, from: data) else { return }
// Updating a background request must not create a non-hosting window.
// The controller creates/orders the panel only after UIKit is foreground.
latestState = state
panelView?.update(state, shortcutAvailable: shortcutAvailable)
if isVisible { resizePanel() }
}
func show() {
createPanelIfNeeded()
guard let panel, let panelView else { return }
panelView.update(latestState, shortcutAvailable: shortcutAvailable)
let screen = NSScreen.screens.first { $0.frame.contains(NSEvent.mouseLocation) } ?? NSScreen.main
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let width = min(680, visibleFrame.width - 48)
panel.setContentSize(NSSize(width: width, height: 240))
panelView.layoutSubtreeIfNeeded()
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
let top = visibleFrame.maxY - min(visibleFrame.height * 0.18, 160)
let y = max(visibleFrame.minY + 24, top - height)
panel.setFrame(NSRect(x: visibleFrame.midX - width / 2, y: y, width: width, height: height), display: true)
// The controller has verified UIKit foreground readiness. A
// nonactivating panel can then take focus without raising a workspace.
panel.makeKeyAndOrderFront(nil)
panel.makeFirstResponder(panelView.promptView)
panelView.preparePromptForPresentation()
installOutsideClickMonitor()
}
func hide() {
dismissPanel(restoringPreviousApplication: true)
}
func activateForQuickQuestion() {
if let frontmost = NSWorkspace.shared.frontmostApplication,
frontmost != NSRunningApplication.current {
previousApplication = frontmost
}
// Do not order a window here. Catalyst must finish its own foreground
// transition before the controller calls show().
NSApp.activate()
}
func activateMainWindow() {
// An explicit Open in Chat/Open Sybil action keeps focus in Sybil.
previousApplication = nil
NSApp.activate()
NSApp.windows.first { $0 !== panel && $0.canBecomeMain }?.makeKeyAndOrderFront(nil)
}
private func dismissPanel(restoringPreviousApplication: Bool) {
guard !isDismissing else { return }
let wasVisible = isVisible
isDismissing = true
if wasVisible { panel?.orderOut(nil) }
removeOutsideClickMonitor()
isDismissing = false
let applicationToRestore = previousApplication
previousApplication = nil
if restoringPreviousApplication, NSApp.isActive,
let applicationToRestore, !applicationToRestore.isTerminated {
NSApp.yieldActivation(to: applicationToRestore)
applicationToRestore.activate(options: [])
}
if wasVisible { delegate?.quickQuestionPanelDismissed() }
}
func stop() {
previousApplication = nil
hide()
removeOutsideClickMonitor()
if let hotKey { UnregisterEventHotKey(hotKey) }
if let eventHandler { RemoveEventHandler(eventHandler) }
hotKey = nil
eventHandler = nil
hotKeyState = SybilQuickQuestionHotKeyState()
if let windowlessActivity {
ProcessInfo.processInfo.endActivity(windowlessActivity)
}
windowlessActivity = nil
delegate = nil
}
func windowDidResignKey(_ notification: Notification) {
// Focus is already moving to a window/app the user chose.
dismissPanel(restoringPreviousApplication: false)
}
private func createPanelIfNeeded() {
guard panel == nil else { return }
let panel = QuickQuestionPanel(
contentRect: NSRect(x: 0, y: 0, width: 680, height: 240),
styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false
)
panel.title = "Quick Question"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient, .ignoresCycle]
panel.isFloatingPanel = true
panel.hidesOnDeactivate = false
panel.becomesKeyOnlyIfNeeded = false
panel.worksWhenModal = true
panel.isReleasedWhenClosed = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.isMovableByWindowBackground = true
panel.appearance = NSAppearance(named: .darkAqua)
panel.delegate = self
panel.onDismiss = { [weak self] in self?.hide() }
let view = QuickQuestionPanelView()
view.delegate = delegate
view.onDismiss = { [weak self] in self?.hide() }
panel.contentView = view
panelView = view
self.panel = panel
}
private func resizePanel() {
guard let panel, let panelView else { return }
panelView.layoutSubtreeIfNeeded()
let visibleFrame = panel.screen?.visibleFrame ?? panel.frame
let height = min(panelView.preferredHeight, visibleFrame.height - 64)
guard abs(height - panel.frame.height) > 1 else { return }
var frame = panel.frame
frame.origin.y = max(visibleFrame.minY + 24, frame.maxY - height)
frame.size.height = height
panel.setFrame(frame, display: true)
}
private func installOutsideClickMonitor() {
guard outsideClickMonitor == nil else { return }
// Mouse-only monitoring does not require Accessibility or Input
// Monitoring permission. No global keyboard event tap is installed.
outsideClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
Task { @MainActor [weak self] in self?.hide() }
}
}
private func removeOutsideClickMonitor() {
if let outsideClickMonitor { NSEvent.removeMonitor(outsideClickMonitor) }
outsideClickMonitor = nil
}
}
@MainActor
private final class QuickQuestionPanel: NSPanel {
var onDismiss: (() -> Void)?
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
override func cancelOperation(_ sender: Any?) { onDismiss?() }
}
@@ -0,0 +1,300 @@
import AppKit
@MainActor
final class QuickQuestionPanelView: NSVisualEffectView, NSTextViewDelegate {
weak var delegate: (any SybilQuickQuestionPanelDelegate)?
var onDismiss: (() -> Void)?
let promptView = QuickQuestionPromptView()
private let stack = NSStackView()
private let answerView = NSTextView()
private let answerScroll = NSScrollView()
private let statusLabel = NSTextField(wrappingLabelWithString: "")
private let providerPopup = NSPopUpButton()
private let modelPopup = NSPopUpButton()
private let convertButton = NSButton(title: "Open in Chat", target: nil, action: nil)
private let openAppButton = NSButton(title: "Open Sybil", target: nil, action: nil)
private let sendButton = NSButton()
private let shortcutLabel = NSTextField(labelWithString: "⌥ Space")
private var panelState = SybilQuickQuestionPanelState()
var preferredHeight: CGFloat { stack.fittingSize.height + 40 }
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
material = .hudWindow
blendingMode = .behindWindow
state = .active
wantsLayer = true
layer?.cornerRadius = 18
layer?.masksToBounds = true
layer?.borderWidth = 1
layer?.borderColor = NSColor.white.withAlphaComponent(0.14).cgColor
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 12
stack.detachesHiddenViews = true
stack.translatesAutoresizingMaskIntoConstraints = false
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20),
stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 20),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
])
addRow(makeHeader())
configureTextView(promptView, fontSize: 22)
promptView.delegate = self
promptView.isRichText = false
promptView.allowsUndo = true
promptView.setAccessibilityLabel("Quick question prompt")
promptView.onSubmit = { [weak self] in self?.submit() }
promptView.onToggle = { [weak self] in self?.delegate?.quickQuestionToggleRequested() }
let promptScroll = makeScrollView(documentView: promptView)
promptScroll.heightAnchor.constraint(equalToConstant: 64).isActive = true
addRow(promptScroll)
configureTextView(answerView, fontSize: 15)
answerView.isEditable = false
answerView.isSelectable = true
answerView.setAccessibilityLabel("Quick question answer")
answerView.linkTextAttributes = [.foregroundColor: NSColor.systemPurple, .underlineStyle: NSUnderlineStyle.single.rawValue]
answerScroll.drawsBackground = false
answerScroll.hasVerticalScroller = true
answerScroll.autohidesScrollers = true
answerScroll.documentView = answerView
answerScroll.heightAnchor.constraint(equalToConstant: 280).isActive = true
addRow(answerScroll)
statusLabel.font = .systemFont(ofSize: 12)
statusLabel.textColor = .secondaryLabelColor
statusLabel.maximumNumberOfLines = 3
addRow(statusLabel)
addRow(makeControls())
let hint = NSTextField(labelWithString: "Return to ask · Shift+Return for a new line · Esc to close")
hint.font = .systemFont(ofSize: 11)
hint.textColor = .secondaryLabelColor
addRow(hint)
update(SybilQuickQuestionPanelState(), shortcutAvailable: true)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
func update(_ next: SybilQuickQuestionPanelState, shortcutAvailable: Bool) {
let previous = panelState
panelState = next
if promptView.string != next.prompt, !promptView.hasMarkedText() {
promptView.string = next.prompt
promptView.setSelectedRange(NSRange(location: (next.prompt as NSString).length, length: 0))
}
promptView.isEditable = !next.isBusy
promptView.needsDisplay = true
if previous.answer != next.answer {
let wasAtBottom = answerScroll.contentView.bounds.maxY >= answerView.bounds.maxY - 36
answerView.textStorage?.setAttributedString(Self.renderAnswer(next.answer))
if wasAtBottom { answerView.scrollToEndOfDocument(nil) }
}
answerScroll.isHidden = next.answer.isEmpty
if providerPopup.numberOfItems == 0 || previous.providers != next.providers {
providerPopup.removeAllItems()
for provider in next.providers {
providerPopup.addItem(withTitle: provider.title)
providerPopup.lastItem?.representedObject = provider.id
}
}
if let index = next.providers.firstIndex(where: { $0.id == next.provider }) {
providerPopup.selectItem(at: index)
}
if modelPopup.numberOfItems == 0 || previous.models != next.models || previous.model != next.model {
modelPopup.removeAllItems()
var models = next.models
if !next.model.isEmpty, !models.contains(next.model) { models.insert(next.model, at: 0) }
modelPopup.addItems(withTitles: models)
modelPopup.selectItem(withTitle: next.model)
}
providerPopup.isEnabled = next.isAuthenticated && !next.isBusy
modelPopup.isEnabled = next.isAuthenticated && !next.isBusy
convertButton.isEnabled = next.canConvert
sendButton.isEnabled = next.canSend
openAppButton.isHidden = next.isAuthenticated
convertButton.isHidden = !next.isAuthenticated
shortcutLabel.stringValue = shortcutAvailable ? "⌥ Space" : "Shortcut unavailable"
shortcutLabel.toolTip = shortcutAvailable ? "Option+Space toggles Quick Question" : "Another app may be using Option+Space. Open Quick Question from Sybil’s File menu."
let status: String
if let error = next.error {
status = error
statusLabel.textColor = .systemRed
} else {
statusLabel.textColor = .secondaryLabelColor
if next.isCheckingSession {
status = "Connecting to Sybil…"
} else if !next.isAuthenticated {
status = "Connect in Sybil before asking a question."
} else if next.isConverting {
status = "Opening in chat…"
} else if next.isSending {
status = next.toolSummaries.last ?? "Thinking…"
} else {
status = ""
}
}
statusLabel.stringValue = status
statusLabel.isHidden = status.isEmpty
}
func preparePromptForPresentation() {
// Select only when invoked, never on streamed updates or normal edits.
promptView.selectAll(nil)
}
func textDidChange(_ notification: Notification) {
guard !panelState.isBusy else { return }
promptView.needsDisplay = true
delegate?.quickQuestionPromptChanged(promptView.string)
}
private func addRow(_ view: NSView) {
stack.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
}
private func makeHeader() -> NSView {
let icon = NSImageView(image: NSImage(systemSymbolName: "sparkles", accessibilityDescription: nil)!)
icon.contentTintColor = .systemPurple
icon.widthAnchor.constraint(equalToConstant: 18).isActive = true
let title = NSTextField(labelWithString: "Quick Question")
title.font = .systemFont(ofSize: 14, weight: .semibold)
shortcutLabel.font = .monospacedSystemFont(ofSize: 11, weight: .medium)
shortcutLabel.textColor = .secondaryLabelColor
let close = NSButton(image: NSImage(systemSymbolName: "xmark", accessibilityDescription: "Close Quick Question")!, target: self, action: #selector(closePanel))
close.isBordered = false
close.toolTip = "Close (Esc)"
close.setAccessibilityLabel("Close Quick Question")
close.widthAnchor.constraint(equalToConstant: 24).isActive = true
let row = NSStackView(views: [icon, title, NSView(), shortcutLabel, close])
row.spacing = 8
row.alignment = .centerY
row.heightAnchor.constraint(equalToConstant: 24).isActive = true
return row
}
private func makeControls() -> NSView {
providerPopup.target = self
providerPopup.action = #selector(changeProvider)
providerPopup.setAccessibilityLabel("Quick question provider")
providerPopup.widthAnchor.constraint(equalToConstant: 120).isActive = true
modelPopup.target = self
modelPopup.action = #selector(changeModel)
modelPopup.setAccessibilityLabel("Quick question model")
modelPopup.widthAnchor.constraint(lessThanOrEqualToConstant: 230).isActive = true
modelPopup.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
convertButton.target = self
convertButton.action = #selector(convertToChat)
convertButton.bezelStyle = .rounded
openAppButton.target = self
openAppButton.action = #selector(openApp)
openAppButton.bezelStyle = .rounded
sendButton.image = NSImage(systemSymbolName: "arrow.up", accessibilityDescription: "Ask Quick Question")
sendButton.imagePosition = .imageOnly
sendButton.bezelStyle = .circular
sendButton.contentTintColor = .systemPurple
sendButton.target = self
sendButton.action = #selector(submit)
sendButton.setAccessibilityLabel("Ask Quick Question")
sendButton.toolTip = "Ask (Return)"
sendButton.widthAnchor.constraint(equalToConstant: 32).isActive = true
let row = NSStackView(views: [providerPopup, modelPopup, NSView(), openAppButton, convertButton, sendButton])
row.spacing = 8
row.alignment = .centerY
row.detachesHiddenViews = true
row.heightAnchor.constraint(equalToConstant: 32).isActive = true
return row
}
private func configureTextView(_ view: NSTextView, fontSize: CGFloat) {
view.drawsBackground = false
view.textColor = .labelColor
view.insertionPointColor = .labelColor
view.font = .systemFont(ofSize: fontSize)
view.textContainerInset = NSSize(width: 0, height: 4)
view.isHorizontallyResizable = false
view.isVerticallyResizable = true
view.autoresizingMask = [.width]
view.textContainer?.widthTracksTextView = true
view.textContainer?.lineFragmentPadding = 0
}
private func makeScrollView(documentView: NSView) -> NSScrollView {
let scroll = NSScrollView()
scroll.drawsBackground = false
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.documentView = documentView
return scroll
}
private static func renderAnswer(_ text: String) -> NSAttributedString {
let parsed = (try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace))) ?? AttributedString(text)
let rendered = NSMutableAttributedString(attributedString: NSAttributedString(parsed))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 4
rendered.addAttributes([.foregroundColor: NSColor.labelColor, .paragraphStyle: paragraph], range: NSRange(location: 0, length: rendered.length))
for run in parsed.runs {
let intent = run.inlinePresentationIntent ?? []
var font = intent.contains(.code) ? NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) : NSFont.systemFont(ofSize: 15)
if intent.contains(.stronglyEmphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask) }
if intent.contains(.emphasized) { font = NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) }
rendered.addAttribute(.font, value: font, range: NSRange(run.range, in: parsed))
}
return rendered
}
@objc private func closePanel() { onDismiss?() }
@objc private func changeProvider() {
guard let provider = providerPopup.selectedItem?.representedObject as? String else { return }
delegate?.quickQuestionProviderChanged(provider)
}
@objc private func changeModel() {
guard let model = modelPopup.titleOfSelectedItem else { return }
delegate?.quickQuestionModelChanged(model)
}
@objc private func convertToChat() { delegate?.quickQuestionConvertRequested() }
@objc private func openApp() { delegate?.quickQuestionOpenAppRequested() }
@objc private func submit() {
guard panelState.canSend, !promptView.hasMarkedText() else { return }
delegate?.quickQuestionSubmitRequested()
}
}
@MainActor
final class QuickQuestionPromptView: NSTextView {
var onSubmit: (() -> Void)?
var onToggle: (() -> Void)?
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
guard string.isEmpty else { return }
("Ask anything…" as NSString).draw(
at: NSPoint(x: textContainerInset.width, y: textContainerInset.height),
withAttributes: [.font: font ?? NSFont.systemFont(ofSize: 22), .foregroundColor: NSColor.placeholderTextColor]
)
}
override func keyDown(with event: NSEvent) {
let modifiers = event.modifierFlags.intersection([.command, .control, .option, .shift])
if event.keyCode == 49, modifiers == .option {
if !event.isARepeat { onToggle?() }
return
}
if (event.keyCode == 36 || event.keyCode == 76), modifiers.isEmpty, !hasMarkedText() {
if !event.isARepeat { onSubmit?() }
return
}
// Let the text input system handle Shift+Return and IME confirmation.
super.keyDown(with: event)
}
}
@@ -0,0 +1,25 @@
targets:
SybilMacQuickQuestion:
type: bundle
platform: macOS
deploymentTarget: "15.0"
sources:
- Sources
- ../../Packages/Sybil/Sources/Sybil/SybilMacQuickQuestionBridge.swift
dependencies:
- sdk: AppKit.framework
- sdk: Carbon.framework
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.quick-question-panel
PRODUCT_NAME: SybilMacQuickQuestion
PRODUCT_MODULE_NAME: SybilMacQuickQuestion
DEVELOPMENT_TEAM: DQQH5H6GBD
CODE_SIGN_STYLE: Automatic
SWIFT_VERSION: 6.0
SUPPORTS_MACCATALYST: NO
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_CFBundleExecutable: $(EXECUTABLE_NAME)
INFOPLIST_KEY_NSPrincipalClass: SybilMacQuickQuestionPlugin
MACH_O_TYPE: mh_bundle
SKIP_INSTALL: YES
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane", "2.237.0"
+244
View File
@@ -0,0 +1,244 @@
GEM
remote: https://rubygems.org/
specs:
CFPropertyList (3.0.8)
abbrev (0.1.2)
addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0)
artifactory (3.0.17)
atomos (0.1.3)
aws-eventstream (1.4.0)
aws-partitions (1.1274.0)
aws-sdk-core (3.254.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
bigdecimal
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.130.0)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.228.1)
aws-sdk-core (~> 3, >= 3.254.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.12.1)
aws-eventstream (~> 1, >= 1.0.2)
babosa (1.0.4)
base64 (0.3.0)
benchmark (0.5.0)
bigdecimal (4.1.2)
claide (1.1.0)
colored (1.2)
colored2 (3.1.2)
commander (4.6.0)
highline (~> 2.0.0)
csv (3.3.6)
declarative (0.0.20)
digest-crc (0.7.0)
rake (>= 12.0.0, < 14.0.0)
domain_name (0.6.20240107)
dotenv (2.8.1)
emoji_regex (3.2.3)
excon (1.6.0)
logger
faraday (1.10.6)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
ruby2_keywords (>= 0.0.4)
faraday-cookie_jar (0.0.8)
faraday (>= 0.8.0)
http-cookie (>= 1.0.0)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.1)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.2.0)
multipart-post (~> 2.0)
faraday-net_http (1.0.2)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.4)
faraday_middleware (1.2.1)
faraday (~> 1.0)
fastimage (2.4.1)
fastlane (2.237.0)
CFPropertyList (>= 2.3, < 5.0.0)
abbrev (~> 0.1)
addressable (>= 2.9.0, < 3.0.0)
artifactory (~> 3.0)
aws-sdk-s3 (~> 1.197)
babosa (>= 1.0.3, < 2.0.0)
base64 (~> 0.2)
benchmark (>= 0.1.0)
bundler (>= 2.4.0, < 5.0.0)
colored (~> 1.2)
commander (~> 4.6)
csv (~> 3.3)
dotenv (>= 2.1.1, < 3.0.0)
emoji_regex (>= 0.1, < 4.0)
excon (>= 0.71.0, < 2.0.0)
faraday (~> 1.0)
faraday-cookie_jar (~> 0.0.6)
faraday_middleware (~> 1.0)
fastimage (>= 2.1.0, < 3.0.0)
fastlane-sirp (>= 1.1.0)
gh_inspector (>= 1.1.2, < 2.0.0)
google-apis-androidpublisher_v3 (~> 0.3)
google-apis-playcustomapp_v1 (~> 0.1)
google-cloud-env (>= 1.6.0, < 2.3.0)
google-cloud-storage (~> 1.31)
highline (~> 2.0)
http-cookie (~> 1.0.5)
json (< 3.0.0)
jwt (>= 2.10.3, < 4)
logger (>= 1.6, < 2.0)
mini_magick (>= 4.9.4, < 5.0.0)
multi_json (~> 1.12)
multipart-post (>= 2.0.0, < 3.0.0)
mutex_m (~> 0.3)
naturally (~> 2.2)
nkf (~> 0.2)
optparse (>= 0.1.1, < 1.0.0)
ostruct (>= 0.1.0)
plist (>= 3.1.0, < 4.0.0)
rubyzip (>= 2.0.0, < 3.0.0)
security (= 0.1.5)
simctl (~> 1.6.3)
terminal-notifier (>= 2.0.0, < 3.0.0)
terminal-table (~> 3)
tty-screen (>= 0.6.3, < 1.0.0)
tty-spinner (>= 0.8.0, < 1.0.0)
word_wrap (~> 1.0.0)
xcodeproj (>= 1.13.0, < 2.0.0)
xcpretty (~> 0.4.1)
xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
fastlane-sirp (1.1.0)
gh_inspector (1.1.3)
google-apis-androidpublisher_v3 (0.106.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-core (0.18.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (~> 1.9)
httpclient (>= 2.8.3, < 3.a)
mini_mime (~> 1.0)
mutex_m
representable (~> 3.0)
retriable (>= 2.0, < 4.a)
google-apis-iamcredentials_v1 (0.28.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-playcustomapp_v1 (0.18.0)
google-apis-core (>= 0.15.0, < 2.a)
google-apis-storage_v1 (0.65.0)
google-apis-core (>= 0.15.0, < 2.a)
google-cloud-core (1.9.0)
google-cloud-env (>= 1.0, < 3.a)
google-cloud-errors (~> 1.0)
google-cloud-env (2.2.2)
base64 (~> 0.2)
faraday (>= 1.0, < 3.a)
google-cloud-errors (1.7.0)
google-cloud-storage (1.62.0)
addressable (~> 2.8)
digest-crc (~> 0.4)
google-apis-core (>= 0.18, < 2)
google-apis-iamcredentials_v1 (~> 0.18)
google-apis-storage_v1 (>= 0.42)
google-cloud-core (~> 1.6)
googleauth (~> 1.9)
mini_mime (~> 1.0)
google-logging-utils (0.2.0)
googleauth (1.17.2)
faraday (>= 1.0, < 3.a)
google-cloud-env (~> 2.2)
google-logging-utils (~> 0.1)
jwt (>= 1.4, < 4.0)
os (>= 0.9, < 2.0)
pstore (~> 0.1)
signet (>= 0.16, < 2.a)
highline (2.0.3)
http-cookie (1.0.8)
domain_name (~> 0.5)
httpclient (2.9.0)
mutex_m
jmespath (1.6.2)
json (2.21.1)
jwt (3.2.0)
base64
logger (1.7.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
multi_json (1.21.1)
multipart-post (2.4.1)
mutex_m (0.3.0)
nanaimo (0.4.0)
naturally (2.3.0)
nkf (0.3.0)
optparse (0.8.1)
os (1.1.4)
ostruct (0.6.3)
plist (3.7.2)
pstore (0.2.1)
public_suffix (7.0.5)
rake (13.4.2)
representable (3.2.0)
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
retriable (3.8.0)
rexml (3.4.4)
rouge (3.28.0)
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
security (0.1.5)
signet (0.22.0)
addressable (~> 2.8)
faraday (>= 0.17.5, < 3.a)
jwt (>= 1.5, < 4.0)
simctl (1.6.10)
CFPropertyList
naturally
terminal-notifier (2.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
trailblazer-option (0.1.2)
tty-cursor (0.7.1)
tty-screen (0.8.2)
tty-spinner (0.9.3)
tty-cursor (~> 0.7)
uber (0.1.0)
unicode-display_width (2.6.0)
word_wrap (1.0.0)
xcodeproj (1.28.1)
CFPropertyList (>= 2.3.3, < 4.0)
atomos (~> 0.1.3)
base64
claide (>= 1.0.2, < 2.0)
colored2 (~> 3.1)
nanaimo (~> 0.4.0)
nkf
rexml (>= 3.3.6, < 4.0)
xcpretty (0.4.1)
rouge (~> 3.28.0)
xcpretty-travis-formatter (1.0.1)
xcpretty (~> 0.2, >= 0.0.7)
PLATFORMS
ruby
DEPENDENCIES
fastlane (= 2.237.0)
BUNDLED WITH
2.5.23
+2 -1
View File
@@ -24,7 +24,8 @@ let package = Package(
name: "Sybil",
dependencies: [
.product(name: "MarkdownUI", package: "swift-markdown-ui")
]),
],
resources: [.process("Resources")]),
.testTarget(
name: "SybilTests",
dependencies: ["Sybil"]
@@ -0,0 +1,14 @@
#if DEBUG
/// Opt-in operation counters for the simulator presentation benchmark. These
/// measure work, not frames; CADisplayLink sampling lives in the test target.
@MainActor
enum AgentRenderDiagnostics {
static var enabled = false
static var counts: [String: Int] = [:]
static func record(_ operation: String) {
guard enabled else { return }
counts[operation, default: 0] += 1
}
}
#endif
@@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,12 @@
{
"images": [
{
"filename": "character-still.png",
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -11,6 +11,7 @@ public struct SplitView: View {
@State private var hasPendingQuickQuestionPresentation = false
@State private var isQuickQuestionPresented = false
@State private var columnVisibility: NavigationSplitViewVisibility = .automatic
@State private var notificationSceneID = UUID()
private var keyboardActions: SybilKeyboardActions? {
guard !viewModel.isCheckingSession, viewModel.isAuthenticated else {
@@ -78,6 +79,7 @@ public struct SplitView: View {
.font(.sybil(.body))
.preferredColorScheme(.dark)
.focusedSceneValue(\.sybilKeyboardActions, keyboardActions)
#if !targetEnvironment(macCatalyst)
.sheet(isPresented: $isQuickQuestionPresented, onDismiss: handleQuickQuestionDismissed) {
SybilQuickQuestionView(
viewModel: viewModel,
@@ -85,10 +87,20 @@ public struct SplitView: View {
)
.presentationDragIndicator(.visible)
}
#endif
.task {
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
await viewModel.bootstrap()
SybilPushNotifications.foregroundFixtureIfRequested()
viewModel.notifications.setScene(notificationSceneID, active: scenePhase == .active)
routePendingNotification()
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.notifications.pendingRouteID) { _, _ in routePendingNotification() }
.onChange(of: viewModel.notifications.scope) { _, _ in routePendingNotification() }
.onDisappear { viewModel.notifications.setScene(notificationSceneID, active: false) }
.onReceive(quickActionRouter.$quickQuestionPresentationRequest) { request in
guard request > 0 else {
return
@@ -96,17 +108,27 @@ public struct SplitView: View {
queueQuickQuestionPresentation()
}
.onChange(of: viewModel.isCheckingSession) { _, _ in
routePendingNotification()
presentPendingQuickQuestionIfPossible()
}
.onChange(of: viewModel.isAuthenticated) { _, _ in
presentPendingQuickQuestionIfPossible()
}
.onChange(of: isQuickQuestionPresented) { _, presented in
viewModel.agent.isModalPresented = presented
}
.onChange(of: scenePhase) { _, nextPhase in
viewModel.notifications.setScene(notificationSceneID, active: nextPhase == .active)
switch nextPhase {
case .background:
shouldRefreshOnForeground = true
viewModel.markAppInactiveForNetwork()
case .active:
SybilPushNotifications.foregroundFixtureIfRequested()
routePendingNotification()
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
#endif
viewModel.markAppActiveForNetwork()
guard shouldRefreshOnForeground, horizontalSizeClass != .compact else {
return
@@ -127,6 +149,13 @@ public struct SplitView: View {
}
}
private func routePendingNotification() {
guard scenePhase == .active, viewModel.consumeAgentNotificationRoute() else { return }
hasPendingQuickQuestionPresentation = false
isQuickQuestionPresented = false
columnVisibility = .detailOnly
}
private func splitNavigationLeadingControl(for size: CGSize) -> SybilWorkspaceNavigationLeadingControl {
return size.width < size.height ? .showSidebar : .hidden
}
@@ -151,8 +180,13 @@ public struct SplitView: View {
}
hasPendingQuickQuestionPresentation = false
#if targetEnvironment(macCatalyst)
SybilMacQuickQuestionController.shared.attach(viewModel)
SybilMacQuickQuestionController.shared.show()
#else
quickQuestionFocusRequest += 1
isQuickQuestionPresented = true
#endif
}
private func handleQuickQuestionDismissed() {
@@ -161,23 +195,57 @@ public struct SplitView: View {
}
public struct SybilCommands: Commands {
public static let workspaceWindowID = "sybil-workspace"
@FocusedValue(\.sybilKeyboardActions) private var keyboardActions
#if targetEnvironment(macCatalyst)
@Environment(\.openWindow) private var openWindow
@ObservedObject private var windowCommands = SybilMacWindowCommands.shared
#endif
public init() {}
public var body: some Commands {
CommandGroup(replacing: .newItem) {
Button("New Chat") {
#if targetEnvironment(macCatalyst)
windowCommands.newChatOrWindow(
newChat: keyboardActions?.newChat,
openWindow: openNewWindow
)
#else
keyboardActions?.newChat()
#endif
}
.keyboardShortcut("n", modifiers: .command)
#if targetEnvironment(macCatalyst)
.disabled(!windowCommands.canStartNewChat(hasFocusedActions: keyboardActions != nil))
#else
.disabled(keyboardActions == nil)
#endif
#if targetEnvironment(macCatalyst)
Button("New Window", action: openNewWindow)
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
Button("New Search") {
keyboardActions?.newSearch()
}
#if targetEnvironment(macCatalyst)
.keyboardShortcut("n", modifiers: [.command, .option])
#else
.keyboardShortcut("n", modifiers: [.command, .shift])
#endif
.disabled(keyboardActions == nil)
#if targetEnvironment(macCatalyst)
Divider()
Button("Quick Question") {
SybilMacQuickQuestionController.shared.toggle()
}
.keyboardShortcut(.space, modifiers: .option)
#endif
}
CommandMenu("Conversation") {
@@ -194,6 +262,13 @@ public struct SybilCommands: Commands {
.disabled(keyboardActions == nil)
}
}
#if targetEnvironment(macCatalyst)
private func openNewWindow() {
// Targeting a WindowGroup without a value creates a fresh scene every
// time, even when another workspace window is already open.
openWindow(id: Self.workspaceWindowID)
}
#endif
}
private struct SybilKeyboardActions {
@@ -1,4 +1,5 @@
import Foundation
import CryptoKit
struct APIConfiguration: Sendable {
var baseURL: URL
@@ -17,7 +18,7 @@ struct AnyEncodable: Encodable {
}
}
actor SybilAPIClient: SybilAPIClienting {
actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClienting {
private let configuration: APIConfiguration
private let session: URLSession
@@ -40,10 +41,176 @@ actor SybilAPIClient: SybilAPIClienting {
self.session = session
}
func agentDescriptor() async throws -> AgentDescriptor {
var value = try await request("/v1/agent", method: "GET", responseType: AgentDescriptor.self)
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
throw AgentFailure(message: "Unsupported Agent room protocol.")
}
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { value.capabilities?.attachments = nil }
return value
}
func agentMessages(after: String?, before: String?) async throws -> AgentPage {
guard after == nil || before == nil else { throw AgentFailure(message: "Use one history cursor.") }
var query = [URLQueryItem(name: "limit", value: "200")]
if let after { query.append(URLQueryItem(name: "after", value: try AgentCursor.normalize(after))) }
if let before { query.append(URLQueryItem(name: "before", value: try AgentCursor.normalize(before))) }
let page = try await request("/v1/agent/messages", method: "GET", queryItems: query, responseType: AgentPage.self)
try page.validate()
return page
}
func agentReadState() async throws -> AgentReadState {
try await request("/v1/agent/read-state", method: "GET", responseType: AgentReadState.self)
}
func agentPushConfig() async throws -> AgentPushConfig {
let data = try await pushRequest(path: "/v1/agent/push/config", method: "GET")
guard let config = try? JSONDecoder().decode(AgentPushConfig.self, from: data) else { throw APIError.invalidResponse }
return config
}
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
let data = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "PUT", body: AnyEncodable(registration))
guard let receipt = try? JSONDecoder().decode(AgentPushReceipt.self, from: data) else { throw APIError.invalidResponse }
return receipt
}
func agentRevokePush(id: UUID) async throws {
_ = try await pushRequest(path: "/v1/agent/push/installations/\(id.uuidString.lowercased())", method: "DELETE")
}
private func pushRequest(path: String, method: String, body: AnyEncodable? = nil) async throws -> Data {
#if DEBUG && targetEnvironment(simulator)
let allowLoopback = true // Local URLProtocol/UI fixtures; no device enrollment.
#else
let allowLoopback = false
#endif
guard AgentPushScope.canonicalBackend(configuration.baseURL, allowLoopback: allowLoopback) != nil,
configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw APIError.invalidBaseURL }
var request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
request.timeoutInterval = 8
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
// Same bounded, redirect-rejecting authenticated Agent transport as attachments.
// Never decode/log a server error body that might reflect an address or token.
let (data, status, _) = try await attachmentBytes(request, maximum: 64 * 1024)
guard method == "DELETE" ? status == 204 : (200...299).contains(status) else { throw AgentPushRequestError(status: status) }
return data
}
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor, sourceID: nil)
}
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
var body = ["cursor": try AgentCursor.normalize(cursor)]
if let sourceID {
guard AgentPushPayload.validSource(sourceID) else { throw APIError.invalidResponse }
body["sourceId"] = sourceID
}
return try await request("/v1/agent/read-state", method: "PUT", body: AnyEncodable(body), responseType: AgentReadState.self)
}
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(["content": content, "requestId": requestID]), responseType: AgentReceipt.self)
try receipt.validate(requestID: requestID)
return receipt
}
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
if !submission.attachmentIDs.isEmpty {
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
guard submission.attachmentIDs.count <= limits.countLimit else { throw AgentAttachmentIssue.local("Too many attachments for this Agent.", code: "attachment_limit_exceeded") }
}
let receipt = try await request("/v1/agent/messages", method: "POST", body: AnyEncodable(submission), responseType: AgentReceipt.self)
try receipt.validate(requestID: submission.requestId, attachmentIDs: submission.attachmentIDs)
return receipt
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
guard let limits = try await agentDescriptor().capabilities?.attachments, limits.supported else { throw AgentAttachmentIssue.unavailable }
let prepared = try AgentAttachmentPreparation.prepare(file.data, filename: file.filename, mimeType: file.mimeType, limits: limits)
let multipart = try AgentMultipart(file: prepared, uploadID: uploadID)
var request = try makeRequest(path: "/v1/agent/attachments", method: "POST", body: nil, acceptsSSE: false)
request.timeoutInterval = 120
request.setValue("multipart/form-data; boundary=\(multipart.boundary)", forHTTPHeaderField: "Content-Type")
request.httpBody = multipart.body
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: 64 * 1024)
guard status == 200 || status == 201 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
struct UploadResponse: Decodable { var attachment: AgentAttachment }
guard let response = try? JSONDecoder().decode(UploadResponse.self, from: data) else {
throw AgentAttachmentIssue.local("Invalid attachment upload receipt. Retry to confirm.", code: "invalid_upstream_response")
}
let attachment = response.attachment
try attachment.validate()
guard attachment.filename == prepared.filename, attachment.mimeType == prepared.mimeType,
attachment.sizeBytes == prepared.data.count, attachment.sha256 == prepared.sha256 else {
throw AgentAttachmentIssue.local("The upload receipt does not match the selected file.", code: "invalid_upstream_response")
}
return attachment
}
func agentDownload(_ attachment: AgentAttachment) async throws -> Data {
try attachment.validate()
guard configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { throw AgentAttachmentIssue.unavailable }
var request = try makeRequest(path: "/v1/agent/attachments", method: "GET", body: nil, acceptsSSE: false)
request.url = try configuration.agentAttachmentContentURL(id: attachment.id)
request.setValue("application/octet-stream", forHTTPHeaderField: "Accept")
let (data, status, retryAfter) = try await attachmentBytes(request, maximum: attachment.sizeBytes)
guard status == 200 else { throw AgentAttachmentIssue.response(data, status: status, retryAfter: retryAfter) }
guard data.count == attachment.sizeBytes,
SHA256.hash(data: data).map({ String(format: "%02x", $0) }).joined() == attachment.sha256 else {
throw AgentAttachmentIssue.local("The downloaded original failed its size or checksum check. Retry the download.", code: "invalid_upstream_response")
}
return data
}
private func attachmentBytes(_ request: URLRequest, maximum: Int) async throws -> (Data, Int, String?) {
let (stream, response) = try await session.bytes(for: request, delegate: AgentAttachmentRedirectPolicy())
defer { stream.task.cancel() }
guard let response = response as? HTTPURLResponse else { throw APIError.invalidResponse }
let success = (200...299).contains(response.statusCode)
let limit = success ? maximum : 64 * 1024
guard response.expectedContentLength <= Int64(limit) else {
throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response")
}
var data = Data()
for try await byte in stream {
try Task.checkCancellation()
guard data.count < limit else { throw AgentAttachmentIssue.local("The attachment response exceeds its size limit.", code: "invalid_upstream_response") }
data.append(byte)
}
return (data, response.statusCode, response.value(forHTTPHeaderField: "Retry-After"))
}
func agentSocket(after: String) async throws -> any AgentSocket {
let task = session.webSocketTask(with: try configuration.agentWebSocketURL())
task.maximumMessageSize = 8 * 1024 * 1024
task.resume()
let socket = AgentURLSocket(task: task)
do { try await socket.send(configuration.agentAuthFrame(after: after)) }
catch { await socket.close(); throw error }
return socket
}
func verifySession() async throws -> AuthSession {
try await request("/v1/auth/session", method: "GET", responseType: AuthSession.self)
}
func listWorkspaceItems() async throws -> [WorkspaceItem] {
let response = try await request("/v1/workspace-items", method: "GET", responseType: WorkspaceListResponse.self)
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
@@ -69,6 +236,36 @@ 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)",
method: "PATCH",
body: AnyEncodable(ChatTitleUpdateBody(title: title)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func updateChatStar(chatID: String, starred: Bool) async throws -> ChatSummary {
let response = try await request(
"/v1/chats/\(chatID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: ChatCreateResponse.self
)
return response.chat
}
func deleteChat(chatID: String) async throws {
_ = try await request("/v1/chats/\(chatID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -113,6 +310,16 @@ actor SybilAPIClient: SybilAPIClienting {
return response.chat
}
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary {
let response = try await request(
"/v1/searches/\(searchID)/star",
method: "PATCH",
body: AnyEncodable(StarUpdateBody(starred: starred)),
responseType: SearchCreateResponse.self
)
return response.search
}
func deleteSearch(searchID: String) async throws {
_ = try await request("/v1/searches/\(searchID)", method: "DELETE", responseType: DeleteResponse.self)
}
@@ -148,6 +355,29 @@ actor SybilAPIClient: SybilAPIClienting {
SybilLog.info(SybilLog.network, "Chat stream completed")
}
func runQuickQuestionStream(
body: QuickQuestionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws {
let request = try makeRequest(
path: "/v1/quick-questions/stream",
method: "POST",
body: AnyEncodable(body),
acceptsSSE: true
)
SybilLog.info(
SybilLog.network,
"Starting quick question stream POST \(request.url?.absoluteString ?? "<unknown>")"
)
try await stream(request: request) { eventName, dataText in
try await Self.handleCompletionStreamEvent(eventName: eventName, dataText: dataText, onEvent: onEvent)
}
SybilLog.info(SybilLog.network, "Quick question stream completed")
}
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
@@ -222,9 +452,10 @@ actor SybilAPIClient: SybilAPIClienting {
_ path: String,
method: String,
body: AnyEncodable? = nil,
queryItems: [URLQueryItem] = [],
responseType: Response.Type
) async throws -> Response {
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false)
let request = try makeRequest(path: path, method: method, body: body, acceptsSSE: false, queryItems: queryItems)
SybilLog.debug(
SybilLog.network,
@@ -235,7 +466,7 @@ actor SybilAPIClient: SybilAPIClienting {
let response: URLResponse
do {
(data, response) = try await session.data(for: request)
(data, response) = try await session.data(for: request, delegate: path.hasPrefix("/v1/agent") ? AgentAttachmentRedirectPolicy() : nil)
} catch {
if let urlError = error as? URLError, urlError.code == .cancelled {
SybilLog.debug(
@@ -249,6 +480,10 @@ actor SybilAPIClient: SybilAPIClienting {
throw wrapped
}
if path.hasPrefix("/v1/agent"), let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode),
(try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue?["code"]?.stringValue != nil {
throw AgentAttachmentIssue.response(data, status: http.statusCode, retryAfter: http.value(forHTTPHeaderField: "Retry-After"))
}
try validate(response: response, data: data)
do {
@@ -276,12 +511,14 @@ actor SybilAPIClient: SybilAPIClienting {
path: String,
method: String,
body: AnyEncodable?,
acceptsSSE: Bool
acceptsSSE: Bool,
queryItems: [URLQueryItem] = []
) throws -> URLRequest {
let url = try buildURL(path: path)
let url = try buildURL(path: path, queryItems: queryItems)
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 120
if path.hasPrefix("/v1/agent") { request.cachePolicy = .reloadIgnoringLocalCacheData }
request.timeoutInterval = path.hasPrefix("/v1/agent") ? 30 : 120
if acceptsSSE {
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
@@ -301,7 +538,7 @@ actor SybilAPIClient: SybilAPIClienting {
return request
}
private func buildURL(path: String) throws -> URL {
private func buildURL(path: String, queryItems: [URLQueryItem]) throws -> URL {
guard var components = URLComponents(url: configuration.baseURL, resolvingAgainstBaseURL: false) else {
throw APIError.invalidBaseURL
}
@@ -312,6 +549,9 @@ actor SybilAPIClient: SybilAPIClienting {
basePath.removeLast()
}
components.path = "\(basePath)/\(trimmedPath)"
if !queryItems.isEmpty {
components.queryItems = (components.queryItems ?? []) + queryItems
}
guard let url = components.url else {
throw APIError.invalidBaseURL
@@ -626,6 +866,13 @@ struct CompletionStreamRequest: Codable, Sendable {
var provider: Provider
var model: String
var messages: [CompletionRequestMessage]
var userLocation: String? = nil
}
struct QuickQuestionStreamRequest: Codable, Sendable {
var provider: Provider
var model: String
var question: String
}
private struct ChatCreateBody: Encodable {
@@ -635,6 +882,18 @@ private struct ChatCreateBody: Encodable {
var messages: [CompletionRequestMessage]?
}
private struct ChatTitleUpdateBody: Encodable {
var title: String
}
private struct ChatForkBody: Encodable {
var messageId: String?
}
private struct StarUpdateBody: Encodable {
var starred: Bool
}
private struct SearchCreateBody: Encodable {
var title: String?
var query: String?
@@ -2,6 +2,8 @@ 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?,
@@ -10,12 +12,16 @@ 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
func suggestChatTitle(chatID: String, content: String) async throws -> ChatSummary
func listSearches() async throws -> [SearchSummary]
func createSearch(title: String?, query: String?) async throws -> SearchSummary
func getSearch(searchID: String) async throws -> SearchDetail
func createChatFromSearch(searchID: String, title: String?) async throws -> ChatSummary
func updateSearchStar(searchID: String, starred: Bool) async throws -> SearchSummary
func deleteSearch(searchID: String) async throws
func listModels() async throws -> ModelCatalogResponse
func getActiveRuns() async throws -> ActiveRunsResponse
@@ -23,6 +29,10 @@ protocol SybilAPIClienting: Sendable {
body: CompletionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func runQuickQuestionStream(
body: QuickQuestionStreamRequest,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
) async throws
func attachCompletionStream(
chatID: String,
onEvent: @escaping @Sendable (CompletionStreamEvent) async -> Void
@@ -0,0 +1,62 @@
import Foundation
// At most two authenticated downloads per room. Cache only verified bytes, bounded to 16 MiB,
// and replace the entire actor when API configuration changes. No credential-bearing disk cache.
actor AgentAttachmentDownloads {
private var active = 0
private var invalidated = false
private var transfers: [UUID: Task<Data, any Error>] = [:]
private var waiters: [(UUID, CheckedContinuation<Void, any Error>)] = []
private var cache: [AgentAttachment: Data] = [:]
private var order: [AgentAttachment] = []
private var byteCount = 0
func data(for attachment: AgentAttachment, client: any AgentAPIClienting) async throws -> Data {
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
if let value = cache[attachment] { return value }
try await acquire()
defer { release() }
try Task.checkCancellation()
if let value = cache[attachment] { return value }
guard !invalidated else { throw CancellationError() }
let id = UUID()
let transfer = Task { try await client.agentDownload(attachment) }
transfers[id] = transfer
defer { transfers[id] = nil }
let value = try await withTaskCancellationHandler { try await transfer.value } onCancel: { transfer.cancel() }
try Task.checkCancellation()
guard !invalidated else { throw CancellationError() }
while byteCount + value.count > 16 * 1024 * 1024, !order.isEmpty {
let oldest = order.removeFirst()
byteCount -= cache.removeValue(forKey: oldest)?.count ?? 0
}
if cache[attachment] == nil { cache[attachment] = value; order.append(attachment); byteCount += value.count }
return value
}
func invalidate() {
invalidated = true
transfers.values.forEach { $0.cancel() }
for waiter in waiters { waiter.1.resume(throwing: CancellationError()) }
waiters = []; cache = [:]; order = []; byteCount = 0
}
private func acquire() async throws {
guard !invalidated else { throw CancellationError() }
if active < 2 { active += 1; return }
let id = UUID()
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
if Task.isCancelled { continuation.resume(throwing: CancellationError()) }
else { waiters.append((id, continuation)) }
}
} onCancel: { Task { await self.cancel(id) } }
}
private func cancel(_ id: UUID) {
guard let index = waiters.firstIndex(where: { $0.0 == id }) else { return }
waiters.remove(at: index).1.resume(throwing: CancellationError())
}
private func release() {
if waiters.isEmpty { active -= 1 }
else { waiters.removeFirst().1.resume() }
}
}
@@ -0,0 +1,219 @@
import CryptoKit
import Foundation
import Observation
struct AgentDraftItem: Codable, Equatable, Identifiable, Sendable {
enum State: String, Codable, Sendable { case loading, uploading, ready, failed }
var id: String = UUID().uuidString // Stable multipart uploadId, including across relaunch.
var filename: String
var mimeType: String?
var sizeBytes: Int?
var state: State = .loading
var attachment: AgentAttachment?
var issue: AgentAttachmentIssue?
}
struct AgentDraftSnapshot: Codable, Sendable {
var caption = ""
var items: [AgentDraftItem] = []
var id: String? = nil // Optional for drafts saved before durable outbox handoff tracking.
}
struct AgentDraftStorage: Sendable {
var read: @Sendable () throws -> AgentDraftSnapshot
var write: @Sendable (AgentDraftSnapshot) throws -> Void
var stage: @Sendable (String, Data) throws -> Void
var bytes: @Sendable (String) throws -> Data
var remove: @Sendable (String) -> Void
static func file(baseURL: URL, root: URL = .applicationSupportDirectory) -> Self {
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
let directory = root.appendingPathComponent("AgentAttachmentDrafts/\(key)", isDirectory: true)
let manifest = directory.appendingPathComponent("draft.json")
let file: @Sendable (String) throws -> URL = { id in
guard UUID(uuidString: id) != nil else { throw AgentAttachmentIssue.local("Invalid staged upload identity.") }
return directory.appendingPathComponent(id + ".bytes")
}
let write: @Sendable (Data, URL) throws -> Void = { data, destination in
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
}
return Self(read: {
guard FileManager.default.fileExists(atPath: manifest.path) else { return AgentDraftSnapshot() }
let data = try Data(contentsOf: manifest)
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
let snapshot = try JSONDecoder().decode(AgentDraftSnapshot.self, from: data)
guard snapshot.items.count <= 8, Set(snapshot.items.map(\.id)).count == snapshot.items.count,
snapshot.items.allSatisfy({ UUID(uuidString: $0.id) != nil }) else { throw AgentAttachmentIssue.local("The saved attachment draft is invalid.") }
return snapshot
}, write: { snapshot in
let data = try JSONEncoder().encode(snapshot)
guard data.count <= 2 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The saved attachment draft is too large.") }
try write(data, manifest)
}, stage: { id, data in
guard data.count <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
try write(data, file(id))
}, bytes: { id in
let url = try file(id)
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 8 * 1024 * 1024 else { throw AgentAttachmentIssue.local("The staged file exceeds the size limit.") }
return try Data(contentsOf: url)
}, remove: { id in if let url = try? file(id) { try? FileManager.default.removeItem(at: url) } })
}
}
@MainActor @Observable
final class AgentAttachmentDraft {
private(set) var id = UUID().uuidString
var caption = "" { didSet { save() } }
private(set) var items: [AgentDraftItem] = []
private(set) var capability: AgentAttachmentCapabilities?
private(set) var error: String?
private(set) var persistenceAvailable = true
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentDraftStorage?
@ObservationIgnored private var prepared: [String: AgentPreparedAttachment] = [:]
@ObservationIgnored private var loaders: [String: @Sendable () async throws -> AgentPreparedAttachment] = [:]
@ObservationIgnored private var worker: Task<Void, Never>?
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var restoring = false
@ObservationIgnored private var active = true
var supportsAttachments: Bool { capability?.supported == true && persistenceAvailable }
var ready: [AgentAttachment] { items.compactMap(\.attachment) }
var canSend: Bool {
caption.utf16.count <= 40_000 && (!caption.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !items.isEmpty)
&& (items.isEmpty || (supportsAttachments && items.allSatisfy { $0.state == .ready && $0.attachment != nil }))
&& persistenceAvailable
}
func configure(client: any AgentAPIClienting, storage: AgentDraftStorage?) {
suspend()
self.client = client; self.storage = storage
prepared = [:]; loaders = [:]; capability = nil; error = nil; persistenceAvailable = true
restoring = true
defer { restoring = false; active = true }
do {
let saved = try storage?.read() ?? AgentDraftSnapshot()
id = saved.id ?? UUID().uuidString
caption = saved.caption
items = saved.items.map { item in
var item = item
if item.state != .ready {
item.state = .failed
item.issue = .local("Upload interrupted. Retry with the same upload ID. If selection did not finish, remove and select the file again.", code: "upload_interrupted")
}
return item
}
} catch {
caption = ""; items = []; persistenceAvailable = false
self.error = "Could not restore attachment draft: \(error.localizedDescription)"
}
}
func updateCapability(_ value: AgentAttachmentCapabilities?) {
capability = value?.supported == true ? value : nil
if capability == nil { suspend(); active = true }
}
func resume() { active = true; pump() }
func suspend() {
active = false; generation = UUID(); worker?.cancel(); worker = nil
for index in items.indices where items[index].state == .loading || items[index].state == .uploading {
items[index].state = .failed
items[index].issue = .local("Transfer interrupted. Retry to continue.", code: "upload_interrupted")
}
save()
}
@discardableResult
func add(filename: String, load: @escaping @Sendable () async throws -> AgentPreparedAttachment) -> String? {
guard supportsAttachments, let capability else { error = AgentAttachmentIssue.unavailable.localizedDescription; return nil }
guard items.count < capability.countLimit else { error = "You can attach up to \(capability.countLimit) files."; return nil }
let item = AgentDraftItem(filename: filename)
items.append(item); loaders[item.id] = load; error = nil
save(); pump()
return item.id
}
func remove(_ id: String) {
// Restart the serial worker; completion from the old generation can never reinsert a removed item.
let interrupted = items.filter { $0.state == .loading || $0.state == .uploading }.map(\.id)
generation = UUID(); worker?.cancel(); worker = nil
items.removeAll { $0.id == id }; loaders[id] = nil; prepared[id] = nil
save()
if persistenceAvailable { storage?.remove(id) }
for other in interrupted where other != id {
if let index = items.firstIndex(where: { $0.id == other }) { items[index].state = .loading }
}
pump()
}
func retry(_ id: String) {
guard supportsAttachments else { error = AgentAttachmentIssue.unavailable.localizedDescription; return }
guard let index = items.firstIndex(where: { $0.id == id }), items[index].state == .failed else { return }
items[index].state = .loading; items[index].issue = nil; error = nil
save(); pump()
}
func validateReady() throws -> [AgentAttachment] {
guard items.isEmpty || supportsAttachments else { throw AgentAttachmentIssue.unavailable }
guard items.allSatisfy({ $0.state == .ready && $0.attachment != nil }) else { throw AgentAttachmentIssue.local("Wait for every attachment to finish, or retry/remove failed files.") }
if let capability { try capability.validate(ready) }
return ready
}
@discardableResult func clearAfterSubmission() -> Bool {
guard persistenceAvailable else { return false }
let nextID = UUID().uuidString
do { try storage?.write(AgentDraftSnapshot(id: nextID)) }
catch {
persistenceAvailable = false
self.error = "Message is saved in the outbox, but its draft could not be cleared. Reconnect before retrying: \(error.localizedDescription)"
return false
}
let ids = items.map(\.id)
generation = UUID(); worker?.cancel(); worker = nil
restoring = true; caption = ""; items = []; id = nextID; restoring = false
prepared = [:]; loaders = [:]
ids.forEach { storage?.remove($0) }
return true
}
func save() {
guard !restoring, persistenceAvailable else { return }
do { try storage?.write(AgentDraftSnapshot(caption: caption, items: items, id: id)) }
catch { persistenceAvailable = false; self.error = "Could not save attachment draft: \(error.localizedDescription)" }
}
private func pump() {
guard active, supportsAttachments, let client, let limits = capability, worker == nil,
let item = items.first(where: { $0.state == .loading }) else { return }
let run = generation
worker = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.worker = nil; self.pump() } }
do {
let file: AgentPreparedAttachment
if let existing = self.prepared[item.id] { file = existing }
else if let mime = item.mimeType, let storage = self.storage {
file = try AgentAttachmentPreparation.prepare(storage.bytes(item.id), filename: item.filename, mimeType: mime, limits: limits)
} else if let loader = self.loaders[item.id] { file = try await loader() }
else { throw AgentAttachmentIssue.local("Selection did not finish before the app closed. Remove this item and select it again.", code: "selection_unavailable") }
try Task.checkCancellation()
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
let otherBytes = self.items.filter { $0.id != item.id }.reduce(0) { $0 + ($1.sizeBytes ?? 0) }
guard otherBytes + file.data.count <= limits.totalLimit else { throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded") }
self.prepared[item.id] = file
try self.storage?.stage(item.id, file.data)
self.items[index].filename = file.filename; self.items[index].mimeType = file.mimeType
self.items[index].sizeBytes = file.data.count; self.items[index].state = .uploading
self.save()
guard self.persistenceAvailable else { throw AgentAttachmentIssue.local("Save failed. Reconnect before uploading.") }
let attachment = try await client.agentUpload(file: file, uploadID: item.id)
try attachment.validate()
guard attachment.filename == file.filename, attachment.mimeType == file.mimeType,
attachment.sizeBytes == file.data.count, attachment.sha256 == file.sha256 else {
throw AgentAttachmentIssue.local("Upload receipt does not match the original file. Retry to confirm.", code: "invalid_upstream_response")
}
try Task.checkCancellation()
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
self.items[index].attachment = attachment; self.items[index].state = .ready; self.items[index].issue = nil
self.save()
self.loaders[item.id] = nil; self.prepared[item.id] = nil
// Keep staged bytes until the message is durably handed to the outbox.
} catch {
guard self.generation == run, let index = self.items.firstIndex(where: { $0.id == item.id }) else { return }
self.items[index].state = .failed; self.items[index].issue = .wrapping(error); self.save()
}
}
}
}
@@ -0,0 +1,247 @@
import Foundation
import ImageIO
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
import UIKit
// FileTransferRepresentation keeps Photo Library imports file-backed and bounded while reading.
// PhotosPicker does not expose the original PHAsset filename without broader library access.
struct AgentPickedPhoto: Transferable, Sendable {
var data: Data
var filename: String?
var mimeType: String?
static var transferRepresentation: some TransferRepresentation {
FileRepresentation(importedContentType: .image) { received in
let url = received.file
let size = try url.resourceValues(forKeys: [.fileSizeKey]).fileSize ?? Int.max
guard size <= 32 * 1024 * 1024 else { throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded") }
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("This photo is too large to prepare. Select its original in Files.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
let type = CGImageSourceCreateWithData(data as CFData, nil).flatMap { CGImageSourceGetType($0) }.flatMap { UTType($0 as String) }
return Self(data: data, filename: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, mimeType: type?.preferredMIMEType)
}
}
}
struct AgentAttachmentComposer<Prompt: View>: View {
@Bindable var room: SybilAgentRoom
@ViewBuilder var prompt: () -> Prompt
@State private var modalID = UUID()
@State private var pickerSession: UUID?
@State private var showChoices = false
@State private var showFiles = false
@State private var showPhotos = false
@State private var photos: [PhotosPickerItem] = []
private var modal: Bool { showChoices || showFiles || showPhotos }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !room.attachmentDraft.items.isEmpty {
ScrollView(.horizontal) {
HStack(alignment: .top, spacing: 8) {
ForEach(room.attachmentDraft.items) { item in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(item.filename).lineLimit(1)
Button { room.attachmentDraft.remove(item.id) } label: { Image(systemName: "xmark.circle") }
.accessibilityLabel("Remove \(item.filename)")
}
if let attachment = item.attachment {
AgentAttachmentDetails(attachment: attachment)
} else {
Text(item.state == .uploading ? "Uploading…" : item.state == .loading ? "Loading…" : "Upload failed")
.foregroundStyle(SybilTheme.textMuted)
if item.state == .uploading || item.state == .loading { ProgressView().controlSize(.small) }
}
if let issue = item.issue {
Text(issue.localizedDescription).foregroundStyle(SybilTheme.danger).fixedSize(horizontal: false, vertical: true)
Button("Retry upload") { room.attachmentDraft.retry(item.id) }
.accessibilityIdentifier("agent-upload-retry")
}
}
.font(.sybil(.caption)).padding(10).frame(width: 260, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
}
}
}.frame(maxHeight: 190)
}
if let error = room.attachmentDraft.error {
Text(error).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
if !room.attachmentDraft.supportsAttachments && !room.attachmentDraft.items.isEmpty {
Text(AgentAttachmentIssue.unavailable.localizedDescription).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger)
}
HStack(alignment: .bottom, spacing: 10) {
if room.attachmentDraft.supportsAttachments {
SybilPromptAttachmentButton(
isEnabled: room.attachmentDraft.items.count < (room.attachmentDraft.capability?.countLimit ?? 0)
) {
pickerSession = room.attachmentSessionID
showChoices = true
}
.accessibilityIdentifier("agent-attach")
}
prompt()
}
}
.confirmationDialog("Add attachment", isPresented: $showChoices) {
Button("Photo Library") { showPhotos = true }
Button("Files") { showFiles = true }
Button("Cancel", role: .cancel) {}
}
.photosPicker(isPresented: $showPhotos, selection: $photos,
maxSelectionCount: max(1, (room.attachmentDraft.capability?.countLimit ?? 1) - room.attachmentDraft.items.count),
selectionBehavior: .ordered, matching: .images)
.fileImporter(isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { return }
switch result {
case let .success(urls):
for url in urls {
room.attachmentDraft.add(filename: url.lastPathComponent) {
try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.file(url, limits: limits) }
}
}
case let .failure(error):
if (error as NSError).code != NSUserCancelledError {
room.attachmentDraft.add(filename: "File selection") { throw AgentAttachmentIssue.wrapping(error) }
}
}
}
.onChange(of: photos) { _, selected in
guard pickerSession == room.attachmentSessionID, room.isSelected, let limits = room.attachmentDraft.capability else { photos = []; return }
for item in selected {
room.attachmentDraft.add(filename: "Photo") {
guard let photo = try await item.loadTransferable(type: AgentPickedPhoto.self) else {
throw AgentAttachmentIssue.local("The selected photo could not be loaded.")
}
return try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(photo.data, filename: photo.filename, mimeType: photo.mimeType, limits: limits)
}
}
}
photos = []
}
.onChange(of: room.attachmentSessionID) { _, _ in showChoices = false; showFiles = false; showPhotos = false; photos = []; pickerSession = nil }
.onChange(of: modal) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onDisappear { room.setAttachmentModal(modalID, presented: false) }
}
}
struct AgentAttachmentDetails: View {
var attachment: AgentAttachment
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text("\(attachment.mimeType) · \(ByteCountFormatter.string(fromByteCount: Int64(attachment.sizeBytes), countStyle: .file))")
.foregroundStyle(SybilTheme.textMuted)
if let path = attachment.workspacePath {
Button {
UIPasteboard.general.string = path
} label: {
Label(path, systemImage: "doc.on.doc").lineLimit(2).multilineTextAlignment(.leading)
}
.buttonStyle(.plain).accessibilityLabel("Copy workspace path: \(path)")
}
}.font(.sybil(.caption2))
}
}
struct AgentAttachmentCard: View {
var attachment: AgentAttachment
var room: SybilAgentRoom
@Environment(\.scenePhase) private var scenePhase
@State private var image: UIImage?
@State private var text: String?
@State private var issue: String?
@State private var loading = false
@State private var wantsDownload = false
@State private var revision = 0
@State private var exportURL: URL?
@State private var showShare = false
@State private var modalID = UUID()
private var loadKey: String { "\(room.attachmentSessionID):\(attachment.id):\(attachment.sha256):\(revision):\(scenePhase == .active)" }
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(attachment.filename).font(.sybil(.footnote, weight: .medium)).lineLimit(2)
AgentAttachmentDetails(attachment: attachment)
if attachment.kind == .image {
ZStack {
SybilTheme.surface
if let image { Image(uiImage: image).resizable().scaledToFit() }
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
}.frame(height: 180).clipped()
}
if let text {
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
.frame(height: 100, alignment: .topLeading).textSelection(.enabled)
}
if loading { ProgressView("Loading original…").font(.sybil(.caption)) }
if let issue { Text(issue).font(.sybil(.caption)).foregroundStyle(SybilTheme.danger) }
HStack {
if exportURL != nil {
Button { showShare = true } label: { Label("Save or share original", systemImage: "square.and.arrow.up") }
} else if !loading {
Button(issue == nil ? "Download original" : "Retry download") { wantsDownload = true; revision += 1 }
.accessibilityIdentifier("agent-download")
}
if loading { Button("Cancel") { wantsDownload = false; revision += 1 } }
}.font(.sybil(.caption))
}
.padding(12).frame(maxWidth: 340, alignment: .leading)
.background(SybilTheme.surfaceStrong, in: RoundedRectangle(cornerRadius: 12))
.task(id: loadKey) {
// Each view owns its transfer; configuration and foreground changes cancel it.
guard scenePhase == .active, wantsDownload || (attachment.kind == .image && revision == 0) else { loading = false; return }
loading = true; issue = nil
let session = room.attachmentSessionID
do {
let data = try await room.downloadAttachment(attachment)
try Task.checkCancellation()
guard session == room.attachmentSessionID else { return }
if attachment.kind == .image {
let bitmap = try await AgentAttachmentPreparation.offMain { try AgentAttachmentPreparation.raster(data, mime: attachment.mimeType) }
try Task.checkCancellation()
image = UIImage(cgImage: bitmap)
} else if attachment.kind == .text, let decoded = String(data: data, encoding: .utf8), !decoded.contains("\u{0000}") {
text = String(decoded.prefix(1000)) // Plain text only, including HTML/SVG.
}
if wantsDownload {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("AgentOriginal-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(try AgentAttachmentPreparation.filename(attachment.filename))
try data.write(to: destination, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
exportURL = destination
}
loading = false
} catch {
if !Task.isCancelled { issue = error.localizedDescription; loading = false }
}
}
.sheet(isPresented: $showShare) {
if let exportURL { AgentOriginalShare(url: exportURL) }
}
.onChange(of: showShare) { _, value in room.setAttachmentModal(modalID, presented: value) }
.onChange(of: room.attachmentSessionID) { _, _ in showShare = false; reset() }
.onDisappear { room.setAttachmentModal(modalID, presented: false); reset() }
}
private func reset() {
image = nil; text = nil
if let exportURL { try? FileManager.default.removeItem(at: exportURL.deletingLastPathComponent()) }
exportURL = nil
}
}
private struct AgentOriginalShare: UIViewControllerRepresentable {
var url: URL
func makeUIViewController(context: Context) -> UIActivityViewController {
UIActivityViewController(activityItems: [url], applicationActivities: nil)
}
func updateUIViewController(_ controller: UIActivityViewController, context: Context) {}
}
@@ -0,0 +1,269 @@
import CryptoKit
import Foundation
import ImageIO
import UniformTypeIdentifiers
import UIKit
struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
var version: Int
var maxCount: Int
var maxTotalBytes: Int
var maxImageBytes: Int
var maxFileBytes: Int
var imageMimeTypes: [String]
var maxImagePixels: Int
var maxImageDimension: Int
var quotaBytes: Int64? = nil
var usedBytes: Int64? = nil
var remainingBytes: Int64? = nil
var maxConcurrentUploads: Int? = nil
var maxConcurrentDecodes: Int? = nil
var supported: Bool {
version == 1 && maxCount > 0 && maxTotalBytes > 0 && maxImageBytes > 0 && maxFileBytes > 0
&& maxImagePixels > 0 && maxImageDimension > 0
}
var countLimit: Int { min(maxCount, 8) }
var totalLimit: Int { min(maxTotalBytes, 32 * 1024 * 1024) }
var imageLimit: Int { min(maxImageBytes, 6 * 1024 * 1024) }
var fileLimit: Int { min(maxFileBytes, 8 * 1024 * 1024) }
var pixelLimit: Int { min(maxImagePixels, 50_000_000) }
var dimensionLimit: Int { min(maxImageDimension, 16_384) }
func validate(_ attachments: [AgentAttachment]) throws {
guard supported else { throw AgentAttachmentIssue.unavailable }
for attachment in attachments { try attachment.validate() }
guard attachments.count <= countLimit, attachments.reduce(0, { $0 + $1.sizeBytes }) <= totalLimit else {
throw AgentAttachmentIssue.local("Attachments exceed the advertised message limit.", code: "attachment_limit_exceeded")
}
for attachment in attachments {
guard attachment.sizeBytes <= (attachment.kind == .image ? imageLimit : fileLimit),
attachment.kind != .image || imageMimeTypes.contains(attachment.mimeType) else {
throw AgentAttachmentIssue.local("An attachment exceeds the current Agent limits or uses an unavailable image format.", code: "attachment_limit_exceeded")
}
}
}
}
struct AgentCapabilities: Decodable, Sendable {
var attachments: AgentAttachmentCapabilities?
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
private enum CodingKeys: String, CodingKey { case attachments }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
// Unknown versions or malformed future capabilities must not break text chat.
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
}
}
struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
enum Kind: String, Codable, Sendable { case image, text, file }
var id: String
var kind: Kind
var filename: String
var mimeType: String
var sizeBytes: Int
var sha256: String
var workspacePath: String? = nil
func validate() throws {
guard !id.isEmpty, id.utf8.count <= 1024, !id.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains),
try AgentAttachmentPreparation.filename(filename) == filename,
sizeBytes >= 0, sizeBytes <= 8 * 1024 * 1024,
sha256.count == 64, sha256.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }),
try AgentAttachmentPreparation.mime(mimeType) == mimeType else {
throw AgentAttachmentIssue.local("The Agent returned an invalid attachment descriptor.", code: "invalid_upstream_response")
}
guard (kind == .image) == AgentAttachmentPreparation.nativeMIMEs.contains(mimeType),
kind != .image || sizeBytes <= 6 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("Invalid image attachment descriptor.", code: "invalid_upstream_response")
}
if let workspacePath {
guard workspacePath.hasPrefix("/"), !workspacePath.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw AgentAttachmentIssue.local("The Agent returned an invalid workspace path.", code: "invalid_upstream_response")
}
}
}
var metadataValue: JSONValue {
// All stored fields are JSON-compatible; this also keeps optional future fields separate from ChatAttachment.
(try? JSONDecoder().decode(JSONValue.self, from: JSONEncoder().encode(self))) ?? .null
}
}
extension Message {
var agentAttachments: [AgentAttachment] {
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
return entries.compactMap {
guard let data = try? JSONEncoder().encode($0),
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
(try? attachment.validate()) != nil else { return nil }
return attachment
}
}
}
struct AgentSubmission: Encodable, Sendable {
struct Reference: Encodable, Sendable { let id: String }
let requestId: String
let content: String
let attachments: [Reference]?
init(content: String, requestID: String, attachments: [AgentAttachment] = []) throws {
guard !requestID.isEmpty, requestID.utf16.count <= 256, content.utf16.count <= 40_000,
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty,
attachments.count <= 8, Set(attachments.map(\.id)).count == attachments.count else {
throw AgentAttachmentIssue.local("Enter a message of at most 40,000 characters or attach files.", code: "invalid_attachment")
}
for attachment in attachments { try attachment.validate() }
guard attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else {
throw AgentAttachmentIssue.local("Attachments exceed the total message size limit.", code: "attachment_limit_exceeded")
}
self.content = content; requestId = requestID
self.attachments = attachments.isEmpty ? nil : attachments.map { Reference(id: $0.id) }
}
var attachmentIDs: [String] { attachments?.map(\.id) ?? [] }
}
struct AgentAttachmentIssue: Error, LocalizedError, Codable, Equatable, Sendable {
var message: String
var code: String
var statusCode: Int?
// Preserve additive quota information without inventing field names.
var details: [String: JSONValue] = [:]
var retryAfter: String? = nil
var errorDescription: String? {
var result = "\(message) [\(code)]"
if code == "attachment_quota_exceeded" {
let numbers = ["requiredBytes", "remainingBytes", "quotaBytes"].compactMap { key -> String? in
guard let number = details[key]?.numberValue, number.isFinite, number >= 0, number < Double(Int64.max) else { return nil }
return "\(key): \(ByteCountFormatter.string(fromByteCount: Int64(number), countStyle: .file))"
}
if !numbers.isEmpty { result += " " + numbers.joined(separator: ", ") }
}
if let retryAfter { result += " Retry-After: \(retryAfter)." }
return result
}
static func local(_ message: String, code: String = "invalid_attachment") -> Self { Self(message: message, code: code) }
static let unavailable = Self.local("Attachments are unavailable on this Agent. Reconnect to check support, or remove the files to send text.", code: "attachments_unavailable")
static func wrapping(_ error: any Error) -> Self {
error as? Self ?? .local(error.localizedDescription, code: "attachment_transfer_failed")
}
static func response(_ data: Data, status: Int, retryAfter: String? = nil) -> Self {
let fields = (try? JSONDecoder().decode(JSONValue.self, from: data))?.objectValue ?? [:]
return Self(message: fields["message"]?.stringValue ?? fields["error"]?.stringValue ?? "Attachment request failed (HTTP \(status)).",
code: fields["code"]?.stringValue ?? "attachment_request_failed", statusCode: status, details: fields, retryAfter: retryAfter)
}
}
struct AgentPreparedAttachment: Sendable {
var filename: String
var mimeType: String
var data: Data
var sha256: String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() }
}
enum AgentAttachmentPreparation {
static func offMain<T: Sendable>(_ operation: @escaping @Sendable () throws -> T) async throws -> T {
let task = Task.detached(operation: operation)
return try await withTaskCancellationHandler { try await task.value } onCancel: { task.cancel() }
}
static let nativeMIMEs: Set<String> = ["image/png", "image/jpeg", "image/gif", "image/webp"]
static func filename(_ value: String) throws -> String {
let value = value.precomposedStringWithCanonicalMapping
guard !value.isEmpty, value.utf8.count <= 255, value != ".", value != "..",
!value.contains("/"), !value.contains("\\"),
!value.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) else {
throw AgentAttachmentIssue.local("The filename must be a safe basename of 1–255 UTF-8 bytes.")
}
return value
}
static func mime(_ value: String) throws -> String {
let value = value.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
let parts = value.split(separator: "/", omittingEmptySubsequences: false)
let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-.^_`|~")
guard parts.count == 2, parts.allSatisfy({ !$0.isEmpty && $0.unicodeScalars.allSatisfy(allowed.contains) }), value.utf8.count <= 255 else {
throw AgentAttachmentIssue.local("The selected file has an invalid media type.")
}
return value
}
static func file(_ url: URL, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
let accessed = url.startAccessingSecurityScopedResource()
defer { if accessed { url.stopAccessingSecurityScopedResource() } }
let name = try filename(url.lastPathComponent)
let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType ?? UTType(filenameExtension: url.pathExtension)
let media = try mime(type?.preferredMIMEType ?? "application/octet-stream")
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
var data = Data()
while let chunk = try handle.read(upToCount: 64 * 1024), !chunk.isEmpty {
try Task.checkCancellation()
guard data.count + chunk.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else {
throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded")
}
data.append(chunk)
}
return try prepare(data, filename: name, mimeType: media, limits: limits)
}
static func prepare(_ data: Data, filename name: String, mimeType: String, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
guard limits.supported else { throw AgentAttachmentIssue.unavailable }
let name = try filename(name), media = try mime(mimeType)
guard data.count <= (nativeMIMEs.contains(media) ? limits.imageLimit : limits.fileLimit) else { throw AgentAttachmentIssue.local("\(name) exceeds the file size limit.", code: "attachment_limit_exceeded") }
if nativeMIMEs.contains(media) {
guard limits.imageMimeTypes.contains(media), data.count <= limits.imageLimit else {
throw AgentAttachmentIssue.local("\(name) exceeds the image limit or uses an unavailable image format.", code: "attachment_limit_exceeded")
}
_ = try raster(data, mime: media, pixels: limits.pixelLimit, dimension: limits.dimensionLimit)
}
return AgentPreparedAttachment(filename: name, mimeType: media, data: data)
}
static func raster(_ data: Data, mime: String, pixels: Int = 50_000_000, dimension: Int = 16_384) throws -> CGImage {
guard nativeMIMEs.contains(mime), let source = CGImageSourceCreateWithData(data as CFData, nil),
let type = CGImageSourceGetType(source), UTType(type as String)?.preferredMIMEType == mime,
CGImageSourceGetStatus(source) == .statusComplete,
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
width > 0, height > 0, width <= dimension, height <= dimension, width <= pixels / height,
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 600, kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary) else {
throw AgentAttachmentIssue.local("The image is invalid, has a mismatched media type, or exceeds the dimension/pixel limit.", code: "invalid_image")
}
return image
}
static func photo(_ data: Data, filename originalName: String?, mimeType: String?, limits: AgentAttachmentCapabilities) throws -> AgentPreparedAttachment {
let media = mimeType ?? "application/octet-stream"
if nativeMIMEs.contains(media) {
return try prepare(data, filename: originalName ?? "photo.\(UTType(mimeType: media)?.preferredFilenameExtension ?? "jpg")", mimeType: media, limits: limits)
}
// Photo Library is an explicit visual-input selection. Convert HEIC/HEIF with bounded decoding.
guard let source = CGImageSourceCreateWithData(data as CFData, nil), let sourceType = CGImageSourceGetType(source),
["public.heic", "public.heif"].contains(sourceType as String), limits.imageMimeTypes.contains("image/jpeg"),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any],
let width = properties[kCGImagePropertyPixelWidth] as? Int, let height = properties[kCGImagePropertyPixelHeight] as? Int,
width > 0, height > 0, width <= limits.dimensionLimit, height <= limits.dimensionLimit, width <= limits.pixelLimit / height,
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: min(2000, limits.dimensionLimit), kCGImageSourceCreateThumbnailWithTransform: true] as CFDictionary),
let jpeg = UIImage(cgImage: image).jpegData(compressionQuality: 0.88) else {
throw AgentAttachmentIssue.local("This photo could not be prepared as a supported image. Select its original in Files to upload it as a file.", code: "invalid_image")
}
let safe = try filename(originalName ?? "photo.heic")
let stem = (safe as NSString).deletingPathExtension
return try prepare(jpeg, filename: stem + ".jpg", mimeType: "image/jpeg", limits: limits)
}
}
struct AgentMultipart: Sendable {
let boundary: String
let body: Data
init(file: AgentPreparedAttachment, uploadID: String) throws {
guard UUID(uuidString: uploadID) != nil else { throw AgentAttachmentIssue.local("Invalid upload identity.") }
let name = try AgentAttachmentPreparation.filename(file.filename)
let media = try AgentAttachmentPreparation.mime(file.mimeType)
var boundary: String
repeat { boundary = "Sybil-" + UUID().uuidString } while file.data.range(of: Data(boundary.utf8)) != nil
self.boundary = boundary
let quoted = name.replacingOccurrences(of: "\"", with: "\\\"")
var bytes = Data("--\(boundary)\r\nContent-Disposition: form-data; name=\"uploadId\"\r\n\r\n\(uploadID)\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"file\"; filename=\"\(quoted)\"\r\nContent-Type: \(media)\r\n\r\n".utf8)
bytes.append(file.data)
bytes.append(Data("\r\n--\(boundary)--\r\n".utf8))
guard bytes.count <= 8 * 1024 * 1024 + 64 * 1024 else { throw AgentAttachmentIssue.local("The upload exceeds the request size limit.", code: "attachment_limit_exceeded") }
body = bytes
}
}
@@ -0,0 +1,272 @@
import Foundation
struct AgentFailure: LocalizedError, Sendable {
let message: String
var errorDescription: String? { message }
}
// Compare decimal strings without losing precision through Double or overflowing Int.
enum AgentCursor {
static func normalize(_ value: String) throws -> String {
guard !value.isEmpty, value.utf8.allSatisfy({ (48...57).contains($0) }) else {
throw AgentFailure(message: "Invalid Agent cursor.")
}
let trimmed = value.drop(while: { $0 == "0" })
return trimmed.isEmpty ? "0" : String(trimmed)
}
static func less(_ a: String, _ b: String) -> Bool {
a.count == b.count ? a < b : a.count < b.count
}
static func advance(_ current: String, _ incoming: String) throws -> String {
let value = try normalize(incoming)
return less(current, value) ? value : current
}
}
struct AgentActivity: Codable, Equatable, Sendable {
struct Tool: Codable, Equatable, Sendable {
var id: String
var name: String
var status: String
var output: String?
}
struct Wait: Codable, Equatable, Sendable {
var due: Double
var reason: String?
}
var state: String = "idle"
var isThinking = false
var queued = 0
var backgroundWorkers = 0
var tools: [Tool] = []
var queuedMessages: [Message] = []
var wait: Wait?
var error: String?
var isBusy: Bool { isThinking || backgroundWorkers > 0 }
}
struct AgentDescriptor: Decodable, Sendable {
var id: String
var title: String
var type: String
var model: String
var `protocol`: String
var activity: AgentActivity
var websocket: String
var capabilities: AgentCapabilities? = nil
}
struct AgentPage: Codable, Sendable {
var conversationId: String
var messages: [Message]
var cursor: String?
var beforeCursor: String?
var hasMore: Bool
func validate() throws {
guard !conversationId.isEmpty, messages.allSatisfy({ !$0.id.isEmpty }) else {
throw AgentFailure(message: "Invalid Agent history.")
}
if let cursor { _ = try AgentCursor.normalize(cursor) }
if let beforeCursor { _ = try AgentCursor.normalize(beforeCursor) }
}
}
struct AgentReadState: Codable, Sendable {
var cursor: String
var updatedAt: String?
var sourceId: String? = nil
}
struct AgentReceipt: Codable, Sendable {
var id: Int64
var status: String
var messageId: String
var attachmentIds: [String]? = nil
func validate(requestID: String, attachmentIDs: [String] = []) throws {
guard id > 0, id <= 9_007_199_254_740_991,
["queued", "placed", "done", "unanswered"].contains(status),
messageId == "inbound:sybil:\(requestID)" else {
throw AgentFailure(message: "Invalid Agent receipt. Retry to confirm delivery.")
}
if !attachmentIDs.isEmpty, attachmentIds != attachmentIDs {
throw AgentAttachmentIssue.local("Agent receipt did not confirm the exact ordered attachments. Retry this message to confirm delivery.", code: "invalid_upstream_response")
}
}
}
enum AgentEvent: Sendable {
case ready(String?)
case messages(AgentPage)
case activity(String, AgentActivity)
case readState(AgentReadState)
case heartbeat
case error(String)
static func decode(_ data: Data) throws -> AgentEvent? {
struct Envelope: Decodable {
var type: String
var conversationId: String?
var activity: AgentActivity?
var error: String?
}
let envelope = try AgentCoding.decoder().decode(Envelope.self, from: data)
switch envelope.type {
case "ready": return .ready(envelope.conversationId)
case "messages":
let page = try AgentCoding.decoder().decode(AgentPage.self, from: data)
try page.validate()
return .messages(page)
case "activity":
guard let id = envelope.conversationId, let activity = envelope.activity else {
throw AgentFailure(message: "Invalid Agent activity frame.")
}
return .activity(id, activity)
case "pong", "heartbeat": return .heartbeat
case "read_state":
// Additive optional frame: malformed new metadata must not tear down
// an otherwise compatible Agent connection.
guard let read = try? AgentCoding.decoder().decode(AgentReadState.self, from: data),
AgentPushPayload.validCursor(read.cursor) != nil,
read.sourceId.map(AgentPushPayload.validSource) ?? true else { return nil }
return .readState(read)
case "error": return .error(envelope.error ?? "Agent connection error.")
default: return nil
}
}
}
enum AgentCoding {
static func decoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let text = try container.decode(String.self)
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let date = formatter.date(from: text) { return date }
formatter.formatOptions = [.withInternetDateTime]
guard let date = formatter.date(from: text) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid message date")
}
return date
}
return decoder
}
}
extension Message {
var agentEntryCursor: String? {
let value = metadata?.objectValue?["entryId"]
if let text = value?.stringValue { return try? AgentCursor.normalize(text) }
if let number = value?.numberValue, number >= 0, number <= 9_007_199_254_740_991, number.rounded() == number {
return String(Int64(number))
}
return nil
}
var isAgentPublicReply: Bool {
role == .assistant && metadata?.objectValue?["kind"]?.stringValue == "message"
}
var agentToolID: String { toolCallMetadata?.toolCallId ?? id }
}
struct AgentRoomState: Equatable, Sendable {
var conversationID: String?
var messages: [Message] = []
var cursor = "0"
var readCursor = "0"
var activity = AgentActivity()
var unreadCount: Int {
messages.filter { $0.isAgentPublicReply && $0.agentEntryCursor.map { AgentCursor.less(readCursor, $0) } == true }.count
}
mutating func checkConversation(_ id: String) throws {
guard conversationID == nil || conversationID == id else {
throw AgentFailure(message: "The Agent room changed. Reconnect in Settings to reload it.")
}
conversationID = id
}
mutating func apply(_ page: AgentPage) throws {
try page.validate()
var next = self
try next.checkConversation(page.conversationId)
next.cursor = try page.cursor.map { try AgentCursor.advance(cursor, $0) } ?? cursor
next.messages = Self.merge(messages, page.messages)
self = next
}
static func merge(_ previous: [Message], _ incoming: [Message]) -> [Message] {
var byID = Dictionary(previous.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
for var message in incoming {
if let old = byID[message.id] {
let prior = old.metadata?.objectValue ?? [:]
let next = message.metadata?.objectValue ?? [:]
if prior["status"] == .string("complete") && next["status"] == .string("queued") { continue }
if old.toolCallMetadata != nil, message.toolCallMetadata != nil {
let stale = ["completed", "failed"].contains(old.toolCallMetadata?.status ?? "") && message.toolCallMetadata?.status == "initiated"
var merged = prior.merging(next, uniquingKeysWith: { a, b in stale ? a : b })
if next["startedAt"] == nil || next["startedAt"] == .null { merged["startedAt"] = prior["startedAt"] }
if let args = prior["args"], next["args"]?.objectValue?.isEmpty != false { merged["args"] = args }
message.metadata = .object(merged)
message.createdAt = min(old.createdAt, message.createdAt)
if stale { message.content = old.content; message.name = old.name }
}
}
byID[message.id] = message
}
return byID.values.sorted {
if $0.createdAt != $1.createdAt { return $0.createdAt < $1.createdAt }
if let a = $0.agentEntryCursor, let b = $1.agentEntryCursor, a != b { return AgentCursor.less(a, b) }
return $0.id < $1.id
}
}
}
// Filter only the transcript projection, before windowing. Keep the room's raw
// messages and cursors intact for upserts, replay, unread state and activity.
struct AgentTranscript {
let messages: [Message]
let startIndex: Int
var displayed: [Message] { Array(messages.dropFirst(startIndex)) }
init(messages: [Message], startID: String? = nil, limit: Int = 100) {
let visible = messages.filter {
$0.role != .tool && $0.metadata?.objectValue?["kind"]?.stringValue != "tool_call"
}
self.messages = visible
startIndex = startID.flatMap { id in visible.firstIndex(where: { $0.id == id }) }
?? max(0, visible.count - limit)
}
}
// The transcript latch is independent of the truthful sidebar/worker activity.
struct AgentPresentation {
private(set) var suppressed = false
private(set) var busySince: Date?
private var seenTools: Set<String> = []
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
switch event {
case let .messages(page):
let existing = Set(before.messages.map(\.id))
let fresh = page.messages.filter { !existing.contains($0.id) && $0.agentEntryCursor.map { AgentCursor.less(before.cursor, $0) } == true }
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
if !replay {
for message in fresh {
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
else if message.role == .user { suppressed = false }
else if message.toolCallMetadata?.status == "initiated",
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
}
}
for message in page.messages where message.role == .tool { seenTools.insert(message.agentToolID) }
case let .activity(_, activity):
if !activity.isThinking || activity.tools.contains(where: { !seenTools.contains($0.id) && !Self.quietTools.contains($0.name) }) { suppressed = false }
seenTools.formUnion(activity.tools.map(\.id))
default: break
}
reconcile(activity: after.activity, now: now)
}
mutating func sent(now: Date, activity: AgentActivity) { suppressed = false; reconcile(activity: activity, now: now) }
private mutating func reconcile(activity: AgentActivity, now: Date) {
if suppressed || !activity.isBusy { busySince = nil }
else if busySince == nil { busySince = now }
}
func isTyping(now: Date) -> Bool { busySince.map { now.timeIntervalSince($0) >= 0.25 } ?? false }
}
@@ -0,0 +1,159 @@
import Foundation
import CoreFoundation
import CryptoKit
struct AgentPushConfig: Codable, Sendable {
var version: Int
var enabled: Bool
var apnsAvailable: Bool
var webPushAvailable: Bool
var sourceId: String?
var quarantined: Bool
var available: Bool { version == 1 && enabled && apnsAvailable && !quarantined && sourceId.map(AgentPushPayload.validSource) == true }
}
enum AgentAPNSEnvironment: String, Codable, Sendable {
case sandbox, production
// The app's Info.plist and entitlement expand the SAME XcodeGen build setting.
// Never guess from DEBUG, token length, simulator, or marketing version.
init?(entitlementSetting: String?) {
switch entitlementSetting {
case "development": self = .sandbox
case "production": self = .production
default: return nil
}
}
}
struct AgentPushRegistration: Encodable, Equatable, Sendable {
let transport = "apns"
let enabled = true
var token: String
var environment: AgentAPNSEnvironment
let topic = "net.buzzert.sybil2"
}
struct AgentPushReceipt: Codable, Sendable {
var installationId: String
var transport: String
var enabled: Bool
var version: Int
}
protocol AgentPushAPIClienting: Sendable {
func agentPushConfig() async throws -> AgentPushConfig
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt
func agentRevokePush(id: UUID) async throws
func agentReadState() async throws -> AgentReadState
}
struct AgentPushRequestError: LocalizedError, Sendable {
var status: Int
var retryable: Bool { status == 408 || status == 429 || status >= 500 }
var errorDescription: String? { "Agent notification request failed (\(status))." }
}
struct AgentPushScope: Codable, Equatable, Hashable, Sendable {
var backend: String
// ADMIN_TOKEN identifies one shared personal principal, not individual people.
var owner = "shared-personal-admin"
var credentialScope: String
var sourceID: String
static func credentialScope(backend: String, token: String) -> String {
// Nonsecret consent epoch, never logged. A credential replacement requires
// new consent even when the server still represents one personal principal.
SHA256.hash(data: Data((backend + "\n" + token.trimmingCharacters(in: .whitespacesAndNewlines)).utf8))
.map { String(format: "%02x", $0) }.joined()
}
static func canonicalBackend(_ url: URL, allowLoopback: Bool = false) -> String? {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false),
let host = parts.host?.lowercased(), parts.user == nil, parts.password == nil,
parts.query == nil, parts.fragment == nil,
parts.scheme?.lowercased() == "https" || (allowLoopback && parts.scheme == "http" && ["localhost", "127.0.0.1", "[::1]"].contains(host)) else { return nil }
parts.scheme = parts.scheme?.lowercased(); parts.host = host
if (parts.scheme == "https" && parts.port == 443) || (parts.scheme == "http" && parts.port == 80) { parts.port = nil }
let path = parts.path
guard !path.contains("//"), !path.split(separator: "/").contains(where: { $0 == "." || $0 == ".." }) else { return nil }
parts.path = path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty ? "" : "/" + path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return parts.url?.absoluteString
}
}
struct AgentPushPayload: Equatable, Sendable {
enum Kind: String { case message, readState = "read_state" }
var sourceID: String
var kind: Kind
var cursor: String
var messageID: String?
init?(_ info: [AnyHashable: Any]) {
guard let version = info["version"] as? NSNumber, CFGetTypeID(version) != CFBooleanGetTypeID(), version == 1,
let source = info["sourceId"] as? String, Self.validSource(source),
let name = info["kind"] as? String, let kind = Kind(rawValue: name),
let raw = info[kind == .message ? "cursor" : "readCursor"] as? String,
let cursor = Self.validCursor(raw) else { return nil }
let message = info["messageId"] as? String
if kind == .message && (message?.isEmpty != false || (message?.utf8.count ?? 0) > 256) { return nil }
self.sourceID = source.lowercased(); self.kind = kind; self.cursor = cursor; self.messageID = message
}
static func validSource(_ value: String) -> Bool {
value.utf8.count == 36 && UUID(uuidString: value) != nil
}
static func validCursor(_ value: String) -> String? {
guard value.utf8.count <= 16, let normalized = try? AgentCursor.normalize(value),
!AgentCursor.less("9007199254740991", normalized) else { return nil }
return normalized
}
}
struct AgentDeliveredNotification: Sendable {
var identifier: String // UNNotification.request.identifier, never messageId.
var payload: AgentPushPayload?
}
enum AgentNotificationPermission: Sendable { case notDetermined, denied, authorized, provisional }
@MainActor
protocol AgentNotificationPlatform: AnyObject {
var environment: AgentAPNSEnvironment? { get }
var allowsLoopback: Bool { get }
func permission() async -> AgentNotificationPermission
func requestPermission() async throws -> Bool
func register(generation: UUID)
func unregister()
func delivered() async -> [AgentDeliveredNotification]
func removeDelivered(identifiers: [String])
}
struct AgentPushRecord: Codable, Equatable {
var scope: AgentPushScope
var installationID = UUID()
var optedIn = false
var mayBeRegistered = false
var version: Int?
}
struct AgentPushPersistence: Codable {
var records: [AgentPushRecord] = []
var tombstones: [AgentPushRecord] = []
}
@MainActor
struct AgentPushStorage {
var read: () throws -> AgentPushPersistence
var write: (AgentPushPersistence) throws -> Void
static func defaults(_ defaults: UserDefaults = .standard) -> Self {
let key = "sybil.ios.agentPush.v1"
return Self(read: {
guard let data = defaults.data(forKey: key) else { return .init() }
return try JSONDecoder().decode(AgentPushPersistence.self, from: data)
}, write: { value in
// No authentication credential or device token is ever persisted here.
defaults.set(try JSONEncoder().encode(value), forKey: key)
})
}
}
@@ -0,0 +1,343 @@
import Foundation
import Observation
/// One app-lifetime owner of push enrollment. OS addresses stay in memory; retired
/// installation IDs are never reused. Every suspension checks its session generation.
@MainActor @Observable
final class SybilAgentNotifications {
static let shared = SybilAgentNotifications(platform: SybilNotificationPlatform.shared, storage: .defaults())
private(set) var optedIn = false
private(set) var available = false
private(set) var permission: AgentNotificationPermission = .notDetermined
private(set) var status = "Connect to a server to configure notifications."
private(set) var pendingRouteID: UUID?
private(set) var generation = UUID()
private(set) var scope: AgentPushScope?
private(set) var pendingRevocations = 0
@ObservationIgnored private let platform: any AgentNotificationPlatform
@ObservationIgnored private let storage: AgentPushStorage
@ObservationIgnored private var saved = AgentPushPersistence()
@ObservationIgnored private var storageHealthy = true
@ObservationIgnored private var client: (any AgentPushAPIClienting)?
@ObservationIgnored private var backend: String?
@ObservationIgnored private var credentialScope: String?
@ObservationIgnored private var token: String?
@ObservationIgnored private var uploaded: AgentPushRegistration?
@ObservationIgnored private var enrollment: Task<Void, Never>?
@ObservationIgnored private var refreshing: UUID?
@ObservationIgnored private var pendingPayload: AgentPushPayload?
@ObservationIgnored private var consumedResponses: [String] = []
@ObservationIgnored private var readCursor = "0"
@ObservationIgnored private var foregroundScenes: Set<UUID> = []
@ObservationIgnored private var sessionBackend: String?
@ObservationIgnored private var sessionIdentity: String?
func transition(baseURL: URL?, token: String?) async {
// Multiple windows and ordinary reconnects share one app enrollment. Compare
// credentials only in memory; never derive/persist an owner from a token.
let canonical = baseURL.flatMap { AgentPushScope.canonicalBackend($0, allowLoopback: platform.allowsLoopback) }
let identity = canonical.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
guard sessionBackend != canonical || sessionIdentity != identity else { return }
let hadSession = sessionBackend != nil || sessionIdentity != nil
sessionBackend = canonical; sessionIdentity = identity
if hadSession { await deactivate() }
}
init(platform: any AgentNotificationPlatform, storage: AgentPushStorage) {
self.platform = platform; self.storage = storage
do { saved = try storage.read(); pendingRevocations = saved.tombstones.count }
catch { storageHealthy = false; status = "Notification settings could not be restored." }
}
private func persist() -> Bool {
do { try storage.write(saved); pendingRevocations = saved.tombstones.count; return true }
catch { storageHealthy = false; available = false; status = "Notification settings could not be saved."; return false }
}
private var recordIndex: Int? { scope.flatMap { scope in saved.records.firstIndex { $0.scope == scope } } }
/// Call BEFORE overwriting the old session settings/client. This invalidates OS,
/// config, read, permission and upload callbacks before attempting revocation.
@discardableResult func deactivate() async -> UUID {
let oldClient = client
let oldScope = scope
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
let retiringGeneration = generation
refreshing = nil; available = false; optedIn = false; readCursor = "0"
scope = nil; client = nil; backend = nil; credentialScope = nil
if oldScope != nil { pendingPayload = nil; pendingRouteID = nil }
platform.unregister()
if let oldScope { retire(oldScope, clearOptIn: true) }
// Server DELETE is a terminal UUID tombstone, even if PUT is in flight.
// Attempt it before settings are replaced. A stale PUT also retries revoke.
if let oldClient, let oldScope {
await revokePending(client: oldClient, scope: oldScope)
}
if generation == retiringGeneration {
status = pendingRevocations > 0 ? "Revocation pending. Reconnect to the original server to retry." : "Notifications are off."
}
return retiringGeneration
}
func bind(client: any AgentPushAPIClienting, baseURL: URL, authenticated: Bool, mode: String, token: String?) async {
await transition(baseURL: baseURL, token: token)
let requestedBackend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback)
let requestedIdentity = requestedBackend.flatMap { backend in token.map { AgentPushScope.credentialScope(backend: backend, token: $0) } }
guard sessionBackend == requestedBackend, sessionIdentity == requestedIdentity else { return }
guard authenticated, mode == "token", let token, !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, storageHealthy,
let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback) else {
if self.client != nil { await deactivate() }
available = false; status = "Notifications require a token-authenticated HTTPS server."; return
}
self.client = client; self.backend = backend
self.credentialScope = AgentPushScope.credentialScope(backend: backend, token: token)
await refresh()
}
func refresh() async {
guard refreshing == nil, let client, let backend, let credentialScope else { return }
let refreshID = UUID()
refreshing = refreshID
let run = generation
defer { if refreshing == refreshID { refreshing = nil } }
do {
let config = try await client.agentPushConfig()
guard generation == run else { return }
guard config.version == 1, let source = config.sourceId, AgentPushPayload.validSource(source) else {
available = false; status = "This server does not support Agent notifications."; return
}
let nextScope = AgentPushScope(backend: backend, credentialScope: credentialScope, sourceID: source.lowercased())
if let scope, scope != nextScope {
let retired = await deactivate()
guard generation == retired else { return }
self.client = client; self.backend = backend; self.credentialScope = credentialScope
await refresh()
return
}
await revokePending(client: client, scope: nextScope)
guard generation == run else { return }
scope = nextScope
if recordIndex == nil { saved.records.append(AgentPushRecord(scope: nextScope)); guard persist() else { return } }
optedIn = recordIndex.map { saved.records[$0].optedIn } ?? false
available = config.available && platform.environment != nil
permission = await platform.permission()
guard generation == run else { return }
if permission == .denied {
await disable()
guard self.scope == nextScope else { return }
status = "Notifications are denied in iOS Settings."
return
}
if !available {
if let index = recordIndex, saved.records[index].mayBeRegistered {
// Invalidate both the cached success and any outstanding PUT
// before replacing the UUID. A fresh callback must enroll the
// replacement even when APNs returns the same token.
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
refreshing = nil; platform.unregister()
let retirementRun = generation
retire(nextScope); await revokePending(client: client, scope: nextScope)
guard generation == retirementRun else { return }
}
status = "Agent notifications are unavailable on this server or build."
} else if optedIn && (permission == .authorized || permission == .provisional) {
status = "Registering notifications…"
// A fresh OS callback is required on each launch/foreground refresh.
platform.register(generation: run)
} else { status = optedIn ? "Enable notifications to allow alerts." : "Notifications are off." }
await syncRead()
} catch {
guard generation == run else { return }
available = false
status = "Agent notifications are unavailable. Retry after reconnecting."
}
}
/// The only code path that can display the system permission prompt.
func enable() async {
guard available, let index = recordIndex else { return }
let run = generation
do {
let current = await platform.permission()
guard generation == run else { return }
let allowed: Bool
if current == .notDetermined { allowed = try await platform.requestPermission() }
else { allowed = current == .authorized || current == .provisional }
guard generation == run else { return }
permission = await platform.permission()
guard generation == run else { return }
guard allowed else { status = "Notifications are denied in iOS Settings."; return }
saved.records[index].optedIn = true
guard persist() else { return }
optedIn = true; status = "Registering notifications…"
platform.register(generation: run)
} catch { if generation == run { status = "Could not request notification permission. Try again." } }
}
func disable() async {
guard let scope else { return }
generation = UUID(); token = nil; uploaded = nil; enrollment = nil
let disablingGeneration = generation
refreshing = nil
optedIn = false; platform.unregister()
retire(scope, clearOptIn: true)
if let client { await revokePending(client: client, scope: scope) }
if generation == disablingGeneration {
status = pendingRevocations > 0 ? "Notifications off; revocation pending until this server reconnects." : "Notifications are off."
}
}
private func retire(_ scope: AgentPushScope, clearOptIn: Bool = false) {
guard let index = saved.records.firstIndex(where: { $0.scope == scope }) else { return }
let old = saved.records[index]
if self.scope == scope { uploaded = nil; token = nil }
if old.mayBeRegistered && !saved.tombstones.contains(where: { $0.installationID == old.installationID }) {
saved.tombstones.append(old)
}
saved.records[index] = AgentPushRecord(scope: scope, optedIn: clearOptIn ? false : old.optedIn)
_ = persist()
}
private func revokePending(client: any AgentPushAPIClienting, scope: AgentPushScope) async {
for record in saved.tombstones.filter({ $0.scope == scope }) {
do {
try await client.agentRevokePush(id: record.installationID)
saved.tombstones.removeAll { $0.installationID == record.installationID }
_ = persist()
} catch { /* Durable nonsecret tombstone survives offline/logout. */ }
}
}
func receivedDeviceToken(_ data: Data, generation run: UUID) {
guard generation == run, available, optedIn, !data.isEmpty, data.count <= 256 else { return }
token = data.map { String(format: "%02x", $0) }.joined()
startEnrollment()
}
func registrationFailed(generation run: UUID) {
guard generation == run else { return }
status = "iOS registration failed. Retry when connected."
}
private func startEnrollment() {
guard enrollment == nil, optedIn, available, let client, let token,
let environment = platform.environment, let index = recordIndex else { return }
let registration = AgentPushRegistration(token: token, environment: environment)
guard uploaded != registration else { status = "Notifications are enabled."; return }
let record = saved.records[index], run = generation
// Persist revocation intent BEFORE the first request, even if its response is lost.
saved.records[index].mayBeRegistered = true
guard persist() else { return }
enrollment = Task { [weak self] in
guard let self else { return }
defer {
if self.generation == run {
self.enrollment = nil
if self.token != token { self.startEnrollment() }
}
}
for attempt in 0..<3 {
do {
let receipt = try await client.agentRegisterPush(id: record.installationID, registration: registration)
guard self.generation == run else {
await self.revokePending(client: client, scope: record.scope); return
}
guard UUID(uuidString: receipt.installationId) == record.installationID,
receipt.transport == "apns", receipt.enabled, receipt.version > 0 else { throw APIError.invalidResponse }
guard let index = self.recordIndex, self.saved.records[index].installationID == record.installationID else { return }
self.saved.records[index].version = receipt.version
guard self.persist() else { return }
self.uploaded = registration; self.status = "Notifications are enabled."
return
} catch {
guard self.generation == run else {
await self.revokePending(client: client, scope: record.scope); return
}
if let failure = error as? AgentPushRequestError, !failure.retryable {
self.status = "The server rejected this notification registration. Disable it and check the server configuration before enabling again."
return
}
self.status = "Registration pending. Retry when connected."
if attempt < 2 { try? await Task.sleep(for: .seconds(attempt + 1)) }
guard self.generation == run else { return }
}
}
}
}
func setScene(_ id: UUID, active: Bool) {
if active { foregroundScenes.insert(id) } else { foregroundScenes.remove(id) }
}
func shouldPresent(_ payload: AgentPushPayload) -> Bool {
// Foreground updates already flow through the Agent transcript. Never mark
// them read here; the visible, bottom-rendered, settled guard owns that.
matches(payload) && payload.kind == .message && foregroundScenes.isEmpty && AgentCursor.less(readCursor, payload.cursor)
}
private func matches(_ payload: AgentPushPayload) -> Bool {
guard let scope, scope.sourceID == payload.sourceID else { return false }
// The wire payload has no backend origin. If one source is known at two
// origins, refuse to route/clear rather than guess which server sent it.
// Credentials may rotate for the same shared personal owner and runtime.
// Old revoke tombstones must not veto a newly consented installation there.
return !(saved.records + saved.tombstones).contains {
$0.scope.sourceID == payload.sourceID &&
($0.scope.backend != scope.backend || $0.scope.owner != scope.owner)
}
}
func syncRead() async {
guard let client else { return }
let run = generation
if let read = try? await client.agentReadState(), generation == run { await acknowledged(read, generation: run) }
}
func acknowledged(_ read: AgentReadState, generation run: UUID) async {
guard generation == run, let source = read.sourceId, source.lowercased() == scope?.sourceID,
let cursor = AgentPushPayload.validCursor(read.cursor) else { return }
readCursor = (try? AgentCursor.advance(readCursor, cursor)) ?? readCursor
await clearDelivered(generation: run)
}
private func clearDelivered(generation run: UUID) async {
let delivered = await platform.delivered()
guard generation == run else { return }
let ids = delivered.compactMap { notification -> String? in
guard let payload = notification.payload, matches(payload), payload.kind == .message,
!AgentCursor.less(readCursor, payload.cursor) else { return nil }
return notification.identifier
}
if !ids.isEmpty { platform.removeDelivered(identifiers: ids) }
}
func receiveBackground(_ payload: AgentPushPayload) async -> Bool {
guard payload.kind == .readState, matches(payload) else { return false }
await acknowledged(AgentReadState(cursor: payload.cursor, sourceId: payload.sourceID), generation: generation)
// No foreground socket, no PUT read receipt, no badge reset. APNs is the
// source of this scoped read hint; foreground GET converges missed hints.
return true
}
/// Cold background delivery may run before SwiftUI/auth bootstrap. Only restore
/// the opted-in record at the currently saved backend; never enroll from here.
func restoreBackgroundScope(baseURL: URL, token: String?, payload: AgentPushPayload) {
guard client == nil, let token, !token.isEmpty, let backend = AgentPushScope.canonicalBackend(baseURL, allowLoopback: platform.allowsLoopback),
let record = saved.records.first(where: { $0.scope.backend == backend && $0.scope.credentialScope == AgentPushScope.credentialScope(backend: backend, token: token) && $0.scope.sourceID == payload.sourceID && $0.optedIn && $0.mayBeRegistered }) else { return }
scope = record.scope
}
func notificationResponse(_ payload: AgentPushPayload, identifier: String) {
guard payload.kind == .message else { return }
let identity = payload.sourceID + "|" + (payload.messageID ?? "") + "|" + payload.cursor
guard !consumedResponses.contains(identity) else { return }
consumedResponses.append(identity)
if consumedResponses.count > 64 { consumedResponses.removeFirst() }
pendingPayload = payload; pendingRouteID = UUID()
}
func consumeRoute() -> Bool {
guard let payload = pendingPayload, client != nil, matches(payload) else { return false }
pendingPayload = nil; pendingRouteID = nil
return true
}
}
@@ -0,0 +1,44 @@
import CryptoKit
import Foundation
struct AgentOutboxRow: Codable, Equatable, Sendable {
var requestID: String
var content: String
var createdAt: Date
var status: String = "sending"
var seenOnServer = false
var confirmed = false
var error: String?
var attachments: [AgentAttachment]? = nil // Optional for legacy text-only outboxes.
var draftID: String? = nil // Recovers a crash between outbox persistence and clearing the composer.
var id: String { "inbound:sybil:\(requestID)" }
var message: Message {
var info: [String: JSONValue] = ["kind": .string("message"), "status": .string(status), "requestId": .string(requestID)]
if let error { info["error"] = .string(error) }
if let attachments, !attachments.isEmpty { info["agentAttachments"] = .array(attachments.map(\.metadataValue)) }
return Message(id: id, createdAt: createdAt, role: .user, content: content, metadata: .object(info))
}
}
struct AgentOutboxStorage: Sendable {
var read: @Sendable () throws -> [AgentOutboxRow]
var write: @Sendable ([AgentOutboxRow]) throws -> Void
static func file(baseURL: URL) -> Self {
// Separate installations, but never persist tokens in a filename or payload.
let key = SHA256.hash(data: Data(baseURL.absoluteString.utf8)).map { String(format: "%02x", $0) }.joined()
let directory = URL.applicationSupportDirectory.appendingPathComponent("AgentOutbox", isDirectory: true)
let url = directory.appendingPathComponent("\(key).json")
return Self(read: {
guard FileManager.default.fileExists(atPath: url.path) else { return [] }
return try JSONDecoder().decode([AgentOutboxRow].self, from: Data(contentsOf: url))
}, write: { rows in
let data = try JSONEncoder().encode(rows)
guard rows.count <= 100, data.count <= 2 * 1024 * 1024 else {
throw AgentFailure(message: "Retry pending messages before sending more.")
}
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication])
})
}
}
@@ -0,0 +1,522 @@
import Foundation
import Network
import Observation
@MainActor
@Observable
final class SybilAgentRoom {
struct Snapshot: Equatable {
var room = AgentRoomState()
var isTyping = false
var entranceIDs: Set<String> = []
var revision = 0
}
private(set) var snapshot = Snapshot()
private(set) var connection = "Offline"
private(set) var error: String?
private(set) var isLoading = false
private(set) var requiresReload = false
private(set) var outbox: [AgentOutboxRow] = []
let attachmentDraft = AgentAttachmentDraft()
private(set) var attachmentSessionID = UUID()
private var attachmentModals: Set<UUID> = []
var isAttachmentModalPresented: Bool { !attachmentModals.isEmpty }
func setAttachmentModal(_ id: UUID, presented: Bool) {
if presented { attachmentModals.insert(id) } else { attachmentModals.remove(id) }
}
func dismissAttachmentPresentations() {
// Existing picker/share views observe this identity and dismiss themselves.
// Keep draft contents and transcript/view identity intact during routing.
attachmentSessionID = UUID()
}
var composer: String {
get { attachmentDraft.caption }
set { attachmentDraft.caption = newValue }
}
var isObscured = false
var isModalPresented = false
var isSelected = false
private(set) var isActive = true
@ObservationIgnored var onAcknowledgedRead: ((AgentReadState) -> Void)?
private enum ReadSourceBinding: Equatable {
case unbound, legacy, source(String)
}
@ObservationIgnored private var readBinding: ReadSourceBinding = .unbound
private var readSourceID: String? {
if case let .source(id) = readBinding { return id }
return nil
}
@ObservationIgnored private var attachmentDownloads = AgentAttachmentDownloads()
@ObservationIgnored private var client: (any AgentAPIClienting)?
@ObservationIgnored private var storage: AgentOutboxStorage?
@ObservationIgnored private var storageReadable = true
@ObservationIgnored private var pending = AgentRoomState()
@ObservationIgnored private var presenter = AgentPresentation()
@ObservationIgnored private var pendingEntrances: Set<String> = []
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var connectionTask: Task<Void, Never>?
@ObservationIgnored private var maintenanceTask: Task<Void, Never>?
@ObservationIgnored private var batchTask: Task<Void, Never>?
@ObservationIgnored private var typingTask: Task<Void, Never>?
@ObservationIgnored private var readTask: Task<Void, Never>?
@ObservationIgnored private var sends: [String: Task<Void, Never>] = [:]
@ObservationIgnored private var socket: (any AgentSocket)?
@ObservationIgnored private var monitor: NWPathMonitor?
@ObservationIgnored private var lastPacket = Date()
@ObservationIgnored private var socketStarted = Date()
@ObservationIgnored private var desiredRead = "0"
@ObservationIgnored private var acknowledgedRead = "0"
@ObservationIgnored private var hasLoaded = false
@ObservationIgnored private var syncing = false
@ObservationIgnored private var wasNetworkOffline = false
var isBusy: Bool { snapshot.room.activity.isBusy }
var unreadCount: Int { snapshot.room.unreadCount }
var messages: [Message] {
#if DEBUG
AgentRenderDiagnostics.record("messageProjection")
#endif
let committed = Set(snapshot.room.messages.map(\.id))
let queued = snapshot.room.activity.queuedMessages.filter { !committed.contains($0.id) }
var messages = AgentRoomState.merge(snapshot.room.messages, queued)
for row in outbox {
if let index = messages.firstIndex(where: { $0.id == row.id }) {
messages[index].createdAt = row.createdAt
if !row.confirmed {
var info = messages[index].metadata?.objectValue ?? [:]
if let attachments = row.attachments, messages[index].agentAttachments.map(\.id) != attachments.map(\.id) {
info["agentAttachments"] = .array(attachments.map(\.metadataValue))
messages[index].content = row.content
}
info["status"] = .string(row.status)
if let error = row.error { info["error"] = .string(error) }
messages[index].metadata = .object(info)
}
} else { messages.append(row.message) }
}
return AgentRoomState.merge([], messages)
}
func configure(client: any AgentAPIClienting, storage: AgentOutboxStorage, draftStorage: AgentDraftStorage? = nil) {
stop()
self.client = client
self.storage = storage
attachmentSessionID = UUID()
let oldDownloads = attachmentDownloads
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
attachmentDraft.configure(client: client, storage: draftStorage)
resetHistory()
error = nil; storageReadable = true
do {
outbox = try storage.read().map { row in
var restored = row
if !restored.seenOnServer {
restored.status = "failed"
restored.error = "Delivery was interrupted. Retry to confirm."
}
return restored
}
if outbox.contains(where: { $0.draftID == attachmentDraft.id }) {
attachmentDraft.clearAfterSubmission()
}
} catch {
outbox = []; storageReadable = false
self.error = "Could not restore pending messages: \(error.localizedDescription)"
}
start()
}
func disconnect() {
stop()
client = nil
attachmentDraft.updateCapability(nil)
attachmentSessionID = UUID()
let oldDownloads = attachmentDownloads
Task { await oldDownloads.invalidate() }
attachmentDownloads = AgentAttachmentDownloads()
}
func stop() {
attachmentDraft.suspend()
generation = UUID()
connectionTask?.cancel(); connectionTask = nil
maintenanceTask?.cancel(); maintenanceTask = nil
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
readTask?.cancel(); readTask = nil
sends.values.forEach { $0.cancel() }; sends = [:]
for index in outbox.indices where !outbox[index].seenOnServer && outbox[index].status == "sending" {
outbox[index].status = "failed"
outbox[index].error = "Delivery was interrupted. Retry to confirm."
}
if !outbox.isEmpty { try? persist() }
monitor?.cancel(); monitor = nil
let old = socket; socket = nil
Task { await old?.close() }
syncing = false
pendingEntrances = []
connection = "Offline"
}
func setActive(_ active: Bool) {
guard isActive != active else { return }
isActive = active
if active { attachmentDraft.resume(); start() }
else {
// Publish successfully merged input before cancelling; replay resumes here.
flush()
stop()
}
}
func retryConnection() {
flush(); stop()
// Explicit Reconnect starts a new history/source boundary. Foreground and
// network recovery must not relabel the preserved old room automatically.
if requiresReload { resetHistory(); error = nil }
if isActive { start() }
}
private func resetHistory() {
pending = AgentRoomState(); snapshot = Snapshot(); presenter = AgentPresentation()
desiredRead = "0"; acknowledgedRead = "0"; hasLoaded = false
readBinding = .unbound; requiresReload = false
}
private func requireReload(_ reason: String) -> AgentFailure {
let failure = AgentFailure(message: reason + " Reconnect to load fresh Agent history.")
requiresReload = true
// Preserve already accepted old cursors/history, cancel every old writer,
// and invalidate callbacks before allowing any further source observation.
flush(); stop()
isLoading = false; connection = "Reload required"; error = failure.localizedDescription
return failure
}
private func start() {
guard !requiresReload, isActive, let client, connectionTask == nil else { return }
let run = generation
isLoading = !hasLoaded
connection = "Connecting"
let monitor = NWPathMonitor()
monitor.pathUpdateHandler = { [weak self] path in
let satisfied = path.status == .satisfied
Task { @MainActor [weak self] in
guard let self, self.generation == run else { return }
let recovered = self.wasNetworkOffline && satisfied
self.wasNetworkOffline = !satisfied
if recovered && !self.requiresReload { self.retryConnection() }
}
}
monitor.start(queue: DispatchQueue(label: "net.buzzert.sybil.agent-network"))
self.monitor = monitor
connectionTask = Task { [weak self] in
guard let self else { return }
var delay = 1
while !Task.isCancelled, self.generation == run {
do {
let descriptor = try await client.agentDescriptor()
guard self.generation == run else { return }
self.attachmentDraft.updateCapability(descriptor.capabilities?.attachments)
self.attachmentDraft.resume()
try self.receive(.activity(descriptor.id, descriptor.activity), replay: !self.hasLoaded)
try await self.refreshRead(client: client, run: run)
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
guard self.generation == run, !Task.isCancelled else { return }
self.hasLoaded = true; self.isLoading = false; self.flush()
let socket = try await client.agentSocket(after: self.pending.cursor)
guard self.generation == run, !Task.isCancelled else { await socket.close(); return }
self.socket = socket
self.socketStarted = Date(); self.lastPacket = Date()
while !Task.isCancelled, self.generation == run {
let data = try await socket.receive()
guard self.generation == run, !Task.isCancelled else { return }
self.lastPacket = Date()
guard let event = try AgentEvent.decode(data) else { continue }
if case .ready = event { self.connection = "Connected"; self.error = nil; delay = 1 }
try self.receive(event)
}
} catch {
guard self.generation == run, !Task.isCancelled else { return }
let failedSocket = self.socket
let code = await failedSocket?.closeCode()
await failedSocket?.close()
guard self.generation == run, !Task.isCancelled else { return }
self.socket = nil
self.isLoading = false
let attachmentsUnavailable = code == 4503 || (error as? AgentAttachmentIssue)?.code == "attachments_unavailable"
if attachmentsUnavailable { self.attachmentDraft.updateCapability(nil) }
self.error = code == 4401 ? "Agent authentication failed. Check your Sybil token in Settings."
: code == 4503 ? "Agent attachments are unavailable. The server requires a safely authenticated Agent connection. [attachments_unavailable, 4503]"
: error.localizedDescription
self.connection = code == 4401 ? "Offline" : "Reconnecting"
if code == 4401 { return }
}
try? await Task.sleep(for: .seconds(delay))
delay = min(delay * 2, 30)
}
}
maintenanceTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .seconds(15))
guard let self, self.generation == run, !Task.isCancelled else { return }
do {
try await self.refreshRead(client: client, run: run)
guard self.generation == run, !Task.isCancelled else { return }
self.flushRead()
if self.connection != "Connected" {
try await self.catchUp(client: client, run: run, replay: !self.hasLoaded)
}
guard self.generation == run, !Task.isCancelled else { return }
if let socket = self.socket {
if Date().timeIntervalSince(self.lastPacket) > 60 || (self.connection != "Connected" && Date().timeIntervalSince(self.socketStarted) > 12) {
await socket.close()
} else { try await socket.send("{\"type\":\"ping\"}") }
}
} catch {
if self.generation == run { self.error = error.localizedDescription }
}
}
}
}
func catchUp(client: any AgentAPIClienting, run: UUID? = nil, replay: Bool = false) async throws {
let run = run ?? generation
guard !requiresReload, generation == run, !Task.isCancelled, !syncing else { return }
syncing = true
defer { if generation == run { syncing = false } }
var cursor = pending.cursor
while !Task.isCancelled, generation == run {
let page = try await client.agentMessages(after: cursor, before: nil)
guard generation == run, !Task.isCancelled else { return }
try page.validate()
let next = try AgentCursor.normalize(page.cursor ?? cursor)
guard !AgentCursor.less(next, cursor), !page.hasMore || next != cursor else {
throw AgentFailure(message: "Agent history did not advance its cursor.")
}
try receive(.messages(page), replay: replay)
cursor = next
if !page.hasMore { return }
}
}
func receive(_ event: AgentEvent, replay: Bool = false) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let before = pending
var next = pending
switch event {
case let .messages(page):
try next.apply(page)
// History has no source field. Pin even an empty first page before
// publishing it so a later UUID cannot adopt legacy cursor space.
if readBinding == .unbound { readBinding = .legacy }
case let .activity(id, activity):
try next.checkConversation(id)
next.activity = activity
case let .ready(id): if let id { try next.checkConversation(id) }
case let .error(message): throw AgentFailure(message: message)
case let .readState(read): try applyRead(read); return
case .heartbeat: return
}
presenter.receive(event, before: before, after: next, replay: replay, now: Date())
pending = next
if !replay {
let oldIDs = Set(before.messages.map(\.id)).union(outbox.map(\.id))
pendingEntrances.formUnion(next.messages.filter { !oldIDs.contains($0.id) && $0.role != .tool }.map(\.id))
}
reconcileOutbox()
schedulePublish()
}
private func schedulePublish() {
guard batchTask == nil else { return }
batchTask = Task { [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
self?.flush()
}
}
func flush() {
#if DEBUG
AgentRenderDiagnostics.record("snapshotPublish")
#endif
batchTask?.cancel(); batchTask = nil
typingTask?.cancel(); typingTask = nil
snapshot = Snapshot(room: pending, isTyping: presenter.isTyping(now: Date()), entranceIDs: pendingEntrances, revision: snapshot.revision + 1)
pendingEntrances = []
if let start = presenter.busySince, !snapshot.isTyping {
let delay = max(0.01, 0.25 - Date().timeIntervalSince(start))
typingTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
self?.flush()
}
}
}
private func refreshRead(client: any AgentAPIClienting, run: UUID) async throws {
let read = try await client.agentReadState()
guard generation == run else { return }
try applyRead(read)
}
func applyRead(_ read: AgentReadState) throws {
guard !requiresReload else { throw AgentFailure(message: error ?? "Agent history requires reload.") }
let cursor = try AgentCursor.normalize(read.cursor)
let incoming: ReadSourceBinding
if let source = read.sourceId {
guard AgentPushPayload.validSource(source) else { throw requireReload("Agent read source is invalid.") }
incoming = .source(source.lowercased())
} else { incoming = .legacy }
guard readBinding == .unbound || readBinding == incoming else {
throw requireReload("Agent read source changed.")
}
readBinding = incoming
acknowledgedRead = try AgentCursor.advance(acknowledgedRead, cursor)
pending.readCursor = acknowledgedRead
schedulePublish()
onAcknowledgedRead?(read)
}
func markRead(renderedCursor: String) {
guard !requiresReload, hasLoaded, isActive, isSelected, !isObscured, !isModalPresented, !isAttachmentModalPresented else { return }
do { desiredRead = try AgentCursor.advance(desiredRead, renderedCursor); flushRead() }
catch { self.error = error.localizedDescription }
}
private func flushRead() {
guard !requiresReload, readBinding != .unbound, let client, readTask == nil, AgentCursor.less(acknowledgedRead, desiredRead) else { return }
let run = generation
readTask = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.readTask = nil } }
do {
while !self.requiresReload, self.generation == run, AgentCursor.less(self.acknowledgedRead, self.desiredRead), !Task.isCancelled {
let captured = self.desiredRead
let read = try await client.agentMarkRead(cursor: captured, sourceID: self.readSourceID)
guard self.generation == run, !Task.isCancelled else { return }
guard let cursor = try? AgentCursor.normalize(read.cursor), !AgentCursor.less(cursor, captured) else {
throw self.requireReload("Agent read state was not acknowledged.")
}
try self.applyRead(read)
}
} catch {
if self.generation == run {
let isHTTPConflict: Bool
if case APIError.httpError(statusCode: 409, message: _) = error { isHTTPConflict = true }
else { isHTTPConflict = (error as? AgentAttachmentIssue)?.statusCode == 409 }
if isHTTPConflict {
_ = self.requireReload("Agent read source changed.")
} else { self.error = error.localizedDescription }
}
}
}
}
func downloadAttachment(_ attachment: AgentAttachment) async throws -> Data {
guard let client else { throw AgentAttachmentIssue.unavailable }
let session = attachmentSessionID
let data = try await attachmentDownloads.data(for: attachment, client: client)
try Task.checkCancellation()
guard attachmentSessionID == session else { throw CancellationError() }
return data
}
@discardableResult
func sendComposer() -> Bool {
do {
let attachments = try attachmentDraft.validateReady()
guard attachmentDraft.canSend, send(composer, attachments: attachments, draftID: attachmentDraft.id) else { return false }
return true
} catch { self.error = error.localizedDescription; return false }
}
@discardableResult
func send(_ content: String, attachments: [AgentAttachment] = [], draftID: String? = nil) -> Bool {
guard !requiresReload else { return false }
do {
_ = try AgentSubmission(content: content, requestID: UUID().uuidString, attachments: attachments)
if !attachments.isEmpty {
guard let limits = attachmentDraft.capability, attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
try limits.validate(attachments)
}
} catch { self.error = error.localizedDescription; return false }
guard storageReadable, client != nil else { error = "Pending messages could not be restored. Reconnect before sending."; return false }
let row = AgentOutboxRow(requestID: UUID().uuidString, content: content, createdAt: Date(), attachments: attachments.isEmpty ? nil : attachments, draftID: draftID)
outbox.append(row)
do { if !attachments.isEmpty || draftID != nil { try persist() } }
catch {
outbox.removeAll { $0.id == row.id }
self.error = "Could not save message: \(error.localizedDescription)"
return false // Keep the entire draft when durable handoff fails.
}
pendingEntrances.insert(row.id)
presenter.sent(now: Date(), activity: pending.activity)
flush()
if draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() {
outbox[outbox.count - 1].status = "failed"
outbox[outbox.count - 1].error = attachmentDraft.error
try? persist()
return false
}
dispatch(row.id)
return true
}
func retry(_ id: String) { dispatch(id) }
private func persist() throws {
guard let storage, storageReadable else { throw AgentFailure(message: "Message storage is unavailable.") }
try storage.write(outbox.filter { !$0.confirmed })
}
private func dispatch(_ id: String) {
guard !requiresReload else { return }
guard let client, sends[id] == nil, let index = outbox.firstIndex(where: { $0.id == id }), !outbox[index].seenOnServer else { return }
if outbox[index].draftID == attachmentDraft.id, !attachmentDraft.clearAfterSubmission() { return }
outbox[index].status = "sending"; outbox[index].error = nil
do { try persist() }
catch { outbox[index].status = "failed"; outbox[index].error = "Could not save message: \(error.localizedDescription)"; return }
let row = outbox[index], run = generation
sends[id] = Task { [weak self] in
guard let self else { return }
defer { if self.generation == run { self.sends[id] = nil } }
do {
let attachments = row.attachments ?? []
if !attachments.isEmpty {
guard let limits = self.attachmentDraft.capability, self.attachmentDraft.supportsAttachments else { throw AgentAttachmentIssue.unavailable }
try limits.validate(attachments)
}
let submission = try AgentSubmission(content: row.content, requestID: row.requestID, attachments: attachments)
let receipt = try await client.agentSubmit(submission)
try receipt.validate(requestID: row.requestID, attachmentIDs: submission.attachmentIDs)
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].confirmed else { return }
self.outbox[index].seenOnServer = true
self.outbox[index].status = receipt.status == "queued" ? "queued" : "complete"
self.outbox[index].error = nil
try? self.persist()
} catch {
guard self.generation == run, let index = self.outbox.firstIndex(where: { $0.id == id }), !self.outbox[index].seenOnServer else { return }
self.outbox[index].status = "failed"; self.outbox[index].error = error.localizedDescription
try? self.persist()
}
}
}
private func reconcileOutbox() {
let committed = pending.messages
let queued = pending.activity.queuedMessages
var changed = false
for index in outbox.indices {
let row = outbox[index]
let matches: (Message) -> Bool = { message in
message.id == row.id && ((row.attachments ?? []).isEmpty || message.agentAttachments.map(\.id) == row.attachments?.map(\.id))
}
if committed.contains(where: matches) {
if !outbox[index].confirmed { changed = true }
outbox[index].confirmed = true; outbox[index].seenOnServer = true
outbox[index].status = "complete"; outbox[index].error = nil
} else if queued.contains(where: matches), !outbox[index].confirmed {
changed = true
outbox[index].seenOnServer = true; outbox[index].status = "queued"; outbox[index].error = nil
}
}
if changed { try? persist() }
}
}
@@ -0,0 +1,95 @@
import Foundation
protocol AgentSocket: Sendable {
func receive() async throws -> Data
func send(_ text: String) async throws
func close() async
func closeCode() async -> Int
}
protocol AgentAPIClienting: Sendable {
func agentDescriptor() async throws -> AgentDescriptor
func agentMessages(after: String?, before: String?) async throws -> AgentPage
func agentReadState() async throws -> AgentReadState
func agentMarkRead(cursor: String) async throws -> AgentReadState
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment
func agentDownload(_ attachment: AgentAttachment) async throws -> Data
func agentSocket(after: String) async throws -> any AgentSocket
}
actor AgentURLSocket: AgentSocket {
private let task: URLSessionWebSocketTask
init(task: URLSessionWebSocketTask) { self.task = task }
func receive() async throws -> Data {
switch try await task.receive() {
case let .string(text): return Data(text.utf8)
case .data: throw AgentFailure(message: "Agent sent a non-JSON text frame.")
@unknown default: throw AgentFailure(message: "Unsupported Agent frame.")
}
}
func send(_ text: String) async throws { try await task.send(.string(text)) }
func close() { task.cancel(with: .goingAway, reason: nil) }
func closeCode() -> Int { task.closeCode.rawValue }
}
extension APIConfiguration {
func agentWebSocketURL() throws -> URL {
guard var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
["http", "https"].contains(parts.scheme), parts.host != nil,
parts.user == nil, parts.password == nil else { throw APIError.invalidBaseURL }
parts.scheme = parts.scheme == "https" ? "wss" : "ws"
parts.path = parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).isEmpty
? "/v1/agent/ws" : "/" + parts.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + "/v1/agent/ws"
parts.query = nil
parts.fragment = nil
guard let url = parts.url else { throw APIError.invalidBaseURL }
return url
}
func agentAuthFrame(after: String) throws -> String {
let token = authToken?.trimmingCharacters(in: .whitespacesAndNewlines)
let value: JSONValue = .object([
"type": .string("auth"),
"clientKind": .string("native"),
"token": token.flatMap { $0.isEmpty ? nil : JSONValue.string($0) } ?? .null,
"after": .string(try AgentCursor.normalize(after))
])
return String(decoding: try JSONEncoder().encode(value), as: UTF8.self)
}
}
// Text-only test/legacy clients fail closed for the new wire path.
extension AgentAPIClienting {
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor)
}
func agentSubmit(_ submission: AgentSubmission) async throws -> AgentReceipt {
guard submission.attachmentIDs.isEmpty else { throw AgentAttachmentIssue.unavailable }
return try await agentSubmit(content: submission.content, requestID: submission.requestId)
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment { throw AgentAttachmentIssue.unavailable }
func agentDownload(_ attachment: AgentAttachment) async throws -> Data { throw AgentAttachmentIssue.unavailable }
}
final class AgentAttachmentRedirectPolicy: NSObject, URLSessionTaskDelegate, Sendable {
// Reject every redirect; attachment credentials are valid only for the configured API.
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void) {
completionHandler(nil)
}
}
extension APIConfiguration {
func agentAttachmentContentURL(id: String) throws -> URL {
guard !id.isEmpty, var parts = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
["http", "https"].contains(parts.scheme), parts.host != nil, parts.user == nil, parts.password == nil,
let escaped = id.addingPercentEncoding(withAllowedCharacters: CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_")) else { throw APIError.invalidBaseURL }
let base = parts.percentEncodedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
parts.percentEncodedPath = (base.isEmpty ? "" : "/" + base) + "/v1/agent/attachments/" + escaped + "/content"
parts.query = nil; parts.fragment = nil
guard let url = parts.url else { throw APIError.invalidBaseURL }
return url
}
}
@@ -0,0 +1,329 @@
import SwiftUI
import Observation
struct SybilAgentView: View {
var room: SybilAgentRoom
var topInset: CGFloat = 0
@State private var latestRequest = 0
var body: some View {
VStack(spacing: 0) {
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
AgentStatusBar(room: room)
AgentComposer(room: room) { latestRequest += 1 }
}
.onAppear { room.isSelected = true }
.onDisappear { room.isSelected = false }
}
}
// Draft edits and keyboard focus belong to the composer. The transcript observes
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
private struct AgentTranscriptView: View {
var room: SybilAgentRoom
var topInset: CGFloat
var latestRequest: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var atLatest = true
@State private var scrollPhase: ScrollPhase = .idle
@State private var historyLimit = 100
@State private var historyStartID: String?
private static let bottomID = "agent-bottom"
@State private var scrollTracking = AgentScrollTracking()
@State private var appeared = false
private var isScrolling: Bool { scrollPhase.isScrolling }
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("agentBody")
#endif
let rendered = room.snapshot
let transcript = AgentTranscript(messages: room.messages, startID: historyStartID, limit: historyLimit)
let allMessages = transcript.messages
let startIndex = transcript.startIndex
let displayed = transcript.displayed
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 15) {
// The native scroll origin is the visual bottom. Keep model
// and cursor order ascending; reverse only presentation.
Color.clear.frame(height: 17).id(Self.bottomID)
if rendered.isTyping {
HStack(spacing: 9) {
AgentAvatar()
ProgressView().controlSize(.small)
Text("Sybil is thinking…").font(.sybil(.caption))
}
.foregroundStyle(SybilTheme.textMuted)
.accessibilityIdentifier("agent-typing")
.scaleEffect(x: 1, y: -1)
}
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
AgentTranscriptRow(message: message, room: room,
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
.equatable()
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.scaleEffect(x: 1, y: -1)
.id(message.id)
}
if room.isLoading {
ProgressView("Loading messages…").frame(maxWidth: .infinity)
.scaleEffect(x: 1, y: -1)
}
if allMessages.count > displayed.count {
Button("Load earlier messages") {
atLatest = false
historyLimit += 100
historyStartID = allMessages[max(0, startIndex - 100)].id
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.scaleEffect(x: 1, y: -1)
}
}
// Keep native lazy-row targets without binding scroll position.
.scrollTargetLayout()
.padding(.horizontal, 16)
// Match normal chat: content clears the header, while the
// viewport extends beneath the shared navigation fade.
.padding(.bottom, 16 + topInset)
.frame(maxWidth: SybilLayout.webContentMaxWidth)
.frame(maxWidth: .infinity)
}
.scaleEffect(x: 1, y: -1)
.accessibilityIdentifier("agent-transcript")
.scrollDismissesKeyboard(.interactively)
.onScrollPhaseChange { _, phase in
scrollPhase = phase
}
.onScrollGeometryChange(for: Bool.self) { geometry in
geometry.containerSize.height > 0 &&
geometry.contentOffset.y + geometry.contentInsets.top <= 24
} action: { _, atBottom in
#if DEBUG
AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.atBottom = atBottom
// Only user scrolling changes follow intent. Animated Latest
// navigation and native keyboard resizing must not cancel it.
if isScrolling && scrollPhase != .animating {
atLatest = atBottom
}
}
.overlay(alignment: .bottomTrailing) {
if !atLatest {
Button {
atLatest = true
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(Self.bottomID, anchor: .top) }
} 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: latestRequest) { _, _ in
if !atLatest {
atLatest = true
proxy.scrollTo(Self.bottomID, anchor: .top)
}
}
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
atLatest: atLatest, isScrolling: isScrolling))
}
}
}
// Bottom proximity can change twice per entrance/keyboard frame while the
// scroll view follows the growing content. Only the read-settling task needs
// those transitions; they must not project and rebuild the transcript hierarchy.
private struct AgentReadAcknowledgement: ViewModifier {
var room: SybilAgentRoom
var tracking: AgentScrollTracking
var atLatest: Bool
var isScrolling: Bool
@Environment(\.scenePhase) private var scenePhase
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
func body(content: Content) -> some View {
let cursor = room.snapshot.room.cursor
content
.task(id: readKey) {
// Capture the rendered cursor before scheduling. New network input
// cancels this task and must settle in layout before acknowledgement.
guard atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
}
private struct AgentStatusBar: View {
var room: SybilAgentRoom
@ViewBuilder var body: some View {
let activity = room.snapshot.room.activity
if let error = room.error ?? activity.error {
HStack(alignment: .top) {
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 struct AgentComposer: View {
@Bindable var room: SybilAgentRoom
var onSubmit: () -> Void
@FocusState private var composerFocused: Bool
private var canSendComposer: Bool {
room.attachmentDraft.canSend
}
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(
text: $room.composer,
focused: $composerFocused,
submit: submitComposer
)
.accessibilityIdentifier("agent-composer")
SybilPromptSendButton(canSend: canSendComposer, submit: submitComposer)
.accessibilityIdentifier("agent-send")
}
.padding(.horizontal, 14)
.padding(.bottom, 12)
.padding(.top, 6)
}
private func submitComposer() {
guard canSendComposer, room.sendComposer() else { return }
onSubmit()
}
}
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)
}
}
// Activity/read publications still update the transcript. Completed rows depend
// only on message values and heading; scrolling needs no per-row geometry.
private struct AgentTranscriptRow: View, Equatable {
var message: Message
var room: SybilAgentRoom
var startsDay: Bool
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.message == rhs.message && lhs.room === rhs.room && lhs.startsDay == rhs.startsDay
}
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("transcriptRow")
#endif
VStack(spacing: 14) {
if startsDay {
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
AgentMessageRow(message: message, room: room)
}
}
}
private struct AgentMessageRow: View {
var message: Message
var room: SybilAgentRoom
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") { room.retry(message.id) }.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)
}
}
}
}
}
// Keep the row's measured height stable for lazy layout. Animate its appearance
// without collapsing the scroll target while the composer or keyboard resizes.
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
content
.opacity(preparing ? 0 : 1)
.offset(y: preparing ? 16 : 0)
.onAppear {
withAnimation(animate && !reduceMotion ? .easeOut(duration: 0.24) : nil) { entered = true }
}
}
}
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
@Observable private final class AgentScrollTracking {
var atBottom = false
}
@@ -1,89 +1,161 @@
import MarkdownUI
import SwiftUI
import UIKit
struct SybilChatTranscriptView: View {
var messages: [Message]
var isLoading: Bool
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
var topContentInset: CGFloat = 0
var bottomContentInset: CGFloat = 0
var tailSpacerHeight: CGFloat = 0
var onViewportHeightChange: ((CGFloat) -> Void)? = nil
var onPendingAssistantHeightChange: ((CGFloat) -> Void)? = nil
var bottomPinRequestID: Int = 0
var isForking: Bool = false
var onForkResponse: ((String) -> Void)? = nil
private var hasPendingAssistant: Bool {
messages.contains { message in
message.id.hasPrefix("temp-assistant-") && message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@State private var hasTrackedToolCallMessages = false
@State private var knownToolCallMessageIDs: Set<String> = []
private let bottomAnchorID = "sybil-chat-transcript-bottom-anchor"
private var renderItems: [TranscriptRenderItem] {
buildTranscriptRenderItems(from: messages)
}
private var toolCallMessageIDs: Set<String> {
Set(messages.compactMap { $0.toolCallMetadata == nil ? nil : $0.id })
}
private var enteringToolCallMessageIDs: Set<String> {
guard hasTrackedToolCallMessages else { return [] }
return toolCallMessageIDs.subtracting(knownToolCallMessageIDs)
}
private var toolCallMessageIDSignature: String {
toolCallMessageIDs.sorted().joined(separator: "|")
}
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
ForEach(messages.reversed()) { message in
MessageBubble(message: message, isSending: isSending)
.frame(maxWidth: .infinity)
.background {
if isStreamingPendingAssistant(message) {
GeometryReader { proxy in
Color.clear.preference(
key: SybilPendingAssistantHeightPreferenceKey.self,
value: proxy.size.height
)
}
}
}
.scaleEffect(x: 1, y: -1)
}
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 26) {
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
}
if isLoading && messages.isEmpty {
Text("Loading messages…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
.padding(.top, 24)
.scaleEffect(x: 1, y: -1)
ForEach(renderItems) { item in
switch item {
case let .message(message):
MessageBubble(
message: message,
isSending: isSending,
streamingAssistantPresentation: streamingAssistantPresentation,
isForking: isForking,
onFork: {
onForkResponse?(message.id)
}
)
.frame(maxWidth: .infinity)
case let .toolGroup(id, messages):
ToolCallStackView(
groupID: id,
messages: messages,
entryAnimationIDs: enteringToolCallMessageIDs
)
.frame(maxWidth: .infinity)
.id(id)
}
}
Color.clear
.frame(height: 18 + bottomContentInset)
.id(bottomAnchorID)
}
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 14)
.padding(.top, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 14)
.padding(.top, 18 + bottomContentInset + tailSpacerHeight)
.padding(.bottom, 18 + topContentInset)
}
.frame(maxWidth: .infinity, alignment: .leading)
.scrollDismissesKeyboard(.interactively)
.background {
GeometryReader { proxy in
Color.clear
.onAppear {
onViewportHeightChange?(proxy.size.height)
}
.onChange(of: proxy.size.height) { _, height in
onViewportHeightChange?(height)
}
.scrollDismissesKeyboard(.interactively)
.onAppear {
syncKnownToolCallMessageIDs()
}
.onChange(of: toolCallMessageIDSignature) { _, _ in
syncKnownToolCallMessageIDs()
}
.onChange(of: bottomPinRequestID) { _, _ in
scrollToBottom(with: proxy, animated: true)
}
}
.onPreferenceChange(SybilPendingAssistantHeightPreferenceKey.self) { height in
onPendingAssistantHeightChange?(height)
}
private func scrollToBottom(with proxy: ScrollViewProxy, animated: Bool) {
let action = {
proxy.scrollTo(bottomAnchorID, anchor: .bottom)
}
if animated {
withAnimation(.easeOut(duration: 0.18), action)
} else {
action()
}
.scaleEffect(x: 1, y: -1)
}
private func isStreamingPendingAssistant(_ message: Message) -> Bool {
isSending && message.id.hasPrefix("temp-assistant-")
private func syncKnownToolCallMessageIDs() {
guard !toolCallMessageIDs.isEmpty else { return }
knownToolCallMessageIDs.formUnion(toolCallMessageIDs)
hasTrackedToolCallMessages = true
}
}
private struct SybilPendingAssistantHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
enum TranscriptRenderItem: Identifiable {
case message(Message)
case toolGroup(id: String, messages: [Message])
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
var id: String {
switch self {
case let .message(message):
return message.id
case let .toolGroup(id, _):
return "tool-group-\(id)"
}
}
}
private struct MessageBubble: View {
func buildTranscriptRenderItems(from messages: [Message]) -> [TranscriptRenderItem] {
var items: [TranscriptRenderItem] = []
var toolRun: [Message] = []
func flushToolRun() {
guard !toolRun.isEmpty else { return }
if toolRun.count == 1, let message = toolRun.first {
items.append(.message(message))
} else if let first = toolRun.first {
items.append(.toolGroup(id: first.id, messages: toolRun))
}
toolRun.removeAll(keepingCapacity: true)
}
for message in messages {
if message.toolCallMetadata != nil {
toolRun.append(message)
} else {
flushToolRun()
items.append(.message(message))
}
}
flushToolRun()
return items
}
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
@@ -93,13 +165,46 @@ private struct MessageBubble: View {
message.role == .user
}
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var isStreamingAssistant: Bool {
message.role == .assistant && message.id.hasPrefix("temp-assistant-")
}
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
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
@@ -118,25 +223,18 @@ private struct MessageBubble: View {
)
}
if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
if isStreamingAssistant {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.tint(SybilTheme.primary)
.foregroundStyle(isUser ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
}
}
.padding(.horizontal, isUser ? 14 : 2)
.padding(.vertical, isUser ? 13 : 2)
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
.padding(.vertical, isUser || agentStyle ? 13 : 2)
.textSelection(.enabled)
.background(
Group {
@@ -147,6 +245,10 @@ private struct MessageBubble: View {
RoundedRectangle(cornerRadius: 16)
.stroke(SybilTheme.primary.opacity(0.45), lineWidth: 1)
)
} else if agentStyle {
RoundedRectangle(cornerRadius: 16)
.fill(Color(red: 0.12, green: 0.075, blue: 0.21))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(SybilTheme.primary.opacity(0.18), lineWidth: 1))
} else {
RoundedRectangle(cornerRadius: 0)
.fill(Color.clear)
@@ -176,10 +278,339 @@ private struct MessageBubble: View {
}
}
private struct ToolCallActivityChip: View {
private struct StreamingTraceHeightPreferenceKey: PreferenceKey {
static let defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct StreamingAssistantContentView: View {
private enum Phase: Hashable {
case pending
case trace(Int)
case answer
}
var content: String
var isSending: Bool
var presentation: StreamingAssistantPresentation?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var retainedTraceHeight: CGFloat = 0
private var traceText: String? {
guard let trace = presentation?.ephemeralTrace?.trimmingCharacters(in: .whitespacesAndNewlines),
!trace.isEmpty
else {
return nil
}
return trace
}
private var hasAnswer: Bool {
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private var phase: Phase {
if hasAnswer {
return .answer
}
if traceText != nil {
return .trace(presentation?.traceRevision ?? 0)
}
return .pending
}
private var traceTransition: AnyTransition {
guard !reduceMotion else { return .identity }
return .asymmetric(
insertion: .opacity.combined(with: .offset(y: 5)),
removal: .opacity.combined(with: .offset(y: -5))
)
}
private var answerTransition: AnyTransition {
guard !reduceMotion else { return .identity }
return .opacity.combined(with: .offset(y: 3))
}
var body: some View {
ZStack(alignment: .topLeading) {
if let traceText {
Text(traceText)
.font(.sybil(size: 15))
.italic()
.foregroundStyle(SybilTheme.textMuted.opacity(0.88))
.lineSpacing(5)
.fixedSize(horizontal: false, vertical: true)
.opacity(0.78)
.textSelection(.disabled)
.allowsHitTesting(false)
.accessibilityLabel("Assistant is working")
.background {
GeometryReader { geometry in
Color.clear.preference(
key: StreamingTraceHeightPreferenceKey.self,
value: geometry.size.height
)
}
}
.id("thinking-trace-\(presentation?.traceRevision ?? 0)")
.transition(traceTransition)
.zIndex(1)
}
if hasAnswer {
SybilMessageText(content: content, format: .markdown)
.textSelection(.enabled)
.id("streaming-assistant-answer")
.transition(answerTransition)
.zIndex(2)
}
if isSending && !hasAnswer && traceText == nil {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.padding(.vertical, 2)
.transition(reduceMotion ? .identity : .opacity)
}
}
.frame(maxWidth: .infinity, minHeight: max(24, retainedTraceHeight), alignment: .topLeading)
.onPreferenceChange(StreamingTraceHeightPreferenceKey.self) { measuredHeight in
guard measuredHeight.isFinite, measuredHeight > retainedTraceHeight else { return }
retainedTraceHeight = measuredHeight
}
.animation(reduceMotion ? nil : .easeOut(duration: 0.20), value: phase)
}
}
private struct ToolCallStackView: View {
private struct CardLayout {
var x: CGFloat
var y: CGFloat
var scale: CGFloat
var opacity: Double
var zIndex: Double
}
var groupID: String
var messages: [Message]
var entryAnimationIDs: Set<String>
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var isExpanded = false
private let visibleCollapsedLimit = 4
private let cardHeight: CGFloat = 62
private let expandedGap: CGFloat = 10
private let collapsedStepX: CGFloat = 11
private let collapsedStepY: CGFloat = 10
private let toggleSize: CGFloat = 32
private let toggleGap: CGFloat = 12
private var animation: Animation? {
reduceMotion ? nil : .easeInOut(duration: 0.34)
}
private var visibleCollapsedCount: Int {
min(messages.count, visibleCollapsedLimit)
}
private var hiddenCount: Int {
max(0, messages.count - visibleCollapsedLimit)
}
private var containerHeight: CGFloat {
if isExpanded {
return cardHeight + CGFloat(max(0, messages.count - 1)) * (cardHeight + expandedGap)
}
return cardHeight + CGFloat(max(0, visibleCollapsedCount - 1)) * collapsedStepY
}
private var accessibilityLabel: String {
"\(messages.count) tool \(messages.count == 1 ? "call" : "calls")"
}
var body: some View {
HStack(alignment: .top, spacing: 0) {
GeometryReader { geometry in
let cardWidth = max(220, min(520, geometry.size.width - toggleSize - toggleGap))
let toggleX = cardWidth + toggleGap
ZStack(alignment: .topLeading) {
ForEach(Array(messages.enumerated()), id: \.element.id) { index, message in
let layout = layout(for: index)
let depth = messages.count - index - 1
let isHidden = !isExpanded && depth >= visibleCollapsedLimit
let shouldAnimateEntry = entryAnimationIDs.contains(message.id) && !isHidden
ToolCallStackCard(
message: message,
cardHeight: cardHeight,
compactLayout: true,
animateEntry: shouldAnimateEntry
)
.frame(width: cardWidth, height: cardHeight, alignment: .topLeading)
.scaleEffect(layout.scale, anchor: .topLeading)
.opacity(layout.opacity)
.offset(x: layout.x, y: layout.y)
.zIndex(layout.zIndex)
.allowsHitTesting(!isHidden)
.accessibilityHidden(isHidden)
}
if !isExpanded && hiddenCount > 0 {
Text("+\(hiddenCount)")
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(SybilTheme.accent.opacity(0.95))
.padding(.horizontal, 7)
.padding(.vertical, 3)
.background(
Capsule()
.fill(Color.black.opacity(0.58))
.overlay(
Capsule()
.stroke(SybilTheme.accent.opacity(0.34), lineWidth: 1)
)
)
.offset(x: max(0, cardWidth - 56), y: containerHeight - 13)
.transition(.opacity)
}
Button {
withAnimation(animation) {
isExpanded.toggle()
}
} label: {
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(SybilTheme.accent.opacity(0.95))
.frame(width: toggleSize, height: toggleSize)
.background(
Circle()
.fill(
LinearGradient(
colors: [
Color(red: 0.06, green: 0.08, blue: 0.15).opacity(0.96),
Color(red: 0.03, green: 0.04, blue: 0.10).opacity(0.96)
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
Circle()
.stroke(SybilTheme.accent.opacity(0.38), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.30), radius: 10, x: 0, y: 6)
)
}
.buttonStyle(.plain)
.accessibilityLabel("\(isExpanded ? "Collapse" : "Expand") \(accessibilityLabel)")
.offset(x: toggleX, y: 8)
.zIndex(Double(messages.count + 2))
}
.frame(width: cardWidth + toggleSize + toggleGap, height: containerHeight, alignment: .topLeading)
.animation(animation, value: isExpanded)
}
.frame(height: containerHeight)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func layout(for index: Int) -> CardLayout {
if isExpanded {
return CardLayout(
x: 0,
y: CGFloat(index) * (cardHeight + expandedGap),
scale: 1,
opacity: 1,
zIndex: Double(messages.count - index)
)
}
let depth = messages.count - index - 1
let visibleDepth = min(depth, visibleCollapsedLimit - 1)
let isHidden = depth >= visibleCollapsedLimit
return CardLayout(
x: CGFloat(visibleDepth) * collapsedStepX,
y: CGFloat(visibleDepth) * collapsedStepY,
scale: max(0.88, 1 - CGFloat(visibleDepth) * 0.035),
opacity: isHidden ? 0 : max(0.34, 1 - Double(visibleDepth) * 0.22),
zIndex: isHidden ? 0 : Double(visibleCollapsedCount - visibleDepth)
)
}
}
private struct ToolCallStackCard: View {
var message: Message
var cardHeight: CGFloat
var compactLayout: Bool
var animateEntry: Bool
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var entryAnimationArmed = false
@State private var didEnter = false
private var isPreparingEntry: Bool {
(animateEntry || entryAnimationArmed) && !didEnter
}
var body: some View {
Group {
if let metadata = message.toolCallMetadata {
ToolCallActivityChip(
metadata: metadata,
fallbackContent: message.content,
createdAt: message.createdAt,
compactLayout: compactLayout
)
}
}
.frame(height: cardHeight, alignment: .top)
.scaleEffect(isPreparingEntry ? 1.025 : 1, anchor: .topLeading)
.offset(y: isPreparingEntry ? -8 : 0)
.rotation3DEffect(.degrees(isPreparingEntry ? 3 : 0), axis: (x: 1, y: 0, z: 0), anchor: .top)
.opacity(isPreparingEntry ? 0.72 : 1)
.onAppear {
guard !didEnter, !entryAnimationArmed else { return }
guard animateEntry else {
didEnter = true
return
}
entryAnimationArmed = true
if reduceMotion {
didEnter = true
} else {
withAnimation(.easeOut(duration: 0.32).delay(0.03)) {
didEnter = true
}
}
}
}
}
struct ToolCallActivityChip: View {
enum VisualState {
case initiated
case completed
case failed
}
var metadata: ToolCallMetadata
var fallbackContent: String
var createdAt: Date
var compactLayout: Bool = false
private var summary: String {
if let text = metadata.summary?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {
@@ -223,11 +654,22 @@ private struct ToolCallActivityChip: View {
}
private var isFailed: Bool {
(metadata.status ?? "").lowercased() == "failed"
visualState == .failed
}
private var visualState: VisualState {
switch (metadata.status ?? "").lowercased() {
case "failed":
return .failed
case "initiated":
return .initiated
default:
return .completed
}
}
private var detailLabel: String {
var pieces: [String] = [isFailed ? "Failed" : "Completed"]
var pieces: [String] = [stateLabel]
if let durationMs = metadata.durationMs, durationMs > 0 {
pieces.append("\(durationMs) ms")
}
@@ -239,14 +681,14 @@ private struct ToolCallActivityChip: View {
HStack(alignment: .top, spacing: 11) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.13))
.fill(iconColor.opacity(0.13))
.overlay(
RoundedRectangle(cornerRadius: 9)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
.stroke(iconColor.opacity(0.34), lineWidth: 1)
)
Image(systemName: iconName)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger : SybilTheme.accent)
.foregroundStyle(iconColor)
}
.frame(width: 30, height: 30)
@@ -255,12 +697,14 @@ private struct ToolCallActivityChip: View {
.font(.sybil(.subheadline))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.96) : SybilTheme.text.opacity(0.94))
.lineSpacing(3)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(compactLayout ? 1 : nil)
.truncationMode(.tail)
.fixedSize(horizontal: false, vertical: !compactLayout)
HStack(spacing: 6) {
Text(toolLabel)
.font(.sybil(.caption2, weight: .semibold))
.foregroundStyle(isFailed ? SybilTheme.danger.opacity(0.84) : SybilTheme.accent.opacity(0.90))
.foregroundStyle(iconColor.opacity(0.90))
.lineLimit(1)
Text(detailLabel)
@@ -275,12 +719,45 @@ private struct ToolCallActivityChip: View {
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isFailed ? SybilTheme.failedToolCallGradient : SybilTheme.toolCallGradient)
.fill(backgroundGradient)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke((isFailed ? SybilTheme.danger : SybilTheme.accent).opacity(0.34), lineWidth: 1)
.stroke(iconColor.opacity(0.34), lineWidth: 1)
)
)
.frame(maxWidth: 520, alignment: .leading)
}
private var stateLabel: String {
switch visualState {
case .failed:
return "Failed"
case .initiated:
return "Running"
case .completed:
return "Completed"
}
}
private var iconColor: Color {
switch visualState {
case .failed:
return SybilTheme.danger
case .initiated:
return SybilTheme.warning
case .completed:
return SybilTheme.accent
}
}
private var backgroundGradient: LinearGradient {
switch visualState {
case .failed:
return SybilTheme.failedToolCallGradient
case .initiated:
return SybilTheme.runningToolCallGradient
case .completed:
return SybilTheme.toolCallGradient
}
}
}
@@ -0,0 +1,71 @@
import Foundation
// This Foundation-only contract is compiled into both the Catalyst client and the
// native macOS bundle. Only Objective-C-compatible values cross the bundle boundary.
@MainActor
@objc(SybilQuickQuestionPanelBridging)
protocol SybilQuickQuestionPanelBridging: NSObjectProtocol {
init()
var isVisible: Bool { get }
func start(delegate: any SybilQuickQuestionPanelDelegate) -> Int32
func updateState(_ data: Data)
func show()
func hide()
func activateForQuickQuestion()
func activateMainWindow()
func stop()
}
@MainActor
@objc(SybilQuickQuestionPanelDelegate)
protocol SybilQuickQuestionPanelDelegate: NSObjectProtocol {
func quickQuestionToggleRequested()
func quickQuestionPromptChanged(_ prompt: String)
func quickQuestionSubmitRequested()
func quickQuestionProviderChanged(_ provider: String)
func quickQuestionModelChanged(_ model: String)
func quickQuestionConvertRequested()
func quickQuestionOpenAppRequested()
func quickQuestionPanelDismissed()
}
struct SybilQuickQuestionPanelState: Codable, Equatable, Sendable {
struct ProviderOption: Codable, Equatable, Sendable {
var id: String
var title: String
}
var prompt = ""
var answer = ""
var toolSummaries: [String] = []
var provider = ""
var providers: [ProviderOption] = []
var model = ""
var models: [String] = []
var isSending = false
var isConverting = false
var canSend = false
var canConvert = false
var isAuthenticated = false
var isCheckingSession = true
var error: String?
var isBusy: Bool { isSending || isConverting }
var hasResponse: Bool { !answer.isEmpty || !toolSummaries.isEmpty || isSending || error != nil }
}
// Carbon can deliver multiple presses while a key is held. Consume one toggle
// per press/release pair, without suppressing a second deliberate press.
struct SybilQuickQuestionHotKeyState {
private var isPressed = false
mutating func shouldToggle(isKeyDown: Bool) -> Bool {
if !isKeyDown {
isPressed = false
return false
}
guard !isPressed else { return false }
isPressed = true
return true
}
}
@@ -0,0 +1,257 @@
#if targetEnvironment(macCatalyst)
import Combine
import Foundation
import Observation
import UIKit
@MainActor
public final class SybilMacQuickQuestionController: NSObject, SybilQuickQuestionPanelDelegate {
public static let shared = SybilMacQuickQuestionController()
private var pluginBundle: Bundle?
private var bridge: (any SybilQuickQuestionPanelBridging)?
private var viewModel: SybilViewModel?
private var observationGeneration = 0
private var isPresentationRequested = false
private var activationRequested = false
private var presentationTask: Task<Void, Never>?
private var foregroundObservation: AnyCancellable?
private var notificationCenter = NotificationCenter.default
private var isApplicationInForeground: @MainActor () -> Bool = {
UIApplication.shared.applicationState != .background
}
private override init() {
super.init()
}
// Inject foreground readiness as well as the panel so tests can exercise
// windowless activation without activating apps or showing real windows.
init(
bridge: any SybilQuickQuestionPanelBridging,
isApplicationInForeground: @escaping @MainActor () -> Bool = { true },
notificationCenter: NotificationCenter = NotificationCenter()
) {
self.bridge = bridge
self.isApplicationInForeground = isApplicationInForeground
self.notificationCenter = notificationCenter
super.init()
_ = bridge.start(delegate: self)
observeForegroundTransitionsIfNeeded()
}
public func start() {
observeForegroundTransitionsIfNeeded()
guard bridge == nil else { return }
guard let url = Bundle.main.builtInPlugInsURL?.appendingPathComponent("SybilMacQuickQuestion.bundle"),
let bundle = Bundle(url: url)
else {
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle is missing.")
return
}
do {
try bundle.loadAndReturnError()
guard let pluginClass = bundle.principalClass as? any SybilQuickQuestionPanelBridging.Type else {
SybilLog.error(SybilLog.app, "The macOS Quick Question bundle has an invalid principal class.")
return
}
let plugin = pluginClass.init()
pluginBundle = bundle
bridge = plugin
let status = plugin.start(delegate: self)
if status != 0 {
SybilLog.warning(SybilLog.app, "Option+Space could not be registered (status \(status)). Quick Question remains available in the app menu.")
}
} catch {
SybilLog.error(SybilLog.app, "Could not load the macOS Quick Question panel", error: error)
}
}
func attach(_ viewModel: SybilViewModel) {
guard self.viewModel !== viewModel else { return }
// A window becoming active must not replace an in-flight quick question.
if self.viewModel != nil,
isPresentationRequested || bridge?.isVisible == true || self.viewModel?.isQuickQuestionSending == true || self.viewModel?.isConvertingQuickQuestion == true {
return
}
self.viewModel = viewModel
if bridge?.isVisible == true { updateAndObserve() }
}
public func toggle() {
start()
if isPresentationRequested || bridge?.isVisible == true {
cancelPresentation()
bridge?.hide()
} else {
show()
}
}
public func show() {
start()
isPresentationRequested = true
schedulePresentationIfNeeded()
}
public func stop() {
cancelPresentation()
foregroundObservation = nil
viewModel?.cancelQuickQuestion()
bridge?.stop()
bridge = nil
// Keep the loaded bundle alive for the lifetime of its code.
}
private func observeForegroundTransitionsIfNeeded() {
guard foregroundObservation == nil else { return }
foregroundObservation = notificationCenter.publisher(for: UIApplication.willEnterForegroundNotification)
.merge(with: notificationCenter.publisher(for: UIApplication.didBecomeActiveNotification))
.sink { [weak self] _ in
Task { @MainActor [weak self] in
self?.schedulePresentationIfNeeded()
}
}
}
private func schedulePresentationIfNeeded() {
guard isPresentationRequested, bridge?.isVisible != true, presentationTask == nil else { return }
// Leave the Carbon/NSApplication event callback before touching windows.
// AppKit activation is asynchronous; an arbitrary delay or NSApp.isActive
// is not a substitute for checking UIKit's foreground lifecycle state.
presentationTask = Task { @MainActor [weak self] in
guard let self, !Task.isCancelled else { return }
self.presentationTask = nil
guard self.isPresentationRequested else { return }
guard self.isApplicationInForeground() else {
guard !self.activationRequested else { return }
self.activationRequested = true
SybilLog.debug(SybilLog.ui, "Waiting for Catalyst foreground before showing Quick Question")
self.bridge?.activateForQuickQuestion()
// Also handle an activation that completes synchronously. If it
// has not completed, subsequent UIKit notifications resume us.
self.schedulePresentationIfNeeded()
return
}
self.activationRequested = false
self.updateAndObserve()
self.bridge?.show()
}
}
private func cancelPresentation() {
isPresentationRequested = false
activationRequested = false
presentationTask?.cancel()
presentationTask = nil
observationGeneration += 1
}
private func updateAndObserve() {
guard let bridge else { return }
observationGeneration += 1
let generation = observationGeneration
let state = withObservationTracking {
viewModel?.macQuickQuestionPanelState ?? SybilQuickQuestionPanelState()
} onChange: { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.observationGeneration == generation, self.bridge?.isVisible == true else { return }
self.updateAndObserve()
}
}
do {
bridge.updateState(try JSONEncoder().encode(state))
} catch {
SybilLog.error(SybilLog.ui, "Could not update the macOS Quick Question panel", error: error)
}
}
func quickQuestionToggleRequested() {
toggle()
}
func quickQuestionPromptChanged(_ prompt: String) {
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.updateQuickQuestionPrompt(prompt)
updateAndObserve()
}
func quickQuestionSubmitRequested() {
guard let viewModel, viewModel.isAuthenticated, !viewModel.isCheckingSession else { return }
viewModel.sendQuickQuestion()
updateAndObserve()
}
func quickQuestionProviderChanged(_ provider: String) {
guard let viewModel, let provider = Provider(rawValue: provider),
!viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.setQuickQuestionProvider(provider)
updateAndObserve()
}
func quickQuestionModelChanged(_ model: String) {
guard let viewModel, !viewModel.isQuickQuestionSending, !viewModel.isConvertingQuickQuestion else { return }
viewModel.setQuickQuestionModel(model)
updateAndObserve()
}
func quickQuestionConvertRequested() {
guard let viewModel, viewModel.canConvertQuickQuestion else { return }
Task { @MainActor [weak self] in
guard let self else { return }
if await viewModel.convertQuickQuestionToChat() {
self.cancelPresentation()
self.openMainWindow()
self.bridge?.hide()
} else {
self.updateAndObserve()
}
}
}
func quickQuestionOpenAppRequested() {
cancelPresentation()
openMainWindow()
bridge?.hide()
}
private func openMainWindow() {
let session = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.session
UIApplication.shared.requestSceneSessionActivation(session, userActivity: nil, options: nil)
bridge?.activateMainWindow()
}
func quickQuestionPanelDismissed() {
// Toggling out preserves the draft and lets an answer finish. Reopening
// reads the latest state, without starting a new request.
cancelPresentation()
}
}
extension SybilViewModel {
var macQuickQuestionPanelState: SybilQuickQuestionPanelState {
SybilQuickQuestionPanelState(
prompt: quickQuestionPrompt,
answer: quickQuestionAnswerText,
toolSummaries: quickQuestionMessages.compactMap { message in
guard let metadata = message.toolCallMetadata else { return nil }
return metadata.summary ?? message.content
},
provider: quickQuestionProvider.rawValue,
providers: providerOptions.map { .init(id: $0.rawValue, title: $0.displayName) },
model: quickQuestionModel,
models: quickQuestionProviderModelOptions,
isSending: isQuickQuestionSending,
isConverting: isConvertingQuickQuestion,
canSend: isAuthenticated && !isCheckingSession && canSendQuickQuestion,
canConvert: canConvertQuickQuestion,
isAuthenticated: isAuthenticated,
isCheckingSession: isCheckingSession,
error: quickQuestionError
)
}
}
#endif
@@ -0,0 +1,37 @@
#if targetEnvironment(macCatalyst)
import Combine
// Scene lifetime is independent of focused commands: a connected window may
// still be authenticating, inactive, or minimized. The native Quick Question
// panel is not a workspace scene and does not count as an open window here.
@MainActor
public final class SybilMacWindowCommands: ObservableObject {
public static let shared = SybilMacWindowCommands()
@Published private var connectedWindowIDs: Set<String> = []
init() {}
var hasOpenWindows: Bool { !connectedWindowIDs.isEmpty }
public func windowConnected(sessionID: String) {
connectedWindowIDs.insert(sessionID)
}
public func windowDisconnected(sessionID: String) {
connectedWindowIDs.remove(sessionID)
}
func canStartNewChat(hasFocusedActions: Bool) -> Bool {
!hasOpenWindows || hasFocusedActions
}
func newChatOrWindow(newChat: (() -> Void)?, openWindow: () -> Void) {
if hasOpenWindows {
newChat?()
} else {
openWindow()
}
}
}
#endif
@@ -0,0 +1,39 @@
import MarkdownUI
import SwiftUI
/// Both roles and both workspaces use normal chat's readable, Dynamic Type-aware theme.
/// Literal input is built from text nodes, never passed through the Markdown parser.
struct SybilMessageText: View {
enum Format { case literal, markdown }
var content: String
var format: Format
static func literalContent(_ text: String) -> MarkdownContent {
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
// paragraph boundaries as Markdown. Keep the newline count so deliberate
// extra blank lines survive; nonblank lines and Markdown syntax stay literal.
let normalized = text.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
.components(separatedBy: "\n")
.map { line in line.allSatisfy { $0 == " " || $0 == "\t" } ? "" : line }
.joined(separator: "\n")
return MarkdownContent {
for paragraph in normalized.components(separatedBy: "\n\n") {
Paragraph {
// Keep extra blank paragraphs visible, including leading/trailing ones.
paragraph.isEmpty ? "\u{00a0}" : paragraph
}
}
}
}
var body: some View {
#if DEBUG
let _ = AgentRenderDiagnostics.record("messageTextBuild")
#endif
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
.tint(SybilTheme.primary)
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
.markdownTheme(.sybilReadable)
}
}
@@ -4,6 +4,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case openai
case anthropic
case xai
case gemini
case hermesAgent = "hermes-agent"
public var displayName: String {
@@ -11,6 +12,7 @@ public enum Provider: String, Codable, CaseIterable, Hashable, Sendable {
case .openai: return "OpenAI"
case .anthropic: return "Anthropic"
case .xai: return "xAI"
case .gemini: return "Gemini"
case .hermesAgent: return "Hermes Agent"
}
}
@@ -152,20 +154,186 @@ public struct ChatAttachment: Codable, Hashable, Identifiable, Sendable {
public struct ChatSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
}
extension ChatSummary {
private enum CodingKeys: String, CodingKey {
case id
case title
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
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)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
}
}
public struct SearchSummary: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
}
public enum WorkspaceItemType: String, Codable, Hashable, Sendable {
case chat
case search
}
public struct WorkspaceItem: Codable, Identifiable, Hashable, Sendable {
public var type: WorkspaceItemType
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
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
public var lastUsedModel: String?
public init(chat: ChatSummary) {
self.type = .chat
self.id = chat.id
self.title = chat.title
self.query = nil
self.parentChatId = chat.parentChatId
self.titleGenerationPending = chat.titleGenerationPending
self.createdAt = chat.createdAt
self.updatedAt = chat.updatedAt
self.starred = chat.starred
self.starredAt = chat.starredAt
self.initiatedProvider = chat.initiatedProvider
self.initiatedModel = chat.initiatedModel
self.lastUsedProvider = chat.lastUsedProvider
self.lastUsedModel = chat.lastUsedModel
}
public init(search: SearchSummary) {
self.type = .search
self.id = search.id
self.title = search.title
self.query = search.query
self.parentChatId = nil
self.titleGenerationPending = false
self.createdAt = search.createdAt
self.updatedAt = search.updatedAt
self.starred = search.starred
self.starredAt = search.starredAt
self.initiatedProvider = nil
self.initiatedModel = nil
self.lastUsedProvider = nil
self.lastUsedModel = nil
}
public var chatSummary: ChatSummary? {
guard type == .chat else { return nil }
return ChatSummary(
id: id,
title: title,
parentChatId: parentChatId,
titleGenerationPending: titleGenerationPending,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt,
initiatedProvider: initiatedProvider,
initiatedModel: initiatedModel,
lastUsedProvider: lastUsedProvider,
lastUsedModel: lastUsedModel
)
}
public var searchSummary: SearchSummary? {
guard type == .search else { return nil }
return SearchSummary(
id: id,
title: title,
query: query,
createdAt: createdAt,
updatedAt: updatedAt,
starred: starred,
starredAt: starredAt
)
}
}
extension WorkspaceItem {
private enum CodingKeys: String, CodingKey {
case type
case id
case title
case query
case searchSnippet
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
type = try container.decode(WorkspaceItemType.self, forKey: .type)
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)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
}
}
public struct Message: Codable, Identifiable, Hashable, Sendable {
@@ -191,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
)
}
@@ -210,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 {
@@ -306,8 +482,12 @@ public enum JSONValue: Codable, Hashable, Sendable {
public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var title: String?
public var parentChatId: String? = nil
public var titleGenerationPending = false
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var initiatedProvider: Provider?
public var initiatedModel: String?
public var lastUsedProvider: Provider?
@@ -315,6 +495,41 @@ public struct ChatDetail: Codable, Identifiable, Hashable, Sendable {
public var messages: [Message]
}
extension ChatDetail {
private enum CodingKeys: String, CodingKey {
case id
case title
case parentChatId
case titleGenerationPending
case createdAt
case updatedAt
case starred
case starredAt
case initiatedProvider
case initiatedModel
case lastUsedProvider
case lastUsedModel
case messages
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
title = try container.decodeIfPresent(String.self, forKey: .title)
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)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
starred = try container.decodeIfPresent(Bool.self, forKey: .starred) ?? false
starredAt = try container.decodeIfPresent(Date.self, forKey: .starredAt)
initiatedProvider = try container.decodeIfPresent(Provider.self, forKey: .initiatedProvider)
initiatedModel = try container.decodeIfPresent(String.self, forKey: .initiatedModel)
lastUsedProvider = try container.decodeIfPresent(Provider.self, forKey: .lastUsedProvider)
lastUsedModel = try container.decodeIfPresent(String.self, forKey: .lastUsedModel)
messages = try container.decode([Message].self, forKey: .messages)
}
}
public struct SearchResultItem: Codable, Identifiable, Hashable, Sendable {
public var id: String
public var createdAt: Date
@@ -346,6 +561,8 @@ public struct SearchDetail: Codable, Identifiable, Hashable, Sendable {
public var query: String?
public var createdAt: Date
public var updatedAt: Date
public var starred = false
public var starredAt: Date?
public var requestId: String?
public var latencyMs: Int?
public var error: String?
@@ -427,8 +644,8 @@ public struct CompletionStreamToolCall: Codable, Sendable {
public var summary: String
public var args: [String: JSONValue]
public var startedAt: String
public var completedAt: String
public var durationMs: Int
public var completedAt: String?
public var durationMs: Int?
public var error: String?
public var resultPreview: String?
}
@@ -524,6 +741,15 @@ struct SearchListResponse: Codable {
var searches: [SearchSummary]
}
struct WorkspaceSearchResponse: Codable, Sendable {
var items: [WorkspaceItem]
var hasMore: Bool
}
struct WorkspaceListResponse: Codable {
var items: [WorkspaceItem]
}
struct ChatDetailResponse: Codable {
var chat: ChatDetail
}
@@ -0,0 +1,191 @@
import Foundation
import UIKit
import UserNotifications
@MainActor
final class SybilNotificationPlatform: AgentNotificationPlatform {
static let shared = SybilNotificationPlatform()
private var center: UNUserNotificationCenter { .current() }
// UIKit's callback has no request ID. Keep an outstanding request tied to its
// original generation until it drains; queue the next session behind it.
private var outstanding: UUID?
private var queued: UUID?
private var registeredGeneration: UUID?
var environment: AgentAPNSEnvironment? {
#if targetEnvironment(simulator)
return isFixture ? .sandbox : nil
#elseif targetEnvironment(macCatalyst)
return nil
#else
return AgentAPNSEnvironment(entitlementSetting: Bundle.main.object(forInfoDictionaryKey: "SybilAPNSEnvironment") as? String)
#endif
}
var allowsLoopback: Bool { isFixture }
var isFixture: Bool {
#if DEBUG && targetEnvironment(simulator)
return ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1"
#else
return false
#endif
}
private var fixturePermission: AgentNotificationPermission = .notDetermined
func permission() async -> AgentNotificationPermission {
if isFixture {
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "authorized" { return .authorized }
if ProcessInfo.processInfo.environment["SYBIL_PUSH_PERMISSION"] == "denied" { return .denied }
return fixturePermission
}
guard environment != nil else { return .notDetermined }
switch await center.notificationSettings().authorizationStatus {
case .authorized, .ephemeral: return .authorized
case .provisional: return .provisional
case .denied: return .denied
default: return .notDetermined
}
}
func requestPermission() async throws -> Bool {
if isFixture {
fixturePermission = ProcessInfo.processInfo.environment["SYBIL_PUSH_DENIED"] == "1" ? .denied : .authorized
return fixturePermission == .authorized
}
guard environment != nil else { return false }
return try await center.requestAuthorization(options: [.alert, .sound, .badge])
}
func register(generation: UUID) {
guard environment != nil else { return }
if let outstanding {
if outstanding != generation { queued = generation }
return
}
outstanding = generation
if isFixture {
// Deliberately synthetic bytes; never call Apple's registrar in a simulator.
Task { didRegister(Data([0x00, 0x01, 0x0a, 0xff] + Array(repeating: 0x42, count: 28))) }
} else {
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
UIApplication.shared.registerForRemoteNotifications()
#endif
}
}
func unregister() {
queued = nil; registeredGeneration = nil
#if !targetEnvironment(simulator) && !targetEnvironment(macCatalyst)
UIApplication.shared.unregisterForRemoteNotifications()
#endif
// Do not reassign an old callback to a new session.
}
func didRegister(_ data: Data) {
let run = outstanding ?? registeredGeneration
outstanding = nil
if let run {
registeredGeneration = run
SybilAgentNotifications.shared.receivedDeviceToken(data, generation: run)
}
drainQueued()
}
func didFail() {
let run = outstanding ?? registeredGeneration
outstanding = nil
if let run { SybilAgentNotifications.shared.registrationFailed(generation: run) }
drainQueued()
}
private func drainQueued() {
if let next = queued { queued = nil; register(generation: next) }
}
func delivered() async -> [AgentDeliveredNotification] {
if isFixture || environment == nil { return [] }
return await center.deliveredNotifications().map {
AgentDeliveredNotification(identifier: $0.request.identifier, payload: AgentPushPayload($0.request.content.userInfo))
}
}
func removeDelivered(identifiers: [String]) {
guard !isFixture, environment != nil else { return }
center.removeDeliveredNotifications(withIdentifiers: identifiers)
}
}
/// Public app-delegate bridge. The package coordinator remains injectable in tests.
@MainActor
public enum SybilPushNotifications {
public struct Event: Sendable {
let payload: AgentPushPayload?
public init(userInfo: [AnyHashable: Any]) { payload = AgentPushPayload(userInfo) }
}
#if DEBUG && targetEnvironment(simulator)
private static var fixtureWasActive = false
private static var fixtureForegroundSequence = 0
#endif
public static func didRegister(deviceToken: Data) { SybilNotificationPlatform.shared.didRegister(deviceToken) }
public static func didFailToRegister() { SybilNotificationPlatform.shared.didFail() }
public static func presentationOptions(event: Event) -> UNNotificationPresentationOptions {
guard let payload = event.payload else { return [] }
let notifications = SybilAgentNotifications.shared
Task { await notifications.syncRead() }
// Covers foreground bootstrap before a SwiftUI scene has attached too.
guard UIApplication.shared.applicationState != .active, notifications.shouldPresent(payload) else { return [] }
return [.banner, .list, .sound]
}
public static func didRespond(userInfo: [AnyHashable: Any], identifier: String) {
didRespond(event: Event(userInfo: userInfo), identifier: identifier)
}
public static func didRespond(event: Event, identifier: String) {
guard let payload = event.payload else { return }
SybilAgentNotifications.shared.notificationResponse(payload, identifier: identifier)
}
public static func receiveBackground(userInfo: [AnyHashable: Any], completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
guard let payload = AgentPushPayload(userInfo), payload.kind == .readState else { completion(.noData); return }
let notifications = SybilAgentNotifications.shared
let settings = SybilSettingsStore()
if let base = settings.normalizedAPIBaseURL {
notifications.restoreBackgroundScope(baseURL: base, token: settings.trimmedTokenOrNil, payload: payload)
}
AgentBackgroundCompletion.run(timeout: .seconds(5), operation: {
await notifications.receiveBackground(payload) ? .newData : .noData
}, completion: completion)
}
public static func launchFixtureIfRequested() {
#if DEBUG && targetEnvironment(simulator)
guard SybilNotificationPlatform.shared.isFixture,
ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP"] == "1" else { return }
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "outbound:3", "cursor": "3"], identifier: "fixture-system-request")
#endif
}
static func foregroundFixtureIfRequested() {
#if DEBUG && targetEnvironment(simulator)
guard SybilNotificationPlatform.shared.isFixture else { return }
let wasActive = fixtureWasActive
fixtureWasActive = true
guard wasActive, ProcessInfo.processInfo.environment["SYBIL_PUSH_TAP_ON_FOREGROUND"] == "1" else { return }
fixtureForegroundSequence += 1
didRespond(userInfo: ["version": 1, "sourceId": "11111111-1111-4111-8111-111111111111", "kind": "message", "messageId": "fixture-warm-tap-\(fixtureForegroundSequence)", "cursor": "3"], identifier: "fixture-warm-system-request")
#endif
}
}
/// A bounded callback gate: even an uncooperative notification-center fixture (or
/// OS suspension) cannot delay the fetch completion waiting for a network/socket.
@MainActor
enum AgentBackgroundCompletion {
static func run(timeout: Duration, operation: @escaping @MainActor () async -> UIBackgroundFetchResult,
completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) {
let gate = Gate(completion)
let work = Task { gate.finish(await operation()) }
Task {
try? await Task.sleep(for: timeout)
if !gate.finished { work.cancel(); gate.finish(.noData) }
}
}
@MainActor private final class Gate {
var finished = false
let completion: @MainActor (UIBackgroundFetchResult) -> Void
init(_ completion: @escaping @MainActor (UIBackgroundFetchResult) -> Void) { self.completion = completion }
func finish(_ result: UIBackgroundFetchResult) {
guard !finished else { return }
finished = true; completion(result)
}
}
}
@@ -2,6 +2,7 @@ import Observation
import SwiftUI
enum PhoneRoute: Hashable {
case agent
case chat(String)
case search(String)
case draftChat
@@ -14,6 +15,7 @@ enum PhoneRoute: Hashable {
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .agent: return .agent
case .settings:
return .settings
}
@@ -36,6 +38,11 @@ struct SybilPhoneShellView: View {
@State private var sidebarHighlightClearTask: Task<Void, Never>?
@State private var openingSelectionRequestID: UUID?
init(viewModel: SybilViewModel) {
self.viewModel = viewModel
_route = State(initialValue: viewModel.selectedItem == .agent ? .agent : .draftChat)
}
private var canRecognizeSidebarSwipe: Bool {
!isSidebarOverlayPresented && !sidebarSwipeIsCompleting
}
@@ -64,6 +71,7 @@ struct SybilPhoneShellView: View {
return .chat(chatID)
case let .search(searchID):
return .search(searchID)
case .agent: return .agent
case .draftChat, .draftSearch, .settings:
return nil
}
@@ -87,6 +95,14 @@ struct SybilPhoneShellView: View {
.tint(SybilTheme.primary)
.animation(.easeOut(duration: 0.22), value: route)
.animation(.easeOut(duration: 0.18), value: isSidebarOverlayPresented)
.onChange(of: shouldRenderSidebarOverlay, initial: true) { _, covered in
viewModel.agent.isObscured = covered
}
.onDisappear { viewModel.agent.isObscured = false }
.onChange(of: viewModel.agentNotificationRouteRequest) { _, _ in
openingSelectionRequestID = nil
showRouteAndClearSidebarHighlight(.agent)
}
.onChange(of: scenePhase) { _, nextPhase in
switch nextPhase {
case .background:
@@ -130,7 +146,10 @@ struct SybilPhoneShellView: View {
route: route,
onRequestBack: { _ in showSidebarOverlay() },
onRequestNewChat: sidebarWorkspaceNewChatAction,
onShowSidebar: showSidebarOverlay
onShowSidebar: showSidebarOverlay,
onOpenForkedChat: { chatID in
showRoute(.chat(chatID))
}
)
.background(SybilTheme.background)
.blur(radius: SidebarOverlaySwipeMetrics.workspaceBlurRadius(for: sidebarOverlayProgress))
@@ -239,6 +258,7 @@ struct SybilPhoneShellView: View {
}
private func showRoute(_ nextRoute: PhoneRoute) {
dismissSidebarKeyboard()
let update = {
route = nextRoute
}
@@ -268,12 +288,19 @@ struct SybilPhoneShellView: View {
}
private func hideSidebarOverlay() {
dismissSidebarKeyboard()
withAnimation(.easeOut(duration: 0.18)) {
isSidebarOverlayPresented = false
}
resetSidebarSwipe(animated: false)
}
private func dismissSidebarKeyboard() {
if isSidebarOverlayPresented {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
}
private func openSidebarSelection(_ selection: SidebarSelection) {
if openingSelectionRequestID != nil, sidebarHighlightSelection == selection {
return
@@ -511,7 +538,12 @@ private struct SybilPhoneSidebarRoot: View {
},
onSelect: { item in
onSelect(item.selection)
}
},
onOpenForkedChat: { chatID in
onSelect(.chat(chatID))
},
onSelectAgent: { onSelect(.agent) },
isAgentSelected: highlightedSelection == .agent
)
}
.background(SybilTheme.panelGradient)
@@ -585,6 +617,7 @@ private struct SybilPhoneDestinationView: View {
let onRequestBack: (_ animateNavigation: Bool) -> Void
let onRequestNewChat: (() -> Void)?
let onShowSidebar: () -> Void
let onOpenForkedChat: (String) -> Void
var body: some View {
SybilWorkspaceView(
@@ -593,7 +626,8 @@ private struct SybilPhoneDestinationView: View {
navigationLeadingControl: .showSidebar,
onShowSidebar: onShowSidebar,
onRequestBack: onRequestBack,
onRequestNewChat: onRequestNewChat
onRequestNewChat: onRequestNewChat,
onOpenForkedChat: onOpenForkedChat
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.task(id: route) {
@@ -613,6 +647,8 @@ private struct SybilPhoneDestinationView: View {
return
}
viewModel.select(.search(searchID))
case .agent:
viewModel.select(.agent)
case .draftChat:
viewModel.startNewChat()
case .draftSearch:
@@ -0,0 +1,85 @@
import SwiftUI
struct SybilPromptField: View {
var placeholder: String = "Enter Prompt"
@Binding var text: String
@FocusState.Binding var focused: Bool
var selection: Binding<TextSelection?>? = nil
var axis: Axis = .vertical
var submit: () -> Void
var body: some View {
Group {
if let selection {
TextField(placeholder, text: $text, selection: selection, axis: axis)
} else {
TextField(placeholder, text: $text, axis: axis)
}
}
.focused($focused)
.font(.sybil(.body))
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit(submit)
.submitOnMacReturn(submit)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(SybilTheme.composerGradient)
.opacity(0.98)
)
.foregroundStyle(SybilTheme.text)
}
}
struct SybilPromptSendButton: View {
var systemImage: String = "arrow.up"
var canSend: Bool
var submit: () -> Void
var body: some View {
Button(action: submit) {
Image(systemName: systemImage)
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(
canSend
? AnyShapeStyle(SybilTheme.primaryGradient)
: AnyShapeStyle(SybilTheme.surface)
)
)
.foregroundStyle(canSend ? SybilTheme.text : SybilTheme.textMuted)
}
.buttonStyle(.plain)
.disabled(!canSend)
.accessibilityLabel("Send message")
}
}
/// The normal chat attachment control, shared with Agent's composer.
/// Picker ownership and availability stay with each composer.
struct SybilPromptAttachmentButton: View {
var isEnabled: Bool
var action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "paperclip")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(Circle().fill(SybilTheme.surface))
.overlay(Circle().stroke(SybilTheme.border.opacity(0.82), lineWidth: 1))
.foregroundStyle(isEnabled ? SybilTheme.text : SybilTheme.textMuted)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.disabled(!isEnabled)
.accessibilityLabel("Attach file")
}
}
@@ -8,6 +8,7 @@ struct SybilQuickQuestionView: View {
@Environment(\.dismiss) private var dismiss
@FocusState private var promptFocused: Bool
@State private var promptSelection: TextSelection?
private var hasAnswerContent: Bool {
!viewModel.quickQuestionMessages.isEmpty || viewModel.quickQuestionError != nil
@@ -36,6 +37,8 @@ struct SybilQuickQuestionView: View {
return
}
promptFocused = true
let prompt = viewModel.quickQuestionPrompt
promptSelection = TextSelection(range: prompt.startIndex..<prompt.endIndex)
}
}
@@ -59,7 +62,11 @@ struct SybilQuickQuestionView: View {
VStack(alignment: .leading, spacing: 12) {
if hasAnswerContent {
ForEach(viewModel.quickQuestionMessages) { message in
QuickQuestionMessageView(message: message, isSending: viewModel.isQuickQuestionSending)
QuickQuestionMessageView(
message: message,
isSending: viewModel.isQuickQuestionSending,
streamingAssistantPresentation: viewModel.quickQuestionStreamingAssistantPresentation
)
}
if let error = viewModel.quickQuestionError {
@@ -88,46 +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) }
),
axis: .vertical
focused: $promptFocused,
selection: $promptSelection,
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")
}
@@ -219,6 +199,11 @@ struct SybilQuickQuestionView: View {
}
private func submitQuestion() {
guard viewModel.canSendQuickQuestion else {
return
}
promptFocused = false
_ = viewModel.sendQuickQuestion()
}
}
@@ -254,11 +239,11 @@ private struct QuickQuestionPickerPill: View {
private struct QuickQuestionMessageView: View {
var message: Message
var isSending: Bool
var streamingAssistantPresentation: StreamingAssistantPresentation?
private var isPendingAssistant: Bool {
message.id.hasPrefix("temp-assistant-quick-") &&
isSending &&
message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
private var matchingStreamingAssistantPresentation: StreamingAssistantPresentation? {
guard streamingAssistantPresentation?.messageID == message.id else { return nil }
return streamingAssistantPresentation
}
var body: some View {
@@ -267,15 +252,12 @@ private struct QuickQuestionMessageView: View {
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
.fixedSize(horizontal: false, vertical: true)
} else if isPendingAssistant {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
.tint(SybilTheme.primary)
Text("Thinking...")
.font(.caption)
.foregroundStyle(SybilTheme.textMuted)
}
} else if message.id.hasPrefix("temp-assistant-quick-") {
StreamingAssistantContentView(
content: message.content,
isSending: isSending,
presentation: matchingStreamingAssistantPresentation
)
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Markdown(message.content)
.font(.body)
@@ -0,0 +1,35 @@
import SwiftUI
// A vertical TextField treats Return as editing, not form submission. Handle
// unmodified Return explicitly on Mac, leaving Shift+Return and other modified
// keys to the native text field. iOS keeps its software-keyboard behavior.
@MainActor
enum SybilReturnKeySubmission {
static func handle(
phase: KeyPress.Phases,
modifiers: EventModifiers,
submit: () -> Void
) -> KeyPress.Result {
guard modifiers.intersection([.shift, .command, .control, .option]).isEmpty else {
return .ignored
}
if phase == .down {
submit()
}
// Consume autorepeat without submitting or inserting a newline.
return .handled
}
}
extension View {
@ViewBuilder
func submitOnMacReturn(_ submit: @escaping () -> Void) -> some View {
#if targetEnvironment(macCatalyst)
onKeyPress(keys: [.return, KeyEquivalent("\u{3}")], phases: [.down, .repeat]) { press in
SybilReturnKeySubmission.handle(phase: press.phase, modifiers: press.modifiers, submit: submit)
}
#else
self
#endif
}
}
@@ -98,7 +98,8 @@ struct SybilSearchResultsView: View {
.foregroundStyle(SybilTheme.danger)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.frame(maxWidth: SybilLayout.webContentMaxWidth, alignment: .leading)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 14)
.padding(.top, 20 + topContentInset)
.padding(.bottom, 20 + bottomContentInset)
@@ -11,11 +11,13 @@ final class SybilSettingsStore {
static let preferredOpenAIModel = "sybil.ios.preferredOpenAIModel"
static let preferredAnthropicModel = "sybil.ios.preferredAnthropicModel"
static let preferredXAIModel = "sybil.ios.preferredXAIModel"
static let preferredGeminiModel = "sybil.ios.preferredGeminiModel"
static let preferredHermesAgentModel = "sybil.ios.preferredHermesAgentModel"
static let quickQuestionPreferredProvider = "sybil.ios.quickQuestionPreferredProvider"
static let quickQuestionPreferredOpenAIModel = "sybil.ios.quickQuestionPreferredOpenAIModel"
static let quickQuestionPreferredAnthropicModel = "sybil.ios.quickQuestionPreferredAnthropicModel"
static let quickQuestionPreferredXAIModel = "sybil.ios.quickQuestionPreferredXAIModel"
static let quickQuestionPreferredGeminiModel = "sybil.ios.quickQuestionPreferredGeminiModel"
static let quickQuestionPreferredHermesAgentModel = "sybil.ios.quickQuestionPreferredHermesAgentModel"
}
@@ -44,16 +46,18 @@ final class SybilSettingsStore {
.openai: defaults.string(forKey: Keys.preferredOpenAIModel) ?? "gpt-4.1-mini",
.anthropic: defaults.string(forKey: Keys.preferredAnthropicModel) ?? "claude-3-5-sonnet-latest",
.xai: defaults.string(forKey: Keys.preferredXAIModel) ?? "grok-3-mini",
.gemini: defaults.string(forKey: Keys.preferredGeminiModel) ?? "gemini-3.5-flash",
.hermesAgent: defaults.string(forKey: Keys.preferredHermesAgentModel) ?? "hermes-agent"
]
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",
.hermesAgent: defaults.string(forKey: Keys.quickQuestionPreferredHermesAgentModel) ?? preferredModels[.hermesAgent] ?? "hermes-agent"
]
}
@@ -72,12 +76,14 @@ final class SybilSettingsStore {
defaults.set(preferredModelByProvider[.openai], forKey: Keys.preferredOpenAIModel)
defaults.set(preferredModelByProvider[.anthropic], forKey: Keys.preferredAnthropicModel)
defaults.set(preferredModelByProvider[.xai], forKey: Keys.preferredXAIModel)
defaults.set(preferredModelByProvider[.gemini], forKey: Keys.preferredGeminiModel)
defaults.set(preferredModelByProvider[.hermesAgent], forKey: Keys.preferredHermesAgentModel)
defaults.set(quickQuestionPreferredProvider.rawValue, forKey: Keys.quickQuestionPreferredProvider)
defaults.set(quickQuestionPreferredModelByProvider[.openai], forKey: Keys.quickQuestionPreferredOpenAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.anthropic], forKey: Keys.quickQuestionPreferredAnthropicModel)
defaults.set(quickQuestionPreferredModelByProvider[.xai], forKey: Keys.quickQuestionPreferredXAIModel)
defaults.set(quickQuestionPreferredModelByProvider[.gemini], forKey: Keys.quickQuestionPreferredGeminiModel)
defaults.set(quickQuestionPreferredModelByProvider[.hermesAgent], forKey: Keys.quickQuestionPreferredHermesAgentModel)
}
@@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UIKit
struct SybilSettingsView: View {
@Bindable var viewModel: SybilViewModel
@@ -86,6 +87,35 @@ struct SybilSettingsView: View {
.foregroundStyle(SybilTheme.textMuted)
}
VStack(alignment: .leading, spacing: 10) {
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
Text("Notifications include Agent message text and may appear on your lock screen. Reading elsewhere clears older alerts when iOS allows the app to sync.")
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
Text(viewModel.notifications.status)
.font(.sybil(.footnote)).accessibilityIdentifier("agent-push-status")
if viewModel.notifications.pendingRevocations > 0 {
Text("A previous registration is awaiting revocation. It will retry when you reconnect with its original server credentials.")
.font(.sybil(.footnote)).foregroundStyle(SybilTheme.textMuted)
}
if viewModel.notifications.optedIn {
Button("Disable Notifications") { Task { await viewModel.notifications.disable() } }
.accessibilityIdentifier("agent-push-disable")
} else {
Button("Enable Notifications") { Task { await viewModel.notifications.enable() } }
.disabled(!viewModel.notifications.available)
.accessibilityIdentifier("agent-push-enable")
}
if viewModel.notifications.permission == .denied {
Button("Open iOS Notification Settings") {
if let url = URL(string: UIApplication.openNotificationSettingsURLString) { UIApplication.shared.open(url) }
}
}
Button("Refresh Notification Status") { Task { await viewModel.notifications.refresh() } }
.accessibilityIdentifier("agent-push-refresh")
}
.buttonStyle(.bordered)
.tint(SybilTheme.primarySoft)
if let authError = viewModel.authError {
Text(authError)
.font(.sybil(.footnote))
@@ -1,5 +1,6 @@
import Observation
import SwiftUI
import UIKit
struct SybilSidebarView: View {
@Bindable var viewModel: SybilViewModel
@@ -111,58 +112,238 @@ 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 {
renameTarget != nil
} set: { isPresented in
if !isPresented {
renameTarget = nil
renameTitle = ""
}
}
}
var body: some View {
if viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text("Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} else if viewModel.sidebarItems.isEmpty {
VStack(spacing: 10) {
Image(systemName: "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
Text("Start a chat or run your first search.")
.font(.sybil(.footnote))
.multilineTextAlignment(.center)
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(16)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 8) {
ForEach(viewModel.sidebarItems) { item in
Button {
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item))
}
.buttonStyle(.plain)
.contextMenu {
Button(role: .destructive) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
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")
}
}
.padding(10)
.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))
}
.refreshable {
await viewModel.refreshVisibleContent(
refreshCollections: true,
refreshSelection: false
)
.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.isSearchingSidebar ||
(!viewModel.isSidebarSearchActive && viewModel.isLoadingCollections && viewModel.sidebarItems.isEmpty) {
VStack(alignment: .leading, spacing: 8) {
ProgressView()
.tint(SybilTheme.primary)
Text(viewModel.isSidebarSearchActive ? "Searching history…" : "Loading conversations…")
.font(.sybil(.footnote))
.foregroundStyle(SybilTheme.textMuted)
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(16)
} 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: viewModel.isSidebarSearchActive ? "magnifyingglass" : "message.badge")
.font(.system(size: 20, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
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.visibleSidebarItems) { item in
Button {
isSearchFocused = false
onSelect(item)
} label: {
SybilSidebarRow(item: item, isSelected: isSelected(item), searchQuery: viewModel.sidebarSearchQuery)
}
.buttonStyle(.plain)
.contextMenu {
Button {
Task {
await viewModel.setItemStarred(item.selection, starred: !item.starred)
}
} label: {
Label(item.starred ? "Unstar" : "Star", systemImage: item.starred ? "star.slash" : "star")
}
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) {
Task {
await viewModel.deleteItem(item.selection)
}
} label: {
Label("Delete", systemImage: "trash")
}
}
}
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()
}
}
}
.alert("Rename Chat", isPresented: isRenameAlertPresented) {
TextField("Title", text: $renameTitle)
Button("Cancel", role: .cancel) {
renameTarget = nil
renameTitle = ""
}
Button("Save") {
let target = renameTarget
let title = renameTitle
renameTarget = nil
renameTitle = ""
if let target, case let .chat(chatID) = target.selection {
Task {
await viewModel.renameChat(chatID: chatID, title: title)
}
}
}
.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))
}
}
}
@@ -171,6 +352,25 @@ struct SybilSidebarItemList: View {
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
@@ -204,6 +404,12 @@ struct SybilSidebarRow: View {
.lineLimit(1)
.layoutPriority(1)
if item.starred {
Image(systemName: "star.fill")
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(.yellow)
}
Spacer(minLength: 8)
if item.isRunning {
@@ -226,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)
@@ -64,6 +64,10 @@ extension Font {
}
}
enum SybilLayout {
static let webContentMaxWidth: CGFloat = 896
}
enum SybilTheme {
static let background = Color(red: 0.02, green: 0.02, blue: 0.05)
static let surface = Color(red: 0.05, green: 0.04, blue: 0.10)
@@ -78,6 +82,7 @@ enum SybilTheme {
static let searchCard = Color(red: 0.07, green: 0.06, blue: 0.14)
static let userBubble = Color(red: 0.29, green: 0.13, blue: 0.65)
static let danger = Color(red: 0.96, green: 0.32, blue: 0.40)
static let warning = Color(red: 0.95, green: 0.69, blue: 0.25)
@MainActor static func applySystemAppearance() {
let navAppearance = UINavigationBarAppearance()
@@ -178,8 +183,19 @@ enum SybilTheme {
static var toolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.01, green: 0.15, blue: 0.17).opacity(0.70),
Color(red: 0.03, green: 0.09, blue: 0.15).opacity(0.78)
Color(red: 0.01, green: 0.15, blue: 0.17),
Color(red: 0.03, green: 0.09, blue: 0.15)
],
startPoint: .leading,
endPoint: .trailing
)
}
static var runningToolCallGradient: LinearGradient {
LinearGradient(
colors: [
Color(red: 0.30, green: 0.19, blue: 0.04),
Color(red: 0.09, green: 0.05, blue: 0.17)
],
startPoint: .leading,
endPoint: .trailing
@@ -189,8 +205,8 @@ enum SybilTheme {
static var failedToolCallGradient: LinearGradient {
LinearGradient(
colors: [
danger.opacity(0.18),
Color(red: 0.15, green: 0.03, blue: 0.07).opacity(0.72)
Color(red: 0.27, green: 0.04, blue: 0.10),
Color(red: 0.15, green: 0.03, blue: 0.07)
],
startPoint: .leading,
endPoint: .trailing
File diff suppressed because it is too large Load Diff
@@ -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
@@ -26,10 +26,6 @@ struct SybilWorkspaceView: View {
@State private var isShowingPhotoPicker = false
@State private var photoPickerItems: [PhotosPickerItem] = []
@State private var isComposerDropTargeted = false
@State private var transcriptTailSpacerHeight = SybilTranscriptTailSpacer.minimumHeight
@State private var transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
@State private var transcriptViewportHeight: CGFloat = 0
@State private var pendingAssistantBaselineHeight: CGFloat?
@State private var newChatSwipeOffset: CGFloat = 0
@State private var newChatSwipeCompletionOffset: CGFloat = 0
@State private var newChatSwipeContainerWidth: CGFloat = NewChatSwipeMetrics.referenceWidth
@@ -42,10 +38,6 @@ struct SybilWorkspaceView: View {
private let customWorkspaceNavigationContentInset: CGFloat = 96
private let composerOverlayContentInset: CGFloat = 112
private var visibleTranscriptTailSpacerHeight: CGFloat {
viewModel.showsComposer && !viewModel.isSearchMode ? transcriptTailSpacerHeight : 0
}
private var isSettingsSelected: Bool {
if case .settings = viewModel.selectedItem {
return true
@@ -153,17 +145,6 @@ struct SybilWorkspaceView: View {
}
resetNewChatSwipe(animated: false)
}
.onChange(of: transcriptScrollContextID) { _, _ in
handleTranscriptContextChange()
}
.onChange(of: viewModel.isSendingVisibleChat) { wasSending, isSending in
handleVisibleChatSendingChange(wasSending: wasSending, isSending: isSending)
}
.onChange(of: viewModel.errorMessage) { _, message in
if message != nil && !viewModel.isSendingVisibleChat {
resetTranscriptTailSpacer(animated: true)
}
}
.task(id: composerFocusPolicyID) {
await applyComposerFocusPolicy()
}
@@ -174,8 +155,6 @@ struct SybilWorkspaceView: View {
workspaceContentStack
if showsCustomWorkspaceNavigation {
SybilWorkspaceCharacterBackdrop(isBusy: viewModel.isActiveSelectionSending)
.allowsHitTesting(false)
customWorkspaceNavigationBar
}
}
@@ -194,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,
@@ -212,15 +193,12 @@ struct SybilWorkspaceView: View {
messages: viewModel.displayedMessages,
isLoading: viewModel.isLoadingSelection,
isSending: viewModel.isSendingVisibleChat,
streamingAssistantPresentation: viewModel.displayedStreamingAssistantPresentation,
topContentInset: showsCustomWorkspaceNavigation ? customWorkspaceNavigationContentInset : 0,
bottomContentInset: viewModel.showsComposer ? composerOverlayContentInset : 0,
tailSpacerHeight: visibleTranscriptTailSpacerHeight,
onViewportHeightChange: { height in
handleTranscriptViewportHeightChange(height)
},
onPendingAssistantHeightChange: { height in
handlePendingAssistantHeightChange(height)
}
bottomPinRequestID: viewModel.chatBottomPinRequestID,
isForking: viewModel.isForkingChat,
onForkResponse: forkResponse
)
.id(transcriptScrollContextID)
}
@@ -254,17 +232,33 @@ 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
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
customWorkspaceNavigationTitle
workspaceNavigationTrailingControl
}
@@ -277,6 +271,32 @@ struct SybilWorkspaceView: View {
}
}
private var selectedProviderModelSubtitle: String {
let selectedModel = viewModel.model.trimmingCharacters(in: .whitespacesAndNewlines)
guard !selectedModel.isEmpty else {
return viewModel.provider.displayName
}
return "\(viewModel.provider.displayName) • \(selectedModel)"
}
private var customWorkspaceNavigationTitle: some View {
VStack(alignment: .leading, spacing: 2) {
Text(viewModel.selectedTitle)
.font(.sybil(size: 16, weight: .semibold))
.foregroundStyle(SybilTheme.text)
.lineLimit(1)
.minimumScaleFactor(0.78)
Text(viewModel.selectedItem == .agent ? viewModel.agent.connection : selectedProviderModelSubtitle)
.font(.sybil(size: 10, weight: .medium))
.foregroundStyle(SybilTheme.textMuted)
.lineLimit(1)
.minimumScaleFactor(0.82)
}
.frame(maxWidth: .infinity, alignment: .leading)
.multilineTextAlignment(.leading)
}
@ViewBuilder
private var workspaceNavigationLeadingControl: some View {
switch navigationLeadingControl {
@@ -311,86 +331,6 @@ struct SybilWorkspaceView: View {
}
}
private func handleTranscriptContextChange() {
resetTranscriptTailSpacer(animated: false)
}
private func handleVisibleChatSendingChange(wasSending: Bool, isSending: Bool) {
guard !viewModel.isSearchMode else {
resetTranscriptTailSpacer(animated: true)
return
}
if isSending {
prepareTranscriptTailSpacerForReply(animated: false)
return
}
if wasSending {
if viewModel.errorMessage != nil {
resetTranscriptTailSpacer(animated: true)
}
pendingAssistantBaselineHeight = nil
}
}
private func handleTranscriptViewportHeightChange(_ height: CGFloat) {
transcriptViewportHeight = height
if viewModel.isSendingVisibleChat,
transcriptTailSpacerTargetHeight <= SybilTranscriptTailSpacer.minimumHeight {
prepareTranscriptTailSpacerForReply(animated: false)
}
}
private func handlePendingAssistantHeightChange(_ height: CGFloat) {
guard viewModel.isSendingVisibleChat, !viewModel.isSearchMode, height > 0 else {
return
}
if pendingAssistantBaselineHeight == nil {
pendingAssistantBaselineHeight = height
}
let measuredHeight = SybilTranscriptTailSpacer.placeholderHeight(
targetHeight: transcriptTailSpacerTargetHeight,
baselineAssistantHeight: pendingAssistantBaselineHeight ?? height,
currentAssistantHeight: height
)
let nextHeight = min(transcriptTailSpacerHeight, measuredHeight)
setTranscriptTailSpacer(nextHeight, animated: false)
}
private func prepareTranscriptTailSpacerForReply(animated: Bool) {
let targetHeight = SybilTranscriptTailSpacer.replyBufferHeight(for: transcriptViewportHeight)
transcriptTailSpacerTargetHeight = targetHeight
pendingAssistantBaselineHeight = nil
setTranscriptTailSpacer(targetHeight, animated: animated)
}
private func resetTranscriptTailSpacer(animated: Bool) {
transcriptTailSpacerTargetHeight = SybilTranscriptTailSpacer.minimumHeight
pendingAssistantBaselineHeight = nil
setTranscriptTailSpacer(SybilTranscriptTailSpacer.minimumHeight, animated: animated)
}
private func setTranscriptTailSpacer(_ height: CGFloat, animated: Bool) {
let nextHeight = SybilTranscriptTailSpacer.clampedHeight(height)
guard abs(nextHeight - transcriptTailSpacerHeight) >= 0.5 else {
return
}
let update = {
transcriptTailSpacerHeight = nextHeight
}
if animated {
withAnimation(.easeOut(duration: 0.22), update)
} else {
update()
}
}
private func beginNewChatSwipe(containerWidth: CGFloat) {
let update = {
newChatSwipeContainerWidth = max(containerWidth, 1)
@@ -569,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
@@ -652,67 +592,25 @@ struct SybilWorkspaceView: View {
HStack(alignment: .bottom, spacing: 10) {
if !viewModel.isSearchMode {
Button {
SybilPromptAttachmentButton(isEnabled: !viewModel.isActiveSelectionSending) {
isShowingAttachmentOptions = true
} label: {
Image(systemName: "paperclip")
.font(.system(size: 17, weight: .semibold))
.frame(width: 40, height: 40)
.background(
Circle()
.fill(SybilTheme.surface)
)
.overlay(
Circle()
.stroke(SybilTheme.border.opacity(0.82), lineWidth: 1)
)
.foregroundStyle(viewModel.isActiveSelectionSending ? SybilTheme.textMuted : SybilTheme.text)
}
.buttonStyle(.plain)
.disabled(viewModel.isActiveSelectionSending)
.accessibilityLabel("Attach file")
.accessibilityIdentifier("chat-attach")
}
TextField(
viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
SybilPromptField(
placeholder: viewModel.isSearchMode ? "Search the web" : "Enter Prompt",
text: $viewModel.composer,
axis: .vertical
focused: $composerFocused,
submit: submitComposer
)
.focused($composerFocused)
.textInputAutocapitalization(.sentences)
.autocorrectionDisabled(false)
.lineLimit(1 ... 6)
.submitLabel(.send)
.onSubmit {
submitComposer()
}
.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)
@@ -808,14 +706,8 @@ struct SybilWorkspaceView: View {
return
}
if !viewModel.isSearchMode {
prepareTranscriptTailSpacerForReply(animated: false)
}
#if !targetEnvironment(macCatalyst)
if !viewModel.isSearchMode {
composerFocused = false
}
composerFocused = false
#endif
Task {
@@ -881,37 +773,6 @@ struct SybilWorkspaceView: View {
}
}
enum SybilTranscriptTailSpacer {
static let minimumHeight: CGFloat = 20
static let replyBufferMin: CGFloat = 288
static let replyBufferMax: CGFloat = 576
static let replyBufferViewportRatio: CGFloat = 0.52
static func replyBufferHeight(for viewportHeight: CGFloat) -> CGFloat {
guard viewportHeight > 0 else {
return replyBufferMin
}
return min(
replyBufferMax,
max(replyBufferMin, (viewportHeight * replyBufferViewportRatio).rounded())
)
}
static func clampedHeight(_ height: CGFloat) -> CGFloat {
max(minimumHeight, height.rounded(.up))
}
static func placeholderHeight(
targetHeight: CGFloat,
baselineAssistantHeight: CGFloat,
currentAssistantHeight: CGFloat
) -> CGFloat {
let consumedHeight = max(currentAssistantHeight - baselineAssistantHeight, 0)
return clampedHeight(targetHeight - consumedHeight)
}
}
enum NewChatSwipeMetrics {
static let referenceWidth: CGFloat = 390
static let horizontalActivationDistance: CGFloat = 18
@@ -1470,173 +1331,6 @@ private struct SybilNavigationFadeBackground: View {
}
}
private struct SybilWorkspaceCharacterBackdrop: View {
var isBusy: Bool
var body: some View {
ZStack(alignment: .topTrailing) {
RadialGradient(
colors: [
SybilTheme.primary.opacity(0.36),
SybilTheme.primary.opacity(0.13),
Color.clear
],
center: .center,
startRadius: 10,
endRadius: 150
)
.frame(width: 136, height: 118)
.blur(radius: 7)
.offset(x: 28, y: -24)
SybilAnimatedGIFView(resourceName: isBusy ? "character-busy" : "character-idle")
.frame(width: 172, height: 172)
.opacity(0.92)
.mask {
LinearGradient(
colors: [
Color.black,
Color.black,
Color.black.opacity(0)
],
startPoint: .top,
endPoint: .bottom
)
}
.mask {
LinearGradient(
colors: [
Color.clear,
Color.black.opacity(0.98),
Color.black
],
startPoint: .leading,
endPoint: .trailing
)
}
.offset(x: 8, y: 4)
}
.frame(maxWidth: .infinity, minHeight: 156, maxHeight: 156, alignment: .topTrailing)
.clipped()
.ignoresSafeArea(edges: .top)
.accessibilityHidden(true)
}
}
private struct SybilAnimatedGIFView: UIViewRepresentable {
var resourceName: String
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeUIView(context: Context) -> UIImageView {
let imageView = SybilAnimatedUIImageView()
imageView.backgroundColor = .clear
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = false
imageView.isOpaque = false
imageView.setContentHuggingPriority(.defaultLow, for: .horizontal)
imageView.setContentHuggingPriority(.defaultLow, for: .vertical)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
return imageView
}
func updateUIView(_ imageView: UIImageView, context: Context) {
guard context.coordinator.resourceName != resourceName else {
if !imageView.isAnimating {
imageView.startAnimating()
}
return
}
context.coordinator.resourceName = resourceName
imageView.image = SybilAnimatedGIFCache.image(named: resourceName)
imageView.startAnimating()
}
final class Coordinator {
var resourceName: String?
}
}
private final class SybilAnimatedUIImageView: UIImageView {
override var intrinsicContentSize: CGSize {
CGSize(width: UIView.noIntrinsicMetric, height: UIView.noIntrinsicMetric)
}
}
@MainActor
private enum SybilAnimatedGIFCache {
private static var images: [String: UIImage] = [:]
static func image(named name: String) -> UIImage? {
if let cached = images[name] {
return cached
}
guard let image = loadImage(named: name) else {
return nil
}
images[name] = image
return image
}
private static func loadImage(named name: String) -> UIImage? {
let url = Bundle.main.url(forResource: name, withExtension: "gif", subdirectory: "Character") ??
Bundle.main.url(forResource: name, withExtension: "gif")
guard let url,
let data = try? Data(contentsOf: url),
let source = CGImageSourceCreateWithData(data as CFData, nil)
else {
return nil
}
let frameCount = CGImageSourceGetCount(source)
guard frameCount > 0 else {
return nil
}
var frames: [UIImage] = []
frames.reserveCapacity(frameCount)
var duration: TimeInterval = 0
for index in 0 ..< frameCount {
guard let cgImage = CGImageSourceCreateImageAtIndex(source, index, nil) else {
continue
}
frames.append(UIImage(cgImage: cgImage))
duration += frameDuration(at: index, source: source)
}
guard !frames.isEmpty else {
return nil
}
if frames.count == 1 {
return frames[0]
}
return UIImage.animatedImage(with: frames, duration: max(duration, 0.1))
}
private static func frameDuration(at index: Int, source: CGImageSource) -> TimeInterval {
guard let properties = CGImageSourceCopyPropertiesAtIndex(source, index, nil) as? [CFString: Any],
let gifProperties = properties[kCGImagePropertyGIFDictionary] as? [CFString: Any]
else {
return 0.08
}
let unclampedDelay = gifProperties[kCGImagePropertyGIFUnclampedDelayTime] as? TimeInterval
let delay = unclampedDelay ?? gifProperties[kCGImagePropertyGIFDelayTime] as? TimeInterval ?? 0.08
return max(delay, 0.03)
}
}
private struct NewChatSwipeBackdrop: View {
var progress: CGFloat
var hasLatched: Bool
@@ -0,0 +1,545 @@
import CryptoKit
import Foundation
import ImageIO
import Testing
import UIKit
import UniformTypeIdentifiers
@testable import Sybil
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg", "image/gif", "image/webp"],
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = originalBytes, name: String = "original.bin") -> AgentAttachment {
AgentAttachment(id: id, kind: .file, filename: name, mimeType: "application/octet-stream", sizeBytes: data.count,
sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), workspacePath: "/runtime/provided/\(name)")
}
private func descriptorData(capability: Bool = true) throws -> Data {
var object: [String: Any] = ["id": "root", "title": "Sybil", "type": "persistent-agent", "model": "fixture", "protocol": "pi-room.v1", "websocket": "/v1/agent/ws",
"activity": ["state": "idle", "isThinking": false, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": []]]
if capability { object["capabilities"] = ["attachments": try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)), "future": true] }
object["unknown"] = ["additive": true]
return try JSONSerialization.data(withJSONObject: object)
}
private final class AttachmentTestStore: @unchecked Sendable {
private let lock = NSLock()
private var rows: [AgentOutboxRow] = []
var storage: AgentOutboxStorage { .init(read: { self.read() }, write: { value in self.lock.withLock { self.rows = value } }) }
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
}
private actor AttachmentTestClient: AgentAPIClienting {
var uploads: [String] = []
var submissions: [AgentSubmission] = []
var failUploads = 0
var badReceipt = false
var uploadDelay = Duration.milliseconds(20)
var holdDownloads = false
var downloadWaiters: [String: CheckedContinuation<Data, Never>] = [:]
var downloadStarts: [String] = []
var downloadActive = 0
var downloadPeak = 0
var beforeSubmit: (@Sendable () -> Bool)?
var unavailableDescriptor = false
var unavailableSocket = false
func agentDescriptor() throws -> AgentDescriptor {
if unavailableDescriptor { throw AgentAttachmentIssue(message: "Synthetic open proxy requires authentication", code: "attachments_unavailable", statusCode: 503) }
return try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
}
func agentMessages(after: String?, before: String?) -> AgentPage { .init(conversationId: "root", messages: [], cursor: after ?? "0", hasMore: false) }
func agentReadState() -> AgentReadState { .init(cursor: "0") }
func agentMarkRead(cursor: String) -> AgentReadState { .init(cursor: cursor) }
func agentSubmit(content: String, requestID: String) -> AgentReceipt { .init(id: 1, status: "queued", messageId: "inbound:sybil:\(requestID)") }
func agentSubmit(_ submission: AgentSubmission) throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append(submission)
return .init(id: 1, status: "queued", messageId: "inbound:sybil:\(submission.requestId)", attachmentIds: badReceipt ? nil : submission.attachmentIDs)
}
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
uploads.append(uploadID)
try await Task.sleep(for: uploadDelay)
if failUploads > 0 { failUploads -= 1; throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507, details: ["futureQuotaField": .number(1)]) }
return AgentAttachment(id: "server-\(uploadID)", kind: .file, filename: file.filename, mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
}
func agentDownload(_ attachment: AgentAttachment) async -> Data {
downloadStarts.append(attachment.id); downloadActive += 1; downloadPeak = max(downloadPeak, downloadActive)
defer { downloadActive -= 1 }
if holdDownloads { return await withCheckedContinuation { downloadWaiters[attachment.id] = $0 } }
return originalBytes
}
func pauseDownloads() { holdDownloads = true }
func releaseDownload(_ id: String) { downloadWaiters.removeValue(forKey: id)?.resume(returning: originalBytes) }
func downloadCounts() -> (Int, Int) { (downloadStarts.count, downloadPeak) }
func downloadedIDs() -> [String] { downloadStarts }
func agentSocket(after: String) throws -> any AgentSocket {
if unavailableSocket { return AttachmentUnavailableSocket() }
throw AgentFailure(message: "No socket in unit fixture")
}
func blockDescriptor() { unavailableDescriptor = true }
func blockSocket() { unavailableSocket = true }
func failNextUpload(delay: Duration = .milliseconds(20)) { failUploads = 1; uploadDelay = delay }
func delayUploads(_ delay: Duration) { uploadDelay = delay }
func malformedReceipts(_ value: Bool) { badReceipt = value }
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
func uploadIDs() -> [String] { uploads }
func sent() -> [AgentSubmission] { submissions }
}
private struct AttachmentUnavailableSocket: AgentSocket {
func receive() throws -> Data { throw AgentFailure(message: "Synthetic closed socket") }
func send(_ text: String) {}
func close() {}
func closeCode() -> Int { 4503 }
}
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
private final class AttachmentURLProtocol: URLProtocol, @unchecked Sendable {
private static let lock = NSLock()
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, [String: String], Data))?
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, [String: String], Data)) { lock.withLock { handler = value } }
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let handler = Self.lock.withLock { Self.handler! }
let (status, headers, data) = try handler(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: headers)!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data)
client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
private func attachmentSession() -> URLSession {
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [AttachmentURLProtocol.self]
return URLSession(configuration: config)
}
private func requestBytes(_ request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open(); defer { stream.close() }
var data = Data(), buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; data.append(contentsOf: buffer.prefix(count)) }
return data
}
@Suite(.serialized) struct AgentAttachmentTests {
@Test func additiveCapabilityAndDistinctMetadata() throws {
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
#expect(old.capabilities == nil)
let current = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData())
#expect(current.capabilities?.attachments?.supported == true)
var future = try JSONSerialization.jsonObject(with: descriptorData()) as! [String: Any]
future["capabilities"] = ["attachments": ["version": 9, "newWire": true]]
let unsupported = try AgentCoding.decoder().decode(AgentDescriptor.self, from: JSONSerialization.data(withJSONObject: future))
#expect(unsupported.capabilities?.attachments == nil)
var other = attachmentLimits; other.version = 2
#expect(!other.supported)
let attachment = sampleAttachment()
let message = Message(id: "user", createdAt: Date(), role: .user, content: "caption", metadata: .object(["agentAttachments": .array([attachment.metadataValue]), "future": .bool(true)]))
#expect(message.attachments.isEmpty)
#expect(message.agentAttachments == [attachment])
#expect(message.content == "caption")
let legacy = Data(#"{"id":"x","kind":"file","filename":"x.bin","mimeType":"application/octet-stream","sizeBytes":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","unknown":true}"#.utf8)
#expect(try JSONDecoder().decode(AgentAttachment.self, from: legacy).workspacePath == nil)
}
@Test func safeNamesAndExactOriginalBytes() throws {
#expect(try AgentAttachmentPreparation.filename("cafe\u{301}.txt") == "café.txt")
for invalid in ["", ".", "..", "../secret", "x/y", "x\\y", "a\u{0000}b", "x\ny", String(repeating: "é", count: 128)] {
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.filename(invalid) }
}
let file = try AgentAttachmentPreparation.prepare(originalBytes, filename: "archive.zip", mimeType: "application/zip", limits: attachmentLimits)
#expect(file.data == originalBytes)
let text = try AgentAttachmentPreparation.prepare(originalBytes, filename: "invalid-utf8.txt", mimeType: "text/plain", limits: attachmentLimits)
#expect(text.data == originalBytes) // Server classifies as file; client never decodes/truncates upload bytes.
#expect(try AgentAttachmentPreparation.prepare(Data(), filename: "empty.txt", mimeType: "text/plain", limits: attachmentLimits).data.isEmpty)
var small = attachmentLimits; small.maxFileBytes = 2
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.prepare(originalBytes, filename: "x.bin", mimeType: "application/octet-stream", limits: small) }
}
@Test @MainActor func rasterValidationAndFileSelection() throws {
let image = UIGraphicsImageRenderer(size: CGSize(width: 12, height: 8)).image { context in UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 12, height: 8)) }
let png = try #require(image.pngData())
#expect(try AgentAttachmentPreparation.prepare(png, filename: "red.png", mimeType: "image/png", limits: attachmentLimits).data == png)
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/jpeg") }
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(png, mime: "image/png", pixels: 20) }
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.raster(Data("<svg/>".utf8), mime: "image/svg+xml") }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("sample.pdf"); try originalBytes.write(to: url)
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
#expect(file.data == originalBytes); #expect(file.mimeType == "application/pdf")
}
@Test @MainActor func syntheticHEICPhotoConvertsButFileKeepsOriginal() async throws {
let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1800)).image { context in
UIColor.blue.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1800))
}
let encoded = NSMutableData()
let destination = try #require(CGImageDestinationCreateWithData(encoded, UTType.heic.identifier as CFString, 1, nil), "Simulator must provide an ImageIO HEIC encoder for this regression")
CGImageDestinationAddImage(destination, try #require(image.cgImage), nil)
#expect(CGImageDestinationFinalize(destination), "Synthetic HEIC encoding must succeed")
let data = encoded as Data
let prepared = try await AgentAttachmentPreparation.offMain {
try AgentAttachmentPreparation.photo(data, filename: "cafe\u{301}.HEIC", mimeType: "image/heic", limits: attachmentLimits)
}
#expect(prepared.filename == "café.jpg"); #expect(prepared.mimeType == "image/jpeg")
#expect(prepared.data.count <= attachmentLimits.imageLimit)
let source = try #require(CGImageSourceCreateWithData(prepared.data as CFData, nil))
#expect(CGImageSourceGetType(source) as String? == UTType.jpeg.identifier)
let properties = try #require(CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
#expect((properties[kCGImagePropertyPixelWidth] as? Int ?? Int.max) <= 2000)
#expect((properties[kCGImagePropertyPixelHeight] as? Int ?? Int.max) <= 2000)
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: root) }
let url = root.appendingPathComponent("original.heic"); try data.write(to: url)
let file = try AgentAttachmentPreparation.file(url, limits: attachmentLimits)
#expect(file.data == data); #expect(file.filename == "original.heic"); #expect(file.mimeType == "image/heic")
var tooSmall = attachmentLimits; tooSmall.maxImagePixels = 100
#expect(throws: AgentAttachmentIssue.self) { try AgentAttachmentPreparation.photo(data, filename: "large.heic", mimeType: "image/heic", limits: tooSmall) }
}
@Test @MainActor func downloadCoordinatorBoundsAndCancelsQueuedTransfers() async throws {
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
await client.pauseDownloads()
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
let three = Task { try await downloads.data(for: sampleAttachment("three"), client: client) }
let cancelled = Task { try await downloads.data(for: sampleAttachment("cancelled"), client: client) }
try await Task.sleep(for: .milliseconds(30)); cancelled.cancel()
await #expect(throws: CancellationError.self) { try await cancelled.value }
#expect(await client.downloadCounts().0 == 2)
await client.releaseDownload("one"); _ = try await one.value
for _ in 0..<100 { if await client.downloadCounts().0 == 3 { break }; try await Task.sleep(for: .milliseconds(10)) }
#expect(await client.downloadCounts().1 == 2)
#expect(await client.downloadedIDs().contains("cancelled") == false)
await client.releaseDownload("two"); await client.releaseDownload("three")
_ = try await two.value; _ = try await three.value
#expect(try await downloads.data(for: sampleAttachment("one"), client: client) == originalBytes)
#expect(await client.downloadCounts().0 == 3) // Verified cache hit.
}
@Test @MainActor func configurationResetRejectsOldDownloadsAndCache() async throws {
let old = AttachmentTestClient(), next = AttachmentTestClient(), room = SybilAgentRoom()
room.configure(client: old, storage: AttachmentTestStore().storage)
defer { room.stop() }
await old.pauseDownloads()
let stale = Task { try await room.downloadAttachment(sampleAttachment()) }
for _ in 0..<100 { if await old.downloadCounts().0 == 1 { break }; try await Task.sleep(for: .milliseconds(10)) }
room.configure(client: next, storage: AttachmentTestStore().storage)
await old.releaseDownload(sampleAttachment().id)
await #expect(throws: CancellationError.self) { try await stale.value }
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
#expect(await next.downloadCounts().0 == 1)
#expect(try await room.downloadAttachment(sampleAttachment()) == originalBytes)
#expect(await next.downloadCounts().0 == 1)
}
@Test @MainActor func invalidatedDownloadQueueNeverStartsOrCaches() async throws {
let client = AttachmentTestClient(), downloads = AgentAttachmentDownloads()
await client.pauseDownloads()
let one = Task { try await downloads.data(for: sampleAttachment("one"), client: client) }
let two = Task { try await downloads.data(for: sampleAttachment("two"), client: client) }
for _ in 0..<100 { if await client.downloadCounts().0 == 2 { break }; try await Task.sleep(for: .milliseconds(10)) }
let queued = Task { try await downloads.data(for: sampleAttachment("queued"), client: client) }
try await Task.sleep(for: .milliseconds(20))
await downloads.invalidate()
await #expect(throws: CancellationError.self) { try await queued.value }
await client.releaseDownload("one"); await client.releaseDownload("two")
await #expect(throws: CancellationError.self) { try await one.value }
await #expect(throws: CancellationError.self) { try await two.value }
await #expect(throws: CancellationError.self) { try await downloads.data(for: sampleAttachment("one"), client: client) }
#expect(await client.downloadCounts().0 == 2)
}
@Test func orderedReferenceAndReceiptValidation() throws {
let a = sampleAttachment("one"), b = sampleAttachment("two")
let request = try AgentSubmission(content: " ", requestID: "stable", attachments: [a, b])
let fields = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(request)) as? [String: Any])
#expect((fields["attachments"] as? [[String: String]]) == [["id": "one"], ["id": "two"]])
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable") }
#expect(throws: AgentAttachmentIssue.self) { try AgentSubmission(content: "", requestID: "stable", attachments: [a, a]) }
for ids: [String]? in [nil, [], ["one"], ["two", "one"], ["one", "two", "extra"]] {
let receipt = AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ids)
#expect(throws: AgentAttachmentIssue.self) { try receipt.validate(requestID: "stable", attachmentIDs: ["one", "two"]) }
}
try AgentReceipt(id: 1, status: "queued", messageId: "inbound:sybil:stable", attachmentIds: ["one", "two"]).validate(requestID: "stable", attachmentIDs: request.attachmentIDs)
}
@Test func multipartPreservesBinaryAndBoundary() throws {
let id = UUID().uuidString
let file = AgentPreparedAttachment(filename: "café \"original\".bin", mimeType: "application/octet-stream", data: originalBytes)
let multipart = try AgentMultipart(file: file, uploadID: id)
let prefix = Data("\r\n\r\n".utf8)
#expect(multipart.body.range(of: originalBytes) != nil)
let printable = String(decoding: multipart.body, as: UTF8.self)
#expect(printable.components(separatedBy: "Content-Disposition:").count == 3)
#expect(printable.contains("name=\"uploadId\"\r\n\r\n\(id)"))
#expect(printable.contains("filename=\"café \\\"original\\\".bin\""))
#expect(multipart.body.suffix(Data("\r\n--\(multipart.boundary)--\r\n".utf8).count) == Data("\r\n--\(multipart.boundary)--\r\n".utf8))
#expect(multipart.body.range(of: prefix) != nil)
}
@Test func uploadTransportAuthBytesAndReceipt() async throws {
let file = AgentPreparedAttachment(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), id = UUID().uuidString
AttachmentURLProtocol.install { request in
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
#expect(request.url?.query == nil)
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
#expect(request.url!.path == "/api/v1/agent/attachments"); #expect(request.httpMethod == "POST")
let contentType = try #require(request.value(forHTTPHeaderField: "Content-Type"))
let boundary = try #require(contentType.components(separatedBy: "boundary=").last)
let bytes = requestBytes(request)
#expect(bytes.starts(with: Data("--\(boundary)\r\n".utf8)))
#expect(bytes.range(of: originalBytes) != nil); #expect(bytes.range(of: Data(id.utf8)) != nil)
return (201, [:], try JSONEncoder().encode(["attachment": sampleAttachment()]))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
#expect(try await client.agentUpload(file: file, uploadID: id) == sampleAttachment())
}
@Test func missingCapabilityNeverUploadsOrSubmitsReferences() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/api/v1/agent")
return (200, [:], try descriptorData(capability: false))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "stable", attachments: [sampleAttachment()])) }
}
@Test func openPublicAPIDisablesAttachmentCapabilityAndDownloads() async throws {
AttachmentURLProtocol.install { request in
#expect(request.url!.path == "/v1/agent")
return (200, [:], try descriptorData())
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!), session: session)
#expect(try await client.agentDescriptor().capabilities?.attachments == nil)
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(sampleAttachment()) }
}
@Test func quotaCapabilitySnapshotAndDotFilenameAreAdditive() throws {
var fields = try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)) as! [String: Any]
fields.merge(["quotaBytes": 1073741824, "usedBytes": 20, "remainingBytes": 1073741804, "maxConcurrentUploads": 2, "maxConcurrentDecodes": 1, "future": true]) { _, next in next }
let limits = try JSONDecoder().decode(AgentAttachmentCapabilities.self, from: JSONSerialization.data(withJSONObject: fields))
#expect(limits.supported); #expect(limits.remainingBytes == 1073741804)
#expect(limits.maxConcurrentUploads == 2); #expect(limits.maxConcurrentDecodes == 1)
var attachment = sampleAttachment(name: ".notes.txt")
attachment.workspacePath = "/data/workspace/uploads/server-id/_notes.txt"
try attachment.validate()
#expect(attachment.filename == ".notes.txt")
#expect(attachment.workspacePath?.hasSuffix("/_notes.txt") == true)
}
@Test func downloadAuthOpaqueIDExactBytesAndLimits() async throws {
let attachment = sampleAttachment("opaque/part?value#x")
AttachmentURLProtocol.install { request in
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/opaque%2Fpart%3Fvalue%23x/content")
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
return (200, ["Content-Length": String(originalBytes.count)], originalBytes)
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api/")!, authToken: "synthetic-test"), session: session)
#expect(try await client.agentDownload(attachment) == originalBytes)
AttachmentURLProtocol.install { _ in (200, [:], originalBytes + Data([9])) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 1, count: originalBytes.count)) }
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
}
@Test func errorCodesAndAdditiveQuotaFieldsSurvive() async throws {
for status in [409, 413, 422, 429, 507, 503] {
AttachmentURLProtocol.install { request in
if request.url!.path == "/api/v1/agent" { return (200, [:], try descriptorData()) }
return (status, status == 429 ? ["Retry-After": "2"] : [:], try JSONSerialization.data(withJSONObject: ["error": true, "message": "Actionable storage message", "statusCode": status, "code": status == 507 ? "attachment_quota_exceeded" : status == 422 ? "image_model_unsupported" : "attachments_busy", "futureByteField": 123]))
}
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid")!, authToken: "synthetic-test"), session: session)
do { _ = try await client.agentDownload(sampleAttachment()); Issue.record("Expected failure") }
catch let issue as AgentAttachmentIssue {
#expect(issue.statusCode == status); #expect(issue.message == "Actionable storage message")
#expect(issue.details["futureByteField"] == .number(123))
if status == 507 { #expect(issue.code == "attachment_quota_exceeded") }
if status == 429 { #expect(issue.retryAfter == "2") }
if status == 422 { #expect(issue.code == "image_model_unsupported") }
}
}
}
@Test func redirectPolicyRejectsUntrustedOrigin() async throws {
let session = attachmentSession(); defer { session.invalidateAndCancel() }
let task = session.dataTask(with: URL(string: "https://fixture.invalid/api")!)
let policy = AgentAttachmentRedirectPolicy()
let response = HTTPURLResponse(url: URL(string: "https://fixture.invalid/api")!, statusCode: 302, httpVersion: nil, headerFields: [:])!
await withCheckedContinuation { continuation in
policy.urlSession(session, task: task, willPerformHTTPRedirection: response, newRequest: URLRequest(url: URL(string: "https://untrusted.invalid")!)) { request in
#expect(request == nil); continuation.resume()
}
}
task.cancel()
}
@Test @MainActor func orderedSelectionFailureRetryRemoveAndCapability() async throws {
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
await client.failNextUpload()
let first = try #require(draft.add(filename: "one.bin") { .init(filename: "one.bin", mimeType: "application/octet-stream", data: originalBytes) })
let second = try #require(draft.add(filename: "two.bin") { .init(filename: "two.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(!draft.canSend)
#expect(await attachmentEventually { draft.items[0].state == .failed && draft.items[1].state == .ready })
#expect(draft.items[0].issue?.code == "attachment_quota_exceeded")
#expect(!draft.canSend); draft.retry(first)
#expect(await attachmentEventually { draft.items.allSatisfy { $0.state == .ready } })
#expect(draft.ready.map(\.filename) == ["one.bin", "two.bin"])
#expect(await client.uploadIDs() == [first, second, first])
#expect(draft.canSend)
draft.updateCapability(nil); #expect(!draft.canSend)
#expect(draft.ready.count == 2)
draft.remove(first); draft.remove(second); draft.caption = "Text still works"
#expect(draft.canSend)
}
@Test @MainActor func removedInflightUploadCannotReappear() async throws {
let client = AttachmentTestClient(), draft = AgentAttachmentDraft()
await client.delayUploads(.milliseconds(250))
draft.configure(client: client, storage: nil); draft.updateCapability(attachmentLimits)
let id = try #require(draft.add(filename: "remove.bin") { .init(filename: "remove.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(await attachmentEventually { draft.items.first?.state == .uploading })
draft.remove(id)
try await Task.sleep(for: .milliseconds(300))
#expect(draft.items.isEmpty); #expect(!draft.canSend)
}
@Test @MainActor func stagedUploadAndReadyDraftSurviveRelaunch() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let storage = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
let client = AttachmentTestClient(), first = AgentAttachmentDraft()
await client.failNextUpload()
first.configure(client: client, storage: storage); first.updateCapability(attachmentLimits); first.caption = "Keep my caption"
let id = try #require(first.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) })
#expect(await attachmentEventually { first.items.first?.state == .failed })
first.suspend()
let next = AgentAttachmentDraft(); next.configure(client: client, storage: storage); next.updateCapability(attachmentLimits)
#expect(next.caption == "Keep my caption"); #expect(next.items.first?.id == id)
next.retry(id); #expect(await attachmentEventually { next.items.first?.state == .ready })
#expect(await client.uploadIDs() == [id, id]); #expect(try storage.bytes(id) == originalBytes)
let reopened = AgentAttachmentDraft(); reopened.configure(client: client, storage: storage)
#expect(reopened.ready == next.ready); #expect(!reopened.canSend)
reopened.updateCapability(attachmentLimits); #expect(reopened.canSend)
reopened.clearAfterSubmission(); #expect(try storage.read().items.isEmpty)
#expect(throws: (any Error).self) { try storage.bytes(id) }
}
@Test @MainActor func outboxReferencesPersistBeforePostRetryAndEchoWins() async throws {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
room.configure(client: client, storage: store.storage); defer { room.stop() }
#expect(await attachmentEventually { !room.isLoading })
await client.malformedReceipts(true)
await client.requirePersisted { store.read().first?.attachments == [sampleAttachment()] }
#expect(room.send("", attachments: [sampleAttachment()]))
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let original = try #require(room.outbox.first)
room.retry(original.id)
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let submitted = await client.sent()
#expect(submitted.count == 2); #expect(submitted.allSatisfy { $0.requestId == original.requestID && $0.attachmentIDs == [sampleAttachment().id] })
var echo = original.message
echo.content = "Attached: original.bin."
echo.metadata?.self = .object(["kind": .string("message"), "entryId": .number(1), "status": .string("complete"), "agentAttachments": .array([sampleAttachment().metadataValue])])
try room.receive(.messages(.init(conversationId: "root", messages: [echo], cursor: "1", hasMore: false)))
room.flush()
#expect(room.outbox.first?.confirmed == true); #expect(store.read().isEmpty)
#expect(room.messages.count == 1); #expect(room.messages.first?.agentAttachments == [sampleAttachment()])
#expect(room.messages.first?.createdAt == original.createdAt)
}
@Test @MainActor func failedDraftHandoffCannotPostOrCreateAnotherIdentityAfterRelaunch() async throws {
let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? FileManager.default.removeItem(at: root) }
let disk = AgentDraftStorage.file(baseURL: URL(string: "https://fixture.invalid/api")!, root: root)
let draftID = UUID().uuidString
let item = AgentDraftItem(filename: "original.bin", state: .ready, attachment: sampleAttachment())
try disk.write(.init(caption: "Keep caption and references", items: [item], id: draftID))
var unavailable = disk
unavailable.write = { snapshot in
if snapshot.items.isEmpty { throw AgentAttachmentIssue.local("Synthetic disk failure") }
try disk.write(snapshot)
}
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
room.configure(client: client, storage: store.storage, draftStorage: unavailable)
#expect(await attachmentEventually { !room.isLoading })
#expect(!room.sendComposer())
#expect(room.composer == "Keep caption and references")
#expect(room.attachmentDraft.ready == [sampleAttachment()])
#expect(store.read().count == 1)
let saved = try #require(store.read().first)
#expect(saved.draftID == draftID)
room.retry(saved.id)
#expect(await client.sent().isEmpty)
room.stop()
let reopened = SybilAgentRoom()
reopened.configure(client: client, storage: store.storage, draftStorage: disk)
defer { reopened.stop() }
#expect(await attachmentEventually { !reopened.isLoading })
#expect(reopened.composer.isEmpty); #expect(reopened.attachmentDraft.items.isEmpty)
#expect(!reopened.sendComposer())
reopened.retry(saved.id)
#expect(await attachmentEventually { reopened.outbox.first?.seenOnServer == true })
#expect(await client.sent().map(\.requestId) == [saved.requestID])
#expect(await client.sent().first?.attachmentIDs == [sampleAttachment().id])
}
@Test @MainActor func openProxyHTTP503AndSocket4503KeepReadyDraftAndOutbox() async throws {
for socketFailure in [false, true] {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
if socketFailure { await client.blockSocket() } else { await client.blockDescriptor() }
let row = AgentOutboxRow(requestID: "unchanged-retry", content: "Retained caption", createdAt: Date(), attachments: [sampleAttachment()])
try store.storage.write([row])
let snapshot = AgentDraftSnapshot(caption: "Retained draft", items: [.init(filename: "original.bin", state: .ready, attachment: sampleAttachment())])
let draft = AgentDraftStorage(read: { snapshot }, write: { _ in }, stage: { _, _ in }, bytes: { _ in originalBytes }, remove: { _ in })
room.configure(client: client, storage: store.storage, draftStorage: draft)
#expect(await attachmentEventually { room.error?.contains(socketFailure ? "4503" : "Synthetic open proxy") == true })
#expect(!room.attachmentDraft.supportsAttachments)
#expect(room.composer == "Retained draft"); #expect(room.attachmentDraft.ready == [sampleAttachment()])
#expect(!room.sendComposer())
#expect(room.outbox.first?.requestID == "unchanged-retry")
#expect(store.read().first?.attachments == [sampleAttachment()])
#expect(await client.sent().isEmpty)
room.stop()
}
}
@Test @MainActor func mismatchedCanonicalAndQueuedEchoesNeverHideLocalReferencesOrRetry() async throws {
let client = AttachmentTestClient(), store = AttachmentTestStore(), room = SybilAgentRoom()
await client.malformedReceipts(true)
room.configure(client: client, storage: store.storage); defer { room.stop() }
#expect(await attachmentEventually { !room.isLoading })
let attachments = [sampleAttachment("first"), sampleAttachment("second")]
#expect(room.send("Original caption", attachments: attachments))
#expect(await attachmentEventually { room.outbox.first?.status == "failed" })
let row = try #require(room.outbox.first)
for (index, wrong) in [[], Array(attachments.reversed()), [attachments[0]]].enumerated() {
var message = row.message
message.content = "Incorrect server echo"
message.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(Double(index + 1)), "agentAttachments": .array(wrong.map(\.metadataValue))])
try room.receive(.messages(.init(conversationId: "root", messages: [message], cursor: String(index + 1), hasMore: false)))
var activity = room.snapshot.room.activity
activity.queuedMessages = [message]
try room.receive(.activity("root", activity)); room.flush()
#expect(room.outbox.first?.confirmed == false); #expect(room.outbox.first?.seenOnServer == false)
#expect(room.outbox.first?.status == "failed"); #expect(room.outbox.first?.error != nil)
#expect(room.messages.first?.agentAttachments == attachments)
#expect(room.messages.first?.content == "Original caption")
#expect(store.read().first?.requestID == row.requestID)
}
}
@Test func legacyOutboxAndQueuedHistoryKeepDistinctAttachmentsAndHiddenTools() throws {
let row = AgentOutboxRow(requestID: "stable", content: "caption", createdAt: Date(timeIntervalSince1970: 1))
#expect(try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(row)).attachments == nil)
var attached = row; attached.attachments = [sampleAttachment()]
let restored = try JSONDecoder().decode(AgentOutboxRow.self, from: JSONEncoder().encode(attached))
#expect(restored.attachments == attached.attachments); #expect(restored.requestID == row.requestID)
var queued = restored.message; queued.metadata = .object(["kind": .string("message"), "status": .string("queued"), "agentAttachments": .array([sampleAttachment().metadataValue])])
var committed = queued; committed.metadata = .object(["kind": .string("message"), "status": .string("complete"), "entryId": .number(2), "agentAttachments": .array([sampleAttachment().metadataValue])])
let tool = Message(id: "hidden", createdAt: Date(), role: .tool, content: "raw tool", metadata: .object(["kind": .string("tool_call"), "entryId": .number(3)]))
var state = AgentRoomState()
try state.apply(.init(conversationId: "root", messages: [queued, tool], cursor: "3", hasMore: false))
try state.apply(.init(conversationId: "root", messages: [committed], cursor: "3", hasMore: false))
try state.apply(.init(conversationId: "root", messages: [queued], cursor: "2", hasMore: false))
#expect(state.messages.count == 2); #expect(state.cursor == "3"); #expect(state.unreadCount == 0)
let transcript = AgentTranscript(messages: state.messages)
#expect(transcript.messages.count == 1); #expect(transcript.messages[0].agentAttachments == [sampleAttachment()])
#expect(transcript.messages[0].attachments.isEmpty)
}
}
@@ -0,0 +1,416 @@
import Foundation
import Testing
import UIKit
@testable import Sybil
@MainActor private final class PushMemory {
var value = AgentPushPersistence()
var failWrite = false
var storage: AgentPushStorage { .init(read: { self.value }, write: { value in
if self.failWrite { throw URLError(.cannotWriteToFile) }
self.value = value
}) }
}
@MainActor private final class PushPlatform: AgentNotificationPlatform {
var environment: AgentAPNSEnvironment? = .sandbox
var allowsLoopback = true
var authorization: AgentNotificationPermission = .notDetermined
var allow = true
var prompts = 0
var registrations: [UUID] = []
var unregisters = 0
var notifications: [AgentDeliveredNotification] = []
var removed: [String] = []
var holdPermission = false
var holdPermissionRead = false
var permissionReadWaiter: CheckedContinuation<AgentNotificationPermission, Never>?
var permissionWaiter: CheckedContinuation<Bool, Never>?
func permission() async -> AgentNotificationPermission {
if holdPermissionRead { return await withCheckedContinuation { permissionReadWaiter = $0 } }
return authorization
}
func requestPermission() async -> Bool {
prompts += 1
if holdPermission { return await withCheckedContinuation { permissionWaiter = $0 } }
authorization = allow ? .authorized : .denied
return allow
}
func register(generation: UUID) { registrations.append(generation) }
func unregister() { unregisters += 1 }
func delivered() async -> [AgentDeliveredNotification] { notifications }
func removeDelivered(identifiers: [String]) { removed += identifiers; notifications.removeAll { identifiers.contains($0.identifier) } }
}
private actor PushAPI: AgentPushAPIClienting {
var config = AgentPushConfig(version: 1, enabled: true, apnsAvailable: true, webPushAvailable: false, sourceId: "11111111-1111-4111-8111-111111111111", quarantined: false)
var read = AgentReadState(cursor: "0", sourceId: "11111111-1111-4111-8111-111111111111")
var puts: [(UUID, AgentPushRegistration)] = []
var deletes: [UUID] = []
var failures = 0
var offline = false
var oldServer = false
var revokeStatus: Int?
var holdPut = false
var putWaiter: CheckedContinuation<Void, Never>?
var holdConfig = false
var configWaiter: CheckedContinuation<Void, Never>?
func agentPushConfig() async throws -> AgentPushConfig {
if holdConfig { await withCheckedContinuation { configWaiter = $0 } }
if oldServer { throw URLError(.badServerResponse) }
return config
}
func agentReadState() -> AgentReadState { read }
func agentRegisterPush(id: UUID, registration: AgentPushRegistration) async throws -> AgentPushReceipt {
puts.append((id, registration))
if holdPut { await withCheckedContinuation { putWaiter = $0 } }
if failures > 0 { failures -= 1; throw URLError(.networkConnectionLost) }
return .init(installationId: id.uuidString, transport: "apns", enabled: true, version: puts.count)
}
func agentRevokePush(id: UUID) throws {
deletes.append(id)
if let revokeStatus { throw AgentPushRequestError(status: revokeStatus) }
if offline { throw URLError(.notConnectedToInternet) }
}
func failRevoke(status: Int) { revokeStatus = status }
func set(failures: Int = 0, offline: Bool = false, holdPut: Bool = false, oldServer: Bool = false, holdConfig: Bool = false) {
self.failures = failures; self.offline = offline; self.holdPut = holdPut; self.oldServer = oldServer; self.holdConfig = holdConfig
}
func source(_ id: String) { config.sourceId = id; read.sourceId = id }
func setAvailable(_ available: Bool) { config.enabled = available }
func releasePut() { holdPut = false; putWaiter?.resume(); putWaiter = nil }
func releaseConfig() { holdConfig = false; configWaiter?.resume(); configWaiter = nil }
func requests() -> [(UUID, AgentPushRegistration)] { puts }
func revokes() -> [UUID] { deletes }
func waitingPut() -> Bool { putWaiter != nil }
func waitingConfig() -> Bool { configWaiter != nil }
}
@MainActor private func pushEventually(_ predicate: () async -> Bool) async -> Bool {
for _ in 0..<300 { if await predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
private func pushPayload(_ cursor: String = "4", source: String = "11111111-1111-4111-8111-111111111111", kind: String = "message") -> AgentPushPayload {
AgentPushPayload(["version": 1, "sourceId": source, "kind": kind, "cursor": cursor, "readCursor": cursor, "messageId": "outbound:\(cursor)"])!
}
@MainActor private func bindPush(_ notifications: SybilAgentNotifications, _ api: PushAPI, url: String = "https://example.test/api", mode: String = "token") async {
await notifications.bind(client: api, baseURL: URL(string: url)!, authenticated: true, mode: mode, token: mode == "token" ? "fixture-token" : nil)
}
@Suite(.serialized) struct AgentNotificationTests {
@Test func payloadValidationAndCanonicalScope() throws {
#expect(AgentPushPayload(["version": true, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
#expect(AgentPushPayload(["version": 2, "sourceId": "s", "kind": "message", "cursor": "1", "messageId": "m"]) == nil)
#expect(AgentPushPayload(["version": 1, "sourceId": "s", "kind": "message", "cursor": 1, "messageId": "m"]) == nil)
for cursor in ["-1", "1e3", "9007199254740992", "1.0", ""] { #expect(AgentPushPayload.validCursor(cursor) == nil) }
#expect(AgentPushPayload.validCursor("0004") == "4")
#expect(AgentPushScope.canonicalBackend(URL(string: "https://EXAMPLE.test:443/api/")!) == "https://example.test/api")
for url in ["https://user:secret@example.test/api", "https://example.test/api?token=bad", "https://example.test/#fragment", "http://example.test/api", "https://example.test/api/../other"] {
#expect(AgentPushScope.canonicalBackend(URL(string: url)!) == nil)
}
#expect(AgentAPNSEnvironment(entitlementSetting: "production") == .production)
#expect(AgentAPNSEnvironment(entitlementSetting: "development") == .sandbox)
#expect(AgentAPNSEnvironment(entitlementSetting: "DEBUG") == nil)
}
@Test @MainActor func explicitAllowFreshTokenRotationAndIdempotence() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
await push.enable()
#expect(platform.prompts == 1 && platform.registrations.count == 1)
#expect(await api.requests().isEmpty)
let run = push.generation
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
#expect(await pushEventually { await api.requests().count == 1 && memory.value.records.first?.version != nil })
let first = try #require(await api.requests().first)
#expect(first.1.token == "00010aff" && first.1.environment == .sandbox)
push.receivedDeviceToken(Data([0, 1, 10, 255]), generation: run)
#expect(await api.requests().count == 1)
push.receivedDeviceToken(Data([0x42, 0xab]), generation: run)
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version == 2 })
let next = try #require(await api.requests().last)
#expect(first.0 == next.0 && next.1.token == "42ab")
let encoded = String(decoding: try JSONEncoder().encode(memory.value), as: UTF8.self)
#expect(!encoded.contains("00010aff") && !encoded.contains("42ab"))
await push.refresh()
#expect(platform.prompts == 1 && platform.registrations.count == 2)
let relaunchedPlatform = PushPlatform(); relaunchedPlatform.authorization = .authorized
let relaunched = SybilAgentNotifications(platform: relaunchedPlatform, storage: memory.storage)
await bindPush(relaunched, api)
#expect(relaunchedPlatform.registrations.count == 1 && relaunchedPlatform.prompts == 0)
#expect(await api.requests().count == 2) // persisted token is never used.
}
@Test @MainActor func deniedOpenOldServerAndPersistenceFailureNeverEnroll() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api, mode: "open"); await push.enable()
#expect(platform.prompts == 0 && platform.registrations.isEmpty)
await api.set(oldServer: true); await bindPush(push, api)
#expect(!push.available && platform.prompts == 0)
await api.set(); await bindPush(push, api)
platform.allow = false; await push.enable()
#expect(platform.prompts == 1 && !push.optedIn && platform.registrations.isEmpty)
platform.authorization = .authorized; memory.failWrite = true
await push.enable()
#expect(platform.registrations.isEmpty)
#expect(await api.requests().isEmpty)
}
@Test @MainActor func retryThenRevokeUsesRetiredUUID() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable(); await api.set(failures: 1)
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
let ids = await api.requests().map(\.0)
#expect(Set(ids).count == 1)
await push.disable()
#expect(await api.revokes().contains(ids[0]))
#expect(!push.optedIn && push.pendingRevocations == 0)
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { await api.requests().count == 3 })
#expect(await api.requests().last?.0 != ids[0])
}
@Test @MainActor func logoutAndServerSwitchRejectLateOSAndUploadCallbacks() async throws {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, old); await push.enable(); await old.set(holdPut: true)
let oldRun = push.generation
push.receivedDeviceToken(Data([1]), generation: oldRun)
#expect(await pushEventually { await old.waitingPut() })
let oldID = try #require(await old.requests().first?.0)
await push.deactivate()
await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test/api")
await push.enable()
push.receivedDeviceToken(Data([0xff]), generation: oldRun)
#expect(await next.requests().isEmpty)
await old.releasePut()
#expect(await pushEventually { await old.revokes().contains(oldID) })
#expect(await next.revokes().isEmpty)
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222" && push.optedIn)
push.receivedDeviceToken(Data([2]), generation: push.generation)
#expect(await pushEventually { await next.requests().count == 1 })
}
@Test @MainActor func oldConfigAndPermissionCannotBindNewSession() async {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await old.set(holdConfig: true)
let binding = Task { await bindPush(push, old) }
#expect(await pushEventually { await old.waitingConfig() })
await push.deactivate(); await next.source("22222222-2222-4222-8222-222222222222"); await bindPush(push, next, url: "https://other.test")
await old.releaseConfig(); await binding.value
#expect(push.scope?.sourceID == "22222222-2222-4222-8222-222222222222")
platform.holdPermission = true
let enabling = Task { await push.enable() }
#expect(await pushEventually { platform.permissionWaiter != nil })
await push.deactivate()
platform.permissionWaiter?.resume(returning: true); await enabling.value
#expect(platform.registrations.isEmpty && !push.optedIn)
}
@Test @MainActor func offlineTombstoneSurvivesRelaunchAndOnlyExactScopeRetries() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable()
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
await api.set(offline: true); await push.deactivate()
#expect(memory.value.tombstones.count == 1)
let id = try #require(memory.value.tombstones.first?.installationID)
let next = PushAPI(), relaunched = SybilAgentNotifications(platform: PushPlatform(), storage: memory.storage)
await bindPush(relaunched, next, url: "https://other.test/api")
#expect(await next.revokes().isEmpty)
await relaunched.deactivate(); await next.source("33333333-3333-4333-8333-333333333333")
await bindPush(relaunched, next)
#expect(await next.revokes().isEmpty)
await relaunched.deactivate(); await next.source("11111111-1111-4111-8111-111111111111"); await bindPush(relaunched, next)
#expect(await next.revokes() == [id] && memory.value.tombstones.isEmpty)
}
@Test @MainActor func deliveredClearUsesSystemIdentifiersAndRetainsNewerOrUnrelated() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
platform.notifications = [
.init(identifier: "apns-system-a", payload: pushPayload("3")),
.init(identifier: "apns-system-b", payload: pushPayload("4")),
.init(identifier: "newer", payload: pushPayload("5")),
.init(identifier: "foreign", payload: pushPayload("1", source: "44444444-4444-4444-8444-444444444444")),
.init(identifier: "unrelated", payload: nil)]
await push.acknowledged(.init(cursor: "99"), generation: push.generation)
#expect(platform.removed.isEmpty) // legacy reads have no source authority.
await push.acknowledged(.init(cursor: "4", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
#expect(platform.removed == ["apns-system-a", "apns-system-b"])
#expect(platform.notifications.map(\.identifier) == ["newer", "foreign", "unrelated"])
await push.acknowledged(.init(cursor: "1", sourceId: "11111111-1111-4111-8111-111111111111"), generation: push.generation)
#expect(!push.shouldPresent(pushPayload("4")))
#expect(push.shouldPresent(pushPayload("5")))
let scene = UUID(); push.setScene(scene, active: true)
#expect(!push.shouldPresent(pushPayload("5")))
push.setScene(scene, active: false)
#expect(await push.receiveBackground(pushPayload("5", kind: "read_state")))
#expect(platform.removed.last == "newer")
#expect(await api.requests().isEmpty) // no read/enrollment side effect.
}
@Test @MainActor func coldTapWaitsForAuthenticationDeduplicatesAndDoesNotRead() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
let payload = pushPayload()
push.notificationResponse(payload, identifier: "system-1")
#expect(!push.consumeRoute())
let request = push.pendingRouteID
push.notificationResponse(payload, identifier: "system-2")
#expect(push.pendingRouteID == request)
await bindPush(push, api, mode: "open"); #expect(!push.consumeRoute())
await bindPush(push, api); #expect(push.consumeRoute()); #expect(!push.consumeRoute())
#expect(platform.removed.isEmpty)
#expect(await api.requests().isEmpty)
#expect(push.shouldPresent(payload)) // tap alone did not advance read.
push.notificationResponse(pushPayload(source: "44444444-4444-4444-8444-444444444444"), identifier: "wrong")
#expect(!push.consumeRoute())
}
@Test @MainActor func backgroundCompletionExactlyOnceIncludingTimeout() async {
var outcomes: [UIBackgroundFetchResult] = []
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { .newData }, completion: { outcomes.append($0) })
#expect(await pushEventually { outcomes.count == 1 })
try? await Task.sleep(for: .milliseconds(40)); #expect(outcomes == [.newData])
var continuation: CheckedContinuation<UIBackgroundFetchResult, Never>?
AgentBackgroundCompletion.run(timeout: .milliseconds(20), operation: { await withCheckedContinuation { continuation = $0 } }, completion: { outcomes.append($0) })
#expect(await pushEventually { outcomes.count == 2 })
continuation?.resume(returning: .newData)
try? await Task.sleep(for: .milliseconds(20)); #expect(outcomes == [.newData, .noData])
}
@Test @MainActor func changedCredentialEpochRequiresConsentAndRejectsColdReadHints() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let original = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(original, api); await original.enable()
original.receivedDeviceToken(Data([1]), generation: original.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
let replacementPlatform = PushPlatform(); replacementPlatform.authorization = .authorized
let replacement = SybilAgentNotifications(platform: replacementPlatform, storage: memory.storage)
replacement.restoreBackgroundScope(baseURL: URL(string: "https://example.test/api")!, token: "replacement-fixture-token", payload: pushPayload(kind: "read_state"))
#expect(replacement.scope == nil)
await replacement.bind(client: api, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
#expect(!replacement.optedIn && replacementPlatform.registrations.isEmpty)
#expect(memory.value.records.count == 2)
#expect(memory.value.records[0].scope.credentialScope != memory.value.records[1].scope.credentialScope)
#expect(await api.revokes().isEmpty)
#expect(replacement.scope?.credentialScope == memory.value.records.last?.scope.credentialScope)
}
@Test @MainActor func deniedPermissionThenAuthorizedRefreshRecovers() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
platform.authorization = .denied
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
#expect(push.permission == .denied)
platform.authorization = .authorized
await push.refresh()
#expect(push.permission == .authorized && push.available)
await push.enable()
#expect(platform.registrations.count == 1)
}
@Test @MainActor func equivalentCanonicalBackendDoesNotRetireConsent() async {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable()
let run = push.generation
await bindPush(push, api, url: "https://EXAMPLE.test:443/api/")
#expect(push.generation == run && push.optedIn)
#expect(memory.value.records.count == 1)
}
@Test @MainActor func sameBackendCredentialRotationWith403TombstoneAllowsNewConsentRoutingAndClear() async {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, old); await push.enable()
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
await old.failRevoke(status: 403)
await push.bind(client: next, baseURL: URL(string: "https://example.test/api")!, authenticated: true, mode: "token", token: "replacement-fixture-token")
#expect(!push.optedIn && memory.value.tombstones.count == 1)
await push.enable(); push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { await next.requests().count == 1 && push.status == "Notifications are enabled." })
push.notificationResponse(pushPayload(), identifier: "system-after-rotation")
#expect(push.consumeRoute())
platform.notifications = [.init(identifier: "actual-system-id", payload: pushPayload())]
#expect(await push.receiveBackground(pushPayload(kind: "read_state")))
#expect(platform.removed == ["actual-system-id"])
#expect(memory.value.tombstones.count == 1)
#expect(await next.revokes().isEmpty)
}
@Test @MainActor func distinctBackendWithSameRuntimeSourceStillRefusesPayloads() async {
let platform = PushPlatform(), memory = PushMemory(), old = PushAPI(), next = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, old); await push.enable()
push.receivedDeviceToken(Data([1]), generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
await old.failRevoke(status: 403)
await bindPush(push, next, url: "https://other.test/api"); await push.enable()
push.receivedDeviceToken(Data([2]), generation: push.generation)
#expect(await pushEventually { await next.requests().count == 1 })
push.notificationResponse(pushPayload(), identifier: "ambiguous-server")
#expect(!push.consumeRoute())
#expect(!(await push.receiveBackground(pushPayload(kind: "read_state"))))
}
@Test @MainActor func disableDuringConfigOrPermissionRefreshDoesNotBlockNewRefresh() async {
for holdConfig in [true, false] {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api)
await api.set(holdConfig: holdConfig); platform.holdPermissionRead = !holdConfig
let oldRefresh = Task { await push.refresh() }
#expect(await pushEventually { holdConfig ? await api.waitingConfig() : platform.permissionReadWaiter != nil })
await push.disable()
if holdConfig { await api.releaseConfig() }
else { platform.holdPermissionRead = false; platform.permissionReadWaiter?.resume(returning: .denied) }
await oldRefresh.value
platform.authorization = .authorized
await push.refresh(); await push.enable()
#expect(push.permission == .authorized && platform.registrations.count == 1)
}
}
@Test @MainActor func capabilityRecoveryReenrollsSameTokenWithNewInstallation() async throws {
let platform = PushPlatform(), memory = PushMemory(), api = PushAPI()
let push = SybilAgentNotifications(platform: platform, storage: memory.storage)
await bindPush(push, api); await push.enable()
let bytes = Data([1, 2, 3])
push.receivedDeviceToken(bytes, generation: push.generation)
#expect(await pushEventually { memory.value.records.first?.version != nil })
let oldID = try #require(await api.requests().first?.0)
await api.setAvailable(false); await push.refresh()
#expect(await api.revokes().contains(oldID))
#expect(memory.value.records.first?.installationID != oldID)
await api.setAvailable(true); await push.refresh()
push.receivedDeviceToken(bytes, generation: push.generation)
#expect(await pushEventually { await api.requests().count == 2 && memory.value.records.first?.version != nil })
#expect(await api.requests().last?.0 != oldID)
#expect(push.status == "Notifications are enabled.")
}
@Test @MainActor func optionalReadStateAndNativeAuthRemainCompatible() throws {
let old = try JSONDecoder().decode(AgentReadState.self, from: Data(#"{"cursor":"4","updatedAt":null}"#.utf8))
#expect(old.sourceId == nil)
let data = Data(#"{"type":"read_state","sourceId":"11111111-1111-4111-8111-111111111111","cursor":"5","updatedAt":null}"#.utf8)
guard case let .readState(read) = try AgentEvent.decode(data) else { Issue.record("Missing read frame"); return }
let room = SybilAgentRoom(); try room.applyRead(read); room.flush()
#expect(room.snapshot.room.readCursor == "5")
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "99", sourceId: "foreign")) }
room.flush()
#expect(room.snapshot.room.readCursor == "5")
#expect(room.requiresReload)
#expect(try AgentEvent.decode(Data(#"{"type":"read_state","cursor":"-1"}"#.utf8)) == nil)
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(APIConfiguration(baseURL: URL(string: "https://example.test")!, authToken: "fixture").agentAuthFrame(after: "0").utf8))
#expect(frame.objectValue?["clientKind"] == .string("native"))
#expect(frame.objectValue?["presenceSessionId"] == nil)
}
}
@@ -0,0 +1,88 @@
import Foundation
import Testing
@testable import Sybil
private final class PushURLProtocol: URLProtocol, @unchecked Sendable {
static let lock = NSLock()
nonisolated(unsafe) private static var handler: (@Sendable (URLRequest) throws -> (Int, Data))?
static func install(_ value: @escaping @Sendable (URLRequest) throws -> (Int, Data)) { lock.withLock { handler = value } }
override class func canInit(with request: URLRequest) -> Bool { true }
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
override func startLoading() {
do {
let handler = Self.lock.withLock { Self.handler! }
let (status, data) = try handler(request)
client?.urlProtocol(self, didReceive: HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: ["Location": "https://untrusted.test/"])!, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: data); client?.urlProtocolDidFinishLoading(self)
} catch { client?.urlProtocol(self, didFailWithError: error) }
}
override func stopLoading() {}
}
private func pushClient(_ url: String = "https://example.test/api", token: String? = "fixture-token") -> SybilAPIClient {
let config = URLSessionConfiguration.ephemeral; config.protocolClasses = [PushURLProtocol.self]
return SybilAPIClient(configuration: .init(baseURL: URL(string: url)!, authToken: token), session: URLSession(configuration: config))
}
private func pushRequestBody(_ request: URLRequest) -> Data {
if let body = request.httpBody { return body }
guard let stream = request.httpBodyStream else { return Data() }
stream.open(); defer { stream.close() }
var bytes = Data(), buffer = [UInt8](repeating: 0, count: 1024)
while stream.hasBytesAvailable { let count = stream.read(&buffer, maxLength: buffer.count); if count <= 0 { break }; bytes.append(contentsOf: buffer.prefix(count)) }
return bytes
}
@Suite(.serialized) struct AgentPushAPITests {
@Test func authenticatedBasePathRegistrationAndRevoke() async throws {
let id = UUID()
PushURLProtocol.install { request in
#expect(request.url?.host == "example.test")
#expect(request.url?.query == nil)
#expect(request.cachePolicy == .reloadIgnoringLocalCacheData)
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-token")
#expect(request.value(forHTTPHeaderField: "Cache-Control") == "no-store")
#expect(request.timeoutInterval == 8)
if request.httpMethod == "GET" {
#expect(request.url?.path == "/api/v1/agent/push/config")
return (200, Data(#"{"version":1,"enabled":true,"apnsAvailable":true,"webPushAvailable":false,"sourceId":"11111111-1111-4111-8111-111111111111","quarantined":false,"policy":{"future":1}}"#.utf8))
}
#expect(request.url?.path == "/api/v1/agent/push/installations/" + id.uuidString.lowercased())
if request.httpMethod == "DELETE" { return (204, Data()) }
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: Any])
#expect(Set(object.keys) == Set(["transport", "enabled", "token", "environment", "topic"]))
#expect(object["token"] as? String == "00010aff")
#expect(object["environment"] as? String == "production")
#expect(object["topic"] as? String == "net.buzzert.sybil2")
return (200, try JSONEncoder().encode(AgentPushReceipt(installationId: id.uuidString, transport: "apns", enabled: true, version: 7)))
}
let client = pushClient()
#expect(try await client.agentPushConfig().available)
#expect(try await client.agentRegisterPush(id: id, registration: .init(token: "00010aff", environment: .production)).version == 7)
try await client.agentRevokePush(id: id)
}
@Test func unsupportedRedirectAndReflectedSecretsAreNotExposed() async {
for status in [302, 401, 404, 409, 503] {
PushURLProtocol.install { _ in (status, Data(#"{"token":"do-not-reflect-this-token","message":"do-not-reflect-this-token"}"#.utf8)) }
do { _ = try await pushClient().agentPushConfig(); Issue.record("Unexpected success") }
catch { #expect(!error.localizedDescription.contains("do-not-reflect")) }
}
PushURLProtocol.install { _ in Issue.record("Unsafe request reached transport"); return (200, Data()) }
for client in [pushClient(token: nil), pushClient("https://example.test/api?token=bad"), pushClient("https://user:password@example.test")] {
do { _ = try await client.agentPushConfig(); Issue.record("Unsafe configuration accepted") }
catch { }
}
}
@Test func sourceAwareReadWritesAndLegacyFallback() async throws {
let source = "11111111-1111-4111-8111-111111111111"
for expectedSource: String? in [nil, source] {
PushURLProtocol.install { request in
#expect(request.url?.path == "/api/v1/agent/read-state")
let object = try #require(JSONSerialization.jsonObject(with: pushRequestBody(request)) as? [String: String])
#expect(object["cursor"] == "4" && object["sourceId"] == expectedSource)
return (200, try JSONEncoder().encode(AgentReadState(cursor: "4", sourceId: expectedSource)))
}
#expect(try await pushClient().agentMarkRead(cursor: "4", sourceID: expectedSource).sourceId == expectedSource)
}
}
}
@@ -0,0 +1,589 @@
import Foundation
import Testing
@testable import Sybil
private func agentMessage(_ id: String, entry: String, role: MessageRole = .assistant, info: [String: JSONValue] = [:]) -> Message {
Message(id: id, createdAt: Date(timeIntervalSince1970: 1_700_000_000), role: role, content: "Message \(id)", metadata: .object(["kind": .string(role == .tool ? "tool_call" : "message"), "entryId": .string(entry)].merging(info, uniquingKeysWith: { _, b in b })))
}
private func agentPage(_ messages: [Message], cursor: String, more: Bool = false) -> AgentPage {
AgentPage(conversationId: "root", messages: messages, cursor: cursor, beforeCursor: nil, hasMore: more)
}
private final class MemoryOutbox: @unchecked Sendable {
private let lock = NSLock()
private var rows: [AgentOutboxRow] = []
var storage: AgentOutboxStorage {
AgentOutboxStorage(read: { self.read() }, write: { self.save($0) })
}
func read() -> [AgentOutboxRow] { lock.withLock { rows } }
func save(_ value: [AgentOutboxRow]) { lock.withLock { rows = value } }
}
private actor ControlledAgentSocket: AgentSocket {
let stream: AsyncThrowingStream<Data, any Error>
let continuation: AsyncThrowingStream<Data, any Error>.Continuation
private(set) var closed = false
init() { (stream, continuation) = AsyncThrowingStream.makeStream() }
func receive() async throws -> Data {
var iterator = stream.makeAsyncIterator()
guard let data = try await iterator.next() else { throw CancellationError() }
return data
}
func send(_ text: String) {}
func close() { closed = true; continuation.finish(throwing: URLError(.networkConnectionLost)) }
func closeCode() -> Int { 0 }
func push(_ text: String) { continuation.yield(Data(text.utf8)) }
}
private actor TestAgentClient: AgentAPIClienting {
var pages: [String: AgentPage]
var requests: [String] = []
var submissions: [(String, String)] = []
var read = "0"
var readSource: String?
var readWrites: [(cursor: String, source: String?)] = []
var writeReply: AgentReadState?
var writeConflict: Bool?
var holdWrite = false
var heldWrite: CheckedContinuation<Void, Never>?
var failRead = false
var failSubmit = false
var malformedReceipt = false
var socketAfters: [String] = []
var sockets: [ControlledAgentSocket] = []
var holdRead = false
var heldRead: CheckedContinuation<AgentReadState, Never>?
var beforeSubmit: (@Sendable () -> Bool)?
init(pages: [String: AgentPage] = [:]) { self.pages = pages }
func agentDescriptor() -> AgentDescriptor {
AgentDescriptor(id: "root", title: "Sybil Agent", type: "persistent-agent", model: "test", protocol: "pi-room.v1", activity: AgentActivity(), websocket: "/v1/agent/ws")
}
func agentMessages(after: String?, before: String?) -> AgentPage {
let cursor = after ?? "0"; requests.append(cursor)
return pages[cursor] ?? agentPage([], cursor: cursor)
}
func agentReadState() async -> AgentReadState {
if holdRead { return await withCheckedContinuation { heldRead = $0 } }
return AgentReadState(cursor: read, sourceId: readSource)
}
func pauseRead() { holdRead = true }
func releaseRead() { holdRead = false; heldRead?.resume(returning: AgentReadState(cursor: read, sourceId: readSource)); heldRead = nil }
func hasHeldRead() -> Bool { heldRead != nil }
func agentMarkRead(cursor: String) async throws -> AgentReadState {
try await agentMarkRead(cursor: cursor, sourceID: nil)
}
func agentMarkRead(cursor: String, sourceID: String?) async throws -> AgentReadState {
readWrites.append((cursor, sourceID))
if holdWrite { await withCheckedContinuation { heldWrite = $0 } }
if let coded = writeConflict {
if coded { throw AgentAttachmentIssue(message: "Source mismatch", code: "source_mismatch", statusCode: 409) }
throw APIError.httpError(statusCode: 409, message: "Source mismatch")
}
if failRead { throw AgentFailure(message: "Offline") }
if let writeReply { return writeReply }
read = try AgentCursor.advance(read, cursor)
return AgentReadState(cursor: read, sourceId: readSource)
}
func setReadState(_ state: AgentReadState) { read = state.cursor; readSource = state.sourceId }
func setWriteReply(_ state: AgentReadState?) { writeReply = state }
func setWriteConflict(coded: Bool) { writeConflict = coded }
func pauseWrite() { holdWrite = true }
func releaseWrite() { holdWrite = false; heldWrite?.resume(); heldWrite = nil }
func hasHeldWrite() -> Bool { heldWrite != nil }
func writtenReads() -> [(cursor: String, source: String?)] { readWrites }
func setHistory(_ pages: [String: AgentPage]) { self.pages = pages }
func agentSubmit(content: String, requestID: String) async throws -> AgentReceipt {
#expect(beforeSubmit?() ?? true)
submissions.append((content, requestID))
try await Task.sleep(for: .milliseconds(80))
if failSubmit { throw AgentFailure(message: "Lost acknowledgement") }
return AgentReceipt(id: 1, status: "queued", messageId: malformedReceipt ? "wrong" : "inbound:sybil:\(requestID)")
}
func agentSocket(after: String) async -> any AgentSocket {
socketAfters.append(after)
let socket = ControlledAgentSocket(); sockets.append(socket)
await socket.push(#"{"type":"ready","conversationId":"root"}"#)
return socket
}
func connectedAfters() -> [String] { socketAfters }
func latestSocket() -> ControlledAgentSocket? { sockets.last }
func setFailures(submit: Bool = false, read: Bool = false, malformed: Bool = false) { failSubmit = submit; failRead = read; malformedReceipt = malformed }
func requirePersisted(_ check: @escaping @Sendable () -> Bool) { beforeSubmit = check }
func submittedIDs() -> [String] { submissions.map(\.1) }
func requestedCursors() -> [String] { requests }
}
@MainActor
private func eventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 {
if predicate() { return true }
try? await Task.sleep(for: .milliseconds(10))
}
return false
}
struct AgentTests {
@Test @MainActor func mixedLoadedHistoryFiltersOnlyToolRows() async throws {
var user = agentMessage("user", entry: "1", role: .user)
user.content = "Explain the tool_call and tool result."
let call = agentMessage("call", entry: "2", role: .tool, info: ["status": .string("initiated")])
var reply = agentMessage("reply", entry: "3")
reply.content = "The search tool failed; here is what you can do."
// Role-only results still need hiding even without tool-call metadata.
var result = agentMessage("result", entry: "4", role: .tool)
result.metadata = nil
let markedCall = agentMessage("marked-call", entry: "5", info: ["kind": .string("tool_call")])
var plainReply = agentMessage("plain-reply", entry: "6")
plainReply.metadata = nil
let failure = agentMessage("failure", entry: "7", info: ["status": .string("failed"), "error": .string("Please try again")])
let terminal = agentMessage("terminal", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("Raw tool failure"), "resultPreview": .string("Raw result")])
let history = [user, call, reply, result, markedCall, plainReply, failure, terminal].enumerated().map { index, message in
var message = message
message.createdAt.addTimeInterval(Double(index))
return message
}
let client = TestAgentClient(pages: ["0": agentPage(Array(history.dropLast()), cursor: "7", more: true), "7": agentPage([history[7]], cursor: "8")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
#expect(await client.requestedCursors() == ["0", "7"])
#expect(await client.connectedAfters() == ["8"])
let transcript = AgentTranscript(messages: room.messages)
#expect(transcript.displayed == [history[0], history[2], history[5], history[6]])
#expect(room.snapshot.room.messages == history)
#expect(room.messages.count == 8)
#expect(room.snapshot.room.cursor == "8")
#expect(room.snapshot.room.readCursor == "0")
#expect(room.unreadCount == 2)
#expect(room.snapshot.entranceIDs.isEmpty)
// Shared regular-chat tool metadata remains available and unchanged.
#expect(terminal.isToolCallLog)
#expect(terminal.toolCallMetadata?.resultPreview == "Raw result")
}
@Test func transcriptWindowsAndAnchorsUseVisibleRows() {
var raw: [Message] = [], visible: [Message] = []
for index in 1...160 {
var message = agentMessage("reply-\(index)", entry: "\(index * 2)")
message.createdAt.addTimeInterval(Double(index * 2) * 86_400)
var tool = agentMessage("tool-\(index)", entry: "\(index * 2 + 1)", role: .tool)
// Tools on separate dates must not introduce their own separators.
tool.createdAt = message.createdAt.addingTimeInterval(86_400)
raw += [message, tool]; visible.append(message)
}
let initial = AgentTranscript(messages: raw)
#expect(initial.startIndex == 60)
#expect(initial.displayed == Array(visible.suffix(100)))
let anchorID = initial.displayed.first?.id
let hidden = agentMessage("late-tool", entry: "400", role: .tool)
let updated = AgentTranscript(messages: raw + [hidden], startID: anchorID)
#expect(updated.displayed == initial.displayed)
let earlierID = initial.messages[max(0, initial.startIndex - 100)].id
let expanded = AgentTranscript(messages: raw, startID: earlierID, limit: 200)
#expect(expanded.displayed == visible)
#expect(expanded.displayed.firstIndex(where: { $0.id == anchorID }) == 60)
let hiddenAnchor = AgentTranscript(messages: raw, startID: "tool-160")
#expect(hiddenAnchor.displayed == initial.displayed)
let onlyTools = AgentTranscript(messages: raw.filter { $0.role == .tool })
#expect(onlyTools.messages.isEmpty)
#expect(onlyTools.displayed.isEmpty)
#expect(onlyTools.startIndex == 0)
}
@Test @MainActor func liveToolUpsertsPreserveRawHistoryCursorsAndActivity() async throws {
let user = agentMessage("user", entry: "1", role: .user)
let reply = agentMessage("reply", entry: "2")
let client = TestAgentClient(pages: ["0": agentPage([user, reply], cursor: "2")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
let queued = agentMessage("queued", entry: "0", role: .user, info: ["status": .string("queued")])
var activity = AgentActivity()
activity.isThinking = true; activity.backgroundWorkers = 2; activity.queued = 1
activity.queuedMessages = [queued]
activity.wait = .init(due: 1_800_000_000_000, reason: "Waiting for background work")
activity.error = "A worker needs attention"
try room.receive(.activity("root", activity)); room.flush()
#expect(await eventually { room.snapshot.isTyping })
let visible = AgentTranscript(messages: room.messages).displayed
#expect(Set(visible.map(\.id)) == ["user", "reply", "queued"])
let start = agentMessage("tool", entry: "3", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
let completed = agentMessage("tool", entry: "4", role: .tool, info: ["status": .string("completed"), "resultPreview": .string("Raw preview")])
let result = agentMessage("tool", entry: "5", role: .tool, info: ["status": .string("completed"), "result": .object(["matches": .number(3)])])
for (message, cursor) in [(start, "3"), (completed, "4"), (result, "5"), (start, "3")] {
try room.receive(.messages(agentPage([message], cursor: cursor))); room.flush()
#expect(AgentTranscript(messages: room.messages).displayed == visible)
#expect(room.snapshot.room.messages.count == 3)
#expect(room.unreadCount == 1)
#expect(room.snapshot.room.readCursor == "0")
#expect(room.snapshot.isTyping)
#expect(room.snapshot.room.activity == activity)
}
let tool = try #require(room.snapshot.room.messages.first(where: { $0.id == "tool" })?.toolCallMetadata)
#expect(tool.status == "completed")
#expect(tool.args == .object(["query": .string("fixture")]))
#expect(tool.resultPreview == "Raw preview")
#expect(tool.result == .object(["matches": .number(3)]))
#expect(tool.startedAt == "start")
#expect(room.snapshot.room.cursor == "5")
// The rendered snapshot's cursor includes hidden tool updates. A later
// reply must remain unread when only that captured cursor is acknowledged.
room.isSelected = true
let capturedCursor = room.snapshot.room.cursor
let lateReply = agentMessage("late-reply", entry: "6")
let lateTool = agentMessage("late-tool", entry: "7", role: .tool)
try room.receive(.messages(agentPage([lateReply, lateTool], cursor: "7"))); room.flush()
room.markRead(renderedCursor: capturedCursor)
#expect(await eventually { room.snapshot.room.readCursor == "5" })
#expect(room.snapshot.room.cursor == "7")
#expect(room.unreadCount == 1)
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
#expect(!room.snapshot.isTyping)
#expect(room.isBusy)
let socket = try #require(await client.latestSocket())
await socket.push(#"{"type":"error","error":"Room connection unavailable"}"#)
#expect(await eventually { room.error == "Room connection unavailable" })
#expect(AgentTranscript(messages: room.messages).displayed == visible + [lateReply])
#expect(room.snapshot.room.messages.count == 5)
}
@Test @MainActor func failedSendDetailsAndRetryStayVisibleWithHiddenTools() async throws {
let tool = agentMessage("tool", entry: "1", role: .tool)
let client = TestAgentClient(pages: ["0": agentPage([tool], cursor: "1")])
await client.setFailures(submit: true)
let memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
#expect(room.send("Please retry the search tool"))
#expect(await eventually { room.outbox.first?.status == "failed" })
let failed = try #require(AgentTranscript(messages: room.messages).displayed.first)
let requestID = try #require(room.outbox.first?.requestID)
#expect(failed.role == .user)
#expect(failed.content == "Please retry the search tool")
#expect(failed.metadata?.objectValue?["status"] == .string("failed"))
#expect(failed.metadata?.objectValue?["error"] == .string("Lost acknowledgement"))
#expect(AgentTranscript(messages: room.messages).displayed.count == 1)
#expect(memory.read().first?.requestID == requestID)
await client.setFailures()
room.retry(failed.id)
#expect(await eventually { room.outbox.first?.status == "queued" })
let retried = try #require(AgentTranscript(messages: room.messages).displayed.first)
#expect(retried.id == failed.id)
#expect(retried.createdAt == failed.createdAt)
#expect(retried.metadata?.objectValue?["status"] == .string("queued"))
#expect(retried.metadata?.objectValue?["error"] == nil)
#expect(await client.submittedIDs() == [requestID, requestID])
#expect(room.snapshot.room.messages == [tool])
#expect(room.messages.count == 2)
}
@Test func cursorAndMalformedFrames() throws {
#expect(try AgentCursor.normalize("00012") == "12")
#expect(try AgentCursor.advance("9", "10") == "10")
#expect(try AgentCursor.advance("9007199254740993", "9007199254740992") == "9007199254740993")
#expect(throws: (any Error).self) { try AgentCursor.normalize("-1") }
var state = AgentRoomState()
try state.apply(agentPage([agentMessage("reply", entry: "10")], cursor: "10"))
let original = state
#expect(throws: (any Error).self) { try state.apply(agentPage([], cursor: "bad")) }
#expect(state == original)
#expect(throws: (any Error).self) { try AgentEvent.decode(Data(#"{"type":"messages","conversationId":"root","cursor":"99","beforeCursor":null,"hasMore":false,"messages":[{"id":"broken"}]}"#.utf8)) }
#expect(state.cursor == "10")
let oversizedDuration = agentMessage("tool", entry: "11", role: .tool, info: ["durationMs": .number(1e100)])
#expect(oversizedDuration.toolCallMetadata?.durationMs == nil)
}
@Test func terminalToolAndRestSocketRace() throws {
let start = agentMessage("tool:1", entry: "5", role: .tool, info: ["status": .string("initiated"), "toolName": .string("search"), "args": .object(["query": .string("fixture")]), "startedAt": .string("start")])
var end = agentMessage("tool:1", entry: "8", role: .tool, info: ["status": .string("failed"), "error": .string("fixture failure"), "resultPreview": .string("detail"), "args": .object([:])])
end.createdAt.addTimeInterval(5)
var state = AgentRoomState()
try state.apply(agentPage([start], cursor: "5"))
try state.apply(agentPage([end, agentMessage("reply", entry: "9")], cursor: "9"))
try state.apply(agentPage([start], cursor: "5"))
#expect(state.cursor == "9")
#expect(state.messages.count == 2)
let tool = try #require(state.messages.first?.toolCallMetadata)
#expect(tool.status == "failed")
#expect(tool.error == "fixture failure")
#expect(tool.args == .object(["query": .string("fixture")]))
#expect(tool.startedAt == "start")
#expect(state.messages.first?.createdAt == start.createdAt)
}
@Test func unreadExcludesToolsAndUsers() throws {
var state = AgentRoomState()
try state.apply(agentPage([agentMessage("a", entry: "9"), agentMessage("b", entry: "10"), agentMessage("u", entry: "11", role: .user), agentMessage("t", entry: "12", role: .tool)], cursor: "12"))
state.readCursor = "9"
#expect(state.unreadCount == 1)
state.readCursor = try AgentCursor.advance(state.readCursor, "12")
state.readCursor = try AgentCursor.advance(state.readCursor, "2")
#expect(state.unreadCount == 0)
}
@Test func typingReplyAndStaleActivityAreAtomic() throws {
var presentation = AgentPresentation(), state = AgentRoomState()
let now = Date(timeIntervalSince1970: 100)
var busy = AgentActivity(); busy.isThinking = true; busy.backgroundWorkers = 1
var after = state; after.activity = busy
presentation.receive(.activity("root", busy), before: state, after: after, replay: false, now: now)
state = after
#expect(!presentation.isTyping(now: now.addingTimeInterval(0.249)))
#expect(presentation.isTyping(now: now.addingTimeInterval(0.251)))
let reply = agentPage([agentMessage("reply", entry: "1")], cursor: "1")
try after.apply(reply)
presentation.receive(.messages(reply), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
state = after
#expect(!presentation.isTyping(now: now.addingTimeInterval(2)))
#expect(state.activity.isBusy)
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(3))
#expect(!presentation.isTyping(now: now.addingTimeInterval(4)))
for name in AgentPresentation.quietTools {
busy.tools.append(.init(id: name, name: name, status: "initiated"))
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(5))
#expect(!presentation.isTyping(now: now.addingTimeInterval(6)))
}
busy.tools.append(.init(id: "real", name: "search", status: "initiated"))
presentation.receive(.activity("root", busy), before: state, after: state, replay: false, now: now.addingTimeInterval(7))
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
}
@Test func socketURLAndAuthentication() throws {
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
let frame = try JSONDecoder().decode(JSONValue.self, from: Data(config.agentAuthFrame(after: "0009").utf8))
#expect(frame.objectValue?["token"] == .string("token"))
#expect(frame.objectValue?["after"] == .string("9"))
let open = APIConfiguration(baseURL: URL(string: "http://localhost:8787")!, authToken: nil)
let openFrame = try JSONDecoder().decode(JSONValue.self, from: Data(open.agentAuthFrame(after: "0").utf8))
#expect(openFrame.objectValue?["token"] == .null)
}
@Test @MainActor func emptyPagesAndInitialHistoryDoNotAnimate() async throws {
let client = TestAgentClient(pages: ["0": agentPage([], cursor: "3", more: true), "3": agentPage([], cursor: "7", more: true), "7": agentPage([agentMessage("reply", entry: "8")], cursor: "8")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
#expect(await client.requestedCursors() == ["0", "3", "7"])
#expect(room.snapshot.room.cursor == "8")
#expect(room.unreadCount == 1)
#expect(room.snapshot.entranceIDs.isEmpty)
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
room.flush()
#expect(room.snapshot.entranceIDs == ["late"])
try room.receive(.messages(agentPage([agentMessage("late", entry: "9")], cursor: "9")))
room.flush()
#expect(room.snapshot.entranceIDs.isEmpty)
#expect(room.messages.count == 2)
}
@Test @MainActor func outboxPersistsBeforePostAndEchoWinsFailure() async throws {
let memory = MemoryOutbox(), client = TestAgentClient()
await client.setFailures(submit: true)
await client.requirePersisted { !memory.read().isEmpty }
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
#expect(room.send("identical")); #expect(room.send("identical"))
#expect(room.messages.count == 2)
let first = room.outbox[0], second = room.outbox[1]
#expect(first.requestID != second.requestID)
#expect(memory.read().count == 2)
try room.receive(.messages(agentPage([agentMessage(first.id, entry: "1", role: .user)], cursor: "1")))
room.flush()
#expect(await eventually { room.outbox[1].status == "failed" })
#expect(room.outbox[0].status == "complete")
#expect(room.messages.first(where: { $0.id == first.id })?.createdAt == first.createdAt)
room.retry(second.id)
#expect(await eventually { room.outbox[1].status == "failed" })
let ids = await client.submittedIDs()
#expect(ids.filter { $0 == second.requestID }.count == 2)
let restored = SybilAgentRoom(); restored.configure(client: client, storage: memory.storage)
defer { restored.stop() }
#expect(restored.outbox.count == 1)
#expect(restored.outbox[0].requestID == second.requestID)
#expect(restored.outbox[0].createdAt == second.createdAt)
}
@Test @MainActor func malformedReceiptAndStorageFailureRemainRetryable() async {
let client = TestAgentClient(); await client.setFailures(malformed: true)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
room.send("fixture")
#expect(await eventually { room.outbox.first?.status == "failed" })
#expect(room.outbox.first?.seenOnServer == false)
let failed = SybilAgentRoom()
failed.configure(client: client, storage: AgentOutboxStorage(read: { [] }, write: { _ in throw AgentFailure(message: "Disk full") }))
defer { failed.stop() }
failed.send("must not submit")
#expect(failed.outbox.first?.status == "failed")
#expect(await client.submittedIDs().count == 1)
}
@Test @MainActor func readOnlyAfterAcknowledgementAndCapturedSnapshot() async throws {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("a", entry: "1")], cursor: "1")])
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { !room.isLoading })
room.isSelected = true
await client.setFailures(read: true)
room.markRead(renderedCursor: "1")
#expect(await eventually { room.error != nil })
#expect(room.snapshot.room.readCursor == "0")
await client.setFailures()
try room.receive(.messages(agentPage([agentMessage("b", entry: "2")], cursor: "2")))
room.markRead(renderedCursor: "1")
#expect(await eventually { room.snapshot.room.readCursor == "1" })
#expect(room.unreadCount == 1)
room.isObscured = true; room.markRead(renderedCursor: "2")
try? await Task.sleep(for: .milliseconds(100))
#expect(room.snapshot.room.readCursor == "1")
try room.applyRead(AgentReadState(cursor: "2")); room.flush()
#expect(room.unreadCount == 0)
room.setActive(false); room.setActive(true)
#expect(await eventually { room.connection == "Connecting" })
#expect(room.messages.count == 2)
}
@Test @MainActor func legacyReadPromotionCannotRelabelQueuedHistoryAndRequiresFreshReload() async throws {
let sourceB = "22222222-2222-4222-8222-222222222222"
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90"))
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; await client.pauseWrite(); room.markRead(renderedCursor: "100")
for _ in 0..<100 {
if await client.hasHeldWrite() { break }
try? await Task.sleep(for: .milliseconds(10))
}
#expect(await client.hasHeldWrite())
#expect(throws: AgentFailure.self) { try room.applyRead(.init(cursor: "0", sourceId: sourceB)) }
#expect(room.requiresReload && room.connection == "Reload required")
#expect(room.snapshot.room.readCursor == "90" && room.snapshot.room.cursor == "100")
await client.setWriteReply(.init(cursor: "100", sourceId: sourceB)); await client.releaseWrite()
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
try await Task.sleep(for: .milliseconds(50))
#expect(room.requiresReload && room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
#expect(await client.writtenReads().first?.source == nil)
#expect(room.messages.map(\.id) == ["old"])
// Explicit reconnect resets cursor space before discovering B or loading
// its history. The old queued100 can never be sent under sourceB.
await client.setWriteReply(nil); await client.setReadState(.init(cursor: "0", sourceId: sourceB))
await client.setHistory(["0": agentPage([agentMessage("new", entry: "1")], cursor: "1")])
room.retryConnection()
#expect(await eventually { room.connection == "Connected" })
#expect(!room.requiresReload && room.snapshot.room.readCursor == "0")
#expect(room.messages.map(\.id) == ["new"])
room.markRead(renderedCursor: "1")
#expect(await eventually { room.snapshot.room.readCursor == "1" })
let writes = await client.writtenReads()
#expect(writes.count == 2 && writes.last?.cursor == "1" && writes.last?.source == sourceB)
#expect(!writes.contains { $0.cursor == "100" && $0.source == sourceB })
}
@Test @MainActor func historyBeforeReadPinsLegacySourceBeforePublishing() throws {
let room = SybilAgentRoom(); defer { room.stop() }
try room.receive(.messages(agentPage([agentMessage("legacy", entry: "100")], cursor: "100")))
#expect(throws: AgentFailure.self) {
try room.applyRead(.init(cursor: "0", sourceId: "22222222-2222-4222-8222-222222222222"))
}
#expect(room.requiresReload && room.snapshot.room.cursor == "100" && room.snapshot.room.readCursor == "0")
}
@Test @MainActor func mismatchedAndNonAdvancingReadAcknowledgmentsStopWriter() async throws {
let sourceA = "11111111-1111-4111-8111-111111111111"
let sourceB = "22222222-2222-4222-8222-222222222222"
for reply in [AgentReadState(cursor: "100", sourceId: sourceB), .init(cursor: "90", sourceId: sourceA), .init(cursor: "100"), .init(cursor: "invalid", sourceId: sourceA)] {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90", sourceId: sourceA)); await client.setWriteReply(reply)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; room.markRead(renderedCursor: "100")
#expect(await eventually { room.requiresReload })
room.markRead(renderedCursor: "100"); room.setActive(false); room.setActive(true)
try await Task.sleep(for: .milliseconds(30))
#expect(room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
#expect(await client.writtenReads().first?.source == sourceA)
room.stop()
}
}
@Test @MainActor func readSourceConflict409StopsWriterUntilReload() async {
for coded in [false, true] {
let client = TestAgentClient(pages: ["0": agentPage([agentMessage("old", entry: "100")], cursor: "100")])
await client.setReadState(.init(cursor: "90", sourceId: "11111111-1111-4111-8111-111111111111"))
await client.setWriteConflict(coded: coded)
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
#expect(await eventually { room.connection == "Connected" })
room.isSelected = true; room.markRead(renderedCursor: "100")
#expect(await eventually { room.requiresReload })
room.markRead(renderedCursor: "100")
#expect(room.snapshot.room.readCursor == "90")
#expect(await client.writtenReads().count == 1)
room.stop()
}
}
@Test @MainActor func interruptedAdmissionForegroundKeepsRetryIdentity() async throws {
let client = TestAgentClient(), memory = MemoryOutbox()
let room = SybilAgentRoom(); room.configure(client: client, storage: memory.storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
room.send("interrupted fixture")
let original = try #require(room.outbox.first)
room.setActive(false)
#expect(room.outbox.first?.status == "failed")
#expect(memory.read().first?.requestID == original.requestID)
room.setActive(true)
#expect(await eventually { room.connection == "Connected" })
room.retry(original.id)
#expect(await eventually { room.outbox.first?.seenOnServer == true })
#expect(room.outbox.first?.createdAt == original.createdAt)
#expect(await client.submittedIDs().allSatisfy { $0 == original.requestID })
}
@Test @MainActor func disconnectCannotRestartOldClientAndStaleReadCannotPoisonNewRun() async throws {
let old = TestAgentClient(); await old.pauseRead()
let room = SybilAgentRoom(); room.configure(client: old, storage: MemoryOutbox().storage)
for _ in 0..<100 {
if await old.hasHeldRead() { break }
try? await Task.sleep(for: .milliseconds(10))
}
room.disconnect()
room.setActive(false); room.setActive(true)
#expect(await old.connectedAfters().isEmpty)
let next = TestAgentClient(pages: ["0": agentPage([agentMessage("new", entry: "2")], cursor: "2")])
room.configure(client: next, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
await old.releaseRead()
try? await Task.sleep(for: .milliseconds(50))
try await room.catchUp(client: next)
#expect(room.messages.map(\.id) == ["new"])
#expect(await next.requestedCursors() == ["0", "2"])
#expect(await old.connectedAfters().isEmpty)
}
@Test @MainActor func socketReplayAfterForegroundDeduplicatesAndRetainsMergedCursor() async throws {
let client = TestAgentClient()
let room = SybilAgentRoom(); room.configure(client: client, storage: MemoryOutbox().storage)
defer { room.stop() }
#expect(await eventually { room.connection == "Connected" })
let socket = try #require(await client.latestSocket())
let frame = #"{"type":"messages","conversationId":"root","messages":[{"id":"late","createdAt":"2026-10-14T18:00:00Z","role":"assistant","content":"Late reply","metadata":{"kind":"message","entryId":7}}],"cursor":"7","beforeCursor":null,"hasMore":false}"#
await socket.push(frame)
#expect(await eventually { room.snapshot.room.cursor == "7" })
room.setActive(false); room.setActive(true)
#expect(await eventually { room.connection == "Connected" })
let next = try #require(await client.latestSocket())
await next.push(frame)
try? await Task.sleep(for: .milliseconds(150))
#expect(await client.connectedAfters() == ["0", "7"])
#expect(room.messages.count == 1)
#expect(await socket.closed)
}
}
@@ -0,0 +1,54 @@
import MarkdownUI
import SwiftUI
import Testing
import UIKit
@testable import Sybil
@Suite @MainActor
struct MessagePresentationTests {
@Test func userMarkupRemainsLiteral() {
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
let content = SybilMessageText.literalContent(source)
#expect(content.renderPlainText() == source)
#expect(!content.renderHTML().contains("<strong>"))
#expect(!content.renderHTML().contains("<a "))
#expect(!content.renderHTML().contains("<code>"))
#expect(!content.renderHTML().contains("<ul>"))
#expect(content.renderHTML().contains("&lt;script&gt;"))
}
@Test func literalLineBreaksAndParagraphsStayDistinct() {
let content = SybilMessageText.literalContent("First\nSecond\n\nThird")
#expect(content.renderPlainText() == "First\nSecond\n\nThird")
#expect(content.renderHTML() == "<p>First\nSecond</p>\n<p>Third</p>\n")
// Empty paragraphs retain layout space instead of disappearing.
#expect(SybilMessageText.literalContent("\n\nFirst\n\n\n\nLast\n\n").renderHTML().components(separatedBy: "<p>").count == 6)
}
@Test func pastedParagraphBoundariesMatchMarkdown() {
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
#expect(SybilMessageText.literalContent(source).renderHTML() == "<p>First</p>\n<p>Second</p>\n")
}
#expect(SybilMessageText.literalContent("First\r\nSecond").renderPlainText() == "First\nSecond")
#expect(SybilMessageText.literalContent(" **literal** \n\nLast").renderPlainText() == " **literal** \n\nLast")
#expect(SybilMessageText.literalContent("First\r\n \r\n\t\r\n\r\nLast") == SybilMessageText.literalContent("First\n\n\n\nLast"))
}
@Test func bothFormatsShareLineParagraphMetricsAndDynamicType() {
func height(_ content: String, _ format: SybilMessageText.Format, _ size: DynamicTypeSize) -> CGFloat {
let host = UIHostingController(rootView: SybilMessageText(content: content, format: format)
.environment(\.dynamicTypeSize, size))
return host.sizeThatFits(in: CGSize(width: 320, height: 10_000)).height
}
let literal = "First line\nSecond line\n\nAnother paragraph"
let markdown = "First line \nSecond line\n\nAnother paragraph"
for size in [DynamicTypeSize.large, .accessibility3] {
#expect(abs(height(literal, .literal, size) - height(markdown, .markdown, size)) < 1)
}
for source in ["First\r\n\r\nSecond", "First\n \nSecond", "First\n\t\nSecond"] {
#expect(abs(height(source, .literal, .large) - height(source, .markdown, .large)) < 1)
}
#expect(height(literal, .literal, .accessibility3) > height(literal, .literal, .large) * 1.5)
#expect(height(markdown, .markdown, .accessibility3) > height(markdown, .markdown, .large) * 1.5)
}
}
File diff suppressed because it is too large Load Diff
+223
View File
@@ -0,0 +1,223 @@
# Sybil for iOS and Mac Catalyst
Run `just run` for the iPhone simulator, or `just run-mac` for Mac Catalyst.
`just build-mac` builds without launching the Mac app. Both regenerate the Xcode
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
Question panel, including over other applications. The prompt is focused as soon
as it opens, with any existing prompt selected so typing replaces it. The same
select-on-open behavior applies to Quick Question on iPhone and iPad.
**Return** submits; **Shift+Return** inserts a new line. **Escape**, a
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. 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
build opts out of automatic termination, and the native shortcut service holds a
matching automatic-termination activity until it stops. No hidden keep-alive
window or sleep-preventing activity is used. Mac scene support also allows the
workspace to reopen after its last window is closed.
When Catalyst is backgrounded, opening Quick Question first requests app
activation and waits for UIKit to enter the foreground before creating or ordering
the native panel. Presentation is deferred out of the hotkey event callback.
Pressing Option+Space again cancels a pending presentation. Dismissing a panel
that required activation returns focus to the previous app; explicitly opening
the workspace keeps focus in Sybil.
On Mac, **Command+N** starts a new chat in the focused workspace, or opens a new
workspace window when none are open. **Command+Shift+N** always opens a new window,
including while signed out. **Command+Option+N** starts a new search; iOS retains
its existing Command+Shift+N search shortcut.
The shortcut is also shown under **File → Quick Question**. If another application
already owns Option+Space, the panel reports that the shortcut is unavailable;
the menu item can still be clicked. No Accessibility or Input Monitoring grant is
needed for the registered hotkey.
The native AppKit panel and Carbon hotkey live in `Apps/SybilMacQuickQuestion`,
a macOS bundle embedded only for the Mac Catalyst destination. Catalyst loads it
through the Foundation-only `SybilMacQuickQuestionBridge` contract. Requests,
provider/model preferences, streaming, and conversion to chat remain in the
existing `SybilViewModel`; there is no separate backend or API contract. The panel
uses AppKit controls rather than trying to load the macOS SwiftUI runtime into a
Catalyst process.
The iPhone/iPad app keeps its existing Quick Question sheet and Home Screen quick
action, and does not build or embed the macOS helper.
## Verification
- `just test`: existing Swift package tests on the iPhone simulator.
- From `Packages/Sybil`, run `xcodebuild test -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -parallel-testing-enabled NO` for package tests including the Catalyst panel controller.
- For a manual Mac check, open the panel from another app, type immediately,
submit, hide/reopen during streaming, and verify Escape/outside-click dismissal
returns input to the previous app. Check a second display and a full-screen Space
when available.
- Close every Mac window, invoke Option+Space, and verify the saved question and
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. 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.
- With all workspace windows closed, switch to another app and repeatedly open
and dismiss Quick Question, including two quick Option+Space presses while it
is opening. Check that no late panel appears after cancellation and that no
`UINSAppLifecycleStateRunningNoOpenWindows` assertion occurs.
+107
View File
@@ -0,0 +1,107 @@
# Agent interaction benchmarks
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
```sh
just generate
xcodebuild test -scheme SybilPresentation \
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
-only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
-resultBundlePath build/agent-input-performance.xcresult
```
`testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
messages through the normal room reducer (100 initially displayed), then:
- Receives 12 more messages at 550 ms intervals through the production 100 ms
publication batch and 240 ms entrance animation, checking bottom following.
- Presents and dismisses the actual software keyboard six times, 600 ms per
transition. It asserts that the transcript viewport shrinks by over 100 points
and remains at the bottom; a hardware-keyboard-only run is invalid.
- Calls UIKit `insertText` on the actual focused composer every 30 ms, including
enough text to wrap and grow to its six-line limit. It verifies the draft,
bottom following, and that keyboard/draft changes don't reproject the history.
The real composer, Markdown rows, avatars and entrance animations remain enabled.
No server, production credentials, notification delivery or network images are
needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
JSON and screenshots of the actual hosted view before and after typing.
Screenshots capture the hosting view, not the separate system keyboard window.
Use the same test source, simulator, build configuration and host load for
before/after measurements. Don't run other builds or profilers during sampling.
Functional assertions about observation boundaries should fail against the old
implementation; use the same sampling procedure when collecting a baseline.
## Measurement limits
Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure **CADisplayLink
main-run-loop callback delivery**, not compositor FPS, GPU hitches, touch-to-key
latency or physical-device performance. The hosted view excludes the surrounding
app shell. UIKit input exercises editing/layout without synthesizing key taps.
The first keyboard presentation includes system warmup; residual system and
simulator scheduling gaps remain. Native gestures, read settling, header fade,
attachments and reading anchors are covered separately by the loopback UI tests.
`AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
a drop in counts alone does not establish improved responsiveness.
The transcript now uses `LazyVStack` with the observation boundaries introduced
in `bbaa5e8`. Loading older history may move the viewport; this is the only
waived position guarantee. Native regressions still assert reading anchors for
incoming messages, bottom following, keyboard behavior, read settling, fade,
and attachments. There is no saved prepend anchor or convergence loop.
Lazy content height estimates change as offscreen rows materialize. The earlier
43869→35870 point difference was not shortened content: after-arrival screenshots
were byte-identical. Compare visible geometry and native behavior, not estimated
whole-history height, when assessing lazy performance.
## Scrolling harness
`testThinkingScrollResponsiveness` retains the earlier three approximately
12-second phases: idle scroll, stationary thinking, and scrolling with changing
queue/worker/tool activity every 500 ms. Its display link sweeps 2400 points at
600 points/second. This programmatic sweep does not reproduce native drag phases;
use the UI tests for reading-anchor geometry and interactive keyboard dismissal.
The eager-stack reference in `b9a6f5d` has exact heights of 39181.333
points idle and 39223.333 while thinking. That reference harness retains its
eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison.
Those totals are not valid assertions about lazy estimated content size. Use the
input harness and native gesture tests above for the current lazy implementation.
That earlier scrolling fix reduced busy-scroll p95 from 88.8 to 19.4 ms; it did
not address the input/entrance paths measured here. Physical phone validation
remains necessary for hardware performance claims.
## 2026-10-16 comparison before the prepend waiver
Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at
`7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds:
| Path | Before | Eager isolation (`bbaa5e8`) | Earlier lazy experiment |
| --- | ---: | ---: | ---: |
| 12 message entrances | 40.8 | 33.2 | 16.8 |
| Six keyboard cycles | 33.2 | 33.1 | 29.2 |
| Multiline composer typing | 24.8 | 25.2 | 21.3 |
Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame
preference callbacks in these pinned-bottom phases. Eager content height stayed
43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
and typing p95 did not improve. These single runs establish removed transcript
work and a safer entrance improvement, not universally smooth input or device FPS.
The lazy option was initially rejected because strict prepend anchors failed.
The subsequent release accepts that prepend tradeoff; all other guarantees remain.
## Lazy release after the prepend waiver
The same input harness passes unchanged: p95 entrance/keyboard/typing gaps are
16.9/28.1/20.9 ms, versus the eager run's 33.2/33.1/25.2 ms. Projections stay
12/0/0; pinned row-frame preference callbacks stay zero. The after-arrival PNG
is byte-identical to the eager baseline. Residual gaps remain (one keyboard gap
over 50 ms and one typing gap over 25 ms); these are single simulator samples. Five
focused native regressions pass with only the two prepend-offset assertions
replaced by checks that the remaining history loaded.
+350
View File
@@ -0,0 +1,350 @@
#!/usr/bin/env python3
"""Local-only deterministic Agent contract fixture. Never contacts external services.
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
"""
import asyncio
import datetime
import json
import hashlib
import unicodedata
import uuid
from urllib.parse import quote
from aiohttp import web
sockets = set()
messages = []
requests = {}
read_cursor = 0
cursor = 0
busy = False
attempts = {}
log = []
reply_tasks = set()
attachments = {}
uploads = {}
attachment_capability = True
next_upload_error = None
blocked_proxy = False
transcript_comparison = False
PUSH_SOURCE = '11111111-1111-4111-8111-111111111111'
push_installations = {}
push_retired = set()
push_requests = []
push_available = True
auth_required = False
CAPABILITY = dict(version=1, maxCount=8, maxTotalBytes=33554432, maxImageBytes=6291456,
maxFileBytes=8388608, imageMimeTypes=['image/png', 'image/jpeg', 'image/gif', 'image/webp'],
maxImagePixels=50000000, maxImageDimension=16384, quotaBytes=1073741824, usedBytes=0,
remainingBytes=1073741824, maxConcurrentUploads=2, maxConcurrentDecodes=1)
def attachment_error(status, code, message, **fields):
return web.json_response(dict(error=True, message=message, statusCode=status, code=code, **fields), status=status,
headers={"Retry-After": "2"} if status == 429 else None)
def stamp():
return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='milliseconds').replace('+00:00', 'Z')
def activity():
return dict(state='thinking' if busy else 'idle', isThinking=busy, queued=0,
backgroundWorkers=0, tools=[], queuedMessages=[])
def message(content, role='assistant', id=None, info=None):
global cursor
cursor += 1
row = dict(id=id or f'outbound:{cursor}', role=role, content=content, createdAt=stamp(),
metadata=dict(kind='message', entryId=cursor, status='complete', **(info or {})))
messages.append(row)
return row
def page(rows, position=None, more=False):
return dict(conversationId='fixture-root', messages=rows, cursor=str(cursor if position is None else position), beforeCursor='1', hasMore=more)
async def broadcast(frame):
for socket in list(sockets):
try:
await socket.send_json(frame)
except ConnectionError:
sockets.discard(socket)
async def emit(content):
row = message(content)
await broadcast(dict(type='messages', **page([row])))
return row
async def reply(content):
global busy, cursor
await asyncio.sleep(1.2)
tool = message('Inspecting the fixture', 'tool', id=f'tool:{cursor + 1}')
tool['metadata'].update(kind='tool_call', toolCallId=tool['id'], toolName='fixture_search', status='initiated', args={'query': content}, startedAt=stamp())
await broadcast(dict(type='messages', **page([tool])))
await asyncio.sleep(0.8)
cursor += 1
tool = dict(tool, metadata=dict(tool['metadata'], entryId=cursor, status='completed', resultPreview='Fixture result: 3 matches'))
messages[messages.index(next(m for m in messages if m['id'] == tool['id']))] = tool
await broadcast(dict(type='messages', **page([tool])))
failed = message('Fixture unavailable', 'tool', id=f'tool:{cursor + 1}')
failed['metadata'].update(kind='tool_call', toolName='fixture_error', status='failed', args={'source': 'local only'}, error='Simulated tool failure')
await broadcast(dict(type='messages', **page([failed])))
await emit(f'**Finished:** {content}\n\nThe local fixture returned three matches.')
# Deliberately stale busy frame after the public reply.
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
await asyncio.sleep(0.5)
busy = False
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
async def route(request):
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
path = request.path
log.append(dict(method=request.method, path=path, query=dict(request.query)))
if path == '/control':
body = await request.json()
action = body.get('action')
if action == 'reset':
for task in reply_tasks:
task.cancel()
reply_tasks.clear()
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
push_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
push_available = True; auth_required = False
for i in range(body.get('count', 3)):
message(f'History message {i + 1}: local simulator fixture.')
elif action == 'presentation-samples':
# Same source messages in normal chat and Agent; loopback-only UI comparisons.
messages.clear(); cursor = 0; read_cursor = 0
transcript_comparison = True
for sample in body['messages']:
message(sample['content'], sample['role'])
elif action == 'transcript-comparison':
transcript_comparison = True
elif action == 'capability':
attachment_capability = body.get('enabled', True)
elif action == 'upload-error':
next_upload_error = body
elif action == 'blocked-proxy':
blocked_proxy = body.get('enabled', True)
elif action == 'attachment-history':
selected = [attachments[key]['descriptor'] for key in body['ids']]
row = message(body.get('content') or 'Attached: ' + ', '.join(a['filename'] for a in selected) + '.', 'user', info={'agentAttachments': selected})
await broadcast(dict(type='messages', **page([row])))
elif action == 'reply':
await emit(body.get('content', 'Late asynchronous fixture reply'))
elif action == 'long-tool':
row = message('Local tool output', 'tool')
row['metadata'].update(kind='tool_call', toolName='fixture_long', status='completed',
args={'source': 'local only'}, result='\n'.join(f'Result line {i}' for i in range(80)))
await broadcast(dict(type='messages', **page([row])))
elif action == 'busy':
busy = body.get('value', True)
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
elif action == 'progress':
busy = True
progress = dict(activity(), queued=body['queued'], backgroundWorkers=body['workers'],
tools=[dict(id='local-review', name='review_transcript', status='running',
output=f"Checked {body['step']} local entries")])
await broadcast(dict(type='activity', conversationId='fixture-root', activity=progress))
elif action == 'edit-last':
cursor += 1
row = dict(messages[-1], content=body['content'],
metadata=dict(messages[-1]['metadata'], entryId=cursor))
messages[-1] = row
await broadcast(dict(type='messages', **page([row])))
elif action == 'read':
read_cursor = cursor
await broadcast(dict(type='read_state', sourceId=PUSH_SOURCE, cursor=str(read_cursor), updatedAt=stamp()))
elif action == 'push-available':
push_available = body.get('enabled', True)
elif action == 'auth-required':
auth_required = body.get('enabled', True)
elif action == 'disconnect':
for socket in list(sockets):
await socket.close(code=1012)
elif action == 'malformed':
await broadcast(dict(type='messages', conversationId='fixture-root', messages=[{'id': 'broken'}], cursor='999999', beforeCursor=None, hasMore=False))
return web.json_response(dict(cursor=cursor, readCursor=read_cursor, messages=messages, requests=requests, log=log,
pushRequests=push_requests, pushActive=len(push_installations), pushRetired=len(push_retired)))
if path == '/api/v1/auth/session':
if auth_required and request.headers.get('Authorization') != 'Bearer fixture-token':
return web.json_response(dict(message='Fixture authentication required'), status=401)
return web.json_response(dict(authenticated=True, mode='token'))
comparison = dict(id='fade-comparison', title='Transcript fade comparison', createdAt=stamp(), updatedAt=stamp())
if path == '/api/v1/workspace-items':
return web.json_response(dict(items=[dict(comparison, type='chat')] if transcript_comparison else [], hasMore=False))
if path == '/api/v1/chats':
return web.json_response(dict(chats=[comparison] if transcript_comparison else []))
if path == '/api/v1/chats/fade-comparison' and transcript_comparison:
return web.json_response(dict(chat=dict(comparison, messages=messages)))
if path == '/api/v1/searches':
return web.json_response(dict(searches=[]))
if path == '/api/v1/models':
return web.json_response(dict(providers={}))
if path == '/api/v1/active-runs':
return web.json_response(dict(chats=[], searches=[]))
if path.startswith('/api/v1/agent') and path != '/api/v1/agent/ws':
if request.headers.get('Authorization') != 'Bearer fixture-token':
return web.json_response(dict(error='Fixture token missing'), status=401)
if blocked_proxy:
return attachment_error(503, 'attachments_unavailable', 'Synthetic proxy requires safely authenticated Agent access.')
if path == '/api/v1/agent':
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
if attachment_capability:
descriptor['capabilities'] = {'attachments': CAPABILITY}
return web.json_response(descriptor)
if path == '/api/v1/agent/push/config':
if not push_available:
return web.json_response(dict(message='Old server fixture'), status=404)
return web.json_response(dict(version=1, enabled=True, apnsAvailable=True, webPushAvailable=False,
sourceId=PUSH_SOURCE, quarantined=False, policy={}))
if path.startswith('/api/v1/agent/push/installations/'):
installation = str(uuid.UUID(path.rsplit('/', 1)[1]))
if request.method == 'DELETE':
push_installations.pop(installation, None); push_retired.add(installation)
push_requests.append(dict(method='DELETE', installationId=installation))
return web.Response(status=204)
if request.method == 'PUT':
body = await request.json()
assert set(body) == {'transport', 'enabled', 'token', 'environment', 'topic'}
assert body['transport'] == 'apns' and body['enabled'] is True
assert body['environment'] == 'sandbox' and body['topic'] == 'net.buzzert.sybil2'
assert body['token'] == '00010aff' + '42' * 28 # explicitly injected synthetic token
if installation in push_retired:
return web.json_response(dict(message='Retired installation'), status=409)
version = push_installations.get(installation, 0) + 1
push_installations[installation] = version
push_requests.append(dict(method='PUT', installationId=installation, environment='sandbox'))
return web.json_response(dict(installationId=installation, transport='apns', enabled=True, version=version))
if path == '/api/v1/agent/attachments' and request.method == 'POST':
if not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if next_upload_error:
failure = next_upload_error; next_upload_error = None
return attachment_error(failure.get('status', 507), failure.get('code', 'attachment_quota_exceeded'),
'Synthetic attachment storage is full; contact the operator or retry later.', **({'quotaBytes': 1073741824, 'remainingBytes': 0, 'requiredBytes': 20000} if failure.get('status', 507) == 507 else {}))
reader = await request.multipart()
parts = {}
while part := await reader.next():
if part.name not in ('file', 'uploadId') or part.name in parts:
return attachment_error(400, 'invalid_upload', 'Exactly one file and uploadId are required.')
parts[part.name] = (part.filename, part.headers.get('Content-Type'), bytes(await part.read(decode=False)))
if set(parts) != {'file', 'uploadId'}:
return attachment_error(400, 'invalid_upload', 'Missing file or uploadId.')
try:
upload_id = str(uuid.UUID(parts['uploadId'][2].decode()))
filename = unicodedata.normalize('NFC', parts['file'][0])
assert filename and len(filename.encode()) <= 255 and filename not in ('.', '..')
assert not any(c in '/\\' or unicodedata.category(c) == 'Cc' for c in filename)
except (ValueError, AssertionError, TypeError):
return attachment_error(400, 'invalid_upload', 'Invalid upload ID or filename.')
_, mime, data = parts['file']
mime = (mime or 'application/octet-stream').lower()
if len(data) > CAPABILITY['maxFileBytes']:
return attachment_error(413, 'attachment_limit_exceeded', 'File exceeds the upload limit.')
digest = hashlib.sha256(data).hexdigest()
identity = (filename, mime, digest)
if upload_id in uploads:
old_identity, attachment_id = uploads[upload_id]
if old_identity != identity:
return attachment_error(409, 'idempotency_conflict', 'Upload ID already belongs to another original.')
return web.json_response({'attachment': attachments[attachment_id]['descriptor']})
kind = 'file'
if mime in CAPABILITY['imageMimeTypes']:
kind = 'image' # Synthetic fixture images are generated by the test; runtime validates decoding.
elif mime.startswith('text/'):
try:
if '\0' not in data.decode('utf-8-sig'):
kind = 'text'
except UnicodeDecodeError:
pass
attachment_id = 'fixture-' + str(uuid.uuid4())
descriptor = dict(id=attachment_id, kind=kind, filename=filename, mimeType=mime, sizeBytes=len(data), sha256=digest,
workspacePath='/data/workspace/uploads/' + attachment_id + '/' + ('_' + filename[1:] if filename.startswith('.') else filename))
attachments[attachment_id] = dict(descriptor=descriptor, data=data)
uploads[upload_id] = (identity, attachment_id)
return web.json_response({'attachment': descriptor}, status=201)
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
stored = attachments.get(attachment_id)
if stored is None:
return attachment_error(404, 'attachment_not_found', 'The original is unavailable.')
descriptor = stored['descriptor']
mime = descriptor['mimeType'] if descriptor['kind'] == 'image' else 'text/plain' if descriptor['kind'] == 'text' else 'application/octet-stream'
return web.Response(body=stored['data'], content_type=mime, headers={'Cache-Control': 'private, no-store',
'Content-Disposition': 'attachment; filename="original"; filename*=UTF-8\'\'' + quote(descriptor['filename'], safe=''), 'X-Content-Type-Options': 'nosniff'})
if path == '/api/v1/agent/read-state':
if request.method == 'PUT':
read_cursor = max(read_cursor, int((await request.json())['cursor']))
return web.json_response(dict(cursor=str(read_cursor), updatedAt=stamp(), sourceId=PUSH_SOURCE))
if path == '/api/v1/agent/messages':
if request.method == 'POST':
body = await request.json()
key, content = body['requestId'], body['content']
refs = body.get('attachments', [])
ids = [ref.get('id') for ref in refs]
if refs and not attachment_capability:
return attachment_error(503, 'attachments_unavailable', 'Attachments are unavailable.')
if any(set(ref) != {'id'} for ref in refs) or len(ids) != len(set(ids)) or len(ids) > 8:
return attachment_error(400, 'invalid_attachment', 'Invalid ordered references.')
if any(key not in attachments for key in ids):
return attachment_error(404, 'attachment_not_found', 'The attachment does not exist.')
if not content.strip() and not ids:
return attachment_error(400, 'invalid_attachment', 'Enter a caption or attach files.')
if key in requests and (ids or requests[key].get('attachmentIds')) and requests[key].get('_payload') != [content, ids]:
return attachment_error(409, 'idempotency_conflict', 'Message identity is already bound to another payload.')
attempts[key] = attempts.get(key, 0) + 1
if content == 'retry fixture' and attempts[key] == 1:
return web.json_response(dict(error='Simulated uncertain admission; retry'), status=502)
receipt = dict(id=len(requests) + 1, status='queued', messageId=f'inbound:sybil:{key}')
if ids:
receipt['attachmentIds'] = ids
if key not in requests:
requests[key] = dict(receipt, _payload=[content, ids])
descriptors = [attachments[key]['descriptor'] for key in ids]
display = content if content.strip() else 'Attached: ' + ', '.join(a['filename'] for a in descriptors) + '.'
row = message(display, 'user', id=receipt['messageId'], info={'agentAttachments': descriptors} if ids else None)
busy = True
await broadcast(dict(type='messages', **page([row])))
await broadcast(dict(type='activity', conversationId='fixture-root', activity=activity()))
task = asyncio.create_task(reply(content))
reply_tasks.add(task)
task.add_done_callback(reply_tasks.discard)
if content == 'echo wins fixture':
await asyncio.sleep(0.3)
return web.json_response(dict(error='Lost HTTP acknowledgement'), status=502)
return web.json_response({k: v for k, v in requests[key].items() if k != '_payload'}, status=202)
after = int(request.query.get('after', 0))
rows = [m for m in messages if m['metadata']['entryId'] > after]
limit = min(int(request.query.get('limit', 200)), 200)
selected = rows[:limit]
return web.json_response(page(selected, selected[-1]['metadata']['entryId'] if len(rows) > limit else cursor, len(rows) > limit))
if path == '/api/v1/agent/ws':
if request.query:
return web.Response(status=400)
socket = web.WebSocketResponse(); await socket.prepare(request)
if blocked_proxy:
await socket.close(code=4503, message=b'attachments_unavailable')
return socket
auth = await socket.receive_json(timeout=5)
if auth.get('token') != 'fixture-token' or auth.get('type') != 'auth':
await socket.close(code=4401); return socket
log.append(dict(authAfter=auth.get('after')))
sockets.add(socket)
await socket.send_json(dict(type='ready', protocol='pi-room.v1', conversationId='fixture-root'))
await socket.send_json(dict(type='messages', **page([m for m in messages if m['metadata']['entryId'] > int(auth.get('after', 0))])))
await socket.send_json(dict(type='activity', conversationId='fixture-root', activity=activity()))
try:
async for frame in socket:
if frame.type == web.WSMsgType.TEXT:
data = json.loads(frame.data)
if data.get('type') == 'ping':
await socket.send_json(dict(type='pong'))
finally:
sockets.discard(socket)
return socket
return web.json_response(dict(error='No fixture for this endpoint'), status=404)
for i in range(3):
message(f'History message {i + 1}: local simulator fixture.')
app = web.Application(client_max_size=8 * 1024 * 1024 + 64 * 1024)
app.router.add_route('*', '/{path:.*}', route)
web.run_app(app, host='127.0.0.1', port=8799)
@@ -0,0 +1,110 @@
import XCTest
import SwiftUI
@testable import Sybil
private let originalBytes = Data([0, 255, 10, 13, 128, 42])
private let attachmentLimits = AgentAttachmentCapabilities(version: 1, maxCount: 8, maxTotalBytes: 33_554_432,
maxImageBytes: 6_291_456, maxFileBytes: 8_388_608, imageMimeTypes: ["image/png", "image/jpeg"],
maxImagePixels: 50_000_000, maxImageDimension: 16_384)
/// In-memory upload-only fixture. Every other path fails; no socket/provider IO.
private actor AttachmentTestClient: AgentAPIClienting {
var fail = false
var delay = Duration.milliseconds(1200)
func failNextUpload(delay: Duration) { fail = true; self.delay = delay }
func agentUpload(file: AgentPreparedAttachment, uploadID: String) async throws -> AgentAttachment {
try await Task.sleep(for: delay)
if fail {
fail = false
throw AgentAttachmentIssue(message: "Free storage or retry later.", code: "attachment_quota_exceeded", statusCode: 507)
}
return AgentAttachment(id: "synthetic-" + uploadID, kind: .file, filename: file.filename,
mimeType: file.mimeType, sizeBytes: file.data.count, sha256: file.sha256)
}
func agentDescriptor() throws -> AgentDescriptor { throw AgentAttachmentIssue.unavailable }
func agentMessages(after: String?, before: String?) throws -> AgentPage { throw AgentAttachmentIssue.unavailable }
func agentReadState() throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
func agentMarkRead(cursor: String) throws -> AgentReadState { throw AgentAttachmentIssue.unavailable }
func agentSubmit(content: String, requestID: String) throws -> AgentReceipt { throw AgentAttachmentIssue.unavailable }
func agentSocket(after: String) throws -> any AgentSocket { throw AgentAttachmentIssue.unavailable }
}
@MainActor private func attachmentEventually(_ predicate: () -> Bool) async -> Bool {
for _ in 0..<200 { if predicate() { return true }; try? await Task.sleep(for: .milliseconds(10)) }
return false
}
// Rendering the production composer against the in-memory upload fixture
// covers transient states without changing the shipping picker or contacting a provider.
@MainActor
final class AgentComposerPresentationTests: XCTestCase {
func testLoadingUploadingFailureRetryCapacityAndCapabilitySnapshots() async throws {
XCTAssertTrue(UserDefaults.standard.string(forKey: "sybil.ios.apiBaseURL") == "http://127.0.0.1:8799/api")
XCTAssertTrue(ProcessInfo.processInfo.environment["SYBIL_PUSH_FIXTURE"] == "1")
let room = SybilAgentRoom(), client = AttachmentTestClient()
let draft = room.attachmentDraft
draft.configure(client: client, storage: nil)
var limits = attachmentLimits
limits.maxCount = 1
draft.updateCapability(limits)
await client.failNextUpload(delay: .milliseconds(1200))
await capture(room, "composer-enabled-empty")
let id = try XCTUnwrap(draft.add(filename: "synthetic-original.bin") {
try await Task.sleep(for: .milliseconds(600))
return .init(filename: "synthetic-original.bin", mimeType: "application/octet-stream", data: originalBytes)
})
XCTAssertFalse(draft.canSend)
XCTAssertEqual(draft.items.first?.state, .loading)
await capture(room, "composer-loading-at-capacity-disabled")
let uploading = await attachmentEventually { draft.items.first?.state == .uploading }
XCTAssertTrue(uploading)
await capture(room, "composer-uploading-at-capacity-disabled")
let failed = await attachmentEventually { draft.items.first?.state == .failed }
XCTAssertTrue(failed); XCTAssertFalse(draft.canSend)
await capture(room, "composer-error-retry-disabled-send")
draft.retry(id)
let ready = await attachmentEventually { draft.items.first?.state == .ready }
XCTAssertTrue(ready); XCTAssertTrue(draft.canSend)
await capture(room, "composer-ready-enabled-send")
await capture(room, "composer-ready-accessibility3", size: .accessibility3)
draft.updateCapability(nil)
XCTAssertFalse(draft.canSend)
await capture(room, "composer-unavailable-retains-attachment")
draft.remove(id)
draft.caption = "Text still works"
XCTAssertTrue(draft.canSend)
await capture(room, "composer-text-only-capability")
draft.suspend()
}
private func capture(_ room: SybilAgentRoom, _ name: String, size: DynamicTypeSize = .large) async {
// ImageRenderer omits UIKit-backed fields/scroll views; host the real hierarchy.
let host = UIHostingController(rootView: AgentComposerPreview(room: room)
.padding(12).background(SybilTheme.background)
.environment(\.colorScheme, .dark).environment(\.dynamicTypeSize, size))
let fitting = host.sizeThatFits(in: CGSize(width: 414, height: 600))
let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 414, height: max(120, fitting.height)))
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true; window.rootViewController = nil }
host.view.frame = window.bounds
host.view.setNeedsLayout(); host.view.layoutIfNeeded()
try? await Task.sleep(for: .milliseconds(100))
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
XCTAssertTrue(host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true))
}
let attachment = XCTAttachment(image: image)
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
}
private struct AgentComposerPreview: View {
var room: SybilAgentRoom
@FocusState private var focused: Bool
var body: some View {
AgentAttachmentComposer(room: room) {
SybilPromptField(text: .constant(room.composer), focused: $focused, submit: {})
SybilPromptSendButton(canSend: room.attachmentDraft.canSend, submit: {})
}
}
}
@@ -0,0 +1,252 @@
import XCTest
import SwiftUI
@testable import Sybil
/// Run on an iPhone simulator, with the real Agent view and deterministic local
/// messages. Display-link delivery gaps measure main-run-loop responsiveness,
/// not GPU presentation, touch latency, or physical-device frame rate.
@MainActor
final class AgentPerformanceTests: XCTestCase {
func testThinkingScrollResponsiveness() async throws {
let room = SybilAgentRoom()
var rows: [Message] = []
for index in 1...160 { rows.append(Self.message(index)) }
try room.receive(.messages(AgentPage(conversationId: "performance", messages: rows, cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
room.flush()
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
.background(SybilTheme.background).environment(\.colorScheme, .dark))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
defer {
AgentRenderDiagnostics.enabled = false
window.isHidden = true
window.rootViewController = nil
room.stop()
}
host.view.frame = window.bounds
host.view.layoutIfNeeded()
try await Task.sleep(for: .seconds(2))
let scroll = try XCTUnwrap(findScroll(host.view))
XCTAssertGreaterThan(scroll.contentSize.height, 10_000)
// Move away from the pinned bottom before the controlled sweeps.
scroll.setContentOffset(CGPoint(x: 0, y: max(0, scroll.contentSize.height - scroll.bounds.height - 1200)), animated: false)
try await Task.sleep(for: .seconds(1))
AgentRenderDiagnostics.enabled = true
var reports: [[String: Any]] = []
for phase in ["idle-scroll", "thinking-stationary", "thinking-scroll"] {
if phase == "thinking-stationary" {
try room.receive(.activity("performance", busyActivity(step: 0)))
room.flush()
scroll.setContentOffset(CGPoint(x: 0, y: scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom), animated: false)
try await Task.sleep(for: .seconds(1))
XCTAssertTrue(room.snapshot.isTyping)
}
AgentRenderDiagnostics.counts = [:]
let sampler = AgentFrameSampler(scroll: phase == "thinking-stationary" ? nil : scroll)
sampler.start()
// Changing queue/worker/tool progress every 500 ms exercises real
// activity observation while the typing spinner remains active. No IO.
for step in 1...24 {
try await Task.sleep(for: .milliseconds(500))
if phase != "idle-scroll" {
try room.receive(.activity("performance", busyActivity(step: step)))
}
}
var report = sampler.stop()
report["phase"] = phase
report["procedure"] = "mixed160-changing-activity-v1"
report["operations"] = AgentRenderDiagnostics.counts
report["contentHeight"] = scroll.contentSize.height
// iPhone 16e / iOS 26.5 / default text size reference geometry from
// the unoptimized view. A faster result must not shrink the content.
XCTAssertEqual(scroll.contentSize.height,
phase == "idle-scroll" ? 39_181.333333 : 39_223.333333,
accuracy: 1)
reports.append(report)
}
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
print("AGENT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
}
/// Exercises UIKit editing/focus and the real message reducer; no synthetic
/// view rebuild loop or replacement composer/animation is used.
func testInputAndEntranceResponsiveness() async throws {
let room = SybilAgentRoom()
try room.receive(.messages(AgentPage(conversationId: "performance",
messages: (1...160).map(Self.message), cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
room.flush()
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
.background(SybilTheme.background).environment(\.colorScheme, .dark))
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
let window = UIWindow(windowScene: scene)
window.rootViewController = host
window.makeKeyAndVisible()
defer {
host.view.endEditing(true)
AgentRenderDiagnostics.enabled = false
window.isHidden = true; window.rootViewController = nil
room.stop()
}
host.view.frame = window.bounds
host.view.layoutIfNeeded()
try await Task.sleep(for: .seconds(2))
let scroll = try XCTUnwrap(findScroll(host.view))
let input = try XCTUnwrap(findInput(host.view))
AgentRenderDiagnostics.enabled = true
var reports: [[String: Any]] = []
func report(_ sampler: AgentFrameSampler, phase: String) {
var value = sampler.stop()
value["phase"] = phase
value["procedure"] = "mixed160-input-entrance-v1"
value["operations"] = AgentRenderDiagnostics.counts
value["contentHeight"] = scroll.contentSize.height
value["viewportHeight"] = scroll.bounds.height
reports.append(value)
}
AgentRenderDiagnostics.counts = [:]
let entrance = AgentFrameSampler(scroll: nil)
entrance.start()
for index in 161...172 {
try room.receive(.messages(AgentPage(conversationId: "performance",
messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false)))
// Use the production 100 ms publication batch and 240 ms entrance.
try await Task.sleep(for: .milliseconds(550))
assertAtBottom(scroll)
}
XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24,
"Entrance frames must not repeatedly project the history")
report(entrance, phase: "message-entrance")
XCTAssertEqual(room.snapshot.room.messages.count, 172)
assertAtBottom(scroll)
capture(host, "input-entrance-latest")
let unfocusedHeight = scroll.bounds.height
AgentRenderDiagnostics.counts = [:]
let keyboard = AgentFrameSampler(scroll: nil)
keyboard.start()
for _ in 0..<6 {
XCTAssertTrue(input.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(600))
XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript")
assertAtBottom(scroll)
XCTAssertTrue(input.resignFirstResponder())
try await Task.sleep(for: .milliseconds(600))
}
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
"Keyboard focus and geometry must not project the transcript")
report(keyboard, phase: "keyboard-presentation")
XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1)
XCTAssertTrue(input.becomeFirstResponder())
try await Task.sleep(for: .milliseconds(700))
let keyInput = try XCTUnwrap(input as? any UIKeyInput)
AgentRenderDiagnostics.counts = [:]
let typing = AgentFrameSampler(scroll: nil)
typing.start()
let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. "
for character in text + text {
keyInput.insertText(String(character))
try await Task.sleep(for: .milliseconds(30))
}
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
"Draft edits and line wrapping must not project the transcript")
assertAtBottom(scroll)
report(typing, phase: "composer-typing")
XCTAssertEqual(room.composer, text + text)
capture(host, "input-multiline-keyboard")
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment)
}
private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) {
XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom,
scroll.contentSize.height, accuracy: 25, file: file, line: line)
}
private func findInput(_ view: UIView) -> UIView? {
if view is UITextView || view is UITextField { return view }
return view.subviews.lazy.compactMap { self.findInput($0) }.first
}
private func capture(_ host: UIViewController, _ name: String) {
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
}
let attachment = XCTAttachment(image: image)
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
private static func message(_ index: Int) -> Message {
let own = index % 3 == 0
let content = own
? "Can you review step \(index)? Keep the scroll position and the **literal input** intact.\n\nInclude the next checks."
: "### Investigation \(index)\n\nThe worker checked the local transcript and found a repeatable result. This paragraph contains **emphasis**, `inline code`, and a [reference](https://example.com).\n\n- Preserve the reading position while new messages arrive.\n- Keep attachments, queued messages, and status updates visible.\n- Verify the result on the simulator before publishing.\n\n```swift\nlet item = messages[\(index)]\nrender(item)\n```\n\nThe next step is to compare the measurements and report any remaining limitations."
return Message(id: "perf-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000 + Double(index * 60)),
role: own ? .user : .assistant, content: content,
metadata: .object(["kind": .string("message"), "entryId": .string(String(index))]))
}
private func busyActivity(step: Int) -> AgentActivity {
AgentActivity(state: "thinking", isThinking: true, queued: 1 + step % 3,
backgroundWorkers: 1 + step % 2,
tools: [.init(id: "local-review", name: "review_transcript", status: "running",
output: "Checked \(step * 7) of 200 local entries; preparing batch \(step + 1).")])
}
private func findScroll(_ view: UIView) -> UIScrollView? {
if let scroll = view as? UIScrollView, scroll.contentSize.height > 10_000 { return scroll }
return view.subviews.lazy.compactMap { self.findScroll($0) }.first
}
}
@MainActor
private final class AgentFrameSampler: NSObject {
private weak var scroll: UIScrollView?
private var link: CADisplayLink?
private var previous: CFTimeInterval?
private var started: CFTimeInterval = 0
private var gaps: [Double] = []
private var budgets: [Double] = []
private var origin: CGFloat = 0
private var scrollDistance: CGFloat = 0
init(scroll: UIScrollView?) { self.scroll = scroll }
func start() {
origin = scroll?.contentOffset.y ?? 0
started = CACurrentMediaTime()
let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 60, preferred: 60)
link.add(to: .main, forMode: .common)
self.link = link
}
@objc private func frame(_ link: CADisplayLink) {
let now = CACurrentMediaTime()
if let previous { gaps.append((now - previous) * 1000); budgets.append((link.targetTimestamp - link.timestamp) * 1000) }
previous = now
if let scroll {
// Constant-speed triangular sweep over 2400 points, one cycle / 8 s.
let phase = (now - started).truncatingRemainder(dividingBy: 8) / 4
let delta = CGFloat(phase <= 1 ? phase : 2 - phase) * 2400
let target = max(-scroll.adjustedContentInset.top, origin - delta)
scrollDistance += abs(target - scroll.contentOffset.y)
scroll.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
func stop() -> [String: Any] {
link?.invalidate(); link = nil
let sorted = gaps.sorted()
let budget = budgets.sorted()[budgets.count / 2]
func percentile(_ value: Double) -> Double { sorted[min(sorted.count - 1, Int(Double(sorted.count - 1) * value))] }
return ["seconds": CACurrentMediaTime() - started, "callbacks": gaps.count,
"medianBudgetMS": budget, "p50MS": percentile(0.5), "p95MS": percentile(0.95),
"p99MS": percentile(0.99), "maxMS": sorted.last ?? 0,
"over25MS": gaps.filter { $0 > 25 }.count, "over50MS": gaps.filter { $0 > 50 }.count,
"missedBudgetIntervals": gaps.reduce(0) { $0 + max(0, Int(($1 / budget).rounded()) - 1) },
"scrollDistance": scrollDistance]
}
}
@@ -0,0 +1,118 @@
import XCTest
import UIKit
/// Actual normal chat and Agent views, using only the loopback contract fixture.
@MainActor final class AgentPolishUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
XCUIDevice.shared.orientation = .portrait
try await control(["action": "reset", "count": 0])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
}
override func tearDown() async throws { app.terminate() }
private func control(_ body: [String: Any]) async throws {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (_, response) = try await URLSession.shared.data(for: request)
XCTAssertEqual((response as? HTTPURLResponse)?.statusCode, 200)
}
private func sidebar() {
if !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 15))
app.buttons["Show sidebar"].tap()
}
}
private func openChat() {
sidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
XCTAssertTrue(chat.waitForExistence(timeout: 8)); chat.tap()
XCTAssertTrue(app.buttons["chat-attach"].waitForExistence(timeout: 8))
}
private func openAgent() {
sidebar(); app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.buttons["agent-attach"].waitForExistence(timeout: 8))
}
private func capture(_ name: String) {
let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
shot.name = name; shot.lifetime = .keepAlways; add(shot)
}
func testSharedAttachmentControlAndPickerFlows() async throws {
try await control(["action": "transcript-comparison"])
app.launch(); openChat()
let normal = app.buttons["chat-attach"]
XCTAssertEqual(normal.label, "Attach file"); XCTAssertTrue(normal.isEnabled)
let normalSize = normal.frame.size
let normalLabel = normal.label
XCTAssertGreaterThanOrEqual(normalSize.width, 44); XCTAssertGreaterThanOrEqual(normalSize.height, 44)
capture("polish-normal-attachment-control")
normal.tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
normal.tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
openAgent()
let agent = app.buttons["agent-attach"]
XCTAssertEqual(agent.label, normalLabel); XCTAssertTrue(agent.isEnabled)
XCTAssertEqual(agent.frame.size.width, normalSize.width, accuracy: 1)
XCTAssertEqual(agent.frame.size.height, normalSize.height, accuracy: 1)
// Same row as the prompt, including the minimum VoiceOver/touch target.
XCTAssertEqual(agent.frame.maxY, app.buttons["agent-send"].frame.maxY, accuracy: 1)
capture("polish-agent-attachment-control")
agent.tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
agent.tap(); app.buttons["Photo Library"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5)); app.buttons["Cancel"].firstMatch.tap()
}
func testUserAssistantMultilineParagraphMetricsInBothWorkspaces() async throws {
try await compareMetrics(user: "User first\nUser second\n\nUser paragraph",
peer: "Peer first \nPeer second\n\nPeer paragraph", name: "multiline-paragraphs")
}
func testPastedParagraphMetricsInBothWorkspaces() async throws {
try await compareMetrics(user: "User first\r\nUser second\r\n \r\nUser paragraph",
peer: "Peer first \nPeer second\n\t\nPeer paragraph", name: "pasted-paragraphs")
}
private func compareMetrics(user: String, peer: String, name: String) async throws {
try await control(["action": "presentation-samples", "messages": [
["role": "user", "content": user], ["role": "assistant", "content": peer]]])
app.launch(); openChat()
let normal = try metrics()
capture("polish-normal-" + name)
openAgent()
let agent = try metrics()
XCTAssertEqual(normal.0, agent.0, accuracy: 1)
XCTAssertEqual(normal.1, agent.1, accuracy: 1)
capture("polish-agent-" + name)
}
private func metrics() throws -> (CGFloat, CGFloat) {
let user = app.staticTexts["User first\nUser second"]
let peer = app.staticTexts["Peer first\nPeer second"]
XCTAssertTrue(user.waitForExistence(timeout: 8)); XCTAssertTrue(peer.waitForExistence(timeout: 8))
let userParagraph = app.staticTexts["User paragraph"], peerParagraph = app.staticTexts["Peer paragraph"]
XCTAssertTrue(userParagraph.exists); XCTAssertTrue(peerParagraph.exists)
XCTAssertEqual(user.frame.height, peer.frame.height, accuracy: 1)
let gap = userParagraph.frame.minY - user.frame.maxY
XCTAssertEqual(gap, peerParagraph.frame.minY - peer.frame.maxY, accuracy: 1)
XCTAssertGreaterThan(gap, 0)
return (user.frame.height, gap)
}
func testLiteralUserAndSemanticAssistantMarkdown() async throws {
try await control(["action": "presentation-samples", "messages": [
["role": "user", "content": "**literal bold** `literal code`\n- literal item\n[not a link](https://example.invalid)"],
["role": "assistant", "content": "Soft break\ncontinues here.\n\n**Real bold** and [Local link](http://127.0.0.1:8799).\n\n- First item\n- Second item\n\n```swift\nlet value = 42\n```"]]])
app.launch(); openChat()
assertMarkdownSemantics(); capture("polish-normal-literal-and-markdown")
openAgent()
assertMarkdownSemantics(); capture("polish-agent-literal-and-markdown")
try await control(["action": "long-tool"])
XCTAssertFalse(app.staticTexts["Local tool output"].exists)
}
private func assertMarkdownSemantics() {
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "**literal bold**")).firstMatch.waitForExistence(timeout: 8))
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "[not a link](https://example.invalid)")).firstMatch.exists)
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "First item")).firstMatch.exists)
XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "let value = 42")).firstMatch.exists)
XCTAssertTrue(app.links["Local link"].exists)
}
}
@@ -0,0 +1,128 @@
import XCTest
/// Local fixture + fake notification center/registrar only. No APNs provider or
/// system permission dialog is involved in these deterministic UI scenarios.
@MainActor final class AgentPushUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() async throws {
continueAfterFailure = false
try await control(["action": "reset", "count": 3])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", "fixture-token"]
app.launchEnvironment = ["SYBIL_PUSH_FIXTURE": "1", "SYBIL_PUSH_PERMISSION": "authorized"]
}
override func tearDown() async throws { app.terminate() }
@discardableResult private func control(_ body: [String: Any]) async throws -> [String: Any] {
var request = URLRequest(url: URL(string: "http://127.0.0.1:8799/control")!)
request.httpMethod = "POST"; request.httpBody = try JSONSerialization.data(withJSONObject: body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
}
private func settings() {
if !app.buttons["Settings"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20))
app.buttons["Show sidebar"].tap()
}
XCTAssertTrue(app.buttons["Settings"].waitForExistence(timeout: 5)); app.buttons["Settings"].tap()
XCTAssertTrue(app.buttons["agent-push-refresh"].waitForExistence(timeout: 5))
if !app.buttons["agent-push-refresh"].isHittable { app.scrollViews.firstMatch.swipeUp() }
}
private func capture(_ name: String) {
let attachment = XCTAttachment(screenshot: XCUIScreen.main.screenshot())
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
}
private func openAgent() {
if !app.buttons["agent-sidebar"].waitForExistence(timeout: 2) || !app.buttons["agent-sidebar"].isHittable {
XCTAssertTrue(app.buttons["Show sidebar"].waitForExistence(timeout: 20)); app.buttons["Show sidebar"].tap()
}
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 5))
}
private func ensureDisabled() {
if app.buttons["agent-push-disable"].exists { app.buttons["agent-push-disable"].tap() }
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
}
func testExplicitEnableRefreshAndDisable() async throws {
app.launch(); settings(); ensureDisabled()
let before = try await control(["action": "state"])
let count = (before["pushRequests"] as? [[String: Any]])?.count ?? 0
app.buttons["agent-push-enable"].tap()
XCTAssertTrue(app.buttons["agent-push-disable"].waitForExistence(timeout: 5))
let status = app.staticTexts["agent-push-status"]
let enabled = NSPredicate(format: "label == %@", "Notifications are enabled.")
let enabledExpectation = expectation(for: enabled, evaluatedWith: status)
await fulfillment(of: [enabledExpectation], timeout: 8)
capture("push-enabled-generic-preview")
app.buttons["agent-push-refresh"].tap()
let state = try await control(["action": "state"])
let requests = try XCTUnwrap(state["pushRequests"] as? [[String: Any]])
XCTAssertEqual(requests.count, count + 1)
XCTAssertEqual(requests.last?["environment"] as? String, "sandbox")
app.buttons["agent-push-disable"].tap()
XCTAssertTrue(app.buttons["agent-push-enable"].waitForExistence(timeout: 5))
let revoked = try await control(["action": "state"])
XCTAssertEqual(revoked["pushActive"] as? Int, 0)
capture("push-disabled-revoked")
}
func testColdTapRoutesAgentAndPreservesAttachmentCaption() async throws {
app.launch()
openAgent()
let composer = app.textFields["agent-composer"]
XCTAssertTrue(composer.waitForExistence(timeout: 5))
composer.tap(); composer.typeText("Push fixture saved caption")
app.terminate()
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
app.launch()
XCTAssertTrue(composer.waitForExistence(timeout: 20))
XCTAssertTrue((composer.value as? String ?? "").contains("Push fixture saved caption"))
XCTAssertTrue(app.buttons["agent-attach"].exists)
capture("push-cold-tap-agent-draft-preserved")
}
func testTapBeforeLoginWaitsThenRoutesAgent() async throws {
try await control(["action": "auth-required", "enabled": true])
app.launchArguments = ["-sybil.ios.apiBaseURL", "http://127.0.0.1:8799/api", "-sybil.ios.adminToken", ""]
app.launchEnvironment["SYBIL_PUSH_TAP"] = "1"
app.launch()
XCTAssertTrue(app.buttons["Connect"].waitForExistence(timeout: 15))
XCTAssertFalse(app.textFields["agent-composer"].exists)
let state = try await control(["action": "state"])
XCTAssertEqual(state["readCursor"] as? Int, 0)
app.secureTextFields.firstMatch.tap(); app.secureTextFields.firstMatch.typeText("fixture-token")
app.buttons["Connect"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 20))
capture("push-pending-auth-routed-after-login")
}
func testDeniedAndOldServerLeaveAgentUsable() async throws {
app.launchEnvironment["SYBIL_PUSH_PERMISSION"] = "denied"
app.launch(); settings()
XCTAssertTrue(app.buttons["Open iOS Notification Settings"].waitForExistence(timeout: 5))
capture("push-denied-settings")
try await control(["action": "push-available", "enabled": false])
app.buttons["agent-push-refresh"].tap()
XCTAssertTrue(app.buttons["Show sidebar"].exists); app.buttons["Show sidebar"].tap()
app.buttons["agent-sidebar"].tap()
XCTAssertTrue(app.textFields["agent-composer"].waitForExistence(timeout: 10))
XCTAssertTrue(app.staticTexts["History message 3: local simulator fixture."].waitForExistence(timeout: 5))
capture("push-old-server-agent-compatible")
}
func testWarmTapDismissesAttachmentPickerPreservingDraft() async throws {
app.launchEnvironment["SYBIL_PUSH_TAP_ON_FOREGROUND"] = "1"
app.launch(); openAgent()
let composer = app.textFields["agent-composer"]
composer.tap(); composer.typeText(" Warm push preserved draft")
app.buttons["agent-attach"].tap(); app.buttons["Files"].tap()
XCTAssertTrue(app.buttons["Cancel"].firstMatch.waitForExistence(timeout: 5))
XCUIDevice.shared.press(.home); app.activate()
XCTAssertTrue(composer.waitForExistence(timeout: 10))
let visible = expectation(for: NSPredicate(format: "hittable == true"), evaluatedWith: composer)
await fulfillment(of: [visible], timeout: 5)
XCTAssertFalse(app.buttons["Cancel"].firstMatch.exists)
XCTAssertTrue((composer.value as? String ?? "").contains("Warm push preserved draft"))
capture("push-warm-tap-dismissed-picker-draft-preserved")
}
}
+368
View File
@@ -0,0 +1,368 @@
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 testBusyProgressAndSameIDMessageUpdate() async throws {
try await control(["action": "progress", "queued": 2, "workers": 1, "step": 7])
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["2 queued"].waitForExistence(timeout: 5))
try await control(["action": "progress", "queued": 3, "workers": 2, "step": 14])
XCTAssertTrue(app.staticTexts["3 queued"].waitForExistence(timeout: 5))
XCTAssertTrue(app.staticTexts["2 working"].exists)
// Same stable ID, changed content: the transcript boundary must update.
try await control(["action": "edit-last", "content": "**Revised response**\n\n[Updated reference](http://127.0.0.1:8799)"])
XCTAssertTrue(app.staticTexts["Revised response"].waitForExistence(timeout: 5))
XCTAssertTrue(app.links["Updated reference"].exists)
XCTAssertFalse(app.staticTexts["History message 3: local simulator fixture."].exists)
XCTAssertTrue(app.staticTexts["Sybil is thinking…"].exists)
try await control(["action": "busy", "value": false])
let stopped = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.staticTexts["Sybil is thinking…"])
await fulfillment(of: [stopped], timeout: 5)
}
func testReduceMotionSend() async throws {
try XCTSkipUnless(UIAccessibility.isReduceMotionEnabled, "Run with Simulator Reduce Motion enabled")
send("reduce motion fixture")
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"].exists && app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
app.buttons["Load earlier messages"].tap()
// Loading older lazy history may move the viewport. It must still load
// the remaining history and preserve Latest/keyboard behavior below.
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
capture("agent-older-history-loaded")
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,233 @@
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 {
// Identify the transcript independently of any particular message row.
app.scrollViews["agent-transcript"]
}
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])
}
private func reloadMixedHistory() async throws {
app.terminate()
let rows = (1...160).map { index in
let short = "History message \(index): local simulator fixture."
return ["role": index % 3 == 0 ? "user" : "assistant",
"content": index == 160 || index % 3 == 0 ? short :
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
}
try await control(["action": "presentation-samples", "messages": rows])
app.launch()
}
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
try await reloadMixedHistory()
openAgent()
let latest = app.staticTexts["History message 160: local simulator fixture."]
XCTAssertTrue(latest.isHittable)
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-initial-open")
showSidebar()
app.buttons["Settings"].tap()
XCTAssertFalse(composer.exists)
openAgent()
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
capture("agent-tab-return")
}
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
try await reloadMixedHistory()
openAgent()
composer.tap()
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
composer.press(forDuration: 1)
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
}
let draft = "Keyboard send\nsecond line\nthird line"
composer.typeText(draft)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let multilineHeight = composer.frame.height
app.buttons["agent-send"].tap()
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
await fulfillment(of: [cleared], timeout: 5)
XCTAssertLessThan(composer.frame.height, multilineHeight)
XCTAssertTrue(app.keyboards.firstMatch.exists)
let sent = app.staticTexts[draft]
XCTAssertTrue(sent.waitForExistence(timeout: 5))
XCTAssertTrue(sent.isHittable)
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
await fulfillment(of: [followed], timeout: 10)
capture("agent-send-cleared-composer-with-keyboard")
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
// Finished: is only the first Markdown paragraph. Measure the final
// reply content, leaving the same allowance for metadata and spacing.
let replyEnd = app.staticTexts["The local fixture returned three matches."]
XCTAssertTrue(replyEnd.isHittable)
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
print("Agent send settled final-content gap: \(bottomGap)pt")
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
try await Task.sleep(for: .milliseconds(500))
let read = try await control(["action": "state"])
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
}
func testSharedHeaderFadeGeometry() async throws {
showSidebar()
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
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"].exists && app.buttons["Load earlier messages"].isHittable { break }
scroll.swipeDown(velocity: .fast)
}
let load = app.buttons["Load earlier messages"]
XCTAssertTrue(load.isHittable)
load.tap()
// Prepend position is intentionally not preserved with lazy history.
// New-message reading anchors above and follow/read settling below remain strict.
XCTAssertFalse(load.exists)
capture("agent-fade-older-history-loaded")
app.buttons["agent-jump-latest"].tap()
let latest = app.staticTexts["Fade regression reply while reading"]
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
await fulfillment(of: [followed], timeout: 5)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(latest.isHittable)
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
capture("agent-fade-latest-with-keyboard")
// 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)
// A reply still follows and settles as read while the keyboard is up.
try await control(["action": "reply", "content": "Reply with keyboard visible"])
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
await fulfillment(of: [keyboardFollowed], timeout: 5)
try await Task.sleep(for: .milliseconds(500))
let keyboardRead = try await control(["action": "state"])
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
// Interactive dismissal is user navigation; Latest must still return to
// the bottom, and presenting the keyboard again must keep it visible.
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
await fulfillment(of: [dismissed], timeout: 5)
if app.buttons["agent-jump-latest"].exists { app.buttons["agent-jump-latest"].tap() }
XCTAssertTrue(keyboardReply.isHittable)
composer.tap()
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
XCTAssertTrue(keyboardReply.isHittable)
}
}
+2
View File
@@ -0,0 +1,2 @@
app_identifier("net.buzzert.sybil2")
team_id("DQQH5H6GBD")
+47
View File
@@ -0,0 +1,47 @@
default_platform(:ios)
platform :ios do
desc "Build a release tag and upload it to TestFlight"
lane :beta do
setup_ci
match(type: "appstore", readonly: true)
tag = ENV.fetch("GITHUB_REF_NAME")
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
UI.user_error!("Expected a tag in the form release/ios/vX.Y.Z; got #{tag.inspect}") unless version
build_number = ENV.fetch("GITHUB_RUN_NUMBER")
UI.user_error!("GITHUB_RUN_NUMBER must be a positive integer") unless build_number.match?(/\A[1-9]\d*\z/)
ios_dir = File.expand_path("..", __dir__)
project_spec = File.join(ios_dir, "Apps/Sybil/project.yml")
contents = File.read(project_spec)
unless contents.match?(/^\s*MARKETING_VERSION:/) && contents.match?(/^\s*CURRENT_PROJECT_VERSION:/)
UI.user_error!("Could not find version settings in #{project_spec}")
end
contents.sub!(/^(\s*MARKETING_VERSION:\s*).*/, "\\1\"#{version}\"")
contents.sub!(/^(\s*CURRENT_PROJECT_VERSION:\s*).*/, "\\1#{build_number}")
File.write(project_spec, contents)
sh("xcodegen", "--spec", File.join(ios_dir, "project.yml"))
api_key = app_store_connect_api_key(
key_id: ENV.fetch("ASC_KEY_ID"),
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
key_content: ENV.fetch("ASC_KEY"),
is_key_content_base64: true
)
build_app(
project: File.join(ios_dir, "Sybil.xcodeproj"),
scheme: "Sybil"
)
upload_to_testflight(
api_key: api_key,
skip_waiting_for_build_processing: true,
uses_non_exempt_encryption: false
)
end
end
+7
View File
@@ -0,0 +1,7 @@
git_url("https://code.buzzert.dev/buzzert/fastlane-match.git")
storage_mode("git")
type("appstore")
app_identifier(["net.buzzert.sybil2"])
team_id("DQQH5H6GBD")
profile_name("Sybil AppStore CI")
+18 -8
View File
@@ -5,24 +5,34 @@ derived_data := "build/DerivedData"
default:
@just build
build:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' | xcbeautify; \
generate:
xcodegen --spec project.yml
build: generate
set -o pipefail; if command -v xcbeautify >/dev/null 2>&1; then \
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' | xcbeautify; \
else \
xcodebuild -scheme Sybil -destination '{{simulator}}'; \
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'; \
fi
test:
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -parallel-testing-enabled NO
cd Packages/Sybil && xcodebuild test -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}' -parallel-testing-enabled NO
run:
if [ ! -d "Sybil.xcodeproj" ]; then xcodegen --spec project.yml; fi
run: generate
xcrun simctl boot '{{simulator_name}}' 2>/dev/null || true
xcodebuild -scheme Sybil -destination '{{simulator}}' -derivedDataPath '{{derived_data}}'
xcrun simctl install booted '{{derived_data}}/Build/Products/Debug-iphonesimulator/Sybil.app'
xcrun simctl launch booted net.buzzert.sybil2
build-mac: generate
xcodebuild -scheme Sybil -destination 'platform=macOS,variant=Mac Catalyst' -derivedDataPath '{{derived_data}}' CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO
run-mac: build-mac
open '{{derived_data}}/Build/Products/Debug-maccatalyst/Sybil.app'
beta:
fastlane ios beta
screenshot path="build/sybil-screenshot.png":
mkdir -p "$(dirname '{{path}}')"
xcrun simctl io booted screenshot '{{path}}'
+59
View File
@@ -7,3 +7,62 @@ packages:
path: Packages/Sybil
include:
- Apps/Sybil/project.yml
- Apps/SybilMacQuickQuestion/project.yml
# Dedicated local-fixture UI suite; ordinary release archives only build SybilApp.
targets:
SybilPresentationTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "18.0"
sources:
- Tests/SybilPresentationTests
dependencies:
- target: SybilApp
- package: Sybil
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.presentation-tests
GENERATE_INFOPLIST_FILE: YES
SWIFT_VERSION: 6.0
TEST_HOST: "$(BUILT_PRODUCTS_DIR)/Sybil.app/Sybil"
BUNDLE_LOADER: "$(TEST_HOST)"
SybilAgentUITests:
type: bundle.ui-testing
platform: iOS
deploymentTarget: "18.0"
sources:
- Tests/SybilUITests
dependencies:
- target: SybilApp
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: net.buzzert.sybil2.agent-ui-tests
GENERATE_INFOPLIST_FILE: YES
INFOPLIST_KEY_NSPhotoLibraryUsageDescription: Synthetic attachment UI tests create and remove their own photo.
TEST_TARGET_NAME: SybilApp
SWIFT_VERSION: 6.0
schemes:
SybilPresentation:
run:
commandLineArguments:
"-sybil.ios.apiBaseURL http://127.0.0.1:8799/api -sybil.ios.adminToken fixture-token": true
environmentVariables:
SYBIL_PUSH_FIXTURE: "1"
build:
targets:
SybilApp: all
SybilPresentationTests: [test]
test:
config: Debug
targets:
- SybilPresentationTests
SybilAgentUI:
build:
targets:
SybilApp: all
SybilAgentUITests: [test]
test:
config: Debug
targets:
- SybilAgentUITests
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SESSION="${SYBIL_TMUX_SESSION:-sybil-dev}"
LOG_DIR="$ROOT/.devbox/logs"
PID_DIR="$ROOT/.devbox/pids"
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "Missing required environment variable: $name" >&2
exit 1
fi
}
require_env OPENAI_API_KEY
require_env EXA_API_KEY
mkdir -p "$LOG_DIR" "$PID_DIR"
export HOST="${HOST:-0.0.0.0}"
export PORT="${PORT:-8787}"
export DATABASE_URL="${DATABASE_URL:-file:$ROOT/server/dev.db}"
echo "Installing server dependencies..."
npm ci --prefix "$ROOT/server" --no-audit --no-fund
echo "Installing web dependencies..."
npm ci --prefix "$ROOT/web" --no-audit --no-fund
if command -v tmux >/dev/null 2>&1; then
tmux kill-session -t "$SESSION" >/dev/null 2>&1 || true
tmux new-session -d -s "$SESSION" -n server -c "$ROOT/server" "sleep infinity"
tmux set-environment -t "$SESSION" OPENAI_API_KEY "$OPENAI_API_KEY"
tmux set-environment -t "$SESSION" EXA_API_KEY "$EXA_API_KEY"
tmux set-environment -t "$SESSION" HOST "$HOST"
tmux set-environment -t "$SESSION" PORT "$PORT"
tmux set-environment -t "$SESSION" DATABASE_URL "$DATABASE_URL"
if [[ -n "${BRIX_CLUSTER:-}" ]]; then
tmux set-environment -t "$SESSION" BRIX_CLUSTER "$BRIX_CLUSTER"
fi
for name in \
CHAT_CODEX_TOOL_ENABLED \
CHAT_CODEX_REMOTE_HOST \
CHAT_CODEX_REMOTE_USER \
CHAT_CODEX_REMOTE_PORT \
CHAT_CODEX_REMOTE_WORKDIR \
CHAT_CODEX_SSH_KEY_PATH \
CHAT_CODEX_SSH_PRIVATE_KEY_B64 \
CHAT_CODEX_EXEC_TIMEOUT_MS \
CHAT_SHELL_TOOL_ENABLED \
CHAT_SHELL_EXEC_TIMEOUT_MS; do
if [[ -n "${!name:-}" ]]; then
tmux set-environment -t "$SESSION" "$name" "${!name}"
fi
done
tmux respawn-pane -k -t "$SESSION:server.0" -c "$ROOT/server" "npm run dev"
tmux new-window -t "$SESSION" -n web -c "$ROOT/web" \
"npm run dev -- --host 0.0.0.0 --port 5173"
echo "Started Sybil in tmux session '$SESSION'."
echo "Server: http://127.0.0.1:8787"
echo "Web: http://127.0.0.1:5173"
exit 0
fi
echo "tmux not found; starting background processes with nohup."
if [[ -f "$PID_DIR/server.pid" ]]; then
kill "$(cat "$PID_DIR/server.pid")" >/dev/null 2>&1 || true
fi
if [[ -f "$PID_DIR/web.pid" ]]; then
kill "$(cat "$PID_DIR/web.pid")" >/dev/null 2>&1 || true
fi
(
cd "$ROOT/server"
exec env HOST="$HOST" PORT="$PORT" DATABASE_URL="$DATABASE_URL" npm run dev
) >"$LOG_DIR/server.log" 2>&1 &
echo "$!" >"$PID_DIR/server.pid"
(
cd "$ROOT/web"
exec npm run dev -- --host 0.0.0.0 --port 5173
) >"$LOG_DIR/web.log" 2>&1 &
echo "$!" >"$PID_DIR/web.pid"
echo "Started Sybil background processes."
echo "Server log: $LOG_DIR/server.log"
echo "Web log: $LOG_DIR/web.log"
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
REPO="${REPO:-Sybil-2}"
WORKLOAD="${WORKLOAD:-sybil-dev}"
CLUSTER="${CLUSTER:-viper}"
NAMESPACE="${NAMESPACE:-buzzert}"
QUOTA="${QUOTA:-research-api}"
SECRET="${SECRET:-sybil-dev-env}"
REMOTE_ROOT="${REMOTE_ROOT:-/root/code/$REPO}"
INGRESS_PORT="${INGRESS_PORT:-5173}"
CODEX_IMAGE="${CODEX_IMAGE:-openai.azurecr.io/rcall:crow-1b540b5e15f9-2026-01-22-2404-312}"
CODEX_SERVICE="${CODEX_SERVICE:-sybil-codex-devbox}"
CODEX_CONTAINER="${CODEX_CONTAINER:-codex-devbox}"
CODEX_SSH_SECRET="${CODEX_SSH_SECRET:-sybil-codex-ssh}"
CODEX_SSH_PORT="${CODEX_SSH_PORT:-2222}"
CODEX_WORKDIR="${CODEX_WORKDIR:-/workspace/sybil-codex}"
CODEX_SSH_KEY_PATH="${CODEX_SSH_KEY_PATH:-/run/secrets/sybil-codex-ssh/id_ed25519}"
echo "Syncing $REPO to $WORKLOAD on $CLUSTER/$NAMESPACE..."
brix create \
--project-root "$(dirname "$ROOT")" \
--path "$REPO=$ROOT" \
--repositories "$REPO" \
--include-untracked \
--reconcile-policy continuous \
-- \
"cluster=$CLUSTER" \
"namespace=$NAMESPACE" \
"name=$WORKLOAD" \
"pool.spec.quota=$QUOTA" \
"pool.spec.size.resource=cpu" \
"pool.spec.size.quantity=1" \
"pool.spec.priority=low"
if [[ "$REPO" != "openai" ]]; then
if kubectl --context "$CLUSTER" -n "$NAMESPACE" get git "$WORKLOAD" -o json 2>/dev/null | jq -e '.spec.repositories.openai?' >/dev/null; then
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch git "$WORKLOAD" --type=json -p='[{"op":"remove","path":"/spec/repositories/openai"}]'
fi
fi
if [[ -n "${OPENAI_API_KEY:-}" && -n "${EXA_API_KEY:-}" ]]; then
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$SECRET" \
--from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \
--from-literal=EXA_API_KEY="$EXA_API_KEY" \
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
elif [[ -n "${OPENAI_API_KEY:-}" || -n "${EXA_API_KEY:-}" ]]; then
echo "Only one API key env var is set; keeping existing Kubernetes secret $SECRET." >&2
fi
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$SECRET" -o name 2>/dev/null || true)" == "" ]]; then
echo "Secret $SECRET does not exist. Re-run with OPENAI_API_KEY and EXA_API_KEY set." >&2
exit 1
fi
if [[ "$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get secret "$CODEX_SSH_SECRET" -o name 2>/dev/null || true)" == "" ]]; then
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
ssh-keygen -t ed25519 -N "" -C "sybil-codex@$WORKLOAD" -f "$tmpdir/id_ed25519" >/dev/null
kubectl --context "$CLUSTER" -n "$NAMESPACE" create secret generic "$CODEX_SSH_SECRET" \
--from-file=id_ed25519="$tmpdir/id_ed25519" \
--from-file=id_ed25519.pub="$tmpdir/id_ed25519.pub" \
--dry-run=client -o yaml | kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f -
fi
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch secret "$SECRET" --type=merge -p "$(jq -n \
--arg host "$CODEX_SERVICE" \
--arg port "$CODEX_SSH_PORT" \
--arg workdir "$CODEX_WORKDIR" \
--arg keyPath "$CODEX_SSH_KEY_PATH" \
'{
stringData: {
CHAT_CODEX_TOOL_ENABLED: "true",
CHAT_CODEX_REMOTE_HOST: $host,
CHAT_CODEX_REMOTE_USER: "root",
CHAT_CODEX_REMOTE_PORT: $port,
CHAT_CODEX_REMOTE_WORKDIR: $workdir,
CHAT_CODEX_SSH_KEY_PATH: $keyPath,
CHAT_CODEX_EXEC_TIMEOUT_MS: "600000",
CHAT_SHELL_TOOL_ENABLED: "true"
}
}')"
ensure_codex_deployment() {
kubectl --context "$CLUSTER" -n "$NAMESPACE" apply -f - <<KUBE_MANIFEST
apiVersion: apps/v1
kind: Deployment
metadata:
name: $CODEX_SERVICE
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: $CODEX_SERVICE
template:
metadata:
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
tolerations:
- key: openai.com/team
operator: Exists
effect: NoSchedule
containers:
- name: $CODEX_CONTAINER
image: $CODEX_IMAGE
imagePullPolicy: IfNotPresent
command: ["/bin/bash", "-lc"]
args:
- |
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
if ! command -v sshd >/dev/null 2>&1; then
apt-get update
apt-get install -y --no-install-recommends openssh-server ca-certificates git curl
rm -rf /var/lib/apt/lists/*
fi
if ! command -v codex >/dev/null 2>&1; then
npm install -g @openai/codex
fi
mkdir -p /run/sshd /root/.ssh "$CODEX_WORKDIR"
cp /run/secrets/sybil-codex-ssh/id_ed25519.pub /root/.ssh/authorized_keys
chmod 700 /root/.ssh
chmod 600 /root/.ssh/authorized_keys
{
if [[ -n "\${OPENAI_API_KEY:-}" ]]; then
printf 'export OPENAI_API_KEY=%q\n' "\$OPENAI_API_KEY"
fi
if [[ -n "\${EXA_API_KEY:-}" ]]; then
printf 'export EXA_API_KEY=%q\n' "\$EXA_API_KEY"
fi
} >/root/.sybil-codex-env
chmod 600 /root/.sybil-codex-env
cat >/usr/local/bin/sybil-codex-ssh-command <<'SSH_COMMAND'
#!/usr/bin/env bash
set -euo pipefail
if [[ -f /root/.sybil-codex-env ]]; then
source /root/.sybil-codex-env
fi
if [[ -n "\${SSH_ORIGINAL_COMMAND:-}" ]]; then
exec /bin/bash -lc "\$SSH_ORIGINAL_COMMAND"
fi
exec /bin/bash -l
SSH_COMMAND
chmod 700 /usr/local/bin/sybil-codex-ssh-command
ssh-keygen -A
cat >/tmp/sybil-codex-sshd_config <<SSHD_CONFIG
Port $CODEX_SSH_PORT
ListenAddress 0.0.0.0
PermitRootLogin prohibit-password
PasswordAuthentication no
PubkeyAuthentication yes
AcceptEnv OPENAI_API_KEY EXA_API_KEY
AuthorizedKeysFile .ssh/authorized_keys
PermitUserEnvironment no
ForceCommand /usr/local/bin/sybil-codex-ssh-command
AllowTcpForwarding no
X11Forwarding no
Subsystem sftp internal-sftp
PidFile /run/sshd/sybil-codex.pid
SSHD_CONFIG
exec \$(command -v sshd) -D -e -f /tmp/sybil-codex-sshd_config
ports:
- name: ssh
containerPort: $CODEX_SSH_PORT
envFrom:
- secretRef:
name: $SECRET
resources:
requests:
cpu: "1"
memory: 4Gi
limits:
cpu: "1"
memory: 4Gi
volumeMounts:
- name: sybil-codex-ssh
mountPath: /run/secrets/sybil-codex-ssh
readOnly: true
- name: sybil-codex-workspace
mountPath: /workspace
volumes:
- name: sybil-codex-ssh
secret:
secretName: $CODEX_SSH_SECRET
defaultMode: 384
- name: sybil-codex-workspace
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: $CODEX_SERVICE
labels:
app.kubernetes.io/name: $CODEX_SERVICE
app.kubernetes.io/part-of: sybil-dev
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: $CODEX_SERVICE
ports:
- name: ssh
port: $CODEX_SSH_PORT
targetPort: ssh
KUBE_MANIFEST
kubectl --context "$CLUSTER" -n "$NAMESPACE" rollout status "deployment/$CODEX_SERVICE" --timeout=300s
}
ensure_codex_deployment
ensure_env_from() {
local resource="$1"
local name="$2"
local jsonpath="$3"
local patch_path="$4"
local existing_env_from
existing_env_from="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get "$resource" "$name" \
-o "jsonpath=$jsonpath" 2>/dev/null || true)"
if [[ "$existing_env_from" == *"$SECRET"* ]]; then
return
fi
local patch
if [[ -z "$existing_env_from" ]]; then
patch="[{\"op\":\"add\",\"path\":\"$patch_path\",\"value\":[{\"secretRef\":{\"name\":\"$SECRET\"}}]}]"
else
patch="[{\"op\":\"add\",\"path\":\"$patch_path/-\",\"value\":{\"secretRef\":{\"name\":\"$SECRET\"}}}]"
fi
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch "$resource" "$name" --type=json -p "$patch"
}
ensure_env_from \
workload \
"$WORKLOAD" \
"{.spec.pools[0].template.template.spec.workers.template.spec.containers[0].envFrom}" \
"/spec/pools/0/template/template/spec/workers/template/spec/containers/0/envFrom"
ensure_env_from \
pool \
"$WORKLOAD" \
"{.spec.workers.template.spec.containers[0].envFrom}" \
"/spec/workers/template/spec/containers/0/envFrom"
codex_ssh_volume_json="$(jq -n --arg secret "$CODEX_SSH_SECRET" '{
name: "sybil-codex-ssh",
secret: {
secretName: $secret,
defaultMode: 384
}
}')"
codex_main_mount_json="$(jq -n '{name: "sybil-codex-ssh", mountPath: "/run/secrets/sybil-codex-ssh", readOnly: true}')"
patch_pool_codex_mount() {
local pool_json containers volumes patch
pool_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o json)"
containers="$(jq \
--arg sidecarName "$CODEX_CONTAINER" \
--argjson mainMount "$codex_main_mount_json" \
'
def ensure_mount($mount):
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
(.spec.workers.template.spec.containers // [])
| map(if .name == "main" then ensure_mount($mainMount) else . end)
| map(select(.name != $sidecarName))
' <<<"$pool_json")"
volumes="$(jq \
--argjson sshVolume "$codex_ssh_volume_json" \
'
(.spec.workers.template.spec.volumes // [])
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
+ [$sshVolume]
' <<<"$pool_json")"
patch="$(jq -n --argjson containers "$containers" --argjson volumes "$volumes" '{
spec: {
workers: {
template: {
spec: {
containers: $containers,
volumes: $volumes
}
}
}
}
}')"
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch pool "$WORKLOAD" --type=merge -p "$patch"
}
patch_workload_codex_mount() {
local workload_json pools patch
workload_json="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get workload "$WORKLOAD" -o json)"
pools="$(jq \
--arg groupName "$WORKLOAD" \
--arg sidecarName "$CODEX_CONTAINER" \
--argjson mainMount "$codex_main_mount_json" \
--argjson sshVolume "$codex_ssh_volume_json" \
'
def ensure_mount($mount):
.volumeMounts = (((.volumeMounts // []) | map(select(.name != $mount.name))) + [$mount]);
.spec.pools
| map(
if .groupName == $groupName then
.template.template.spec.workers.template.spec.containers =
((.template.template.spec.workers.template.spec.containers // [])
| map(if .name == "main" then ensure_mount($mainMount) else . end)
| map(select(.name != $sidecarName)))
| .template.template.spec.workers.template.spec.volumes =
((.template.template.spec.workers.template.spec.volumes // [])
| map(select(.name != $sshVolume.name and .name != "sybil-codex-workspace"))
+ [$sshVolume])
else
.
end
)
' <<<"$workload_json")"
patch="$(jq -n --argjson pools "$pools" '{spec: {pools: $pools}}')"
kubectl --context "$CLUSTER" -n "$NAMESPACE" patch workload "$WORKLOAD" --type=merge -p "$patch"
}
patch_workload_codex_mount
patch_pool_codex_mount
echo "Waiting for $WORKLOAD-0 to run the current pool revision..."
deadline=$((SECONDS + 300))
while true; do
pool_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pool "$WORKLOAD" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
pod_revision="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.labels.brix\.openai\.com/revision}' 2>/dev/null || true)"
pod_ready="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
pod_deletion_timestamp="$(kubectl --context "$CLUSTER" -n "$NAMESPACE" get pod "$WORKLOAD-0" -o jsonpath='{.metadata.deletionTimestamp}' 2>/dev/null || true)"
if [[ -n "$pool_revision" && "$pod_revision" == "$pool_revision" && "$pod_ready" == "True" && -z "$pod_deletion_timestamp" ]]; then
break
fi
if (( SECONDS >= deadline )); then
echo "Timed out waiting for current ready pod. pool_revision=$pool_revision pod_revision=$pod_revision pod_ready=$pod_ready deleting=$pod_deletion_timestamp" >&2
exit 1
fi
sleep 5
done
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- ./scripts/devbox-start.sh
ssh_check="ssh -n -i '$CODEX_SSH_KEY_PATH' -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/sybil-codex-known-hosts -p '$CODEX_SSH_PORT' root@'$CODEX_SERVICE' true"
brix run --clusters "$CLUSTER" --namespace "$NAMESPACE" --pools "$WORKLOAD" --dir "$REMOTE_ROOT" -- /bin/bash -lc "$ssh_check"
url="$(brix ingress create --cluster "$CLUSTER" --namespace "$NAMESPACE" --pod "$WORKLOAD-0" --ports "web:$INGRESS_PORT" --root web --timeout 300 "$WORKLOAD" | tail -1)"
echo "Devbox update complete: $url"
+30 -4
View File
@@ -1,7 +1,7 @@
# Sybil Server
Backend API for:
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Hermes Agent)
- LLM multiplexer (OpenAI Responses / Anthropic / xAI Chat Completions-compatible Grok / Gemini / Hermes Agent)
- Personal chat database (chats/messages + LLM call log)
## Stack
@@ -43,14 +43,16 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`
- `XAI_API_KEY`
- `GEMINI_API_KEY`
- `HERMES_AGENT_API_BASE_URL` (`http://127.0.0.1:8642/v1` by default; include the `/v1` suffix)
- `HERMES_AGENT_API_KEY` (enables the Hermes Agent provider; set to Hermes `API_SERVER_KEY`, or any non-empty value if that local server does not require auth)
- `HERMES_AGENT_MODEL` (optional fallback/override model id; defaults client-side to `hermes-agent`)
- `EXA_API_KEY`
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default, or `searxng` for chat tool calls only)
- `BRAVE_SEARCH_API_KEY` (required when `CHAT_WEB_SEARCH_ENGINE=brave`)
- `CHAT_WEB_SEARCH_ENGINE` (`exa` by default; `brave` and `searxng` are also supported for chat tool calls only)
- `SEARXNG_BASE_URL` (required when `CHAT_WEB_SEARCH_ENGINE=searxng`; instance must allow `format=json`)
- `CHAT_MAX_TOOL_ROUNDS` (`100` by default; maximum model/tool result cycles per chat completion)
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for OpenAI/xAI)
- `CHAT_CODEX_TOOL_ENABLED` (`false` by default; enables the `codex_exec` chat tool for managed-tool providers)
- `CHAT_CODEX_REMOTE_HOST` (required when Codex tool is enabled; SSH host/IP or `user@host`)
- `CHAT_CODEX_REMOTE_USER` (optional SSH user when host does not include one)
- `CHAT_CODEX_REMOTE_PORT` (`22` by default)
@@ -58,7 +60,7 @@ If `ADMIN_TOKEN` is not set, the server runs in open mode (dev).
- `CHAT_CODEX_SSH_KEY_PATH` (recommended: path to a read-only mounted private key)
- `CHAT_CODEX_SSH_PRIVATE_KEY_B64` (optional fallback private key delivery)
- `CHAT_CODEX_EXEC_TIMEOUT_MS` (`600000` by default)
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for OpenAI/xAI on the same devbox)
- `CHAT_SHELL_TOOL_ENABLED` (`false` by default; enables the `shell_exec` chat tool for managed-tool providers on the same devbox)
- `CHAT_SHELL_EXEC_TIMEOUT_MS` (`120000` by default)
## API
@@ -104,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.
+194
View File
@@ -19,13 +19,18 @@
"exa-js": "^2.4.0",
"fastify": "^5.7.2",
"html-to-text": "^9.0.5",
"marked": "17.0.2",
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"web-push": "^3.6.7",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/web-push": "^3.6.4",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -876,12 +881,41 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
@@ -915,6 +949,18 @@
}
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@@ -934,6 +980,18 @@
"fastq": "^1.17.1"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/colorette": {
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
@@ -1104,6 +1162,15 @@
"url": "https://dotenvx.com"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -1478,6 +1545,15 @@
"entities": "^4.4.0"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1498,6 +1574,19 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -1577,6 +1666,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"node_modules/leac": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
@@ -1632,6 +1742,18 @@
"node": "20 || >=22"
}
},
"node_modules/marked": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.2.tgz",
"integrity": "sha512-s5HZGFQea7Huv5zZcAGhJLT3qLpAfnY7v7GWkICUr0+Wd5TFEtdlRR2XUL5Gg+RH7u2Df595ifrxR03mBaw7gA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
@@ -1678,6 +1800,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
@@ -1994,6 +2122,26 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.0.0.tgz",
@@ -2022,6 +2170,12 @@
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
@@ -2219,6 +2373,25 @@
"node": ">= 0.8"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
@@ -2241,6 +2414,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",
+5
View File
@@ -28,13 +28,18 @@
"exa-js": "^2.4.0",
"fastify": "^5.7.2",
"html-to-text": "^9.0.5",
"marked": "17.0.2",
"openai": "^6.16.0",
"pino-pretty": "^13.1.3",
"prisma": "^6.6.0",
"web-push": "^3.6.7",
"ws": "8.22.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.0.10",
"@types/web-push": "^3.6.4",
"@types/ws": "8.18.1",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Chat" ADD COLUMN "additionalSystemPrompt" TEXT;
ALTER TABLE "Chat" ADD COLUMN "enabledTools" JSONB;
@@ -0,0 +1,44 @@
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
"kind" TEXT NOT NULL DEFAULT 'folder',
"title" TEXT NOT NULL,
"userId" TEXT,
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "ProjectItem" (
"id" TEXT NOT NULL PRIMARY KEY,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"projectId" TEXT NOT NULL,
"chatId" TEXT,
"searchId" TEXT,
CONSTRAINT "ProjectItem_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_searchId_fkey" FOREIGN KEY ("searchId") REFERENCES "Search" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ProjectItem_one_target_check" CHECK (("chatId" IS NOT NULL AND "searchId" IS NULL) OR ("chatId" IS NULL AND "searchId" IS NOT NULL))
);
-- CreateIndex
CREATE INDEX "Project_kind_idx" ON "Project"("kind");
-- CreateIndex
CREATE INDEX "Project_userId_idx" ON "Project"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_chatId_key" ON "ProjectItem"("projectId", "chatId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectItem_projectId_searchId_key" ON "ProjectItem"("projectId", "searchId");
-- CreateIndex
CREATE INDEX "ProjectItem_projectId_createdAt_idx" ON "ProjectItem"("projectId", "createdAt");
-- CreateIndex
CREATE INDEX "ProjectItem_chatId_idx" ON "ProjectItem"("chatId");
-- CreateIndex
CREATE INDEX "ProjectItem_searchId_idx" ON "ProjectItem"("searchId");
@@ -0,0 +1,8 @@
-- Add normalized search query lookup key for cache/reuse behavior.
ALTER TABLE "Search" ADD COLUMN "queryNormalized" TEXT;
UPDATE "Search"
SET "queryNormalized" = lower(trim("query"))
WHERE "query" IS NOT NULL AND trim("query") != '';
CREATE INDEX "Search_queryNormalized_updatedAt_idx" ON "Search"("queryNormalized", "updatedAt");
@@ -0,0 +1,5 @@
-- Add durable root grouping and one-time fork title generation state.
ALTER TABLE "Chat" ADD COLUMN "parentChatId" TEXT REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Chat" ADD COLUMN "titleGenerationPending" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX "Chat_parentChatId_idx" ON "Chat"("parentChatId");
@@ -0,0 +1,70 @@
-- External-content indexes avoid storing a second copy of conversation text.
-- Keep these virtual tables and triggers in SQL: Prisma does not model FTS5.
CREATE VIRTUAL TABLE "ChatSearch" USING fts5(
title, content='Chat', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
-- Hidden system prompts and tool payloads are not searchable conversation text.
CREATE VIEW "MessageSearchContent" AS
SELECT rowid, content FROM "Message" WHERE role IN ('user', 'assistant');
CREATE VIRTUAL TABLE "MessageSearch" USING fts5(
content, content='MessageSearchContent', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE VIRTUAL TABLE "SavedSearchSearch" USING fts5(
title, query, answerText, content='Search', content_rowid='rowid',
tokenize='unicode61 remove_diacritics 2', prefix='1 2 3'
);
CREATE TRIGGER "ChatSearch_insert" AFTER INSERT ON "Chat" BEGIN
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "ChatSearch_delete" AFTER DELETE ON "Chat" BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
END;
CREATE TRIGGER "ChatSearch_update" AFTER UPDATE OF title ON "Chat"
WHEN old.title IS NOT new.title BEGIN
INSERT INTO "ChatSearch" ("ChatSearch", rowid, title) VALUES ('delete', old.rowid, old.title);
INSERT INTO "ChatSearch" (rowid, title) VALUES (new.rowid, new.title);
END;
CREATE TRIGGER "MessageSearch_insert" AFTER INSERT ON "Message"
WHEN new.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" (rowid, content) VALUES (new.rowid, new.content);
END;
CREATE TRIGGER "MessageSearch_delete" AFTER DELETE ON "Message"
WHEN old.role IN ('user', 'assistant') BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content) VALUES ('delete', old.rowid, old.content);
END;
CREATE TRIGGER "MessageSearch_update" AFTER UPDATE OF content, role ON "Message"
WHEN old.content IS NOT new.content OR old.role IS NOT new.role BEGIN
INSERT INTO "MessageSearch" ("MessageSearch", rowid, content)
SELECT 'delete', old.rowid, old.content WHERE old.role IN ('user', 'assistant');
INSERT INTO "MessageSearch" (rowid, content)
SELECT new.rowid, new.content WHERE new.role IN ('user', 'assistant');
END;
CREATE TRIGGER "SavedSearchSearch_insert" AFTER INSERT ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_delete" AFTER DELETE ON "Search" BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
END;
CREATE TRIGGER "SavedSearchSearch_update" AFTER UPDATE OF title, query, answerText ON "Search"
WHEN old.title IS NOT new.title OR old.query IS NOT new.query OR old.answerText IS NOT new.answerText BEGIN
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch", rowid, title, query, answerText)
VALUES ('delete', old.rowid, old.title, old.query, old.answerText);
INSERT INTO "SavedSearchSearch" (rowid, title, query, answerText)
VALUES (new.rowid, new.title, new.query, new.answerText);
END;
-- Index existing history during migration, then maintain it transactionally.
INSERT INTO "ChatSearch" ("ChatSearch") VALUES ('rebuild');
INSERT INTO "MessageSearch" ("MessageSearch") VALUES ('rebuild');
INSERT INTO "SavedSearchSearch" ("SavedSearchSearch") VALUES ('rebuild');
CREATE INDEX "Chat_updatedAt_idx" ON "Chat"("updatedAt");
@@ -0,0 +1,5 @@
CREATE TABLE "AgentReadState" (
"id" TEXT NOT NULL PRIMARY KEY,
"cursor" TEXT NOT NULL DEFAULT '0',
"updatedAt" DATETIME NOT NULL
);
@@ -0,0 +1,13 @@
ALTER TABLE "AgentReadState" ADD COLUMN "sourceId" TEXT;
CREATE TABLE "AgentPushInstallation" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "transport" TEXT NOT NULL, "payload" TEXT NOT NULL, "fingerprint" TEXT NOT NULL, "enabled" BOOLEAN NOT NULL DEFAULT false, "version" INTEGER NOT NULL DEFAULT 1, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" DATETIME NOT NULL, "lastClearAt" DATETIME, "revokedAt" DATETIME);
CREATE INDEX "AgentPushInstallation_ownerEpoch_enabled_idx" ON "AgentPushInstallation"("ownerEpoch", "enabled");
CREATE INDEX "AgentPushInstallation_fingerprint_idx" ON "AgentPushInstallation"("fingerprint");
CREATE TABLE "AgentPushPresence" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sequence" INTEGER NOT NULL, "visible" BOOLEAN NOT NULL, "focused" BOOLEAN NOT NULL, "lastInteractionAt" DATETIME, "expiresAt" DATETIME NOT NULL);
CREATE INDEX "AgentPushPresence_ownerEpoch_expiresAt_idx" ON "AgentPushPresence"("ownerEpoch", "expiresAt");
CREATE TABLE "AgentPushConnection" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "kind" TEXT NOT NULL, "sessionId" TEXT, "expiresAt" DATETIME NOT NULL);
CREATE INDEX "AgentPushConnection_ownerEpoch_expiresAt_idx" ON "AgentPushConnection"("ownerEpoch", "expiresAt");
CREATE TABLE "AgentPushCoordinator" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "sourceId" TEXT, "cursor" TEXT NOT NULL DEFAULT '0', "quarantined" BOOLEAN NOT NULL DEFAULT false, "contextObservedAt" DATETIME, "latestUserMessageAt" DATETIME, "localPromptAt" DATETIME, "nativeActive" BOOLEAN NOT NULL DEFAULT false, "nativeUnknown" BOOLEAN NOT NULL DEFAULT true, "uncertain" BOOLEAN NOT NULL DEFAULT true, "lockOwner" TEXT, "lockUntil" DATETIME);
CREATE TABLE "AgentPushDelivery" ("id" TEXT NOT NULL PRIMARY KEY, "ownerEpoch" TEXT NOT NULL, "installationId" TEXT NOT NULL, "installationVersion" INTEGER NOT NULL, "sourceId" TEXT NOT NULL, "messageId" TEXT NOT NULL, "cursor" TEXT NOT NULL, "kind" TEXT NOT NULL, "trigger" TEXT NOT NULL, "priority" INTEGER NOT NULL DEFAULT 0, "status" TEXT NOT NULL DEFAULT 'pending', "attempts" INTEGER NOT NULL DEFAULT 0, "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "dueAt" DATETIME NOT NULL, "expiresAt" DATETIME NOT NULL, "updatedAt" DATETIME NOT NULL, "outcome" TEXT);
CREATE INDEX "AgentPushDelivery_status_dueAt_idx" ON "AgentPushDelivery"("status", "dueAt");
CREATE INDEX "AgentPushDelivery_installationId_status_idx" ON "AgentPushDelivery"("installationId", "status");
CREATE INDEX "AgentPushDelivery_ownerEpoch_sourceId_cursor_idx" ON "AgentPushDelivery"("ownerEpoch", "sourceId", "cursor");
@@ -0,0 +1,5 @@
ALTER TABLE "AgentPushPresence" ADD COLUMN "inactiveUntil" DATETIME;
ALTER TABLE "AgentPushCoordinator" ADD COLUMN "unknownSince" DATETIME;
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressionReason" TEXT;
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressionClientKind" TEXT;
ALTER TABLE "AgentPushDelivery" ADD COLUMN "lastSuppressedAt" DATETIME;
+150 -1
View File
@@ -13,6 +13,7 @@ enum Provider {
openai
anthropic
xai
gemini
hermes_agent @map("hermes-agent")
}
@@ -27,6 +28,11 @@ enum SearchSource {
exa
}
enum ProjectKind {
starred
folder
}
model User {
id String @id @default(cuid())
createdAt DateTime @default(now())
@@ -37,6 +43,7 @@ model User {
chats Chat[]
searches Search[]
projects Project[]
}
model Chat {
@@ -44,20 +51,32 @@ model Chat {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
title String?
title String?
titleGenerationPending Boolean @default(false)
initiatedProvider Provider?
initiatedModel String?
lastUsedProvider Provider?
lastUsedModel String?
additionalSystemPrompt String?
enabledTools Json?
user User? @relation(fields: [userId], references: [id])
userId String?
// Forks always point directly to the single root chat, never to another fork.
parentChat Chat? @relation("ChatForks", fields: [parentChatId], references: [id], onDelete: Cascade)
parentChatId String?
childChats Chat[] @relation("ChatForks")
messages Message[]
calls LlmCall[]
projectItems ProjectItem[]
@@index([userId])
@@index([parentChatId])
@@index([updatedAt])
}
model Message {
@@ -111,6 +130,7 @@ model Search {
title String?
query String?
queryNormalized String?
source SearchSource @default(exa)
@@ -129,8 +149,10 @@ model Search {
userId String?
results SearchResult[]
projectItems ProjectItem[]
@@index([updatedAt])
@@index([queryNormalized, updatedAt])
@@index([userId])
}
@@ -156,3 +178,130 @@ model SearchResult {
@@index([searchId, rank])
}
model Project {
id String @id @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
kind ProjectKind @default(folder)
title String
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
items ProjectItem[]
@@index([kind])
@@index([userId])
}
model ProjectItem {
id String @id @default(cuid())
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
projectId String
chat Chat? @relation(fields: [chatId], references: [id], onDelete: Cascade)
chatId String?
search Search? @relation(fields: [searchId], references: [id], onDelete: Cascade)
searchId String?
@@unique([projectId, chatId])
@@unique([projectId, searchId])
@@index([projectId, createdAt])
@@index([chatId])
@@index([searchId])
}
// Shared personal read position for the external persistent Sybil Agent room.
// Messages remain in the agent's durable transcript, not duplicated in Sybil Chat.
model AgentReadState {
sourceId String?
id String @id
cursor String @default("0")
updatedAt DateTime @updatedAt
}
// One server-derived shared principal. Transport payloads are secret DB data.
model AgentPushInstallation {
id String @id
ownerEpoch String
transport String
payload String
fingerprint String
enabled Boolean @default(false)
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastClearAt DateTime?
revokedAt DateTime?
@@index([ownerEpoch, enabled])
@@index([fingerprint])
}
model AgentPushPresence {
id String @id
ownerEpoch String
sequence Int
visible Boolean
focused Boolean
lastInteractionAt DateTime?
inactiveUntil DateTime?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
model AgentPushConnection {
id String @id
ownerEpoch String
kind String
sessionId String?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
model AgentPushCoordinator {
id String @id
ownerEpoch String
sourceId String?
cursor String @default("0")
quarantined Boolean @default(false)
contextObservedAt DateTime?
latestUserMessageAt DateTime?
localPromptAt DateTime?
nativeActive Boolean @default(false)
nativeUnknown Boolean @default(true)
unknownSince DateTime?
uncertain Boolean @default(true)
lockOwner String?
lockUntil DateTime?
}
model AgentPushDelivery {
id String @id
ownerEpoch String
installationId String
installationVersion Int
sourceId String
messageId String
cursor String
kind String
trigger String
priority Int @default(0)
status String @default("pending")
attempts Int @default(0)
createdAt DateTime @default(now())
dueAt DateTime
expiresAt DateTime
updatedAt DateTime @updatedAt
outcome String?
lastSuppressionReason String?
lastSuppressionClientKind String?
lastSuppressedAt DateTime?
@@index([status, dueAt])
@@index([installationId, status])
@@index([ownerEpoch, sourceId, cursor])
}
+141
View File
@@ -0,0 +1,141 @@
import { Readable } from 'node:stream';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
export const AGENT_UPLOAD_LIMIT = 8 * 1024 * 1024 + 64 * 1024;
export const AgentAttachmentId = z.string().regex(/^att_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
export const AgentAttachmentReferences = z.array(z.object({ id: AgentAttachmentId }).strict()).max(8)
.refine(items => new Set(items.map(item => item.id)).size === items.length, 'Attachment IDs must be unique');
export const AgentAttachmentDescriptor = z.object({
id: AgentAttachmentId, kind: z.enum(['image', 'text', 'file']), filename: z.string().min(1).max(255),
mimeType: z.string().min(1).max(127), sizeBytes: z.number().int().min(0).max(8 * 1024 * 1024),
sha256: z.string().regex(/^[0-9a-f]{64}$/), workspacePath: z.string().startsWith('/').max(4096).optional(),
});
export type AgentProxyJson = (path: string, method?: string, body?: unknown, signal?: AbortSignal) => Promise<{ status: number; data: unknown }>;
function apiError(statusCode: number, code: string, message: string) {
return Object.assign(new Error(message), { statusCode, code });
}
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
}
/** Authentication precedes parsing; bounded streaming keeps concurrent upload memory bounded. */
export async function registerAgentAttachmentRoutes(app: FastifyInstance, options: { baseUrl: string; apiKey?: string; proxyJson: AgentProxyJson }) {
await app.register(async routes => {
let uploads = 0, downloads = 0;
type Transfer = { controller: AbortController; release(): void };
const transfers = new WeakMap<FastifyRequest, Transfer>();
const transfer = (req: FastifyRequest) => { const value = transfers.get(req)!; value.controller.signal.throwIfAborted(); return value; };
routes.addHook('onRequest', async (req, reply) => {
reply.header('Cache-Control', 'private, no-store'); requireAdmin(req);
if (!options.apiKey) throw apiError(503, 'attachments_unavailable', 'Sybil Agent is not configured.');
const uploading = req.method === 'POST';
if (uploading ? uploads >= 2 : downloads >= 4) throw apiError(429, 'attachments_busy', 'Attachment transfers are busy. Retry shortly.');
if (uploading) uploads++; else downloads++;
const controller = new AbortController(); let released = false;
const timeout = setTimeout(() => { release(); reply.raw.destroy(); }, 60_000);
const release = () => {
if (released) return; released = true;
if (uploading) uploads--; else downloads--;
clearTimeout(timeout); controller.abort();
req.raw.off('aborted', release); reply.raw.off('close', release); reply.raw.off('finish', release); reply.raw.off('error', release);
};
transfers.set(req, { controller, release });
req.raw.once('aborted', release); reply.raw.once('close', release); reply.raw.once('finish', release); reply.raw.once('error', release);
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
await requireAgentAttachments(options.proxyJson, controller.signal);
controller.signal.throwIfAborted();
});
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
routes.setErrorHandler((error: Error & { statusCode?: number; code?: string }, _req, reply) => {
if (reply.raw.destroyed) return;
if (_req.method === 'POST') reply.header('Connection', 'close');
const statusCode = error.statusCode || 502;
const code = typeof error.code === 'string' ? error.code : undefined;
if (statusCode === 429) reply.header('Retry-After', '2');
reply.code(statusCode).send({ error: true, message: statusCode >= 500 && !code ? 'Sybil Agent is unavailable.' : error.message,
statusCode, code: code || 'agent_unavailable' });
});
routes.addContentTypeParser(/^multipart\/form-data(?:;|$)/i, (req, payload, done) => {
if (String(req.headers['content-type']).length > 512) done(apiError(400, 'invalid_upload', 'Invalid multipart content type.'));
else done(null, payload);
});
const json = async (response: Response) => {
let size = 0; const chunks: Uint8Array[] = [];
if (!response.body) throw apiError(502, 'invalid_upstream_response', 'The Agent returned an empty response.');
const reader = response.body.getReader();
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > 64 * 1024) { await reader.cancel(); throw new Error('Response too large'); } chunks.push(part.value); }
return JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown;
} catch { throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid response.'); }
finally { reader.releaseLock(); }
};
routes.post('/v1/agent/attachments', { bodyLimit: AGENT_UPLOAD_LIMIT }, async (req, reply) => {
if (!(req.body instanceof Readable)) throw apiError(400, 'invalid_upload', 'A multipart/form-data upload is required.');
let oversized = false;
const incoming = req.body;
const { controller } = transfer(req);
const body = Readable.from((async function* () {
let count = 0;
for await (const chunk of incoming) {
count += chunk.length;
if (count > AGENT_UPLOAD_LIMIT) { oversized = true; throw new Error('Upload limit exceeded'); }
yield chunk;
}
})());
try {
const response = await fetch(new URL('/api/conversations/root/attachments', options.baseUrl), {
method: 'POST', headers: { Authorization: `Bearer ${options.apiKey}`, 'Content-Type': String(req.headers['content-type']) },
body: body as unknown as RequestInit['body'], duplex: 'half', signal: controller.signal, redirect: 'error',
} as RequestInit & { duplex: string });
const value = await json(response); controller.signal.throwIfAborted();
if (response.ok && (![200, 201].includes(response.status) || !AgentAttachmentDescriptor.safeParse((value as { attachment?: unknown })?.attachment).success))
throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid attachment descriptor.');
if (response.status === 429) reply.header('Retry-After', '2');
return reply.code(response.status).send(value);
} catch (error) {
if (oversized) throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
if (error && typeof error === 'object' && 'statusCode' in error) throw error;
throw apiError(502, 'agent_unavailable', 'The upload could not be confirmed. Retry with the same uploadId.');
} finally { body.destroy(); }
});
routes.get<{ Params: { id: string } }>('/v1/agent/attachments/:id/content', async (req, reply) => {
if (!AgentAttachmentId.safeParse(req.params.id).success) throw apiError(404, 'attachment_not_found', 'Attachment not found.');
const { controller } = transfer(req);
let response: Response;
try { response = await fetch(new URL(`/api/conversations/root/attachments/${req.params.id}/content`, options.baseUrl), {
headers: { Authorization: `Bearer ${options.apiKey}` }, signal: controller.signal, redirect: 'error',
}); } catch { throw apiError(502, 'agent_unavailable', 'Sybil Agent is unavailable.'); }
controller.signal.throwIfAborted();
if (!response.ok) return reply.code(response.status).send(await json(response));
const length = Number(response.headers.get('content-length'));
const disposition = response.headers.get('content-disposition');
const type = response.headers.get('content-type') || '';
if (!response.body || !Number.isSafeInteger(length) || length < 0 || length > 8 * 1024 * 1024
|| !disposition?.startsWith('attachment;') || !/^(image\/(png|jpeg|gif|webp)|text\/plain(?:; charset=utf-8)?|application\/octet-stream)$/.test(type)) {
await response.body?.cancel(); throw apiError(502, 'invalid_upstream_response', 'The Agent returned an invalid download.');
}
// Originals are bounded to 8 MiB. Verify the received length before sending a success response.
const reader = response.body.getReader(); let size = 0; const chunks: Uint8Array[] = [];
try {
for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.length;
if (size > length) throw new Error('Download length mismatch'); chunks.push(part.value); }
if (size !== length) throw new Error('Download length mismatch');
} catch { await reader.cancel().catch(() => {}); throw apiError(502, 'invalid_upstream_response', 'The attachment download was incomplete.'); }
finally { reader.releaseLock(); }
controller.signal.throwIfAborted();
reply.header('Content-Type', type).header('Content-Length', length).header('Content-Disposition', disposition).header('X-Content-Type-Options', 'nosniff');
return reply.send(Buffer.concat(chunks, size));
});
});
}
+34
View File
@@ -0,0 +1,34 @@
import { readFileSync, statSync } from 'node:fs';
import { createECDH } from 'node:crypto';
import { env } from './env.js';
import { validateAPNsKey } from './agent-push-contract.js';
export type PushConfig = { enabled: boolean; adminToken?: string; apns?: { key: string; keyId: string; teamId: string; topic: string; environment: 'sandbox' | 'production' };
web?: { publicKey: string; privateKey: string; subject: string } };
function secretFile(path: string): string {
const stat = statSync(path);
if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error('Unsafe push secret file');
return readFileSync(path, 'utf8').trim();
}
/** Never log config objects or secret-file read/crypto errors. Invalid credentials disable their adapter. */
export function loadPushConfig(): PushConfig {
const result: PushConfig = { enabled: env.AGENT_PUSH_ENABLED && Boolean(env.ADMIN_TOKEN), adminToken: env.ADMIN_TOKEN };
if (!result.enabled) return result;
try {
if (env.AGENT_PUSH_APNS_KEY_FILE && env.AGENT_PUSH_APNS_KEY_ID && env.AGENT_PUSH_APNS_TEAM_ID) {
const key = secretFile(env.AGENT_PUSH_APNS_KEY_FILE); validateAPNsKey(key);
result.apns = { key, keyId: env.AGENT_PUSH_APNS_KEY_ID, teamId: env.AGENT_PUSH_APNS_TEAM_ID,
topic: env.AGENT_PUSH_APNS_TOPIC, environment: env.AGENT_PUSH_APNS_ENVIRONMENT };
}
} catch { /* Unavailable, fail closed. Public config exposes only the availability bit. */ }
try {
if (env.AGENT_PUSH_VAPID_FILE) {
const value = JSON.parse(secretFile(env.AGENT_PUSH_VAPID_FILE));
const curve = createECDH('prime256v1'); curve.setPrivateKey(Buffer.from(value.privateKey, 'base64url'));
if (curve.getPublicKey().toString('base64url') !== value.publicKey || typeof value.subject !== 'string'
|| !/^(mailto:[^\s@]+@[^\s@]+|https:\/\/[^\s]+)$/.test(value.subject)) throw new Error();
result.web = { publicKey: value.publicKey, privateKey: value.privateKey, subject: value.subject };
}
} catch { /* Unavailable; never emit credential parse errors. */ }
return result;
}
+67
View File
@@ -0,0 +1,67 @@
import { createHash, createPublicKey, ECDH } from 'node:crypto';
import { z } from 'zod';
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 5_000,
inactiveGraceMs: 5_000, unknownGraceMs: 5_000, presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
export const MAX_AGE_MS = 5 * 60_000;
export const MAX_ATTEMPTS = 4;
export const ROOT_OWNER = 'root';
export const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)))
.transform(value => String(Number(value)));
export const InstallationId = z.string().uuid();
export const PresenceBody = z.object({ sequence: z.number().int().min(0).max(2_147_483_647),
visibility: z.enum(['visible', 'hidden']), focused: z.boolean(), interacted: z.boolean() }).strict();
export type Presence = z.infer<typeof PresenceBody>;
const Base64url = z.string().regex(/^[A-Za-z0-9_-]+={0,2}$/);
export const WebSubscription = z.object({ endpoint: z.string().max(2048), expirationTime: z.number().nonnegative().nullable().optional(),
keys: z.object({ p256dh: Base64url.max(90), auth: Base64url.max(24) }).strict() }).strict();
export const InstallationBody = z.discriminatedUnion('transport', [
z.object({ transport: z.literal('apns'), enabled: z.literal(true), token: z.string().regex(/^[a-fA-F0-9]{32,512}$/).refine(v => v.length % 2 === 0),
environment: z.enum(['sandbox', 'production']), topic: z.string().min(1).max(128) }).strict(),
z.object({ transport: z.literal('web'), enabled: z.literal(true), subscription: WebSubscription }).strict(),
]);
export type Registration = z.infer<typeof InstallationBody>;
export type Subscription = z.infer<typeof WebSubscription>;
export const NotificationContext = z.object({ version: z.literal(1), sourceId: z.string().uuid(), conversationId: z.string().min(1).max(128),
cursor: Cursor, hasMore: z.boolean(), latestUserMessageAt: z.string().datetime().nullable(), nativeReadCursor: Cursor,
uncertain: z.boolean(), webPresence: z.object({ active: z.boolean(), unknown: z.boolean(), observedAt: z.string().datetime() }),
candidates: z.array(z.object({ messageId: z.string().min(1).max(200), cursor: Cursor,
createdAt: z.string().datetime(), trigger: z.enum(['user', 'external_event', 'timer', 'worker', 'unknown']) })).max(200),
});
export type Context = z.infer<typeof NotificationContext>;
export type Identity = { version: 1; sourceId: string; kind: 'message'; messageId: string; cursor: string } |
{ version: 1; sourceId: string; kind: 'read_state'; readCursor: string };
export const digest = (value: string) => createHash('sha256').update(value).digest('hex');
export const ownerEpoch = (token: string) => digest(`sybil-agent-push-owner-v1\0${token}`);
export const fail = (statusCode: number, message: string) => Object.assign(new Error(message), { statusCode });
export function parseSafe<T>(schema: z.ZodType<T>, value: unknown): T {
const result = schema.safeParse(value);
// Do not reflect supplied endpoints/tokens or validation objects into logs/errors.
if (!result.success) throw fail(400, 'Invalid Agent push request');
return result.data;
}
/** Exact provider origins only: no arbitrary-host/redirect/private-network egress. */
export function validatePushEndpoint(endpoint: string): URL {
let url: URL;
try { url = new URL(endpoint); } catch { throw fail(400, 'Invalid Web Push subscription'); }
const allowed = url.hostname === 'fcm.googleapis.com' || url.hostname === 'updates.push.services.mozilla.com'
|| url.hostname === 'web.push.apple.com' || /^[a-z0-9-]+\.push\.apple\.com$/.test(url.hostname);
if (!allowed || url.protocol !== 'https:' || url.username || url.password || url.hash || (url.port && url.port !== '443')
|| endpoint.length > 2048 || !url.pathname || url.pathname === '/') throw fail(400, 'Unsupported Web Push provider');
return url;
}
export function validateSubscription(subscription: Subscription, now: number) {
validatePushEndpoint(subscription.endpoint);
if (subscription.expirationTime != null && subscription.expirationTime <= now) throw fail(400, 'Expired Web Push subscription');
try {
const key = Buffer.from(subscription.keys.p256dh, 'base64url');
if (key.length !== 65 || key[0] !== 4 || Buffer.from(subscription.keys.auth, 'base64url').length !== 16) throw new Error();
// Validate the curve point as well as its length before persistence.
ECDH.convertKey(key, 'prime256v1', undefined, undefined, 'uncompressed');
} catch { throw fail(400, 'Invalid Web Push encryption keys'); }
}
export function validateAPNsKey(key: string) {
const publicKey = createPublicKey(key);
if (publicKey.asymmetricKeyType !== 'ec' || publicKey.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new Error('Invalid APNs key');
}
+88
View File
@@ -0,0 +1,88 @@
import { convert } from 'html-to-text';
import { marked } from 'marked';
import { z } from 'zod';
import { Cursor, parseSafe } from './agent-push-contract.js';
export const MAX_PREVIEW_BYTES = 3000;
export const APNS_PAYLOAD_BYTES = 4096;
// RFC 8291: 4096 bytes less the 86-byte header, delimiter and 16-byte tag.
export const WEB_PUSH_PAYLOAD_BYTES = 3993;
const FALLBACK = 'You have a new message.';
export type PublicMessage = { conversationId: string; messageId: string; cursor: string };
export type MessageText = (message: PublicMessage) => Promise<string>;
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
messages: z.array(z.unknown()).max(1000) });
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe() }) });
/** Resolve only an already eligible candidate from one immutable root public entry. */
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
return async candidate => {
if (!upstream.apiKey || !Cursor.safeParse(candidate.cursor).success || Number(candidate.cursor) < 1
|| !(candidate.messageId === `outbound:${candidate.cursor}` || new RegExp(`^entry:${candidate.cursor}:\\d+$`).test(candidate.messageId))) {
throw new Error('Public Agent message unavailable');
}
const url = new URL('/api/conversations/root/messages', upstream.baseUrl);
url.searchParams.set('after', String(Number(candidate.cursor) - 1));
url.searchParams.set('limit', '1'); url.searchParams.set('includeTools', 'false');
const response = await fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' },
signal: AbortSignal.timeout(10_000), redirect: 'error' });
if (!response.ok) { await response.body?.cancel(); throw new Error('Public Agent message unavailable'); }
const reader = response.body?.getReader();
if (!reader) throw new Error('Public Agent message unavailable');
const chunks: Uint8Array[] = []; let length = 0;
try {
for (;;) {
const { done, value } = await reader.read(); if (done) break;
length += value.length;
if (length > 512 * 1024) throw new Error('Public Agent message unavailable');
chunks.push(value);
}
} finally { await reader.cancel(); }
const page = parseSafe(Page, JSON.parse(Buffer.concat(chunks).toString('utf8')));
if (page.conversationId !== candidate.conversationId || page.cursor !== candidate.cursor || page.beforeCursor !== candidate.cursor) {
throw new Error('Public Agent message unavailable');
}
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
const message = matches.length === 1 ? Message.safeParse(matches[0]) : undefined;
if (!message?.success || message.data.metadata.entryId !== Number(candidate.cursor)) throw new Error('Public Agent message unavailable');
// Never inspect metadata, tool results, activity, reasoning blocks or linked resources for text.
return message.data.content;
};
}
export function previewText(message: string): string {
const html = marked.parse(message, { async: false });
const text = convert(html, { wordwrap: false,
selectors: [
{ selector: 'a', options: { ignoreHref: true } },
{ selector: 'img', format: 'imageAlt' },
...['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].map(selector => ({ selector, options: { uppercase: false } })),
{ selector: 'hr', format: 'skip' },
],
formatters: { imageAlt: (element: { attribs: { alt?: string } }, _walk: unknown, builder: { addInline: (text: string) => void }) => builder.addInline(element.attribs.alt || '') },
});
// A string body for native OS display; normalize whitespace/control characters and malformed UTF-16.
return Buffer.from(text).toString('utf8').replace(/[\u0000-\u001f\u007f-\u009f\s]+/gu, ' ').trim();
}
/** Budget the complete serialized UTF-8 payload, including JSON escaping and identity overhead. */
export function previewPayload(message: string | undefined, payload: (body: string) => unknown, maxBytes: number): Buffer {
const text = (message === undefined ? '' : previewText(message)) || FALLBACK;
const budget = Math.min(MAX_PREVIEW_BYTES, maxBytes - Buffer.byteLength(JSON.stringify(payload(''))));
if (budget < 3) throw new Error('Agent push identity exceeds payload budget');
let body = text;
if (Buffer.byteLength(JSON.stringify(text)) - 2 > budget) {
body = ''; let bytes = 0;
for (const character of text) {
const size = Buffer.byteLength(JSON.stringify(character)) - 2;
if (bytes + size > budget - 3) break;
body += character; bytes += size;
}
body = body.trimEnd() + '…';
}
const result = Buffer.from(JSON.stringify(payload(body)));
if (result.length > maxBytes) throw new Error('Agent push exceeds payload budget');
return result;
}
+147
View File
@@ -0,0 +1,147 @@
import { createHash, sign } from 'node:crypto';
import { lookup } from 'node:dns';
import { connect } from 'node:http2';
import { request } from 'node:https';
import { isIP } from 'node:net';
import webpush from 'web-push';
import type { PushConfig } from './agent-push-config.js';
import { validatePushEndpoint, type Identity, type Registration } from './agent-push-contract.js';
import { APNS_PAYLOAD_BYTES, previewPayload, WEB_PUSH_PAYLOAD_BYTES } from './agent-push-preview.js';
export type SendResult = { outcome: 'accepted' | 'retry' | 'invalid' | 'failed' | 'ambiguous'; retryAfterMs?: number };
export type ProviderRequest = { endpoint: string; headers: Record<string, string | number>; body: Buffer };
export type ProviderResponse = { status: number; body?: string; retryAfter?: string };
export type RequestTransport = (request: ProviderRequest) => Promise<ProviderResponse>;
export type PushSend = (registration: Registration, identity: Identity, expiresAt: number, now: number, messageText?: string) => Promise<SendResult>;
function boundedRetry(value?: string): number | undefined {
if (!value || !/^\d+$/.test(value)) return undefined;
return Math.min(60_000, Math.max(1_000, Number(value) * 1000));
}
function classify(response: ProviderResponse, apns: boolean): SendResult {
if (response.status >= 200 && response.status < 300) return { outcome: 'accepted' };
if (response.status === 410 || (!apns && response.status === 404)) return { outcome: 'invalid' };
if (apns && response.status === 400) {
try { if (['BadDeviceToken', 'DeviceTokenNotForTopic', 'Unregistered'].includes(JSON.parse(response.body || '{}').reason)) return { outcome: 'invalid' }; } catch { /* Never emit provider text. */ }
}
if (response.status === 429 || response.status >= 500) return { outcome: 'retry', retryAfterMs: boundedRetry(response.retryAfter) };
return { outcome: 'failed' };
}
/** Fixed Apple origin, strict ten-second boundary. Unknown network outcomes are never auto-retried. */
export const createAPNsRequest = (open: typeof connect = connect, timeoutMs = 10_000): RequestTransport => async input => new Promise((resolve, reject) => {
const url = new URL(input.endpoint);
const session = open(url.origin);
let settled = false;
const finish = (error?: Error, value?: ProviderResponse) => {
if (settled) return; settled = true; clearTimeout(timer); session.destroy();
error ? reject(new Error('APNs transport unavailable')) : resolve(value!);
};
const timer = setTimeout(() => finish(new Error()), timeoutMs);
session.on('error', () => finish(new Error()));
session.on('goaway', () => finish(new Error()));
session.on('close', () => finish(new Error()));
session.on('connect', () => {
if (settled) return;
try {
const stream = session.request({ ':method': 'POST', ':path': url.pathname, ...input.headers });
let body = '', status = 0, retryAfter: string | undefined;
stream.on('response', headers => { status = Number(headers[':status']); retryAfter = String(headers['retry-after'] || ''); });
stream.on('data', data => { body += data.toString(); if (body.length > 4096) finish(new Error()); });
stream.on('error', () => finish(new Error()));
stream.on('end', () => finish(undefined, { status, body, retryAfter }));
stream.end(input.body);
} catch { finish(new Error()); }
});
});
export const apnsRequest = createAPNsRequest();
// Only global addresses are permitted even when an allowlisted provider's DNS is compromised.
export function isPublicAddress(address: string): boolean {
if (isIP(address) === 4) {
const [a,b] = address.split('.').map(Number);
return !(a === 0 || a === 10 || a === 127 || a >= 224 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && [0, 168].includes(b)) || (a === 100 && b >= 64 && b <= 127) || (a === 198 && [18,19,51].includes(b)) || (a === 203 && b === 0));
}
if (isIP(address) === 6) return /^[23][0-9a-f]{3}:/i.test(address) && !/^2001:(db8|0):/i.test(address) && !/^(2002|3fff):/i.test(address);
return false;
}
export const createWebRequest = (open: typeof request = request, timeoutMs = 10_000): RequestTransport => input => new Promise((resolve, reject) => {
const url = validatePushEndpoint(input.endpoint);
// https.request never follows redirects. The DNS result passed to the connection is checked here (no resolve/use race).
let settled = false;
const finish = (error?: Error, response?: ProviderResponse) => {
if (settled) return; settled = true; clearTimeout(deadline);
if (error) { req?.destroy(); reject(new Error('Web Push transport unavailable')); } else resolve(response!);
};
const deadline = setTimeout(() => finish(new Error()), timeoutMs);
let req: ReturnType<typeof request>;
try { req = open(url, { method: 'POST', headers: input.headers, timeout: 10_000,
lookup: (hostname, options, callback) => {
lookup(hostname, { all: true, verbatim: true }, (error, addresses) => {
if (error || !addresses.length || addresses.some(value => !isPublicAddress(value.address))) {
callback(new Error('Unsafe push provider address'), '', 4); return;
}
const selected = addresses.find(value => !options.family || value.family === options.family) || addresses[0];
// Node's automatic address selection requests all results.
if (options.all) (callback as unknown as (error: Error | null, results: { address: string; family: number }[]) => void)(null, addresses);
else callback(null, selected.address, selected.family);
});
},
}, res => {
res.resume(); // Provider bodies can contain endpoint/token values; never retain or log them.
res.on('end', () => finish(undefined, { status: res.statusCode || 0, retryAfter: String(res.headers['retry-after'] || '') }));
res.on('error', () => finish(new Error()));
res.on('aborted', () => finish(new Error()));
});
req.on('timeout', () => req.destroy(new Error('Web Push timeout')));
req.on('error', () => finish(new Error()));
req.on('close', () => { if (!settled) finish(new Error()); });
req.end(input.body);
} catch { finish(new Error()); }
});
export const webRequest = createWebRequest();
export function createPushSender(config: PushConfig, adapters: { apns?: RequestTransport; web?: RequestTransport } = {}): PushSend {
let jwt: { value: string; issuedAt: number } | undefined;
return async (registration, identity, expiresAt, now, messageText) => {
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
try {
if (registration.transport === 'apns') {
const credential = config.apns;
if (!credential || credential.topic !== registration.topic || credential.environment !== registration.environment) return { outcome: 'failed' };
if (!jwt || now - jwt.issuedAt >= 50 * 60_000 || now < jwt.issuedAt) {
const header = Buffer.from(JSON.stringify({ alg: 'ES256', kid: credential.keyId })).toString('base64url');
const claims = Buffer.from(JSON.stringify({ iss: credential.teamId, iat: Math.floor(now / 1000) })).toString('base64url');
const value = `${header}.${claims}`;
jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now };
}
const clear = identity.kind === 'read_state';
const body = clear ? Buffer.from(JSON.stringify({ aps: { 'content-available': 1 }, ...identity }))
: previewPayload(messageText, body => ({ aps: { alert: { title: 'Sybil Agent', body }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }), APNS_PAYLOAD_BYTES);
const response = await (adapters.apns || apnsRequest)({
endpoint: `https://${registration.environment === 'sandbox' ? 'api.sandbox.push.apple.com' : 'api.push.apple.com'}/3/device/${registration.token}`,
headers: { authorization: `bearer ${jwt.value}`, 'apns-topic': credential.topic, 'apns-push-type': clear ? 'background' : 'alert',
'apns-priority': clear ? '5' : '10', 'apns-expiration': String(Math.floor(Math.min(expiresAt, now + 60_000) / 1000)),
'apns-collapse-id': `sybil-${clear ? 'read' : 'message'}-${createHash('sha256').update(identity.sourceId).digest('hex').slice(0,24)}`,
'content-type': 'application/json' }, body,
});
return classify(response, true);
}
// Safari requires visible pushes; there is deliberately no Web Push read-clear transport.
if (!config.web || identity.kind !== 'message') return { outcome: 'failed' };
validatePushEndpoint(registration.subscription.endpoint);
const payload = previewPayload(messageText, body => ({ ...identity, title: 'Sybil Agent', body, url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` }), WEB_PUSH_PAYLOAD_BYTES);
const details = webpush.generateRequestDetails(registration.subscription, payload, { vapidDetails: config.web,
contentEncoding: 'aes128gcm', TTL: Math.max(1, Math.min(60, Math.floor((expiresAt - now) / 1000))), urgency: 'normal',
topic: createHash('sha256').update(identity.sourceId).digest('base64url').slice(0,32) });
if (!details.body || details.body.length > 4096) return { outcome: 'failed' };
const response = await (adapters.web || webRequest)({ endpoint: details.endpoint, headers: details.headers, body: details.body! });
return classify(response, false);
} catch {
// Encryption/config and I/O errors are intentionally indistinguishable externally. Never replay an uncertain send.
return { outcome: 'ambiguous' };
}
};
}
+440
View File
@@ -0,0 +1,440 @@
import { randomUUID } from 'node:crypto';
import type { Prisma, PrismaClient } from '@prisma/client';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { requireAdmin } from './auth.js';
import { env } from './env.js';
import type { PushConfig } from './agent-push-config.js';
import { loadPushConfig } from './agent-push-config.js';
import { Cursor, InstallationBody, InstallationId, MAX_AGE_MS, MAX_ATTEMPTS, NotificationContext, PresenceBody, PUSH_POLICY,
digest, fail, ownerEpoch, parseSafe, validateSubscription, type Context, type Identity, type Presence, type Registration } from './agent-push-contract.js';
import { createPushSender, type PushSend } from './agent-push-transports.js';
import { createMessageText, type MessageText } from './agent-push-preview.js';
type Tx = Prisma.TransactionClient;
export type ReadState = { cursor: string; updatedAt: string | null; sourceId: string | null };
type Options = { db: PrismaClient; config: PushConfig; context: (after?: string, sourceId?: string) => Promise<Context>; messageText: MessageText; send: PushSend; now?: () => number;
broadcast?: (state: ReadState) => void; warmupMs?: number };
const date = (ms: number) => new Date(ms);
const activeStatuses = ['pending', 'intent'];
type Suppression = { reason: string; clientKind: 'web' | 'unknown' | 'runtime_web' | 'runtime_unknown' | 'system' };
/** Sole coordinator: persisted source checkpoint, installations, presence, claims and outcomes. */
export class AgentPushCoordinator {
readonly instanceId = randomUUID();
readonly epoch: string;
private readonly now: () => number;
private readonly warmUntil: number;
private started = false;
private untrackedConnections = new Map<string, { at: number; kind: 'web' | 'unknown' }>();
private running = false;
private timer?: ReturnType<typeof setInterval>;
private stopped = false;
private observedRoot?: { sourceId: string; conversationId: string };
constructor(readonly options: Options) {
this.epoch = ownerEpoch(options.config.adminToken || 'disabled');
this.now = options.now || Date.now;
this.warmUntil = this.now() + (options.warmupMs ?? PUSH_POLICY.presenceLeaseMs);
}
async initialize() {
if (this.started) return;
await this.options.db.$transaction(async tx => {
const current = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
if (!current) await tx.agentPushCoordinator.create({ data: { id: 'root', ownerEpoch: this.epoch } });
else if (current.ownerEpoch !== this.epoch) {
// Credentials rotate ownership generation; old endpoint opt-ins do not cross it.
await tx.agentPushInstallation.updateMany({ where: { enabled: true }, data: { enabled: false } });
await tx.agentPushDelivery.updateMany({ where: { status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'owner_changed' } });
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { ownerEpoch: this.epoch, contextObservedAt: null,
nativeUnknown: true, unknownSince: null, uncertain: true, lockOwner: null, lockUntil: null } });
}
});
this.started = true;
}
start() {
if (this.timer || !this.options.config.adminToken) return;
this.timer = setInterval(() => { void this.tick(); }, 2_000); this.timer.unref();
void this.tick();
}
async stop() { this.stopped = true; if (this.timer) clearInterval(this.timer); while (this.running) await new Promise(r => setTimeout(r, 20)); }
async config() {
await this.initialize();
const state = await this.options.db.agentPushCoordinator.findUnique({ where: { id: 'root' } });
const config = this.options.config;
return { version: 1, enabled: config.enabled, apnsAvailable: Boolean(config.enabled && config.apns),
webPushAvailable: Boolean(config.enabled && config.web), ...(config.enabled && config.web ? { vapidPublicKey: config.web.publicKey } : {}),
sourceId: state?.sourceId || null, quarantined: state?.quarantined || false, policy: PUSH_POLICY };
}
async register(id: string, value: Registration) {
await this.initialize();
const config = this.options.config;
if (!config.enabled) throw fail(503, 'Agent push is disabled');
if (value.transport === 'apns') {
if (!config.apns || config.apns.topic !== value.topic || config.apns.environment !== value.environment) throw fail(400, 'APNs topic or environment unavailable');
value = { ...value, token: value.token.toLowerCase() };
} else { if (!config.web) throw fail(503, 'Web Push is unavailable'); validateSubscription(value.subscription, this.now()); }
const payload = JSON.stringify(value), fingerprint = digest(value.transport === 'apns' ? `${value.environment}:${value.topic}:${value.token}` : value.subscription.endpoint);
return this.options.db.$transaction(async tx => {
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
if (old?.revokedAt) throw fail(409, 'Installation was retired; explicit opt-in requires a new installation ID');
if (old?.enabled && old.ownerEpoch === this.epoch && old.payload === payload) return { installationId: id, transport: old.transport, enabled: true, version: old.version };
if (await tx.agentPushInstallation.count({ where: { ownerEpoch: this.epoch, enabled: true } }) >= 64 && !old?.enabled) throw fail(429, 'Too many push installations');
// A rotated/recreated local installation cannot retain another active delivery to the same endpoint.
const duplicates = await tx.agentPushInstallation.findMany({ where: { fingerprint, revokedAt: null, id: { not: id } }, select: { id: true } });
if (duplicates.length) {
await tx.agentPushInstallation.updateMany({ where: { id: { in: duplicates.map(v => v.id) } }, data: { enabled: false, revokedAt: date(this.now()), payload: '', fingerprint: '' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: { in: duplicates.map(v => v.id) }, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'endpoint_rebound' } });
}
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'registration_changed' } });
const record = await tx.agentPushInstallation.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true },
update: { ownerEpoch: this.epoch, transport: value.transport, payload, fingerprint, enabled: true, version: { increment: 1 } } });
return { installationId: id, transport: record.transport, enabled: record.enabled, version: record.version };
});
}
async revoke(id: string) {
await this.options.db.$transaction(async tx => {
const old = await tx.agentPushInstallation.findUnique({ where: { id } });
if (!old?.revokedAt) await tx.agentPushInstallation.upsert({ where: { id },
create: { id, ownerEpoch: this.epoch, transport: 'revoked', payload: '', fingerprint: '', enabled: false, revokedAt: date(this.now()) },
update: { enabled: false, revokedAt: date(this.now()), version: { increment: 1 }, payload: '', fingerprint: '' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: id, status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'revoked' } });
});
}
async presence(id: string, value: Presence) {
const now = this.now();
return this.options.db.$transaction(async tx => {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
const previous = old?.ownerEpoch === this.epoch ? old : null;
const visible = value.visibility === 'visible', focused = visible && value.focused;
const data = { ownerEpoch: this.epoch, sequence: value.sequence, visible, focused,
lastInteractionAt: value.interacted && visible ? date(now) : previous?.lastInteractionAt || null,
// Only a transition out of proven focus starts grace. Inactive heartbeats cannot renew it.
inactiveUntil: focused ? null : previous?.visible && previous.focused && previous.expiresAt.getTime() > now ? date(now + PUSH_POLICY.inactiveGraceMs) : previous?.inactiveUntil || null,
expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
const row = await tx.agentPushPresence.upsert({ where: { id }, create: { id, ...data }, update: data });
return { expiresAt: row.expiresAt.toISOString() };
});
}
async retirePresence(id: string) {
await this.options.db.$transaction(async tx => {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch !== this.epoch) return;
await tx.agentPushPresence.update({ where: { id }, data: { visible: false, focused: false,
inactiveUntil: old.visible && old.focused && old.expiresAt.getTime() > this.now() ? date(this.now() + PUSH_POLICY.inactiveGraceMs) : old.inactiveUntil } });
});
}
async connect(id: string, kind?: string, sessionId?: string) {
const now = this.now();
if (kind !== 'native' && !this.untrackedConnections.has(id)) this.untrackedConnections.set(id, { at: now, kind: kind === 'web' ? 'web' : 'unknown' });
await this.initialize();
await this.options.db.$transaction(async tx => {
const connection = await tx.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) } });
const observed = connection.kind === 'web' && connection.sessionId
? await tx.agentPushPresence.findFirst({ where: { id: connection.sessionId, ownerEpoch: this.epoch } }) : null;
if (connection.kind !== 'native' && !observed) {
// Raw clients have no stable identity. One persisted introduction window per owner
// prevents reconnects (including invented UUIDs) from manufacturing attention.
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(now) } });
}
});
this.untrackedConnections.delete(id);
}
async heartbeat(id: string) {
// Transport liveness never creates a client, changes its attribution or proves attention.
await this.options.db.agentPushConnection.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
}
async disconnect(id: string) { this.untrackedConnections.delete(id); await this.options.db.agentPushConnection.deleteMany({ where: { id, ownerEpoch: this.epoch } }); }
async notePrompt() {
if (!this.options.config.adminToken) return;
await this.initialize();
// Covers submissions arriving after the last runtime snapshot but before the send claim.
await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch }, data: { localPromptAt: date(this.now()) } });
}
async readState(tx: Tx | PrismaClient = this.options.db): Promise<ReadState> {
const row = await tx.agentReadState.findUnique({ where: { id: 'root' } });
return { cursor: row?.cursor || '0', updatedAt: row?.updatedAt.toISOString() || null, sourceId: row?.sourceId || null };
}
private async mergeRead(tx: Tx, cursor: string, sourceId: string | null): Promise<ReadState | undefined> {
const old = await tx.agentReadState.findUnique({ where: { id: 'root' } });
if (old?.sourceId && sourceId && old.sourceId !== sourceId) return;
if (Number(cursor) <= Number(old?.cursor || 0)) return;
await tx.$executeRaw`INSERT INTO "AgentReadState" ("id", "cursor", "updatedAt", "sourceId") VALUES ('root', ${cursor}, CURRENT_TIMESTAMP, ${sourceId})
ON CONFLICT("id") DO UPDATE SET "cursor" = excluded."cursor", "updatedAt" = CURRENT_TIMESTAMP,
"sourceId" = COALESCE("AgentReadState"."sourceId", excluded."sourceId")
WHERE CAST(excluded."cursor" AS INTEGER) > CAST("AgentReadState"."cursor" AS INTEGER)`;
if (sourceId) {
await tx.$executeRaw`UPDATE "AgentPushDelivery" SET "status" = 'cancelled', "outcome" = 'read', "updatedAt" = CURRENT_TIMESTAMP
WHERE "ownerEpoch" = ${this.epoch} AND "sourceId" = ${sourceId} AND "kind" = 'message' AND "status" = 'pending' AND CAST("cursor" AS INTEGER) <= ${Number(cursor)}`;
if (this.options.config.enabled && this.options.config.apns) {
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true, transport: 'apns' } });
for (const installation of installations) {
// Do not send background clears to installations that never received an alert.
const delivered = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId, kind: 'message', status: { in: ['accepted', 'intent', 'ambiguous'] } } });
if (!delivered) continue;
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, kind: 'read_state', status: 'pending' }, data: { status: 'cancelled', outcome: 'superseded' } });
const id = digest(`clear:${installation.id}:${installation.version}:${sourceId}:${cursor}`);
const now = this.now();
await tx.agentPushDelivery.upsert({ where: { id }, update: {}, create: { id, ownerEpoch: this.epoch, installationId: installation.id,
installationVersion: installation.version, sourceId, cursor, messageId: `read:${cursor}`, kind: 'read_state', trigger: 'read',
dueAt: date(Math.max(now, (installation.lastClearAt?.getTime() || 0) + 60_000)), expiresAt: date(now + MAX_AGE_MS) } });
}
}
}
return this.readState(tx);
}
async advanceRead(cursor: string, suppliedSource?: string): Promise<ReadState> {
await this.initialize();
const result = await this.options.db.$transaction(async tx => {
const coordinator = await tx.agentPushCoordinator.findUnique({ where: { id: 'root' } });
if (suppliedSource && coordinator?.sourceId !== suppliedSource) throw fail(409, 'Agent source changed; refresh before acknowledging');
// Quarantine never relabels old read positions as a different runtime.
const changed = await this.mergeRead(tx, cursor, coordinator?.sourceId || null);
return { state: changed || await this.readState(tx), changed: Boolean(changed) };
});
if (result.changed) this.options.broadcast?.(result.state);
return result.state;
}
private async acquire(): Promise<boolean> {
const now = this.now();
const result = await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch,
OR: [{ lockOwner: this.instanceId }, { lockUntil: null }, { lockUntil: { lte: date(now) } }] },
data: { lockOwner: this.instanceId, lockUntil: date(now + 30_000) } });
return result.count === 1;
}
private async observe(): Promise<boolean> {
const state = await this.options.db.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
const context = parseSafe(NotificationContext, await this.options.context(state.sourceId ? state.cursor : undefined, state.sourceId || undefined));
const now = this.now(), observedAt = Date.parse(context.webPresence.observedAt);
if ((!state.sourceId || (state.sourceId === context.sourceId && Number(context.cursor) >= Number(state.cursor))) && (observedAt > now + 5_000 || observedAt < now - 10_000 || (state.contextObservedAt && observedAt < state.contextObservedAt.getTime())
|| (context.latestUserMessageAt && Date.parse(context.latestUserMessageAt) > now + 5_000)
|| (state.latestUserMessageAt && (!context.latestUserMessageAt || Date.parse(context.latestUserMessageAt) < state.latestUserMessageAt.getTime())))) throw new Error('Uncertain runtime clock');
const read = await this.options.db.$transaction(async tx => {
const current = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
if (current.lockOwner !== this.instanceId || current.ownerEpoch !== this.epoch) throw new Error('Lost coordinator lease');
if ((current.sourceId && current.sourceId !== context.sourceId) || (current.sourceId && Number(context.cursor) < Number(current.cursor))) {
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { quarantined: true, uncertain: true } });
await tx.agentPushDelivery.updateMany({ where: { status: 'pending' }, data: { status: 'cancelled', outcome: 'source_changed' } });
return undefined;
}
if (current.quarantined) return undefined;
const initial = !current.sourceId;
const previousCursor = Number(current.cursor);
let candidateCursor = previousCursor;
for (const candidate of context.candidates) {
if (Number(candidate.cursor) < candidateCursor || Number(candidate.cursor) > Number(context.cursor)) throw new Error('Invalid source order');
candidateCursor = Number(candidate.cursor);
}
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { sourceId: context.sourceId, cursor: context.cursor,
latestUserMessageAt: context.latestUserMessageAt ? date(Date.parse(context.latestUserMessageAt)) : null,
nativeActive: context.webPresence.active, nativeUnknown: context.webPresence.unknown, contextObservedAt: date(observedAt), uncertain: context.uncertain,
lockUntil: date(now + 30_000) } });
await tx.agentReadState.upsert({ where: { id: 'root' }, create: { id: 'root', cursor: '0', sourceId: context.sourceId }, update: {} });
await tx.agentReadState.updateMany({ where: { id: 'root', sourceId: null }, data: { sourceId: context.sourceId } });
const changedRead = await this.mergeRead(tx, context.nativeReadCursor, context.sourceId);
const readState = await this.readState(tx);
if (!initial && this.options.config.enabled && !context.uncertain) {
const installations = await tx.agentPushInstallation.findMany({ where: { ownerEpoch: this.epoch, enabled: true } });
for (const candidate of context.candidates) {
const createdAt = Date.parse(candidate.createdAt);
if (Number(candidate.cursor) <= previousCursor || Number(candidate.cursor) <= Number(readState.cursor) || candidate.trigger === 'unknown'
|| createdAt > now + 5_000 || createdAt + MAX_AGE_MS <= now || (candidate.trigger === 'user' && !context.latestUserMessageAt)) continue;
for (const installation of installations) {
const id = digest(`${this.epoch}:${installation.id}:${installation.version}:${context.sourceId}:${candidate.messageId}`);
if (await tx.agentPushDelivery.findUnique({ where: { id } })) continue;
const older = await tx.agentPushDelivery.findFirst({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, orderBy: { createdAt: 'asc' } });
await tx.agentPushDelivery.updateMany({ where: { installationId: installation.id, sourceId: context.sourceId, kind: 'message', status: 'pending' }, data: { status: 'cancelled', outcome: 'coalesced' } });
const burstStart = older?.createdAt.getTime() || now;
await tx.agentPushDelivery.create({ data: { id, ownerEpoch: this.epoch, installationId: installation.id, installationVersion: installation.version,
sourceId: context.sourceId, messageId: candidate.messageId, cursor: candidate.cursor, kind: 'message', trigger: candidate.trigger,
priority: Math.max(older?.priority || 0, ['timer', 'external_event'].includes(candidate.trigger) ||
(context.latestUserMessageAt && createdAt - Date.parse(context.latestUserMessageAt) >= 30_000) ? 2 : 1),
createdAt: date(burstStart), dueAt: date(Math.max(Math.min(now + PUSH_POLICY.debounceMs, burstStart + 15_000),
context.latestUserMessageAt ? Date.parse(context.latestUserMessageAt) + PUSH_POLICY.promptGraceMs : 0)), expiresAt: date(createdAt + MAX_AGE_MS) } });
}
}
}
// Keep the durable active outbox plus at most 10,000 completed receipts. The source checkpoint,
// not receipt retention, prevents historical replay after cleanup.
await tx.$executeRaw`DELETE FROM "AgentPushDelivery" WHERE "id" IN (
SELECT "id" FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent') ORDER BY "updatedAt" ASC
LIMIT MAX(0, (SELECT COUNT(*) FROM "AgentPushDelivery" WHERE "status" NOT IN ('pending','intent')) - 10000))`;
return changedRead;
});
if (read) this.options.broadcast?.(read);
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
return context.hasMore;
}
private async browserSuppression(tx: Tx, now: number): Promise<Suppression | undefined> {
const observations = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch } });
const presences = observations.filter(v => v.expiresAt.getTime() > now);
if (presences.some(v => v.visible && v.focused)) return { reason: 'foreground_focused', clientKind: 'web' };
if (presences.some(v => v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now)) return { reason: 'recent_interaction', clientKind: 'web' };
if (presences.some(v => v.inactiveUntil && v.inactiveUntil.getTime() > now)) return { reason: 'inactive_grace', clientKind: 'web' };
const connections = await tx.agentPushConnection.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
const unobserved = connections.filter(v => v.kind !== 'native' && (v.kind !== 'web' || !observations.some(p => p.id === v.sessionId)));
if (!unobserved.length && !this.untrackedConnections.size) return;
const firstUntracked = [...this.untrackedConnections.values()].sort((a, b) => a.at - b.at)[0];
await tx.agentPushCoordinator.updateMany({ where: { id: 'root', ownerEpoch: this.epoch, unknownSince: null }, data: { unknownSince: date(firstUntracked?.at ?? now) } });
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
if (state.unknownSince!.getTime() + PUSH_POLICY.unknownGraceMs > now) return { reason: 'unknown_client_grace',
clientKind: unobserved[0]?.kind === 'web' ? 'web' : firstUntracked?.kind || 'unknown' };
}
private async claim(id: string, commit: boolean) {
return this.options.db.$transaction(async tx => {
const now = this.now();
const state = await tx.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
const row = await tx.agentPushDelivery.findUnique({ where: { id } });
if (!row || row.status !== 'pending' || row.dueAt.getTime() > now) return;
const suppress = async ({ reason, clientKind }: Suppression) => {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs),
lastSuppressionReason: reason, lastSuppressionClientKind: clientKind, lastSuppressedAt: date(now) } });
};
const installation = await tx.agentPushInstallation.findUnique({ where: { id: row.installationId } });
const read = await this.readState(tx);
if (!installation?.enabled || installation.ownerEpoch !== this.epoch || installation.version !== row.installationVersion || row.expiresAt.getTime() <= now
|| row.ownerEpoch !== this.epoch || row.sourceId !== state.sourceId || state.quarantined || (row.kind === 'message' && Number(row.cursor) <= Number(read.cursor))) {
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'obsolete' } }); return;
}
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) {
await suppress({ reason: now < this.warmUntil ? 'startup_warmup' : 'source_or_coordinator_unavailable', clientKind: 'system' }); return;
}
if (row.kind === 'message') {
const attention: Suppression | undefined = state.nativeActive ? { reason: 'runtime_active', clientKind: 'runtime_web' }
: state.nativeUnknown ? { reason: 'runtime_unknown', clientKind: 'runtime_unknown' } : await this.browserSuppression(tx, now);
if (attention) { await suppress(attention); return; }
if ((state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now)) {
await suppress({ reason: 'prompt_grace', clientKind: 'system' }); return;
}
}
if (row.attempts >= MAX_ATTEMPTS) { await tx.agentPushDelivery.update({ where: { id }, data: { status: 'failed', outcome: 'attempt_limit' } }); return; }
const registration = parseSafe(InstallationBody, JSON.parse(installation.payload));
if (registration.transport === 'web' && registration.subscription.expirationTime != null && registration.subscription.expirationTime <= now) {
await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { enabled: false } });
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'expired_subscription' } }); return;
}
const identity: Identity = row.kind === 'read_state' ? { version: 1, sourceId: row.sourceId, kind: 'read_state', readCursor: read.cursor }
: { version: 1, sourceId: row.sourceId, kind: 'message', messageId: row.messageId, cursor: row.cursor };
if (!commit) return { row, registration, identity, now };
// Durable crossing intent is committed BEFORE invoking an external adapter. A crash here is ambiguous, never replayed.
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'intent', attempts: { increment: 1 }, outcome: null } });
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { lockUntil: date(now + 30_000) } });
if (row.kind === 'read_state') await tx.agentPushInstallation.update({ where: { id: installation.id }, data: { lastClearAt: date(now) } });
return { row, registration, identity, now };
});
}
private async dispatch(id: string) {
// Only resolve text for an otherwise dispatchable candidate, outside the DB transaction.
// Recheck source/read/presence and every claim condition after the bounded public lookup.
const ready = await this.claim(id, false);
if (!ready) return;
let messageText: string | undefined;
if (ready.identity.kind === 'message') {
if (this.observedRoot?.sourceId !== ready.identity.sourceId) throw new Error('Public Agent source unavailable');
messageText = await this.options.messageText({ ...ready.identity, conversationId: this.observedRoot.conversationId });
if (await this.observe()) return;
}
const claim = await this.claim(id, true);
if (!claim) return;
let result;
try { result = await this.options.send(claim.registration, claim.identity, claim.row.expiresAt.getTime(), claim.now, messageText); }
catch { result = { outcome: 'ambiguous' as const }; }
await this.options.db.$transaction(async tx => {
const pending = result.outcome === 'retry' && claim.row.attempts + 1 < MAX_ATTEMPTS;
await tx.agentPushDelivery.updateMany({ where: { id, status: 'intent' }, data: { status: pending ? 'pending' : result.outcome === 'retry' ? 'failed' : result.outcome,
outcome: result.outcome, ...(pending ? { dueAt: date(this.now() + (result.retryAfterMs ?? Math.min(60_000, 2_000 * 2 ** claim.row.attempts))) } : {}) } });
if (result.outcome === 'invalid') await tx.agentPushInstallation.updateMany({ where: { id: claim.row.installationId, version: claim.row.installationVersion, ownerEpoch: this.epoch }, data: { enabled: false } });
});
}
async tick() {
if (this.running || this.stopped || !this.options.config.adminToken) return;
this.running = true;
try {
await this.initialize(); if (!await this.acquire()) return;
// Only the lease holder recovers expired intents. Never resend a potentially accepted provider request.
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'intent', updatedAt: { lt: date(this.now() - 30_000) } }, data: { status: 'ambiguous', outcome: 'interrupted_send' } });
let more = true;
for (let i = 0; i < 10 && more; i++) { if (!await this.acquire()) return; more = await this.observe(); }
if (more) return; // Current native read/prompt/presence must be caught up before dispatch.
const pending = await this.options.db.agentPushDelivery.findMany({ where: { status: 'pending', dueAt: { lte: date(this.now()) } }, orderBy: [{ priority: 'desc' }, { dueAt: 'asc' }], take: 20, select: { id: true } });
for (const row of pending) {
if (this.stopped || !await this.acquire()) break;
// Fetch fresh runtime native presence/read/latest prompt before EACH provider boundary.
if (await this.observe()) break;
await this.dispatch(row.id);
}
await this.options.db.agentPushDelivery.updateMany({ where: { status: 'pending', expiresAt: { lte: date(this.now()) } }, data: { status: 'cancelled', outcome: 'expired' } });
await this.options.db.agentPushPresence.deleteMany({ where: { expiresAt: { lt: date(this.now() - 86_400_000) } } });
await this.options.db.agentPushConnection.deleteMany({ where: { expiresAt: { lt: date(this.now()) } } });
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
} catch {
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
try {
await this.options.db.$transaction(async tx => {
const held = await tx.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } });
if (held.count) await tx.agentPushDelivery.updateMany({ where: { ownerEpoch: this.epoch, status: 'pending' }, data: {
lastSuppressionReason: 'source_or_coordinator_unavailable', lastSuppressionClientKind: 'system', lastSuppressedAt: date(this.now()) } });
});
} catch { /* Retry on next loop. */ }
} finally {
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { lockOwner: null, lockUntil: null } }); } catch { /* Lease expiry recovers. */ }
this.running = false;
}
}
}
export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient, upstream: { baseUrl: string; apiKey?: string }, broadcast: (state: ReadState) => void,
overrides?: Partial<Options>): Promise<AgentPushCoordinator> {
const config = overrides?.config || loadPushConfig();
const coordinator = new AgentPushCoordinator({ db, config, broadcast,
context: async (after, sourceId) => {
if (!upstream.apiKey) throw new Error('Runtime unavailable');
// A replaced/restored runtime can reject the old cursor before returning its identity.
// Share one deadline across the incremental request and its single high-water probe.
const signal = AbortSignal.timeout(10_000);
const request = async (cursor?: string) => {
const url = new URL('/api/notification-context', upstream.baseUrl);
if (cursor !== undefined) url.searchParams.set('after', cursor); url.searchParams.set('limit', '200');
return fetch(url, { headers: { Authorization: `Bearer ${upstream.apiKey}`, 'X-Sybil-Client': 'backend-proxy-v1' }, signal, redirect: 'error' });
};
let response = await request(after);
const probe = response.status === 409 && after !== undefined;
if (probe) { await response.body?.cancel(); response = await request(); }
if (!response.ok) { await response.body?.cancel(); throw new Error('Runtime unavailable'); }
const reader = response.body?.getReader(); if (!reader) throw new Error('Invalid runtime response');
const chunks: Uint8Array[] = []; let length = 0;
try { for (;;) { const { done, value } = await reader.read(); if (done) break; length += value.length;
if (length > 512 * 1024) throw new Error('Invalid runtime response'); chunks.push(value); }
} finally { await reader.cancel(); }
const context = parseSafe(NotificationContext, JSON.parse(Buffer.concat(chunks).toString()));
// This probe may only establish quarantine. Never use it to skip a backlog, advance
// the checkpoint, merge new reads, or replay historical candidates after a generic409.
if (probe && (!sourceId || context.hasMore || context.candidates.length > 0
|| (context.sourceId === sourceId && Number(context.cursor) >= Number(after)))) throw new Error('Runtime cursor unavailable');
return context;
}, messageText: createMessageText(upstream), send: createPushSender(config), ...overrides });
const auth = (req: FastifyRequest) => { if (!env.ADMIN_TOKEN) throw fail(503, 'Agent push requires authenticated access'); requireAdmin(req); };
const safe = <T>(fn: () => Promise<T>): Promise<T> => fn().catch(error => { if (error?.statusCode) throw error; throw fail(503, 'Agent push is unavailable'); });
const routeOptions = { bodyLimit: 16 * 1024 };
app.get('/v1/agent/push/config', async (req, reply) => { reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.config()); });
app.put<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', routeOptions, async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req);
const id = parseSafe(InstallationId, req.params.installationId), value = parseSafe(InstallationBody, req.body);
return safe(() => coordinator.register(id, value));
});
app.delete<{ Params: { installationId: string } }>('/v1/agent/push/installations/:installationId', async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.revoke(parseSafe(InstallationId, req.params.installationId))); return reply.code(204).send();
});
app.put<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', routeOptions, async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req); return safe(() => coordinator.presence(parseSafe(InstallationId, req.params.sessionId), parseSafe(PresenceBody, req.body)));
});
app.delete<{ Params: { sessionId: string } }>('/v1/agent/presence/:sessionId', async (req, reply) => {
reply.header('Cache-Control', 'no-store'); auth(req); await safe(() => coordinator.retirePresence(parseSafe(InstallationId, req.params.sessionId))); return reply.code(204).send();
});
app.addHook('onReady', async () => { if (config.adminToken && upstream.apiKey) coordinator.start(); });
app.addHook('onClose', async () => coordinator.stop());
return coordinator;
}
+196
View File
@@ -0,0 +1,196 @@
import { randomUUID } from 'node:crypto';
import { registerPushRoutes, type AgentPushCoordinator, type ReadState } from './agent-push.js';
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import type { FastifyInstance } from 'fastify';
import { WebSocket, WebSocketServer } from 'ws';
import { z } from 'zod';
import { requireAdmin } from './auth.js';
import { prisma } from './db.js';
import { env } from './env.js';
import { AgentAttachmentReferences, registerAgentAttachmentRoutes, requireAgentAttachments } from './agent-attachments.js';
const Cursor = z.string().regex(/^\d{1,16}$/).refine(value => Number.isSafeInteger(Number(value)), 'Invalid cursor')
.transform(value => String(Number(value)));
const PageQuery = z.object({ after: Cursor.optional(), before: Cursor.optional(), limit: z.coerce.number().int().min(1).max(200).optional() })
.refine(value => !(value.after && value.before), 'Use after or before, not both');
const MessageBody = z.object({ content: z.string().max(40_000), requestId: z.string().min(1).max(256), attachments: AgentAttachmentReferences.optional() })
.refine(value => value.content.trim().length > 0 || Boolean(value.attachments?.length), 'content or attachments are required');
const ReadBody = z.object({ cursor: Cursor, sourceId: z.string().uuid().optional() });
const AuthFrame = z.object({ type: z.literal('auth'), token: z.string().nullable().optional(), after: Cursor.optional(), clientKind: z.enum(['web', 'native']).optional(), presenceSessionId: z.string().uuid().optional() });
const SubscribeFrame = z.object({ type: z.literal('subscribe'), after: Cursor.optional() });
const SubmissionReceipt = z.object({ id: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
status: z.enum(['queued', 'placed', 'done', 'unanswered']), messageId: z.string(), attachmentIds: z.array(z.string()).optional() });
type AgentOptions = { baseUrl?: string; apiKey?: string };
const parse = <T>(schema: z.ZodType<T>, value: unknown): T => {
const result = schema.safeParse(value);
if (!result.success) throw Object.assign(new Error(result.error.issues.map(issue => issue.message).join('; ')), { statusCode: 400 });
return result.data;
};
/** Sybil Agent owns the conversation; the chat server stores only its shared read cursor. */
export async function registerAgentRoutes(app: FastifyInstance, options: AgentOptions = {}) {
const baseUrl = options.baseUrl || env.SYBIL_AGENT_BASE_URL;
const apiKey = options.apiKey ?? env.SYBIL_AGENT_API_KEY;
const readListeners = new Set<(state: ReadState) => void>();
const push = await registerPushRoutes(app, prisma, { baseUrl, apiKey }, state => { for (const send of readListeners) send(state); });
// An open development proxy must not expose a token-protected attachment runtime's
// history/model context using its server-side credential.
const guardOpenProxy = async (signal?: AbortSignal) => {
if (env.ADMIN_TOKEN) return;
try {
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
});
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
};
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
if (!apiKey) throw app.httpErrors.serviceUnavailable('Sybil Agent is not configured');
await guardOpenProxy(signal);
let response: Response;
try {
response = await fetch(new URL(path, baseUrl), { method,
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
});
} catch { throw app.httpErrors.badGateway('Sybil Agent is unavailable'); }
let data: unknown;
try { data = await response.json(); }
catch { throw app.httpErrors.badGateway('Sybil Agent returned invalid JSON'); }
return { status: response.status, data };
};
app.get('/v1/agent', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const result = await proxyJson('/api/conversations/root');
reply.code(result.status);
if (result.status !== 200) return result.data;
const descriptor: Record<string, unknown> = { ...result.data as Record<string, unknown>, websocket: '/v1/agent/ws' };
if (!env.ADMIN_TOKEN) delete descriptor.capabilities;
return descriptor;
});
app.get('/v1/agent/messages', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const query = parse(PageQuery, req.query);
const params = new URLSearchParams();
for (const [name, value] of Object.entries(query)) if (value !== undefined) params.set(name, String(value));
const result = await proxyJson(`/api/conversations/root/messages?${params}`);
reply.code(result.status);
return result.data;
});
app.post('/v1/agent/messages', { bodyLimit: 256 * 1024 }, async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const body = parse(MessageBody, req.body);
await push.notePrompt();
if (body.attachments?.length) await requireAgentAttachments(proxyJson);
const result = await proxyJson('/api/conversations/root/messages', 'POST', { ...body, channel: 'sybil', whenBusy: 'followUp', wait: false });
if (result.status >= 200 && result.status < 300) {
const receipt = SubmissionReceipt.safeParse(result.data);
if (!receipt.success || receipt.data.messageId !== `inbound:sybil:${body.requestId}`
|| (body.attachments?.length && JSON.stringify(receipt.data.attachmentIds) !== JSON.stringify(body.attachments.map(item => item.id)))) {
throw app.httpErrors.badGateway('Sybil Agent returned an invalid message receipt');
}
}
reply.code(result.status);
return result.data;
});
app.get('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
return push.readState();
});
app.put('/v1/agent/read-state', async (req, reply) => {
reply.header('Cache-Control', 'no-store');
requireAdmin(req);
const { cursor, sourceId } = parse(ReadBody, req.body);
return push.advanceRead(cursor, sourceId);
});
await registerAgentAttachmentRoutes(app, { baseUrl, apiKey, proxyJson });
attachAgentSocket(app, baseUrl, apiKey, guardOpenProxy, push, readListeners);
}
function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string | undefined, guardOpenProxy: () => Promise<void>, push: AgentPushCoordinator, readListeners: Set<(state: ReadState) => void>) {
const sockets = new WebSocketServer({ noServer: true, maxPayload: 16 * 1024 });
const cleanup = new Set<() => void>();
const upgrade = (request: IncomingMessage, socket: Duplex, head: Buffer) => {
let url: URL;
try { url = new URL(request.url || '/', 'http://localhost'); }
catch { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
if (url.pathname !== '/v1/agent/ws') { socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n'); return; }
// Credentials must never enter URLs and access logs.
if (url.search) { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); return; }
sockets.handleUpgrade(request, socket, head, browser => {
let upstream: WebSocket | undefined;
const connectionId = randomUUID();
const sendRead = (state: ReadState) => send({ type: 'read_state', ...state });
let authenticated = false, authenticating = false, disposed = false, alive = true;
const send = (value: unknown) => {
if (browser.readyState !== WebSocket.OPEN) return;
if (browser.bufferedAmount > 2 * 1024 * 1024) { browser.close(1013, 'Slow consumer; reconnect with cursor'); return; }
browser.send(typeof value === 'string' ? value : JSON.stringify(value));
};
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.heartbeat(connectionId).catch(() => {}); });
const dispose = () => {
if (disposed) return;
disposed = true; clearTimeout(authTimeout); clearInterval(heartbeat); cleanup.delete(dispose);
readListeners.delete(sendRead);
void push.disconnect(connectionId).catch(() => {});
upstream?.terminate(); browser.terminate();
};
cleanup.add(dispose);
browser.on('close', dispose); browser.on('error', dispose);
browser.on('message', async (raw, isBinary) => {
try {
if (isBinary) throw new Error('JSON text frames are required');
const frame = JSON.parse(raw.toString());
if (!authenticated) {
if (authenticating) { browser.close(4400, 'Authentication is still pending'); return; }
authenticating = true;
const auth = parse(AuthFrame, frame);
if (env.ADMIN_TOKEN && auth.token !== env.ADMIN_TOKEN) { browser.close(4401, 'Invalid token'); return; }
if (!apiKey) { browser.close(4503, 'Sybil Agent is not configured'); return; }
try { await guardOpenProxy(); } catch { browser.close(4503, 'Authenticated Sybil access is required'); return; }
if (disposed || browser.readyState !== WebSocket.OPEN) return;
authenticated = true; clearTimeout(authTimeout);
readListeners.add(sendRead);
if (env.ADMIN_TOKEN) await push.connect(connectionId, auth.clientKind, auth.presenceSessionId).catch(() => {});
if (disposed) { void push.disconnect(connectionId).catch(() => {}); return; }
const target = new URL('/api/ws', baseUrl); target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
upstream = new WebSocket(target, { headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, handshakeTimeout: 10_000, maxPayload: 16 * 1024 * 1024 });
upstream.on('open', () => upstream!.send(JSON.stringify({ type: 'subscribe', ...(auth.after === undefined ? {} : { after: auth.after }) })));
upstream.on('message', (data, binary) => {
if (binary) { browser.close(1011, 'Invalid Sybil Agent frame'); return; }
send(data.toString());
});
upstream.on('close', () => { if (!disposed) browser.close(1012, 'Sybil Agent disconnected; reconnect with cursor'); });
upstream.on('error', () => { send({ type: 'error', error: 'Sybil Agent is unavailable' }); browser.close(1011, 'Sybil Agent is unavailable'); });
return;
}
if (frame.type === 'ping') { send({ type: 'pong' }); return; }
const subscribe = parse(SubscribeFrame, frame);
if (upstream?.readyState !== WebSocket.OPEN) { send({ type: 'error', error: 'Sybil Agent is still connecting' }); return; }
upstream.send(JSON.stringify(subscribe));
} catch (error) {
if (!authenticated) browser.close(4400, 'Invalid authentication frame');
else send({ type: 'error', error: error instanceof Error ? error.message : 'Invalid frame' });
}
});
});
};
app.server.on('upgrade', upgrade);
app.addHook('preClose', async () => {
app.server.off('upgrade', upgrade);
for (const stop of cleanup) stop();
for (const client of sockets.clients) client.terminate();
sockets.close();
});
}
+26
View File
@@ -0,0 +1,26 @@
export const CHROMIUM_USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36";
export const BROWSER_ACCEPT_LANGUAGE = "en-US,en;q=0.9";
export const FETCH_URL_ACCEPT =
"text/html,application/xhtml+xml,application/xml;q=0.9,application/pdf;q=0.9,*/*;q=0.8";
export function buildBrowserLikeRequestHeaders(accept: string): Record<string, string> {
return {
"User-Agent": CHROMIUM_USER_AGENT,
Accept: accept,
"Accept-Language": BROWSER_ACCEPT_LANGUAGE,
};
}
export function buildBrowserLikeNavigationHeaders(accept = FETCH_URL_ACCEPT): Record<string, string> {
return {
...buildBrowserLikeRequestHeaders(accept),
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
};
}
+2 -2
View File
@@ -8,9 +8,9 @@ export const prisma = new PrismaClient({
});
prisma.$on("error", (e: any) => {
console.error("[prisma:error]", e);
console.error("[prisma:error] Database operation failed");
});
prisma.$on("warn", (e: any) => {
console.warn("[prisma:warn]", e);
console.warn("[prisma:warn] Database warning");
});

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