From bbc587d518280486c4968efe044a93dcfc0a6023 Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Thu, 27 Aug 2026 01:55:01 +0800 Subject: [PATCH] Fix stopped conversation streaming state --- Termind/Features/AI/ConversationModel.swift | 24 ++++- TermindTests/ChatComposerStateTests.swift | 109 ++++++++++++++++++++ 2 files changed, 130 insertions(+), 3 deletions(-) diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index e6aa70c7..9f47f749 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -1299,7 +1299,7 @@ final class ConversationModel: Identifiable { } func stop() { - activeReasoningMessageID = nil + settleStreamingPresentation(for: activeRunID) for index in toolCards.indices where toolCards[index].state == .running { toolCards[index].state = .interrupted } @@ -1627,11 +1627,29 @@ final class ConversationModel: Identifiable { isStreaming = runState != .idle && !runState.isTerminal } + /// Ends presentation that only makes sense while a provider stream is + /// live. A cancelled turn never emits `messageFinished`, so without this + /// explicit cleanup its empty placeholder keeps drawing Working forever + /// (and a partial answer keeps its streaming caret) after the run ends. + /// + /// There can only be one active run in a conversation. `nil` is therefore + /// the safe fallback for a terminal event whose run boundary was missed: + /// settle every orphaned streaming message rather than leave the UI live. + private func settleStreamingPresentation(for runID: UUID?) { + activeReasoningMessageID = nil + for index in messages.indices where messages[index].isStreaming { + guard runID == nil || messageRunIDs[messages[index].id] == runID else { + continue + } + messages[index].isStreaming = false + } + } + func handle(_ event: AgentEvent) { switch event { case .runState(let state): runtimeState = state - if state.isTerminal { activeReasoningMessageID = nil } + if state.isTerminal { settleStreamingPresentation(for: activeRunID) } refreshRunState() if state.isTerminal { returnAllSteeringToDraft() @@ -1650,7 +1668,7 @@ final class ConversationModel: Identifiable { messageRunIDs[prompt.id] = run.id } case .runFinished(let run): - activeReasoningMessageID = nil + settleStreamingPresentation(for: run.id) runSummaries[run.id] = run if activeRunID == run.id { activeRunID = nil } case .turnStarted: diff --git a/TermindTests/ChatComposerStateTests.swift b/TermindTests/ChatComposerStateTests.swift index b6a61192..f4db6be3 100644 --- a/TermindTests/ChatComposerStateTests.swift +++ b/TermindTests/ChatComposerStateTests.swift @@ -262,6 +262,87 @@ struct ChatComposerStateTests { #expect(!conversation.messages[1].isStreaming) } + @Test + func stoppingBeforeAnswerHidesTheWorkingPlaceholder() throws { + let (conversation, _, assistant) = beginStreamingConversation() + + conversation.stop() + + let stoppedMessage = try #require( + conversation.messages.first { $0.id == assistant.id } + ) + #expect(!stoppedMessage.isStreaming) + #expect(conversation.runState == .cancelled) + #expect(conversation.timeline.map(timelineKind) == ["user", "work"]) + #expect(conversation.workGroups.first?.run.state == .cancelled) + } + + @Test + func stoppingDuringReasoningEndsItsActivePresentation() throws { + let (conversation, _, assistant) = beginStreamingConversation() + conversation.handle(.reasoningDelta( + messageID: assistant.id, text: "Evaluating the request." + )) + #expect(conversation.workGroups.first?.activeReasoningBlockID != nil) + + conversation.stop() + + let stoppedMessage = try #require( + conversation.messages.first { $0.id == assistant.id } + ) + #expect(!stoppedMessage.isStreaming) + #expect(stoppedMessage.reasoning.map(\.text) == ["Evaluating the request."]) + #expect(conversation.workGroups.first?.activeReasoningBlockID == nil) + #expect(conversation.timeline.map(timelineKind) == ["user", "work"]) + } + + @Test + func stoppingAPartialAnswerKeepsItsTextWithoutAStreamingCaret() throws { + let (conversation, _, assistant) = beginStreamingConversation() + conversation.handle(.messageDelta(messageID: assistant.id, text: "Partial answer")) + + conversation.stop() + + let stoppedMessage = try #require( + conversation.messages.first { $0.id == assistant.id } + ) + #expect(stoppedMessage.text == "Partial answer") + #expect(!stoppedMessage.isStreaming) + #expect(conversation.timeline.map(timelineKind) == ["user", "assistant", "work"]) + } + + @Test + func stoppingAWorkingToolInterruptsItAndSettlesItsMessage() throws { + let (conversation, runID, assistant) = beginStreamingConversation() + let invocation = AgentToolInvocation( + runID: runID, + call: AgentToolCall(id: "call-1", name: "lookup", arguments: .object([:])), + sourceMessageID: assistant.id + ) + conversation.handle(.toolProposed(invocation, AgentToolDescriptor( + name: "lookup", summary: "lookup", inputSchema: .object([:]), + target: .local, safety: .locallyReadOnly + ))) + conversation.handle(.toolStarted(invocation)) + + conversation.stop() + + #expect(conversation.toolCards.first?.state == .interrupted) + #expect(conversation.messages.allSatisfy { !$0.isStreaming }) + #expect(conversation.workGroups.first?.run.state == .cancelled) + } + + @Test + func terminalRunEventSettlesAnUnfinishedMessage() { + let (conversation, runID, assistant) = beginStreamingConversation() + + conversation.handle(.runFinished(AgentRunSummary( + id: runID, state: .failed, startedAt: .now, finishedAt: .now + ))) + + #expect(conversation.messages.first { $0.id == assistant.id }?.isStreaming == false) + } + @Test func immediatelyCompletedRuntimeStillReachesTheConversation() async { let conversation = ConversationModel() @@ -1109,6 +1190,34 @@ struct ChatComposerStateTests { #expect(conversation.derivedTitle == "Root volume filling up") } + private func beginStreamingConversation() -> ( + conversation: ConversationModel, runID: UUID, assistant: AgentTranscriptMessage + ) { + let conversation = ConversationModel() + conversation.appendUserTurn("Explain") + let runID = UUID() + // Keep both event stamps before the stop that follows in each test, + // matching the runtime's monotonic event order. + let startedAt = Date().addingTimeInterval(-2) + let assistant = AgentTranscriptMessage( + role: .assistant, createdAt: startedAt.addingTimeInterval(1) + ) + conversation.handle(.runStarted(AgentRunSummary( + id: runID, state: .running, startedAt: startedAt + ))) + conversation.handle(.runState(.running)) + conversation.handle(.messageStarted(assistant)) + return (conversation, runID, assistant) + } + + private func timelineKind(_ item: TimelineItem) -> String { + switch item { + case .message(let message): return message.role == .user ? "user" : "assistant" + case .work: return "work" + default: return "other" + } + } + @Test func derivedTitleFallsBackToTheOpeningLineUntilASummaryArrives() { let conversation = ConversationModel()