feat(assistant): add chat creator attribution and source icons
This commit is contained in:
1 parent
039de457e2
commit
7f1eec4ba3
9 files changed
+428
-13
No files matched your search
@@ -73,4 +73,34 @@ struct AssistantChatCollaborationPersistenceTests {
|
||||
#expect(final.state == .delivered)
|
||||
#expect(final.failure == nil)
|
||||
}
|
||||
|
||||
@Test
|
||||
func creationRelationsSurviveAStoreReopenAndRemainScopedToRequestedChats() throws {
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
let url = directory.appending(path: "Termind.store")
|
||||
let requested = AssistantChatCreationRelation(
|
||||
conversationID: UUID(), sourceConversationID: UUID(),
|
||||
sourceRunID: UUID(), sourceToolCallID: "requested")
|
||||
let unrelated = AssistantChatCreationRelation(
|
||||
conversationID: UUID(), sourceConversationID: UUID(),
|
||||
sourceRunID: UUID(), sourceToolCallID: "unrelated")
|
||||
|
||||
do {
|
||||
let container = try TermindPersistence.makeContainer(url: url)
|
||||
let store = SwiftDataAssistantChatCollaborationStore(
|
||||
context: ModelContext(container))
|
||||
try store.save(requested)
|
||||
try store.save(unrelated)
|
||||
}
|
||||
|
||||
let reopened = try TermindPersistence.makeContainer(url: url)
|
||||
let restored = SwiftDataAssistantChatCollaborationStore(
|
||||
context: ModelContext(reopened))
|
||||
let relations = try restored.creationRelations(
|
||||
conversationIDs: [requested.conversationID, UUID()])
|
||||
|
||||
#expect(relations == [requested.conversationID: requested])
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,195 @@ struct AssistantChatCollaborationReviewTests {
|
||||
#expect(text.count == 8_000)
|
||||
}
|
||||
|
||||
@Test func listingBatchesMixedCreatorRelationsOncePerPage() async throws {
|
||||
let collaborationStore = CountingChatReviewStore()
|
||||
let fixture = ChatReviewFixture(receipts: collaborationStore)
|
||||
fixture.source.updatedAt = Date(timeIntervalSince1970: 0)
|
||||
var targets: [ConversationModel] = []
|
||||
for index in 0..<21 {
|
||||
let target = fixture.addTarget()
|
||||
target.customTitle = "Target \(index)"
|
||||
target.updatedAt = Date(timeIntervalSince1970: Double(10_000 - index))
|
||||
targets.append(target)
|
||||
}
|
||||
let firstRelation = AssistantChatCreationRelation(
|
||||
conversationID: targets[0].id,
|
||||
sourceConversationID: fixture.source.id,
|
||||
sourceRunID: UUID(),
|
||||
sourceToolCallID: "first-page")
|
||||
let secondRelation = AssistantChatCreationRelation(
|
||||
conversationID: targets[20].id,
|
||||
sourceConversationID: fixture.source.id,
|
||||
sourceRunID: UUID(),
|
||||
sourceToolCallID: "second-page")
|
||||
try collaborationStore.save(firstRelation)
|
||||
try collaborationStore.save(secondRelation)
|
||||
let firstPageIDs = Set(targets.prefix(20).map(\.id))
|
||||
let secondPageIDs = Set([targets[20].id, fixture.source.id])
|
||||
|
||||
let first = try await fixture.coordinator.listChats(cursor: nil, includeArchived: false)
|
||||
let firstRows = try #require(first.objectValue?["chats"]?.arrayValue)
|
||||
#expect(collaborationStore.relationReads == [firstPageIDs])
|
||||
#expect(try creator(in: firstRows, conversationID: targets[0].id)["kind"] == .string("agent"))
|
||||
#expect(try creator(in: firstRows, conversationID: targets[1].id) == ["kind": .string("user")])
|
||||
|
||||
let cursor = try #require(first.objectValue?["next_cursor"]?.stringValue)
|
||||
let second = try await fixture.coordinator.listChats(cursor: cursor, includeArchived: false)
|
||||
let secondRows = try #require(second.objectValue?["chats"]?.arrayValue)
|
||||
#expect(collaborationStore.relationReads == [firstPageIDs, secondPageIDs])
|
||||
let secondCreator = try creator(in: secondRows, conversationID: targets[20].id)
|
||||
#expect(secondCreator["kind"] == .string("agent"))
|
||||
#expect(secondCreator["chat_id"] == .string(fixture.source.id.uuidString))
|
||||
#expect(secondCreator["title"] == .string("Source"))
|
||||
#expect(try creator(in: secondRows, conversationID: fixture.source.id) == ["kind": .string("user")])
|
||||
}
|
||||
|
||||
@Test func creatorKeepsCreationAuthorityThroughLaterDeliveryRenameAndSourceDeletion() async throws {
|
||||
let fixture = ChatReviewFixture()
|
||||
let creationRunID = UUID()
|
||||
let created = try await fixture.coordinator.createChat(
|
||||
prompt: "Created task", model: nil, title: "Created target",
|
||||
source: fixture.sourceContext(), sourceRunID: creationRunID,
|
||||
sourceToolCallID: "create-target")
|
||||
#expect(created.objectValue?["creator"] == nil)
|
||||
let target = try fixture.target(from: created)
|
||||
try await waitUntil { !target.isPreparingAgentRun }
|
||||
|
||||
let laterSourceID = UUID()
|
||||
let later = try await fixture.coordinator.sendToChat(
|
||||
id: target.id, message: "Later agent message",
|
||||
source: AgentChatSourceContext(
|
||||
conversationID: laterSourceID,
|
||||
title: "Later source",
|
||||
modelReference: AssistantChatCoordinator.reference(fixture.sourceModel),
|
||||
permissionMode: .askForApproval,
|
||||
mode: .acting),
|
||||
sourceRunID: UUID(), sourceToolCallID: "later-send")
|
||||
#expect(later.objectValue?["creator"] == nil)
|
||||
try await waitUntil { !target.isPreparingAgentRun }
|
||||
#expect(target.messages.compactMap(\.origin).count == 2)
|
||||
|
||||
var rows = try await listRows(fixture)
|
||||
var creatorInfo = try creator(in: rows, conversationID: target.id)
|
||||
#expect(creatorInfo["chat_id"] == .string(fixture.source.id.uuidString))
|
||||
#expect(creatorInfo["title"] == .string("Source"))
|
||||
|
||||
fixture.source.customTitle = "Renamed source"
|
||||
rows = try await listRows(fixture)
|
||||
creatorInfo = try creator(in: rows, conversationID: target.id)
|
||||
#expect(creatorInfo["title"] == .string("Renamed source"))
|
||||
|
||||
fixture.store.remove(fixture.source.id)
|
||||
rows = try await listRows(fixture)
|
||||
creatorInfo = try creator(in: rows, conversationID: target.id)
|
||||
#expect(creatorInfo["chat_id"] == .string(fixture.source.id.uuidString))
|
||||
#expect(creatorInfo["title"] == .string("Source"))
|
||||
}
|
||||
|
||||
@Test func creatorUsesNullWithoutAnExactSavedTitleAndManualForkRemainsUserCreated() async throws {
|
||||
let fixture = ChatReviewFixture()
|
||||
let missingSourceID = UUID()
|
||||
let creationRunID = UUID()
|
||||
let target = fixture.addTarget()
|
||||
target.messages = [
|
||||
ChatMessage(
|
||||
role: .user,
|
||||
text: "Wrong run",
|
||||
origin: AgentMessageOrigin(
|
||||
sourceConversationID: missingSourceID,
|
||||
sourceRunID: UUID(),
|
||||
sourceToolCallID: "create-without-title",
|
||||
sourceTitle: "Wrong run title")),
|
||||
ChatMessage(
|
||||
role: .user,
|
||||
text: "Wrong tool call",
|
||||
origin: AgentMessageOrigin(
|
||||
sourceConversationID: missingSourceID,
|
||||
sourceRunID: creationRunID,
|
||||
sourceToolCallID: "wrong-tool-call",
|
||||
sourceTitle: "Wrong tool title")),
|
||||
ChatMessage(
|
||||
role: .user,
|
||||
text: "Created without a saved title",
|
||||
origin: AgentMessageOrigin(
|
||||
sourceConversationID: missingSourceID,
|
||||
sourceRunID: creationRunID,
|
||||
sourceToolCallID: "create-without-title")),
|
||||
]
|
||||
try fixture.receipts.save(
|
||||
AssistantChatCreationRelation(
|
||||
conversationID: target.id,
|
||||
sourceConversationID: missingSourceID,
|
||||
sourceRunID: creationRunID,
|
||||
sourceToolCallID: "create-without-title"))
|
||||
|
||||
let compactedSourceID = UUID()
|
||||
let compactedRunID = UUID()
|
||||
let displayOnlyTarget = fixture.addTarget()
|
||||
displayOnlyTarget.messages = [
|
||||
ChatMessage(
|
||||
role: .user,
|
||||
text: "Created before compaction",
|
||||
origin: AgentMessageOrigin(
|
||||
sourceConversationID: compactedSourceID,
|
||||
sourceRunID: compactedRunID,
|
||||
sourceToolCallID: "compacted-creation",
|
||||
sourceTitle: "Compacted source"))
|
||||
]
|
||||
#expect(displayOnlyTarget.transcript.isEmpty)
|
||||
try fixture.receipts.save(
|
||||
AssistantChatCreationRelation(
|
||||
conversationID: displayOnlyTarget.id,
|
||||
sourceConversationID: compactedSourceID,
|
||||
sourceRunID: compactedRunID,
|
||||
sourceToolCallID: "compacted-creation"))
|
||||
|
||||
let copiedOrigin = AgentMessageOrigin(
|
||||
sourceConversationID: UUID(), sourceRunID: UUID(),
|
||||
sourceToolCallID: "copied", sourceTitle: "Copied source")
|
||||
let manualSource = ConversationModel()
|
||||
let incoming = manualSource.composeIncomingAgentTurn("Copied task", origin: copiedOrigin)
|
||||
_ = manualSource.appendIncomingAgentTurnContent(incoming)
|
||||
let answer = AgentTranscriptMessage(role: .assistant, text: "Answer")
|
||||
manualSource.handle(.messageFinished(answer))
|
||||
let fork = try #require(manualSource.forked(throughAssistantMessage: answer.id))
|
||||
fixture.store.insert(fork)
|
||||
|
||||
let rows = try await listRows(fixture)
|
||||
let targetCreator = try creator(in: rows, conversationID: target.id)
|
||||
#expect(targetCreator["kind"] == .string("agent"))
|
||||
#expect(targetCreator["chat_id"] == .string(missingSourceID.uuidString))
|
||||
#expect(targetCreator["title"] == .null)
|
||||
#expect(
|
||||
try creator(in: rows, conversationID: displayOnlyTarget.id)["title"]
|
||||
== .string("Compacted source"))
|
||||
#expect(try creator(in: rows, conversationID: fork.id) == ["kind": .string("user")])
|
||||
#expect(fork.messages.first?.origin == copiedOrigin)
|
||||
}
|
||||
|
||||
@Test func relationReadFailurePropagatesFromListChats() async {
|
||||
let fixture = ChatReviewFixture(receipts: RejectingChatReviewRelationReads())
|
||||
|
||||
await #expect(throws: ChatReviewReadFailure.self) {
|
||||
try await fixture.coordinator.listChats(cursor: nil, includeArchived: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func listRows(_ fixture: ChatReviewFixture) async throws -> [AgentJSONValue] {
|
||||
let result = try await fixture.coordinator.listChats(
|
||||
cursor: nil,
|
||||
includeArchived: false)
|
||||
return try #require(result.objectValue?["chats"]?.arrayValue)
|
||||
}
|
||||
|
||||
private func creator(
|
||||
in rows: [AgentJSONValue], conversationID: UUID
|
||||
) throws -> [String: AgentJSONValue] {
|
||||
let row = try #require(
|
||||
rows.first { $0.objectValue?["chat_id"] == .string(conversationID.uuidString) })
|
||||
return try #require(row.objectValue?["creator"]?.objectValue)
|
||||
}
|
||||
|
||||
private func waitUntil(_ condition: @escaping @MainActor () -> Bool) async throws {
|
||||
let clock = ContinuousClock()
|
||||
let deadline = clock.now.advanced(by: .seconds(2))
|
||||
@@ -323,8 +512,52 @@ private struct SuspendedChatReviewModel: AgentModelStreaming {
|
||||
}
|
||||
|
||||
private struct ChatReviewWriteFailure: Error {}
|
||||
private struct ChatReviewReadFailure: Error {}
|
||||
private struct ChatReviewTimeout: Error {}
|
||||
|
||||
@MainActor
|
||||
private final class CountingChatReviewStore: AssistantChatCollaborationStoring {
|
||||
private let base = InMemoryAssistantChatCollaborationStore()
|
||||
private(set) var relationReads: [Set<UUID>] = []
|
||||
|
||||
var confirmsDeliveryDuringMessagePersistence: Bool {
|
||||
base.confirmsDeliveryDuringMessagePersistence
|
||||
}
|
||||
|
||||
func receipt(sourceRunID: UUID, sourceToolCallID: String) throws -> AssistantChatDeliveryReceipt? {
|
||||
try base.receipt(sourceRunID: sourceRunID, sourceToolCallID: sourceToolCallID)
|
||||
}
|
||||
|
||||
func save(_ receipt: AssistantChatDeliveryReceipt) throws {
|
||||
try base.save(receipt)
|
||||
}
|
||||
|
||||
func updateReceipt(
|
||||
messageID: UUID, state: AgentMessageDeliveryState, failure: String?
|
||||
) throws {
|
||||
try base.updateReceipt(messageID: messageID, state: state, failure: failure)
|
||||
}
|
||||
|
||||
func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] {
|
||||
try base.receipts(targetConversationID: targetConversationID)
|
||||
}
|
||||
|
||||
func save(_ relation: AssistantChatCreationRelation) throws {
|
||||
try base.save(relation)
|
||||
}
|
||||
|
||||
func creationRelations(
|
||||
conversationIDs: Set<UUID>
|
||||
) throws -> [UUID: AssistantChatCreationRelation] {
|
||||
relationReads.append(conversationIDs)
|
||||
return try base.creationRelations(conversationIDs: conversationIDs)
|
||||
}
|
||||
|
||||
func deleteRelation(conversationID: UUID) throws {
|
||||
try base.deleteRelation(conversationID: conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct RejectingChatReviewReceipts: AssistantChatCollaborationStoring {
|
||||
let confirmsDeliveryDuringMessagePersistence = false
|
||||
@@ -333,5 +566,22 @@ private struct RejectingChatReviewReceipts: AssistantChatCollaborationStoring {
|
||||
func updateReceipt(messageID: UUID, state: AgentMessageDeliveryState, failure: String?) throws {}
|
||||
func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] { [] }
|
||||
func save(_ relation: AssistantChatCreationRelation) throws { throw ChatReviewWriteFailure() }
|
||||
func creationRelations(
|
||||
conversationIDs: Set<UUID>
|
||||
) throws -> [UUID: AssistantChatCreationRelation] { [:] }
|
||||
func deleteRelation(conversationID: UUID) throws {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct RejectingChatReviewRelationReads: AssistantChatCollaborationStoring {
|
||||
let confirmsDeliveryDuringMessagePersistence = false
|
||||
func receipt(sourceRunID: UUID, sourceToolCallID: String) throws -> AssistantChatDeliveryReceipt? { nil }
|
||||
func save(_ receipt: AssistantChatDeliveryReceipt) throws {}
|
||||
func updateReceipt(messageID: UUID, state: AgentMessageDeliveryState, failure: String?) throws {}
|
||||
func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt] { [] }
|
||||
func save(_ relation: AssistantChatCreationRelation) throws {}
|
||||
func creationRelations(
|
||||
conversationIDs: Set<UUID>
|
||||
) throws -> [UUID: AssistantChatCreationRelation] { throw ChatReviewReadFailure() }
|
||||
func deleteRelation(conversationID: UUID) throws {}
|
||||
}
|
||||
@@ -62,6 +62,9 @@ nonisolated public struct AssistantChatDeliveryReceipt: Identifiable, Hashable,
|
||||
) throws
|
||||
func receipts(targetConversationID: UUID) throws -> [AssistantChatDeliveryReceipt]
|
||||
func save(_ relation: AssistantChatCreationRelation) throws
|
||||
func creationRelations(
|
||||
conversationIDs: Set<UUID>
|
||||
) throws -> [UUID: AssistantChatCreationRelation]
|
||||
func deleteRelation(conversationID: UUID) throws
|
||||
}
|
||||
|
||||
@@ -117,6 +120,12 @@ public final class InMemoryAssistantChatCollaborationStore: AssistantChatCollabo
|
||||
if relations[relation.conversationID] == nil { relations[relation.conversationID] = relation }
|
||||
}
|
||||
|
||||
public func creationRelations(
|
||||
conversationIDs: Set<UUID>
|
||||
) throws -> [UUID: AssistantChatCreationRelation] {
|
||||
relations.filter { conversationIDs.contains($0.key) }
|
||||
}
|
||||
|
||||
public func deleteRelation(conversationID: UUID) throws {
|
||||
relations[conversationID] = nil
|
||||
}
|
||||
@@ -261,8 +270,15 @@ public final class AssistantChatCoordinator: AgentChatModelCatalog, AgentChatCoo
|
||||
throw AgentToolError.invalidArguments("The chat cursor is invalid.")
|
||||
}
|
||||
let page = Array(candidates.dropFirst(offset).prefix(20))
|
||||
let relations = try collaborationStore.creationRelations(
|
||||
conversationIDs: Set(page.map(\.id)))
|
||||
var result: [String: AgentJSONValue] = [
|
||||
"chats": .array(page.map { chatJSON($0) }),
|
||||
"chats": .array(
|
||||
page.map { conversation in
|
||||
listChatJSON(
|
||||
conversation,
|
||||
relation: relations[conversation.id])
|
||||
}),
|
||||
"count": .number(Double(page.count)),
|
||||
]
|
||||
let next = offset + page.count
|
||||
@@ -479,6 +495,35 @@ public final class AssistantChatCoordinator: AgentChatModelCatalog, AgentChatCoo
|
||||
return .object(object)
|
||||
}
|
||||
|
||||
private func listChatJSON(
|
||||
_ conversation: ConversationModel,
|
||||
relation: AssistantChatCreationRelation?
|
||||
) -> AgentJSONValue {
|
||||
var object = chatObject(conversation)
|
||||
object["creator"] = creatorJSON(for: conversation, relation: relation)
|
||||
return .object(object)
|
||||
}
|
||||
|
||||
private func creatorJSON(
|
||||
for conversation: ConversationModel,
|
||||
relation: AssistantChatCreationRelation?
|
||||
) -> AgentJSONValue {
|
||||
guard let relation else {
|
||||
return .object(["kind": .string("user")])
|
||||
}
|
||||
let savedTitle = conversation.messages.lazy.compactMap(\.origin).first { origin in
|
||||
origin.sourceConversationID == relation.sourceConversationID
|
||||
&& origin.sourceRunID == relation.sourceRunID
|
||||
&& origin.sourceToolCallID == relation.sourceToolCallID
|
||||
}?.sourceTitle
|
||||
let title = conversations.conversation(id: relation.sourceConversationID)?.derivedTitle ?? savedTitle
|
||||
return .object([
|
||||
"kind": .string("agent"),
|
||||
"chat_id": .string(relation.sourceConversationID.uuidString),
|
||||
"title": title.map(AgentJSONValue.string) ?? .null,
|
||||
])
|
||||
}
|
||||
|
||||
private func chatObject(_ conversation: ConversationModel) -> [String: AgentJSONValue] {
|
||||
[
|
||||
"chat_id": .string(conversation.id.uuidString),
|
||||
|
||||
@@ -40,6 +40,14 @@ public final class PersistentAgentConversationOrigin {
|
||||
sourceRunID = value.sourceRunID
|
||||
sourceToolCallID = value.sourceToolCallID
|
||||
}
|
||||
|
||||
public var value: AssistantChatCreationRelation {
|
||||
AssistantChatCreationRelation(
|
||||
conversationID: conversationID,
|
||||
sourceConversationID: sourceConversationID,
|
||||
sourceRunID: sourceRunID,
|
||||
sourceToolCallID: sourceToolCallID)
|
||||
}
|
||||
}
|
||||
|
||||
@Model
|
||||
@@ -167,6 +175,20 @@ public final class SwiftDataAssistantChatCollaborationStore: AssistantChatCollab
|
||||
try context.savePendingChanges()
|
||||
}
|
||||
|
||||
public func creationRelations(
|
||||
conversationIDs: Set<UUID>
|
||||
) throws -> [UUID: AssistantChatCreationRelation] {
|
||||
guard !conversationIDs.isEmpty else { return [:] }
|
||||
let context = ModelContext(container)
|
||||
let ids = conversationIDs
|
||||
return Dictionary(
|
||||
uniqueKeysWithValues: try context.fetch(
|
||||
FetchDescriptor<PersistentAgentConversationOrigin>(
|
||||
predicate: #Predicate { ids.contains($0.conversationID) })
|
||||
).map { ($0.conversationID, $0.value) }
|
||||
)
|
||||
}
|
||||
|
||||
public func deleteRelation(conversationID: UUID) throws {
|
||||
let context = ModelContext(container)
|
||||
let rows = try context.fetch(
|
||||
|
||||
@@ -214,6 +214,32 @@ struct MobileAssistantAgentOriginPresentation: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileAssistantAgentOriginButton: View {
|
||||
static let systemImage = "sparkles"
|
||||
|
||||
let presentation: MobileAssistantAgentOriginPresentation
|
||||
let navigate: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: navigate) {
|
||||
Label(
|
||||
AppStrings.Assistant.agent.swiftUIKey,
|
||||
systemImage: Self.systemImage
|
||||
)
|
||||
.labelStyle(.iconOnly)
|
||||
.frame(minWidth: 44, minHeight: 44)
|
||||
.contentShape(.rect)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(!presentation.canNavigate)
|
||||
.accessibilityLabel(Text(presentation.label))
|
||||
.help(presentation.label)
|
||||
.accessibilityIdentifier("AssistantMessage.AgentOrigin")
|
||||
}
|
||||
}
|
||||
|
||||
struct MobileAssistantUserBubble: View {
|
||||
let text: String
|
||||
|
||||
@@ -944,17 +970,10 @@ struct MobileAssistantConversation: View {
|
||||
origin: origin,
|
||||
source: model.library.conversation(id: origin.sourceConversationID),
|
||||
locale: locale)
|
||||
Button(AppStrings.Assistant.agent.swiftUIKey) {
|
||||
MobileAssistantAgentOriginButton(presentation: presentation) {
|
||||
guard let target = model.library.conversation(id: origin.sourceConversationID) else { return }
|
||||
model.select(target)
|
||||
}
|
||||
.font(.caption.weight(.medium))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(!presentation.canNavigate)
|
||||
.accessibilityLabel(Text(presentation.label))
|
||||
.help(presentation.label)
|
||||
.accessibilityIdentifier("AssistantMessage.AgentOrigin")
|
||||
}
|
||||
|
||||
if conversation.canForkAssistantMessage(message.id) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import AgentKit
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindPresentation
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@@ -44,4 +46,37 @@ struct MobileAssistantAgentOriginTests {
|
||||
locale: locale))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func renderedMarkerUsesASingleSparklesControlAcrossLocalesAndAvailability() {
|
||||
#expect(MobileAssistantAgentOriginButton.systemImage == "sparkles")
|
||||
for language in ["en", "zh-Hans", "zh-Hant"] {
|
||||
for canNavigate in [true, false] {
|
||||
let source = canNavigate ? ConversationModel() : nil
|
||||
source?.customTitle = "Source"
|
||||
let sourceID = source?.id ?? UUID()
|
||||
let presentation = MobileAssistantAgentOriginPresentation(
|
||||
origin: AgentMessageOrigin(
|
||||
sourceConversationID: sourceID,
|
||||
sourceRunID: UUID(),
|
||||
sourceToolCallID: "send",
|
||||
sourceTitle: "Source"),
|
||||
source: source,
|
||||
locale: Locale(identifier: language))
|
||||
let controller = UIHostingController(
|
||||
rootView: MobileAssistantAgentOriginButton(
|
||||
presentation: presentation,
|
||||
navigate: {}
|
||||
)
|
||||
.fixedSize()
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.dynamicTypeSize, .accessibility3))
|
||||
let size = controller.sizeThatFits(
|
||||
in: CGSize(width: 200, height: 200))
|
||||
|
||||
#expect(abs(size.width - 44) < 0.5)
|
||||
#expect(abs(size.height - 44) < 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -736,9 +736,6 @@ private enum NativeTranscriptFactory {
|
||||
context.registerHoverView(hoverActions)
|
||||
}
|
||||
if let origin = message.origin {
|
||||
let agent = NativeButton(title: String(localized: AppStrings.Assistant.agent))
|
||||
agent.identifier = NSUserInterfaceItemIdentifier("Transcript.AgentOrigin")
|
||||
agent.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
|
||||
let sourceTitle =
|
||||
context.configuration.chatDestinations[origin.sourceConversationID]
|
||||
?? origin.sourceTitle
|
||||
@@ -751,7 +748,8 @@ private enum NativeTranscriptFactory {
|
||||
?? AppLocalization.resolve(
|
||||
AppStrings.Assistant.agent,
|
||||
locale: context.configuration.locale)
|
||||
agent.toolTip = label
|
||||
let agent = NativeButton(symbol: "sparkles", help: label)
|
||||
agent.identifier = NSUserInterfaceItemIdentifier("Transcript.AgentOrigin")
|
||||
agent.setAccessibilityLabel(label)
|
||||
agent.isEnabled = context.configuration.chatDestinations[origin.sourceConversationID] != nil
|
||||
agent.handler = { context.actions.navigateChat(origin.sourceConversationID) }
|
||||
|
||||
@@ -36,11 +36,14 @@ struct AgentOriginRenderingTests {
|
||||
$0.identifier?.rawValue == "Transcript.AgentOrigin"
|
||||
})
|
||||
#expect(liveMarker.isEnabled)
|
||||
#expect(liveMarker.title.isEmpty)
|
||||
#expect(liveMarker.imagePosition == .imageOnly)
|
||||
#expect(
|
||||
liveMarker.toolTip
|
||||
== AppLocalization.resolve(
|
||||
AppStrings.Assistant.agentFrom("Live source"),
|
||||
locale: Locale(identifier: "en")))
|
||||
#expect(liveMarker.image != nil)
|
||||
expectVisibleAncestors(from: liveMarker, through: row)
|
||||
#expect(buttons.contains { $0.toolTip == String(localized: AppStrings.Common.copy) })
|
||||
#expect(!buttons.contains { $0.toolTip == String(localized: AppStrings.Common.edit) })
|
||||
@@ -54,6 +57,8 @@ struct AgentOriginRenderingTests {
|
||||
$0.identifier?.rawValue == "Transcript.AgentOrigin"
|
||||
})
|
||||
#expect(!deletedMarker.isEnabled)
|
||||
#expect(deletedMarker.title.isEmpty)
|
||||
#expect(deletedMarker.imagePosition == .imageOnly)
|
||||
#expect(
|
||||
deletedMarker.toolTip
|
||||
== AppLocalization.resolve(
|
||||
|
||||
@@ -356,12 +356,19 @@ struct AgentToolPresentationTests {
|
||||
@Test
|
||||
func chatCompactPreviewAndFullCopyContainOnlyProjectedBoundedFields() throws {
|
||||
let secret = "body-that-must-never-render"
|
||||
let creatorSecrets = (0..<12).map { "creator-that-must-never-render-\($0)" }
|
||||
let creatorIDs = (0..<12).map { _ in UUID().uuidString }
|
||||
let chats = (0..<12).map { index in
|
||||
AgentJSONValue.object([
|
||||
"chat_id": .string(UUID().uuidString),
|
||||
"title": .string("Chat \(index)"),
|
||||
"status": .string("completed"),
|
||||
"message": .string("\(secret)-\(index)"),
|
||||
"creator": .object([
|
||||
"kind": .string("agent"),
|
||||
"chat_id": .string(creatorIDs[index]),
|
||||
"title": .string(creatorSecrets[index]),
|
||||
]),
|
||||
])
|
||||
}
|
||||
var card = makeCard(
|
||||
@@ -384,10 +391,14 @@ struct AgentToolPresentationTests {
|
||||
#expect(preview.isShortened)
|
||||
#expect(preview.fullText.contains("Chat 11"))
|
||||
#expect(!preview.fullText.contains(secret))
|
||||
#expect(creatorSecrets.allSatisfy { !preview.fullText.contains($0) })
|
||||
#expect(creatorIDs.allSatisfy { !preview.fullText.contains($0) })
|
||||
let displayed = AgentToolDetailPreview(
|
||||
AgentToolDetail(items: preview.items)
|
||||
).fullText
|
||||
#expect(!displayed.contains(secret))
|
||||
#expect(creatorSecrets.allSatisfy { !displayed.contains($0) })
|
||||
#expect(creatorIDs.allSatisfy { !displayed.contains($0) })
|
||||
guard case .list(let list) = try #require(preview.items.first) else {
|
||||
Issue.record("Expected compact chat list")
|
||||
return
|
||||
|
||||
Reference in new issue
Block a user