Fix stopped conversation streaming state

This commit is contained in:
Kaede Akino committed 2026-08-27 01:55:01 +08:00
1 parent e9d5149d9d
commit bbc587d518
2 files changed
+130 -3

No files matched your search

+21 -3
View File
@@ -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:
+109
View File
@@ -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()