Fix stopped conversation streaming state
This commit is contained in:
1 parent
e9d5149d9d
commit
bbc587d518
2 files changed
+130
-3
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user