diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift index 747016a5a13d..a54d8e5c58c0 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel+TransportEvents.swift @@ -192,15 +192,21 @@ extension OpenClawChatViewModel { } if chat.state == "final" || chat.state == "aborted" || chat.state == "error" { self.invalidateHistorySnapshots() - self.updateActiveSessionRunWithoutChatSnapshot(false) + if isOurRun || self.pendingRuns.isEmpty { + self.updateActiveSessionRunWithoutChatSnapshot(false) + } } self.invalidateRunSnapshots() if !isOurRun { // Keep multiple clients in sync: if another client finishes a run for our session, refresh history. switch chat.state { case "final", "aborted", "error": - self.updateStreamingAssistantText(nil) - self.pendingToolCallsById = [:] + // An older external turn must not erase the stream or tools + // owned by the run this client is still actively following. + if self.pendingRuns.isEmpty { + self.updateStreamingAssistantText(nil) + self.pendingToolCallsById = [:] + } if let runId = chat.runId { self.clearPlan(for: runId) } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index a54aa180aec1..4a8649a4d6e8 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -4055,6 +4055,61 @@ struct ChatViewModelTests { ]) } + @Test(arguments: ["final", "aborted", "error"]) + func `terminal event for another run preserves active streaming and tools`(state: String) async throws { + let activeRunId = "active-run" + let initialHistory = historyPayload() + let activeHistory = historyPayload( + inFlightRun: OpenClawChatInFlightRun( + runId: activeRunId, + text: "Still working", + plan: OpenClawChatPlanSnapshot( + steps: [OpenClawChatPlanStep(step: "Keep working", status: .inProgress)]))) + let (transport, vm) = await makeViewModel( + historyResponses: [initialHistory, activeHistory, activeHistory], + sendMessageHook: { _ in + OpenClawChatSendResponse(runId: activeRunId, status: "pending") + }) + try await loadAndWaitBootstrap(vm: vm, sessionId: "sess-main") + + await sendUserMessage(vm, text: "keep active stream") + try await waitUntil("remote run is adopted") { + await MainActor.run { vm.pendingRunCount == 1 && !vm.isSending } + } + emitAssistantText(transport: transport, runId: activeRunId, text: "Still working") + emitToolStart(transport: transport, runId: activeRunId) + emitPlan( + transport: transport, + runId: activeRunId, + steps: [planStep("Keep working", status: "in_progress")]) + try await waitUntil("active run owns streaming, tools, and plan") { + await MainActor.run { + vm.streamingAssistantText == "Still working" && + vm.pendingToolCalls.count == 1 && + vm.planSteps.first?.step == "Keep working" + } + } + + transport.emit( + .chat( + OpenClawChatEventPayload( + runId: "older-run", + sessionKey: "main", + state: state, + message: nil, + errorMessage: state == "error" ? "Other run failed" : nil))) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(await MainActor.run { vm.pendingRunCount } == 1) + #expect(await MainActor.run { vm.streamingAssistantText } == "Still working") + #expect(await MainActor.run { vm.pendingToolCalls.count } == 1) + #expect(await MainActor.run { vm.planSteps } == [ + OpenClawChatPlanStep(step: "Keep working", status: .inProgress), + ]) + #expect(await MainActor.run { vm.errorText } == nil) + } + @Test func `pending run blocks second main send`() async throws { let sessionId = "sess-main" let history = historyPayload(sessionId: sessionId, messages: [])