feat(assistant): add chat creator attribution and source icons

This commit is contained in:
Kaede Akino committed 2026-09-27 19:54:56 +08:00
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)
}
}
}
}
+2 -4
View File
@@ -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) }
+5
View File
@@ -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(
+11
View File
@@ -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