Show reasoning in active tool batches

This commit is contained in:
Kaede Akino committed 2026-08-27 04:27:32 +08:00
1 parent 4690e856e9
commit b294f4346f
5 files changed
+186 -18

No files matched your search

@@ -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
+34 -8
View File
@@ -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
@@ -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))
@@ -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(
+67 -7
View File
@@ -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: []
}