From e3ee3fc62ea100a307fbb20ec176e17c46e3e4cc Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 27 Aug 2026 11:01:49 +0800 Subject: [PATCH] Prevent duplicate live reasoning --- Termind/Features/AI/ConversationModel.swift | 4 ++ TermindTests/ChatComposerStateTests.swift | 68 +++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index 87bb46f4..511bea9d 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -1002,6 +1002,10 @@ final class ConversationModel: Identifiable { }) let reasoningTitles = Dictionary(uniqueKeysWithValues: processMessages.compactMap { message -> (UUID, String)? in + // The live reasoning row owns this text until its provider + // turn ends. A tool batch from the same response may reuse it + // afterwards, but must never render a duplicate alongside it. + guard message.id != activeReasoningMessageID else { return nil } let reasoning = message.reasoning.map(\.text).joined(separator: "\n\n") let title = AgentToolPresentation.reasoningTitle(from: reasoning) return title.isEmpty ? nil : (message.id, title) diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index b457c0fb..b46a357a 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -718,6 +718,74 @@ struct ChatComposerStateTests { #expect(terminal.reasoningTitle == nil) } + @Test + func liveToolBatchDoesNotDuplicateCurrentlyVisibleReasoning() throws { + let conversation = ConversationModel() + conversation.appendUserTurn("inspect both") + let runID = UUID() + let start = Date() + let reasoning = AgentReasoningBlock(text: "Compare both hosts.") + let source = AgentTranscriptMessage( + role: .assistant, reasoning: [reasoning], + 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(.reasoningDelta( + messageID: source.id, text: reasoning.text + )) + conversation.handle(.messageFinished(source)) + + let descriptor = AgentToolDescriptor( + name: "lookup", summary: "lookup", inputSchema: .object([:]), + target: .local, safety: .locallyReadOnly + ) + for (offset, call) in source.toolCalls.enumerated() { + conversation.handle(.toolProposed(AgentToolInvocation( + runID: runID, call: call, sourceMessageID: source.id, + createdAt: start.addingTimeInterval(Double(offset + 2)) + ), descriptor)) + } + + let overlappingGroup = try #require(conversation.workGroups.first) + #expect(overlappingGroup.items.contains { item in + guard case .reasoning = item else { return false } + return true + }) + guard case .tools(let overlappingBatch) = try #require( + overlappingGroup.items.first(where: { + if case .tools = $0 { return true } + return false + }) + ) else { + Issue.record("Expected a live tool batch") + return + } + #expect(overlappingBatch.reasoningTitle == nil) + + conversation.handle(.turnFinished(index: 0, stopReason: .toolCalls)) + let settledGroup = try #require(conversation.workGroups.first) + #expect(!settledGroup.items.contains { item in + guard case .reasoning = item else { return false } + return true + }) + guard case .tools(let settledBatch) = try #require( + settledGroup.items.first(where: { + if case .tools = $0 { return true } + return false + }) + ) else { + Issue.record("Expected the live tool batch to remain") + return + } + #expect(settledBatch.reasoningTitle == reasoning.text) + } + @Test func directAnswerDoesNotCreateAWorkGroup() { let conversation = ConversationModel()