diff --git a/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap b/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap index 3b6521a17f4..3c7eeaeff3d 100644 --- a/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +++ b/assistant/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap @@ -529,6 +529,7 @@ exports[`IPC message snapshots ServerMessage types history_response serializes t "timestamp": 1700000001, }, ], + "sessionId": "sess-history-001", "type": "history_response", } `; diff --git a/assistant/src/__tests__/ipc-snapshot.test.ts b/assistant/src/__tests__/ipc-snapshot.test.ts index ef310576079..8db1a96c364 100644 --- a/assistant/src/__tests__/ipc-snapshot.test.ts +++ b/assistant/src/__tests__/ipc-snapshot.test.ts @@ -336,6 +336,7 @@ const serverMessages: Record = { }, history_response: { type: 'history_response', + sessionId: 'sess-history-001', messages: [ { role: 'user', text: 'Hello', timestamp: 1700000000 }, { role: 'assistant', text: 'Hi there!', timestamp: 1700000001 }, diff --git a/assistant/src/daemon/handlers.ts b/assistant/src/daemon/handlers.ts index 8faad47cf25..0d7303ffe8f 100644 --- a/assistant/src/daemon/handlers.ts +++ b/assistant/src/daemon/handlers.ts @@ -673,7 +673,7 @@ function handleHistoryRequest( timestamp: m.timestamp, ...(m.toolCalls.length > 0 ? { toolCalls: m.toolCalls } : {}), })); - ctx.send(socket, { type: 'history_response', messages: historyMessages }); + ctx.send(socket, { type: 'history_response', sessionId: msg.sessionId, messages: historyMessages }); } export function mergeToolResults(messages: ParsedHistoryMessage[]): ParsedHistoryMessage[] { diff --git a/assistant/src/daemon/ipc-protocol.ts b/assistant/src/daemon/ipc-protocol.ts index 614d94ad7cd..34c2d61121d 100644 --- a/assistant/src/daemon/ipc-protocol.ts +++ b/assistant/src/daemon/ipc-protocol.ts @@ -533,6 +533,7 @@ export interface HistoryResponseToolCall { export interface HistoryResponse { type: 'history_response'; + sessionId: string; messages: Array<{ role: string; text: string; diff --git a/clients/macos/vellum-assistant/Features/Chat/ChatViewModel.swift b/clients/macos/vellum-assistant/Features/Chat/ChatViewModel.swift index a27ae09c824..ec536137f1f 100644 --- a/clients/macos/vellum-assistant/Features/Chat/ChatViewModel.swift +++ b/clients/macos/vellum-assistant/Features/Chat/ChatViewModel.swift @@ -968,7 +968,15 @@ final class ChatViewModel: ObservableObject { } /// Populate messages from history data returned by the daemon. + /// Only replaces messages if the user hasn't sent any new messages yet, + /// preventing a late history_response from overwriting live conversation. func populateFromHistory(_ historyMessages: [HistoryResponseMessage.HistoryMessageItem]) { + let hasUserSentMessages = messages.contains { $0.role == .user } + if hasUserSentMessages { + isHistoryLoaded = true + return + } + var chatMessages: [ChatMessage] = [] for item in historyMessages { let role: ChatRole = item.role == "assistant" ? .assistant : .user diff --git a/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift b/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift index 1950517aa52..0857ef190a2 100644 --- a/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift +++ b/clients/macos/vellum-assistant/Features/MainWindow/ThreadManager.swift @@ -19,8 +19,9 @@ final class ThreadManager: ObservableObject { private var viewModelCancellable: AnyCancellable? private var connectionCancellable: AnyCancellable? - /// Tracks which thread is currently awaiting a history_response from the daemon. - private var pendingHistoryThreadId: UUID? + /// Maps session IDs to thread IDs for in-flight history_request messages, + /// so rapid tab switches don't cause history from one thread to land in another. + private var pendingHistoryBySessionId: [String: UUID] = [:] /// Called when an inline confirmation response should dismiss the floating panel. var confirmationDismissHandler: ((String) -> Void)? @@ -175,23 +176,22 @@ final class ThreadManager: ObservableObject { private func loadHistoryForActiveThreadIfNeeded() { guard let activeThreadId else { return } guard let thread = threads.first(where: { $0.id == activeThreadId }) else { return } - guard thread.sessionId != nil else { return } + guard let sessionId = thread.sessionId else { return } guard let viewModel = chatViewModels[activeThreadId] else { return } guard !viewModel.isHistoryLoaded else { return } - pendingHistoryThreadId = activeThreadId + pendingHistoryBySessionId[sessionId] = activeThreadId do { - try daemonClient.sendHistoryRequest(sessionId: thread.sessionId!) + try daemonClient.sendHistoryRequest(sessionId: sessionId) } catch { log.error("Failed to send history_request: \(error.localizedDescription)") - pendingHistoryThreadId = nil + pendingHistoryBySessionId.removeValue(forKey: sessionId) } } private func handleHistoryResponse(_ response: HistoryResponseMessage) { - guard let threadId = pendingHistoryThreadId else { return } - pendingHistoryThreadId = nil + guard let threadId = pendingHistoryBySessionId.removeValue(forKey: response.sessionId) else { return } guard let viewModel = chatViewModels[threadId] else { return } viewModel.populateFromHistory(response.messages) diff --git a/clients/macos/vellum-assistant/IPC/IPCMessages.swift b/clients/macos/vellum-assistant/IPC/IPCMessages.swift index 5371160ff6f..4169618a353 100644 --- a/clients/macos/vellum-assistant/IPC/IPCMessages.swift +++ b/clients/macos/vellum-assistant/IPC/IPCMessages.swift @@ -691,6 +691,7 @@ struct SessionListResponseMessage: Decodable, Sendable { /// Response containing message history for a session. /// Wire type: `"history_response"` struct HistoryResponseMessage: Decodable, Sendable { + let sessionId: String struct HistoryToolCallItem: Decodable, Sendable { let name: String let input: [String: AnyCodable]