ios: support safe assistant inline images
This commit is contained in:
@@ -15,7 +15,9 @@ let package = Package(
|
||||
targets: ["Sybil"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.1")
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui.git", from: "2.4.1"),
|
||||
// Use MarkdownUI's existing parser to remove Agent image/HTML nodes structurally.
|
||||
.package(url: "https://github.com/swiftlang/swift-cmark", from: "0.7.1")
|
||||
],
|
||||
targets: [
|
||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||
@@ -23,7 +25,9 @@ let package = Package(
|
||||
.target(
|
||||
name: "Sybil",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui")
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
.product(name: "cmark-gfm", package: "swift-cmark"),
|
||||
.product(name: "cmark-gfm-extensions", package: "swift-cmark")
|
||||
],
|
||||
resources: [.process("Resources")]),
|
||||
.testTarget(
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import Foundation
|
||||
import MarkdownUI
|
||||
import cmark_gfm
|
||||
import cmark_gfm_extensions
|
||||
|
||||
/// Agent images come only from validated attachment descriptors. Rewrite parsed nodes,
|
||||
/// including reference/nested images, so neither MarkdownUI image loader receives a URL.
|
||||
enum AgentMessageMarkdown {
|
||||
static func content(_ source: String) -> MarkdownContent {
|
||||
cmark_gfm_core_extensions_ensure_registered()
|
||||
guard let parser = cmark_parser_new(CMARK_OPT_DEFAULT) else { return literal(source) }
|
||||
defer { cmark_parser_free(parser) }
|
||||
for name in ["table", "strikethrough", "autolink", "tasklist"] {
|
||||
if let ext = cmark_find_syntax_extension(name) { cmark_parser_attach_syntax_extension(parser, ext) }
|
||||
}
|
||||
source.withCString { cmark_parser_feed(parser, $0, source.utf8.count) }
|
||||
guard let document = cmark_parser_finish(parser) else { return literal(source) }
|
||||
defer { cmark_node_free(document) }
|
||||
stripImagesAndHTML(document)
|
||||
guard let markdown = cmark_render_commonmark(document, CMARK_OPT_DEFAULT, 0) else { return literal(source) }
|
||||
defer { free(markdown) }
|
||||
return MarkdownContent(String(cString: markdown))
|
||||
}
|
||||
|
||||
private static func literal(_ source: String) -> MarkdownContent {
|
||||
MarkdownContent { Paragraph { source } }
|
||||
}
|
||||
|
||||
private static func stripImagesAndHTML(_ parent: UnsafeMutablePointer<cmark_node>) {
|
||||
var child = cmark_node_first_child(parent)
|
||||
while let node = child {
|
||||
child = cmark_node_next(node)
|
||||
let type = cmark_node_get_type(node)
|
||||
if type == CMARK_NODE_IMAGE || type == CMARK_NODE_HTML_INLINE || type == CMARK_NODE_HTML_BLOCK {
|
||||
let text = type == CMARK_NODE_IMAGE ? altText(node) : htmlText(cmark_node_get_literal(node).map { String(cString: $0) } ?? "")
|
||||
let replacement = cmark_node_new(type == CMARK_NODE_HTML_BLOCK ? CMARK_NODE_PARAGRAPH : CMARK_NODE_TEXT)!
|
||||
if type == CMARK_NODE_HTML_BLOCK {
|
||||
let inline = cmark_node_new(CMARK_NODE_TEXT)!
|
||||
// cmark includes the HTML block's terminating newline in its literal.
|
||||
// It is a block delimiter, not an extra line in the visible alt text.
|
||||
cmark_node_set_literal(inline, text.trimmingCharacters(in: .newlines))
|
||||
cmark_node_append_child(replacement, inline)
|
||||
} else {
|
||||
cmark_node_set_literal(replacement, text)
|
||||
}
|
||||
cmark_node_replace(node, replacement)
|
||||
cmark_node_free(node)
|
||||
} else {
|
||||
stripImagesAndHTML(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func altText(_ parent: UnsafeMutablePointer<cmark_node>) -> String {
|
||||
var result = "", child = cmark_node_first_child(parent)
|
||||
while let node = child {
|
||||
let type = cmark_node_get_type(node)
|
||||
if type == CMARK_NODE_TEXT || type == CMARK_NODE_CODE {
|
||||
result += cmark_node_get_literal(node).map { String(cString: $0) } ?? ""
|
||||
} else if type == CMARK_NODE_SOFTBREAK || type == CMARK_NODE_LINEBREAK {
|
||||
result += " "
|
||||
} else if type != CMARK_NODE_HTML_INLINE {
|
||||
result += altText(node)
|
||||
}
|
||||
child = cmark_node_next(node)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private static func htmlText(_ source: String) -> String {
|
||||
// HTML is never rendered. Retain ordinary text and img alt attributes as text
|
||||
// nodes; quoted '>' characters cannot expose the rest of an image attribute.
|
||||
let tags = try! NSRegularExpression(pattern: #"(?s)<(?:[^>"']|"[^"]*"|'[^']*')*>"#)
|
||||
let alt = try! NSRegularExpression(pattern: #"(?is)^<img\b.*?\s+alt\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))"#)
|
||||
var result = source
|
||||
for tag in tags.matches(in: source, range: NSRange(source.startIndex..., in: source)).reversed() {
|
||||
let range = Range(tag.range, in: source)!
|
||||
let value = String(source[range])
|
||||
let match = alt.firstMatch(in: value, range: NSRange(value.startIndex..., in: value))
|
||||
let label = match.flatMap { match in
|
||||
(1...3).compactMap { Range(match.range(at: $0), in: value).map { String(value[$0]) } }.first
|
||||
} ?? ""
|
||||
result.replaceSubrange(Range(tag.range, in: result)!, with: label)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,10 @@ actor SybilAPIClient: SybilAPIClienting, AgentAPIClienting, AgentPushAPIClientin
|
||||
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
||||
}
|
||||
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false { value.capabilities?.attachments = nil }
|
||||
if configuration.authToken?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty != false {
|
||||
value.capabilities?.attachments = nil
|
||||
value.capabilities?.assistantImages = nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
|
||||
@@ -174,9 +174,15 @@ struct AgentAttachmentCard: View {
|
||||
if attachment.kind == .image {
|
||||
ZStack {
|
||||
SybilTheme.surface
|
||||
if let image { Image(uiImage: image).resizable().scaledToFit() }
|
||||
if let image {
|
||||
Image(uiImage: image).resizable().scaledToFit()
|
||||
.accessibilityLabel("Image: \(attachment.filename)")
|
||||
.accessibilityIdentifier("agent-image-preview")
|
||||
}
|
||||
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
|
||||
}.frame(height: 180).clipped()
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityIdentifier("agent-image-frame")
|
||||
}
|
||||
if let text {
|
||||
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
|
||||
|
||||
@@ -44,14 +44,24 @@ struct AgentAttachmentCapabilities: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentAssistantImageCapabilities: Decodable, Equatable, Sendable {
|
||||
var version: Int
|
||||
var supported: Bool { version == 1 }
|
||||
}
|
||||
|
||||
struct AgentCapabilities: Decodable, Sendable {
|
||||
var attachments: AgentAttachmentCapabilities?
|
||||
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
|
||||
private enum CodingKeys: String, CodingKey { case attachments }
|
||||
var assistantImages: AgentAssistantImageCapabilities?
|
||||
init(attachments: AgentAttachmentCapabilities? = nil, assistantImages: AgentAssistantImageCapabilities? = nil) {
|
||||
self.attachments = attachments
|
||||
self.assistantImages = assistantImages
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey { case attachments, assistantImages }
|
||||
init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
// Unknown versions or malformed future capabilities must not break text chat.
|
||||
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
|
||||
assistantImages = try? values.decode(AgentAssistantImageCapabilities.self, forKey: .assistantImages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,9 +101,21 @@ struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
|
||||
|
||||
extension Message {
|
||||
var agentAttachments: [AgentAttachment] {
|
||||
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
|
||||
return entries.compactMap {
|
||||
guard let data = try? JSONEncoder().encode($0),
|
||||
guard role == .user || role == .assistant,
|
||||
let info = metadata?.objectValue,
|
||||
let entries = info["agentAttachments"]?.arrayValue else { return [] }
|
||||
if role == .assistant {
|
||||
guard info["kind"]?.stringValue == "message", info["status"]?.stringValue == "complete" else { return [] }
|
||||
}
|
||||
return entries.compactMap { entry in
|
||||
// Inspect the original JSON before Codable can discard unknown fields. Output
|
||||
// descriptors are deliberately path/URL-free; legacy user uploads stay compatible.
|
||||
if role == .assistant {
|
||||
let safeFields: Set<String> = ["id", "kind", "filename", "mimeType", "sizeBytes", "sha256"]
|
||||
guard let fields = entry.objectValue, Set(fields.keys) == safeFields,
|
||||
fields["kind"]?.stringValue == "image" else { return nil }
|
||||
}
|
||||
guard let data = try? JSONEncoder().encode(entry),
|
||||
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
|
||||
(try? attachment.validate()) != nil else { return nil }
|
||||
return attachment
|
||||
|
||||
@@ -249,7 +249,7 @@ struct AgentPresentation {
|
||||
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
||||
if !replay {
|
||||
for message in fresh {
|
||||
if message.isAgentPublicReply && !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { suppressed = true }
|
||||
if message.isAgentPublicReply && (!message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !message.agentAttachments.isEmpty) { suppressed = true }
|
||||
else if message.role == .user { suppressed = false }
|
||||
else if message.toolCallMetadata?.status == "initiated",
|
||||
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
||||
|
||||
@@ -275,7 +275,9 @@ private struct AgentMessageRow: View {
|
||||
ForEach(message.agentAttachments) { attachment in
|
||||
AgentAttachmentCard(attachment: attachment, room: room)
|
||||
}
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.agentAttachments.isEmpty {
|
||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||
}
|
||||
HStack(spacing: 6) {
|
||||
Text(own ? "You" : "Sybil")
|
||||
Text(message.createdAt, style: .time)
|
||||
|
||||
@@ -223,14 +223,14 @@ struct MessageBubble: View {
|
||||
)
|
||||
}
|
||||
|
||||
if isStreamingAssistant {
|
||||
if isStreamingAssistant && !agentStyle {
|
||||
StreamingAssistantContentView(
|
||||
content: message.content,
|
||||
isSending: isSending,
|
||||
presentation: matchingStreamingAssistantPresentation
|
||||
)
|
||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown)
|
||||
SybilMessageText(content: message.content, format: isUser ? .literal : .markdown, agentStyle: agentStyle)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||
|
||||
@@ -7,6 +7,7 @@ struct SybilMessageText: View {
|
||||
enum Format { case literal, markdown }
|
||||
var content: String
|
||||
var format: Format
|
||||
var agentStyle = false
|
||||
|
||||
static func literalContent(_ text: String) -> MarkdownContent {
|
||||
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
|
||||
@@ -31,7 +32,7 @@ struct SybilMessageText: View {
|
||||
#if DEBUG
|
||||
let _ = AgentRenderDiagnostics.record("messageTextBuild")
|
||||
#endif
|
||||
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
|
||||
Markdown(format == .literal ? Self.literalContent(content) : agentStyle ? AgentMessageMarkdown.content(content) : MarkdownContent(content))
|
||||
.tint(SybilTheme.primary)
|
||||
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||
.markdownTheme(.sybilReadable)
|
||||
|
||||
@@ -14,10 +14,15 @@ private func sampleAttachment(_ id: String = "opaque-server-id", data: Data = or
|
||||
AgentAttachment(id: id, kind: .file, filename: name, mimeType: "application/octet-stream", sizeBytes: data.count,
|
||||
sha256: SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined(), workspacePath: "/runtime/provided/\(name)")
|
||||
}
|
||||
private func descriptorData(capability: Bool = true) throws -> Data {
|
||||
private func descriptorData(capability: Bool = true, assistantImages: Bool = false) throws -> Data {
|
||||
var object: [String: Any] = ["id": "root", "title": "Sybil", "type": "persistent-agent", "model": "fixture", "protocol": "pi-room.v1", "websocket": "/v1/agent/ws",
|
||||
"activity": ["state": "idle", "isThinking": false, "queued": 0, "backgroundWorkers": 0, "tools": [], "queuedMessages": []]]
|
||||
if capability { object["capabilities"] = ["attachments": try JSONSerialization.jsonObject(with: JSONEncoder().encode(attachmentLimits)), "future": true] }
|
||||
if assistantImages {
|
||||
var capabilities = object["capabilities"] as? [String: Any] ?? [:]
|
||||
capabilities["assistantImages"] = ["version": 1]
|
||||
object["capabilities"] = capabilities
|
||||
}
|
||||
object["unknown"] = ["additive": true]
|
||||
return try JSONSerialization.data(withJSONObject: object)
|
||||
}
|
||||
@@ -124,6 +129,106 @@ private func requestBytes(_ request: URLRequest) -> Data {
|
||||
}
|
||||
|
||||
@Suite(.serialized) struct AgentAttachmentTests {
|
||||
@Test @MainActor func assistantCapabilityDoesNotAdmitInputAndRequiresToken() async throws {
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.path == "/api/v1/agent") // Never upload or submit references.
|
||||
return (200, [:], try descriptorData(capability: false, assistantImages: true))
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
for token in [nil, " ", "synthetic-test"] {
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: token), session: session)
|
||||
let descriptor = try await client.agentDescriptor()
|
||||
#expect(descriptor.capabilities?.attachments == nil)
|
||||
#expect((descriptor.capabilities?.assistantImages?.supported == true) == (token == "synthetic-test"))
|
||||
let draft = AgentAttachmentDraft()
|
||||
draft.configure(client: client, storage: nil); draft.updateCapability(descriptor.capabilities?.attachments)
|
||||
#expect(!draft.supportsAttachments)
|
||||
#expect(draft.add(filename: "original.bin") { .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes) } == nil)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentUpload(file: .init(filename: "original.bin", mimeType: "application/octet-stream", data: originalBytes), uploadID: UUID().uuidString) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentSubmit(AgentSubmission(content: "", requestID: "output-only", attachments: [sampleAttachment()])) }
|
||||
}
|
||||
for json in [#"{"assistantImages":{"version":2}}"#, #"{"assistantImages":{"version":"future"}}"#] {
|
||||
#expect(try JSONDecoder().decode(AgentCapabilities.self, from: Data(json.utf8)).assistantImages?.supported != true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func assistantDescriptorsRequireCompletedAssistantImageAndSafeFields() throws {
|
||||
let image = AgentAttachment(id: "output-image", kind: .image, filename: "landscape.png", mimeType: "image/png", sizeBytes: 12, sha256: String(repeating: "a", count: 64))
|
||||
var row = Message(id: "outbound:1", createdAt: Date(), role: .assistant, content: "", metadata: .object([
|
||||
"kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([image.metadataValue])]))
|
||||
#expect(row.agentAttachments == [image])
|
||||
for role in [MessageRole.system, .tool] {
|
||||
var wrong = row; wrong.role = role
|
||||
#expect(wrong.agentAttachments.isEmpty)
|
||||
}
|
||||
for (key, value) in [("kind", "tool_call"), ("kind", "other"), ("status", "queued"), ("status", "streaming")] {
|
||||
var wrong = row, fields = row.metadata!.objectValue!
|
||||
fields[key] = .string(value); wrong.metadata = .object(fields)
|
||||
#expect(wrong.agentAttachments.isEmpty)
|
||||
}
|
||||
var descriptors: [JSONValue] = [sampleAttachment().metadataValue]
|
||||
for key in ["workspacePath", "sourcePath", "privatePath", "url", "sourceURL", "downloadUrl", "external", "future"] {
|
||||
var fields = image.metadataValue.objectValue!; fields[key] = .string("/private/synthetic.png")
|
||||
descriptors.append(.object(fields))
|
||||
}
|
||||
for (key, value) in [("filename", "../private.png"), ("filename", "https://example.invalid/a.png"), ("mimeType", "image/svg+xml"), ("sha256", "bad")] {
|
||||
var fields = image.metadataValue.objectValue!; fields[key] = .string(value)
|
||||
descriptors.append(.object(fields))
|
||||
}
|
||||
for descriptor in descriptors {
|
||||
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([descriptor])])
|
||||
#expect(row.agentAttachments.isEmpty)
|
||||
}
|
||||
row.role = .user
|
||||
row.metadata = .object(["agentAttachments": .array([sampleAttachment().metadataValue])])
|
||||
#expect(row.agentAttachments == [sampleAttachment()])
|
||||
}
|
||||
|
||||
@Test @MainActor func imageOnlyAssistantLiveAndHistoryUseSameMergeAndAuthenticatedLoader() async throws {
|
||||
let png = try #require(UIGraphicsImageRenderer(size: CGSize(width: 24, height: 16)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 24, height: 16))
|
||||
}.pngData())
|
||||
let attachment = AgentAttachment(id: "output/opaque", kind: .image, filename: "landscape.png", mimeType: "image/png", sizeBytes: png.count,
|
||||
sha256: SHA256.hash(data: png).map { String(format: "%02x", $0) }.joined())
|
||||
let row = Message(id: "outbound:1", createdAt: Date(timeIntervalSince1970: 1), role: .assistant, content: "", metadata: .object([
|
||||
"kind": .string("message"), "status": .string("complete"), "entryId": .number(1), "agentAttachments": .array([attachment.metadataValue])]))
|
||||
let page = AgentPage(conversationId: "fixture-root", messages: [row], cursor: "1", hasMore: false)
|
||||
let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601
|
||||
let bytes = try encoder.encode(page)
|
||||
var envelope = try JSONSerialization.jsonObject(with: bytes) as! [String: Any]; envelope["type"] = "messages"
|
||||
let decodedEvent = try AgentEvent.decode(JSONSerialization.data(withJSONObject: envelope))
|
||||
let event = try #require(decodedEvent)
|
||||
let room = SybilAgentRoom(); defer { room.stop() }
|
||||
try room.receive(event); room.flush()
|
||||
var history = AgentRoomState()
|
||||
try history.apply(AgentCoding.decoder().decode(AgentPage.self, from: bytes))
|
||||
// Replay must not duplicate a completed output; no current capability is needed.
|
||||
try room.receive(event); room.flush()
|
||||
#expect(AgentTranscript(messages: room.messages).displayed == AgentTranscript(messages: history.messages).displayed)
|
||||
#expect(room.messages.count == 1); #expect(room.messages[0].content.isEmpty)
|
||||
#expect(room.messages[0].agentAttachments == [attachment])
|
||||
var presentation = AgentPresentation()
|
||||
presentation.receive(event, before: AgentRoomState(), after: history, replay: false, now: Date())
|
||||
#expect(presentation.suppressed)
|
||||
|
||||
AttachmentURLProtocol.install { request in
|
||||
#expect(request.url!.absoluteString == "https://fixture.invalid/api/v1/agent/attachments/output%2Fopaque/content")
|
||||
#expect(request.httpMethod == "GET")
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer synthetic-test")
|
||||
return (200, ["Content-Length": String(png.count)], png)
|
||||
}
|
||||
let session = attachmentSession(); defer { session.invalidateAndCancel() }
|
||||
let client = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!, authToken: "synthetic-test"), session: session)
|
||||
let downloads = AgentAttachmentDownloads()
|
||||
#expect(try await downloads.data(for: attachment, client: client) == png)
|
||||
AttachmentURLProtocol.install { _ in Issue.record("Verified output should be cached"); return (500, [:], Data()) }
|
||||
#expect(try await downloads.data(for: attachment, client: client) == png)
|
||||
AttachmentURLProtocol.install { _ in (200, [:], Data(repeating: 0, count: png.count)) }
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await client.agentDownload(attachment) }
|
||||
let unauthenticated = SybilAPIClient(configuration: .init(baseURL: URL(string: "https://fixture.invalid/api")!), session: session)
|
||||
await #expect(throws: AgentAttachmentIssue.self) { try await unauthenticated.agentDownload(attachment) }
|
||||
}
|
||||
|
||||
@Test func additiveCapabilityAndDistinctMetadata() throws {
|
||||
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
|
||||
#expect(old.capabilities == nil)
|
||||
|
||||
@@ -6,6 +6,57 @@ import UIKit
|
||||
|
||||
@Suite @MainActor
|
||||
struct MessagePresentationTests {
|
||||
@Test func agentMarkdownKeepsAltAndFormattingWithoutImageOrHTMLNodes() {
|
||||
let source = """
|
||||
**Caption** 
|
||||
|
||||
![Reference landscape][photo]
|
||||
|
||||
[](https://example.invalid)
|
||||
|
||||
> ](data:image/png;base64,AAAA)
|
||||
|
||||
<picture><source srcset="https://images.invalid/source.png"><img src="https://images.invalid/html.png" alt="HTML landscape" title="a > b"></picture>
|
||||
|
||||
[Useful link](https://example.invalid)
|
||||
|
||||
[photo]: https://images.invalid/reference.png
|
||||
"""
|
||||
let content = AgentMessageMarkdown.content(source)
|
||||
let html = content.renderHTML(), plain = content.renderPlainText()
|
||||
for label in ["Inline landscape", "Reference landscape", "Linked landscape", "Nested inner", "HTML landscape"] {
|
||||
#expect(plain.contains(label))
|
||||
}
|
||||
#expect(!html.contains("<img")); #expect(!html.contains("<picture")); #expect(!html.contains("<source"))
|
||||
#expect(!html.contains("images.invalid")); #expect(!html.contains("/private/"))
|
||||
#expect(html.contains("<strong>Caption</strong>"))
|
||||
#expect(html.contains(#"href="https://example.invalid""#))
|
||||
// Normal chat still uses unmodified MarkdownUI content.
|
||||
#expect(MarkdownContent(source).renderHTML().contains("<img"))
|
||||
#expect(AgentMessageMarkdown.content("<img src='https://images.invalid/a.png' alt='HTML landscape'>\n").renderHTML() == "<p>HTML landscape</p>\n")
|
||||
}
|
||||
|
||||
@Test func agentMarkdownRetainsCodeTablesAndEscapesImageLikeAltText() {
|
||||
let source = """
|
||||
``
|
||||
|
||||
```html
|
||||
<img src="literal-code">
|
||||
```
|
||||
|
||||
| Image | Value |
|
||||
| --- | --- |
|
||||
|  | **Kept** |
|
||||
|
||||
<IMG ALT='' SRC='ignored'>
|
||||
"""
|
||||
let html = AgentMessageMarkdown.content(source).renderHTML()
|
||||
#expect(html.contains("<table>")); #expect(html.contains("Table alt"))
|
||||
#expect(html.contains("<strong>Kept</strong>"))
|
||||
#expect(html.contains("<img src=")); #expect(html.contains("![literal]"))
|
||||
#expect(html.contains("![literal alt]")); #expect(!html.contains("<img"))
|
||||
}
|
||||
|
||||
@Test func userMarkupRemainsLiteral() {
|
||||
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
|
||||
let content = SybilMessageText.literalContent(source)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||
"""
|
||||
import asyncio
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import hashlib
|
||||
@@ -23,6 +24,8 @@ reply_tasks = set()
|
||||
attachments = {}
|
||||
uploads = {}
|
||||
attachment_capability = True
|
||||
assistant_image_capability = False
|
||||
fail_image_download = False
|
||||
next_upload_error = None
|
||||
blocked_proxy = False
|
||||
transcript_comparison = False
|
||||
@@ -95,6 +98,7 @@ async def reply(content):
|
||||
|
||||
async def route(request):
|
||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
|
||||
global assistant_image_capability, fail_image_download
|
||||
path = request.path
|
||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||
if path == '/control':
|
||||
@@ -106,6 +110,7 @@ async def route(request):
|
||||
reply_tasks.clear()
|
||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
||||
attachment_capability = True; next_upload_error = None; blocked_proxy = False; transcript_comparison = False
|
||||
assistant_image_capability = False; fail_image_download = False
|
||||
push_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
|
||||
push_available = True; auth_required = False
|
||||
for i in range(body.get('count', 3)):
|
||||
@@ -120,6 +125,16 @@ async def route(request):
|
||||
transcript_comparison = True
|
||||
elif action == 'capability':
|
||||
attachment_capability = body.get('enabled', True)
|
||||
assistant_image_capability = body.get('assistantImages', False)
|
||||
elif action == 'assistant-image':
|
||||
# Synthetic output supplied by XCTest, never a workspace file or provider.
|
||||
data = base64.b64decode(body['png'], validate=True)
|
||||
descriptor = dict(id='fixture-output-image', kind='image', filename='fixture-landscape.png',
|
||||
mimeType='image/png', sizeBytes=len(data), sha256=hashlib.sha256(data).hexdigest())
|
||||
attachments[descriptor['id']] = dict(descriptor=descriptor, data=data)
|
||||
fail_image_download = body.get('failFirstDownload', False)
|
||||
row = message(body.get('content', ''), info={'agentAttachments': [descriptor]})
|
||||
await broadcast(dict(type='messages', **page([row])))
|
||||
elif action == 'upload-error':
|
||||
next_upload_error = body
|
||||
elif action == 'blocked-proxy':
|
||||
@@ -190,6 +205,8 @@ async def route(request):
|
||||
descriptor = dict(id='fixture-root', title='Sybil Agent', type='persistent-agent', model='fixture', protocol='pi-room.v1', websocket='/v1/agent/ws', activity=activity())
|
||||
if attachment_capability:
|
||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||
if assistant_image_capability:
|
||||
descriptor.setdefault('capabilities', {})['assistantImages'] = dict(version=1)
|
||||
return web.json_response(descriptor)
|
||||
if path == '/api/v1/agent/push/config':
|
||||
if not push_available:
|
||||
@@ -263,6 +280,9 @@ async def route(request):
|
||||
uploads[upload_id] = (identity, attachment_id)
|
||||
return web.json_response({'attachment': descriptor}, status=201)
|
||||
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
|
||||
if fail_image_download:
|
||||
fail_image_download = False
|
||||
return attachment_error(503, 'fixture_retry', 'Synthetic image unavailable. Retry the download.')
|
||||
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
|
||||
stored = attachments.get(attachment_id)
|
||||
if stored is None:
|
||||
|
||||
@@ -65,6 +65,59 @@ final class AgentUITests: XCTestCase {
|
||||
return try XCTUnwrap(json["attachment"] as? [String: Any])
|
||||
}
|
||||
|
||||
func testAssistantImageOnlyLiveRetryAndHistoryWithoutInputCapability() async throws {
|
||||
app.terminate()
|
||||
try await control(["action": "reset", "count": 0])
|
||||
try await control(["action": "capability", "enabled": false, "assistantImages": true])
|
||||
try await control(["action": "push-available", "enabled": false])
|
||||
app.launch(); openAgent()
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
let image = UIGraphicsImageRenderer(size: CGSize(width: 320, height: 180)).image { context in
|
||||
UIColor.systemTeal.setFill(); context.fill(CGRect(x: 0, y: 0, width: 320, height: 180))
|
||||
UIColor.systemYellow.setFill(); context.cgContext.fillEllipse(in: CGRect(x: 225, y: 25, width: 50, height: 50))
|
||||
UIColor.systemIndigo.setFill()
|
||||
context.cgContext.move(to: CGPoint(x: 0, y: 180)); context.cgContext.addLine(to: CGPoint(x: 120, y: 55))
|
||||
context.cgContext.addLine(to: CGPoint(x: 250, y: 180)); context.cgContext.fillPath()
|
||||
}
|
||||
try await control(["action": "reply", "content": "\n\n<img src='http://127.0.0.1:8799/blocked-html.png' alt='HTML landscape'>"])
|
||||
try await control(["action": "assistant-image", "content": "", "png": try XCTUnwrap(image.pngData()).base64EncodedString(), "failFirstDownload": true])
|
||||
XCTAssertTrue(app.staticTexts["fixture-landscape.png"].waitForExistence(timeout: 8))
|
||||
XCTAssertTrue(app.buttons["Retry download"].waitForExistence(timeout: 8))
|
||||
let imageFrame = app.otherElements["agent-image-frame"]
|
||||
XCTAssertTrue(imageFrame.exists)
|
||||
let placeholder = imageFrame.frame
|
||||
XCTAssertGreaterThan(placeholder.width, 0)
|
||||
XCTAssertEqual(placeholder.height, 180, accuracy: 1)
|
||||
app.buttons["Retry download"].tap()
|
||||
let preview = app.images["agent-image-preview"]
|
||||
XCTAssertTrue(preview.waitForExistence(timeout: 8))
|
||||
XCTAssertEqual(preview.label, "Image: fixture-landscape.png")
|
||||
XCTAssertLessThanOrEqual(preview.frame.height, 180)
|
||||
XCTAssertEqual(imageFrame.frame.width, placeholder.width, accuracy: 1)
|
||||
XCTAssertEqual(imageFrame.frame.height, placeholder.height, accuracy: 1)
|
||||
let loadedSize = imageFrame.frame.size
|
||||
XCTAssertTrue(app.staticTexts["Markdown landscape"].exists)
|
||||
XCTAssertTrue(app.staticTexts["HTML landscape"].exists)
|
||||
XCTAssertFalse(app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "Copy workspace path:")).firstMatch.exists)
|
||||
capture("agent-assistant-image-live")
|
||||
|
||||
// Previously saved output remains visible when both capabilities disappear.
|
||||
try await control(["action": "capability", "enabled": false, "assistantImages": false])
|
||||
app.terminate(); app.launch(); openAgent()
|
||||
XCTAssertTrue(preview.waitForExistence(timeout: 8))
|
||||
XCTAssertEqual(imageFrame.frame.width, placeholder.width, accuracy: 1)
|
||||
XCTAssertEqual(imageFrame.frame.height, placeholder.height, accuracy: 1)
|
||||
print("Assistant image frame sizes: placeholder=\(placeholder.size), live=\(loadedSize), history=\(imageFrame.frame.size)")
|
||||
XCTAssertEqual(app.staticTexts.matching(NSPredicate(format: "label == %@", "fixture-landscape.png")).count, 1)
|
||||
XCTAssertFalse(app.buttons["agent-attach"].exists)
|
||||
capture("agent-assistant-image-history")
|
||||
let state = try await control(["action": "state"])
|
||||
let log = try XCTUnwrap(state["log"] as? [[String: Any]])
|
||||
XCTAssertFalse(log.contains { ($0["path"] as? String)?.hasPrefix("/blocked-") == true })
|
||||
XCTAssertGreaterThanOrEqual(log.filter { ($0["path"] as? String) == "/api/v1/agent/attachments/fixture-output-image/content" }.count, 3)
|
||||
XCTAssertTrue((state["requests"] as? [String: Any])?.isEmpty == true)
|
||||
}
|
||||
|
||||
func testAttachmentHistoryDownloadAndReload() async throws {
|
||||
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
|
||||
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
|
||||
|
||||
Reference in New Issue
Block a user