diff --git a/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift b/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift index de9a0c20..3af597b0 100644 --- a/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift +++ b/FrameworkTests/AssistantChatCollaborationPersistenceTests.swift @@ -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]) + } } diff --git a/FrameworkTests/AssistantChatCollaborationReviewTests.swift b/FrameworkTests/AssistantChatCollaborationReviewTests.swift index 0a7840c8..cc353b19 100644 --- a/FrameworkTests/AssistantChatCollaborationReviewTests.swift +++ b/FrameworkTests/AssistantChatCollaborationReviewTests.swift @@ -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] = [] + + 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 + ) 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 + ) 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 + ) throws -> [UUID: AssistantChatCreationRelation] { throw ChatReviewReadFailure() } func deleteRelation(conversationID: UUID) throws {} } diff --git a/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift b/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift index 836adebf..72757a77 100644 --- a/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift +++ b/Frameworks/TermindApplication/Agent/AssistantChatCollaboration.swift @@ -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 + ) 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 + ) 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), diff --git a/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift b/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift index d78baa4e..382c5bb6 100644 --- a/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift +++ b/Frameworks/TermindPersistence/AssistantChatCollaborationPersistence.swift @@ -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 + ) throws -> [UUID: AssistantChatCreationRelation] { + guard !conversationIDs.isEmpty else { return [:] } + let context = ModelContext(container) + let ids = conversationIDs + return Dictionary( + uniqueKeysWithValues: try context.fetch( + FetchDescriptor( + 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( diff --git a/TFI/Features/AI/MobileAssistantConversation.swift b/TFI/Features/AI/MobileAssistantConversation.swift index 30ed37fd..c2697476 100644 --- a/TFI/Features/AI/MobileAssistantConversation.swift +++ b/TFI/Features/AI/MobileAssistantConversation.swift @@ -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) { diff --git a/TFITests/MobileAssistantAgentOriginTests.swift b/TFITests/MobileAssistantAgentOriginTests.swift index 1b5a91cf..af8cc761 100644 --- a/TFITests/MobileAssistantAgentOriginTests.swift +++ b/TFITests/MobileAssistantAgentOriginTests.swift @@ -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) + } + } + } } diff --git a/TFM/Features/AI/TranscriptNativeRows.swift b/TFM/Features/AI/TranscriptNativeRows.swift index 05368370..11c91789 100644 --- a/TFM/Features/AI/TranscriptNativeRows.swift +++ b/TFM/Features/AI/TranscriptNativeRows.swift @@ -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) } diff --git a/TFMTests/AgentOriginRenderingTests.swift b/TFMTests/AgentOriginRenderingTests.swift index 17b737b6..653f9c9c 100644 --- a/TFMTests/AgentOriginRenderingTests.swift +++ b/TFMTests/AgentOriginRenderingTests.swift @@ -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( diff --git a/TFMTests/AgentToolPresentationTests.swift b/TFMTests/AgentToolPresentationTests.swift index bc181c94..20e72e0c 100644 --- a/TFMTests/AgentToolPresentationTests.swift +++ b/TFMTests/AgentToolPresentationTests.swift @@ -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