diff --git a/Termind/Features/AI/AgentToolPresentation.swift b/Termind/Features/AI/AgentToolPresentation.swift index 6bd05e04..7815ff5b 100644 --- a/Termind/Features/AI/AgentToolPresentation.swift +++ b/Termind/Features/AI/AgentToolPresentation.swift @@ -46,6 +46,28 @@ nonisolated enum AgentToolPresentation { return .warning } + /// A stable 30% sample for a batch. Invocation identifiers are random UUIDs, + /// so using their first 32 bits gives the desired distribution without + /// making a SwiftUI row redraw or scroll reuse roll the dice again. + static func showsReasoning(forBatchID id: UUID) -> Bool { + let sample = withUnsafeBytes(of: id.uuid) { bytes in + bytes.prefix(4).reduce(UInt32.zero) { value, byte in + (value << 8) | UInt32(byte) + } + } + return sample < (UInt32.max / 10) * 3 + } + + /// Converts provider-authored Markdown into the one-line prose a tool + /// batch header can accommodate. Structure and emphasis remain available + /// in the live reasoning row; here only the visible words are reused. + static func reasoningTitle(from markdown: String) -> String { + let text = MarkdownDocumentParser.parse(markdown) + .flatMap(plainText) + .joined(separator: " ") + return text.split(whereSeparator: \.isWhitespace).joined(separator: " ") + } + static func summary( for card: AgentToolCard, hostName: String? = nil @@ -92,6 +114,27 @@ nonisolated enum AgentToolPresentation { return action } + private static func plainText(_ block: MarkdownBlock) -> [String] { + switch block { + case .paragraph(let inlines), .heading(_, let inlines): + [inlines.map(\.text).joined()] + case .codeBlock(_, let code): + [code] + case .list(let list): + list.items.flatMap { $0.blocks.flatMap(plainText) } + case .table(let table): + ([table.header] + table.rows).flatMap { row in + row.map { $0.map(\.text).joined() } + } + case .blockQuote(let blocks): + blocks.flatMap(plainText) + case .htmlBlock(let raw): + [raw] + case .thematicBreak: + [] + } + } + /// A finished contiguous batch, phrased from the same presentation /// metadata as its individual rows but with repeated activities counted. /// Calls in different terminal states stay separate, so one failure can diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index 818bf400..87bb46f4 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -304,21 +304,34 @@ nonisolated enum TimelineItem: Identifiable, Hashable, Sendable { } } +nonisolated struct AgentToolBatch: Identifiable, Hashable, Sendable { + var id: UUID { cards.first?.id ?? Self.emptyID } + var cards: [AgentToolCard] + /// The assistant response that requested every tool in this batch. + var sourceMessageID: UUID + /// A single-line projection of that response's display-safe reasoning. + /// Kept only while at least one member is unfinished. + var reasoningTitle: String? + + private static let emptyID = UUID( + uuidString: "00000000-0000-0000-0000-000000000000" + )! +} + nonisolated enum AgentWorkItem: Identifiable, Hashable, Sendable { case reasoning(AgentReasoningBlock) case message(ChatMessage) case tool(AgentToolCard) /// Two or more contiguous tool calls, regardless of their current result. /// The view shows activity until every member is terminal, then summarizes. - case tools([AgentToolCard]) + case tools(AgentToolBatch) var id: UUID { switch self { case .reasoning(let block): block.id case .message(let message): message.id case .tool(let card): card.id - case .tools(let cards): - cards.first?.id ?? UUID(uuidString: "00000000-0000-0000-0000-000000000000")! + case .tools(let batch): batch.id } } @@ -327,7 +340,7 @@ nonisolated enum AgentWorkItem: Identifiable, Hashable, Sendable { case .reasoning(let block): block.createdAt case .message(let message): message.createdAt case .tool(let card): card.invocation.createdAt - case .tools(let cards): cards.first?.invocation.createdAt ?? .distantPast + case .tools(let batch): batch.cards.first?.invocation.createdAt ?? .distantPast } } } @@ -987,8 +1000,15 @@ final class ConversationModel: Identifiable { ?? recoveredSourceByCallID[card.invocation.call.id] return source.map { (card.id, $0) } }) + let reasoningTitles = Dictionary(uniqueKeysWithValues: processMessages.compactMap { + message -> (UUID, String)? in + let reasoning = message.reasoning.map(\.text).joined(separator: "\n\n") + let title = AgentToolPresentation.reasoningTitle(from: reasoning) + return title.isEmpty ? nil : (message.id, title) + }) items = Self.groupAdjacentTools( - in: items, sourceMessageIDs: sourceMessageIDs + in: items, sourceMessageIDs: sourceMessageIDs, + reasoningTitles: reasoningTitles ) let stored = runSummaries[runID] // A user stop is useful transcript state even when it interrupted a @@ -1027,7 +1047,8 @@ final class ConversationModel: Identifiable { } private static func groupAdjacentTools( - in items: [AgentWorkItem], sourceMessageIDs: [AgentToolCard.ID: UUID] + in items: [AgentWorkItem], sourceMessageIDs: [AgentToolCard.ID: UUID], + reasoningTitles: [UUID: String] ) -> [AgentWorkItem] { var grouped: [AgentWorkItem] = [] var adjacent: [AgentToolCard] = [] @@ -1036,8 +1057,13 @@ final class ConversationModel: Identifiable { guard !adjacent.isEmpty else { return } if adjacent.count == 1 { grouped.append(.tool(adjacent[0])) - } else { - grouped.append(.tools(adjacent)) + } else if let sourceMessageID = adjacentSourceMessageID { + let hasUnfinished = adjacent.contains { !$0.state.isTerminal } + grouped.append(.tools(AgentToolBatch( + cards: adjacent, + sourceMessageID: sourceMessageID, + reasoningTitle: hasUnfinished ? reasoningTitles[sourceMessageID] : nil + ))) } adjacent.removeAll(keepingCapacity: true) adjacentSourceMessageID = nil diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 763a9677..18c9991a 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -394,8 +394,8 @@ private struct AgentWorkGroupView: View { processMessage(message) case .tool(let card): AgentToolRowView(card: card) - case .tools(let cards): - AgentToolBatchView(cards: cards) + case .tools(let batch): + AgentToolBatchView(batch: batch) } } } @@ -440,11 +440,13 @@ private struct AgentWorkGroupView: View { /// the header reuses those calls' ordinary live wording exactly; only a fully /// terminal batch changes over to a compact action summary. private struct AgentToolBatchView: View { - let cards: [AgentToolCard] + let batch: AgentToolBatch @Environment(AppModel.self) private var app @State private var isExpanded = false + private var cards: [AgentToolCard] { batch.cards } + private var unfinished: [AgentToolCard] { cards.filter { card in switch card.state { @@ -454,10 +456,18 @@ private struct AgentToolBatchView: View { } } + private var displayedReasoning: String? { + guard !unfinished.isEmpty, let reasoning = batch.reasoningTitle, + AgentToolPresentation.showsReasoning(forBatchID: batch.id) + else { return nil } + return reasoning + } + private var title: String { guard !unfinished.isEmpty else { return AgentToolPresentation.batchSummary(for: cards) } + if let displayedReasoning { return displayedReasoning } return unfinished.map { card in AgentToolPresentation.summary(for: card, hostName: hostName(for: card)) }.joined(separator: "; ") @@ -474,8 +484,11 @@ private struct AgentToolBatchView: View { .frame(width: Metrics.Sidebar.rowIcon) Text(title) .font(MarkdownTypography.body) + .lineLimit(displayedReasoning == nil ? nil : 1) + .truncationMode(.tail) .foregroundStyle(.secondary) .modifier(ScanningTextMask(isActive: !unfinished.isEmpty)) + .layoutPriority(1) Image(systemName: "chevron.right") .font(.system(size: 8, weight: .bold)) .rotationEffect(.degrees(isExpanded ? 90 : 0)) diff --git a/TermindTests/AgentToolPresentationTests.swift b/TermindTests/AgentToolPresentationTests.swift index 83834a44..8c1c1d3f 100644 --- a/TermindTests/AgentToolPresentationTests.swift +++ b/TermindTests/AgentToolPresentationTests.swift @@ -254,6 +254,32 @@ struct AgentToolPresentationTests { ) } + @Test + func reasoningBatchTitleIsPlainSingleLineText() { + let title = AgentToolPresentation.reasoningTitle(from: """ + **Checked both hosts.** + + - Compare the logs + - Verify `sshd` + """) + + #expect(title == "Checked both hosts. Compare the logs Verify sshd") + } + + @Test + func reasoningBatchSamplingIsStableAtThirtyPercent() throws { + let included = try #require(UUID( + uuidString: "00000000-0000-4000-8000-000000000001" + )) + let excluded = try #require(UUID( + uuidString: "ffffffff-ffff-4fff-8fff-ffffffffffff" + )) + + #expect(AgentToolPresentation.showsReasoning(forBatchID: included)) + #expect(AgentToolPresentation.showsReasoning(forBatchID: included)) + #expect(!AgentToolPresentation.showsReasoning(forBatchID: excluded)) + } + @Test func countedArrayActivityRoundTrips() throws { let activity = AgentToolDescriptor.Presentation.Activity.arrayCount( diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index c044c515..b457c0fb 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -641,7 +641,7 @@ struct ChatComposerStateTests { Issue.record("Two adjacent tools should form one live batch") return } - #expect(live.map(\.id) == [first.id, second.id]) + #expect(live.cards.map(\.id) == [first.id, second.id]) conversation.handle(.toolFinished(first, AgentToolResult( callID: first.call.id, content: "failed", isError: true @@ -655,7 +655,67 @@ struct ChatComposerStateTests { Issue.record("A failed member must not split the completed batch") return } - #expect(terminal.map(\.state) == [.failed, .completed]) + #expect(terminal.cards.map(\.state) == [.failed, .completed]) + } + + @Test + func liveToolBatchReusesOnlyItsProviderTurnsReasoning() throws { + let conversation = ConversationModel() + conversation.appendUserTurn("inspect both") + let runID = UUID() + let start = Date() + let source = AgentTranscriptMessage( + role: .assistant, + reasoning: [AgentReasoningBlock(text: "**Compare** both hosts.\nThen verify them.")], + toolCalls: ["first", "second"].map { + AgentToolCall(id: $0, name: "lookup", arguments: .object([:])) + }, + createdAt: start.addingTimeInterval(1) + ) + conversation.handle(.runStarted(AgentRunSummary( + id: runID, state: .running, startedAt: start + ))) + conversation.handle(.messageStarted(source)) + conversation.handle(.messageFinished(source)) + conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls)) + + let descriptor = AgentToolDescriptor( + name: "lookup", summary: "lookup", inputSchema: .object([:]), + target: .local, safety: .locallyReadOnly + ) + let invocations = ["first", "second"].enumerated().map { offset, callID in + AgentToolInvocation( + runID: runID, + call: AgentToolCall(id: callID, name: "lookup", arguments: .object([:])), + sourceMessageID: source.id, + createdAt: start.addingTimeInterval(Double(offset + 2)) + ) + } + for invocation in invocations { + conversation.handle(.toolProposed(invocation, descriptor)) + } + + guard case .tools(let live) = try #require( + conversation.workGroups.first?.items.first + ) else { + Issue.record("Expected a live tool batch") + return + } + #expect(live.sourceMessageID == source.id) + #expect(live.reasoningTitle == "Compare both hosts. Then verify them.") + + for invocation in invocations { + conversation.handle(.toolFinished(invocation, AgentToolResult( + callID: invocation.call.id, content: "ok" + ))) + } + guard case .tools(let terminal) = try #require( + conversation.workGroups.first?.items.first + ) else { + Issue.record("Expected the completed tools to remain batched") + return + } + #expect(terminal.reasoningTitle == nil) } @Test @@ -837,8 +897,8 @@ struct ChatComposerStateTests { case .reasoning(let block): return "reasoning:\(block.text)" case .message(let message): return "message:\(message.text)" case .tool(let card): return "tool:\(card.invocation.call.id)" - case .tools(let cards): - return "tools:\(cards.map(\.invocation.call.id).joined(separator: ","))" + case .tools(let batch): + return "tools:\(batch.cards.map(\.invocation.call.id).joined(separator: ","))" } } #expect(kinds == [ @@ -896,7 +956,7 @@ struct ChatComposerStateTests { let group = try #require(conversation.workGroups.first) let batches: [[String]] = group.items.map { item -> [String] in switch item { - case .tools(let cards): cards.map(\.invocation.call.id) + case .tools(let batch): batch.cards.map(\.invocation.call.id) case .tool(let card): [card.invocation.call.id] case .reasoning, .message: [] } @@ -953,7 +1013,7 @@ struct ChatComposerStateTests { let items = try #require(store.conversation(id: conversationID)?.workGroups.first).items let groups = items.map { item -> [String] in switch item { - case .tools(let cards): cards.map(\.invocation.call.id) + case .tools(let batch): batch.cards.map(\.invocation.call.id) case .tool(let card): [card.invocation.call.id] case .reasoning, .message: [] } @@ -998,7 +1058,7 @@ struct ChatComposerStateTests { let items = group.items let groups = items.map { item -> [String] in switch item { - case .tools(let cards): cards.map(\.descriptor.name) + case .tools(let batch): batch.cards.map(\.descriptor.name) case .tool(let card): [card.descriptor.name] case .reasoning, .message: [] }