Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9098fee32a | ||
|
|
489c347976 | ||
|
|
7b48201e28 | ||
|
|
671ee98ab1 | ||
|
|
f59c7db7bb | ||
|
|
15efbcc205 | ||
|
|
ff48a25186 | ||
|
|
e681e9e05c | ||
|
|
d5427926d5 | ||
|
|
bbaa5e8859 |
+66
-1
@@ -605,6 +605,28 @@ durable entries and can advance without visible messages. Use returned cursors;
|
|||||||
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
do not derive them from message IDs. `limit` bounds source entries, not displayed
|
||||||
messages.
|
messages.
|
||||||
|
|
||||||
|
The web Agent starts with one cursorless `limit=100` window and renders it in
|
||||||
|
chronological order, initially positioned at its newest end. `hasMore` on this
|
||||||
|
window or a `before` page means **older** source entries exist; on an `after`
|
||||||
|
page it means **newer** entries remain. Each **Load older messages** action fetches
|
||||||
|
one `before=<beforeCursor>&limit=100` page, even if that page has no visible rows.
|
||||||
|
Older pages do not advance the live replay/read cursor or overwrite newer copies
|
||||||
|
of the same message. Initial loading never walks forward from zero or drains
|
||||||
|
older history. Reconnect recovery still follows `after` from the accepted live
|
||||||
|
cursor. Aborted/replaced room sessions discard late responses.
|
||||||
|
|
||||||
|
Web unread counts cover loaded public replies. A `+` suffix (or a dot when no
|
||||||
|
loaded reply is known unread) indicates that earlier messages may also be unread;
|
||||||
|
the API does not supply an exact total for unloaded history. Observing the latest
|
||||||
|
end in a focused visible conversation marks it read through that rendered cursor,
|
||||||
|
including earlier unloaded history. Fetching or reading older pages does not
|
||||||
|
acknowledge newer replies. An internal-only window with no rendered public
|
||||||
|
messages does not acknowledge history; older loading remains an explicit action.
|
||||||
|
Initial source-entry/network work is bounded; retained
|
||||||
|
messages/DOM grow with explicit older loads and live updates. Offscreen web rows
|
||||||
|
use CSS `content-visibility: auto`, which skips rendering work while retaining
|
||||||
|
chronological DOM, selection, find and accessibility; it is not DOM virtualization.
|
||||||
|
|
||||||
### `POST /v1/agent/messages`
|
### `POST /v1/agent/messages`
|
||||||
|
|
||||||
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
Body: `{ "content": "Check the oven", "requestId": "client-generated-uuid" }`.
|
||||||
@@ -686,11 +708,54 @@ Global `SYBIL_AGENT_ATTACHMENT_QUOTA_BYTES` defaults to 1 GiB. Concurrent reserv
|
|||||||
|
|
||||||
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
|
Release requires native `SYBIL_AGENT_AUTH_MODE=token` with nonempty `SYBIL_AGENT_API_KEY`, plus backend `ADMIN_TOKEN` and matching server-side `SYBIL_AGENT_API_KEY` / `SYBIL_AGENT_BASE_URL`. Native open mode cannot advertise/store/download attachments. A runtime containing attachment records/files refuses open startup; raw/model APIs and sockets are protected in token mode. An open Sybil proxy also refuses access to an attachment-capable native runtime, rather than exposing its history through a private server credential. Explicit text-only open development remains supported with an open/legacy runtime lacking attachment capability. Native keys never enter browser/iOS payloads. No insecure development opt-in is enabled.
|
||||||
|
|
||||||
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. The deployed open auth mode must be changed separately; no deployed config or mount is modified by this implementation. Backend and web must be upgraded with runtime capability/auth configured before clients advertise uploads.
|
The runtime source is built into its container: rebuild it with pinned busboy/sharp dependencies for the target Node/Linux platform before release. Deployment must use native token auth and matching backend credentials before clients advertise attachments; no additional public mount is needed.
|
||||||
|
|
||||||
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
|
Browser drafts retain upload IDs and completed descriptors across pane changes and reload. Incomplete uploads require reselection of the original filename/type/size and, once network delivery has begun, matching SHA-256 bytes; retries reuse uploadId. Sending persists the attachment-bearing outbox before clearing the draft. Message confirmation requires the same ordered attachment IDs in receipts and canonical echoes. Mismatched echoes must retain local attachments and delivery errors; they are not acknowledgement of a file submission.
|
||||||
|
|
||||||
|
|
||||||
|
### Assistant image output (v1)
|
||||||
|
|
||||||
|
The independent optional capability `capabilities.assistantImages: {"version":1}`
|
||||||
|
advertises the runtime's root-only `output_image` tool. It does **not** enable input
|
||||||
|
upload controls; those still require `capabilities.attachments.version == 1`.
|
||||||
|
Authenticated downloads accept either supported capability. Open proxies refuse
|
||||||
|
history and socket access if either private attachment capability is advertised.
|
||||||
|
Capability absence does not hide previously stored assistant image messages.
|
||||||
|
|
||||||
|
The agent invokes `output_image({path, uploadId, filename?, caption?})` with a stable
|
||||||
|
UUID retry identity and a local persistent raster exported beneath the workspace's
|
||||||
|
`artifacts/` or `downloads/` directory. It copies validated bytes into the existing
|
||||||
|
attachment store and publishes one durable assistant message. This is an internal
|
||||||
|
agent tool, with no browser/iOS publishing endpoint or client-supplied role.
|
||||||
|
Workers cannot publish through it. No URL fetch, remote image proxy, file execution
|
||||||
|
or implicit forwarding of native tool image blocks is performed. Generation is
|
||||||
|
available only when a separate generation provider/tool is actually installed.
|
||||||
|
|
||||||
|
Output uses the same original-byte/hash/quota limits (PNG/JPEG/GIF/WebP, 6 MiB,
|
||||||
|
50 million pixels, 16,384 pixels per axis), authenticated content route and backup
|
||||||
|
policy as uploads. Sources must be confined regular files without symlink/hardlink
|
||||||
|
substitution or private path segments. Upload IDs are bound to output provenance;
|
||||||
|
conflicting arguments fail and identical retries retain their message/attachment IDs.
|
||||||
|
Retries reuse the stored snapshot even if the original source moves or changes;
|
||||||
|
publishing changed bytes requires a new upload UUID.
|
||||||
|
|
||||||
|
Public output rows have `id: "outbound:<entryId>"`, `role: "assistant"`,
|
||||||
|
`metadata.kind: "message"`, `metadata.status: "complete"`, and
|
||||||
|
`metadata.agentAttachments` containing image descriptors. `content` is the optional
|
||||||
|
caption, otherwise an empty string; attachment-only rows remain visible. Descriptors
|
||||||
|
contain only `id`, `kind`, safe `filename`, `mimeType`, `sizeBytes` and `sha256`.
|
||||||
|
They contain no workspace/source path, URL, credential, or image bytes. The public
|
||||||
|
tool row redacts output tool arguments/results; internal task/submission provenance
|
||||||
|
stays in the existing durable outbox.
|
||||||
|
|
||||||
|
Web/iOS automatically render assistant rasters in fixed preview frames, retrieving
|
||||||
|
only authenticated ID-based originals, checking size and SHA-256, rejecting
|
||||||
|
redirects, and offering retry/download. Web limits downloads to two at a time and
|
||||||
|
uses revocable blob URLs. Clients discard assistant descriptor paths and unsupported
|
||||||
|
types. Agent Markdown cannot load images; external links remain explicit navigation.
|
||||||
|
Image-bearing push alerts use **You have a new message.**, including when a caption
|
||||||
|
exists, and never include attachment names, paths, bytes or URLs.
|
||||||
|
|
||||||
## Agent push notifications (v1)
|
## Agent push notifications (v1)
|
||||||
|
|
||||||
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
|
All push/config/presence routes require a configured `ADMIN_TOKEN` and bearer authentication; open development mode returns 503. Missing/wrong bearer returns 401/403. Bodies are limited to 16 KiB, responses use `Cache-Control: no-store`, invalid bodies return 400, and unavailable configuration returns 503. Do not put credentials, device tokens or subscription endpoints into URLs. Ownership is one **shared authenticated personal principal**, derived on the server from the configured credential generation. Anyone possessing that shared token has authority over all its installations; UUIDs identify installations and do not create per-human isolation. No `ownerId` is accepted. Changing `ADMIN_TOKEN` invalidates all prior push opt-ins. Protect the SQLite database/backups because endpoint credentials are stored there.
|
||||||
|
|||||||
@@ -337,8 +337,34 @@ The proxy closes upstream sockets when browsers leave or Sybil shuts down.
|
|||||||
History remains durable in Sybil Agent. Shared read position is separate from delivery:
|
History remains durable in Sybil Agent. Shared read position is separate from delivery:
|
||||||
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
update `GET`/`PUT /v1/agent/read-state` only when messages have actually been viewed.
|
||||||
|
|
||||||
|
The web client first accepts source-bound read state and one recent REST history
|
||||||
|
window, then authenticates this socket with that window's `cursor`. Initial
|
||||||
|
`hasMore` refers to older history and does not trigger a forward replay from zero.
|
||||||
|
Older pages load on demand using a separate `beforeCursor`; delayed older pages
|
||||||
|
preserve live edits and never change the socket replay position. Reconnects and
|
||||||
|
foreground catch-up use the last accepted live cursor as before. Incoming output
|
||||||
|
follows the viewport only while it is pinned to the newest end; older prepends and
|
||||||
|
dynamic heights preserve the reader's keyed row. Only a focused, visible, settled
|
||||||
|
latest view acknowledges the rendered cursor, marking the conversation read
|
||||||
|
through that point. See [web history windows and unread counts](rest.md#get-v1agentmessages)
|
||||||
|
for unloaded-history count semantics and rendering limits.
|
||||||
|
|
||||||
### Agent attachment history, queued activity and replay
|
### Agent attachment history, queued activity and replay
|
||||||
|
|
||||||
|
Assistant image output uses the same `messages` frames and durable history rows,
|
||||||
|
with `role: "assistant"`, `id: "outbound:<entryId>"`, `metadata.kind: "message"`,
|
||||||
|
`metadata.status: "complete"`, and `metadata.agentAttachments`. Each image descriptor
|
||||||
|
contains `id`, `kind: "image"`, `filename`, `mimeType`, `sizeBytes` and `sha256` only;
|
||||||
|
there is no source/workspace path or URL. An omitted caption yields empty `content`;
|
||||||
|
clients must retain this image-only row. Merge/replay by the unchanged message ID,
|
||||||
|
render authenticated image bytes inline in a fixed frame, and retain loaded history
|
||||||
|
even if the optional `capabilities.assistantImages.version: 1` is absent later.
|
||||||
|
That capability does not authorize input uploads. Native tool image blocks are not
|
||||||
|
automatically forwarded; the root agent explicitly calls `output_image`. Public
|
||||||
|
output tool rows redact arguments/results, and Agent Markdown does not fetch images.
|
||||||
|
See [assistant image output](./rest.md#assistant-image-output-v1) for publishing,
|
||||||
|
retry, auth and generic push-preview semantics. No new socket event is introduced.
|
||||||
|
|
||||||
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
|
Uploads and attached sends use REST, as specified in [rest.md](./rest.md#agent-attachments-v1). WebSocket authentication, event types, stable message IDs, cursor semantics and raw tool rows are unchanged. Capability discovery is optional `GET /v1/agent` → `capabilities.attachments.version: 1`; absence disables new attachment sends. Upgrade/authenticate the native runtime and backend before advertising it.
|
||||||
|
|
||||||
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
|
Both `messages.messages[]` and `activity.activity.queuedMessages[]` carry attachment descriptors under **`metadata.agentAttachments`**, distinct from ordinary chat's inline `metadata.attachments`:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 791 KiB |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"images" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "buzzert_iOS_app_icon_prophet_Sibyl_simple_minimal_mystic_blue_b_08983f8f-941b-4ff3-abb4-9df3f3f7612d.png",
|
"filename" : "AppIcon.png",
|
||||||
"idiom" : "universal",
|
"idiom" : "universal",
|
||||||
"platform" : "ios",
|
"platform" : "ios",
|
||||||
"size" : "1024x1024"
|
"size" : "1024x1024"
|
||||||
|
|||||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 877 KiB |
@@ -1,30 +1,37 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import Observation
|
||||||
|
|
||||||
struct SybilAgentView: View {
|
struct SybilAgentView: View {
|
||||||
@Bindable var room: SybilAgentRoom
|
var room: SybilAgentRoom
|
||||||
var topInset: CGFloat = 0
|
var topInset: CGFloat = 0
|
||||||
@Environment(\.scenePhase) private var scenePhase
|
@State private var latestRequest = 0
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
AgentTranscriptView(room: room, topInset: topInset, latestRequest: latestRequest)
|
||||||
|
AgentStatusBar(room: room)
|
||||||
|
AgentComposer(room: room) { latestRequest += 1 }
|
||||||
|
}
|
||||||
|
.onAppear { room.isSelected = true }
|
||||||
|
.onDisappear { room.isSelected = false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draft edits and keyboard focus belong to the composer. The transcript observes
|
||||||
|
// only room/render/scroll state; SwiftUI handles keyboard avoidance.
|
||||||
|
private struct AgentTranscriptView: View {
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
var topInset: CGFloat
|
||||||
|
var latestRequest: Int
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@FocusState private var composerFocused: Bool
|
|
||||||
@State private var atLatest = true
|
@State private var atLatest = true
|
||||||
@State private var isScrolling = false
|
@State private var scrollPhase: ScrollPhase = .idle
|
||||||
@State private var historyLimit = 100
|
@State private var historyLimit = 100
|
||||||
@State private var historyStartID: String?
|
@State private var historyStartID: String?
|
||||||
@State private var scrollPosition = ScrollPosition()
|
private static let bottomID = "agent-bottom"
|
||||||
@State private var scrollTracking = AgentScrollTracking()
|
@State private var scrollTracking = AgentScrollTracking()
|
||||||
@State private var atBottom = false
|
|
||||||
@State private var restoreAnchor: AgentReadingAnchor?
|
|
||||||
@State private var appeared = false
|
@State private var appeared = false
|
||||||
private let bottomID = "agent-bottom"
|
private var isScrolling: Bool { scrollPhase.isScrolling }
|
||||||
|
|
||||||
private var readKey: String {
|
|
||||||
"\(room.snapshot.room.cursor):\(atLatest):\(atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
|
||||||
}
|
|
||||||
private var visibleReadingAnchor: AgentReadingAnchor? {
|
|
||||||
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 {
|
var body: some View {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -35,149 +42,135 @@ struct SybilAgentView: View {
|
|||||||
let allMessages = transcript.messages
|
let allMessages = transcript.messages
|
||||||
let startIndex = transcript.startIndex
|
let startIndex = transcript.startIndex
|
||||||
let displayed = transcript.displayed
|
let displayed = transcript.displayed
|
||||||
VStack(spacing: 0) {
|
ScrollViewReader { proxy in
|
||||||
ScrollViewReader { proxy in
|
ScrollView {
|
||||||
ScrollView {
|
LazyVStack(alignment: .leading, spacing: 0) {
|
||||||
VStack(alignment: .leading, spacing: 15) {
|
// The native scroll origin is the visual bottom. Keep model
|
||||||
if allMessages.count > displayed.count {
|
// and cursor order ascending; reverse only presentation.
|
||||||
Button("Load earlier messages") {
|
Color.clear.frame(height: 17).id(Self.bottomID)
|
||||||
if var anchor = visibleReadingAnchor {
|
if rendered.isTyping {
|
||||||
anchor.expectedCount = min(allMessages.count, displayed.count + 100)
|
HStack(spacing: 9) {
|
||||||
restoreAnchor = anchor
|
AgentAvatar()
|
||||||
}
|
ProgressView().controlSize(.small)
|
||||||
atLatest = false
|
Text("Sybil is thinking…").font(.sybil(.caption))
|
||||||
historyLimit += 100
|
|
||||||
historyStartID = allMessages[max(0, startIndex - 100)].id
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
}
|
}
|
||||||
if room.isLoading { ProgressView("Loading messages…").frame(maxWidth: .infinity) }
|
.foregroundStyle(SybilTheme.textMuted)
|
||||||
ForEach(Array(displayed.enumerated()), id: \.element.id) { index, message in
|
.accessibilityIdentifier("agent-typing")
|
||||||
AgentTranscriptRow(message: message, room: room,
|
.padding(.bottom, 15)
|
||||||
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
.scaleEffect(x: 1, y: -1)
|
||||||
.equatable()
|
|
||||||
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
|
||||||
.id(message.id)
|
|
||||||
}
|
|
||||||
if rendered.isTyping {
|
|
||||||
HStack(spacing: 9) {
|
|
||||||
AgentAvatar()
|
|
||||||
ProgressView().controlSize(.small)
|
|
||||||
Text("Sybil is thinking…").font(.sybil(.caption))
|
|
||||||
}
|
|
||||||
.foregroundStyle(SybilTheme.textMuted)
|
|
||||||
.accessibilityIdentifier("agent-typing")
|
|
||||||
}
|
|
||||||
Color.clear.frame(height: 1).id(bottomID)
|
|
||||||
}
|
}
|
||||||
.scrollTargetLayout()
|
ForEach(Array(displayed.enumerated().reversed()), id: \.element.id) { index, message in
|
||||||
.padding(.horizontal, 16)
|
AgentTranscriptRow(message: message, room: room,
|
||||||
.padding(.bottom, 16)
|
startsDay: index == 0 || !Calendar.current.isDate(message.createdAt, inSameDayAs: displayed[index - 1].createdAt))
|
||||||
// Match normal chat: content clears the header, while the
|
.equatable()
|
||||||
// viewport extends beneath the shared navigation fade.
|
// Include the gap in the occupied-space entrance.
|
||||||
.padding(.top, 16 + topInset)
|
.padding(.bottom, 15)
|
||||||
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
.modifier(AgentEntrance(animate: appeared && rendered.entranceIDs.contains(message.id), reduceMotion: reduceMotion))
|
||||||
.frame(maxWidth: .infinity)
|
.scaleEffect(x: 1, y: -1)
|
||||||
}
|
.id(message.id)
|
||||||
.coordinateSpace(name: "agent-scroll")
|
}
|
||||||
.scrollPosition($scrollPosition)
|
if room.isLoading {
|
||||||
.defaultScrollAnchor(.bottom, for: .initialOffset)
|
ProgressView("Loading messages…").frame(maxWidth: .infinity)
|
||||||
.defaultScrollAnchor(.bottom, for: .alignment)
|
.padding(.bottom, 15)
|
||||||
.scrollDismissesKeyboard(.interactively)
|
.scaleEffect(x: 1, y: -1)
|
||||||
.onScrollPhaseChange { _, phase in
|
}
|
||||||
isScrolling = phase.isScrolling
|
if allMessages.count > displayed.count {
|
||||||
if phase == .interacting || phase == .tracking { restoreAnchor = nil }
|
Button("Load earlier messages") {
|
||||||
}
|
atLatest = false
|
||||||
.onScrollGeometryChange(for: AgentScrollMetrics.self) { geometry in
|
historyLimit += 100
|
||||||
AgentScrollMetrics(offset: geometry.contentOffset.y,
|
historyStartID = allMessages[max(0, startIndex - 100)].id
|
||||||
height: geometry.containerSize.height,
|
}
|
||||||
contentHeight: geometry.contentSize.height,
|
.frame(maxWidth: .infinity)
|
||||||
bottomInset: geometry.contentInsets.bottom, topInset: geometry.contentInsets.top,
|
.padding(.vertical, 10)
|
||||||
visibleBottom: geometry.visibleRect.maxY)
|
.padding(.bottom, 15)
|
||||||
} action: { previous, next in
|
.scaleEffect(x: 1, y: -1)
|
||||||
#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 {
|
|
||||||
proxy.scrollTo(bottomID, anchor: .bottom)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onPreferenceChange(AgentRowFrames.self) { frames in
|
// Keep native lazy-row targets without binding scroll position.
|
||||||
#if DEBUG
|
.scrollTargetLayout()
|
||||||
AgentRenderDiagnostics.record("rowPreferences")
|
.padding(.horizontal, 16)
|
||||||
#endif
|
// Match normal chat: content clears the header, while the
|
||||||
let anchor = restoreAnchor ?? (!atLatest && !isScrolling ? visibleReadingAnchor : nil)
|
// viewport extends beneath the shared navigation fade.
|
||||||
if let anchor, let frame = frames[anchor.id], abs(frame.minY - anchor.offset) > 1 {
|
.padding(.bottom, 16 + topInset)
|
||||||
// Restore the exact pixel offset after insertion or text
|
.frame(maxWidth: SybilLayout.webContentMaxWidth)
|
||||||
// reflow. Normalize the saved frames to
|
.frame(maxWidth: .infinity)
|
||||||
// the requested offset so the next layout cannot undo it.
|
}
|
||||||
let requested = scrollTracking.metrics.offset + frame.minY - anchor.offset
|
.scaleEffect(x: 1, y: -1)
|
||||||
let target = min(max(requested, -scrollTracking.metrics.topInset), scrollTracking.metrics.maximumOffset)
|
.accessibilityIdentifier("agent-transcript")
|
||||||
let appliedShift = target - scrollTracking.metrics.offset
|
.scrollDismissesKeyboard(.interactively)
|
||||||
scrollTracking.rowFrames = frames.mapValues { $0.offsetBy(dx: 0, dy: -appliedShift) }
|
.onScrollPhaseChange { _, phase in
|
||||||
if abs(appliedShift) > 1 {
|
scrollPhase = phase
|
||||||
// Geometry reports the raw offset; ScrollPosition uses
|
}
|
||||||
// zero at the top inset, including the safe area.
|
.onScrollGeometryChange(for: Bool.self) { geometry in
|
||||||
scrollPosition.scrollTo(y: target + scrollTracking.metrics.topInset)
|
geometry.containerSize.height > 0 &&
|
||||||
}
|
geometry.contentOffset.y + geometry.contentInsets.top <= 24
|
||||||
restoreAnchor = nil
|
} action: { _, atBottom in
|
||||||
} else {
|
#if DEBUG
|
||||||
scrollTracking.rowFrames = frames
|
AgentRenderDiagnostics.record("scrollGeometry")
|
||||||
if let expected = restoreAnchor?.expectedCount, frames.count >= expected { restoreAnchor = nil }
|
#endif
|
||||||
}
|
scrollTracking.atBottom = atBottom
|
||||||
}
|
// Only user scrolling changes follow intent. Send navigation
|
||||||
.overlay(alignment: .bottomTrailing) {
|
// and native keyboard resizing must not cancel it.
|
||||||
if !atLatest {
|
if isScrolling && scrollPhase != .animating {
|
||||||
Button {
|
atLatest = atBottom
|
||||||
atLatest = true
|
|
||||||
withAnimation(reduceMotion ? nil : .easeOut(duration: 0.24)) { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
||||||
} label: {
|
|
||||||
Label(room.unreadCount > 0 ? "\(room.unreadCount) unread" : "Latest", systemImage: "arrow.down")
|
|
||||||
.font(.sybil(.caption, weight: .semibold))
|
|
||||||
.padding(12)
|
|
||||||
.background(SybilTheme.surfaceStrong, in: Capsule())
|
|
||||||
.overlay(Capsule().stroke(SybilTheme.border))
|
|
||||||
}
|
|
||||||
.padding(14)
|
|
||||||
.accessibilityIdentifier("agent-jump-latest")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
appeared = true
|
|
||||||
if !room.isLoading { historyStartID = displayed.first?.id }
|
|
||||||
}
|
|
||||||
.onChange(of: room.isLoading) { _, loading in
|
|
||||||
if !loading && historyStartID == nil { historyStartID = displayed.first?.id }
|
|
||||||
}
|
|
||||||
.onChange(of: allMessages.count) { _, _ in
|
|
||||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
||||||
}
|
|
||||||
.onChange(of: composerFocused) { _, _ in
|
|
||||||
if atLatest { proxy.scrollTo(bottomID, anchor: .bottom) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statusBar
|
.onAppear {
|
||||||
composer
|
appeared = true
|
||||||
|
if !room.isLoading {
|
||||||
|
historyStartID = displayed.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: room.isLoading) { _, loading in
|
||||||
|
if !loading && historyStartID == nil {
|
||||||
|
historyStartID = displayed.first?.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: latestRequest) { _, _ in
|
||||||
|
if !atLatest {
|
||||||
|
atLatest = true
|
||||||
|
proxy.scrollTo(Self.bottomID, anchor: .top)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.modifier(AgentReadAcknowledgement(room: room, tracking: scrollTracking,
|
||||||
|
atLatest: atLatest, isScrolling: isScrolling))
|
||||||
}
|
}
|
||||||
.onAppear { room.isSelected = true }
|
}
|
||||||
.onDisappear { room.isSelected = false }
|
}
|
||||||
|
|
||||||
|
// Bottom proximity can change twice per entrance/keyboard frame while the
|
||||||
|
// scroll view follows the growing content. Only the read-settling task needs
|
||||||
|
// those transitions; they must not project and rebuild the transcript hierarchy.
|
||||||
|
private struct AgentReadAcknowledgement: ViewModifier {
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
var tracking: AgentScrollTracking
|
||||||
|
var atLatest: Bool
|
||||||
|
var isScrolling: Bool
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
|
|
||||||
|
private var readKey: String {
|
||||||
|
"\(room.snapshot.room.cursor):\(atLatest):\(tracking.atBottom):\(isScrolling):\(room.isObscured):\(room.isModalPresented):\(room.isAttachmentModalPresented):\(scenePhase == .active):\(room.isLoading):\(room.snapshot.revision)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
let cursor = room.snapshot.room.cursor
|
||||||
|
content
|
||||||
.task(id: readKey) {
|
.task(id: readKey) {
|
||||||
// This cursor belongs to the rendered snapshot. New network input cancels
|
// Capture the rendered cursor before scheduling. New network input
|
||||||
// the task, and cannot be acknowledged while still entering the layout.
|
// cancels this task and must settle in layout before acknowledgement.
|
||||||
let cursor = rendered.room.cursor
|
guard atLatest, tracking.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))
|
try? await Task.sleep(for: .milliseconds(reduceMotion ? 80 : 360))
|
||||||
guard !Task.isCancelled, atLatest, scrollTracking.metrics.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
guard !Task.isCancelled, atLatest, tracking.atBottom, !isScrolling, !room.isObscured, !room.isModalPresented, !room.isAttachmentModalPresented, scenePhase == .active else { return }
|
||||||
room.markRead(renderedCursor: cursor)
|
room.markRead(renderedCursor: cursor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder private var statusBar: some View {
|
private struct AgentStatusBar: View {
|
||||||
|
var room: SybilAgentRoom
|
||||||
|
|
||||||
|
@ViewBuilder var body: some View {
|
||||||
let activity = room.snapshot.room.activity
|
let activity = room.snapshot.room.activity
|
||||||
if let error = room.error ?? activity.error {
|
if let error = room.error ?? activity.error {
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
@@ -199,12 +192,18 @@ struct SybilAgentView: View {
|
|||||||
.padding(.horizontal, 16).padding(.vertical, 6)
|
.padding(.horizontal, 16).padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct AgentComposer: View {
|
||||||
|
@Bindable var room: SybilAgentRoom
|
||||||
|
var onSubmit: () -> Void
|
||||||
|
@FocusState private var composerFocused: Bool
|
||||||
|
|
||||||
private var canSendComposer: Bool {
|
private var canSendComposer: Bool {
|
||||||
room.attachmentDraft.canSend
|
room.attachmentDraft.canSend
|
||||||
}
|
}
|
||||||
|
|
||||||
private var composer: some View {
|
var body: some View {
|
||||||
AgentAttachmentComposer(room: room) {
|
AgentAttachmentComposer(room: room) {
|
||||||
SybilPromptField(
|
SybilPromptField(
|
||||||
text: $room.composer,
|
text: $room.composer,
|
||||||
@@ -223,7 +222,7 @@ struct SybilAgentView: View {
|
|||||||
|
|
||||||
private func submitComposer() {
|
private func submitComposer() {
|
||||||
guard canSendComposer, room.sendComposer() else { return }
|
guard canSendComposer, room.sendComposer() else { return }
|
||||||
atLatest = true
|
onSubmit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +235,8 @@ struct AgentAvatar: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activity/read publications still update the surrounding view. Completed rows
|
// Activity/read publications still update the transcript. Completed rows depend
|
||||||
// depend only on their message values and date heading, so progress changes
|
// only on message values and heading; scrolling needs no per-row geometry.
|
||||||
// must not rebuild the eager history (whose exact heights preserve anchors).
|
|
||||||
private struct AgentTranscriptRow: View, Equatable {
|
private struct AgentTranscriptRow: View, Equatable {
|
||||||
var message: Message
|
var message: Message
|
||||||
var room: SybilAgentRoom
|
var room: SybilAgentRoom
|
||||||
@@ -261,9 +259,6 @@ private struct AgentTranscriptRow: View, Equatable {
|
|||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
}
|
}
|
||||||
AgentMessageRow(message: message, room: room)
|
AgentMessageRow(message: message, room: room)
|
||||||
.background(GeometryReader { geometry in
|
|
||||||
Color.clear.preference(key: AgentRowFrames.self, value: [message.id: geometry.frame(in: .named("agent-scroll"))])
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -300,26 +295,35 @@ private struct AgentMessageRow: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Animate both occupied height and content, so incoming rows push older history up.
|
// Grow the occupied space with the bubble's entrance so neighboring rows move
|
||||||
private struct AgentGrowingLayout: Layout {
|
// smoothly. Measure at natural height; no geometry state or scroll correction.
|
||||||
|
private struct AgentEntranceLayout: Layout {
|
||||||
var progress: CGFloat
|
var progress: CGFloat
|
||||||
var animatableData: CGFloat { get { progress } set { progress = newValue } }
|
var animatableData: CGFloat {
|
||||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
get { progress }
|
||||||
let size = subviews.first?.sizeThatFits(ProposedViewSize(width: proposal.width, height: nil)) ?? .zero
|
set { progress = newValue }
|
||||||
return CGSize(width: proposal.width ?? size.width, height: size.height * progress)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let size = subviews.first?.sizeThatFits(.init(width: proposal.width, height: nil)) ?? .zero
|
||||||
|
return CGSize(width: size.width, height: size.height * progress)
|
||||||
|
}
|
||||||
|
|
||||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
subviews.first?.place(at: bounds.origin, proposal: ProposedViewSize(width: bounds.width, height: nil))
|
subviews.first?.place(at: bounds.origin, proposal: .init(width: bounds.width, height: nil))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct AgentEntrance: ViewModifier {
|
private struct AgentEntrance: ViewModifier {
|
||||||
var animate: Bool
|
var animate: Bool
|
||||||
var reduceMotion: Bool
|
var reduceMotion: Bool
|
||||||
@State private var entered = false
|
@State private var entered = false
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
let preparing = animate && !entered && !reduceMotion
|
let preparing = animate && !entered && !reduceMotion
|
||||||
AgentGrowingLayout(progress: preparing ? 0 : 1) {
|
AgentEntranceLayout(progress: preparing ? 0 : 1) {
|
||||||
content.opacity(preparing ? 0 : 1).offset(y: preparing ? 16 : 0)
|
content
|
||||||
|
.opacity(preparing ? 0 : 1)
|
||||||
|
.offset(y: preparing ? 16 : 0)
|
||||||
}
|
}
|
||||||
.clipped()
|
.clipped()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -328,35 +332,7 @@ private struct AgentEntrance: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pixel geometry is imperative anchor bookkeeping, not rendered content. Keeping
|
// Only the bottom boundary invalidates read acknowledgement, not the transcript.
|
||||||
// it outside observation avoids rebuilding the entire transcript on every scroll
|
@Observable private final class AgentScrollTracking {
|
||||||
// frame. Only the at-bottom boundary above invalidates the read acknowledgement.
|
var atBottom = false
|
||||||
private final class AgentScrollTracking {
|
|
||||||
var metrics = AgentScrollMetrics()
|
|
||||||
var rowFrames: [String: CGRect] = [:]
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct AgentScrollMetrics: Equatable {
|
|
||||||
var offset: CGFloat = 0
|
|
||||||
var height: CGFloat = 0
|
|
||||||
var contentHeight: CGFloat = 0
|
|
||||||
var bottomInset: CGFloat = 0
|
|
||||||
var topInset: CGFloat = 0
|
|
||||||
var visibleBottom: CGFloat = 0
|
|
||||||
// visibleRect includes the viewport extended into the safe area, as well as
|
|
||||||
// the alignment inset used when a short transcript is pinned to the bottom.
|
|
||||||
var distanceFromBottom: CGFloat { contentHeight + bottomInset - visibleBottom }
|
|
||||||
var atBottom: Bool { height > 0 && distanceFromBottom <= 24 }
|
|
||||||
var maximumOffset: CGFloat { max(-topInset, offset + distanceFromBottom) }
|
|
||||||
}
|
|
||||||
private struct AgentReadingAnchor {
|
|
||||||
var id: String
|
|
||||||
var offset: CGFloat
|
|
||||||
var expectedCount: Int? = nil
|
|
||||||
}
|
|
||||||
private struct AgentRowFrames: PreferenceKey {
|
|
||||||
static let defaultValue: [String: CGRect] = [:]
|
|
||||||
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
|
|
||||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Agent scrolling benchmark
|
# Agent interaction benchmarks
|
||||||
|
|
||||||
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
Run the production Agent view in an app-hosted XCTest on the iPhone 16e simulator:
|
||||||
|
|
||||||
@@ -7,62 +7,101 @@ just generate
|
|||||||
xcodebuild test -scheme SybilPresentation \
|
xcodebuild test -scheme SybilPresentation \
|
||||||
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
-destination 'platform=iOS Simulator,name=iPhone 16e,OS=latest' \
|
||||||
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
-derivedDataPath build/DerivedData -parallel-testing-enabled NO \
|
||||||
-only-testing:SybilPresentationTests/AgentPerformanceTests \
|
-only-testing:SybilPresentationTests/AgentPerformanceTests/testInputAndEntranceResponsiveness \
|
||||||
-resultBundlePath build/agent-performance.xcresult
|
-resultBundlePath build/agent-input-performance.xcresult
|
||||||
```
|
```
|
||||||
|
|
||||||
`AgentPerformanceTests` seeds a room through its normal event reducer with 160
|
`testInputAndEntranceResponsiveness` seeds 160 local mixed literal/Markdown
|
||||||
local messages (100 initially displayed). User text is literal; assistant text
|
messages through the normal room reducer (100 initially displayed), then:
|
||||||
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
|
- Receives 12 more messages at 550 ms intervals through the production 100 ms
|
||||||
the bottom, and thinking while scrolling. The test explicitly resets the offset
|
publication batch and 240 ms entrance animation, checking bottom following.
|
||||||
to the bottom before the stationary thinking phase (including in the baseline).
|
- Presents and dismisses the actual software keyboard six times, 600 ms per
|
||||||
Thinking activity changes queue depth, worker count and tool progress every
|
transition. It asserts that the transcript viewport shrinks by over 100 points
|
||||||
500 ms through the normal 100 ms publication
|
and remains at the bottom; a hardware-keyboard-only run is invalid.
|
||||||
batch. The display link sweeps a 2400-point range at 600 points/second, reversing
|
- Calls UIKit `insertText` on the actual focused composer every 30 ms, including
|
||||||
every four seconds. Use the **same test source, simulator, build configuration
|
enough text to wrap and grow to its six-line limit. It verifies the draft,
|
||||||
and host load** for before/after runs. Don't run other builds or profilers during
|
bottom following, and that keyboard/draft changes don't reproject the history.
|
||||||
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
|
The real composer, Markdown rows, avatars and entrance animations remain enabled.
|
||||||
retain the unoptimized transcript heights (39181.333 points idle, 39223.333 while
|
No server, production credentials, notification delivery or network images are
|
||||||
thinking), so an isolation wrapper cannot silently change row spacing. Rebaseline
|
needed. The log prints `AGENT_INPUT_PERFORMANCE_JSON`; the result bundle retains
|
||||||
these explicitly when changing simulator/font configuration or the fixture.
|
JSON and screenshots of the actual hosted view before and after typing.
|
||||||
|
Screenshots capture the hosting view, not the separate system keyboard window.
|
||||||
|
|
||||||
The log prints `AGENT_PERFORMANCE_JSON`; the result bundle retains the JSON as an
|
Use the same test source, simulator, build configuration and host load for
|
||||||
attachment. Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure actual
|
before/after measurements. Don't run other builds or profilers during sampling.
|
||||||
main-run-loop responsiveness. `missedBudgetIntervals` estimates gaps relative to
|
Functional assertions about observation boundaries should fail against the old
|
||||||
the reported display-link budget. These are **not compositor FPS, GPU hitches,
|
implementation; use the same sampling procedure when collecting a baseline.
|
||||||
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
|
## Measurement limits
|
||||||
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
|
Delivery-gap p50/p95/p99/max and counts over 25/50 ms measure **CADisplayLink
|
||||||
|
main-run-loop callback delivery**, not compositor FPS, GPU hitches, touch-to-key
|
||||||
|
latency or physical-device performance. The hosted view excludes the surrounding
|
||||||
|
app shell. UIKit input exercises editing/layout without synthesizing key taps.
|
||||||
|
The first keyboard presentation includes system warmup; residual system and
|
||||||
|
simulator scheduling gaps remain. Native gestures, read settling, header fade,
|
||||||
|
attachments and reading anchors are covered separately by the loopback UI tests.
|
||||||
|
|
||||||
On monsoon, Xcode 26.5, Debug, iPhone 16e / iOS 26.5, clean repeat runs against
|
`AgentRenderDiagnostics` counters are Debug-only and opt-in. They explain work;
|
||||||
`f534f76` plus counters and against the geometry/row isolation fix gave:
|
a drop in counts alone does not establish improved responsiveness.
|
||||||
|
|
||||||
| Phase | Before p95 / p99 (ms) | After p95 / p99 (ms) | Gaps >50 ms, before / after |
|
The transcript now uses `LazyVStack` with the observation boundaries introduced
|
||||||
|
in `bbaa5e8`. Loading older history may move the viewport; this is the only
|
||||||
|
waived position guarantee. Native regressions still assert reading anchors for
|
||||||
|
incoming messages, bottom following, keyboard behavior, read settling, fade,
|
||||||
|
and attachments. There is no saved prepend anchor or convergence loop.
|
||||||
|
|
||||||
|
Lazy content height estimates change as offscreen rows materialize. The earlier
|
||||||
|
43869→35870 point difference was not shortened content: after-arrival screenshots
|
||||||
|
were byte-identical. Compare visible geometry and native behavior, not estimated
|
||||||
|
whole-history height, when assessing lazy performance.
|
||||||
|
|
||||||
|
## Scrolling harness
|
||||||
|
|
||||||
|
`testThinkingScrollResponsiveness` retains the earlier three approximately
|
||||||
|
12-second phases: idle scroll, stationary thinking, and scrolling with changing
|
||||||
|
queue/worker/tool activity every 500 ms. Its display link sweeps 2400 points at
|
||||||
|
600 points/second. This programmatic sweep does not reproduce native drag phases;
|
||||||
|
use the UI tests for reading-anchor geometry and interactive keyboard dismissal.
|
||||||
|
|
||||||
|
The eager-stack reference in `b9a6f5d` has exact heights of 39181.333
|
||||||
|
points idle and 39223.333 while thinking. That reference harness retains its
|
||||||
|
eager-only total-height assertions; run it at `bbaa5e8` for the eager comparison.
|
||||||
|
Those totals are not valid assertions about lazy estimated content size. Use the
|
||||||
|
input harness and native gesture tests above for the current lazy implementation.
|
||||||
|
That earlier scrolling fix reduced busy-scroll p95 from 88.8 to 19.4 ms; it did
|
||||||
|
not address the input/entrance paths measured here. Physical phone validation
|
||||||
|
remains necessary for hardware performance claims.
|
||||||
|
|
||||||
|
## 2026-10-16 comparison before the prepend waiver
|
||||||
|
|
||||||
|
Single Debug simulator runs on the same iPhone 16e / iOS 26.5, starting at
|
||||||
|
`7510603` (which includes `b9a6f5d`). Values are callback-gap p95 milliseconds:
|
||||||
|
|
||||||
|
| Path | Before | Eager isolation (`bbaa5e8`) | Earlier lazy experiment |
|
||||||
| --- | ---: | ---: | ---: |
|
| --- | ---: | ---: | ---: |
|
||||||
| Idle scroll | 50.01 / 50.02 | 16.68 / 16.71 | 128 / 0 |
|
| 12 message entrances | 40.8 | 33.2 | 16.8 |
|
||||||
| Thinking stationary | 33.33 / 81.92 | 28.91 / 38.35 | 22 / 2 |
|
| Six keyboard cycles | 33.2 | 33.1 | 29.2 |
|
||||||
| Thinking scroll | 88.78 / 90.89 | 19.41 / 30.42 | 123 / 1 |
|
| Multiline composer typing | 24.8 | 25.2 | 21.3 |
|
||||||
|
|
||||||
Each phase lasted approximately 12 seconds. Content heights matched exactly.
|
Eager transcript projections were 12/0/0 versus 220/24/10, with no row-frame
|
||||||
Busy-scroll Agent body/projection evaluations fell from 261 to 27; unchanged
|
preference callbacks in these pinned-bottom phases. Eager content height stayed
|
||||||
text constructions were zero in both versions. Geometry isolation alone removed
|
43869.333 points. Entrance p99 improved 83.4→57.1 ms; keyboard p99 55.9→44.5;
|
||||||
the idle-scroll stalls; individual row isolation reduced activity-related gaps.
|
typing p99 33.2→26.7. Residual gaps remain: entrance/keyboard had 10/3 gaps over
|
||||||
These results support those two boundaries, without a Markdown cache or lazy
|
50 ms; typing had 30 over 25 ms (baseline 19). Keyboard p95 was essentially flat
|
||||||
layout change. Occasional simulator gaps remain; this does not replace device
|
and typing p95 did not improve. These single runs establish removed transcript
|
||||||
validation.
|
work and a safer entrance improvement, not universally smooth input or device FPS.
|
||||||
|
The lazy option was initially rejected because strict prepend anchors failed.
|
||||||
|
The subsequent release accepts that prepend tradeoff; all other guarantees remain.
|
||||||
|
|
||||||
|
## Lazy release after the prepend waiver
|
||||||
|
|
||||||
|
The same input harness passes unchanged: p95 entrance/keyboard/typing gaps are
|
||||||
|
16.9/28.1/20.9 ms, versus the eager run's 33.2/33.1/25.2 ms. Projections stay
|
||||||
|
12/0/0; pinned row-frame preference callbacks stay zero. The after-arrival PNG
|
||||||
|
is byte-identical to the eager baseline. Residual gaps remain (one keyboard gap
|
||||||
|
over 50 ms and one typing gap over 25 ms); these are single simulator samples. Five
|
||||||
|
focused native regressions pass with only the two prepend-offset assertions
|
||||||
|
replaced by checks that the remaining history loaded.
|
||||||
|
|||||||
@@ -7,6 +7,51 @@ import SwiftUI
|
|||||||
/// not GPU presentation, touch latency, or physical-device frame rate.
|
/// not GPU presentation, touch latency, or physical-device frame rate.
|
||||||
@MainActor
|
@MainActor
|
||||||
final class AgentPerformanceTests: XCTestCase {
|
final class AgentPerformanceTests: XCTestCase {
|
||||||
|
func testEntranceGrowsOccupiedHeight() async throws {
|
||||||
|
XCTAssertFalse(UIAccessibility.isReduceMotionEnabled, "This check must exercise the entrance animation")
|
||||||
|
let room = SybilAgentRoom()
|
||||||
|
let rows = (1...3).map { index in
|
||||||
|
Message(id: "entrance-\(index)", createdAt: Date(timeIntervalSince1970: 1_700_000_000),
|
||||||
|
role: .assistant, content: "Existing neighbor \(index)")
|
||||||
|
}
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: rows,
|
||||||
|
cursor: "3", beforeCursor: nil, hasMore: false)), replay: true)
|
||||||
|
room.flush()
|
||||||
|
let host = UIHostingController(rootView: SybilAgentView(room: room))
|
||||||
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||||
|
let window = UIWindow(windowScene: scene)
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer { window.isHidden = true; window.rootViewController = nil; room.stop() }
|
||||||
|
host.view.frame = window.bounds
|
||||||
|
host.view.layoutIfNeeded()
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
func transcript(in view: UIView) -> UIScrollView? {
|
||||||
|
if let scroll = view as? UIScrollView, scroll.contentSize.height > 100 { return scroll }
|
||||||
|
return view.subviews.lazy.compactMap { transcript(in: $0) }.first
|
||||||
|
}
|
||||||
|
let scroll = try XCTUnwrap(transcript(in: host.view))
|
||||||
|
let before = scroll.contentSize.height
|
||||||
|
let reply = Message(id: "entrance-4", createdAt: rows[0].createdAt, role: .assistant,
|
||||||
|
content: "Incoming reply\n\nIts occupied space should grow with the bubble.")
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "entrance", messages: [reply],
|
||||||
|
cursor: "4", beforeCursor: nil, hasMore: false)))
|
||||||
|
room.flush()
|
||||||
|
var heights: [CGFloat] = []
|
||||||
|
for _ in 0..<32 {
|
||||||
|
try await Task.sleep(for: .milliseconds(16))
|
||||||
|
heights.append(scroll.contentSize.height)
|
||||||
|
}
|
||||||
|
let after = try XCTUnwrap(heights.last)
|
||||||
|
// All four short rows fit onscreen, so these are measured heights, not
|
||||||
|
// estimates for unmaterialized lazy history. No frame-rate claim.
|
||||||
|
XCTAssertLessThan(after, scroll.bounds.height)
|
||||||
|
XCTAssertGreaterThan(after - before, 50)
|
||||||
|
let intermediate = Set(heights.filter { $0 > before + 2 && $0 < after - 2 }.map { Int($0.rounded()) })
|
||||||
|
XCTAssertGreaterThanOrEqual(intermediate.count, 3, "Neighbors must get intermediate layout heights, not a full-height insertion")
|
||||||
|
print("AGENT_ENTRANCE_HEIGHT before=\(before) samples=\(heights)")
|
||||||
|
}
|
||||||
|
|
||||||
func testThinkingScrollResponsiveness() async throws {
|
func testThinkingScrollResponsiveness() async throws {
|
||||||
let room = SybilAgentRoom()
|
let room = SybilAgentRoom()
|
||||||
var rows: [Message] = []
|
var rows: [Message] = []
|
||||||
@@ -72,6 +117,116 @@ final class AgentPerformanceTests: XCTestCase {
|
|||||||
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
attachment.name = "agent-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exercises UIKit editing/focus and the real message reducer; no synthetic
|
||||||
|
/// view rebuild loop or replacement composer/animation is used.
|
||||||
|
func testInputAndEntranceResponsiveness() async throws {
|
||||||
|
let room = SybilAgentRoom()
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "performance",
|
||||||
|
messages: (1...160).map(Self.message), cursor: "160", beforeCursor: nil, hasMore: false)), replay: true)
|
||||||
|
room.flush()
|
||||||
|
let host = UIHostingController(rootView: SybilAgentView(room: room, topInset: 64)
|
||||||
|
.background(SybilTheme.background).environment(\.colorScheme, .dark))
|
||||||
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||||
|
let window = UIWindow(windowScene: scene)
|
||||||
|
window.rootViewController = host
|
||||||
|
window.makeKeyAndVisible()
|
||||||
|
defer {
|
||||||
|
host.view.endEditing(true)
|
||||||
|
AgentRenderDiagnostics.enabled = false
|
||||||
|
window.isHidden = true; window.rootViewController = nil
|
||||||
|
room.stop()
|
||||||
|
}
|
||||||
|
host.view.frame = window.bounds
|
||||||
|
host.view.layoutIfNeeded()
|
||||||
|
try await Task.sleep(for: .seconds(2))
|
||||||
|
let scroll = try XCTUnwrap(findScroll(host.view))
|
||||||
|
let input = try XCTUnwrap(findInput(host.view))
|
||||||
|
AgentRenderDiagnostics.enabled = true
|
||||||
|
var reports: [[String: Any]] = []
|
||||||
|
func report(_ sampler: AgentFrameSampler, phase: String) {
|
||||||
|
var value = sampler.stop()
|
||||||
|
value["phase"] = phase
|
||||||
|
value["procedure"] = "mixed160-input-entrance-v1"
|
||||||
|
value["operations"] = AgentRenderDiagnostics.counts
|
||||||
|
value["contentHeight"] = scroll.contentSize.height
|
||||||
|
value["viewportHeight"] = scroll.bounds.height
|
||||||
|
reports.append(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let entrance = AgentFrameSampler(scroll: nil)
|
||||||
|
entrance.start()
|
||||||
|
for index in 161...172 {
|
||||||
|
try room.receive(.messages(AgentPage(conversationId: "performance",
|
||||||
|
messages: [Self.message(index)], cursor: String(index), beforeCursor: nil, hasMore: false)))
|
||||||
|
// Use the production 100 ms publication batch and 240 ms entrance.
|
||||||
|
try await Task.sleep(for: .milliseconds(550))
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
}
|
||||||
|
XCTAssertLessThanOrEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 24,
|
||||||
|
"Entrance frames must not repeatedly project the history")
|
||||||
|
report(entrance, phase: "message-entrance")
|
||||||
|
XCTAssertEqual(room.snapshot.room.messages.count, 172)
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
capture(host, "input-entrance-latest")
|
||||||
|
|
||||||
|
let unfocusedHeight = scroll.bounds.height
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let keyboard = AgentFrameSampler(scroll: nil)
|
||||||
|
keyboard.start()
|
||||||
|
for _ in 0..<6 {
|
||||||
|
XCTAssertTrue(input.becomeFirstResponder())
|
||||||
|
try await Task.sleep(for: .milliseconds(600))
|
||||||
|
XCTAssertLessThan(scroll.bounds.height, unfocusedHeight - 100, "Software keyboard must actually resize the transcript")
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
XCTAssertTrue(input.resignFirstResponder())
|
||||||
|
try await Task.sleep(for: .milliseconds(600))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
|
||||||
|
"Keyboard focus and geometry must not project the transcript")
|
||||||
|
report(keyboard, phase: "keyboard-presentation")
|
||||||
|
XCTAssertEqual(scroll.bounds.height, unfocusedHeight, accuracy: 1)
|
||||||
|
XCTAssertTrue(input.becomeFirstResponder())
|
||||||
|
try await Task.sleep(for: .milliseconds(700))
|
||||||
|
let keyInput = try XCTUnwrap(input as? any UIKeyInput)
|
||||||
|
AgentRenderDiagnostics.counts = [:]
|
||||||
|
let typing = AgentFrameSampler(scroll: nil)
|
||||||
|
typing.start()
|
||||||
|
let text = "Typing into the actual composer should keep the long transcript responsive as this sentence wraps across several lines. "
|
||||||
|
for character in text + text {
|
||||||
|
keyInput.insertText(String(character))
|
||||||
|
try await Task.sleep(for: .milliseconds(30))
|
||||||
|
}
|
||||||
|
XCTAssertEqual(AgentRenderDiagnostics.counts["messageProjection", default: 0], 0,
|
||||||
|
"Draft edits and line wrapping must not project the transcript")
|
||||||
|
assertAtBottom(scroll)
|
||||||
|
report(typing, phase: "composer-typing")
|
||||||
|
XCTAssertEqual(room.composer, text + text)
|
||||||
|
capture(host, "input-multiline-keyboard")
|
||||||
|
let data = try JSONSerialization.data(withJSONObject: reports, options: [.sortedKeys])
|
||||||
|
print("AGENT_INPUT_PERFORMANCE_JSON " + String(decoding: data, as: UTF8.self))
|
||||||
|
let attachment = XCTAttachment(data: data, uniformTypeIdentifier: "public.json")
|
||||||
|
attachment.name = "agent-input-performance"; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertAtBottom(_ scroll: UIScrollView, file: StaticString = #filePath, line: UInt = #line) {
|
||||||
|
XCTAssertEqual(scroll.contentOffset.y + scroll.bounds.height - scroll.adjustedContentInset.bottom,
|
||||||
|
scroll.contentSize.height, accuracy: 25, file: file, line: line)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func findInput(_ view: UIView) -> UIView? {
|
||||||
|
if view is UITextView || view is UITextField { return view }
|
||||||
|
return view.subviews.lazy.compactMap { self.findInput($0) }.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capture(_ host: UIViewController, _ name: String) {
|
||||||
|
let image = UIGraphicsImageRenderer(bounds: host.view.bounds).image { _ in
|
||||||
|
host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true)
|
||||||
|
}
|
||||||
|
let attachment = XCTAttachment(image: image)
|
||||||
|
attachment.name = name; attachment.lifetime = .keepAlways; add(attachment)
|
||||||
|
}
|
||||||
|
|
||||||
private static func message(_ index: Int) -> Message {
|
private static func message(_ index: Int) -> Message {
|
||||||
let own = index % 3 == 0
|
let own = index % 3 == 0
|
||||||
let content = own
|
let content = own
|
||||||
|
|||||||
@@ -313,7 +313,8 @@ final class AgentUITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
scroll.swipeDown()
|
scroll.swipeDown()
|
||||||
scroll.swipeDown()
|
scroll.swipeDown()
|
||||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 3))
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first { $0.isHittable })
|
||||||
let before = anchor.frame.minY
|
let before = anchor.frame.minY
|
||||||
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
try await control(["action": "reply", "content": "Reply while reading earlier history"])
|
||||||
@@ -321,20 +322,33 @@ final class AgentUITests: XCTestCase {
|
|||||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||||
capture("agent-reading-anchor")
|
capture("agent-reading-anchor")
|
||||||
for _ in 0..<25 {
|
for _ in 0..<25 {
|
||||||
if app.buttons["Load earlier messages"].isHittable { break }
|
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
||||||
scroll.swipeDown(velocity: .fast)
|
scroll.swipeDown(velocity: .fast)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
XCTAssertTrue(app.buttons["Load earlier messages"].isHittable)
|
||||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
|
||||||
let beforeLoad = first.frame.minY
|
|
||||||
app.buttons["Load earlier messages"].tap()
|
app.buttons["Load earlier messages"].tap()
|
||||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3)
|
// Loading older lazy history may move the viewport. It must still load
|
||||||
capture("agent-older-history-anchor")
|
// the remaining history and preserve scroll-to-latest/keyboard behavior below.
|
||||||
app.buttons["agent-jump-latest"].tap()
|
XCTAssertFalse(app.buttons["Load earlier messages"].exists)
|
||||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
capture("agent-older-history-loaded")
|
||||||
|
let latest = app.staticTexts["Reply while reading earlier history"]
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if latest.isHittable { break }
|
||||||
|
scroll.swipeUp(velocity: .fast)
|
||||||
|
}
|
||||||
|
scroll.swipeUp(velocity: .slow)
|
||||||
|
XCTAssertTrue(latest.isHittable)
|
||||||
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
composer.tap(); composer.typeText("A multiline draft\nwith a second line\nand a third line")
|
||||||
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
XCTAssertTrue(app.staticTexts["Reply while reading earlier history"].isHittable)
|
||||||
capture("agent-multiline-keyboard")
|
capture("agent-multiline-keyboard")
|
||||||
|
// Sending remains the explicit navigation path while reading history.
|
||||||
|
scroll.swipeDown(velocity: .slow)
|
||||||
|
scroll.swipeDown(velocity: .slow)
|
||||||
|
XCTAssertFalse(latest.isHittable)
|
||||||
|
app.buttons["agent-send"].tap()
|
||||||
|
let sent = app.staticTexts["A multiline draft\nwith a second line\nand a third line"]
|
||||||
|
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: sent)
|
||||||
|
await fulfillment(of: [followed], timeout: 5)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
func testBackgroundUnreadReadReconciliationAndReplay() async throws {
|
||||||
|
|||||||
@@ -37,8 +37,19 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var transcript: XCUIElement {
|
private var transcript: XCUIElement {
|
||||||
// The tablet sidebar is also a scroll view; select the detail by its content.
|
// Identify the transcript independently of any particular message row.
|
||||||
app.scrollViews.containing(.staticText, identifier: "History message 160: local simulator fixture.").firstMatch
|
app.scrollViews["agent-transcript"]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToLatest(_ latest: XCUIElement) {
|
||||||
|
for _ in 0..<30 {
|
||||||
|
if latest.isHittable { break }
|
||||||
|
transcript.swipeUp(velocity: .fast)
|
||||||
|
}
|
||||||
|
// Finish at the physical end, including the reply metadata and spacing.
|
||||||
|
transcript.swipeUp(velocity: .slow)
|
||||||
|
XCTAssertTrue(latest.isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func capture(_ name: String) {
|
private func capture(_ name: String) {
|
||||||
@@ -55,6 +66,79 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
return try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func reloadMixedHistory() async throws {
|
||||||
|
app.terminate()
|
||||||
|
let rows = (1...160).map { index in
|
||||||
|
let short = "History message \(index): local simulator fixture."
|
||||||
|
return ["role": index % 3 == 0 ? "user" : "assistant",
|
||||||
|
"content": index == 160 || index % 3 == 0 ? short :
|
||||||
|
"### Investigation \(index)\n\n" + String(repeating: "A longer transcript paragraph with **emphasis** and `inline code`.\n\n", count: 4) +
|
||||||
|
"- Preserve visible content.\n- Keep the composer available.\n\n```swift\nrender(messages[\(index)])\n```"]
|
||||||
|
}
|
||||||
|
try await control(["action": "presentation-samples", "messages": rows])
|
||||||
|
app.launch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testInitialOpenAndTabReturnShowLatestWithoutScrolling() async throws {
|
||||||
|
try await reloadMixedHistory()
|
||||||
|
openAgent()
|
||||||
|
let latest = app.staticTexts["History message 160: local simulator fixture."]
|
||||||
|
XCTAssertTrue(latest.isHittable)
|
||||||
|
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
|
||||||
|
capture("agent-initial-open")
|
||||||
|
|
||||||
|
showSidebar()
|
||||||
|
app.buttons["Settings"].tap()
|
||||||
|
XCTAssertFalse(composer.exists)
|
||||||
|
openAgent()
|
||||||
|
XCTAssertTrue(latest.isHittable, "Returning to Agent must not need a scroll to materialize rows")
|
||||||
|
XCTAssertLessThan(latest.frame.maxY, composer.frame.minY)
|
||||||
|
capture("agent-tab-return")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSendClearsMultilineComposerAndKeepsLatestAboveKeyboard() async throws {
|
||||||
|
try await reloadMixedHistory()
|
||||||
|
openAgent()
|
||||||
|
composer.tap()
|
||||||
|
if let existing = composer.value as? String, !existing.isEmpty, existing != "Enter Prompt" {
|
||||||
|
composer.press(forDuration: 1)
|
||||||
|
let selectAll = app.descendants(matching: .any).matching(NSPredicate(format: "label == %@", "Select All")).firstMatch
|
||||||
|
if selectAll.waitForExistence(timeout: 2) { selectAll.tap() }
|
||||||
|
else { composer.typeText(String(repeating: XCUIKeyboardKey.rightArrow.rawValue, count: existing.count)) }
|
||||||
|
composer.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: existing.count))
|
||||||
|
}
|
||||||
|
let draft = "Keyboard send\nsecond line\nthird line"
|
||||||
|
composer.typeText(draft)
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.exists)
|
||||||
|
let multilineHeight = composer.frame.height
|
||||||
|
app.buttons["agent-send"].tap()
|
||||||
|
let cleared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "value == %@ OR value == %@", "", "Enter Prompt"), object: composer)
|
||||||
|
await fulfillment(of: [cleared], timeout: 5)
|
||||||
|
XCTAssertLessThan(composer.frame.height, multilineHeight)
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.exists)
|
||||||
|
let sent = app.staticTexts[draft]
|
||||||
|
XCTAssertTrue(sent.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(sent.isHittable)
|
||||||
|
let reply = app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "Finished: Keyboard send")).firstMatch
|
||||||
|
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: reply)
|
||||||
|
await fulfillment(of: [followed], timeout: 10)
|
||||||
|
capture("agent-send-cleared-composer-with-keyboard")
|
||||||
|
XCTAssertGreaterThan(reply.frame.minY, transcript.frame.minY + 64)
|
||||||
|
XCTAssertLessThan(reply.frame.maxY, composer.frame.minY)
|
||||||
|
// Finished: is only the first Markdown paragraph. Measure the final
|
||||||
|
// reply content, leaving the same allowance for metadata and spacing.
|
||||||
|
let replyEnd = app.staticTexts["The local fixture returned three matches."]
|
||||||
|
XCTAssertTrue(replyEnd.isHittable)
|
||||||
|
XCTAssertLessThan(replyEnd.frame.maxY, composer.frame.minY)
|
||||||
|
let bottomGap = composer.frame.minY - replyEnd.frame.maxY
|
||||||
|
print("Agent send settled final-content gap: \(bottomGap)pt")
|
||||||
|
XCTAssertLessThan(bottomGap, 100, "Composer clearing must not leave the transcript shifted above the bottom")
|
||||||
|
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
let read = try await control(["action": "state"])
|
||||||
|
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||||
|
}
|
||||||
|
|
||||||
func testSharedHeaderFadeGeometry() async throws {
|
func testSharedHeaderFadeGeometry() async throws {
|
||||||
showSidebar()
|
showSidebar()
|
||||||
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
let chat = app.buttons.containing(NSPredicate(format: "label CONTAINS %@", "Transcript fade comparison")).firstMatch
|
||||||
@@ -73,7 +157,7 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
let scroll = transcript
|
let scroll = transcript
|
||||||
XCTAssertTrue(scroll.exists)
|
XCTAssertTrue(scroll.exists)
|
||||||
scroll.swipeDown(velocity: .slow)
|
scroll.swipeDown(velocity: .slow)
|
||||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
capture("agent-content-crossing-shared-fade")
|
capture("agent-content-crossing-shared-fade")
|
||||||
XCTAssertTrue(composer.isHittable)
|
XCTAssertTrue(composer.isHittable)
|
||||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||||
@@ -89,7 +173,8 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
XCTAssertTrue(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
scroll.swipeDown(velocity: .slow)
|
scroll.swipeDown(velocity: .slow)
|
||||||
scroll.swipeDown(velocity: .slow)
|
scroll.swipeDown(velocity: .slow)
|
||||||
XCTAssertTrue(app.buttons["agent-jump-latest"].waitForExistence(timeout: 5))
|
XCTAssertFalse(app.staticTexts["History message 160: local simulator fixture."].isHittable)
|
||||||
|
XCTAssertFalse(app.buttons["agent-jump-latest"].exists)
|
||||||
// Stay below the header's content clearance, where text is readable.
|
// Stay below the header's content clearance, where text is readable.
|
||||||
let visibleTop = scroll.frame.minY + 100
|
let visibleTop = scroll.frame.minY + 100
|
||||||
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
let anchor = try XCTUnwrap(app.staticTexts.matching(NSPredicate(format: "label BEGINSWITH %@", "History message")).allElementsBoundByIndex.first {
|
||||||
@@ -98,8 +183,9 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
let before = anchor.frame.minY
|
let before = anchor.frame.minY
|
||||||
let stateBefore = try await control(["action": "state"])
|
let stateBefore = try await control(["action": "state"])
|
||||||
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
try await control(["action": "reply", "content": "Fade regression reply while reading"])
|
||||||
let unread = XCTNSPredicateExpectation(predicate: NSPredicate(format: "label CONTAINS %@", "unread"), object: app.buttons["agent-jump-latest"])
|
// The removed button no longer exposes unread state. Allow the entrance
|
||||||
await fulfillment(of: [unread], timeout: 8)
|
// and read debounce to settle, then inspect the fixture's actual cursor.
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
XCTAssertEqual(anchor.frame.minY, before, accuracy: 2)
|
||||||
let stateAfter = try await control(["action": "state"])
|
let stateAfter = try await control(["action": "state"])
|
||||||
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
XCTAssertEqual(stateAfter["readCursor"] as? Int, stateBefore["readCursor"] as? Int)
|
||||||
@@ -107,23 +193,23 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
capture("agent-fade-reading-position-and-unread")
|
capture("agent-fade-reading-position-and-unread")
|
||||||
|
|
||||||
for _ in 0..<30 {
|
for _ in 0..<30 {
|
||||||
if app.buttons["Load earlier messages"].isHittable { break }
|
if app.buttons["Load earlier messages"].exists && app.buttons["Load earlier messages"].isHittable { break }
|
||||||
scroll.swipeDown(velocity: .fast)
|
scroll.swipeDown(velocity: .fast)
|
||||||
}
|
}
|
||||||
let load = app.buttons["Load earlier messages"]
|
let load = app.buttons["Load earlier messages"]
|
||||||
XCTAssertTrue(load.isHittable)
|
XCTAssertTrue(load.isHittable)
|
||||||
let first = app.staticTexts["History message 61: local simulator fixture."]
|
|
||||||
let beforeLoad = first.frame.minY
|
|
||||||
load.tap()
|
load.tap()
|
||||||
capture("agent-fade-prepend-reading-position")
|
// Prepend position is intentionally not preserved with lazy history.
|
||||||
XCTAssertEqual(first.frame.minY, beforeLoad, accuracy: 3,
|
// New-message reading anchors above and follow/read settling below remain strict.
|
||||||
"Prepending must preserve the readable row's position beneath the header")
|
XCTAssertFalse(load.exists)
|
||||||
|
capture("agent-fade-older-history-loaded")
|
||||||
|
|
||||||
app.buttons["agent-jump-latest"].tap()
|
|
||||||
let latest = app.staticTexts["Fade regression reply while reading"]
|
let latest = app.staticTexts["Fade regression reply while reading"]
|
||||||
|
scrollToLatest(latest)
|
||||||
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
let followed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: latest)
|
||||||
await fulfillment(of: [followed], timeout: 5)
|
await fulfillment(of: [followed], timeout: 5)
|
||||||
composer.tap()
|
composer.tap()
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||||
XCTAssertTrue(latest.isHittable)
|
XCTAssertTrue(latest.isHittable)
|
||||||
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
XCTAssertTrue(app.buttons["agent-attach"].isHittable)
|
||||||
capture("agent-fade-latest-with-keyboard")
|
capture("agent-fade-latest-with-keyboard")
|
||||||
@@ -134,5 +220,27 @@ final class TranscriptFadeUITests: XCTestCase {
|
|||||||
try await Task.sleep(for: .milliseconds(500))
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
let read = try await control(["action": "state"])
|
let read = try await control(["action": "state"])
|
||||||
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
XCTAssertEqual(read["readCursor"] as? Int, read["cursor"] as? Int)
|
||||||
|
|
||||||
|
// A reply still follows and settles as read while the keyboard is up.
|
||||||
|
try await control(["action": "reply", "content": "Reply with keyboard visible"])
|
||||||
|
let keyboardReply = app.staticTexts["Reply with keyboard visible"]
|
||||||
|
let keyboardFollowed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "hittable == true"), object: keyboardReply)
|
||||||
|
await fulfillment(of: [keyboardFollowed], timeout: 5)
|
||||||
|
try await Task.sleep(for: .milliseconds(500))
|
||||||
|
let keyboardRead = try await control(["action": "state"])
|
||||||
|
XCTAssertEqual(keyboardRead["readCursor"] as? Int, keyboardRead["cursor"] as? Int)
|
||||||
|
|
||||||
|
// Interactive dismissal is user navigation; scrolling must still return
|
||||||
|
// to the bottom, and presenting the keyboard again must keep it visible.
|
||||||
|
let dismissStart = scroll.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.9))
|
||||||
|
let dismissEnd = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.98))
|
||||||
|
dismissStart.press(forDuration: 0.1, thenDragTo: dismissEnd)
|
||||||
|
let dismissed = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: app.keyboards.firstMatch)
|
||||||
|
await fulfillment(of: [dismissed], timeout: 5)
|
||||||
|
scrollToLatest(keyboardReply)
|
||||||
|
XCTAssertTrue(keyboardReply.isHittable)
|
||||||
|
composer.tap()
|
||||||
|
XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5))
|
||||||
|
XCTAssertTrue(keyboardReply.isHittable)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ export type AgentProxyJson = (path: string, method?: string, body?: unknown, sig
|
|||||||
function apiError(statusCode: number, code: string, message: string) {
|
function apiError(statusCode: number, code: string, message: string) {
|
||||||
return Object.assign(new Error(message), { statusCode, code });
|
return Object.assign(new Error(message), { statusCode, code });
|
||||||
}
|
}
|
||||||
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal) {
|
export async function requireAgentAttachments(proxyJson: AgentProxyJson, signal?: AbortSignal, download = false) {
|
||||||
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
|
if (!env.ADMIN_TOKEN) throw apiError(503, 'attachments_unavailable', 'Attachments require authenticated Sybil access.');
|
||||||
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
|
const descriptor = await proxyJson('/api/conversations/root', 'GET', undefined, signal);
|
||||||
const value = descriptor.data as { capabilities?: { attachments?: { version?: number } } } | null;
|
const value = descriptor.data as { capabilities?: { attachments?: { version?: number }; assistantImages?: { version?: number } } } | null;
|
||||||
if (descriptor.status !== 200 || value?.capabilities?.attachments?.version !== 1)
|
if (descriptor.status !== 200 || !(value?.capabilities?.attachments?.version === 1
|
||||||
|
|| (download && value?.capabilities?.assistantImages?.version === 1)))
|
||||||
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
|
throw apiError(503, 'attachments_unavailable', 'The Agent does not currently support authenticated attachments.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ export async function registerAgentAttachmentRoutes(app: FastifyInstance, option
|
|||||||
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
|
if (req.raw.aborted || reply.raw.destroyed) { release(); controller.signal.throwIfAborted(); }
|
||||||
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
|
if (uploading && Number(req.headers['content-length'] || 0) > AGENT_UPLOAD_LIMIT)
|
||||||
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
throw apiError(413, 'attachment_limit_exceeded', 'Upload request exceeds its size limit.');
|
||||||
await requireAgentAttachments(options.proxyJson, controller.signal);
|
await requireAgentAttachments(options.proxyJson, controller.signal, !uploading);
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
});
|
});
|
||||||
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
|
routes.addHook('onResponse', async req => { transfers.get(req)?.release(); });
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export type MessageText = (message: PublicMessage) => Promise<string>;
|
|||||||
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
|
const Page = z.object({ conversationId: z.string(), cursor: Cursor, beforeCursor: Cursor,
|
||||||
messages: z.array(z.unknown()).max(1000) });
|
messages: z.array(z.unknown()).max(1000) });
|
||||||
const Message = z.object({ id: z.string(), role: z.literal('assistant'), content: z.string(),
|
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() }) });
|
metadata: z.object({ kind: z.literal('message'), status: z.literal('complete'), entryId: z.number().int().safe(),
|
||||||
|
agentAttachments: z.unknown().optional() }) });
|
||||||
|
|
||||||
/** Resolve only an already eligible candidate from one immutable root public entry. */
|
/** Resolve only an already eligible candidate from one immutable root public entry. */
|
||||||
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
|
export function createMessageText(upstream: { baseUrl: string; apiKey?: string }): MessageText {
|
||||||
@@ -47,7 +48,10 @@ export function createMessageText(upstream: { baseUrl: string; apiKey?: string }
|
|||||||
const matches = page.messages.filter(value => value && typeof value === 'object' && 'id' in value && value.id === candidate.messageId);
|
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;
|
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');
|
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.
|
// Attachment messages use the generic alert. Never expose captions, filenames,
|
||||||
|
// paths, image data or linked resources in an image notification.
|
||||||
|
if (message.data.metadata.agentAttachments !== undefined) return '';
|
||||||
|
// Never inspect tool results, activity, reasoning blocks or linked resources for text.
|
||||||
return message.data.content;
|
return message.data.content;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-2
@@ -43,8 +43,8 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
|||||||
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
|
const response = await fetch(new URL('/api/conversations/root', baseUrl), {
|
||||||
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}` }, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000), redirect: 'error',
|
||||||
});
|
});
|
||||||
const descriptor = await response.json() as { capabilities?: { attachments?: unknown } };
|
const descriptor = await response.json() as { capabilities?: { attachments?: unknown; assistantImages?: unknown } };
|
||||||
if (!response.ok || descriptor?.capabilities?.attachments) throw new Error('Private runtime');
|
if (!response.ok || descriptor?.capabilities?.attachments || descriptor?.capabilities?.assistantImages) throw new Error('Private runtime');
|
||||||
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
|
} catch { throw Object.assign(new Error('Authenticated Sybil access is required for this Agent runtime.'), { statusCode: 503, code: 'attachments_unavailable' }); }
|
||||||
};
|
};
|
||||||
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
|
const proxyJson = async (path: string, method = 'GET', body?: unknown, signal?: AbortSignal) => {
|
||||||
@@ -53,6 +53,7 @@ export async function registerAgentRoutes(app: FastifyInstance, options: AgentOp
|
|||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(new URL(path, baseUrl), { method,
|
response = await fetch(new URL(path, baseUrl), { method,
|
||||||
|
redirect: 'error',
|
||||||
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
headers: { 'X-Sybil-Client': 'backend-proxy-v1', Authorization: `Bearer ${apiKey}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
|
||||||
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(15_000)]) : AbortSignal.timeout(15_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ const [{ registerAgentRoutes }, { env }] = await Promise.all([import('../src/age
|
|||||||
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
|
const auth = { authorization: `Bearer ${process.env.ADMIN_TOKEN}` }, key = 'runtime-private-test-key';
|
||||||
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
|
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'résumé.txt',mimeType:'text/plain',sizeBytes:4,sha256:'a'.repeat(64),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/résumé.txt' };
|
||||||
const bodies: any[] = [];
|
const bodies: any[] = [];
|
||||||
let capable=true, badReceipt=false, errorStatus=0, uploadCalls=0;
|
let capable=true, assistantImages=false, badReceipt=false, errorStatus=0, uploadCalls=0;
|
||||||
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
|
let holdDescriptor=false, holdDownload=false;const held:ServerResponse[]=[], closed:ServerResponse[]=[];
|
||||||
const source=createServer(async(req,res)=>{
|
const source=createServer(async(req,res)=>{
|
||||||
assert.equal(req.headers.authorization,`Bearer ${key}`);
|
assert.equal(req.headers.authorization,`Bearer ${key}`);
|
||||||
const chunks: Buffer[]=[];
|
const chunks: Buffer[]=[];
|
||||||
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
|
try { for await(const chunk of req) chunks.push(chunk); } catch { return; }
|
||||||
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
|
const bytes=Buffer.concat(chunks);res.setHeader('Content-Type','application/json');
|
||||||
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',...(capable?{capabilities:{attachments:{version:1}}}:{})}));return;}
|
if(req.url==='/api/conversations/root'){if(holdDescriptor){held.push(res);res.once('close',()=>closed.push(res));return;}res.end(JSON.stringify({id:'1',capabilities:{...(capable?{attachments:{version:1}}:{}),...(assistantImages?{assistantImages:{version:1}}:{})}}));return;}
|
||||||
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
|
if(errorStatus){res.statusCode=errorStatus;res.end(JSON.stringify({error:true,message:'Storage quota reached',statusCode:errorStatus,code:'attachment_quota_exceeded',remainingBytes:12,quotaBytes:1024,requiredBytes:99}));return;}
|
||||||
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
|
if(req.url?.endsWith('/attachments')){uploadCalls++;assert.match(req.headers['content-type']!,/^multipart\/form-data; boundary=/);assert.ok(bytes.includes(Buffer.from('résumé.txt')));bodies.push(bytes.toString());res.statusCode=201;res.end(JSON.stringify({attachment:file}));return;}
|
||||||
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
|
if(req.url?.endsWith('/content')){if(holdDownload){held.push(res);res.once('close',()=>closed.push(res));return;}res.writeHead(200,{'Content-Type':'text/plain; charset=utf-8','Content-Length':'4','Content-Disposition':"attachment; filename=notes.txt; filename*=UTF-8''notes.txt"});res.end('data');return;}
|
||||||
@@ -57,6 +57,22 @@ test('multipart forwards Unicode filename/uploadId with private auth; quota deta
|
|||||||
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
|
assert.equal((await app.inject({url:'/v1/agent/attachments/not-an-id/content',headers:auth})).statusCode,404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('assistant-only capability allows authenticated retrieval without granting input uploads or open history',async()=>{
|
||||||
|
capable=false;assistantImages=true;
|
||||||
|
const saved=env.ADMIN_TOKEN;
|
||||||
|
try {
|
||||||
|
const descriptor=(await app.inject({url:'/v1/agent',headers:auth})).json();
|
||||||
|
assert.deepEqual(descriptor.capabilities,{assistantImages:{version:1}});
|
||||||
|
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`,{headers:auth})).status,200);
|
||||||
|
assert.equal((await fetch(base+`/v1/agent/attachments/${file.id}/content`)).status,401);
|
||||||
|
assert.equal((await fetch(base+'/v1/agent/attachments',{method:'POST',headers:auth,body:form()})).status,503);
|
||||||
|
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload:{content:'',requestId:'assistant-only',attachments:[{id:file.id}]}})).statusCode,503);
|
||||||
|
assert.equal((await app.inject({method:'POST',url:'/v1/agent/output_image',headers:auth,payload:{path:'https://never-fetch.invalid/image'}})).statusCode,404);
|
||||||
|
env.ADMIN_TOKEN=undefined;
|
||||||
|
for(const url of ['/v1/agent','/v1/agent/messages',`/v1/agent/attachments/${file.id}/content`]) assert.equal((await app.inject({url})).statusCode,503);
|
||||||
|
} finally {env.ADMIN_TOKEN=saved;capable=true;assistantImages=false;}
|
||||||
|
});
|
||||||
|
|
||||||
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
|
test('ID-only sends require matching receipts; history retains attachment metadata and raw tools',async()=>{
|
||||||
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
|
const payload={content:'',requestId:'send',attachments:[{id:file.id}]};
|
||||||
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
|
assert.equal((await app.inject({method:'POST',url:'/v1/agent/messages',headers:auth,payload})).statusCode,202);assert.deepEqual(bodies.at(-1),{...payload,channel:'sybil',whenBusy:'followUp',wait:false});
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ test('preview lookup uses only the exact eligible completed root assistant conte
|
|||||||
]) { page = invalid; await assert.rejects(load(candidate)); }
|
]) { page = invalid; await assert.rejects(load(candidate)); }
|
||||||
page = { ...original, messages: [{ ...message, content: '' }] };
|
page = { ...original, messages: [{ ...message, content: '' }] };
|
||||||
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
|
assert.equal(await load(candidate), '', 'empty content never borrows metadata, tool output or reasoning');
|
||||||
|
page = { ...original, messages: [{ ...message, content: 'Caption stays inside authenticated chat.', metadata: { ...message.metadata,
|
||||||
|
agentAttachments: [{ filename: 'private-name.png', workspacePath: '/private/source.png', url: 'https://never-fetch.invalid/image' }] } }] };
|
||||||
|
const imageText = await load(candidate);
|
||||||
|
assert.equal(imageText, '', 'image captions and metadata use only a generic notification');
|
||||||
|
assert.deepEqual(JSON.parse(previewPayload(imageText, body => ({ body }), 4096).toString()), { body: 'You have a new message.' });
|
||||||
const before = requests.length;
|
const before = requests.length;
|
||||||
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
|
for (const messageId of ['tool:1:11', 'outbound:12', 'https://never-fetch.invalid/', 'entry:11:0/../../state']) {
|
||||||
await assert.rejects(load({ ...candidate, messageId }));
|
await assert.rejects(load({ ...candidate, messageId }));
|
||||||
|
|||||||
+3
-3
@@ -3554,7 +3554,7 @@ export default function App() {
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="agent-sidebar-item"
|
data-testid="agent-sidebar-item"
|
||||||
aria-current={isAgentMode ? "page" : undefined}
|
aria-current={isAgentMode ? "page" : undefined}
|
||||||
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount} unread messages` : ""}${isAgentBusy ? ", working" : ""}`}
|
aria-label={`Sybil Agent${agentRoom.unreadCount ? `, ${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? " or more" : ""} unread messages` : agentRoom.unreadCountIsPartial ? ", earlier messages may be unread" : ""}${isAgentBusy ? ", working" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setContextMenu(null);
|
setContextMenu(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -3580,9 +3580,9 @@ export default function App() {
|
|||||||
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
<span className="block truncate text-sm font-semibold">Sybil Agent</span>
|
||||||
</span>
|
</span>
|
||||||
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
{isAgentBusy ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-cyan-200" aria-label="Agent working" /> : null}
|
||||||
{agentRoom.unreadCount > 0 ? (
|
{agentRoom.unreadCount > 0 || agentRoom.unreadCountIsPartial ? (
|
||||||
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
<span data-testid="agent-unread-badge" className="flex h-5 min-w-5 shrink-0 items-center justify-center rounded-full bg-cyan-200 px-1.5 text-[11px] font-bold tabular-nums text-slate-950">
|
||||||
{agentRoom.unreadCount}
|
{agentRoom.unreadCount ? `${agentRoom.unreadCount}${agentRoom.unreadCountIsPartial ? "+" : ""}` : "•"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,19 +1,20 @@
|
|||||||
import { useEffect, useRef, useState } from "preact/hooks";
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||||
import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact";
|
import { Copy, Download, FileText, Image, LoaderCircle } from "lucide-preact";
|
||||||
import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
|
import { attachmentSize, type AgentAttachment } from "@/lib/agent-attachments";
|
||||||
|
|
||||||
type Props = { file: AgentAttachment; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
|
type Props = { file: AgentAttachment; inline?: boolean; download(file: AgentAttachment, signal?: AbortSignal): Promise<Blob> };
|
||||||
/** Authenticated bytes only; no native credential, remote href, or public file serving. */
|
/** Authenticated bytes only; no native credential, remote href, or public file serving. */
|
||||||
export function AgentAttachmentCard({ file, download }: Props) {
|
export function AgentAttachmentCard({ file, download, inline = false }: Props) {
|
||||||
const [preview, setPreview] = useState<string>();
|
const [preview, setPreview] = useState<string>();
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const controller = useRef<AbortController>();
|
const controller = useRef<AbortController>();
|
||||||
useEffect(() => () => { controller.current?.abort(); }, []);
|
const transferring = useRef(false);
|
||||||
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
|
useEffect(() => () => { if (preview) URL.revokeObjectURL(preview); }, [preview]);
|
||||||
const retrieve = async (show: boolean) => {
|
const retrieve = useCallback(async (show: boolean) => {
|
||||||
if (busy) return;
|
if (transferring.current) return;
|
||||||
|
transferring.current = true;
|
||||||
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
|
const request = new AbortController(); controller.current = request; setBusy(true); setError(undefined);
|
||||||
try {
|
try {
|
||||||
const blob = await download(file, request.signal); if (request.signal.aborted) return;
|
const blob = await download(file, request.signal); if (request.signal.aborted) return;
|
||||||
@@ -24,14 +25,22 @@ export function AgentAttachmentCard({ file, download }: Props) {
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
}
|
}
|
||||||
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
|
} catch (failure) { if (!request.signal.aborted) setError(failure instanceof Error ? failure.message : "Download failed."); }
|
||||||
finally { if (!request.signal.aborted) setBusy(false); }
|
finally { if (controller.current === request) { transferring.current = false; if (!request.signal.aborted) setBusy(false); } }
|
||||||
};
|
}, [download, file.id, file.sha256, file.mimeType, file.kind, file.sizeBytes, file.filename]);
|
||||||
|
useEffect(() => {
|
||||||
|
setPreview(undefined); setError(undefined); setBusy(false); transferring.current = false;
|
||||||
|
if (inline && file.kind === "image") void retrieve(true);
|
||||||
|
return () => { controller.current?.abort(); };
|
||||||
|
}, [inline, retrieve]);
|
||||||
return <div className="agent-attachment">
|
return <div className="agent-attachment">
|
||||||
<div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div>
|
<div className="agent-attachment__title"><FileText size={15} /><span>{file.filename}</span><small>{attachmentSize(file.sizeBytes)}</small></div>
|
||||||
{file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null}
|
{!inline && file.workspacePath ? <div className="agent-attachment__path"><code title={file.workspacePath}>{file.workspacePath}</code><button type="button" aria-label={`Copy path for ${file.filename}`} title={copied ? "Copied" : "Copy workspace path"} onClick={() => { void navigator.clipboard.writeText(file.workspacePath!).then(() => setCopied(true)).catch(() => setError("Could not copy the path.")); }}><Copy size={13} /></button></div> : null}
|
||||||
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
|
{inline ? <div className="agent-attachment__image-frame" aria-busy={busy}>
|
||||||
|
{preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} onError={() => { setPreview(undefined); setError("Image could not be displayed."); }} />
|
||||||
|
: <span role="status">{error ? "Image unavailable" : "Loading image…"}</span>}
|
||||||
|
</div> : preview ? <img className="agent-attachment__preview" src={preview} alt={file.filename} /> : null}
|
||||||
<div className="agent-attachment__actions">
|
<div className="agent-attachment__actions">
|
||||||
{file.kind === "image" ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{preview ? "Hide preview" : "Preview"}</button> : null}
|
{file.kind === "image" && (!inline || error) ? <button type="button" disabled={busy} onClick={() => preview ? setPreview(undefined) : void retrieve(true)}><Image size={13} />{inline ? "Retry image" : preview ? "Hide preview" : "Preview"}</button> : null}
|
||||||
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
|
<button type="button" disabled={busy} onClick={() => void retrieve(false)}>{busy ? <LoaderCircle size={13} className="animate-spin" /> : <Download size={13} />}Download</button>
|
||||||
</div>
|
</div>
|
||||||
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
|
{error ? <span className="agent-attachment__error" role="alert">{error} Retry using the buttons above.</span> : null}
|
||||||
|
|||||||
@@ -45,6 +45,32 @@
|
|||||||
.agent-room__entry {
|
.agent-room__entry {
|
||||||
flex-shrink:0;
|
flex-shrink:0;
|
||||||
min-width:0;
|
min-width:0;
|
||||||
|
/* Retain chronological DOM/selection/a11y; skip offscreen layout and paint.
|
||||||
|
This is not DOM virtualization. Remember measured heights when skipped. */
|
||||||
|
content-visibility:auto;
|
||||||
|
contain-intrinsic-size:auto 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__history {
|
||||||
|
display:grid;
|
||||||
|
justify-items:center;
|
||||||
|
gap:8px;
|
||||||
|
flex-shrink:0;
|
||||||
|
color:hsl(255 19% 70%);
|
||||||
|
font-size:12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__history button {
|
||||||
|
border:1px solid hsl(267 75% 73%/.2);
|
||||||
|
border-radius:8px;
|
||||||
|
padding:8px 14px;
|
||||||
|
background:hsl(261 52% 20%/.56);
|
||||||
|
cursor:pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-room__history button:disabled {
|
||||||
|
cursor:wait;
|
||||||
|
opacity:.65;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-room__day {
|
.agent-room__day {
|
||||||
@@ -238,31 +264,6 @@
|
|||||||
font-size:9px;
|
font-size:9px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.agent-room__jump {
|
|
||||||
position:absolute;
|
|
||||||
z-index:2;
|
|
||||||
left:50%;
|
|
||||||
bottom:14px;
|
|
||||||
transform:translateX(-50%);
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
gap:7px;
|
|
||||||
white-space:nowrap;
|
|
||||||
padding:9px 14px;
|
|
||||||
border:1px solid hsl(266 76% 72%/.43);
|
|
||||||
border-radius:99px;
|
|
||||||
background:hsl(256 46% 20%/.96);
|
|
||||||
color:hsl(263 60% 93%);
|
|
||||||
box-shadow:0 5px 24px hsl(240 70% 2%/.5);
|
|
||||||
font-size:11px;
|
|
||||||
cursor:pointer;
|
|
||||||
backdrop-filter:blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-room__jump:hover {
|
|
||||||
background:hsl(261 48% 27%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-room__composer-area {
|
.agent-room__composer-area {
|
||||||
flex:none;
|
flex:none;
|
||||||
border-top:1px solid hsl(253 40% 40%/.14);
|
border-top:1px solid hsl(253 40% 40%/.14);
|
||||||
@@ -475,4 +476,6 @@
|
|||||||
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
|
.agent-attachment__actions button { display: flex; align-items: center; gap: 5px; color: #ddd6fe; }
|
||||||
.agent-attachment__actions button:disabled { opacity: .5; }
|
.agent-attachment__actions button:disabled { opacity: .5; }
|
||||||
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
|
.agent-attachment__preview { display: block; max-height: 260px; max-width: 100%; margin-top: 8px; border-radius: 6px; }
|
||||||
|
.agent-attachment__image-frame { display: flex; align-items: center; justify-content: center; height: 260px; width: 480px; max-width: 100%; margin-top: 8px; }
|
||||||
|
.agent-attachment__image-frame .agent-attachment__preview { width: 100%; height: 100%; object-fit: contain; margin: 0; }
|
||||||
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
|
.agent-attachment__error { display: block; width: 100%; color: #fda4af; font-size: 12px; margin-top: 5px; }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
import { ArrowDown, Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
import { memo } from "preact/compat";
|
||||||
|
import { Clock3, LoaderCircle, Menu, Paperclip, X, RefreshCw, SendHorizontal, WifiOff } from "lucide-preact";
|
||||||
import type { Message } from "@/lib/api";
|
import type { Message } from "@/lib/api";
|
||||||
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
import type { AgentRoomHookResult } from "@/hooks/use-agent-room";
|
||||||
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
|
import { agentTranscriptMessages } from "@/lib/agent-room-presentation";
|
||||||
@@ -49,7 +50,7 @@ function toolName(value: unknown) {
|
|||||||
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
return typeof value === "string" ? value.replace(/_/g, " ") : "Tool";
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
const AgentBubble = memo(function AgentBubble({ message, animate, onRetry, download }: { message: Message; animate: boolean; onRetry: (id: string) => void; download: AgentRoomHookResult["downloadAttachment"] }) {
|
||||||
const own = message.role === "user";
|
const own = message.role === "user";
|
||||||
const info = metadata(message);
|
const info = metadata(message);
|
||||||
const status = typeof info.status === "string" ? info.status : "complete";
|
const status = typeof info.status === "string" ? info.status : "complete";
|
||||||
@@ -60,8 +61,8 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message
|
|||||||
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
{!own ? <span className="agent-room__message-avatar" aria-hidden="true"><img src="/character-still.png" alt="" draggable={false} /></span> : null}
|
||||||
<div className="agent-room__message-content">
|
<div className="agent-room__message-content">
|
||||||
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
<div className={`message-bubble${own ? " message-bubble--user" : ""}`}>
|
||||||
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab />}
|
{own ? <p className="agent-room__plain-message">{message.content}</p> : <MarkdownContent markdown={message.content} openLinksInNewTab allowImages={false} />}
|
||||||
{agentAttachments(message.metadata).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} />)}
|
{agentAttachments(message.metadata, message.role).map(file => <AgentAttachmentCard key={file.id} file={file} download={download} inline={message.role === "assistant"} />)}
|
||||||
</div>
|
</div>
|
||||||
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
{own && failed && typeof info.error === "string" ? <div className="agent-attachment__error" role="alert">{info.error}</div> : null}
|
||||||
<div className="agent-room__message-meta">
|
<div className="agent-room__message-meta">
|
||||||
@@ -77,7 +78,7 @@ function AgentBubble({ message, animate, onRetry, download }: { message: Message
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Props) {
|
||||||
const [draft, setDraft] = useState(savedDraft);
|
const [draft, setDraft] = useState(savedDraft);
|
||||||
@@ -98,7 +99,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
const entryAnimationsRef = useRef(new Set<Animation>());
|
const entryAnimationsRef = useRef(new Set<Animation>());
|
||||||
const typingHeightRef = useRef(0);
|
const typingHeightRef = useRef(0);
|
||||||
const historyReadyRef = useRef(!room.isLoading);
|
const historyReadyRef = useRef(!room.isLoading);
|
||||||
const [atBottom, setAtBottom] = useState(true);
|
const historyBeforeRef = useRef(room.historyBeforeCursor);
|
||||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -131,9 +132,20 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
const element = scrollerRef.current;
|
const element = scrollerRef.current;
|
||||||
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
if (!element || pinnedRef.current) { anchorRef.current = null; return; }
|
||||||
const top = element.getBoundingClientRect().top;
|
const top = element.getBoundingClientRect().top;
|
||||||
// Anchor to layout, independently of the bubble's entrance transform.
|
// Find the first visible row without laying out every offscreen message.
|
||||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.getBoundingClientRect().bottom > top + 8);
|
const entries = element.querySelectorAll<HTMLElement>("[data-agent-entry-id]");
|
||||||
anchorRef.current = node ? { id: node.dataset.agentEntryId!, offset: node.getBoundingClientRect().top - top } : null;
|
// Probe row boxes without measuring every skipped subtree in a long history.
|
||||||
|
let low = 0, high = entries.length;
|
||||||
|
while (low < high) {
|
||||||
|
const mid = (low + high) >>> 1;
|
||||||
|
if (entries[mid].getBoundingClientRect().bottom <= top + 8) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
const node = entries[low];
|
||||||
|
// The first row can lose its day separator when older rows arrive. Anchor
|
||||||
|
// the message itself so that changing that separator cannot shift its text.
|
||||||
|
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||||
|
anchorRef.current = node && message ? { id: node.dataset.agentEntryId!, offset: message.getBoundingClientRect().top - top } : null;
|
||||||
};
|
};
|
||||||
const restoreViewport = () => {
|
const restoreViewport = () => {
|
||||||
const element = scrollerRef.current;
|
const element = scrollerRef.current;
|
||||||
@@ -141,17 +153,19 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
if (pinnedRef.current) element.scrollTop = element.scrollHeight;
|
||||||
else if (anchorRef.current) {
|
else if (anchorRef.current) {
|
||||||
const anchor = anchorRef.current;
|
const anchor = anchorRef.current;
|
||||||
const node = Array.from(element.querySelectorAll<HTMLElement>("[data-agent-entry-id]")).find(item => item.dataset.agentEntryId === anchor.id);
|
const node = element.querySelector<HTMLElement>(`[data-agent-entry-id="${CSS.escape(anchor.id)}"]`);
|
||||||
if (node) element.scrollTop += node.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
const message = node?.querySelector<HTMLElement>("[data-agent-message-id]");
|
||||||
|
if (message) element.scrollTop += message.getBoundingClientRect().top - element.getBoundingClientRect().top - anchor.offset;
|
||||||
}
|
}
|
||||||
restoredScrollTopRef.current = element.scrollTop;
|
restoredScrollTopRef.current = element.scrollTop;
|
||||||
rememberAnchor();
|
// Keep the user's key/offset through successive intrinsic-size corrections.
|
||||||
|
// Recapturing here can anchor a newly revealed older row while its neighbors
|
||||||
|
// are still being measured, causing the original reading position to drift.
|
||||||
scheduleRead();
|
scheduleRead();
|
||||||
};
|
};
|
||||||
const jumpToLatest = () => {
|
const jumpToLatest = () => {
|
||||||
pinnedRef.current = true;
|
pinnedRef.current = true;
|
||||||
anchorRef.current = null;
|
anchorRef.current = null;
|
||||||
setAtBottom(true);
|
|
||||||
if (scrollerRef.current) {
|
if (scrollerRef.current) {
|
||||||
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
||||||
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
restoredScrollTopRef.current = scrollerRef.current.scrollTop;
|
||||||
@@ -197,7 +211,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
restoreViewport();
|
restoreViewport();
|
||||||
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, isVisible]);
|
}, [room.messages, room.isTyping, room.activity?.state, room.isLoading, room.isLoadingOlder, room.olderError, room.hasOlder, isVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mountedRef.current = true;
|
mountedRef.current = true;
|
||||||
@@ -231,10 +245,10 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
setDraft(value);
|
setDraft(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryMessage = (id: string) => {
|
const retryMessage = useCallback((id: string) => {
|
||||||
// Admission errors remain attached to their own bubble.
|
// Admission errors remain attached to their own bubble.
|
||||||
void room.retryMessage(id).catch(() => {});
|
void room.retryMessage(id).catch(() => {});
|
||||||
};
|
}, [room.retryMessage]);
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
const content = draftRef.current.trim();
|
const content = draftRef.current.trim();
|
||||||
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
|
if ((!content && !uploads.length) || pendingUploads || (uploads.length && !room.attachmentCapability)) return;
|
||||||
@@ -247,9 +261,14 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
|
|
||||||
const messages = useMemo(() => {
|
const messages = useMemo(() => {
|
||||||
const transcript = agentTranscriptMessages(room.messages);
|
const transcript = agentTranscriptMessages(room.messages);
|
||||||
|
const historyChanged = historyBeforeRef.current !== room.historyBeforeCursor;
|
||||||
|
historyBeforeRef.current = room.historyBeforeCursor;
|
||||||
let previousDay = "";
|
let previousDay = "";
|
||||||
for (const message of transcript) {
|
let lastSeen = -1;
|
||||||
if (!seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
transcript.forEach((message, index) => { if (seenMessageIdsRef.current.has(message.id)) lastSeen = index; });
|
||||||
|
for (const [index, message] of transcript.entries()) {
|
||||||
|
// Historical prepends keep their measured space; only new tail rows enter.
|
||||||
|
if (index > lastSeen && !(historyChanged && lastSeen < 0) && !seenMessageIdsRef.current.has(message.id) && historyReadyRef.current && !room.isLoading) {
|
||||||
enteringMessageIdsRef.current.add(message.id);
|
enteringMessageIdsRef.current.add(message.id);
|
||||||
}
|
}
|
||||||
seenMessageIdsRef.current.add(message.id);
|
seenMessageIdsRef.current.add(message.id);
|
||||||
@@ -265,7 +284,7 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [room.messages, room.isLoading, room.retryMessage, room.downloadAttachment]);
|
}, [room.messages, room.isLoading, room.historyBeforeCursor, room.retryMessage, room.downloadAttachment]);
|
||||||
|
|
||||||
const activity = room.activity;
|
const activity = room.activity;
|
||||||
const running = room.isTyping;
|
const running = room.isTyping;
|
||||||
@@ -305,19 +324,23 @@ export function AgentConversation({ room, onOpenSidebar, isVisible = true }: Pro
|
|||||||
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
|
&& Math.abs(scrollerRef.current.scrollTop - restoredScrollTopRef.current) < 1) return;
|
||||||
const bottom = distanceFromBottom() <= 48;
|
const bottom = distanceFromBottom() <= 48;
|
||||||
pinnedRef.current = bottom;
|
pinnedRef.current = bottom;
|
||||||
setAtBottom(current => current === bottom ? current : bottom);
|
|
||||||
rememberAnchor();
|
rememberAnchor();
|
||||||
if (bottom) scheduleRead();
|
if (bottom) scheduleRead();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={timelineRef} className="agent-room__timeline">
|
<div ref={timelineRef} className="agent-room__timeline">
|
||||||
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
{room.isLoading && !messages.length ? <div className="agent-room__empty" role="status"><LoaderCircle className="h-5 w-5 animate-spin" aria-hidden="true" /><span className="sr-only">Loading conversation</span></div> : null}
|
||||||
|
{room.hasOlder ? <div className="agent-room__history">
|
||||||
|
<button type="button" disabled={room.isLoadingOlder} onClick={() => {
|
||||||
|
pinnedRef.current = false; rememberAnchor(); room.loadOlder();
|
||||||
|
}}>{room.isLoadingOlder ? "Loading older messages…" : "Load older messages"}</button>
|
||||||
|
{room.olderError ? <span role="alert">{room.olderError} Try loading again.</span> : null}
|
||||||
|
</div> : null}
|
||||||
{messages}
|
{messages}
|
||||||
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
{running ? <div className="agent-room__typing" role="status" aria-live="polite"><span className="agent-room__typing-dots" aria-hidden="true"><i /><i /><i /></span><span>{workingLabel}</span>{(activity?.backgroundWorkers || 0) > 1 ? <span className="agent-room__worker-count">{activity!.backgroundWorkers} workers</span> : null}</div> : null}
|
||||||
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
{!running && waiting && activity?.wait ? <div className="agent-room__waiting" role="status"><Clock3 size={13} /><span>Checking back at {timeFormat.format(new Date(activity.wait.due))}</span></div> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!atBottom ? <button className="agent-room__jump" onClick={jumpToLatest}><ArrowDown size={15} /><span>{room.unreadCount ? `${room.unreadCount} new message${room.unreadCount === 1 ? "" : "s"}` : "Jump to latest"}</span></button> : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="agent-room__composer-area"
|
<div className="agent-room__composer-area"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type Props = {
|
|||||||
mode?: MarkdownMode;
|
mode?: MarkdownMode;
|
||||||
resolveCitationIndex?: (href: string) => number | undefined;
|
resolveCitationIndex?: (href: string) => number | undefined;
|
||||||
openLinksInNewTab?: boolean;
|
openLinksInNewTab?: boolean;
|
||||||
|
/** Agent images are delivered only through authenticated attachment IDs. */
|
||||||
|
allowImages?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
|
function replaceMarkdownLinksWithCitationTokens(markdown: string, resolveCitationIndex?: (href: string) => number | undefined) {
|
||||||
@@ -35,24 +37,28 @@ function setNewTabLinkAttributes(currentNode: Element) {
|
|||||||
currentNode.setAttribute("rel", "noopener noreferrer");
|
currentNode.setAttribute("rel", "noopener noreferrer");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMarkdown(markdown: string, openLinksInNewTab: boolean) {
|
function renderMarkdown(markdown: string, openLinksInNewTab: boolean, allowImages: boolean) {
|
||||||
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
const rawHtml = marked.parse(markdown, { gfm: true, breaks: true, renderer: markdownRenderer }) as string;
|
||||||
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
const config = { ADD_ATTR: ["class", "target", "rel"], ...(!allowImages ? {
|
||||||
|
FORBID_TAGS: ["img", "picture", "source", "video", "audio", "svg", "math", "style"],
|
||||||
|
FORBID_ATTR: ["style", "srcset", "background", "poster"],
|
||||||
|
} : {}) };
|
||||||
|
if (!openLinksInNewTab) return DOMPurify.sanitize(rawHtml, config);
|
||||||
|
|
||||||
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
DOMPurify.addHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||||
try {
|
try {
|
||||||
return DOMPurify.sanitize(rawHtml, { ADD_ATTR: ["class", "target", "rel"] });
|
return DOMPurify.sanitize(rawHtml, config);
|
||||||
} finally {
|
} finally {
|
||||||
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
DOMPurify.removeHook("afterSanitizeAttributes", setNewTabLinkAttributes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false }: Props) {
|
export function MarkdownContent({ markdown, className, mode = "default", resolveCitationIndex, openLinksInNewTab = false, allowImages = true }: Props) {
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
const prepared =
|
const prepared =
|
||||||
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
|
mode === "citationTokens" ? replaceMarkdownLinksWithCitationTokens(markdown, resolveCitationIndex) : markdown;
|
||||||
return renderMarkdown(prepared, openLinksInNewTab);
|
return renderMarkdown(prepared, openLinksInNewTab, allowImages);
|
||||||
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex]);
|
}, [markdown, mode, openLinksInNewTab, resolveCitationIndex, allowImages]);
|
||||||
|
|
||||||
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
return <div className={cn("md-content", className)} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"
|
|||||||
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
import { getApiBaseUrl, getAuthToken, type Message } from "@/lib/api";
|
||||||
import {
|
import {
|
||||||
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
AGENT_ADMISSION_STORAGE_KEY, AgentRoomClient, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||||
applyAgentEvent, applyAgentReadState,
|
applyAgentEvent, applyAgentReadState, applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||||
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
createAgentReadSession, createAgentRoomState, parseAgentRoomEvent, syncAgentHistory,
|
||||||
type AgentActivity, type AgentRoomState,
|
type AgentActivity, type AgentRoomState,
|
||||||
} from "@/lib/agent-room";
|
} from "@/lib/agent-room";
|
||||||
@@ -16,6 +16,7 @@ export type AgentRoomConnection = "connecting" | "connected" | "reconnecting" |
|
|||||||
export type AgentRoomHookResult = {
|
export type AgentRoomHookResult = {
|
||||||
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
messages: Message[]; activity: AgentActivity | null; unreadCount: number;
|
||||||
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
|
connection: AgentRoomConnection; error: string | null; isLoading: boolean; isTyping: boolean;
|
||||||
|
hasOlder: boolean; historyBeforeCursor: string | null; isLoadingOlder: boolean; olderError: string | null; loadOlder(): void; unreadCountIsPartial: boolean;
|
||||||
attachmentCapability?: AgentAttachmentCapability;
|
attachmentCapability?: AgentAttachmentCapability;
|
||||||
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
uploadAttachment(file: File, uploadId: string, signal: AbortSignal): Promise<AgentAttachment>;
|
||||||
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
|
downloadAttachment(file: AgentAttachment, signal?: AbortSignal): Promise<Blob>;
|
||||||
@@ -33,6 +34,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
|
const [attachmentCapability, setAttachmentCapability] = useState<AgentAttachmentCapability>();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [isLoading, setLoading] = useState(enabled);
|
const [isLoading, setLoading] = useState(enabled);
|
||||||
|
const [isLoadingOlder, setLoadingOlder] = useState(false);
|
||||||
|
const [olderError, setOlderError] = useState<string | null>(null);
|
||||||
|
const older = useRef<(() => Promise<void>) | null>(null);
|
||||||
const [revision, setRevision] = useState(0);
|
const [revision, setRevision] = useState(0);
|
||||||
const client = useRef<AgentRoomClient | null>(null);
|
const client = useRef<AgentRoomClient | null>(null);
|
||||||
const [, renderUploads] = useState(0);
|
const [, renderUploads] = useState(0);
|
||||||
@@ -81,9 +85,10 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoadingOlder(false); setOlderError(null);
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
sourceMismatch.current = false;
|
sourceMismatch.current = false;
|
||||||
client.current = null; mark.current = null; setAttachmentCapability(undefined);
|
client.current = null; mark.current = null; older.current = null; setAttachmentCapability(undefined);
|
||||||
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
update(() => createAgentRoomState()); setLoading(false); setConnection("offline"); setError(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -96,6 +101,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
let reconnectAttempt = 0;
|
let reconnectAttempt = 0;
|
||||||
let started = false;
|
let started = false;
|
||||||
let maintaining = false;
|
let maintaining = false;
|
||||||
|
let loadingOlder = false;
|
||||||
let lastPacket = Date.now();
|
let lastPacket = Date.now();
|
||||||
let socketAuthenticated = false;
|
let socketAuthenticated = false;
|
||||||
let authFailed = false;
|
let authFailed = false;
|
||||||
@@ -110,7 +116,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
setView({ room: next, isTyping });
|
setView({ room: next, isTyping });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setLoading(current.current.conversationId === null); setConnection("connecting"); setError(null);
|
setLoading(!current.current.historyLoaded); setConnection("connecting"); setError(null);
|
||||||
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
const reportError = (failure: unknown) => { if (active && !controller.signal.aborted) setError(errorText(failure)); };
|
||||||
const readSession = createAgentReadSession({
|
const readSession = createAgentReadSession({
|
||||||
initial: current.current,
|
initial: current.current,
|
||||||
@@ -119,6 +125,7 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
sourceChanged = true; sourceMismatch.current = true; mark.current = null;
|
||||||
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
clearTimeout(reconnectTimer); clearTimeout(startupTimer); clearTimeout(socketTimeout);
|
||||||
presenter.dispose(); controller.abort(); socket?.close();
|
presenter.dispose(); controller.abort(); socket?.close();
|
||||||
|
older.current = null; setLoadingOlder(false);
|
||||||
setError(failure.message); setConnection("offline"); setLoading(false);
|
setError(failure.message); setConnection("offline"); setLoading(false);
|
||||||
},
|
},
|
||||||
apply: read => {
|
apply: read => {
|
||||||
@@ -139,6 +146,22 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
const descriptor = await api.descriptor(controller.signal);
|
const descriptor = await api.descriptor(controller.signal);
|
||||||
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
if (active && !sourceChanged) { setAttachmentCapability(descriptor.capabilities?.attachments?.version === 1 ? descriptor.capabilities.attachments : undefined); presenter.push({ type: "activity", conversationId: descriptor.id, activity: descriptor.activity }); }
|
||||||
};
|
};
|
||||||
|
older.current = async () => {
|
||||||
|
const before = current.current.beforeCursor;
|
||||||
|
if (!active || sourceChanged || !started || loadingOlder || !current.current.hasOlder || before === null) return;
|
||||||
|
loadingOlder = true; setLoadingOlder(true); setOlderError(null);
|
||||||
|
try {
|
||||||
|
const page = await api.history(before, controller.signal);
|
||||||
|
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||||
|
presenter.update(state => applyAgentHistoryPage(state, page, before));
|
||||||
|
presenter.flush();
|
||||||
|
} catch (failure) {
|
||||||
|
if (active && !sourceChanged && !controller.signal.aborted) setOlderError(errorText(failure));
|
||||||
|
} finally {
|
||||||
|
loadingOlder = false;
|
||||||
|
if (active && !sourceChanged) setLoadingOlder(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!active || authFailed || sourceChanged) return;
|
if (!active || authFailed || sourceChanged) return;
|
||||||
@@ -191,9 +214,11 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
const start = async () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
await Promise.all([refreshRead(), refreshActivity()]);
|
await Promise.all([refreshRead(), refreshActivity()]);
|
||||||
// The first start begins at zero, not the latest page. Unread counts
|
if (!current.current.historyLoaded) {
|
||||||
// therefore include older public replies even after a long absence.
|
const page = await api.history(undefined, controller.signal);
|
||||||
await catchUp(!started);
|
if (!active || sourceChanged || controller.signal.aborted) return;
|
||||||
|
presenter.update(state => applyAgentHistoryPage(state, page));
|
||||||
|
} else await catchUp(!started);
|
||||||
if (!active || sourceChanged) return;
|
if (!active || sourceChanged) return;
|
||||||
presenter.flush();
|
presenter.flush();
|
||||||
started = true; setLoading(false); connect();
|
started = true; setLoading(false); connect();
|
||||||
@@ -231,18 +256,19 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
window.removeEventListener("focus", focus); document.removeEventListener("visibilitychange", focus);
|
||||||
socket?.close();
|
socket?.close();
|
||||||
if (client.current === api) client.current = null;
|
if (client.current === api) client.current = null;
|
||||||
mark.current = null;
|
mark.current = null; older.current = null;
|
||||||
};
|
};
|
||||||
}, [enabled, revision, update]);
|
}, [enabled, revision, update]);
|
||||||
|
|
||||||
const renderedCursor = room.cursor;
|
const renderedCursor = room.cursor;
|
||||||
const renderedSourceId = room.sourceId;
|
const renderedSourceId = room.sourceId;
|
||||||
|
const hasRenderedHistory = room.messages.some(message => message.role === "user" || message.role === "assistant");
|
||||||
const markRead = useCallback(() => {
|
const markRead = useCallback(() => {
|
||||||
if (!enabled || isLoading) return;
|
if (!enabled || isLoading || !hasRenderedHistory) return;
|
||||||
// This callback belongs to this rendered snapshot. Reading current.current
|
// This callback belongs to this rendered snapshot. Reading current.current
|
||||||
// here would incorrectly mark a socket message that has not rendered yet.
|
// here would incorrectly mark a socket message that has not rendered yet.
|
||||||
mark.current?.(renderedCursor, renderedSourceId);
|
mark.current?.(renderedCursor, renderedSourceId);
|
||||||
}, [enabled, isLoading, renderedCursor, renderedSourceId]);
|
}, [enabled, isLoading, hasRenderedHistory, renderedCursor, renderedSourceId]);
|
||||||
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
const sendMessage = useCallback(async (content: string, attachments?: AgentAttachment[], onPersisted?: () => void) => {
|
||||||
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
if (!enabled || !outbox.current) throw new Error("Agent connection is unavailable.");
|
||||||
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
await outbox.current.send(content, attachments, () => { uploadQueue.current!.clear(); onPersisted?.(); });
|
||||||
@@ -263,7 +289,9 @@ export function useAgentRoom(enabled: boolean): AgentRoomHookResult {
|
|||||||
if (sourceMismatch.current) { window.location.reload(); return; }
|
if (sourceMismatch.current) { window.location.reload(); return; }
|
||||||
setRevision(value => value + 1);
|
setRevision(value => value + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
const loadOlder = useCallback(() => { void older.current?.(); }, []);
|
||||||
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
|
const messages = useMemo(() => outbox.current?.display(agentDisplayMessages(room)) || agentDisplayMessages(room), [room.messages, room.activity, outboxRevision]);
|
||||||
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
|
return { messages: enabled ? messages : [], activity: room.activity, unreadCount: agentUnreadCount(room), connection, error, isLoading,
|
||||||
|
hasOlder: room.hasOlder, historyBeforeCursor: room.beforeCursor, isLoadingOlder, olderError, loadOlder, unreadCountIsPartial: agentUnreadCountIsPartial(room),
|
||||||
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
attachmentCapability, uploadQueue: uploadQueue.current!, uploadAttachment, downloadAttachment, isTyping: enabled && view.isTyping, sendMessage, retryMessage, markRead, retry };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,23 @@ export function parseAgentAttachment(value: unknown): AgentAttachment {
|
|||||||
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
|
return { id: item.id, kind: item.kind, filename: item.filename, mimeType: item.mimeType, sizeBytes: item.sizeBytes, sha256: item.sha256,
|
||||||
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
|
...(item.workspacePath ? { workspacePath: item.workspacePath } : {}) };
|
||||||
}
|
}
|
||||||
export function agentAttachments(metadata: unknown): AgentAttachment[] {
|
export function agentAttachments(metadata: unknown, role = "user"): AgentAttachment[] {
|
||||||
|
if (role !== "user" && role !== "assistant") return [];
|
||||||
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
|
const values = (metadata as { agentAttachments?: unknown } | null)?.agentAttachments;
|
||||||
if (!Array.isArray(values) || values.length > 8) return [];
|
if (!Array.isArray(values) || values.length > 8) return [];
|
||||||
try { return values.map(parseAgentAttachment); } catch { return []; }
|
try {
|
||||||
|
const files = values.map(value => {
|
||||||
|
// Source paths are not part of the assistant output contract, even if an
|
||||||
|
// older/malformed server supplies one. Only the ID authorizes retrieval.
|
||||||
|
const file = parseAgentAttachment(role === "assistant" ? { ...value, workspacePath: undefined } : value);
|
||||||
|
if (role === "assistant" && (file.kind !== "image" || !["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.mimeType)
|
||||||
|
|| file.sizeBytes > 6 * 1024 * 1024 || file.filename.length > 255 || /[\\/\u0000-\u001f\u007f]/.test(file.filename)))
|
||||||
|
throw new Error("Invalid assistant image.");
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
if (new Set(files.map(file => file.id)).size !== files.length || files.reduce((total, file) => total + file.sizeBytes, 0) > 32 * 1024 * 1024) return [];
|
||||||
|
return files;
|
||||||
|
} catch { return []; }
|
||||||
}
|
}
|
||||||
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
|
export function attachmentCaption(content: string, files: AgentAttachment[]): string {
|
||||||
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
|
return content.trim() ? content : `Attached: ${files.map(file => file.filename).join(", ")}.`;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export type AgentRoomPresenterOptions = {
|
|||||||
typingDelayMs?: number;
|
typingDelayMs?: number;
|
||||||
clock?: Scheduler;
|
clock?: Scheduler;
|
||||||
};
|
};
|
||||||
const quietTools = new Set(['user_message', 'notify_parent', 'clock_wait', 'event_decision']);
|
const quietTools = new Set(['user_message', 'output_image', 'notify_parent', 'clock_wait', 'event_decision']);
|
||||||
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
|
const data = (message: Message): Record<string, unknown> => message.metadata && typeof message.metadata === 'object'
|
||||||
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
|
&& !Array.isArray(message.metadata) ? message.metadata as Record<string, unknown> : {};
|
||||||
const entryId = (message: Message): bigint | undefined => {
|
const entryId = (message: Message): bigint | undefined => {
|
||||||
|
|||||||
@@ -18,12 +18,13 @@ export type AgentMessagesPage = {
|
|||||||
};
|
};
|
||||||
export type AgentDescriptor = {
|
export type AgentDescriptor = {
|
||||||
id: string; title: string; model: string; activity: AgentActivity;
|
id: string; title: string; model: string; activity: AgentActivity;
|
||||||
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability };
|
websocket: string; protocol: string; capabilities?: { attachments?: AgentAttachmentCapability; assistantImages?: { version: 1 } };
|
||||||
};
|
};
|
||||||
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
|
export type AgentReadState = { cursor: string; updatedAt: string | null; sourceId?: string | null };
|
||||||
export type AgentRoomState = {
|
export type AgentRoomState = {
|
||||||
conversationId: string | null; messages: Message[]; cursor: string;
|
conversationId: string | null; messages: Message[]; cursor: string;
|
||||||
readCursor: string; activity: AgentActivity | null;
|
readCursor: string; activity: AgentActivity | null;
|
||||||
|
historyLoaded: boolean; beforeCursor: string | null; hasOlder: boolean;
|
||||||
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
/** undefined before the first read snapshot; null pins a legacy source-less session. */
|
||||||
sourceId?: string | null;
|
sourceId?: string | null;
|
||||||
};
|
};
|
||||||
@@ -84,6 +85,7 @@ export function mergeAgentMessages(previous: readonly Message[], incoming: reado
|
|||||||
}
|
}
|
||||||
export const createAgentRoomState = (): AgentRoomState => ({
|
export const createAgentRoomState = (): AgentRoomState => ({
|
||||||
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
conversationId: null, messages: [], cursor: "0", readCursor: "0", activity: null,
|
||||||
|
historyLoaded: false, beforeCursor: null, hasOlder: false,
|
||||||
});
|
});
|
||||||
export class AgentReadSourceChangedError extends Error {
|
export class AgentReadSourceChangedError extends Error {
|
||||||
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
constructor() { super("Agent history changed. Reload the page before marking messages read."); this.name = "AgentReadSourceChangedError"; }
|
||||||
@@ -102,6 +104,25 @@ export function applyAgentPage(state: AgentRoomState, page: AgentMessagesPage):
|
|||||||
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
const cursor = page.cursor === null ? state.cursor : advanceAgentCursor(state.cursor, page.cursor);
|
||||||
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
return { ...state, conversationId: page.conversationId, messages: mergeAgentMessages(state.messages, page.messages), cursor };
|
||||||
}
|
}
|
||||||
|
/** Recent/older windows have their own boundary; live replay never moves it. */
|
||||||
|
export function applyAgentHistoryPage(state: AgentRoomState, page: AgentMessagesPage, before?: string): AgentRoomState {
|
||||||
|
// A retired or repeated older request cannot overwrite a newer window.
|
||||||
|
if (before !== undefined && before !== state.beforeCursor) return state;
|
||||||
|
const boundary = page.beforeCursor === null ? null : normalizeAgentCursor(page.beforeCursor);
|
||||||
|
const cursor = page.cursor === null ? null : normalizeAgentCursor(page.cursor);
|
||||||
|
if (page.hasMore && (boundary === null || boundary === "0"
|
||||||
|
|| (before !== undefined && BigInt(boundary) >= BigInt(before)))) throw new Error("Agent older history did not advance its cursor.");
|
||||||
|
if (before !== undefined && ((cursor !== null && BigInt(cursor) >= BigInt(before))
|
||||||
|
|| (boundary !== null && BigInt(boundary) > BigInt(before)))) throw new Error("Agent older history returned an invalid boundary.");
|
||||||
|
const next = applyAgentPage(state, page);
|
||||||
|
return { ...next, historyLoaded: true, beforeCursor: boundary, hasOlder: page.hasMore,
|
||||||
|
// Older snapshots must not replace newer socket edits or advance delivery/read state.
|
||||||
|
...(before === undefined ? {} : { cursor: state.cursor, messages: mergeAgentMessages(page.messages, state.messages) }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function agentUnreadCountIsPartial(state: AgentRoomState): boolean {
|
||||||
|
return state.hasOlder && state.beforeCursor !== null && BigInt(state.readCursor) + 1n < BigInt(state.beforeCursor);
|
||||||
|
}
|
||||||
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
export function applyAgentReadState(state: AgentRoomState, read: AgentReadState): AgentRoomState {
|
||||||
const sourceId = readSource(state, read);
|
const sourceId = readSource(state, read);
|
||||||
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
return { ...state, sourceId, readCursor: advanceAgentCursor(state.readCursor, read.cursor) };
|
||||||
@@ -154,6 +175,8 @@ export class AgentRoomClient {
|
|||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly fetcher: typeof globalThis.fetch;
|
private readonly fetcher: typeof globalThis.fetch;
|
||||||
private readonly token: () => string | null;
|
private readonly token: () => string | null;
|
||||||
|
private activeDownloads = 0;
|
||||||
|
private readonly downloadWaiters = new Set<() => void>();
|
||||||
constructor(options: AgentRoomClientOptions = {}) {
|
constructor(options: AgentRoomClientOptions = {}) {
|
||||||
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
|
this.baseUrl = (options.baseUrl || "/api").replace(/\/+$/, "");
|
||||||
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
|
this.fetcher = (options.fetch || globalThis.fetch).bind(globalThis);
|
||||||
@@ -178,6 +201,9 @@ export class AgentRoomClient {
|
|||||||
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
messages(after: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||||
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
return this.request(`/messages?${new URLSearchParams({ after: normalizeAgentCursor(after), limit: "200" })}`, { signal });
|
||||||
}
|
}
|
||||||
|
history(before?: string, signal?: AbortSignal): Promise<AgentMessagesPage> {
|
||||||
|
return this.request(`/messages?${new URLSearchParams({ ...(before === undefined ? {} : { before: normalizeAgentCursor(before) }), limit: "100" })}`, { signal });
|
||||||
|
}
|
||||||
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
readState(signal?: AbortSignal): Promise<AgentReadState> { return this.request("/read-state", { signal }); }
|
||||||
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
markRead(cursor: string, signal?: AbortSignal, sourceId?: string | null): Promise<AgentReadState> {
|
||||||
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
return this.request("/read-state", { method: "PUT", body: JSON.stringify({ cursor: normalizeAgentCursor(cursor), ...(agentPushSourceId(sourceId) ? { sourceId } : {}) }), signal });
|
||||||
@@ -199,12 +225,46 @@ export class AgentRoomClient {
|
|||||||
}
|
}
|
||||||
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
|
async download(attachment: AgentAttachment, signal?: AbortSignal): Promise<Blob> {
|
||||||
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
|
const file = parseAgentAttachment(attachment), headers = new Headers(), token = this.token();
|
||||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
signal?.throwIfAborted();
|
||||||
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
|
while (this.activeDownloads >= 2) {
|
||||||
if (!response.ok) { const error = await response.json().catch(() => null); throw new Error(error?.message || "Attachment download failed."); }
|
await new Promise<void>((resolve, reject) => {
|
||||||
const blob = await response.blob();
|
const cleanup = () => { this.downloadWaiters.delete(wake); signal?.removeEventListener("abort", abort); };
|
||||||
if (blob.size !== file.sizeBytes || blob.size > 8 * 1024 * 1024) throw new Error("Attachment download was incomplete.");
|
const wake = () => { cleanup(); resolve(); };
|
||||||
return blob;
|
const abort = () => { cleanup(); reject(signal?.reason || new Error("Download canceled.")); };
|
||||||
|
this.downloadWaiters.add(wake); signal?.addEventListener("abort", abort, { once: true });
|
||||||
|
if (signal?.aborted) abort();
|
||||||
|
});
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
}
|
||||||
|
this.activeDownloads++;
|
||||||
|
try {
|
||||||
|
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||||
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||||
|
const response = await this.fetcher(`${this.baseUrl}/v1/agent/attachments/${encodeURIComponent(file.id)}/content`, { headers, signal, cache: "no-store", redirect: "error" });
|
||||||
|
if (!response.ok) { await response.body?.cancel(); throw new Error("Attachment download failed. Retry the download."); }
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error("Attachment download was incomplete.");
|
||||||
|
const bytes = new Uint8Array(file.sizeBytes); let size = 0;
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read(); if (done) break;
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (size + value.length > bytes.length) throw new Error("Attachment download exceeds its size limit.");
|
||||||
|
bytes.set(value, size); size += value.length;
|
||||||
|
}
|
||||||
|
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
||||||
|
if (size !== file.sizeBytes) throw new Error("Attachment download was incomplete.");
|
||||||
|
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map(n => n.toString(16).padStart(2, "0")).join("");
|
||||||
|
if (sha256 !== file.sha256) throw new Error("Attachment checksum did not match.");
|
||||||
|
if (file.kind === "image" && response.headers.get("content-type")?.split(";")[0].trim() !== file.mimeType)
|
||||||
|
throw new Error("Attachment image type did not match.");
|
||||||
|
signal?.throwIfAborted();
|
||||||
|
if (token !== this.token()) throw new Error("Authentication changed. Retry the download.");
|
||||||
|
return new Blob([bytes], { type: response.headers.get("content-type") || "application/octet-stream" });
|
||||||
|
} finally {
|
||||||
|
this.activeDownloads--;
|
||||||
|
this.downloadWaiters.values().next().value?.();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
websocketUrl(pageUrl: string): string {
|
websocketUrl(pageUrl: string): string {
|
||||||
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
|
const url = new URL(`${this.baseUrl}/v1/agent/ws`, pageUrl);
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ const browser=await chromium.launch({executablePath:process.env.CHROME_EXECUTABL
|
|||||||
...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})});
|
...(process.env.BROWSER_NO_SANDBOX === '1' ? {args:['--no-sandbox']} : {})});
|
||||||
const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']});
|
const context=await browser.newContext({viewport:{width:1200,height:900},permissions:['clipboard-write']});
|
||||||
await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token'));
|
await context.addInitScript(()=>localStorage.setItem('sybil_admin_token','browser-fixture-token'));
|
||||||
const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[];
|
const page=await context.newPage(), errors=[], uploads=[], sends=[], downloads=[], trackingRequests=[];
|
||||||
page.on('pageerror',e=>errors.push(e.stack || e.message));
|
page.on('pageerror',e=>errors.push(e.stack || e.message));
|
||||||
const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1};
|
const cap={version:1,maxCount:8,maxTotalBytes:33554432,maxImageBytes:6291456,maxFileBytes:8388608,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp'],quotaBytes:1073741824,usedBytes:0,remainingBytes:1073741824,maxConcurrentUploads:2,maxConcurrentDecodes:1};
|
||||||
const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]};
|
const idle={state:'idle',isThinking:false,queued:0,backgroundWorkers:0,tools:[],queuedMessages:[]};
|
||||||
let failedUpload=true, failedSend=true, live, cursor=0;const history=[];
|
let failedUpload=true, failedSend=true, live, cursor=0, inputAttachments=true;const history=[];
|
||||||
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64');
|
const image=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a2ioAAAAASUVORK5CYII=','base64');
|
||||||
const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`});
|
const attachment=(name='notes.txt')=>({id:`att_12345678-1234-4234-8234-${name==='picture.png'?'123456789abd':'123456789abc'}`,filename:name,mimeType:name==='picture.png'?'image/png':'text/plain',kind:name==='picture.png'?'image':'text',sizeBytes:name==='picture.png'?image.length:4,sha256:createHash('sha256').update(name==='picture.png'?image:Buffer.from('data')).digest('hex'),workspacePath:`/data/workspace/uploads/test/${name}`});
|
||||||
|
const assistantImage={...attachment('picture.png'),id:'att_12345678-1234-4234-8234-123456789abe',filename:'assistant.png'};delete assistantImage.workspacePath;
|
||||||
|
await page.route('https://never-fetch.invalid/**',async route=>{trackingRequests.push(route.request().url());await route.abort();});
|
||||||
await page.route('**/api/**',async route=>{
|
await page.route('**/api/**',async route=>{
|
||||||
const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value;
|
const req=route.request(),url=new URL(req.url()),p=url.pathname,m=req.method();let value;
|
||||||
if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token');
|
if(p.startsWith('/api/v1/agent'))assert.equal(req.headers().authorization,'Bearer browser-fixture-token');
|
||||||
@@ -26,7 +28,7 @@ await page.route('**/api/**',async route=>{
|
|||||||
if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;}
|
if(failedUpload){failedUpload=false;await route.fulfill({status:507,json:{message:'Storage quota reached',code:'attachment_quota_exceeded'}});return;}
|
||||||
await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return;
|
await route.fulfill({status:201,json:{attachment:attachment(body.includes('picture.png')?'picture.png':'notes.txt')}});return;
|
||||||
}
|
}
|
||||||
if(p.endsWith('/content')){downloads.push(req.url());await route.fulfill({contentType:p.includes('789abd')?'image/png':'text/plain',body:p.includes('789abd')?image:'data'});return;}
|
if(p.endsWith('/content')){downloads.push(req.url());const isImage=p.includes('789abd')||p.includes('789abe');await route.fulfill({contentType:isImage?'image/png':'text/plain',body:isImage?image:'data'});return;}
|
||||||
if(p.endsWith('/agent/messages')&&m==='POST'){
|
if(p.endsWith('/agent/messages')&&m==='POST'){
|
||||||
const body=req.postDataJSON();sends.push(body);
|
const body=req.postDataJSON();sends.push(body);
|
||||||
if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;}
|
if(failedSend){failedSend=false;await route.fulfill({status:503,json:{message:'Temporarily unavailable'}});return;}
|
||||||
@@ -37,7 +39,7 @@ await page.route('**/api/**',async route=>{
|
|||||||
}
|
}
|
||||||
if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false};
|
if(p.endsWith('/agent/messages'))value={conversationId:'1',messages:history,cursor:String(cursor),beforeCursor:null,hasMore:false};
|
||||||
else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null};
|
else if(p.endsWith('/read-state'))value={cursor:String(cursor),updatedAt:null};
|
||||||
else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{attachments:cap},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
|
else if(p.endsWith('/agent'))value={id:'1',title:'Sybil Agent',activity:idle,capabilities:{...(inputAttachments?{attachments:cap}:{}),assistantImages:{version:1}},protocol:'pi-room.v1',websocket:'/v1/agent/ws'};
|
||||||
else if(p.includes('session'))value={mode:'token',authenticated:true};
|
else if(p.includes('session'))value={mode:'token',authenticated:true};
|
||||||
else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]};
|
else if(p.endsWith('/tools')||p.endsWith('/chat-tools'))value={tools:[]};
|
||||||
else if(p.endsWith('/models'))value={models:[],providers:{}};
|
else if(p.endsWith('/models'))value={models:[],providers:{}};
|
||||||
@@ -75,7 +77,28 @@ try{
|
|||||||
await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click();
|
await page.getByText('Uploaded',{exact:true}).waitFor();await page.getByRole('button',{name:'Remove notes.txt'}).click();
|
||||||
await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));});
|
await page.locator('.agent-room__composer-area').evaluate(el=>{const data=new DataTransfer();data.items.add(new File(['data'],'notes.txt',{type:'text/plain'}));el.dispatchEvent(new DragEvent('drop',{dataTransfer:data,bubbles:true,cancelable:true}));});
|
||||||
await page.getByText('Uploaded',{exact:true}).waitFor();
|
await page.getByText('Uploaded',{exact:true}).waitFor();
|
||||||
|
await page.getByRole('button',{name:'Remove notes.txt'}).click();
|
||||||
|
// Assistant image-only messages use authenticated bytes automatically, with no source path.
|
||||||
|
const assistant={id:'outbound:900',role:'assistant',createdAt:new Date().toISOString(),content:'',metadata:{kind:'message',status:'complete',entryId:++cursor,agentAttachments:[assistantImage]}};history.push(assistant);
|
||||||
|
live.send(JSON.stringify({type:'messages',conversationId:'1',messages:[assistant],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
|
||||||
|
const assistantBubble=page.locator('[data-agent-message-id="outbound:900"]'),inlineImage=assistantBubble.locator('.agent-attachment__preview');
|
||||||
|
await inlineImage.waitFor();await inlineImage.evaluate(img=>img.decode());
|
||||||
|
assert.ok((await inlineImage.getAttribute('src')).startsWith('blob:'));
|
||||||
|
assert.equal(await assistantBubble.getByRole('button',{name:'Preview',exact:true}).count(),0);
|
||||||
|
assert.equal(await assistantBubble.locator('.agent-attachment__path').count(),0);
|
||||||
|
const tracking={id:'outbound:901',role:'assistant',createdAt:new Date().toISOString(),content:'Remote images must not load.\n\n <img src="https://never-fetch.invalid/html.png">',metadata:{kind:'message',status:'complete',entryId:++cursor}};history.push(tracking);
|
||||||
|
live.send(JSON.stringify({type:'messages',conversationId:'1',messages:[tracking],cursor:String(cursor),beforeCursor:String(cursor),hasMore:false}));
|
||||||
|
await page.getByText('Remote images must not load.',{exact:true}).waitFor();
|
||||||
|
assert.equal(await page.locator('[data-agent-message-id="outbound:901"] .md-content img').count(),0);
|
||||||
|
// An output-only capability must preserve history without enabling input controls.
|
||||||
|
inputAttachments=false;await page.reload({waitUntil:'networkidle'});await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||||
|
await inlineImage.waitFor();await inlineImage.evaluate(img=>img.decode());
|
||||||
|
assert.equal(await assistantBubble.count(),1);assert.equal(await page.locator('.agent-attachment__preview').count(),1);
|
||||||
|
assert.ok((await inlineImage.getAttribute('src')).startsWith('blob:'));
|
||||||
|
assert.equal(await page.getByRole('button',{name:'Attach files',exact:true}).count(),0);
|
||||||
|
assert.equal(await page.getByLabel('Choose attachments').count(),0);
|
||||||
|
assert.deepEqual(trackingRequests,[],'Neither live nor historical Markdown/HTML image markup fetches remote bytes');
|
||||||
await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT});
|
await page.setViewportSize({width:390,height:844});if(process.env.ATTACHMENT_SCREENSHOT)await page.screenshot({path:process.env.ATTACHMENT_SCREENSHOT});
|
||||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
|
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>window.innerWidth),false);
|
||||||
assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, mobile layout');
|
assert.deepEqual(errors,[]);console.log('PASS browser picker/retry/quota/remove, file-only send/reload, authenticated preview/download, copy, paste/drop, assistant image-only live/history, output-only capability, blocked Markdown/HTML images, mobile layout');
|
||||||
}finally{await context.close();await browser.close();}
|
}finally{await context.close();await browser.close();}
|
||||||
|
|||||||
@@ -5,11 +5,48 @@ import { createAgentUploadQueue, agentAttachments, parseAgentAttachment } from '
|
|||||||
import { AgentRoomClient } from '../src/lib/agent-room.ts';
|
import { AgentRoomClient } from '../src/lib/agent-room.ts';
|
||||||
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
|
import { createAgentOptimisticOutbox } from '../src/lib/agent-optimistic-outbox.ts';
|
||||||
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
|
import { agentTranscriptMessages } from '../src/lib/agent-room-presentation.ts';
|
||||||
|
import { createAgentRoomState, applyAgentEvent, mergeAgentMessages } from '../src/lib/agent-room.ts';
|
||||||
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
|
const file = { id:'att_12345678-1234-4234-8234-123456789abc',kind:'text',filename:'notes.txt',mimeType:'text/plain',sizeBytes:4,sha256:createHash('sha256').update('data').digest('hex'),workspacePath:'/data/workspace/uploads/att_12345678-1234-4234-8234-123456789abc/notes.txt' };
|
||||||
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
|
const cap = {version:1,maxCount:8,maxTotalBytes:32*1024*1024,maxImageBytes:6*1024*1024,maxFileBytes:8*1024*1024,imageMimeTypes:['image/png','image/jpeg','image/gif','image/webp']};
|
||||||
const tick=()=>new Promise(r=>setTimeout(r,0));
|
const tick=()=>new Promise(r=>setTimeout(r,0));
|
||||||
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
|
const waitFor=async check=>{const until=Date.now()+5000;while(!check()){assert.ok(Date.now()<until,'async condition timed out');await tick();}};
|
||||||
|
|
||||||
|
test('assistant images retain stable live/history identity, omit paths, and never enable input capability',async()=>{
|
||||||
|
const image={...file,kind:'image',mimeType:'image/png',filename:'image.png',url:'https://never-fetch.invalid/image'};
|
||||||
|
const safe={...image};delete safe.workspacePath;delete safe.url;
|
||||||
|
const message={id:'outbound:42',role:'assistant',content:'',createdAt:new Date().toISOString(),metadata:{kind:'message',status:'complete',entryId:42,agentAttachments:[image]}};
|
||||||
|
const page={type:'messages',conversationId:'1',messages:[message],cursor:'42',beforeCursor:'42',hasMore:false};
|
||||||
|
const live=applyAgentEvent(createAgentRoomState(),page);
|
||||||
|
assert.deepEqual(agentAttachments(live.messages[0].metadata,'assistant'),[safe]);
|
||||||
|
const history=mergeAgentMessages(live.messages,[structuredClone(message)]);
|
||||||
|
assert.equal(history.length,1);assert.equal(history[0].id,'outbound:42');
|
||||||
|
assert.deepEqual(agentAttachments(history[0].metadata,'assistant'),[safe]);
|
||||||
|
assert.equal(agentTranscriptMessages(history).length,1,'image-only assistant bubble remains visible');
|
||||||
|
for(const invalid of [{...image,kind:'file'},{...image,mimeType:'image/svg+xml'},{...image,sizeBytes:6*1024*1024+1},{...image,filename:'/private/source.png'}])
|
||||||
|
assert.deepEqual(agentAttachments({agentAttachments:[invalid]},'assistant'),[]);
|
||||||
|
assert.deepEqual(agentAttachments(message.metadata,'tool'),[]);
|
||||||
|
assert.deepEqual(agentAttachments({agentAttachments:[image,image]},'assistant'),[]);
|
||||||
|
const descriptor={capabilities:{assistantImages:{version:1}}};
|
||||||
|
const queue=createAgentUploadQueue({capability:()=>descriptor.capabilities.attachments,changed:()=>{},upload:async()=>{throw new Error('Must not upload');}});
|
||||||
|
assert.throws(()=>queue.add([new File(['data'],'notes.txt')]),/not available/);queue.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloads bound concurrent bytes, validate hashes/types, cancel queued work, and reject changed credentials',async()=>{
|
||||||
|
const complete=[];let calls=0,token='one';
|
||||||
|
const client=new AgentRoomClient({token:()=>token,fetch:async()=>{calls++;return new Promise(resolve=>complete.push(resolve));}});
|
||||||
|
const a=client.download(file), b=client.download(file), cancel=new AbortController();
|
||||||
|
const canceled=client.download(file,cancel.signal);const canceledAssertion=assert.rejects(canceled);cancel.abort();
|
||||||
|
await canceledAssertion;assert.equal(calls,2);
|
||||||
|
complete.shift()(new Response('data'));await a;
|
||||||
|
const c=client.download(file);assert.equal(calls,3);
|
||||||
|
complete.shift()(new Response('evil'));await assert.rejects(b,/checksum/);
|
||||||
|
token='two';complete.shift()(new Response('data'));await assert.rejects(c,/Authentication changed/);
|
||||||
|
const huge=new AgentRoomClient({fetch:async()=>new Response('data-exceeds-receipt')});
|
||||||
|
await assert.rejects(huge.download(file),/size limit/);
|
||||||
|
const wrongType=new AgentRoomClient({fetch:async()=>new Response('data',{headers:{'content-type':'text/html'}})});
|
||||||
|
await assert.rejects(wrongType.download({...file,kind:'image',mimeType:'image/png'}),/type did not match/);
|
||||||
|
});
|
||||||
|
|
||||||
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
|
test('composer gates unsupported servers, count/type/size and batches before upload; preserves retry ID and remove races',async()=>{
|
||||||
let capability, attempts=[], next=0, changed=0, finish;
|
let capability, attempts=[], next=0, changed=0, finish;
|
||||||
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
|
const queue=createAgentUploadQueue({capability:()=>capability,newId:()=>`upload-${++next}`,changed:()=>changed++,upload:async(_file,id,signal)=>{attempts.push({id,signal});if(attempts.length===1)throw new Error('Quota full');return new Promise(resolve=>finish=resolve);}});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
AgentRoomClient, AgentReadSourceChangedError, advanceAgentCursor, agentAuthFrame, agentDisplayMessages, agentUnreadCount,
|
||||||
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
applyAgentEvent, applyAgentPage, applyAgentReadState, createAgentReadSession, createAgentReadWriter,
|
||||||
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
createAgentRoomState, mergeAgentMessages, normalizeAgentCursor, parseAgentRoomEvent, syncAgentHistory,
|
||||||
|
applyAgentHistoryPage, agentUnreadCountIsPartial,
|
||||||
} from "../src/lib/agent-room.ts";
|
} from "../src/lib/agent-room.ts";
|
||||||
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
import { createAgentOptimisticOutbox } from "../src/lib/agent-optimistic-outbox.ts";
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ test("tool starts and results merge across replay without downgrading completed
|
|||||||
assert.equal(messages[0].createdAt, start.createdAt);
|
assert.equal(messages[0].createdAt, start.createdAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("initial synchronization reads every page including invisible entries and counts only unread assistant messages", async () => {
|
test("forward recovery reads every missing page including invisible entries and counts only unread assistant messages", async () => {
|
||||||
const requests = [];
|
const requests = [];
|
||||||
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
|
const responses = [page("2", [message(1), message(2, "user")], true), page("4", [], true), page("7", [message(5), message(6, "tool"), message(7)], false)];
|
||||||
let state = createAgentRoomState();
|
let state = createAgentRoomState();
|
||||||
@@ -47,6 +48,78 @@ test("initial synchronization reads every page including invisible entries and c
|
|||||||
assert.equal(agentUnreadCount(state), 1);
|
assert.equal(agentUnreadCount(state), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("recent history is one cursorless window and older history uses the returned source boundary", async () => {
|
||||||
|
const requests = [];
|
||||||
|
const client = new AgentRoomClient({ fetch: async (url, options) => {
|
||||||
|
requests.push({ url, signal: options.signal });
|
||||||
|
return new Response(JSON.stringify({ ...page("1200", [message(1200)], true), beforeCursor: "1101" }));
|
||||||
|
} });
|
||||||
|
const controller = new AbortController();
|
||||||
|
let state = applyAgentHistoryPage(createAgentRoomState(), await client.history(undefined, controller.signal));
|
||||||
|
assert.equal(requests.length, 1, "hasMore must not drain old history");
|
||||||
|
assert.equal(requests[0].url, "/api/v1/agent/messages?limit=100");
|
||||||
|
assert.equal(requests[0].signal, controller.signal);
|
||||||
|
assert.equal(state.historyLoaded, true);
|
||||||
|
assert.equal(state.cursor, "1200");
|
||||||
|
assert.equal(state.beforeCursor, "1101", "boundary is not derived from visible message 1200");
|
||||||
|
assert.equal(state.hasOlder, true);
|
||||||
|
await client.history(state.beforeCursor, controller.signal);
|
||||||
|
assert.equal(requests[1].url, "/api/v1/agent/messages?before=1101&limit=100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("delayed older pages preserve newer edits, live cursor, source and read state", () => {
|
||||||
|
let state = applyAgentHistoryPage(sourceRoom(), { ...page("100", [message(100)], true), beforeCursor: "90" });
|
||||||
|
state = applyAgentPage(state, page("110", [message(100, "assistant", { content: "New streamed text" }), message(110)]));
|
||||||
|
const next = applyAgentHistoryPage(state, { ...page("89", [message(80), message(100, "assistant", { content: "Old snapshot" })], true), beforeCursor: "70" }, "90");
|
||||||
|
assert.equal(next.messages.find(m => m.id === "entry:100").content, "New streamed text");
|
||||||
|
assert.equal(next.cursor, "110");
|
||||||
|
assert.equal(next.readCursor, "90");
|
||||||
|
assert.equal(next.sourceId, sourceA);
|
||||||
|
assert.equal(next.beforeCursor, "70");
|
||||||
|
assert.equal(next.hasOlder, true);
|
||||||
|
assert.equal(applyAgentHistoryPage(next, page("89", [message(80)], false), "90"), next, "retired boundary cannot replace the next window");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty internal pages advance older boundaries without chasing another page or moving read state", () => {
|
||||||
|
const initial = applyAgentHistoryPage(createAgentRoomState(), { ...page("1200", [], true), beforeCursor: "1101" });
|
||||||
|
assert.equal(initial.historyLoaded, true);
|
||||||
|
assert.equal(initial.messages.length, 0);
|
||||||
|
const next = applyAgentHistoryPage(initial, { ...page("1100", [], true), beforeCursor: "1001" }, "1101");
|
||||||
|
assert.equal(next.beforeCursor, "1001");
|
||||||
|
assert.equal(next.cursor, "1200");
|
||||||
|
assert.equal(next.readCursor, "0");
|
||||||
|
assert.equal(next.hasOlder, true);
|
||||||
|
const end = applyAgentHistoryPage(next, { ...page("0"), beforeCursor: "1001" }, "1001");
|
||||||
|
assert.equal(end.hasOlder, false);
|
||||||
|
assert.equal(end.cursor, "1200");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed or stalled older pages cannot change history or delivery boundaries", () => {
|
||||||
|
const state = applyAgentHistoryPage(createAgentRoomState(), { ...page("100", [message(100)], true), beforeCursor: "90" });
|
||||||
|
for (const bad of [
|
||||||
|
{ ...page("89", [], true), beforeCursor: "90" },
|
||||||
|
{ ...page("89", [], true), beforeCursor: null },
|
||||||
|
{ ...page("91", [], false), beforeCursor: "80" },
|
||||||
|
{ ...page("89", [], false), beforeCursor: "91" },
|
||||||
|
{ ...page("bad", [], false), beforeCursor: "80" },
|
||||||
|
]) assert.throws(() => applyAgentHistoryPage(state, bad, "90"), /cursor|boundary/);
|
||||||
|
assert.throws(() => applyAgentHistoryPage(state, { ...page("89"), conversationId: "different" }, "90"), /conversation changed/);
|
||||||
|
assert.equal(state.beforeCursor, "90");
|
||||||
|
assert.equal(state.cursor, "100");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unread counts disclose unknown older replies until reading the latest rendered window", () => {
|
||||||
|
let state = applyAgentHistoryPage(createAgentRoomState(), { ...page("1200", [message(1199), message(1200, "user")], true), beforeCursor: "1101" });
|
||||||
|
assert.equal(agentUnreadCount(state), 1);
|
||||||
|
assert.equal(agentUnreadCountIsPartial(state), true);
|
||||||
|
state = applyAgentReadState(state, { cursor: "1100", updatedAt: null });
|
||||||
|
assert.equal(agentUnreadCountIsPartial(state), false);
|
||||||
|
assert.equal(agentUnreadCount(state), 1);
|
||||||
|
state = applyAgentReadState(state, { cursor: "1200", updatedAt: null });
|
||||||
|
assert.equal(agentUnreadCount(state), 0);
|
||||||
|
assert.equal(state.hasOlder, true, "reading latest does not fetch or discard older pages");
|
||||||
|
});
|
||||||
|
|
||||||
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
|
test("queued user messages keep stable IDs and disappear if withdrawn or promoted", () => {
|
||||||
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
|
const pending = message(1, "user", { id: "inbound:sybil:request1", metadata: { kind: "message", status: "queued" } });
|
||||||
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
let state = applyAgentEvent(createAgentRoomState(), { type: "activity", conversationId: "1", activity: activity([pending]) });
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
// Local production preview; all APIs/sockets are synthetic and external requests are blocked.
|
||||||
|
// PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs WEB_TEST_URL=http://127.0.0.1:5196
|
||||||
|
// TRANSCRIPT_EVIDENCE=/tmp/evidence TRANSCRIPT_BASELINE=1 node tests/agent-transcript.browser.mjs
|
||||||
|
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||||
|
const target = new URL(process.env.WEB_TEST_URL || 'http://127.0.0.1:5196/');
|
||||||
|
assert.ok(target.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(target.hostname));
|
||||||
|
const baseline = process.env.TRANSCRIPT_BASELINE === '1';
|
||||||
|
const evidence = process.env.TRANSCRIPT_EVIDENCE;
|
||||||
|
if (evidence) await mkdir(evidence, { recursive: true });
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_EXECUTABLE || undefined });
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' });
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [], requests = [], reads = [], socketCursors = [];
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
const idle = { state: 'idle', isThinking: false, queued: 0, backgroundWorkers: 0, tools: [], queuedMessages: [] };
|
||||||
|
const sourceId = '00000000-0000-4000-8000-000000000001';
|
||||||
|
const row = (id, content) => ({ id: `entry:${id}`, role: id % 3 ? 'assistant' : 'user',
|
||||||
|
createdAt: new Date(Date.UTC(2026, 9, 16, 0, 0, id)).toISOString(),
|
||||||
|
content: content || `Fixture message ${id}\n\n${'Synthetic transcript text for delayed loading. '.repeat(id % 11 === 0 ? 30 : 3)}`,
|
||||||
|
metadata: { kind: 'message', entryId: id, status: 'complete' } });
|
||||||
|
const history = Array.from({ length: 1200 }, (_, i) => row(i + 1));
|
||||||
|
let live, readCursor = '0', failOlder = false, imageBytes, emptyRecent = false;
|
||||||
|
const start = performance.now();
|
||||||
|
const elapsed = () => Math.round(performance.now() - start);
|
||||||
|
await context.addInitScript(() => {
|
||||||
|
localStorage.clear(); sessionStorage.clear();
|
||||||
|
window.fixtureTiming = {};
|
||||||
|
const sample = () => {
|
||||||
|
const scroller = document.querySelector('.agent-room__scroll');
|
||||||
|
if (scroller) {
|
||||||
|
const box = scroller.getBoundingClientRect();
|
||||||
|
const visible = [...scroller.querySelectorAll('[data-agent-message-id]')].filter(e => {
|
||||||
|
const r = e.getBoundingClientRect(); return r.bottom > box.top && r.top < box.bottom;
|
||||||
|
});
|
||||||
|
if (visible.length && !window.fixtureTiming.first) window.fixtureTiming.first = { ms: performance.now(), ids: visible.map(e => e.dataset.agentMessageId) };
|
||||||
|
if (visible.some(e => e.dataset.agentMessageId === 'entry:1200') && !window.fixtureTiming.latestMs) window.fixtureTiming.latestMs = performance.now();
|
||||||
|
}
|
||||||
|
if (!window.fixtureTiming.latestMs) requestAnimationFrame(sample);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(sample);
|
||||||
|
});
|
||||||
|
await page.route('**/*', async route => {
|
||||||
|
const req = route.request(), url = new URL(req.url());
|
||||||
|
if (url.origin !== target.origin) return route.abort();
|
||||||
|
if (!url.pathname.startsWith('/api/')) return route.continue();
|
||||||
|
const path = url.pathname, method = req.method();
|
||||||
|
assert.equal(req.headers().authorization, undefined, 'Fixture must never use a real credential');
|
||||||
|
let value;
|
||||||
|
if (path.endsWith('/content')) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
return route.fulfill({ contentType: 'image/png', body: imageBytes });
|
||||||
|
} else if (path.endsWith('/agent/messages')) {
|
||||||
|
assert.equal(method, 'GET', 'Fixture must not send messages');
|
||||||
|
const after = url.searchParams.get('after'), before = url.searchParams.get('before'), limit = Number(url.searchParams.get('limit') || 200);
|
||||||
|
const record = { query: url.search, requestedMs: elapsed() }; requests.push(record);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, before ? 1600 : 800));
|
||||||
|
if (before && failOlder) { failOlder = false; return route.fulfill({ status: 503, json: { message: 'Fixture older page unavailable' } }); }
|
||||||
|
const eligible = history.filter(m => after !== null ? m.metadata.entryId > Number(after) : before !== null ? m.metadata.entryId < Number(before) : true);
|
||||||
|
const messages = after !== null ? eligible.slice(0, limit) : eligible.slice(-limit);
|
||||||
|
value = { conversationId: 'fixture-room', messages, cursor: String(messages.at(-1)?.metadata.entryId ?? after ?? 0),
|
||||||
|
beforeCursor: messages.length ? String(messages[0].metadata.entryId) : null, hasMore: eligible.length > messages.length };
|
||||||
|
if (emptyRecent && before === null && after === null) value.messages = [];
|
||||||
|
Object.assign(record, { responseMs: elapsed(), rows: messages.length, bytes: Buffer.byteLength(JSON.stringify(value)) });
|
||||||
|
} else if (path.endsWith('/read-state')) {
|
||||||
|
if (method === 'PUT') { readCursor = req.postDataJSON().cursor; reads.push({ cursor: readCursor, ms: elapsed() }); }
|
||||||
|
value = { cursor: readCursor, sourceId, updatedAt: null };
|
||||||
|
} else if (path.endsWith('/agent')) value = { id: 'fixture-room', title: 'Sybil Agent', activity: idle, protocol: 'pi-room.v1', websocket: '/v1/agent/ws' };
|
||||||
|
else if (path.endsWith('/auth/session')) value = { authenticated: true, mode: 'open' };
|
||||||
|
else if (path.endsWith('/models')) value = { models: [], providers: {} };
|
||||||
|
else if (path.endsWith('/tools') || path.endsWith('/chat-tools')) value = { tools: [] };
|
||||||
|
else value = [];
|
||||||
|
await route.fulfill({ json: value });
|
||||||
|
});
|
||||||
|
await page.routeWebSocket('**/api/v1/agent/ws', socket => {
|
||||||
|
live = socket;
|
||||||
|
socket.onMessage(raw => { const frame = JSON.parse(String(raw)); if (frame.type === 'auth') {
|
||||||
|
assert.equal(frame.token, null); socketCursors.push(frame.after);
|
||||||
|
socket.send(JSON.stringify({ type: 'ready', conversationId: 'fixture-room' }));
|
||||||
|
} });
|
||||||
|
});
|
||||||
|
const send = messages => {
|
||||||
|
for (const message of messages) { const index = history.findIndex(m => m.id === message.id); if (index < 0) history.push(message); else history[index] = message; }
|
||||||
|
live.send(JSON.stringify({ type: 'messages', conversationId: 'fixture-room', messages,
|
||||||
|
cursor: String(messages.at(-1).metadata.entryId), beforeCursor: String(messages[0].metadata.entryId), hasMore: false }));
|
||||||
|
};
|
||||||
|
const position = () => page.locator('.agent-room__scroll').evaluate(el => ({ top: el.scrollTop, distance: el.scrollHeight - el.scrollTop - el.clientHeight,
|
||||||
|
rows: el.querySelectorAll('[data-agent-entry-id]').length }));
|
||||||
|
const anchor = () => page.locator('.agent-room__scroll').evaluate(el => {
|
||||||
|
const top = el.getBoundingClientRect().top;
|
||||||
|
const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.getBoundingClientRect().bottom > top + 8);
|
||||||
|
return { id: entry.dataset.agentEntryId, offset: entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - top };
|
||||||
|
});
|
||||||
|
const anchorDrift = saved => page.locator('.agent-room__scroll').evaluate((el, saved) => {
|
||||||
|
const entry = [...el.querySelectorAll('[data-agent-entry-id]')].find(e => e.dataset.agentEntryId === saved.id);
|
||||||
|
return entry.querySelector('[data-agent-message-id]').getBoundingClientRect().top - el.getBoundingClientRect().top - saved.offset;
|
||||||
|
}, saved);
|
||||||
|
const metrics = { mode: baseline ? 'before' : 'after', fixtureRows: 1200, pageDelayMs: 800, olderDelayMs: 1600 };
|
||||||
|
try {
|
||||||
|
await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||||
|
await page.waitForFunction(() => window.fixtureTiming?.first);
|
||||||
|
if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-first.png` });
|
||||||
|
await page.waitForFunction(() => window.fixtureTiming?.latestMs, { timeout: 20000 });
|
||||||
|
await page.getByRole('status').filter({ hasText: /^Connected$/ }).waitFor();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
Object.assign(metrics, await page.evaluate(() => window.fixtureTiming), { initial: await position(), initialRequests: requests.map(r => ({ ...r })), socketCursors: [...socketCursors] });
|
||||||
|
if (evidence) await page.screenshot({ path: `${evidence}/${metrics.mode}-latest.png` });
|
||||||
|
if (!baseline) {
|
||||||
|
assert.equal(requests.length, 1, 'No eager older/forward chain on initial load');
|
||||||
|
assert.equal(new URLSearchParams(requests[0].query).has('after'), false);
|
||||||
|
assert.equal(socketCursors[0], '1200');
|
||||||
|
assert.ok(metrics.initial.rows <= 100);
|
||||||
|
assert.ok(metrics.initial.distance < 2);
|
||||||
|
await page.waitForTimeout(1800);
|
||||||
|
assert.equal(requests.length, 1, 'Idle initial view must not drain history');
|
||||||
|
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; });
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const saved = await anchor(), readBefore = readCursor;
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||||
|
send([row(1201)]);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
assert.equal(readCursor, readBefore, 'Incoming while reading must stay unread');
|
||||||
|
metrics.incomingDriftPx = await anchorDrift(saved);
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
metrics.prependDriftPx = await anchorDrift(saved);
|
||||||
|
metrics.afterPrepend = await position();
|
||||||
|
assert.ok(Math.abs(metrics.incomingDriftPx) < 2);
|
||||||
|
assert.ok(Math.abs(metrics.prependDriftPx) < 2);
|
||||||
|
assert.equal(readCursor, readBefore);
|
||||||
|
assert.equal(requests.length, 2, 'One user action loads one older page');
|
||||||
|
assert.equal(await page.locator('[data-agent-entry-id="entry:1001"] [data-animation="enter"]').count(), 0);
|
||||||
|
const beforeGrowth = await anchor();
|
||||||
|
send([row(1201, 'Dynamic streamed content.\n\n'.repeat(80))]);
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
metrics.streamingReadingDriftPx = await anchorDrift(beforeGrowth);
|
||||||
|
assert.ok(Math.abs(metrics.streamingReadingDriftPx) < 2);
|
||||||
|
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; });
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
send([row(1201, 'Dynamic streamed content continues.\n\n'.repeat(100))]);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
metrics.streamingPinnedDistancePx = (await position()).distance;
|
||||||
|
assert.ok(metrics.streamingPinnedDistancePx < 2);
|
||||||
|
const order = await page.locator('.agent-room__timeline').evaluate(el => {
|
||||||
|
const ids = [...el.querySelectorAll('[data-agent-entry-id]')].map(e => Number(e.dataset.agentEntryId.split(':')[1]));
|
||||||
|
const range = document.createRange(); range.selectNodeContents(el);
|
||||||
|
return { ids, text: range.toString(), direction: getComputedStyle(el).flexDirection,
|
||||||
|
skipped: [...el.querySelectorAll('[data-agent-message-id]')].filter(e => !e.checkVisibility({ contentVisibilityAuto: true })).length };
|
||||||
|
});
|
||||||
|
assert.deepEqual(order.ids, [...order.ids].sort((a, b) => a - b));
|
||||||
|
assert.ok(order.text.indexOf('Fixture message 1001') < order.text.indexOf('Fixture message 1200'));
|
||||||
|
assert.equal(order.direction, 'column');
|
||||||
|
assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0);
|
||||||
|
metrics.skippedOffscreenRows = order.skipped;
|
||||||
|
assert.ok(order.skipped > 0, 'Offscreen rows should skip layout/paint');
|
||||||
|
const ax = await page.locator('.agent-room__scroll').ariaSnapshot();
|
||||||
|
assert.ok(ax.includes('Fixture message 1001') && ax.includes('Fixture message 1200'));
|
||||||
|
assert.ok(ax.indexOf('Fixture message 1001') < ax.indexOf('Fixture message 1200'));
|
||||||
|
|
||||||
|
// Authenticated attachment bytes are synthetic, delayed, and never fetched off origin.
|
||||||
|
imageBytes = Buffer.from(await page.evaluate(() => {
|
||||||
|
const canvas = document.createElement('canvas'); canvas.width = 240; canvas.height = 180;
|
||||||
|
const ctx = canvas.getContext('2d'); ctx.fillStyle = '#8974da'; ctx.fillRect(0, 0, 240, 180);
|
||||||
|
return canvas.toDataURL('image/png').split(',')[1];
|
||||||
|
}), 'base64');
|
||||||
|
const file = { id: 'att_12345678-1234-4234-8234-123456789abc', filename: 'fixture.png', mimeType: 'image/png', kind: 'image',
|
||||||
|
sizeBytes: imageBytes.length, sha256: createHash('sha256').update(imageBytes).digest('hex') };
|
||||||
|
send([{ ...row(1202), metadata: { ...row(1202).metadata, agentAttachments: [file] } }]);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.getByRole('button', { name: 'Preview', exact: true }).click();
|
||||||
|
await page.locator('.agent-attachment__preview').waitFor();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
metrics.attachmentPinnedDistancePx = (await position()).distance;
|
||||||
|
assert.ok(metrics.attachmentPinnedDistancePx < 2);
|
||||||
|
send([row(1203, 'Following the delayed attachment.\n\n'.repeat(60))]);
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.locator('.agent-room__scroll').evaluate(el => {
|
||||||
|
const entry = el.querySelector('[data-agent-entry-id="entry:1203"]');
|
||||||
|
el.scrollTop += entry.getBoundingClientRect().top - el.getBoundingClientRect().top + 80;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const attachmentAnchor = await anchor();
|
||||||
|
// Collapse an earlier preview while reading the following message.
|
||||||
|
await page.getByRole('button', { name: 'Hide preview', exact: true }).evaluate(el => el.click());
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
metrics.attachmentReadingDriftPx = await anchorDrift(attachmentAnchor);
|
||||||
|
assert.ok(Math.abs(metrics.attachmentReadingDriftPx) < 2);
|
||||||
|
|
||||||
|
// A failed older page is locally retryable without clearing the recent window.
|
||||||
|
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const rowsBeforeFailure = (await position()).rows;
|
||||||
|
failOlder = true;
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||||
|
await page.getByRole('alert').filter({ hasText: 'Fixture older page unavailable' }).waitFor();
|
||||||
|
assert.equal((await position()).rows, rowsBeforeFailure);
|
||||||
|
const socketsBefore = socketCursors.length;
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
live.close({ code: 1012, reason: 'Fixture reconnect' });
|
||||||
|
await page.getByRole('button', { name: 'Reconnect to Sybil Agent', exact: true }).click();
|
||||||
|
await page.waitForTimeout(2200);
|
||||||
|
assert.ok(socketCursors.length > socketsBefore);
|
||||||
|
assert.equal(socketCursors.at(-1), '1203');
|
||||||
|
assert.equal((await position()).rows, rowsBeforeFailure, 'Retired older response must not publish into the new session');
|
||||||
|
assert.equal(await page.getByRole('alert').count(), 0);
|
||||||
|
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = 0; });
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelector('[data-agent-entry-id="entry:901"]'));
|
||||||
|
metrics.retiredOlderIgnored = true;
|
||||||
|
metrics.olderFailureRetry = true;
|
||||||
|
assert.equal(requests.filter(r => r.query.includes('after=0')).length, 0);
|
||||||
|
await page.locator('.agent-room__scroll').evaluate(el => { el.scrollTop = el.scrollHeight; });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
metrics.mobilePinnedDistancePx = (await position()).distance;
|
||||||
|
assert.ok(metrics.mobilePinnedDistancePx < 2);
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||||
|
assert.equal(await page.getByRole('button', { name: /latest/i }).count(), 0);
|
||||||
|
if (evidence) await page.screenshot({ path: `${evidence}/after-mobile.png` });
|
||||||
|
// A source window can contain only internal entries. It stays bounded and
|
||||||
|
// must not mark unloaded public history read or animate an older prepend.
|
||||||
|
emptyRecent = true; readCursor = '0';
|
||||||
|
const requestsBeforeEmpty = requests.length;
|
||||||
|
await page.setViewportSize({ width: 1280, height: 900 });
|
||||||
|
await page.goto(new URL('/?agent=root', target).href, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.locator('[data-testid="agent-sidebar-item"]').click();
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).waitFor();
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
assert.equal((await position()).rows, 0);
|
||||||
|
assert.equal(requests.length, requestsBeforeEmpty + 1);
|
||||||
|
assert.equal(readCursor, '0');
|
||||||
|
await page.getByRole('button', { name: 'Load older messages', exact: true }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('[data-agent-entry-id]').length > 0);
|
||||||
|
assert.equal(await page.locator('[data-animation="enter"]').count(), 0);
|
||||||
|
metrics.internalOnlyWindowBounded = true;
|
||||||
|
metrics.internalOnlyWindowUnacknowledged = true;
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
metrics.requests = requests; metrics.reads = reads; metrics.errors = errors;
|
||||||
|
if (evidence) await writeFile(`${evidence}/${metrics.mode}.json`, JSON.stringify(metrics, null, 2));
|
||||||
|
console.log(JSON.stringify(metrics, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(JSON.stringify({ metrics, requests, errors, body: (await page.locator('body').innerText()).slice(0, 1800) }));
|
||||||
|
throw error;
|
||||||
|
} finally { await context.close(); await browser.close(); }
|
||||||
Reference in New Issue
Block a user