Compare commits
2
Commits
9098fee32a
...
b743523fd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b743523fd5 | ||
|
|
ee845c9fa3 |
@@ -15,7 +15,9 @@ let package = Package(
|
|||||||
targets: ["Sybil"]),
|
targets: ["Sybil"]),
|
||||||
],
|
],
|
||||||
dependencies: [
|
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: [
|
||||||
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
// Targets are the basic building blocks of a package, defining a module or a test suite.
|
||||||
@@ -23,7 +25,9 @@ let package = Package(
|
|||||||
.target(
|
.target(
|
||||||
name: "Sybil",
|
name: "Sybil",
|
||||||
dependencies: [
|
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")]),
|
resources: [.process("Resources")]),
|
||||||
.testTarget(
|
.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 {
|
guard value.type == "persistent-agent", value.protocol == "pi-room.v1" else {
|
||||||
throw AgentFailure(message: "Unsupported Agent room protocol.")
|
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
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,9 +174,15 @@ struct AgentAttachmentCard: View {
|
|||||||
if attachment.kind == .image {
|
if attachment.kind == .image {
|
||||||
ZStack {
|
ZStack {
|
||||||
SybilTheme.surface
|
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) }
|
else { Image(systemName: "photo").foregroundStyle(SybilTheme.textMuted) }
|
||||||
}.frame(height: 180).clipped()
|
}.frame(height: 180).clipped()
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("agent-image-frame")
|
||||||
}
|
}
|
||||||
if let text {
|
if let text {
|
||||||
Text(text).font(.system(.caption, design: .monospaced)).lineLimit(6)
|
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 {
|
struct AgentCapabilities: Decodable, Sendable {
|
||||||
var attachments: AgentAttachmentCapabilities?
|
var attachments: AgentAttachmentCapabilities?
|
||||||
init(attachments: AgentAttachmentCapabilities? = nil) { self.attachments = attachments }
|
var assistantImages: AgentAssistantImageCapabilities?
|
||||||
private enum CodingKeys: String, CodingKey { case attachments }
|
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 {
|
init(from decoder: Decoder) throws {
|
||||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
// Unknown versions or malformed future capabilities must not break text chat.
|
// Unknown versions or malformed future capabilities must not break text chat.
|
||||||
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
|
attachments = try? values.decode(AgentAttachmentCapabilities.self, forKey: .attachments)
|
||||||
|
assistantImages = try? values.decode(AgentAssistantImageCapabilities.self, forKey: .assistantImages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,13 +101,34 @@ struct AgentAttachment: Codable, Hashable, Identifiable, Sendable {
|
|||||||
|
|
||||||
extension Message {
|
extension Message {
|
||||||
var agentAttachments: [AgentAttachment] {
|
var agentAttachments: [AgentAttachment] {
|
||||||
guard let entries = metadata?.objectValue?["agentAttachments"]?.arrayValue else { return [] }
|
guard role == .user || role == .assistant,
|
||||||
return entries.compactMap {
|
let info = metadata?.objectValue,
|
||||||
guard let data = try? JSONEncoder().encode($0),
|
let entries = info["agentAttachments"]?.arrayValue else { return [] }
|
||||||
|
if role == .assistant {
|
||||||
|
guard info["kind"]?.stringValue == "message", info["status"]?.stringValue == "complete",
|
||||||
|
entries.count <= 8 else { return [] }
|
||||||
|
}
|
||||||
|
let attachments: [AgentAttachment] = 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),
|
let attachment = try? JSONDecoder().decode(AgentAttachment.self, from: data),
|
||||||
(try? attachment.validate()) != nil else { return nil }
|
(try? attachment.validate()) != nil else { return nil }
|
||||||
return attachment
|
return attachment
|
||||||
}
|
}
|
||||||
|
if role == .assistant {
|
||||||
|
// Reject the entire output set, including partially invalid arrays. The
|
||||||
|
// count and per-image bounds above also bound this aggregate calculation.
|
||||||
|
guard attachments.count == entries.count,
|
||||||
|
Set(attachments.map(\.id)).count == attachments.count,
|
||||||
|
attachments.reduce(0, { $0 + $1.sizeBytes }) <= 32 * 1024 * 1024 else { return [] }
|
||||||
|
}
|
||||||
|
return attachments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,7 +239,7 @@ struct AgentPresentation {
|
|||||||
private(set) var suppressed = false
|
private(set) var suppressed = false
|
||||||
private(set) var busySince: Date?
|
private(set) var busySince: Date?
|
||||||
private var seenTools: Set<String> = []
|
private var seenTools: Set<String> = []
|
||||||
static let quietTools: Set<String> = ["user_message", "notify_parent", "clock_wait", "event_decision"]
|
static let quietTools: Set<String> = ["user_message", "output_image", "notify_parent", "clock_wait", "event_decision"]
|
||||||
|
|
||||||
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
mutating func receive(_ event: AgentEvent, before: AgentRoomState, after: AgentRoomState, replay: Bool, now: Date) {
|
||||||
switch event {
|
switch event {
|
||||||
@@ -249,7 +249,7 @@ struct AgentPresentation {
|
|||||||
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
.sorted { AgentCursor.less($0.agentEntryCursor ?? "0", $1.agentEntryCursor ?? "0") }
|
||||||
if !replay {
|
if !replay {
|
||||||
for message in fresh {
|
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.role == .user { suppressed = false }
|
||||||
else if message.toolCallMetadata?.status == "initiated",
|
else if message.toolCallMetadata?.status == "initiated",
|
||||||
!Self.quietTools.contains(message.toolCallMetadata?.toolName ?? ""), !seenTools.contains(message.agentToolID) { suppressed = false }
|
!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
|
ForEach(message.agentAttachments) { attachment in
|
||||||
AgentAttachmentCard(attachment: attachment, room: room)
|
AgentAttachmentCard(attachment: attachment, room: room)
|
||||||
}
|
}
|
||||||
|
if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || message.agentAttachments.isEmpty {
|
||||||
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
MessageBubble(message: message, isSending: false, streamingAssistantPresentation: nil, isForking: false, onFork: nil, agentStyle: true)
|
||||||
|
}
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
Text(own ? "You" : "Sybil")
|
Text(own ? "You" : "Sybil")
|
||||||
Text(message.createdAt, style: .time)
|
Text(message.createdAt, style: .time)
|
||||||
|
|||||||
@@ -223,14 +223,14 @@ struct MessageBubble: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if isStreamingAssistant {
|
if isStreamingAssistant && !agentStyle {
|
||||||
StreamingAssistantContentView(
|
StreamingAssistantContentView(
|
||||||
content: message.content,
|
content: message.content,
|
||||||
isSending: isSending,
|
isSending: isSending,
|
||||||
presentation: matchingStreamingAssistantPresentation
|
presentation: matchingStreamingAssistantPresentation
|
||||||
)
|
)
|
||||||
} else if !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
} 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)
|
.padding(.horizontal, isUser || agentStyle ? 14 : 2)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct SybilMessageText: View {
|
|||||||
enum Format { case literal, markdown }
|
enum Format { case literal, markdown }
|
||||||
var content: String
|
var content: String
|
||||||
var format: Format
|
var format: Format
|
||||||
|
var agentStyle = false
|
||||||
|
|
||||||
static func literalContent(_ text: String) -> MarkdownContent {
|
static func literalContent(_ text: String) -> MarkdownContent {
|
||||||
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
|
// Pasted CRLF/CR and blank lines containing spaces or tabs have the same
|
||||||
@@ -31,7 +32,7 @@ struct SybilMessageText: View {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
let _ = AgentRenderDiagnostics.record("messageTextBuild")
|
let _ = AgentRenderDiagnostics.record("messageTextBuild")
|
||||||
#endif
|
#endif
|
||||||
Markdown(format == .literal ? Self.literalContent(content) : MarkdownContent(content))
|
Markdown(format == .literal ? Self.literalContent(content) : agentStyle ? AgentMessageMarkdown.content(content) : MarkdownContent(content))
|
||||||
.tint(SybilTheme.primary)
|
.tint(SybilTheme.primary)
|
||||||
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
.foregroundStyle(format == .literal ? SybilTheme.text : SybilTheme.text.opacity(0.95))
|
||||||
.markdownTheme(.sybilReadable)
|
.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,
|
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)")
|
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",
|
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": []]]
|
"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 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]
|
object["unknown"] = ["additive": true]
|
||||||
return try JSONSerialization.data(withJSONObject: object)
|
return try JSONSerialization.data(withJSONObject: object)
|
||||||
}
|
}
|
||||||
@@ -124,6 +129,126 @@ private func requestBytes(_ request: URLRequest) -> Data {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Suite(.serialized) struct AgentAttachmentTests {
|
@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.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array([image.metadataValue, descriptor])])
|
||||||
|
#expect(row.agentAttachments.isEmpty) // No valid prefix from an invalid output set.
|
||||||
|
}
|
||||||
|
let maximum = (0..<8).map { index in
|
||||||
|
var item = image; item.id = "image-\(index)"; item.sizeBytes = 4 * 1024 * 1024
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array(maximum.map(\.metadataValue))])
|
||||||
|
#expect(row.agentAttachments == maximum) // Exactly eight IDs and 32 MiB.
|
||||||
|
let tooMany = (0..<9).map { index in
|
||||||
|
var item = image; item.id = "image-\(index)"
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
var tooLarge = maximum; tooLarge[0].sizeBytes += 1
|
||||||
|
for invalid in [tooMany, [image, image], tooLarge] {
|
||||||
|
row.role = .assistant
|
||||||
|
row.metadata = .object(["kind": .string("message"), "status": .string("complete"), "agentAttachments": .array(invalid.map(\.metadataValue))])
|
||||||
|
#expect(row.agentAttachments.isEmpty)
|
||||||
|
row.role = .user
|
||||||
|
#expect(row.agentAttachments == invalid) // These new output limits do not change legacy input parsing.
|
||||||
|
}
|
||||||
|
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 {
|
@Test func additiveCapabilityAndDistinctMetadata() throws {
|
||||||
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
|
let old = try AgentCoding.decoder().decode(AgentDescriptor.self, from: descriptorData(capability: false))
|
||||||
#expect(old.capabilities == nil)
|
#expect(old.capabilities == nil)
|
||||||
|
|||||||
@@ -347,6 +347,33 @@ struct AgentTests {
|
|||||||
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
|
#expect(presentation.isTyping(now: now.addingTimeInterval(7.3)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func outputImageActivityKeepsImageReplyTypingSuppressed() throws {
|
||||||
|
var presentation = AgentPresentation(), state = AgentRoomState()
|
||||||
|
let now = Date(timeIntervalSince1970: 100)
|
||||||
|
state.activity.isThinking = true
|
||||||
|
presentation.sent(now: now, activity: state.activity)
|
||||||
|
#expect(presentation.isTyping(now: now.addingTimeInterval(0.3)))
|
||||||
|
let image = AgentAttachment(id: "image", kind: .image, filename: "image.png", mimeType: "image/png", sizeBytes: 12, sha256: String(repeating: "a", count: 64))
|
||||||
|
var reply = agentMessage("image-reply", entry: "1", info: ["status": .string("complete"), "agentAttachments": .array([image.metadataValue])])
|
||||||
|
reply.content = ""
|
||||||
|
let page = agentPage([reply], cursor: "1")
|
||||||
|
var after = state; try after.apply(page)
|
||||||
|
presentation.receive(.messages(page), before: state, after: after, replay: false, now: now.addingTimeInterval(1))
|
||||||
|
state = after
|
||||||
|
#expect(presentation.suppressed)
|
||||||
|
|
||||||
|
after.activity.tools = [.init(id: "output-activity", name: "output_image", status: "initiated")]
|
||||||
|
presentation.receive(.activity("root", after.activity), before: state, after: after, replay: false, now: now.addingTimeInterval(2))
|
||||||
|
state = after
|
||||||
|
#expect(!presentation.isTyping(now: now.addingTimeInterval(3)))
|
||||||
|
|
||||||
|
let tool = agentPage([agentMessage("output-tool", entry: "2", role: .tool, info: ["toolName": .string("output_image"), "status": .string("initiated")])], cursor: "2")
|
||||||
|
try after.apply(tool)
|
||||||
|
presentation.receive(.messages(tool), before: state, after: after, replay: false, now: now.addingTimeInterval(4))
|
||||||
|
#expect(presentation.suppressed)
|
||||||
|
#expect(!presentation.isTyping(now: now.addingTimeInterval(5)))
|
||||||
|
}
|
||||||
|
|
||||||
@Test func socketURLAndAuthentication() throws {
|
@Test func socketURLAndAuthentication() throws {
|
||||||
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
|
let config = APIConfiguration(baseURL: URL(string: "https://sybil.bajor.cloud/api/?accidental=query")!, authToken: " token ")
|
||||||
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
|
#expect(try config.agentWebSocketURL().absoluteString == "wss://sybil.bajor.cloud/api/v1/agent/ws")
|
||||||
|
|||||||
@@ -6,6 +6,57 @@ import UIKit
|
|||||||
|
|
||||||
@Suite @MainActor
|
@Suite @MainActor
|
||||||
struct MessagePresentationTests {
|
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() {
|
@Test func userMarkupRemainsLiteral() {
|
||||||
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
|
let source = "**bold** [link](https://example.invalid) `code`\n- item\n<script>literal</script>"
|
||||||
let content = SybilMessageText.literalContent(source)
|
let content = SybilMessageText.literalContent(source)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
Run with Python + aiohttp, then xcodebuild test -scheme SybilAgentUI.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -23,6 +24,8 @@ reply_tasks = set()
|
|||||||
attachments = {}
|
attachments = {}
|
||||||
uploads = {}
|
uploads = {}
|
||||||
attachment_capability = True
|
attachment_capability = True
|
||||||
|
assistant_image_capability = False
|
||||||
|
fail_image_download = False
|
||||||
next_upload_error = None
|
next_upload_error = None
|
||||||
blocked_proxy = False
|
blocked_proxy = False
|
||||||
transcript_comparison = False
|
transcript_comparison = False
|
||||||
@@ -95,6 +98,7 @@ async def reply(content):
|
|||||||
|
|
||||||
async def route(request):
|
async def route(request):
|
||||||
global read_cursor, cursor, busy, attachment_capability, next_upload_error, blocked_proxy, transcript_comparison, push_available, auth_required
|
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
|
path = request.path
|
||||||
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
log.append(dict(method=request.method, path=path, query=dict(request.query)))
|
||||||
if path == '/control':
|
if path == '/control':
|
||||||
@@ -106,6 +110,7 @@ async def route(request):
|
|||||||
reply_tasks.clear()
|
reply_tasks.clear()
|
||||||
messages.clear(); requests.clear(); attempts.clear(); cursor = 0; read_cursor = 0; busy = False
|
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
|
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_installations.clear(); push_retired.clear(); push_requests.clear(); log.clear()
|
||||||
push_available = True; auth_required = False
|
push_available = True; auth_required = False
|
||||||
for i in range(body.get('count', 3)):
|
for i in range(body.get('count', 3)):
|
||||||
@@ -120,6 +125,16 @@ async def route(request):
|
|||||||
transcript_comparison = True
|
transcript_comparison = True
|
||||||
elif action == 'capability':
|
elif action == 'capability':
|
||||||
attachment_capability = body.get('enabled', True)
|
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':
|
elif action == 'upload-error':
|
||||||
next_upload_error = body
|
next_upload_error = body
|
||||||
elif action == 'blocked-proxy':
|
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())
|
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:
|
if attachment_capability:
|
||||||
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
descriptor['capabilities'] = {'attachments': CAPABILITY}
|
||||||
|
if assistant_image_capability:
|
||||||
|
descriptor.setdefault('capabilities', {})['assistantImages'] = dict(version=1)
|
||||||
return web.json_response(descriptor)
|
return web.json_response(descriptor)
|
||||||
if path == '/api/v1/agent/push/config':
|
if path == '/api/v1/agent/push/config':
|
||||||
if not push_available:
|
if not push_available:
|
||||||
@@ -263,6 +280,9 @@ async def route(request):
|
|||||||
uploads[upload_id] = (identity, attachment_id)
|
uploads[upload_id] = (identity, attachment_id)
|
||||||
return web.json_response({'attachment': descriptor}, status=201)
|
return web.json_response({'attachment': descriptor}, status=201)
|
||||||
if path.startswith('/api/v1/agent/attachments/') and path.endswith('/content'):
|
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')
|
attachment_id = path.removeprefix('/api/v1/agent/attachments/').removesuffix('/content')
|
||||||
stored = attachments.get(attachment_id)
|
stored = attachments.get(attachment_id)
|
||||||
if stored is None:
|
if stored is None:
|
||||||
|
|||||||
@@ -65,6 +65,59 @@ final class AgentUITests: XCTestCase {
|
|||||||
return try XCTUnwrap(json["attachment"] as? [String: Any])
|
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 {
|
func testAttachmentHistoryDownloadAndReload() async throws {
|
||||||
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
|
let original = Data("Synthetic original text. <script>plain text only</script>".utf8)
|
||||||
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
|
let file = try await uploadOriginal(name: "fixture-original.txt", mime: "text/plain", bytes: original)
|
||||||
|
|||||||
Reference in New Issue
Block a user