Skip to content

Commit a7c33be

Browse files
mcintyre94claude
andcommitted
Fix reconnect placing response before latest user message
When reconnecting to a service, runReconnectLoop was using messages.last(where: .assistant) which could find an assistant message from a *previous* exchange and write the new response into it — placing it before the most recent user message. Fix: only reuse an existing assistant message if it's already at the tail of the messages array. Since each user message gets a fresh service, if the last message is a user message a new assistant message must always be created after it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a8cadea commit a7c33be

File tree

2 files changed

+52
-1
lines changed

2 files changed

+52
-1
lines changed

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1063,7 +1063,11 @@ final class ChatViewModel {
10631063
if let existing = currentAssistantMessage {
10641064
assistantMessage = existing
10651065
if !hasPriorEvents { assistantMessage.content = [] }
1066-
} else if let last = messages.last(where: { $0.role == .assistant }) {
1066+
} else if let last = messages.last, last.role == .assistant {
1067+
// Only reuse an assistant message if it's already at the tail — meaning
1068+
// the last exchange ended with a partial response that needs replaying.
1069+
// If the last message is a user message, fall through to create a new
1070+
// assistant message after it (each service is scoped to one user message).
10671071
assistantMessage = last
10681072
if !hasPriorEvents { assistantMessage.content = [] }
10691073
currentAssistantMessage = last

WispTests/ChatViewModelTests.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1117,6 +1117,53 @@ struct ChatViewModelTests {
11171117
}
11181118
}
11191119

1120+
// MARK: - runReconnectLoop: assistant message placement
1121+
1122+
@Test func runReconnectLoop_appendsAssistantAfterLatestUserMessage() async throws {
1123+
// Regression: messages.last(where: .assistant) would find a *previous* exchange's
1124+
// assistant message and write the new response into it, placing it before the latest
1125+
// user message. The fix checks messages.last only, so a new assistant message is
1126+
// always created after the most recent user message.
1127+
let ctx = try makeModelContext()
1128+
let (vm, _) = makeChatViewModel(modelContext: ctx)
1129+
1130+
// Simulate a completed prior exchange already in messages
1131+
let prevUser = ChatMessage(role: .user, content: [.text("first question")])
1132+
let prevAssistant = ChatMessage(role: .assistant, content: [.text("first answer")])
1133+
let newUser = ChatMessage(role: .user, content: [.text("second question")])
1134+
vm.messages = [prevUser, prevAssistant, newUser]
1135+
1136+
let textLine = #"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"second answer"}]}}"# + "\n"
1137+
let resultLine = #"{"type":"result","session_id":"s1","subtype":"success"}"# + "\n"
1138+
let stream = AsyncThrowingStream<ServiceLogEvent, Error> { continuation in
1139+
continuation.yield(ServiceLogEvent(type: .stdout, data: textLine, exitCode: nil, timestamp: nil, logFiles: nil))
1140+
continuation.yield(ServiceLogEvent(type: .stdout, data: resultLine, exitCode: nil, timestamp: nil, logFiles: nil))
1141+
continuation.finish()
1142+
}
1143+
let mock = MockServiceLogsProvider(streams: [stream], statuses: ["stopped"])
1144+
1145+
await vm.runReconnectLoop(apiClient: mock, modelContext: ctx, serviceAlreadyStopped: true)
1146+
1147+
// Should have 4 messages: prevUser, prevAssistant, newUser, newAssistant
1148+
#expect(vm.messages.count == 4)
1149+
#expect(vm.messages[0].role == .user)
1150+
#expect(vm.messages[1].role == .assistant)
1151+
#expect(vm.messages[2].role == .user)
1152+
#expect(vm.messages[3].role == .assistant)
1153+
// New response lands in the new assistant message, not the previous one
1154+
if case .text(let text) = vm.messages[3].content.first {
1155+
#expect(text == "second answer")
1156+
} else {
1157+
Issue.record("Expected text in new assistant message")
1158+
}
1159+
// Prior assistant message content is untouched
1160+
if case .text(let text) = vm.messages[1].content.first {
1161+
#expect(text == "first answer")
1162+
} else {
1163+
Issue.record("Expected prior assistant message to be unchanged")
1164+
}
1165+
}
1166+
11201167
// MARK: - reconnecting → streaming status transition
11211168

11221169
@Test func runReconnectLoop_transitionsReconnectingToStreamingOnStdoutData() async throws {

0 commit comments

Comments
 (0)