From bd2c0ec83896fb6c2614af5ddd931aaf19dcd988 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 1 May 2026 16:07:32 +0000 Subject: [PATCH 1/3] Detect truncated chat history and reload from wisplog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the phone goes to sleep mid-stream and reconnects, SwiftData can end up persisting a partial message history that starts with an assistant message (e.g. the first user+assistant exchange is lost). This happens in an edge case of the reconnect flow where persistMessages fires before the full context is in memory. Extend the empty-messages check in reconnectIfNeeded to also trigger a wisplog reload when messages.first?.role == .assistant — chats must always start with a user message, so an orphaned leading assistant message is a reliable signal of truncated history. The wisplog on the sprite always has the full conversation. Adds a regression test covering this case. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Wisp/ViewModels/ChatViewModel.swift | 9 +++++---- WispTests/ChatViewModelTests.swift | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/Wisp/ViewModels/ChatViewModel.swift b/Wisp/ViewModels/ChatViewModel.swift index 0757650..d076484 100644 --- a/Wisp/ViewModels/ChatViewModel.swift +++ b/Wisp/ViewModels/ChatViewModel.swift @@ -918,10 +918,11 @@ final class ChatViewModel { func reconnectIfNeeded(apiClient: SpritesAPIClient, modelContext: ModelContext) { guard !isStreaming else { return } - if messages.isEmpty { - // No local messages — the wisp log file (or legacy JSONL) on the sprite - // contains the full conversation. Load it so the chat isn't blank. - // This handles the case where SwiftData was cleared or the app was reinstalled. + if messages.isEmpty || messages.first?.role == .assistant { + // No local messages, or messages appear truncated (first message should always + // be from the user — if it's an assistant message the persisted state is corrupt, + // likely because a reconnect after backgrounding wrote a partial history starting + // mid-conversation). Reload the full conversation from the wisp log file. if !isLoadingHistory { Task { await loadFromWispLog(apiClient: apiClient, modelContext: modelContext) } } diff --git a/WispTests/ChatViewModelTests.swift b/WispTests/ChatViewModelTests.swift index a067e98..06206d2 100644 --- a/WispTests/ChatViewModelTests.swift +++ b/WispTests/ChatViewModelTests.swift @@ -821,6 +821,32 @@ struct ChatViewModelTests { #expect(vm.streamTask != nil) } + @Test func reconnectIfNeeded_corruptedMessages_firstIsAssistant_triggersWispLogLoad() throws { + // Regression: after a reconnect following phone-sleep during streaming, + // SwiftData can end up with a partial history that starts with an assistant + // message (e.g. the first user+assistant exchange was lost). The wisplog + // on the sprite still has the full history. Verify that reconnectIfNeeded + // detects this corruption and does NOT try to reattach to a (nonexistent) + // exec session — it should take the wisplog-reload path instead. + let ctx = try makeModelContext() + let (vm, _) = makeChatViewModel(modelContext: ctx) + + // Simulate truncated SwiftData: first message is an assistant (wrong — should be user) + vm.messages = [ + ChatMessage(role: .assistant, content: [.text("Here's the deep dive...")]), + ChatMessage(role: .user, content: [.text("Follow-up question")]), + ] + vm.setExecSessionId("exec-abc") // would normally trigger reattach + + vm.reconnectIfNeeded(apiClient: SpritesAPIClient(), modelContext: ctx) + + // Should NOT have started a reattach task (the exec-session path). + // An async wisplog load is scheduled instead — but it's async so we verify + // the exec-reattach task was NOT created and messages were not synchronously changed. + #expect(vm.streamTask == nil) + #expect(vm.messages.count == 2) + } + // MARK: - UUID persistence @Test func persistMessages_savesUUIDsToChat() throws { From ce5bd4877d4ca0c1fd8984d1b6b55cd89b112b2a Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 1 May 2026 17:00:14 +0000 Subject: [PATCH 2/3] Use wisplog as authoritative source after each completed session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes for chat history reliability: 1. Reload from wisplog after every completed stream. Both executeClaudeCommand and reattachToExec now fire a background loadFromWispLog when a session finishes cleanly with no queued follow-up. This corrects any history truncation (including user-first truncation, not just the assistant-first case caught by the earlier reconnectIfNeeded guard) without requiring heuristics to detect which messages were lost. A guard in loadFromWispLog bails out if the user sends another message before the cat completes. 2. Reload on cold open of completed chats. The lastSessionComplete early-return in reconnectIfNeeded now distinguishes the undelivered-draft edge case (messages.last == .user → restoreUndeliveredDraft as before) from the normal case (messages.last == .assistant → background wisplog verify). This closes the gap where a cold open could show user-first truncated history indefinitely. 3. Check wisplog before the slow-response retry. When executeClaudeCommand times out with no data, it now reads the wisplog first. If Claude finished before the WebSocket connected (fast exec), the response is loaded from the log and the retry is skipped — avoiding the duplicate user prompt that was previously written to the wisplog. Also adds a regression test for the thinking-block + text-block pattern that caused the missed response in the fast-exec case. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Wisp/ViewModels/ChatViewModel.swift | 70 ++++++++++++++++++++++++++--- WispTests/ChatViewModelTests.swift | 20 +++++++++ 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/Wisp/ViewModels/ChatViewModel.swift b/Wisp/ViewModels/ChatViewModel.swift index d076484..0527d93 100644 --- a/Wisp/ViewModels/ChatViewModel.swift +++ b/Wisp/ViewModels/ChatViewModel.swift @@ -711,6 +711,10 @@ final class ChatViewModel { let (parsed, parsedSessionId) = Self.parseWispLog(output) guard !parsed.isEmpty else { return } + // Don't overwrite messages if a new streaming session started while the + // wisplog was being fetched (user sent another message in the window). + guard !isStreaming else { return } + messages = parsed if let parsedSessionId { sessionId = parsedSessionId } rebuildToolUseIndex() @@ -929,13 +933,20 @@ final class ChatViewModel { return } - // If the last session completed cleanly, content is already loaded from - // persistence — no need to hit the network at all. + // If the last session completed cleanly, no exec reconnect is needed. if let chat = fetchChat(modelContext: modelContext), chat.lastSessionComplete { - // Edge case: app killed between persistMessages and saveSession(isComplete:false). - // The session was previously complete but a new user message was appended and - // persisted before the exec session was created. Restore it as a draft. - restoreUndeliveredDraft(modelContext: modelContext) + if messages.last?.role == .user { + // Edge case: app killed between persistMessages and saveSession(isComplete:false). + // A new user message was appended and persisted before the exec session was + // created. Restore it as a draft — don't also reload the wisplog here since + // the wisplog would include this undelivered prompt and put it back in messages. + restoreUndeliveredDraft(modelContext: modelContext) + } else if !isLoadingHistory { + // Session ended cleanly with an assistant response. Verify against the + // wisplog in the background to catch any history truncation from earlier + // reconnects or backgrounding that our streaming reload may not have fixed. + Task { await loadFromWispLog(apiClient: apiClient, modelContext: modelContext) } + } return } @@ -1130,7 +1141,40 @@ final class ChatViewModel { return } - // If timed out with no data, clear Claude lock files and retry once + // If timed out with no data, first check whether Claude actually finished and + // the output was written to the wisplog before our WebSocket could connect. + // This happens when a short response completes in under ~1s (faster than the + // WebSocket handshake), causing processExecStream to see no stdout at all. + // Loading from the wisplog avoids a redundant retry and a duplicate user prompt. + if case .timedOut = streamResult, !retriedAfterTimeout, sessionId != nil { + let logPath = Self.wispLogPath(for: chatId) + let (wispOutput, wispSuccess) = await apiClient.runExec( + spriteName: spriteName, + command: "cat \(shellEscape(logPath)) 2>/dev/null", + timeout: 15 + ) + if wispSuccess, !wispOutput.isEmpty { + let (parsed, parsedSessionId) = Self.parseWispLog(wispOutput) + // The wisplog has a completed response if it has at least as many messages + // as the current array and ends with a non-empty assistant message. + if parsed.count >= messages.count, + parsed.last?.role == .assistant, + !(parsed.last?.content.isEmpty ?? true) { + logger.info("[Chat] Exec timed out but wisplog has completed response — loading it") + messages = parsed + if let parsedSessionId { sessionId = parsedSessionId } + rebuildToolUseIndex() + execSessionId = nil + saveSession(modelContext: modelContext, isComplete: true) + persistMessages(modelContext: modelContext) + if !Task.isCancelled { status = .idle } + return + } + } + } + + // If timed out with no data and wisplog had nothing useful, clear Claude lock + // files and retry once. if case .timedOut = streamResult, !retriedAfterTimeout { logger.info("Timeout — clearing Claude lock files and retrying") retriedAfterTimeout = true @@ -1175,6 +1219,13 @@ final class ChatViewModel { messages.append(userMessage) persistMessages(modelContext: modelContext) await executeClaudeCommand(prompt: prompt, apiClient: apiClient, modelContext: modelContext) + } else { + // Reload from the wisplog to correct any history truncation from reconnects + // or backgrounding. The guard in loadFromWispLog aborts if the user sends + // another message before the cat completes. + let capturedApiClient = apiClient + let capturedModelContext = modelContext + Task { await loadFromWispLog(apiClient: capturedApiClient, modelContext: capturedModelContext) } } } @@ -1410,6 +1461,11 @@ final class ChatViewModel { messages.append(userMessage) persistMessages(modelContext: modelContext) await executeClaudeCommand(prompt: prompt, apiClient: apiClient, modelContext: modelContext) + } else if case .completed = streamResult, !Task.isCancelled { + // Same post-completion wisplog reload as executeClaudeCommand. + let capturedApiClient = apiClient + let capturedModelContext = modelContext + Task { await loadFromWispLog(apiClient: capturedApiClient, modelContext: capturedModelContext) } } } diff --git a/WispTests/ChatViewModelTests.swift b/WispTests/ChatViewModelTests.swift index 06206d2..6eaf991 100644 --- a/WispTests/ChatViewModelTests.swift +++ b/WispTests/ChatViewModelTests.swift @@ -1499,6 +1499,26 @@ struct ChatViewModelTests { #expect(messages[1].textContent == "I was saying...") } + @Test func parseWispLog_thinkingOnlyBlockFollowedByTextBlock() { + // Regression: extended thinking emits a thinking-only assistant event before the + // text event. Both events belong to the same turn and should produce a single + // assistant message containing only the text (thinking blocks are ignored). + let ndjson = """ + {"type":"wisp_user_prompt","text":"Do we have similar tests?","timestamp":""} + {"type":"system","session_id":"sess-1"} + {"type":"assistant","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Let me check..."}]}} + {"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Looking back — no, no similar tests exist."}]}} + {"type":"result","session_id":"sess-1","is_error":false} + """ + + let (messages, _) = ChatViewModel.parseWispLog(ndjson) + + #expect(messages.count == 2) + #expect(messages[0].role == .user) + #expect(messages[1].role == .assistant) + #expect(messages[1].textContent == "Looking back — no, no similar tests exist.") + } + // MARK: - convertJSONLToWisp @Test func convertJSONLToWisp_userPromptString() { From 0e2142d1434a1e67898351c773dcc8ab0d712123 Mon Sep 17 00:00:00 2001 From: Callum McIntyre Date: Fri, 1 May 2026 19:14:24 +0000 Subject: [PATCH 3/3] loadFromWispLog: mirror restoreFromSessionFile session state management Previously loadFromWispLog only called persistMessages, leaving lastSessionComplete=false and execSessionId set in SwiftData. This caused an unnecessary reattachToExec attempt on the next reconnectIfNeeded call, which would time out, call restoreFromSessionFile, and read the wisplog a second time before finally marking the session complete. Apply the same pattern as restoreFromSessionFile: if the loaded messages end with a user message, surface it as a draft (undelivered prompt edge case); otherwise clear execSessionId and saveSession(isComplete:true) so the next reconnectIfNeeded takes the fast completed-session path. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- Wisp/ViewModels/ChatViewModel.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Wisp/ViewModels/ChatViewModel.swift b/Wisp/ViewModels/ChatViewModel.swift index 0527d93..14014f9 100644 --- a/Wisp/ViewModels/ChatViewModel.swift +++ b/Wisp/ViewModels/ChatViewModel.swift @@ -718,7 +718,17 @@ final class ChatViewModel { messages = parsed if let parsedSessionId { sessionId = parsedSessionId } rebuildToolUseIndex() - persistMessages(modelContext: modelContext) + + // Mirror restoreFromSessionFile: surface a trailing user message as a draft, + // or mark the session complete and clear the exec ID so reconnectIfNeeded + // doesn't attempt a pointless reattach on the next open. + if let last = messages.last, last.role == .user { + restoreUndeliveredDraft(modelContext: modelContext) + } else { + execSessionId = nil + saveSession(modelContext: modelContext, isComplete: true) + persistMessages(modelContext: modelContext) + } } func persistMessages(modelContext: ModelContext) {