feat: support attachment-only messages and context opening
This commit is contained in:
1 parent
4caf472558
commit
0191db5447
18 files changed
+1386
-128
No files matched your search
@@ -256,7 +256,7 @@ final class AppModel {
|
||||
includesTerminalSuggestion: Bool = false
|
||||
) {
|
||||
let draft = conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !draft.isEmpty else { return }
|
||||
guard conversation.canSendDraft else { return }
|
||||
// The *returned* text, not `draft`: attachments are folded in there, and
|
||||
// running on the pre-fold string would show the model a turn that does
|
||||
// not match the one the conversation recorded.
|
||||
@@ -272,7 +272,8 @@ final class AppModel {
|
||||
await compactIfNeeded(conversation)
|
||||
startAgentRun(
|
||||
conversation, promptID: prompt.id, prompt: prompt.text,
|
||||
authoredPrompt: prompt.authoredText, promptImages: prompt.images, context: context,
|
||||
authoredPrompt: prompt.authoredText, promptImages: prompt.images,
|
||||
promptContextAttachments: prompt.contextAttachments, context: context,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion
|
||||
)
|
||||
}
|
||||
@@ -284,6 +285,7 @@ final class AppModel {
|
||||
prompt: String? = nil,
|
||||
authoredPrompt: String? = nil,
|
||||
promptImages: [AgentImageAttachment]? = nil,
|
||||
promptContextAttachments: [AgentContextAttachment]? = nil,
|
||||
context: SessionContext = .none,
|
||||
includesTerminalSuggestion: Bool = false
|
||||
) {
|
||||
@@ -295,7 +297,11 @@ final class AppModel {
|
||||
let userImages = promptImages
|
||||
?? lastUserTurn?.images
|
||||
?? []
|
||||
guard !userPrompt.isEmpty else { return }
|
||||
let userContextAttachments = promptContextAttachments
|
||||
?? lastUserTurn?.contextAttachments
|
||||
?? []
|
||||
guard !userPrompt.isEmpty || !userImages.isEmpty || !userContextAttachments.isEmpty
|
||||
else { return }
|
||||
guard let chat = ai.resolve(conversation: conversation.model) else {
|
||||
conversation.messages.append(
|
||||
ChatMessage(
|
||||
@@ -426,6 +432,7 @@ final class AppModel {
|
||||
promptID: promptID ?? lastUserTurn?.id ?? UUID(), prompt: userPrompt,
|
||||
authoredPrompt: authoredPrompt ?? lastUserTurn?.authoredText,
|
||||
promptImages: userImages,
|
||||
promptContextAttachments: userContextAttachments,
|
||||
permissionMode: mode, isPlanning: isPlanning,
|
||||
priorMessages: conversation.pendingPriorMessages,
|
||||
sessionContext: context
|
||||
@@ -598,11 +605,17 @@ final class AppModel {
|
||||
/// instead of staying labelled by its opening line forever.
|
||||
static func titleContext(for conversation: ConversationModel) -> ConversationTitleContext? {
|
||||
guard conversation.title == nil,
|
||||
let user = conversation.transcript.first(where: { $0.role == .user })?.text,
|
||||
let firstUser = conversation.transcript.first(where: { $0.role == .user }),
|
||||
let assistant = conversation.transcript.first(where: {
|
||||
$0.role == .assistant && !$0.text.isEmpty
|
||||
})?.text
|
||||
else { return nil }
|
||||
let user = firstUser.text.isEmpty
|
||||
? firstUser.images.first?.label
|
||||
?? firstUser.contextAttachments.first?.label
|
||||
?? ""
|
||||
: firstUser.text
|
||||
guard !user.isEmpty else { return nil }
|
||||
return ConversationTitleContext(
|
||||
firstUserMessage: user, firstAssistantMessage: assistant
|
||||
)
|
||||
@@ -811,6 +824,7 @@ final class AppModel {
|
||||
self.startAgentRun(
|
||||
conversation, promptID: prompt.id, prompt: prompt.text,
|
||||
authoredPrompt: prompt.authoredText, promptImages: prompt.images,
|
||||
promptContextAttachments: prompt.contextAttachments,
|
||||
context: context,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ struct TermindApp: App {
|
||||
private let launchLanguage = AppLanguage.stored()
|
||||
|
||||
init() {
|
||||
ContextAttachmentOpener.cleanPreviousSession()
|
||||
do {
|
||||
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"]
|
||||
!= nil
|
||||
|
||||
@@ -128,7 +128,9 @@ actor AgentRuntime {
|
||||
@discardableResult
|
||||
func steer(_ message: AgentTranscriptMessage) -> Bool {
|
||||
guard acceptsSteering else { return false }
|
||||
guard !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
guard !message.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !message.images.isEmpty || !message.contextAttachments.isEmpty
|
||||
else {
|
||||
return false
|
||||
}
|
||||
steering.append(message)
|
||||
@@ -164,7 +166,8 @@ actor AgentRuntime {
|
||||
}
|
||||
let prompt = AgentTranscriptMessage(
|
||||
id: request.promptID, role: .user, text: request.prompt,
|
||||
authoredText: request.authoredPrompt, images: request.promptImages
|
||||
authoredText: request.authoredPrompt, images: request.promptImages,
|
||||
contextAttachments: request.promptContextAttachments
|
||||
)
|
||||
snapshot.messages.append(prompt)
|
||||
let pipeline = configuration.contextPipeline(
|
||||
|
||||
@@ -77,7 +77,8 @@ final class AssistantConversationStore {
|
||||
id: message.id, role: .user,
|
||||
text: message.authoredText ?? ContextChip.visiblePrompt(in: message.text),
|
||||
contextChips: ContextChip.visibleReferences(
|
||||
in: message.text, images: message.images
|
||||
in: message.text, images: message.images,
|
||||
contextAttachments: message.contextAttachments
|
||||
),
|
||||
images: message.images,
|
||||
createdAt: message.createdAt
|
||||
|
||||
@@ -550,6 +550,27 @@ nonisolated struct AgentImageAttachment: Identifiable, Hashable, Sendable, Codab
|
||||
var pixelHeight: Int
|
||||
}
|
||||
|
||||
/// A user-visible, non-image context attachment retained for opening after the
|
||||
/// turn has been sent. `data` is presentation backing only: provider adapters
|
||||
/// continue to receive the extracted text embedded in the user message, never
|
||||
/// these original file bytes.
|
||||
nonisolated struct AgentContextAttachment: Identifiable, Hashable, Sendable, Codable {
|
||||
nonisolated enum Kind: String, Hashable, Sendable, Codable {
|
||||
case file, output, selection, pastedText, host, directory
|
||||
}
|
||||
|
||||
var id: UUID = UUID()
|
||||
var kind: Kind
|
||||
var label: String
|
||||
var contentTypeIdentifier: String
|
||||
var suggestedFilename: String
|
||||
var data: Data
|
||||
/// False when `data` is an exported text representation rather than an
|
||||
/// original file snapshot. Those open as `.txt`, never as a fake PDF or
|
||||
/// Office document.
|
||||
var isOriginalFile: Bool = true
|
||||
}
|
||||
|
||||
/// Provider-authored, user-visible reasoning text.
|
||||
///
|
||||
/// This is deliberately separate from `providerItems`: `text` is safe to draw
|
||||
@@ -570,6 +591,9 @@ nonisolated struct AgentTranscriptMessage: Identifiable, Hashable, Sendable, Cod
|
||||
/// Nil on assistant/tool turns and on stores created before message editing.
|
||||
var authoredText: String?
|
||||
var images: [AgentImageAttachment] = []
|
||||
/// Local presentation backing for sent context chips. This is deliberately
|
||||
/// ignored by every provider wire encoder.
|
||||
var contextAttachments: [AgentContextAttachment] = []
|
||||
var reasoning: [AgentReasoningBlock] = []
|
||||
var toolCalls: [AgentToolCall] = []
|
||||
var toolCallID: String?
|
||||
@@ -705,6 +729,7 @@ nonisolated struct AgentRunRequest: Sendable {
|
||||
var prompt: String
|
||||
var authoredPrompt: String?
|
||||
var promptImages: [AgentImageAttachment]
|
||||
var promptContextAttachments: [AgentContextAttachment]
|
||||
var permissionMode: ChatPermissionMode
|
||||
/// Whether this run may only look.
|
||||
///
|
||||
@@ -725,6 +750,7 @@ nonisolated struct AgentRunRequest: Sendable {
|
||||
prompt: String,
|
||||
authoredPrompt: String? = nil,
|
||||
promptImages: [AgentImageAttachment] = [],
|
||||
promptContextAttachments: [AgentContextAttachment] = [],
|
||||
permissionMode: ChatPermissionMode,
|
||||
isPlanning: Bool = false,
|
||||
systemPrompt: String = AgentSystemPrompt.default,
|
||||
@@ -736,6 +762,7 @@ nonisolated struct AgentRunRequest: Sendable {
|
||||
self.prompt = prompt
|
||||
self.authoredPrompt = authoredPrompt
|
||||
self.promptImages = promptImages
|
||||
self.promptContextAttachments = promptContextAttachments
|
||||
self.permissionMode = permissionMode
|
||||
self.isPlanning = isPlanning
|
||||
self.systemPrompt = systemPrompt
|
||||
|
||||
@@ -42,23 +42,23 @@ nonisolated enum ContextAttachmentImporter {
|
||||
|
||||
if ["doc", "xls", "ppt"].contains(ext) { throw ImportError.legacyOffice }
|
||||
if ext == "pdf" {
|
||||
return ContextChip(kind: .file, label: label, content: try pdfText(data))
|
||||
return fileChip(label: label, data: data, content: try pdfText(data), url: url)
|
||||
}
|
||||
if ext == "docx" {
|
||||
return ContextChip(kind: .file, label: label, content: try docxText(data))
|
||||
return fileChip(label: label, data: data, content: try docxText(data), url: url)
|
||||
}
|
||||
if ext == "pptx" {
|
||||
return ContextChip(kind: .file, label: label, content: try pptxText(data))
|
||||
return fileChip(label: label, data: data, content: try pptxText(data), url: url)
|
||||
}
|
||||
if ext == "xlsx" {
|
||||
return ContextChip(kind: .file, label: label, content: try xlsxText(data))
|
||||
return fileChip(label: label, data: data, content: try xlsxText(data), url: url)
|
||||
}
|
||||
if isImage(url: url) {
|
||||
let image = try normalizedImage(data: data, label: label)
|
||||
return ContextChip(kind: .image, label: label, image: image)
|
||||
}
|
||||
guard isText(url: url) else { throw ImportError.unsupportedFormat }
|
||||
return ContextChip(kind: .file, label: label, content: try decodedText(data))
|
||||
return fileChip(label: label, data: data, content: try decodedText(data), url: url)
|
||||
}
|
||||
return try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
@@ -89,6 +89,21 @@ nonisolated enum ContextAttachmentImporter {
|
||||
text.count >= 2_000 || text.split(separator: "\n", omittingEmptySubsequences: false).count >= 20
|
||||
}
|
||||
|
||||
private static func fileChip(
|
||||
label: String, data: Data, content: String, url: URL
|
||||
) -> ContextChip {
|
||||
let attachment = AgentContextAttachment(
|
||||
kind: .file, label: label,
|
||||
contentTypeIdentifier: UTType(filenameExtension: url.pathExtension)?.identifier
|
||||
?? UTType.data.identifier,
|
||||
suggestedFilename: label, data: data
|
||||
)
|
||||
return ContextChip(
|
||||
id: attachment.id, kind: .file, label: label, content: content,
|
||||
sourceAttachment: attachment
|
||||
)
|
||||
}
|
||||
|
||||
private static func isImage(url: URL) -> Bool {
|
||||
guard let type = UTType(filenameExtension: url.pathExtension) else { return false }
|
||||
return type.conforms(to: .image)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
nonisolated enum ContextAttachmentOpener {
|
||||
private static let directoryName = "Termind-Context-Previews"
|
||||
|
||||
static var previewDirectory: URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(directoryName, isDirectory: true)
|
||||
}
|
||||
|
||||
/// Preview files are snapshots and can contain sensitive context. They are
|
||||
/// kept only long enough for the receiving app to read them, then removed
|
||||
/// the next time Termind launches.
|
||||
static func cleanPreviousSession() {
|
||||
try? FileManager.default.removeItem(at: previewDirectory)
|
||||
}
|
||||
|
||||
static func materialize(_ attachment: AgentContextAttachment) throws -> URL {
|
||||
let manager = FileManager.default
|
||||
try manager.createDirectory(
|
||||
at: previewDirectory, withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
let name = safeFilename(for: attachment)
|
||||
let folder = previewDirectory.appendingPathComponent(
|
||||
attachment.id.uuidString, isDirectory: true
|
||||
)
|
||||
try manager.createDirectory(
|
||||
at: folder, withIntermediateDirectories: true,
|
||||
attributes: [.posixPermissions: 0o700]
|
||||
)
|
||||
let destination = folder.appendingPathComponent(name, isDirectory: false)
|
||||
if !manager.fileExists(atPath: destination.path) {
|
||||
try attachment.data.write(to: destination, options: .atomic)
|
||||
try manager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func open(_ attachment: AgentContextAttachment) async throws {
|
||||
let url = try await Task.detached(priority: .userInitiated) {
|
||||
try materialize(attachment)
|
||||
}.value
|
||||
guard NSWorkspace.shared.open(url) else {
|
||||
throw OpenError.noDefaultApplication
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func open(_ image: AgentImageAttachment) async throws {
|
||||
let type = UTType(mimeType: image.mimeType) ?? .image
|
||||
let attachment = AgentContextAttachment(
|
||||
id: image.id, kind: .file, label: image.label,
|
||||
contentTypeIdentifier: type.identifier,
|
||||
suggestedFilename: image.label, data: image.data
|
||||
)
|
||||
try await open(attachment)
|
||||
}
|
||||
|
||||
static func safeFilename(for attachment: AgentContextAttachment) -> String {
|
||||
var name = (attachment.suggestedFilename as NSString).lastPathComponent
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
name = name.unicodeScalars.map {
|
||||
CharacterSet.controlCharacters.contains($0) ? "-" : String($0)
|
||||
}.joined()
|
||||
name = name.replacingOccurrences(of: "/", with: "-")
|
||||
.replacingOccurrences(of: ":", with: "-")
|
||||
while name.hasPrefix(".") { name.removeFirst() }
|
||||
if name.isEmpty { name = "Attachment" }
|
||||
if (name as NSString).pathExtension.isEmpty,
|
||||
let ext = UTType(attachment.contentTypeIdentifier)?.preferredFilenameExtension
|
||||
{
|
||||
name += ".\(ext)"
|
||||
}
|
||||
let ext = (name as NSString).pathExtension
|
||||
guard name.count > 180, !ext.isEmpty else { return String(name.prefix(180)) }
|
||||
let suffix = ".\(ext)"
|
||||
let stemLimit = max(1, 180 - suffix.count)
|
||||
let stem = (name as NSString).deletingPathExtension
|
||||
return String(stem.prefix(stemLimit)) + suffix
|
||||
}
|
||||
|
||||
enum OpenError: LocalizedError {
|
||||
case noDefaultApplication
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noDefaultApplication:
|
||||
String(localized: "No application is available to open this attachment.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,34 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable {
|
||||
var label: String
|
||||
var content: String = ""
|
||||
var image: AgentImageAttachment? = nil
|
||||
/// Original non-image bytes used only to reopen a sent attachment. The
|
||||
/// model still receives `content`, which is the validated extraction.
|
||||
var sourceAttachment: AgentContextAttachment? = nil
|
||||
|
||||
var contextAttachment: AgentContextAttachment? {
|
||||
guard image == nil, let attachmentKind = kind.agentAttachmentKind else { return nil }
|
||||
if let sourceAttachment {
|
||||
return AgentContextAttachment(
|
||||
id: id, kind: attachmentKind, label: label,
|
||||
contentTypeIdentifier: sourceAttachment.contentTypeIdentifier,
|
||||
suggestedFilename: sourceAttachment.suggestedFilename,
|
||||
data: sourceAttachment.data,
|
||||
isOriginalFile: sourceAttachment.isOriginalFile
|
||||
)
|
||||
}
|
||||
let filename: String
|
||||
if kind == .file {
|
||||
let stem = (label as NSString).deletingPathExtension
|
||||
filename = "\(stem.isEmpty ? label : stem) extracted.txt"
|
||||
} else {
|
||||
filename = (label as NSString).pathExtension.isEmpty ? "\(label).txt" : label
|
||||
}
|
||||
return AgentContextAttachment(
|
||||
id: id, kind: attachmentKind, label: label,
|
||||
contentTypeIdentifier: "public.plain-text", suggestedFilename: filename,
|
||||
data: Data(content.utf8), isOriginalFile: false
|
||||
)
|
||||
}
|
||||
|
||||
/// Derived, not declared. A hand-passed count is a number that was true
|
||||
/// once, and the estimate exists to warn about payload size.
|
||||
@@ -93,13 +121,15 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable {
|
||||
/// Rebuilds lightweight bubble chips from the model-facing markup without
|
||||
/// retaining or rendering the payload between the tags.
|
||||
static func visibleReferences(
|
||||
in composedText: String, images: [AgentImageAttachment] = []
|
||||
in composedText: String, images: [AgentImageAttachment] = [],
|
||||
contextAttachments: [AgentContextAttachment] = []
|
||||
) -> [ContextChipReference] {
|
||||
let boundary = Kind.allCases.compactMap { kind in
|
||||
composedText.range(of: "\n\n<\(kind.blockName) label=\"")?.lowerBound
|
||||
}.min()
|
||||
|
||||
var matches: [(String.Index, ContextChipReference)] = []
|
||||
var unusedAttachments = contextAttachments
|
||||
if let boundary {
|
||||
let suffix = composedText[boundary...]
|
||||
for kind in Kind.allCases where kind != .image {
|
||||
@@ -110,13 +140,31 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable {
|
||||
guard let labelEnd = suffix.range(
|
||||
of: "\">", range: labelStart..<suffix.endIndex
|
||||
) else { break }
|
||||
let contentStart = labelEnd.upperBound
|
||||
let closing = "</\(kind.blockName)>"
|
||||
guard let contentEnd = suffix.range(
|
||||
of: closing, range: contentStart..<suffix.endIndex
|
||||
) else { break }
|
||||
let label = String(suffix[labelStart..<labelEnd.lowerBound])
|
||||
let storedIndex = unusedAttachments.firstIndex {
|
||||
$0.label.replacingOccurrences(of: "\"", with: "'") == label
|
||||
&& $0.kind == kind.agentAttachmentKind
|
||||
}
|
||||
let attachment: AgentContextAttachment? = storedIndex.map {
|
||||
unusedAttachments.remove(at: $0)
|
||||
} ?? kind.fallbackAttachment(
|
||||
label: label,
|
||||
content: String(suffix[contentStart..<contentEnd.lowerBound])
|
||||
)
|
||||
matches.append((
|
||||
opening.lowerBound,
|
||||
ContextChipReference(
|
||||
kind: kind, label: String(suffix[labelStart..<labelEnd.lowerBound])
|
||||
id: attachment?.id ?? UUID(), kind: kind,
|
||||
label: attachment?.label ?? label,
|
||||
attachment: attachment
|
||||
)
|
||||
))
|
||||
cursor = labelEnd.upperBound
|
||||
cursor = contentEnd.upperBound
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +185,41 @@ nonisolated struct ContextChipReference: Identifiable, Hashable, Sendable {
|
||||
var id: UUID = UUID()
|
||||
var kind: ContextChip.Kind
|
||||
var label: String
|
||||
var attachment: AgentContextAttachment? = nil
|
||||
}
|
||||
|
||||
private extension ContextChip.Kind {
|
||||
nonisolated var agentAttachmentKind: AgentContextAttachment.Kind? {
|
||||
switch self {
|
||||
case .file: .file
|
||||
case .output: .output
|
||||
case .selection: .selection
|
||||
case .pastedText: .pastedText
|
||||
case .host: .host
|
||||
case .directory: .directory
|
||||
case .image: nil
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func fallbackAttachment(
|
||||
label: String, content: String
|
||||
) -> AgentContextAttachment? {
|
||||
guard let kind = agentAttachmentKind else { return nil }
|
||||
var text = content
|
||||
if text.hasPrefix("\n") { text.removeFirst() }
|
||||
if text.hasSuffix("\n") { text.removeLast() }
|
||||
let filename: String
|
||||
if self == .file {
|
||||
let stem = (label as NSString).deletingPathExtension
|
||||
filename = "\(stem.isEmpty ? label : stem) extracted.txt"
|
||||
} else {
|
||||
filename = (label as NSString).pathExtension.isEmpty ? "\(label).txt" : label
|
||||
}
|
||||
return AgentContextAttachment(
|
||||
kind: kind, label: label, contentTypeIdentifier: "public.plain-text",
|
||||
suggestedFilename: filename, data: Data(text.utf8), isOriginalFile: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated struct PendingContextAttachment: Identifiable, Hashable, Sendable {
|
||||
@@ -718,10 +801,21 @@ final class ConversationModel: Identifiable {
|
||||
guard let first = messages.first(where: { $0.role == .user }) else {
|
||||
return String(localized: "New Chat")
|
||||
}
|
||||
if first.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||
let attachment = first.contextChips.first?.label ?? first.images.first?.label
|
||||
{
|
||||
return attachment
|
||||
}
|
||||
let line = first.text.split(separator: "\n").first.map(String.init) ?? first.text
|
||||
return line.count > 40 ? String(line.prefix(40)) + "…" : line
|
||||
}
|
||||
|
||||
/// User-authored content only. Surface-owned session context is supplied
|
||||
/// separately and must never make an otherwise empty composer sendable.
|
||||
var canSendDraft: Bool {
|
||||
!draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !attachments.isEmpty
|
||||
}
|
||||
|
||||
var isEmpty: Bool { timeline.isEmpty }
|
||||
var isArchived: Bool { archivedAt != nil }
|
||||
|
||||
@@ -752,7 +846,8 @@ final class ConversationModel: Identifiable {
|
||||
? String(original.text.dropFirst(visible.count)) : ""
|
||||
return AgentTranscriptMessage(
|
||||
role: .user, text: authoredText + suffix, authoredText: authoredText,
|
||||
images: original.images, createdAt: .now
|
||||
images: original.images, contextAttachments: original.contextAttachments,
|
||||
createdAt: .now
|
||||
)
|
||||
}
|
||||
|
||||
@@ -772,7 +867,8 @@ final class ConversationModel: Identifiable {
|
||||
id: replacement.id, role: .user,
|
||||
text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text),
|
||||
contextChips: ContextChip.visibleReferences(
|
||||
in: replacement.text, images: replacement.images
|
||||
in: replacement.text, images: replacement.images,
|
||||
contextAttachments: replacement.contextAttachments
|
||||
), images: replacement.images, createdAt: replacement.createdAt
|
||||
)])
|
||||
}
|
||||
@@ -794,7 +890,8 @@ final class ConversationModel: Identifiable {
|
||||
}
|
||||
return ComposedUserTurn(
|
||||
id: replacement.id, text: replacement.text,
|
||||
authoredText: replacement.authoredText ?? "", images: replacement.images
|
||||
authoredText: replacement.authoredText ?? "", images: replacement.images,
|
||||
contextAttachments: replacement.contextAttachments
|
||||
)
|
||||
}
|
||||
|
||||
@@ -839,7 +936,8 @@ final class ConversationModel: Identifiable {
|
||||
id: message.id, role: .user,
|
||||
text: message.authoredText ?? ContextChip.visiblePrompt(in: message.text),
|
||||
contextChips: ContextChip.visibleReferences(
|
||||
in: message.text, images: message.images
|
||||
in: message.text, images: message.images,
|
||||
contextAttachments: message.contextAttachments
|
||||
), images: message.images, createdAt: message.createdAt
|
||||
)
|
||||
case .assistant:
|
||||
@@ -1274,7 +1372,7 @@ final class ConversationModel: Identifiable {
|
||||
/// path; the sidebar promotion flow uses this before starting that run.
|
||||
func sendDraft() {
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
guard canSendDraft else { return }
|
||||
appendUserTurn(text)
|
||||
draft = ""
|
||||
}
|
||||
@@ -1302,6 +1400,7 @@ final class ConversationModel: Identifiable {
|
||||
var text: String
|
||||
var authoredText: String
|
||||
var images: [AgentImageAttachment]
|
||||
var contextAttachments: [AgentContextAttachment]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
@@ -1309,7 +1408,10 @@ final class ConversationModel: Identifiable {
|
||||
let composed = composed(text)
|
||||
let messageID = UUID()
|
||||
let contextChips = attachments.map {
|
||||
ContextChipReference(id: $0.id, kind: $0.kind, label: $0.label)
|
||||
ContextChipReference(
|
||||
id: $0.id, kind: $0.kind, label: $0.label,
|
||||
attachment: $0.contextAttachment
|
||||
)
|
||||
}
|
||||
attachments.removeAll()
|
||||
messages.append(
|
||||
@@ -1321,12 +1423,14 @@ final class ConversationModel: Identifiable {
|
||||
transcript.append(
|
||||
AgentTranscriptMessage(
|
||||
id: messageID, role: .user, text: composed.text,
|
||||
authoredText: text, images: composed.images
|
||||
authoredText: text, images: composed.images,
|
||||
contextAttachments: composed.contextAttachments
|
||||
)
|
||||
)
|
||||
updatedAt = .now
|
||||
return ComposedUserTurn(
|
||||
id: messageID, text: composed.text, authoredText: text, images: composed.images
|
||||
id: messageID, text: composed.text, authoredText: text, images: composed.images,
|
||||
contextAttachments: composed.contextAttachments
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1336,7 +1440,10 @@ final class ConversationModel: Identifiable {
|
||||
/// belong in a Markdown bubble.
|
||||
private func composed(_ text: String) -> ComposedUserTurn {
|
||||
guard !attachments.isEmpty else {
|
||||
return ComposedUserTurn(id: UUID(), text: text, authoredText: text, images: [])
|
||||
return ComposedUserTurn(
|
||||
id: UUID(), text: text, authoredText: text, images: [],
|
||||
contextAttachments: []
|
||||
)
|
||||
}
|
||||
let blocks = attachments.compactMap { chip -> String? in
|
||||
guard chip.image == nil else { return nil }
|
||||
@@ -1352,7 +1459,8 @@ final class ConversationModel: Identifiable {
|
||||
return ComposedUserTurn(
|
||||
id: UUID(), text: ([text] + blocks).joined(separator: "\n\n"),
|
||||
authoredText: text,
|
||||
images: attachments.compactMap(\.image)
|
||||
images: attachments.compactMap(\.image),
|
||||
contextAttachments: attachments.compactMap(\.contextAttachment)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1418,13 +1526,13 @@ final class ConversationModel: Identifiable {
|
||||
/// really saw it.
|
||||
func submitSteeringDraft() {
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, let runtime else { return }
|
||||
guard canSendDraft, let runtime else { return }
|
||||
let composed = composed(text)
|
||||
let pending = PendingSteeringMessage(
|
||||
draft: text, attachments: attachments,
|
||||
message: AgentTranscriptMessage(
|
||||
role: .user, text: composed.text, authoredText: text,
|
||||
images: composed.images
|
||||
images: composed.images, contextAttachments: composed.contextAttachments
|
||||
)
|
||||
)
|
||||
pendingSteering.append(pending)
|
||||
@@ -1914,8 +2022,14 @@ final class ConversationModel: Identifiable {
|
||||
let pending = pendingSteering.first(where: { $0.id == message.id })
|
||||
let visibleText = pending?.draft ?? ContextChip.visiblePrompt(in: message.text)
|
||||
let contextChips = pending?.attachments.map {
|
||||
ContextChipReference(id: $0.id, kind: $0.kind, label: $0.label)
|
||||
} ?? ContextChip.visibleReferences(in: message.text, images: message.images)
|
||||
ContextChipReference(
|
||||
id: $0.id, kind: $0.kind, label: $0.label,
|
||||
attachment: $0.contextAttachment
|
||||
)
|
||||
} ?? ContextChip.visibleReferences(
|
||||
in: message.text, images: message.images,
|
||||
contextAttachments: message.contextAttachments
|
||||
)
|
||||
pendingSteering.removeAll { $0.id == message.id }
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
|
||||
@@ -621,17 +621,13 @@ struct ComposerView: View {
|
||||
/// when the field gains content.
|
||||
private static let editorTextInset: CGFloat = 5
|
||||
|
||||
private var trimmedDraft: String {
|
||||
conversation.draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// Stop and Send share one slot rather than standing side by side.
|
||||
///
|
||||
/// Drawn together they read as a pair of equals, and the primary action
|
||||
/// moved under the pointer the moment the draft gained a character. With a
|
||||
/// draft to send, Send is what the button is for; with nothing to send,
|
||||
/// stopping is the only thing left to do.
|
||||
private var showsStopButton: Bool { conversation.isStreaming && trimmedDraft.isEmpty }
|
||||
private var showsStopButton: Bool { conversation.isStreaming && !conversation.canSendDraft }
|
||||
|
||||
private var standardControls: some View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
@@ -646,7 +642,7 @@ struct ComposerView: View {
|
||||
stopButton
|
||||
} else {
|
||||
sendButton(
|
||||
enabled: !trimmedDraft.isEmpty && conversation.pendingAttachments.isEmpty,
|
||||
enabled: conversation.canSendDraft && conversation.pendingAttachments.isEmpty,
|
||||
help: conversation.isStreaming
|
||||
? "Queue for the running agent — ⏎" : "Send — ⌘⏎",
|
||||
action: sendDraft
|
||||
|
||||
@@ -18,6 +18,7 @@ struct UserMessageView: View {
|
||||
@State private var editText = ""
|
||||
@State private var bubbleWidth: CGFloat = 0
|
||||
@State private var hasMarkedText = false
|
||||
@State private var attachmentOpenError: String?
|
||||
@FocusState private var editFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -44,6 +45,17 @@ struct UserMessageView: View {
|
||||
.onHover { isHovering = $0 }
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel(Text("You said"))
|
||||
.alert(
|
||||
"Couldn’t Open Attachment",
|
||||
isPresented: Binding(
|
||||
get: { attachmentOpenError != nil },
|
||||
set: { if !$0 { attachmentOpenError = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK", role: .cancel) { attachmentOpenError = nil }
|
||||
} message: {
|
||||
Text(attachmentOpenError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
private func beginEdit() {
|
||||
@@ -56,13 +68,19 @@ struct UserMessageView: View {
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.s) {
|
||||
if !message.contextChips.isEmpty {
|
||||
FlowLayout(spacing: Metrics.Space.xs, fillsWidth: false) {
|
||||
ForEach(message.contextChips) { BubbleContextChipView(chip: $0) }
|
||||
ForEach(message.contextChips) { chip in
|
||||
BubbleContextChipView(chip: chip) { open(chip) }
|
||||
}
|
||||
}
|
||||
}
|
||||
if !message.images.isEmpty {
|
||||
FlowLayout(spacing: Metrics.Space.s, fillsWidth: false) {
|
||||
ForEach(message.images) {
|
||||
BubbleImageView(attachment: $0, isCompact: isCompact)
|
||||
ForEach(message.images) { attachment in
|
||||
Button { open(attachment) } label: {
|
||||
BubbleImageView(attachment: attachment, isCompact: isCompact)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(Text("Open \(attachment.label)"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +105,34 @@ struct UserMessageView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ chip: ContextChipReference) {
|
||||
if chip.kind == .image,
|
||||
let image = message.images.first(where: { $0.id == chip.id })
|
||||
?? message.images.first(where: { $0.label == chip.label })
|
||||
{
|
||||
open(image)
|
||||
return
|
||||
}
|
||||
guard let attachment = chip.attachment else { return }
|
||||
Task {
|
||||
do {
|
||||
try await ContextAttachmentOpener.open(attachment)
|
||||
} catch {
|
||||
attachmentOpenError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func open(_ image: AgentImageAttachment) {
|
||||
Task {
|
||||
do {
|
||||
try await ContextAttachmentOpener.open(image)
|
||||
} catch {
|
||||
attachmentOpenError = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var editor: some View {
|
||||
VStack(alignment: .trailing, spacing: Metrics.Space.s) {
|
||||
TextEditor(text: $editText)
|
||||
@@ -148,8 +194,20 @@ struct UserMessageView: View {
|
||||
|
||||
private struct BubbleContextChipView: View {
|
||||
let chip: ContextChipReference
|
||||
var open: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
if let open {
|
||||
Button(action: open) { content }
|
||||
.buttonStyle(.plain)
|
||||
.help(Text("Open \(chip.label)"))
|
||||
.accessibilityLabel(Text("Open \(chip.label)"))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
|
||||
private var content: some View {
|
||||
HStack(spacing: Metrics.Space.xs) {
|
||||
Image(systemName: chip.kind.symbol)
|
||||
.font(.caption2)
|
||||
|
||||
@@ -709,9 +709,13 @@ struct WelcomeView: View {
|
||||
|
||||
private var header: some View {
|
||||
VStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.system(size: 40))
|
||||
Image("TermindGlyph")
|
||||
.resizable()
|
||||
.renderingMode(.template)
|
||||
.scaledToFit()
|
||||
.frame(width: 88, height: 88)
|
||||
.foregroundStyle(.tertiary)
|
||||
.accessibilityHidden(true)
|
||||
Text("Termind")
|
||||
.font(.largeTitle.weight(.semibold))
|
||||
Text("SSH and SFTP, with an assistant at hand.")
|
||||
|
||||
@@ -98,6 +98,25 @@ final class PersistentAgentMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/// V3 storage for original context snapshots. A separate model keeps the V2
|
||||
/// message schema frozen and makes the migration an additive lightweight one.
|
||||
@Model
|
||||
final class PersistentAgentContextAttachments {
|
||||
@Attribute(.unique) var messageID: UUID = UUID()
|
||||
@Attribute(.externalStorage) var attachmentsData = Data()
|
||||
|
||||
init(messageID: UUID, attachments: [AgentContextAttachment]) {
|
||||
self.messageID = messageID
|
||||
attachmentsData = (try? JSONEncoder().encode(attachments)) ?? Data()
|
||||
}
|
||||
|
||||
var attachments: [AgentContextAttachment] {
|
||||
(try? JSONDecoder().decode(
|
||||
[AgentContextAttachment].self, from: attachmentsData
|
||||
)) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
final class PersistentAgentToolInvocation {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
@@ -232,6 +251,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
context.insert(PersistentAgentMessage(
|
||||
message, runID: runID, sequence: sequence
|
||||
))
|
||||
insertContextAttachments(for: message)
|
||||
inserted = true
|
||||
}
|
||||
guard inserted || context.hasChanges else { return }
|
||||
@@ -278,6 +298,12 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
))
|
||||
let runs = try context.fetch(FetchDescriptor<PersistentAgentRun>())
|
||||
let messages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
|
||||
let attachmentRows = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentContextAttachments>()
|
||||
)
|
||||
let attachmentsByMessage = Dictionary(
|
||||
uniqueKeysWithValues: attachmentRows.map { ($0.messageID, $0.attachments) }
|
||||
)
|
||||
let invocations = try context.fetch(FetchDescriptor<PersistentAgentToolInvocation>())
|
||||
let results = try context.fetch(FetchDescriptor<PersistentAgentToolResult>())
|
||||
let reviews = try context.fetch(FetchDescriptor<PersistentAgentReviewRecord>())
|
||||
@@ -319,12 +345,14 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
let images = (try? JSONDecoder().decode(
|
||||
[AgentImageAttachment].self, from: row.imagesData
|
||||
)) ?? []
|
||||
let contextAttachments = attachmentsByMessage[row.id] ?? []
|
||||
let reasoning = (try? JSONDecoder().decode(
|
||||
[AgentReasoningBlock].self, from: row.reasoningData
|
||||
)) ?? []
|
||||
return AgentTranscriptMessage(
|
||||
id: row.id, role: role, text: row.text,
|
||||
authoredText: row.authoredText, images: images,
|
||||
contextAttachments: contextAttachments,
|
||||
reasoning: reasoning,
|
||||
toolCalls: calls,
|
||||
toolCallID: row.toolCallID, toolName: row.toolName,
|
||||
@@ -445,6 +473,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
context.insert(PersistentAgentMessage(
|
||||
replacement, runID: replacementRun.id, sequence: 0
|
||||
))
|
||||
insertContextAttachments(for: replacement)
|
||||
|
||||
let descriptor = FetchDescriptor<PersistentAgentConversation>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
@@ -508,6 +537,7 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
} ?? fallbackRunID,
|
||||
sequence: sequence
|
||||
))
|
||||
insertContextAttachments(for: message)
|
||||
}
|
||||
for card in value.toolCards {
|
||||
var invocation = card.invocation
|
||||
@@ -553,11 +583,22 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentToolInvocation>())
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentMessage>())
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
let messages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
|
||||
.filter { runIDs.contains($0.runID) }
|
||||
let messageIDs = Set(messages.map(\.id))
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentContextAttachments>())
|
||||
where messageIDs.contains(row.messageID) { context.delete(row) }
|
||||
for row in messages { context.delete(row) }
|
||||
for row in runs { context.delete(row) }
|
||||
}
|
||||
|
||||
private func insertContextAttachments(for message: AgentTranscriptMessage) {
|
||||
guard !message.contextAttachments.isEmpty else { return }
|
||||
context.insert(PersistentAgentContextAttachments(
|
||||
messageID: message.id, attachments: message.contextAttachments
|
||||
))
|
||||
}
|
||||
|
||||
/// Puts back tool results that were recorded but never made it into the
|
||||
/// transcript.
|
||||
///
|
||||
|
||||
@@ -347,15 +347,26 @@ enum TermindSchemaV2: VersionedSchema {
|
||||
}
|
||||
}
|
||||
|
||||
enum TermindSchemaV3: VersionedSchema {
|
||||
static let versionIdentifier = Schema.Version(3, 0, 0)
|
||||
|
||||
static var models: [any PersistentModel.Type] {
|
||||
TermindSchemaV2.models + [PersistentAgentContextAttachments.self]
|
||||
}
|
||||
}
|
||||
|
||||
enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
static var schemas: [any VersionedSchema.Type] {
|
||||
[TermindSchemaV1.self, TermindSchemaV2.self]
|
||||
[TermindSchemaV1.self, TermindSchemaV2.self, TermindSchemaV3.self]
|
||||
}
|
||||
|
||||
static var stages: [MigrationStage] {
|
||||
[
|
||||
MigrationStage.lightweight(
|
||||
fromVersion: TermindSchemaV1.self, toVersion: TermindSchemaV2.self
|
||||
),
|
||||
MigrationStage.lightweight(
|
||||
fromVersion: TermindSchemaV2.self, toVersion: TermindSchemaV3.self
|
||||
)
|
||||
]
|
||||
}
|
||||
@@ -363,7 +374,7 @@ enum TermindMigrationPlan: SchemaMigrationPlan {
|
||||
|
||||
@MainActor
|
||||
final class HostPersistence {
|
||||
static let schema = Schema(versionedSchema: TermindSchemaV2.self)
|
||||
static let schema = Schema(versionedSchema: TermindSchemaV3.self)
|
||||
|
||||
let context: ModelContext
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -94,6 +94,85 @@ struct AgentPersistenceTests {
|
||||
#expect(restored.first?.messages.first?.images == [image])
|
||||
}
|
||||
|
||||
@Test
|
||||
func contextAttachmentsRoundTripThroughConversationPersistence() async throws {
|
||||
let configuration = localModelConfiguration(
|
||||
"AgentContextAttachmentPersistenceTests", schema: HostPersistence.schema,
|
||||
isStoredInMemoryOnly: true
|
||||
)
|
||||
let container = try ModelContainer(
|
||||
for: HostPersistence.schema, configurations: [configuration]
|
||||
)
|
||||
let repository = SwiftDataAgentRunRepository(context: container.mainContext)
|
||||
let attachment = AgentContextAttachment(
|
||||
kind: .file, label: "manual.pdf", contentTypeIdentifier: "com.adobe.pdf",
|
||||
suggestedFilename: "manual.pdf", data: Data([0x25, 0x50, 0x44, 0x46])
|
||||
)
|
||||
let message = AgentTranscriptMessage(
|
||||
role: .user, text: "\n\n<file label=\"manual.pdf\">\nText\n</file>",
|
||||
contextAttachments: [attachment]
|
||||
)
|
||||
let snapshot = AgentRunSnapshot(
|
||||
conversationID: UUID(), state: .completed,
|
||||
permissionMode: .askForApproval, messages: [message]
|
||||
)
|
||||
|
||||
try await repository.save(snapshot)
|
||||
let restored = try await repository.loadConversations()
|
||||
|
||||
#expect(restored.first?.messages.first?.contextAttachments == [attachment])
|
||||
#expect(try container.mainContext.fetch(
|
||||
FetchDescriptor<PersistentAgentContextAttachments>()
|
||||
).count == 1)
|
||||
|
||||
try await repository.deleteConversation(snapshot.conversationID)
|
||||
#expect(try container.mainContext.fetch(
|
||||
FetchDescriptor<PersistentAgentContextAttachments>()
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func schemaV2MigratesToV3WithEmptyAttachmentBacking() throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("TermindAgentV3Migration-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory, withIntermediateDirectories: true
|
||||
)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let storeURL = directory.appendingPathComponent("Termind.store")
|
||||
let legacySchema = Schema(versionedSchema: TermindSchemaV2.self)
|
||||
let conversationID = UUID()
|
||||
|
||||
var legacyContainer: ModelContainer? = try ModelContainer(
|
||||
for: legacySchema,
|
||||
configurations: [
|
||||
localModelConfiguration("Migration", schema: legacySchema, url: storeURL)
|
||||
]
|
||||
)
|
||||
legacyContainer?.mainContext.insert(PersistentAgentConversation(
|
||||
id: conversationID, title: "Existing"
|
||||
))
|
||||
try legacyContainer?.mainContext.save()
|
||||
legacyContainer = nil
|
||||
|
||||
let migrated = try ModelContainer(
|
||||
for: HostPersistence.schema,
|
||||
migrationPlan: TermindMigrationPlan.self,
|
||||
configurations: [
|
||||
localModelConfiguration(
|
||||
"Migration", schema: HostPersistence.schema, url: storeURL
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
#expect(try migrated.mainContext.fetch(
|
||||
FetchDescriptor<PersistentAgentConversation>()
|
||||
).map(\.id) == [conversationID])
|
||||
#expect(try migrated.mainContext.fetch(
|
||||
FetchDescriptor<PersistentAgentContextAttachments>()
|
||||
).isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func displayReasoningRoundTripsRedactedWithoutProviderState() async throws {
|
||||
let configuration = localModelConfiguration(
|
||||
|
||||
@@ -148,6 +148,28 @@ struct AgentSteeringTests {
|
||||
#expect(conversation.draft == " \n ")
|
||||
}
|
||||
|
||||
@Test
|
||||
func anImageOnlySteeringMessageQueuesAndRestoresItsAttachment() throws {
|
||||
let conversation = ConversationModel()
|
||||
startRun(conversation)
|
||||
let image = AgentImageAttachment(
|
||||
label: "graph.png", mimeType: "image/png", data: Data([7, 8, 9]),
|
||||
pixelWidth: 20, pixelHeight: 20
|
||||
)
|
||||
conversation.attach(ContextChip(kind: .image, label: image.label, image: image))
|
||||
|
||||
conversation.submitSteeringDraft()
|
||||
|
||||
let pending = try #require(conversation.pendingSteering.first)
|
||||
#expect(pending.draft.isEmpty)
|
||||
#expect(pending.message.text.isEmpty)
|
||||
#expect(pending.message.images == [image])
|
||||
#expect(conversation.attachments.isEmpty)
|
||||
|
||||
conversation.stop()
|
||||
#expect(conversation.attachments.first?.image == image)
|
||||
}
|
||||
|
||||
/// Nothing is queued without a run to hand it to — an idle composer sends.
|
||||
@Test
|
||||
func thereIsNothingToSteerWithoutARun() {
|
||||
|
||||
@@ -73,6 +73,52 @@ struct ChatComposerStateTests {
|
||||
#expect(conversation.attachments.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func aManualAttachmentMakesAnEmptyDraftSendable() {
|
||||
let conversation = ConversationModel()
|
||||
#expect(!conversation.canSendDraft)
|
||||
|
||||
conversation.attach(ContextChip(
|
||||
kind: .output, label: "Last lines", content: "service failed"
|
||||
))
|
||||
#expect(conversation.canSendDraft)
|
||||
|
||||
conversation.sendDraft()
|
||||
|
||||
#expect(conversation.messages.last?.text == "")
|
||||
#expect(conversation.messages.last?.contextChips.map(\.label) == ["Last lines"])
|
||||
#expect(conversation.transcript.last?.text.contains("service failed") == true)
|
||||
#expect(conversation.transcript.last?.contextAttachments.first?.data
|
||||
== Data("service failed".utf8))
|
||||
#expect(conversation.attachments.isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
func imageOnlyTurnIsValidAndSuppliesItsFallbackTitle() {
|
||||
let image = AgentImageAttachment(
|
||||
label: "dashboard.png", mimeType: "image/png", data: Data([1, 2, 3]),
|
||||
pixelWidth: 10, pixelHeight: 10
|
||||
)
|
||||
let conversation = ConversationModel()
|
||||
conversation.attach(ContextChip(kind: .image, label: image.label, image: image))
|
||||
|
||||
conversation.sendDraft()
|
||||
|
||||
#expect(conversation.transcript.last?.text == "")
|
||||
#expect(conversation.transcript.last?.images == [image])
|
||||
#expect(conversation.derivedTitle == "dashboard.png")
|
||||
}
|
||||
|
||||
@Test
|
||||
func legacyFileReferenceFallsBackToExtractedText() throws {
|
||||
let text = "\n\n<file label=\"manual.pdf\">\nExtracted body\n</file>"
|
||||
let reference = try #require(ContextChip.visibleReferences(in: text).first)
|
||||
|
||||
#expect(reference.attachment?.suggestedFilename == "manual extracted.txt")
|
||||
#expect(reference.attachment?.isOriginalFile == false)
|
||||
#expect(reference.attachment?.data == Data("Extracted body".utf8))
|
||||
}
|
||||
|
||||
@Test
|
||||
func activeFormKeysYieldToImeAndConsumeOnlyPlainReturnOrEscape() {
|
||||
#expect(
|
||||
|
||||
@@ -18,6 +18,24 @@ struct ContextAttachmentImporterTests {
|
||||
|
||||
#expect(chip.kind == .file)
|
||||
#expect(chip.content == "hello 世界")
|
||||
#expect(chip.sourceAttachment?.data == "hello 世界".data(using: .utf16))
|
||||
#expect(chip.sourceAttachment?.suggestedFilename == url.lastPathComponent)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func pdfKeepsOriginalBytesAlongsideExtractedText() async throws {
|
||||
let view = NSTextView(frame: NSRect(x: 0, y: 0, width: 300, height: 120))
|
||||
view.string = "Hello retained PDF"
|
||||
let data = view.dataWithPDF(inside: view.bounds)
|
||||
let url = temporaryFile(extension: "pdf")
|
||||
defer { try? FileManager.default.removeItem(at: url) }
|
||||
try data.write(to: url)
|
||||
|
||||
let chip = try await ContextAttachmentImporter.importFile(at: url)
|
||||
|
||||
#expect(chip.content.contains("Hello retained PDF"))
|
||||
#expect(chip.sourceAttachment?.data == data)
|
||||
#expect(chip.sourceAttachment?.contentTypeIdentifier == "com.adobe.pdf")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -60,6 +78,20 @@ struct ContextAttachmentImporterTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func materializedAttachmentCannotEscapeItsPrivateDirectory() throws {
|
||||
let attachment = AgentContextAttachment(
|
||||
kind: .file, label: "unsafe", contentTypeIdentifier: "public.plain-text",
|
||||
suggestedFilename: "../../secret.txt", data: Data("safe".utf8)
|
||||
)
|
||||
let url = try ContextAttachmentOpener.materialize(attachment)
|
||||
defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) }
|
||||
|
||||
#expect(url.lastPathComponent == "secret.txt")
|
||||
#expect(url.deletingLastPathComponent().lastPathComponent == attachment.id.uuidString)
|
||||
#expect(try Data(contentsOf: url) == attachment.data)
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func rejectsImageOnlyPDFWithoutRunningOCR() async throws {
|
||||
let bitmap = NSBitmapImageRep(
|
||||
|
||||
Reference in new issue
Block a user