Compare commits

..
Author SHA1 Message Date
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
33 changed files with 862 additions and 178 deletions
+16 -4
View File
@@ -698,7 +698,7 @@ All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer au
### `GET /v1/agent/push/config`
```json
{"version":1,"enabled":false,"apnsAvailable":false,"webPushAvailable":false,"sourceId":null,"quarantined":false,"policy":{"promptGraceMs":5000,"debounceMs":2000,"interactionGraceMs":30000,"presenceHeartbeatMs":15000,"presenceLeaseMs":60000}}
{"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.
@@ -735,9 +735,11 @@ A UUID per browser document/tab, independent of notification permission and sele
{"sequence":12,"visibility":"visible","focused":true,"interacted":false}
```
Response `{ "expiresAt":"2026-10-15T03:00:00.000Z" }`. Sequence is a nonnegative signed 32-bit integer; stale/out-of-order heartbeats neither overwrite state nor extend the lease. Heartbeat every 15 seconds and on focus/visibility changes and recent pointer/key/touch input. Times are assigned by the server. Visible+focused is active even without recent interaction; any recent interaction blocks alerts for another 30 seconds. Leases expire after 60 seconds. All authenticated workspace sessions must report, even without notification opt-in; at most 512 unexpired sessions are accepted. Associate the UUID with WS auth as described in the streaming contract. Unknown authenticated web sockets block dispatch until classified or their heartbeat lease expires.
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.
`DELETE /v1/agent/presence/:sessionId` returns 204 and retires focus/visibility best effort; recent-interaction grace is retained until expiry. It does not revoke push consent. Backend restarts impose a full 60-second attention warm-up, with leases preserved across restart.
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
@@ -745,14 +747,24 @@ Only complete user-visible root assistant messages enter the durable outbox. Too
All web sessions must be inactive at dispatch, including the runtime-native browser. Before each provider request, the backend refreshes runtime read/prompt/presence, merges native read into the canonical shared read row, and transactionally rechecks opt-in/version, owner generation, source freshness, read cursor and all attention leases. Unknown/malformed/unavailable source data suppresses sends. Messages expire after five minutes; provider TTL is at most 60 seconds. The active outbox coalesces per installation; completed receipts are capped at 10,000 and expire after seven days, while revocation tombstones remain durable. There are at most four attempts, only after definite retryable provider rejections, with bounded backoff. A persisted send intent protects crash recovery: interrupted/ambiguous network sends are never automatically replayed. Exactly-once provider/device delivery is not promised; a final focus/read race after dispatch remains possible.
Alert payload identity is top-level `{version:1,sourceId,kind:"message",messageId,cursor}`. APNs adds `aps.alert` with title **Sybil Agent**, body **You have a new message.**, default sound and `thread-id:sybil-agent-root`. Web Push adds those same generic title/body strings, fixed `url:"/?agent=root"` and `tag:"sybil-agent:"+sourceId`. No transcript preview, filename, event source, token or key appears in a notification. Normal APNs alerts do not request critical/time-sensitive entitlements.
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`.
+6 -2
View File
@@ -377,7 +377,9 @@ Attached input admission and its request binding are atomic. Replay verifies the
### Agent attention and shared read events (push v1)
Web auth frames add `{ "clientKind":"web", "presenceSessionId":"per-document-uuid" }` to the existing `type`, `token`, and optional `after`. Report the same UUID to `PUT /v1/agent/presence/:sessionId` across the entire authenticated workspace. Native iOS clients add `clientKind:"native"`; no browser-attention lease is required for a native app. These declarations classify a client under the existing shared principal and do not grant new authority. Old/unclassified authenticated sockets conservatively veto push delivery until closed/expired. A web-classified socket without a live matching presence receipt is also unknown. Transport ping/pong renews connection liveness, never user activity. The proxy classifies itself upstream using the authenticated `X-Sybil-Client: backend-proxy-v1` upgrade header.
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:
@@ -389,4 +391,6 @@ The backend adds this optional server-to-client frame after a successful monoton
Distinguish an unbound room from one already bound to a legacy absent/null source or a source UUID. Bind on the first accepted read state; if history arrives first, pin the legacy state before publishing it. A different source in a later GET, stream frame or PUT acknowledgment, including null-to-UUID or UUID-to-null, must stop stale read writes and require an explicit fresh room load. Preserve the old snapshot and cursors until that reload, then fetch read state before fresh history; never attach a new UUID to an old cursor. Ordinary foreground/network recovery must not reset this stopped room. HTTP 409, an invalid acknowledgment cursor or an acknowledgment below the requested cursor also stops the writer. A transient transport failure can retry only within the same pinned source. None of these client reloads resets server read state.
Older clients may ignore unknown server frame types. `read_state` and presence are not client subscribe messages and are never forwarded to runtime as generic frames. Push consent, revoke, presence, retry/expiry and notification payload contracts live in [rest.md](./rest.md#agent-push-notifications-v1). The runtime built-in browser has its own authenticated presence/read inputs; its unknown sessions participate in the same conservative veto. No Web Push is used for invisible read synchronization.
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.
@@ -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
@@ -74,6 +74,9 @@ final class SybilAgentRoom {
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)
@@ -337,6 +340,9 @@ final class SybilAgentRoom {
}
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)
@@ -11,22 +11,25 @@ struct SybilAgentView: View {
@State private var historyLimit = 100
@State private var historyStartID: String?
@State private var scrollPosition = ScrollPosition()
@State private var scrollMetrics = AgentScrollMetrics()
@State private var rowFrames: [String: CGRect] = [:]
@State private var scrollTracking = AgentScrollTracking()
@State private var atBottom = false
@State private var restoreAnchor: AgentReadingAnchor?
@State private var appeared = false
private let bottomID = "agent-bottom"
private var readKey: String {
"\(room.snapshot.room.cursor):\(atLatest):\(scrollMetrics.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
"\(room.snapshot.room.cursor):\(atLatest):\(atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
}
private var visibleReadingAnchor: AgentReadingAnchor? {
rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollMetrics.height }
scrollTracking.rowFrames.filter { $0.value.maxY > topInset && $0.value.minY < scrollTracking.metrics.height }
.min { $0.value.minY < $1.value.minY }
.map { AgentReadingAnchor(id: $0.key, offset: $0.value.minY) }
}
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
@@ -51,21 +54,11 @@ struct SybilAgentView: View {
}
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
VStack(spacing: 14) {
if index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt) {
Text(message.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.sybil(.caption2))
.foregroundStyle(SybilTheme.textMuted)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
}
AgentMessageRow(message: message, room: room, retry: { room.retry(message.id) })
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
}
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
.id(message.id)
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))
.id(message.id)
}
if rendered.isTyping {
HStack(spacing: 9) {
@@ -103,7 +96,11 @@ struct SybilAgentView: View {
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
visibleBottom: geometry.visibleRect.maxY)
} action: { previous, next in
scrollMetrics = next
#if DEBUG
AgentRenderDiagnostics.record("scrollGeometry")
#endif
scrollTracking.metrics = next
if atBottom != next.atBottom { atBottom = next.atBottom }
if isScrolling || next.contentHeight <= next.height { atLatest = next.atBottom }
if atLatest, !isScrolling, restoreAnchor == nil,
previous.height != next.height || previous.contentHeight != next.contentHeight || previous.bottomInset != next.bottomInset {
@@ -111,23 +108,26 @@ struct SybilAgentView: View {
}
}
.onPreferenceChange(AgentRowFrames.self) { frames in
#if DEBUG
AgentRenderDiagnostics.record("rowPreferences")
#endif
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
// Restore the exact pixel offset after insertion or text
// reflow. Normalize the saved frames to
// the requested offset so the next layout cannot undo it.
let requested = scrollMetrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollMetrics.topInset), scrollMetrics.maximumOffset)
let appliedShift = target - scrollMetrics.offset
rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
let appliedShift = target - scrollTracking.metrics.offset
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
if abs(appliedShift) > 1 {
// Geometry reports the raw offset; ScrollPosition uses
// zero at the top inset, including the safe area.
scrollPosition.scrollTo(y: target + scrollMetrics.topInset)
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
}
restoreAnchor = nil
} else {
rowFrames = frames
scrollTracking.rowFrames = frames
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
}
}
@@ -170,9 +170,9 @@ struct SybilAgentView: View {
// This cursor belongs to the rendered snapshot. New network input cancels
// the task, and cannot be acknowledged while still entering the layout.
let cursor = rendered.room.cursor
guard atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
guard atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active, !room.isLoading else { return }
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
guard !Task.isCancelled, atLatest, scrollMetrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
room.markRead(renderedCursor: cursor)
}
}
@@ -236,10 +236,41 @@ struct AgentAvatar: View {
}
}
// Activity/read publications still update the surrounding view. Completed rows
// depend only on their message values and date heading, so progress changes
// must not rebuild the eager history (whose exact heights preserve anchors).
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)
.background(GeometryReader { geometry in
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
})
}
}
}
private struct AgentMessageRow: View {
var message: Message
var room: SybilAgentRoom
var retry: () -> Void
private var own: Bool { message.role == .user }
private var status: String { message.metadata?.objectValue?["status"]?.stringValue ?? "complete" }
var body: some View {
@@ -257,7 +288,7 @@ private struct AgentMessageRow: View {
if own && status == "queued" { Label("Queued", systemImage: "clock") }
if own && status == "failed" {
Text("Not confirmed").foregroundStyle(SybilTheme.danger)
Button("Retry", action: retry).accessibilityIdentifier("agent-retry")
Button("Retry") { room.retry(message.id) }.accessibilityIdentifier("agent-retry")
}
}
.font(.sybil(.caption2)).foregroundStyle(SybilTheme.textMuted)
@@ -297,6 +328,14 @@ private struct AgentEntrance: ViewModifier {
}
}
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
// it outside observation avoids rebuilding the entire transcript on every scroll
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
private final class AgentScrollTracking {
var metrics = AgentScrollMetrics()
var rowFrames: [String: CGRect] = [:]
}
private struct AgentScrollMetrics: Equatable {
var offset: CGFloat = 0
var height: CGFloat = 0
@@ -28,6 +28,9 @@ struct SybilMessageText: View {
}
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))
@@ -89,7 +89,7 @@ struct SybilSettingsView: View {
VStack(alignment: .leading, spacing: 10) {
Text("Agent Notifications").font(.sybil(.title3, weight: .semibold))
Text("Get an alert when the Agent has a new message. Previews say only “You have a new message.” Reading elsewhere clears older alerts when iOS allows the app to sync.")
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")
+68
View File
@@ -0,0 +1,68 @@
# Agent scrolling benchmark
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 \
-resultBundlePath build/agent-performance.xcresult
```
`AgentPerformanceTests` seeds a room through its normal event reducer with 160
local messages (100 initially displayed). User text is literal; assistant text
contains paragraphs, lists, links, emphasis and code. The real Agent view,
composer, avatar, entrance layout and thinking spinner remain enabled. No server,
production credentials, notification delivery or network images are needed.
Three approximately 12-second phases run in order: idle scrolling, thinking at
the bottom, and thinking while scrolling. The test explicitly resets the offset
to the bottom before the stationary thinking phase (including in the baseline).
Thinking activity changes queue depth, worker count and tool progress every
500 ms through the normal 100 ms publication
batch. The display link sweeps a 2400-point range at 600 points/second, reversing
every four seconds. Use the **same test source, simulator, build configuration
and host load** for before/after runs. Don't run other builds or profilers during
a measured run. Warm builds and repeat runs help distinguish stable effects from
host noise.
The geometry assertions use iPhone 16e, iOS 26.5, and the default text size. They
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline
these explicitly when changing simulator/font configuration or the fixture.
The log prints `AGENT_PERFORMANCE_JSON`; the result bundle retains the JSON as an
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to
the reported display-link budget. These are **not compositor FPS, GPU hitches,
touch latency or physical-device results**. Scrolling is programmatic; native
swipe/anchor/read behavior needs the local-fixture UI regression tests as well.
The hosted view excludes the surrounding app shell. Physical iPhone scrolling,
streaming and thinking animation remain a required follow-up.
Opt-in `AgentRenderDiagnostics` counters are compiled only in Debug and are off
outside this test. They explain work (Agent body evaluations, message projection,
message text construction, isolated row evaluations, geometry callbacks and
snapshot publications). A drop in these counts alone does not establish improved
frame responsiveness.
## Reference result
On monsoon, Xcode 26.5, Debug, iPhone 16e / iOS 26.5, clean repeat runs against
`f534f76` plus counters and against the geometry/row isolation fix gave:
| Phase | Before p95 / p99 (ms) | After p95 / p99 (ms) | Gaps >50 ms, before / after |
| --- | ---: | ---: | ---: |
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 |
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 |
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 |
Each phase lasted approximately 12 seconds. Content heights matched exactly.
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged
text constructions were zero in both versions. Geometry isolation alone removed
the idle-scroll stalls; individual row isolation reduced activity-related gaps.
These results support those two boundaries, without a Markdown cache or lazy
layout change. Occasional simulator gaps remain; this does not replace device
validation.
+12
View File
@@ -138,6 +138,18 @@ async def route(request):
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()))
@@ -0,0 +1,142 @@
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)
}
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]
}
}
+18
View File
@@ -241,6 +241,24 @@ final class AgentUITests: XCTestCase {
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")
+1 -4
View File
@@ -1,5 +1,3 @@
require_relative 'refresh_push_profile'
default_platform(:ios)
platform :ios do
@@ -7,8 +5,7 @@ platform :ios do
lane :beta do
setup_ci
SybilPushProfile.run
match(type: "appstore")
match(type: "appstore", readonly: true)
tag = ENV.fetch("GITHUB_REF_NAME")
version = tag[%r{\Arelease/ios/v(\d+\.\d+\.\d+)\z}, 1]
-75
View File
@@ -1,75 +0,0 @@
# One app-scoped bootstrap for the already-regenerated Sybil distribution profile.
# Invoked inside the existing CI job so MATCH_PASSWORD never leaves that job.
require 'match'
require 'match/encryption'
require 'cfpropertylist'
require 'digest'
require 'fileutils'
require 'open3'
require 'tmpdir'
module SybilPushProfile
PROFILE = File.expand_path('~/.config/sybil-release-signing/Sybil_AppStore_CI.mobileprovision')
REPOSITORY = 'https://code.buzzert.dev/buzzert/fastlane-match.git'
TARGET = 'profiles/appstore/AppStore_net.buzzert.sybil2.mobileprovision'
UUID = '6ca8c49d-ca89-4b9f-8ae8-a0e33fafd19c'
CERTIFICATE = '6B74B268C4761720FB2051D01D8BB3E47B55D9F5'
def self.run
return unless File.exist?(PROFILE)
stage = 'profile validation'
file = File.lstat(PROFILE)
parent = File.stat(File.dirname(PROFILE))
raise unless file.file? && !file.symlink? && file.uid == Process.uid && (file.mode & 0o077).zero?
raise unless parent.uid == Process.uid && (parent.mode & 0o077).zero?
xml, _, status = Open3.capture3('/usr/bin/security', 'cms', '-D', '-i', PROFILE)
raise unless status.success?
profile = CFPropertyList.native_types(CFPropertyList::List.new(data: xml).value)
entitlements = profile.fetch('Entitlements')
raise unless profile['UUID'] == UUID && profile['Name'] == 'Sybil AppStore CI'
raise unless profile['TeamIdentifier'] == ['DQQH5H6GBD']
raise unless entitlements['application-identifier'] == 'DQQH5H6GBD.net.buzzert.sybil2'
raise unless entitlements['aps-environment'] == 'production' && entitlements['get-task-allow'] == false
raise unless profile.fetch('DeveloperCertificates').any? { |c| Digest::SHA1.hexdigest(c).upcase == CERTIFICATE }
raise unless profile.fetch('ExpirationDate') > Time.now
plaintext = File.binread(PROFILE)
password = ENV.fetch('MATCH_PASSWORD')
authorization = ENV.fetch('MATCH_GIT_BASIC_AUTHORIZATION')
raise if password.empty? || authorization.empty? || authorization.match?(/[\r\n]/)
git_env = { 'GIT_TERMINAL_PROMPT' => '0', 'GIT_CONFIG_COUNT' => '1',
'GIT_CONFIG_KEY_0' => 'http.https://code.buzzert.dev/.extraheader',
'GIT_CONFIG_VALUE_0' => "Authorization: Basic #{authorization}" }
git = lambda do |*args|
out, _, result = Open3.capture3(git_env, 'git', *args)
raise unless result.success?
out
end
stage = 'encrypted profile refresh'
Dir.mktmpdir('sybil-profile-') do |temp|
File.chmod(0o700, temp)
repo = File.join(temp, 'match')
git.call('clone', '--quiet', '--single-branch', REPOSITORY, repo)
target = File.join(repo, TARGET)
raise unless File.file?(target) && !File.symlink?(target)
crypto = Match::Encryption::MatchDataEncryption.new
current = crypto.decrypt(base64encoded_encrypted: File.read(target), password: password)
if current == plaintext
puts 'Sybil production Push profile is already current in match.'
return
end
encrypted = crypto.encrypt(data: plaintext, password: password, version: 2)
raise unless crypto.decrypt(base64encoded_encrypted: encrypted, password: password) == plaintext
File.write(target, encrypted)
changed = git.call('-C', repo, 'diff', '--name-only').lines.map(&:strip)
raise unless changed == [TARGET]
git.call('-C', repo, 'add', '--', TARGET)
git.call('-C', repo, '-c', 'user.name=Sybil CI', '-c', 'user.email=james@magahern.com',
'commit', '--quiet', '-m', 'signing: refresh Sybil production Push profile')
stage = 'normal signing-repository push'
git.call('-C', repo, 'push', 'origin', 'HEAD')
puts 'Updated only the encrypted Sybil production Push profile in match.'
end
rescue StandardError
abort "Sybil signing profile refresh failed at #{stage}; credential details withheld."
end
end
+13
View File
@@ -19,6 +19,7 @@
"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",
@@ -1741,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",
+1
View File
@@ -28,6 +28,7 @@
"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",
@@ -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;
+5
View File
@@ -249,6 +249,7 @@ model AgentPushPresence {
visible Boolean
focused Boolean
lastInteractionAt DateTime?
inactiveUntil DateTime?
expiresAt DateTime
@@index([ownerEpoch, expiresAt])
}
@@ -273,6 +274,7 @@ model AgentPushCoordinator {
localPromptAt DateTime?
nativeActive Boolean @default(false)
nativeUnknown Boolean @default(true)
unknownSince DateTime?
uncertain Boolean @default(true)
lockOwner String?
lockUntil DateTime?
@@ -296,6 +298,9 @@ model AgentPushDelivery {
expiresAt DateTime
updatedAt DateTime @updatedAt
outcome String?
lastSuppressionReason String?
lastSuppressionClientKind String?
lastSuppressedAt DateTime?
@@index([status, dueAt])
@@index([installationId, status])
@@index([ownerEpoch, sourceId, cursor])
+2 -2
View File
@@ -1,8 +1,8 @@
import { createHash, createPublicKey, ECDH } from 'node:crypto';
import { z } from 'zod';
export const PUSH_POLICY = Object.freeze({ promptGraceMs: 5_000, debounceMs: 2_000, interactionGraceMs: 30_000,
presenceHeartbeatMs: 15_000, presenceLeaseMs: 60_000 });
export const 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';
+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;
}
+7 -4
View File
@@ -6,12 +6,13 @@ 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) => Promise<SendResult>;
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;
@@ -104,7 +105,7 @@ 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) => {
return async (registration, identity, expiresAt, now, messageText) => {
if (!config.enabled || !config.adminToken || expiresAt <= now) return { outcome: 'failed' };
try {
if (registration.transport === 'apns') {
@@ -117,7 +118,8 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
jwt = { value: `${value}.${sign('sha256', Buffer.from(value), { key: credential.key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`, issuedAt: now };
}
const clear = identity.kind === 'read_state';
const body = Buffer.from(JSON.stringify({ aps: clear ? { 'content-available': 1 } : { alert: { title: 'Sybil Agent', body: 'You have a new message.' }, sound: 'default', 'thread-id': 'sybil-agent-root' }, ...identity }));
const 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',
@@ -130,10 +132,11 @@ export function createPushSender(config: PushConfig, adapters: { apns?: RequestT
// Safari requires visible pushes; there is deliberately no Web Push read-clear transport.
if (!config.web || identity.kind !== 'message') return { outcome: 'failed' };
validatePushEndpoint(registration.subscription.endpoint);
const payload = JSON.stringify({ ...identity, title: 'Sybil Agent', body: 'You have a new message.', url: '/?agent=root', tag: `sybil-agent:${identity.sourceId}` });
const 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 {
+93 -27
View File
@@ -8,13 +8,15 @@ 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>; send: PushSend; now?: () => number;
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 {
@@ -23,10 +25,11 @@ export class AgentPushCoordinator {
private readonly now: () => number;
private readonly warmUntil: number;
private started = false;
private untrackedConnections = new Set<string>();
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;
@@ -42,7 +45,7 @@ export class AgentPushCoordinator {
await tx.agentPushInstallation.updateMany({ where: { enabled: true }, data: { enabled: false } });
await tx.agentPushDelivery.updateMany({ where: { status: { in: activeStatuses } }, data: { status: 'cancelled', outcome: 'owner_changed' } });
await tx.agentPushCoordinator.update({ where: { id: 'root' }, data: { ownerEpoch: this.epoch, contextObservedAt: null,
nativeUnknown: true, uncertain: true, lockOwner: null, lockUntil: null } });
nativeUnknown: true, unknownSince: null, uncertain: true, lockOwner: null, lockUntil: null } });
}
});
this.started = true;
@@ -102,22 +105,46 @@ export class AgentPushCoordinator {
const old = await tx.agentPushPresence.findUnique({ where: { id } });
if (old?.ownerEpoch === this.epoch && value.sequence <= old.sequence) return { expiresAt: old.expiresAt.toISOString() };
if (!old && await tx.agentPushPresence.count({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } }) >= 512) throw fail(429, 'Too many presence sessions');
const data = { ownerEpoch: this.epoch, sequence: value.sequence, visible: value.visibility === 'visible', focused: value.focused,
lastInteractionAt: value.interacted ? date(now) : old?.ownerEpoch === this.epoch ? old.lastInteractionAt : null, expiresAt: date(now + PUSH_POLICY.presenceLeaseMs) };
const 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) {
// Keep recent interaction's grace even after a best-effort pagehide release.
await this.options.db.agentPushPresence.updateMany({ where: { id, ownerEpoch: this.epoch }, data: { visible: false, focused: false } });
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) {
this.untrackedConnections.add(id);
await this.options.db.agentPushConnection.upsert({ where: { id }, create: { id, ownerEpoch: this.epoch, kind: kind || 'unknown', sessionId,
expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) }, update: { expiresAt: date(this.now() + PUSH_POLICY.presenceLeaseMs) } });
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;
@@ -236,21 +263,34 @@ export class AgentPushCoordinator {
return changedRead;
});
if (read) this.options.broadcast?.(read);
this.observedRoot = { sourceId: context.sourceId, conversationId: context.conversationId };
return context.hasMore;
}
private async browserActive(tx: Tx, now: number): Promise<boolean> {
if (this.untrackedConnections.size) return true;
const presences = await tx.agentPushPresence.findMany({ where: { ownerEpoch: this.epoch, expiresAt: { gt: date(now) } } });
if (presences.some(v => (v.visible && v.focused) || (v.lastInteractionAt && v.lastInteractionAt.getTime() + PUSH_POLICY.interactionGraceMs > now))) return true;
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) } } });
return connections.some(v => v.kind !== 'native' && (v.kind !== 'web' || !presences.some(p => p.id === v.sessionId)));
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 dispatch(id: string) {
const claim = await this.options.db.$transaction(async tx => {
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
@@ -258,11 +298,17 @@ export class AgentPushCoordinator {
await tx.agentPushDelivery.update({ where: { id }, data: { status: 'cancelled', outcome: 'obsolete' } }); return;
}
if (!this.options.config.enabled || state.lockOwner !== this.instanceId || !state.lockUntil || state.lockUntil.getTime() <= now
|| state.uncertain || !state.contextObservedAt || state.contextObservedAt.getTime() < now - 10_000 || now < this.warmUntil) return;
if (row.kind === 'message' && (state.nativeActive || state.nativeUnknown || await this.browserActive(tx, now)
|| (state.latestUserMessageAt && state.latestUserMessageAt.getTime() + PUSH_POLICY.promptGraceMs > now)
|| (state.localPromptAt && state.localPromptAt.getTime() + PUSH_POLICY.promptGraceMs > now))) {
await tx.agentPushDelivery.update({ where: { id }, data: { dueAt: date(now + PUSH_POLICY.debounceMs) } }); return;
|| 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));
@@ -270,17 +316,31 @@ export class AgentPushCoordinator {
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) } });
const identity: Identity = row.kind === 'read_state' ? { version: 1, sourceId: row.sourceId, kind: 'read_state', readCursor: read.cursor }
: { version: 1, sourceId: row.sourceId, kind: 'message', messageId: row.messageId, cursor: row.cursor };
return { row, registration, identity, now };
});
}
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); }
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;
@@ -312,7 +372,13 @@ export class AgentPushCoordinator {
await this.options.db.agentPushDelivery.deleteMany({ where: { status: { notIn: activeStatuses }, updatedAt: { lt: date(this.now() - 7 * 86_400_000) } } });
} catch {
// An unavailable source, DB, malformed contract or clock uncertainty blocks dispatch without leaking payloads.
try { await this.options.db.agentPushCoordinator.updateMany({ where: { id: 'root', lockOwner: this.instanceId }, data: { uncertain: true } }); } catch { /* Retry on next loop. */ }
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;
@@ -349,7 +415,7 @@ export async function registerPushRoutes(app: FastifyInstance, db: PrismaClient,
if (probe && (!sourceId || context.hasMore || context.candidates.length > 0
|| (context.sourceId === sourceId && Number(context.cursor) >= Number(after)))) throw new Error('Runtime cursor unavailable');
return context;
}, send: createPushSender(config), ...overrides });
}, 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 };
+1 -1
View File
@@ -138,7 +138,7 @@ function attachAgentSocket(app: FastifyInstance, baseUrl: string, apiKey: string
};
const authTimeout = setTimeout(() => browser.close(4401, 'Authentication required'), 5000);
const heartbeat = setInterval(() => { if (!alive) browser.terminate(); else { alive = false; browser.ping(); } }, 25_000);
browser.on('pong', () => { alive = true; if (authenticated && env.ADMIN_TOKEN) void push.connect(connectionId).catch(() => {}); });
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);
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import { createECDH, generateKeyPairSync, randomUUID } from 'node:crypto';
import { once } from 'node:events';
import { createServer } from 'node:http';
import { createRequire } from 'node:module';
import test from 'node:test';
import webpush from 'web-push';
import { createMessageText, MAX_PREVIEW_BYTES, previewPayload, previewText, WEB_PUSH_PAYLOAD_BYTES } from '../src/agent-push-preview.js';
import { createPushSender, type ProviderRequest } from '../src/agent-push-transports.js';
const { decrypt } = createRequire(import.meta.url)('http_ece');
test('preview lookup uses only the exact eligible completed root assistant content on the fixed public endpoint', async t => {
const requests: string[] = [];
const message = { id: 'outbound:11', role: 'assistant', content: 'Your **public answer** includes the requested passphrase: sky-blue.',
metadata: { kind: 'message', status: 'complete', entryId: 11, reasoning: 'private reasoning', resultPreview: 'tool secret', output: 'worker output' } };
const original = { conversationId: '1', cursor: '11', beforeCursor: '11', messages: [message] };
let page: unknown = original, status = 200;
const server = createServer((req, res) => {
requests.push(req.url!);
assert.equal(req.headers.authorization, 'Bearer fixture-key');
assert.equal(req.headers['x-sybil-client'], 'backend-proxy-v1');
res.writeHead(status, { 'Content-Type': 'application/json', Location: 'http://never-fetch.invalid/' });
res.end(JSON.stringify(page));
});
server.listen(0, '127.0.0.1'); await once(server, 'listening');
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); });
const load = createMessageText({ baseUrl: `http://127.0.0.1:${(server.address() as { port: number }).port}`, apiKey: 'fixture-key' });
const candidate = { conversationId: '1', cursor: '11', messageId: 'outbound:11' };
assert.equal(await load(candidate), message.content, 'explicit public assistant text is authorized without guessing/redacting secrets');
assert.deepEqual(requests, ['/api/conversations/root/messages?after=10&limit=1&includeTools=false']);
page = { ...original, messages: [{ ...message, id: 'entry:11:0' }] };
assert.equal(await load({ ...candidate, messageId: 'entry:11:0' }), message.content);
for (const invalid of [
{ ...original, conversationId: 'worker-conversation' }, { ...original, cursor: '12' }, { ...original, beforeCursor: '10' },
{ ...original, messages: [] }, { ...original, messages: [message, message] },
...['tool', 'user', 'system', 'reasoning'].map(role => ({ ...original, messages: [{ ...message, role }] })),
...[{ kind: 'tool_call' }, { kind: 'worker' }, { status: 'initiated' }, { entryId: 12 }].map(metadata => ({ ...original, messages: [{ ...message, metadata: { ...message.metadata, ...metadata } }] })),
{ ...original, messages: [{ ...message, content: [{ type: 'reasoning', text: 'private reasoning' }] }] },
{ ...original, messages: [{ ...message, id: 'tool:1:11' }] },
]) { page = invalid; await assert.rejects(load(candidate)); }
page = { ...original, messages: [{ ...message, content: '' }] };
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
const before = requests.length;
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
await assert.rejects(load({ ...candidate, messageId }));
}
assert.equal(requests.length, before);
page = { ...original, messages: [{ ...message, content: 'x'.repeat(512 * 1024) }] };
await assert.rejects(load(candidate));
status = 302; page = original; await assert.rejects(load(candidate));
status = 503; await assert.rejects(load(candidate));
assert.ok(requests.every(url => url === requests[0]), 'no worker/raw-state/event/linked-resource requests or redirects');
});
test('Markdown becomes readable message text without link destinations, image sources or HTML internals', () => {
const markdown = '# Status\n\nThe **report** is _ready_ &amp; checked.\n\n- Read [the summary](https://private.invalid/secret)\n- `keep_snake_case()`\n\n> Done.\n\n```ts\nconst answer = 42;\n```\n\n![Chart](https://private.invalid/image)\n<!-- hidden comment -->\n<script>internal script</script>';
const result = previewText(markdown);
for (const text of ['Status', 'The report is ready & checked.', 'Read the summary', 'keep_snake_case()', 'Done.', 'const answer = 42;', 'Chart']) assert.ok(result.includes(text), result);
for (const hidden of ['https://', 'private.invalid', 'secret', 'hidden comment', 'internal script', '**', '```', '# Status']) assert.ok(!result.includes(hidden), result);
assert.equal(previewText('Plain text: 日本語 🌧️ — keep_this_path.txt'), 'Plain text: 日本語 🌧️ — keep_this_path.txt');
assert.equal(previewText('A\u0000B\n\tC\ud800'), 'A B C�');
});
test('actual APNs and decrypted Web Push bodies carry public text with UTF-8/JSON/encryption bounds', async () => {
const apnsKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const receiver = createECDH('prime256v1'); receiver.generateKeys();
const auth = Buffer.alloc(16, 1).toString('base64url');
const native = { transport: 'apns' as const, enabled: true as const, token: 'a'.repeat(64), environment: 'sandbox' as const, topic: 'net.buzzert.sybil2' };
const browser = { transport: 'web' as const, enabled: true as const, subscription: { endpoint: 'https://web.push.apple.com/fixture',
keys: { p256dh: receiver.getPublicKey().toString('base64url'), auth } } };
const apns: ProviderRequest[] = [], web: ProviderRequest[] = [];
const sender = createPushSender({ enabled: true, adminToken: 'fixture',
apns: { key: apnsKey.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(), keyId: 'ABCDEFGHIJ', teamId: 'DQQH5H6GBD', environment: 'sandbox', topic: native.topic },
web: { ...webpush.generateVAPIDKeys(), subject: 'mailto:tests@example.com' } }, {
apns: async request => { apns.push(request); return { status: 200 }; }, web: async request => { web.push(request); return { status: 201 }; },
});
const identity = { version: 1 as const, kind: 'message' as const, sourceId: randomUUID(), messageId: 'outbound:11', cursor: '11' };
const now = Date.now();
for (const content of ['The **report** is ready. 日本語 🌧️', 'Requested public passphrase: sky-blue.', 'a'.repeat(3000),
'a'.repeat(3001), '世界🌧️é'.repeat(2000), '"\\'.repeat(4000), '\ud800'.repeat(4000), ' ']) {
assert.equal((await sender(native, identity, now + 60_000, now, content)).outcome, 'accepted');
assert.equal((await sender(browser, identity, now + 60_000, now, content)).outcome, 'accepted');
const apnsBytes = apns.at(-1)!.body, encrypted = web.at(-1)!.body;
const plaintext: Buffer = decrypt(encrypted, { version: 'aes128gcm', privateKey: receiver, authSecret: auth });
const apnsPayload = JSON.parse(apnsBytes.toString()), webPayload = JSON.parse(plaintext.toString());
const body = apnsPayload.aps.alert.body;
assert.equal(typeof body, 'string'); assert.equal(webPayload.body, body);
assert.ok(Buffer.byteLength(body) <= MAX_PREVIEW_BYTES);
assert.ok(apnsBytes.length <= 4096); assert.ok(plaintext.length <= 3993); assert.ok(encrypted.length <= 4096);
assert.equal(encrypted.length, plaintext.length + 103);
assert.equal(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(body)), body);
for (const [key, value] of Object.entries(identity)) { assert.equal(apnsPayload[key], value); assert.equal(webPayload[key], value); }
assert.equal(webPayload.url, '/?agent=root'); assert.equal(webPayload.tag, `sybil-agent:${identity.sourceId}`);
const flattened = previewText(content) || 'You have a new message.';
if (Buffer.byteLength(JSON.stringify(flattened)) - 2 <= MAX_PREVIEW_BYTES) assert.equal(body, flattened);
else { assert.ok(body.endsWith('…')); assert.ok(flattened.startsWith(body.slice(0, -1))); }
assert.ok(!encrypted.includes(Buffer.from(body)), 'Web Push plaintext is encrypted');
}
await sender(native, { version: 1, kind: 'read_state', sourceId: identity.sourceId, readCursor: '11' }, now + 60_000, now, 'never include text in a clear');
assert.deepEqual(JSON.parse(apns.at(-1)!.body.toString()).aps, { 'content-available': 1 });
assert.ok(!apns.at(-1)!.body.includes('never include'));
});
test('payload budget includes identity overhead and JSON escaping before truncating on a Unicode boundary', () => {
const result = previewPayload('界"\\🌧'.repeat(2000), body => ({ messageId: '"'.repeat(1000), body }), WEB_PUSH_PAYLOAD_BYTES);
assert.ok(result.length <= WEB_PUSH_PAYLOAD_BYTES);
const body = JSON.parse(result.toString()).body;
assert.ok(body.endsWith('…')); assert.equal(body.includes('�'), false);
assert.throws(() => previewPayload('hello', body => ({ extra: 'x'.repeat(5000), body }), 4096));
});
+122 -10
View File
@@ -40,12 +40,13 @@ async function fixture(options: { warmupMs?: number; config?: PushConfig } = {})
let context: Context = { version: 1, sourceId: source, conversationId: '1', cursor: '10', hasMore: false,
latestUserMessageAt: new Date(now - 30_000).toISOString(), nativeReadCursor: '0', uncertain: false,
webPresence: { active: false, unknown: false, observedAt: new Date(now).toISOString() }, candidates: [] };
const sent: { registration: Registration; identity: any }[] = [], reads: any[] = [];
const sent: { registration: Registration; identity: any; messageText?: string }[] = [], reads: any[] = [];
let result: SendResult = { outcome: 'accepted' }, failure = false;
const manager = new AgentPushCoordinator({ db: prisma, config: options.config || config, now: () => now,
warmupMs: options.warmupMs ?? 0, context: async after => { if (failure) throw new Error('upstream secret must not leak');
return { ...context, webPresence: { ...context.webPresence, observedAt: new Date(now).toISOString() }, candidates: after === undefined ? [] : context.candidates.filter(c => Number(c.cursor) > Number(after)) }; },
send: async (registration, identity) => { sent.push({ registration, identity }); return result; }, broadcast: value => reads.push(value) });
messageText: async () => 'The Agent finished the requested work.',
send: async (registration, identity, _expiresAt, _now, messageText) => { sent.push({ registration, identity, messageText }); return result; }, broadcast: value => reads.push(value) });
await manager.initialize();
const installationId = randomUUID(); await manager.register(installationId, apns); await manager.tick();
return { manager, installationId, source, sent, reads, get now() { return now; }, advance(ms: number) { now += ms; },
@@ -66,21 +67,132 @@ test('first source bootstrap never alerts history; later durable candidates debo
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
await f.manager.tick(); assert.equal(f.sent.length, 1);
assert.deepEqual(f.sent[0].identity, { version: 1, sourceId: f.source, kind: 'message', messageId: 'outbound:11', cursor: '11' });
assert.equal(f.sent[0].messageText, 'The Agent finished the requested work.');
assert.equal((await prisma.agentPushDelivery.findFirst())?.status, 'accepted');
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick(); assert.equal(f.sent.length, 1);
});
test('all tabs veto: visible focused idle, recent interaction and unknown connections; leases eventually expire', async () => {
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID(), unknown = randomUUID();
test('preview resolves only the coalesced eligible root candidate after attention clears and survives a pending restart', async () => {
const f = await fixture(), tab = randomUUID(), lookups: unknown[] = [];
f.manager.options.messageText = async candidate => { lookups.push(candidate); return `Public reply ${candidate.messageId}`; };
f.candidate('11', 'unknown'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
assert.equal(lookups.length, 0);
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate('12', 'worker'); await f.manager.tick(); f.candidate('13', 'timer'); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
assert.equal(lookups.length, 0, 'focused clients do not cause repeated message fetches');
await f.manager.retirePresence(tab); f.advance(6_000);
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 }); await restarted.tick();
assert.equal(f.sent.length, 1); assert.equal(f.sent[0].messageText, 'Public reply outbound:13');
assert.equal((lookups[0] as any).conversationId, '1'); assert.equal((lookups[0] as any).messageId, 'outbound:13');
assert.ok(!JSON.stringify(await prisma.agentPushDelivery.findMany()).includes('Public reply'), 'receipts contain no preview text');
});
for (const change of ['read', 'focus', 'source', 'revoke']) test(`preview lookup cannot bypass ${change} changes before the send intent`, async () => {
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
f.manager.options.messageText = async () => {
if (change === 'read') f.context({ nativeReadCursor: '11' });
if (change === 'source') f.context({ sourceId: randomUUID() });
if (change === 'focus') await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
if (change === 'revoke') await f.manager.revoke(f.installationId);
return 'Public text that must not be sent now';
};
await f.manager.tick(); assert.equal(f.sent.length, 0);
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).attempts, 0);
});
test('unavailable public text suppresses without claiming a provider send; the next valid lookup sends actual text', async () => {
const f = await fixture(); f.candidate(); await f.manager.tick(); f.advance(2_000);
f.manager.options.messageText = async () => { throw new Error('private upstream detail'); };
await f.manager.tick(); assert.equal(f.sent.length, 0);
const pending = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(pending.status, 'pending'); assert.equal(pending.attempts, 0);
assert.equal(pending.lastSuppressionReason, 'source_or_coordinator_unavailable');
f.manager.options.messageText = async () => 'Recovered public reply';
await f.manager.tick(); assert.equal(f.sent[0].messageText, 'Recovered public reply');
});
test('all tabs veto while focused; hidden heartbeats and stale sequences cannot extend short transition grace', async () => {
const f = await fixture(), tab1 = randomUUID(), tab2 = randomUUID();
await f.manager.presence(tab1, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'focused but idle still vetoes');
let receipt = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(receipt.lastSuppressionReason, 'foreground_focused'); assert.equal(receipt.lastSuppressionClientKind, 'web');
await f.manager.presence(tab2, { sequence: 2, visibility: 'hidden', focused: false, interacted: true });
f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'recent input vetoes blur');
await f.manager.presence(tab2, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab2 } })).sequence, 2);
await f.manager.connect(unknown); f.advance(31_000); await f.manager.tick(); assert.equal(f.sent.length, 0, 'unknown authenticated socket vetoes');
f.advance(61_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'expired stale sessions retire');
await f.manager.presence(tab2, { sequence: 3, visibility: 'hidden', focused: false, interacted: true });
f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'hidden receipts do not renew attention');
receipt = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(receipt.lastSuppressionReason, 'inactive_grace'); assert.ok(receipt.lastSuppressedAt);
});
test('blur and visible recent interaction have short grace; fresh focus renews a live lease', async () => {
const f = await fixture(), tab = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate(); await f.manager.tick(); f.advance(2_000);
await f.manager.presence(tab, { sequence: 2, visibility: 'visible', focused: false, interacted: true });
await f.manager.tick(); assert.equal(f.sent.length, 0);
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'recent_interaction');
f.advance(3_000); await f.manager.presence(tab, { sequence: 3, visibility: 'visible', focused: false, interacted: false });
f.advance(3_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
await f.manager.presence(tab, { sequence: 4, visibility: 'visible', focused: true, interacted: false });
f.candidate('12'); await f.manager.tick(); f.advance(45_000);
await f.manager.presence(tab, { sequence: 5, visibility: 'visible', focused: true, interacted: false });
f.advance(20_000); await f.manager.tick(); assert.equal(f.sent.length, 1, 'fresh foreground heartbeat extends attention');
f.advance(41_000); await f.manager.tick(); assert.equal(f.sent.length, 2, 'stale focus expires');
});
test('anonymous introduction grace survives heartbeat, new UUID reconnects and coordinator restart without renewing', async () => {
const f = await fixture(), connection = randomUUID();
await f.manager.connect(connection); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
assert.equal(f.sent.length, 0);
const first = await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } });
assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'unknown_client_grace');
await f.manager.heartbeat(connection); await f.manager.disconnect(connection);
await f.manager.connect(randomUUID(), 'web', randomUUID()); f.advance(4_000); await f.manager.tick(); assert.equal(f.sent.length, 1);
f.candidate('12'); await f.manager.tick(); f.advance(2_000);
const restarted = new AgentPushCoordinator({ ...f.manager.options, warmupMs: 0 });
await restarted.connect(randomUUID()); await restarted.tick(); assert.equal(f.sent.length, 2);
assert.deepEqual((await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } })).unknownSince, first.unknownSince);
// Spending the anonymous budget never disables an independent authenticated browser lease.
await f.manager.presence(randomUUID(), { sequence: 1, visibility: 'visible', focused: true, interacted: false });
f.candidate('13'); await f.manager.tick(); f.advance(2_000); await f.manager.tick(); assert.equal(f.sent.length, 2);
});
test('expired observed web lease is inactive with a live socket; transport heartbeat preserves attribution and never recreates it', async () => {
const f = await fixture(), tab = randomUUID(), connection = randomUUID(), native = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'visible', focused: true, interacted: false });
await f.manager.connect(connection, 'web', tab); await f.manager.connect(native, 'native');
f.candidate(); await f.manager.tick(); f.advance(61_000);
await f.manager.heartbeat(connection); await f.manager.heartbeat(native); await f.manager.tick(); assert.equal(f.sent.length, 1);
assert.equal((await prisma.agentPushCoordinator.findUniqueOrThrow({ where: { id: 'root' } })).unknownSince, null);
const row = await prisma.agentPushConnection.findUniqueOrThrow({ where: { id: connection } });
assert.equal(row.kind, 'web'); assert.equal(row.sessionId, tab);
assert.equal((await prisma.agentPushConnection.findUniqueOrThrow({ where: { id: native } })).kind, 'native');
await f.manager.disconnect(native); await f.manager.heartbeat(native);
assert.equal(await prisma.agentPushConnection.findUnique({ where: { id: native } }), null);
await f.manager.presence(tab, { sequence: 2, visibility: 'hidden', focused: false, interacted: false });
assert.equal((await prisma.agentPushPresence.findUniqueOrThrow({ where: { id: tab } })).inactiveUntil, null, 'stale focus cannot start fresh grace');
});
test('presence classification cannot borrow a session from a different credential generation', async () => {
const f = await fixture(), tab = randomUUID();
await f.manager.presence(tab, { sequence: 1, visibility: 'hidden', focused: false, interacted: false });
await prisma.agentPushPresence.update({ where: { id: tab }, data: { ownerEpoch: 'another-generation' } });
await f.manager.connect(randomUUID(), 'web', tab); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
assert.equal(f.sent.length, 0); assert.equal((await prisma.agentPushDelivery.findFirstOrThrow()).lastSuppressionReason, 'unknown_client_grace');
});
test('read at final context refresh cancels without a send and preserves the prior suppression evidence', async () => {
const f = await fixture(); await f.manager.connect(randomUUID()); f.candidate(); await f.manager.tick(); f.advance(2_000); await f.manager.tick();
const prior = await prisma.agentPushDelivery.findFirstOrThrow(), original = f.manager.options.context; let calls = 0;
f.manager.options.context = async (...args) => { if (++calls === 2) await f.manager.advanceRead('11', f.source); return original(...args); };
f.advance(4_000); await f.manager.tick();
const receipt = await prisma.agentPushDelivery.findFirstOrThrow();
assert.equal(f.sent.length, 0); assert.equal(receipt.attempts, 0); assert.equal(receipt.outcome, 'read');
assert.equal(receipt.lastSuppressionReason, prior.lastSuppressionReason); assert.deepEqual(receipt.lastSuppressedAt, prior.lastSuppressedAt);
});
test('instrumented hidden web socket is inactive; missing lease and native-runtime unknown veto', async () => {
@@ -172,20 +284,20 @@ test('provider endpoint validation rejects SSRF targets and private/reserved DNS
assert.equal(isPublicAddress('8.8.8.8'), true); assert.equal(isPublicAddress('2606:4700::1111'), true);
});
test('real APNs ES256 request construction uses generic alerts, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
test('real APNs ES256 request construction uses message text, explicit environment and background clear headers through injected HTTP2 boundary', async () => {
const key = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const requests: any[] = []; const now = Date.now(), sourceId = randomUUID();
const sender = createPushSender({ ...config, apns: { ...config.apns!, key: key.privateKey.export({ format: 'pem', type: 'pkcs8' }).toString() } },
{ apns: async request => { requests.push(request); return { status: 200 }; } });
const identity = { version: 1 as const, sourceId, kind: 'message' as const, messageId: 'outbound:12', cursor: '12' };
assert.equal((await sender(apns, identity, now + 300_000, now)).outcome, 'accepted');
assert.equal((await sender(apns, identity, now + 300_000, now, 'Your **report** is ready.')).outcome, 'accepted');
assert.equal(requests[0].endpoint, `https://api.sandbox.push.apple.com/3/device/${apns.token}`);
assert.equal(requests[0].headers['apns-push-type'], 'alert'); assert.equal(requests[0].headers['apns-topic'], apns.topic);
const [header, claims, signature] = requests[0].headers.authorization.slice(7).split('.');
assert.equal(JSON.parse(Buffer.from(header, 'base64url').toString()).kid, 'ABCDEFGHIJ');
assert.equal(JSON.parse(Buffer.from(claims, 'base64url').toString()).iss, 'DQQH5H6GBD');
assert.equal(verify('sha256', Buffer.from(`${header}.${claims}`), { key: key.publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url')), true);
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'You have a new message.' });
assert.deepEqual(JSON.parse(requests[0].body).aps.alert, { title: 'Sybil Agent', body: 'Your report is ready.' });
await sender(apns, { version: 1, sourceId, kind: 'read_state', readCursor: '12' }, now + 60_000, now);
assert.equal(requests[1].headers['apns-push-type'], 'background'); assert.equal(requests[1].headers['apns-priority'], '5');
assert.deepEqual(JSON.parse(requests[1].body).aps, { 'content-available': 1 });
@@ -206,7 +318,7 @@ test('standard encrypted aes128gcm + VAPID Web Push uses library request constru
test('production-scoped APNs configuration refuses sandbox registration and cannot send it', async () => {
const production = { ...config, apns: { ...config.apns!, environment: 'production' as const } };
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
const manager = new AgentPushCoordinator({ db: prisma, config: production, context: async () => { throw new Error(); }, messageText: async () => { throw new Error(); }, send: async () => { throw new Error('must not send'); } });
await assert.rejects(manager.register(randomUUID(), apns), { statusCode: 400 });
let requests = 0;
const sender = createPushSender(production, { apns: async () => { requests++; throw new Error('must not send'); } });
+11 -5
View File
@@ -17,6 +17,8 @@ self.addEventListener("fetch", (event) => {
// Safari requires a visible notification for each valid message push. Reads are
// reconciled only by foreground page messages, never an invisible Web Push.
const PUSH_TITLE = "Sybil Agent", PUSH_BODY = "You have a new message.", PUSH_URL = "/?agent=root";
const bytes = value => new TextEncoder().encode(value).length;
const preview = value => typeof value === "string" && value.trim().length > 0 && bytes(value) <= 3000;
const sourceId = value => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
const cursor = value => typeof value === "string" && /^\d{1,16}$/.test(value) && Number.isSafeInteger(Number(value));
const messageId = value => typeof value === "string" && /^[A-Za-z0-9:_-]{1,200}$/.test(value);
@@ -67,20 +69,24 @@ async function announce(type) {
self.addEventListener("push", event => {
event.waitUntil(serial(async () => {
let value;
try { const raw = event.data?.text(); if (!raw || raw.length > 4096) return; value = JSON.parse(raw); } catch { return; }
try { const raw = event.data?.text(); if (!raw || bytes(raw) > 3993) return; value = JSON.parse(raw); } catch { return; }
if (!owned(value) || value.url !== PUSH_URL || value.tag !== `sybil-agent:${value.sourceId}`
|| value.title !== PUSH_TITLE || value.body !== PUSH_BODY) return;
|| value.title !== PUSH_TITLE || !preview(value.body)) return;
const previous = await stored(value.sourceId).catch(() => null);
// Re-delivery replaces the same owned tag without regressing its cursor.
// Still show it visibly: local read state is not a reason for an invisible push.
const identity = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor) ? previous.latest : {
const stale = owned(previous?.latest) && BigInt(previous.latest.cursor) > BigInt(value.cursor);
const identity = stale ? previous.latest : {
version: 1, kind: "message", sourceId: value.sourceId, messageId: value.messageId, cursor: BigInt(value.cursor).toString(),
};
// Keep the displayed text paired with the retained identity across late pushes/restarts.
// Older workers persisted identity only, so their retained notifications use the fallback.
const body = stale ? preview(previous.latestBody) ? previous.latestBody : PUSH_BODY : value.body;
await self.registration.showNotification(PUSH_TITLE, {
body: PUSH_BODY, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
body, tag: `sybil-agent:${identity.sourceId}`, icon: "/icons/icon-192.png", badge: "/icons/icon-192.png",
renotify: false, data: identity,
});
await save({ ...previous, sourceId: identity.sourceId, latest: identity }).catch(() => undefined);
await save({ ...previous, sourceId: identity.sourceId, latest: identity, latestBody: body }).catch(() => undefined);
await announce("sybil-push-shown");
}));
});
@@ -9,6 +9,7 @@ export function AgentNotifications({ onLogout }: { onLogout: () => Promise<void>
return <details className="shrink-0 border-t border-violet-300/15 px-3 py-2 text-xs text-violet-100/75">
<summary className="flex cursor-pointer items-center gap-2 rounded py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring"><Bell className="h-4 w-4" />Agent notifications</summary>
<div className="space-y-2 pb-1 pt-2">
<p>Notifications include Agent message text and may appear on your lock screen.</p>
<p role="status">{state.message}</p>
{state.error ? <p role="alert" className="text-rose-300">{state.error}</p> : null}
<div className="flex flex-wrap gap-2">
+1 -1
View File
@@ -94,7 +94,7 @@ export const disableAgentBrowserPush = () => controller.disable();
export const refreshAgentBrowserPush = () => controller.refresh();
const presence = createAgentPresenceReporter({
snapshot: () => ({ visibility: document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
snapshot: () => ({ visibility: !document.hidden && document.visibilityState === "visible" ? "visible" : "hidden", focused: document.hasFocus() }),
send: (value, keepalive) => { const id = getAgentPresenceSessionId(); return id ? api.presence(id, value, keepalive) : Promise.reject(new Error("Attention session unavailable.")); },
});
let stopSession: (() => void) | undefined;
+1 -1
View File
@@ -135,7 +135,7 @@ export function createAgentPushController(options: {
: cleanupFull ? "Notification cleanup is full. Sign in to a previous server to finish removing old registrations before enabling more."
: consent?.revocationPending ? "Notifications are off here. Server removal will retry when connected."
: !available ? "Notifications are not configured on this server yet."
: enabled ? "Notifications are on for this browser. Alerts use a private, generic preview."
: enabled ? "Notifications are on for this browser."
: "Get an alert for unread Agent replies when all your web sessions are inactive.";
return { busy, enabled, canEnable: !busy && available && support === "supported" && permission !== "denied" && !consent?.revocationPending && !cleanupFull, message, error };
};
+1 -1
View File
@@ -145,7 +145,7 @@ export function parseAgentRoomEvent(raw: string): AgentRoomEvent | null {
}
export function agentAuthFrame(token: string | null, cursor: string, presenceSessionId?: string): string {
return JSON.stringify({ type: "auth", token, after: normalizeAgentCursor(cursor),
...(agentPushUuid(presenceSessionId) ? { clientKind: "web", presenceSessionId } : {}) });
clientKind: "web", ...(agentPushUuid(presenceSessionId) ? { presenceSessionId } : {}) });
}
export type AgentSubmission = { id: number; status: "queued" | "placed" | "done" | "unanswered"; messageId: string; attachmentIds?: string[] };
+21 -7
View File
@@ -45,7 +45,7 @@ function fixture(sharedRecords = new Map()) {
getNotifications: async ({ tag } = {}) => notifications.filter(value => !value.closed && (!tag || value.tag === tag)),
},
};
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt,
vm.runInNewContext(workerSource, { self, indexedDB: idb(sharedRecords), URL, Date, Promise, BigInt, TextEncoder,
fetch: async request => { fetches.push(request); throw new Error("Network blocked in worker tests"); }, Request });
const emit = async (type, properties = {}) => {
const waits = []; handlers.get(type)?.({ ...properties, waitUntil: value => waits.push(value) }); await Promise.all(waits);
@@ -58,9 +58,9 @@ function fixture(sharedRecords = new Map()) {
};
}
test("actual worker push handler shows fixed generic notification and never fetches credentials", async () => {
const f = fixture(); await f.push(payload());
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "You have a new message.");
test("actual worker push handler displays supplied public message text and never fetches credentials", async () => {
const f = fixture(); await f.push(payload("5", { body: "Your report is ready. 日本語 🌧️" }));
assert.equal(f.shown.length, 1); assert.equal(f.shown[0].title, "Sybil Agent"); assert.equal(f.shown[0].body, "Your report is ready. 日本語 🌧️");
assert.deepEqual(f.shown[0].data, { version: 1, kind: "message", sourceId, messageId: "outbound:5", cursor: "5" });
assert.equal(f.shown[0].tag, `sybil-agent:${sourceId}`); assert.equal(f.shown[0].renotify, false);
assert.deepEqual(f.fetches, []); assert.ok(!JSON.stringify([...f.records]).includes("token"));
@@ -69,22 +69,36 @@ test("actual worker push handler shows fixed generic notification and never fetc
test("every valid message push remains visible, including read or duplicate deliveries; stale payload never regresses identity", async () => {
const records = new Map(), f = fixture(records);
await f.message({ type: "sybil-agent-read", version: 1, sourceId, cursor: "10" });
await f.push(payload("8")); await f.push(payload("8")); await f.push(payload("5"));
await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("8", { body: "Latest reply" })); await f.push(payload("5", { body: "Older reply" }));
assert.equal(f.shown.length, 3, "Safari visibility requirement is preserved");
assert.ok(f.shown.every(notification => notification.data.cursor === "8"));
assert.ok(f.shown.every(notification => notification.body === "Latest reply"));
const restarted = fixture(records); await restarted.push(payload("6"));
assert.equal(restarted.shown[0].data.cursor, "8");
assert.equal(restarted.shown[0].body, "Latest reply");
assert.equal(records.get(sourceId).readCursor, "10");
});
test("malformed/unexpected payloads cannot supply text, destinations, cross-source tags or silent read pushes", async () => {
test("malformed/unexpected payloads cannot supply invalid bodies, destinations, cross-source tags or silent read pushes", async () => {
const f = fixture();
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: "transcript" }),
for (const value of ["not-json", "x".repeat(5000), payload("1", { title: "sensitive text" }), payload("1", { body: { text: "invalid" } }),
payload("1", { body: " " }), payload("1", { body: "界".repeat(1001) }), payload("1", { body: "x".repeat(3001) }),
payload("1", { body: "界".repeat(900), extra: "界".repeat(500) }),
payload("1", { url: "https://evil.example/" }), payload("1", { tag: "some-other-app" }), payload("-1"), payload("9007199254740992"),
payload("1", { sourceId: "../../bad" }), payload("1", { messageId: "<script>" }), { version: 1, kind: "read_state", sourceId, readCursor: "8" }]) await f.push(value);
assert.equal(f.shown.length, 0); assert.equal(f.fetches.length, 0);
});
test("worker accepts bounded text, renders HTML literally and remains compatible with old generic pushes and stored identities", async () => {
const f = fixture();
for (const body of ["界".repeat(1000), "🌧".repeat(750), '<b>public literal text</b>', "You have a new message."]) {
await f.push(payload("9", { body })); assert.equal(f.shown.at(-1).body, body);
}
const records = new Map([[sourceId, { sourceId, latest: payload("10") }]]);
const upgraded = fixture(records); await upgraded.push(payload("8", { body: "Earlier reply" }));
assert.equal(upgraded.shown[0].data.cursor, "10"); assert.equal(upgraded.shown[0].body, "You have a new message.");
});
test("authenticated page read sync closes only owned matching source at or below monotonic read cursor", async () => {
const f = fixture();
const older = f.makeNotification({ tag: `sybil-agent:${sourceId}`, data: payload("4") });
+19
View File
@@ -108,6 +108,20 @@ try {
assert.equal(sessionPaths.length, 2);
const secondPath = sessionPaths.find(path => !path.endsWith(firstSession));
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.focused === true));
// Playwright forces every target focused/visible. Supply controlled DOM state
// to exercise the production reporter's blur/visibility wiring deterministically.
const beforeBlur = requests.length;
await page.evaluate(() => {
Object.defineProperty(document, "hasFocus", { configurable: true, value: () => false });
window.dispatchEvent(new Event("blur"));
});
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === false));
await page.evaluate(() => {
Object.defineProperty(document, "hidden", { configurable: true, value: true });
document.dispatchEvent(new Event("visibilitychange"));
});
await expectEventually(() => requests.slice(beforeBlur).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
await page.evaluate(() => { delete document.hasFocus; delete document.hidden; window.dispatchEvent(new Event("focus")); });
await second.getByRole("textbox").first().click();
await second.getByRole("textbox").first().pressSequentially("interaction");
await expectEventually(() => requests.some(request => request.path === secondPath && request.body?.interacted === true));
@@ -116,6 +130,11 @@ try {
assert.deepEqual(sequences, [...new Set(sequences)].sort((a, b) => a - b));
}
await page.bringToFront();
const beforeHide = requests.length;
await page.evaluate(() => window.dispatchEvent(new Event("pagehide")));
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "hidden" && request.body?.focused === false));
await page.evaluate(() => window.dispatchEvent(new Event("pageshow")));
await expectEventually(() => requests.slice(beforeHide).some(request => request.path.endsWith(firstSession) && request.body?.visibility === "visible" && request.body?.focused === true));
const worker = context.serviceWorkers().find(worker => new URL(worker.url()).pathname === "/sw.js");
assert.ok(worker, "actual service worker activated");
const push = async cursor => worker.evaluate(async ({ cursor, sourceId }) => {
+1 -1
View File
@@ -405,7 +405,7 @@ test("attention is per-tab, ordered, focused while idle, and recent input persis
c.interact(); const report = c.report(); failure.reject(new Error("offline")); await assert.rejects(report); await c.report();
const id = randomUUID(), frame = JSON.parse(agentAuthFrame("fixture-token", "2", id));
assert.equal(frame.clientKind, "web"); assert.equal(frame.presenceSessionId, id);
assert.equal(JSON.parse(agentAuthFrame(null, "0", "not-an-id")).clientKind, undefined);
assert.deepEqual(JSON.parse(agentAuthFrame(null, "0", "not-an-id")), { type: "auth", token: null, after: "0", clientKind: "web" });
});
test("read frames validate source/cursor and preserve existing read semantics", () => {
+2 -2
View File
@@ -254,8 +254,8 @@ test("client uses Sybil auth and routes while websocket credentials remain in th
const url = client.websocketUrl("https://sybil.example/");
assert.equal(url, "wss://sybil.example/api/v1/agent/ws");
assert.equal(new URL(url).search, "");
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42" });
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42" });
assert.deepEqual(JSON.parse(agentAuthFrame(null, "42")), { type: "auth", token: null, after: "42", clientKind: "web" });
assert.deepEqual(JSON.parse(agentAuthFrame(token, "42")), { type: "auth", token, after: "42", clientKind: "web" });
});
test("client sends the pinned source and exposes409 without requiring a JSON response body", async () => {