From 164c52b2ed521b365068e2b0ecf8fe4bba190948 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:11:30 +0900 Subject: [PATCH 1/2] fix(review-log): validate command output display mutations --- ...wMonitorCommandOutputDisplayDocument.swift | 234 +++++++++++++----- .../Detail/ReviewMonitorLogScrollView.swift | 43 +++- Tests/ReviewUITests/ReviewUITests.swift | 220 ++++++++++++++++ 3 files changed, 426 insertions(+), 71 deletions(-) diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift index 12ff4054..ac410d6a 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorCommandOutputDisplayDocument.swift @@ -794,69 +794,148 @@ enum ReviewMonitorCommandOutputDisplayDocument { ) -> ReviewMonitorLog.Change { switch change { case .append(let append): - guard - let mappedRange = mappedChangeRange( - append.range, - blockID: append.blockID, - sourceBlocks: sourceBlocks, - displayBlocks: displayBlocks + if let panelMutation = mappedCommandPanelMutation( + sourceRange: append.range, + blockID: append.blockID, + sourceBlocks: sourceBlocks, + displayBlocks: displayBlocks, + previousDisplay: previousDisplay, + displayText: displayText + ), + let validatedMutation = validatedChange( + panelMutation, + previousDisplay: previousDisplay, + displayText: displayText ) - else { - if let panelMutation = mappedCommandPanelMutation( - sourceRange: append.range, - blockID: append.blockID, - sourceBlocks: sourceBlocks, - displayBlocks: displayBlocks, + { + return validatedMutation + } + if let displayAppend = mappedDisplayAppend( + previousDisplay: previousDisplay, + displayText: displayText, + displayBlocks: displayBlocks + ), + let validatedMutation = validatedChange( + .append(displayAppend), previousDisplay: previousDisplay, displayText: displayText - ) { - return panelMutation - } - return .reload + ) + { + return validatedMutation } - return .append( - .init( - kind: append.kind, - blockID: append.blockID, - range: mappedRange, - text: append.text, - textUTF16Length: append.textUTF16Length, - animationSpans: append.animationSpans - )) + return .reload case .replace(let replacement): - guard - let mappedRange = mappedChangeRange( - replacement.range, - blockID: replacement.blockID, - sourceBlocks: sourceBlocks, - displayBlocks: displayBlocks + if let mappedReplacement = mappedBlockReplacement( + replacement, + sourceBlocks: sourceBlocks, + displayBlocks: displayBlocks, + previousDisplay: previousDisplay, + displayText: displayText + ), + let validatedMutation = validatedChange( + .replace(mappedReplacement), + previousDisplay: previousDisplay, + displayText: displayText ) - else { - if let panelMutation = mappedCommandPanelMutation( + { + return validatedMutation + } + if let panelMutation = mappedCommandPanelMutation( sourceRange: replacement.range, blockID: replacement.blockID, sourceBlocks: sourceBlocks, displayBlocks: displayBlocks, previousDisplay: previousDisplay, displayText: displayText - ) { - return panelMutation - } - return .reload + ), + let validatedMutation = validatedChange( + panelMutation, + previousDisplay: previousDisplay, + displayText: displayText + ) + { + return validatedMutation } - return .replace( - .init( - kind: replacement.kind, - blockID: replacement.blockID, - range: mappedRange, - text: replacement.text, - textUTF16Length: replacement.textUTF16Length - )) + return .reload case .reload: return .reload } } + private static func mappedDisplayAppend( + previousDisplay: ReviewMonitorLog.Document?, + displayText: String, + displayBlocks: [ReviewMonitorLog.Block] + ) -> ReviewMonitorLog.Append? { + guard let previousDisplay else { + return nil + } + + let displayString = displayText as NSString + let previousLength = previousDisplay.textUTF16Length + guard previousLength < displayString.length, + displayString.substring(with: NSRange(location: 0, length: previousLength)) == previousDisplay.text + else { + return nil + } + + let suffixRange = NSRange(location: previousLength, length: displayString.length - previousLength) + let suffix = displayString.substring(with: suffixRange) + let block = displayBlocks.first { + NSIntersectionRange($0.range, suffixRange).length > 0 + } + return .init( + kind: block?.kind ?? .event, + blockID: block?.id ?? ReviewMonitorLog.BlockID("commandOutputDisplayAppend"), + range: suffixRange, + text: suffix, + textUTF16Length: suffixRange.length, + animationSpans: displayBlocks.flatMap { block in + let intersection = NSIntersectionRange(block.range, suffixRange) + guard intersection.length > 0 else { + return [] as [ReviewMonitorLog.AnimationSpan] + } + return ReviewMonitorLog.Append.animationSpans( + forKind: block.kind, + absoluteRange: intersection, + appendBaseLocation: previousLength + ) + } + ) + } + + private static func mappedBlockReplacement( + _ replacement: ReviewMonitorLog.Replacement, + sourceBlocks: [ReviewMonitorLog.Block], + displayBlocks: [ReviewMonitorLog.Block], + previousDisplay: ReviewMonitorLog.Document?, + displayText: String + ) -> ReviewMonitorLog.Replacement? { + guard let previousDisplay, + let sourceBlock = sourceBlocks.first(where: { $0.id == replacement.blockID }), + sourceBlock.kind != .command, + sourceBlock.kind != .commandOutput, + let displayBlock = displayBlocks.first(where: { $0.id == replacement.blockID }), + let previousDisplayBlock = previousDisplay.blocks.first(where: { $0.id == replacement.blockID }) + else { + return nil + } + + guard rangeIsValid(displayBlock.range, in: displayText), + rangeIsValid(previousDisplayBlock.range, in: previousDisplay.text) + else { + return nil + } + let replacementText = (displayText as NSString).substring(with: displayBlock.range) + return .init( + kind: displayBlock.kind, + blockID: displayBlock.id, + range: previousDisplayBlock.range, + text: replacementText, + textUTF16Length: (replacementText as NSString).length + ) + } + private static func mappedCommandPanelMutation( sourceRange: NSRange, blockID: ReviewMonitorLog.BlockID, @@ -907,6 +986,37 @@ enum ReviewMonitorCommandOutputDisplayDocument { return .append(append) } + private static func validatedChange( + _ change: ReviewMonitorLog.Change, + previousDisplay: ReviewMonitorLog.Document?, + displayText: String + ) -> ReviewMonitorLog.Change? { + guard let previousDisplay else { + return nil + } + switch change { + case .append(let append): + guard append.textUTF16Length == (append.text as NSString).length, + append.range.location == previousDisplay.textUTF16Length, + append.range.length == append.textUTF16Length, + previousDisplay.text + append.text == displayText + else { + return nil + } + return change + case .replace(let replacement): + guard replacement.textUTF16Length == (replacement.text as NSString).length, + rangeIsValid(replacement.range, in: previousDisplay.text), + replacingText(in: previousDisplay.text, range: replacement.range, with: replacement.text) == displayText + else { + return nil + } + return change + case .reload: + return change + } + } + private static func mappedNewCommandPanelAppend( displayBlock: ReviewMonitorLog.Block, displayText: String, @@ -958,25 +1068,25 @@ enum ReviewMonitorCommandOutputDisplayDocument { } } - private static func mappedChangeRange( - _ range: NSRange, - blockID: ReviewMonitorLog.BlockID, - sourceBlocks: [ReviewMonitorLog.Block], - displayBlocks: [ReviewMonitorLog.Block] - ) -> NSRange? { - guard let sourceBlock = sourceBlocks.first(where: { $0.id == blockID }), - sourceBlock.kind != .command, - sourceBlock.kind != .commandOutput, - let displayBlock = displayBlocks.first(where: { $0.id == blockID }), - NSMaxRange(range) <= NSMaxRange(sourceBlock.range) + private static func rangeIsValid(_ range: NSRange, in text: String) -> Bool { + let length = (text as NSString).length + return range.location >= 0 && range.length >= 0 && NSMaxRange(range) <= length + } + + private static func replacingText(in text: String, range: NSRange, with replacement: String) -> String { + let string = text as NSString + guard range.location >= 0, + range.length >= 0, + NSMaxRange(range) <= string.length else { - return nil + return text } - let mappedRange = map(range, from: sourceBlock.range, to: displayBlock.range) - guard NSMaxRange(mappedRange) <= NSMaxRange(displayBlock.range) else { - return nil - } - return mappedRange + let prefix = string.substring(with: NSRange(location: 0, length: range.location)) + let suffixLocation = NSMaxRange(range) + let suffix = string.substring( + with: NSRange(location: suffixLocation, length: string.length - suffixLocation) + ) + return prefix + replacement + suffix } } diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift index 9d4014a0..36841468 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift @@ -344,21 +344,34 @@ final class ReviewMonitorLogScrollView: NSScrollView { guard append.text.isEmpty == false else { return false } - let appendEnd = displayedUTF16Length + append.textUTF16Length - return append.textUTF16Length > 0 && - append.range.location >= displayedUTF16Length && - NSMaxRange(append.range) <= appendEnd && - document.textUTF16Length == displayedUTF16Length + append.textUTF16Length + let expectedRange = NSRange(location: displayedUTF16Length, length: append.textUTF16Length) + guard append.textUTF16Length == (append.text as NSString).length, + append.range == expectedRange, + document.textUTF16Length == displayedUTF16Length + append.textUTF16Length, + displayedText + append.text == document.text + else { + assertionFailure("Log append change is not synchronized with the display document.") + return false + } + return true } private func canApplyReplacement( _ replacement: ReviewMonitorLog.Replacement, to document: ReviewMonitorLog.Document ) -> Bool { - replacement.textUTF16Length >= 0 && - replacement.range.location >= 0 && - NSMaxRange(replacement.range) <= displayedUTF16Length && - document.textUTF16Length == displayedUTF16Length - replacement.range.length + replacement.textUTF16Length + guard replacement.textUTF16Length == (replacement.text as NSString).length, + replacement.range.location >= 0, + replacement.range.length >= 0, + NSMaxRange(replacement.range) <= displayedUTF16Length, + document.textUTF16Length == displayedUTF16Length - replacement.range.length + replacement.textUTF16Length, + let replacementResult = replacingDisplayedText(in: replacement.range, with: replacement.text), + replacementResult == document.text + else { + assertionFailure("Log replacement change is not synchronized with the display document.") + return false + } + return true } private func canApplyFallbackAppend(_ suffix: String, to document: ReviewMonitorLog.Document) -> Bool { @@ -883,6 +896,18 @@ final class ReviewMonitorLogScrollView: NSScrollView { displayedText = mutable as String } + private func replacingDisplayedText(in range: NSRange, with text: String) -> String? { + guard range.location >= 0, + range.length >= 0, + NSMaxRange(range) <= displayedUTF16Length + else { + return nil + } + let mutable = NSMutableString(string: displayedText) + mutable.replaceCharacters(in: range, with: text) + return mutable as String + } + private func invalidateDocumentLayout() { if syncDocumentFrameToTextLayout() { logDocumentView.invalidateIntrinsicContentSize() diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index 1f02d39e..a14a2346 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2699,6 +2699,201 @@ struct ReviewUITests { #expect(transport.logReloadCountForTesting == reloadCount) } + @Test func commandOutputDisplayAppendBeforeMarkdownHeadingIsSelfConsistent() throws { + let startedAt = Date(timeIntervalSince1970: 200) + var projection = ReviewMonitorLogDocumentProjection() + let previousSource = projection.render(projectedBlocks: [ + .init( + id: .init("reasoning_1"), + kind: .rawReasoning, + groupID: "reasoning_1", + text: "Need to inspect files." + ) + ]) + let previousDisplay = ReviewMonitorCommandOutputDisplayDocument.make( + from: previousSource, + currentDate: startedAt + ) + + let currentSource = projection.render(projectedBlocks: [ + .init( + id: .init("reasoning_1"), + kind: .rawReasoning, + groupID: "reasoning_1", + text: "Need to inspect files." + ), + .init( + id: .init("command_1"), + kind: .command, + groupID: "cmd_1", + text: "$ git diff", + metadata: .init( + sourceType: "commandExecution", + status: "running", + itemID: "cmd_1", + command: "git diff", + startedAt: startedAt, + commandStatus: "running" + ) + ), + .init( + id: .init("reasoning_2"), + kind: .rawReasoning, + groupID: "reasoning_2", + text: "**Reviewing JSONRPC requests**\n\nI'm checking the response shape." + ), + ]) + let display = ReviewMonitorCommandOutputDisplayDocument.make( + from: currentSource, + previousDisplay: previousDisplay, + currentDate: Date(timeIntervalSince1970: 211) + ) + + guard case .append(let append) = display.lastChange else { + Issue.record("Expected command display update to remain an append.") + return + } + #expect(applyingDisplayChange(display.lastChange, to: previousDisplay.text) == display.text) + #expect(append.text == (display.text as NSString).substring(from: previousDisplay.textUTF16Length)) + #expect(append.text.contains("\u{fffc}\n\nReviewing JSONRPC requests")) + let visibleText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: display.text) + #expect(visibleText.contains("Running git diff\n\nReviewing JSONRPC requests")) + #expect(visibleText.contains("git diffReviewing JSONRPC requests") == false) + } + + @Test func commandOutputDisplayReplacementAfterDurationUpdateIsSelfConsistent() throws { + let startedAt = Date(timeIntervalSince1970: 200) + var projection = ReviewMonitorLogDocumentProjection() + let previousSource = projection.render(projectedBlocks: [ + .init( + id: .init("command_1"), + kind: .command, + groupID: "cmd_1", + text: "$ git diff", + metadata: .init( + sourceType: "commandExecution", + status: "running", + itemID: "cmd_1", + command: "git diff", + startedAt: startedAt, + commandStatus: "running" + ) + ), + .init( + id: .init("reasoning_1"), + kind: .rawReasoning, + groupID: "reasoning_1", + text: "**Reviewing JSONRPC requests**\n\nI'm checking the response shape." + ), + ]) + let previousDisplay = ReviewMonitorCommandOutputDisplayDocument.make( + from: previousSource, + currentDate: startedAt + ) + + let currentSource = projection.render(projectedBlocks: [ + .init( + id: .init("command_1"), + kind: .command, + groupID: "cmd_1", + text: "$ git diff", + metadata: .init( + sourceType: "commandExecution", + status: "completed", + itemID: "cmd_1", + command: "git diff", + startedAt: startedAt, + completedAt: Date(timeIntervalSince1970: 211), + durationMs: 11_000, + commandStatus: "completed" + ) + ), + .init( + id: .init("reasoning_1"), + kind: .rawReasoning, + groupID: "reasoning_1", + text: "**Reviewing JSONRPC requests**\n\nI'm checking the response shape." + ), + ]) + let display = ReviewMonitorCommandOutputDisplayDocument.make( + from: currentSource, + previousDisplay: previousDisplay, + currentDate: Date(timeIntervalSince1970: 211) + ) + + guard case .replace = display.lastChange else { + Issue.record("Expected command duration update to replace the display command panel.") + return + } + #expect(applyingDisplayChange(display.lastChange, to: previousDisplay.text) == display.text) + let visibleText = ReviewMonitorCommandOutputDisplayDocument.userVisibleText(from: display.text) + #expect(visibleText.contains("Ran git diff for 11s\n\nReviewing JSONRPC requests")) + #expect(visibleText.contains("11sReviewing JSONRPC requests") == false) + } + + @Test func coalescedRunningCommandBeforeMarkdownHeadingKeepsParagraphBoundary() async throws { + let startedAt = Date(timeIntervalSince1970: 200) + let chat = makeReviewChatFixtureForTesting( + id: "chat-running-command-markdown-heading", + cwd: "/tmp/workspace-alpha", + title: "Uncommitted changes", + status: .running, + startedAt: startedAt, + chatEntries: [ + .init( + kind: .rawReasoning, + groupID: "reasoning_1", + text: "Need to inspect files." + ) + ] + ) + let store = CodexReviewStore.makePreviewStore() + store.loadForTesting(serverState: .running, fixtures: [chat]) + let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 860, height: 520)) + let viewController = harness.viewController + let window = harness.window + defer { window.close() } + let transport = viewController.transportViewControllerForTesting + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) + _ = try await awaitChatRenderForTesting( + chat, + in: transport, + allowIncrementalUpdate: false + ) + let appendCount = transport.logAppendCountForTesting + let reloadCount = transport.logReloadCountForTesting + + await appendChatLogEntryForTesting( + .init( + kind: .command, + groupID: "cmd_1", + text: "$ git diff", + metadata: .init(command: "git diff", commandStatus: "running") + ), + to: chat.chatID, + turnID: chat.turnID + ) + await appendChatLogEntryForTesting( + .init( + kind: .rawReasoning, + groupID: "reasoning_2", + text: "**Reviewing JSONRPC requests**\n\nI'm checking the response shape." + ), + to: chat.chatID, + turnID: chat.turnID + ) + + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) { + $0.log.contains("Ran git diff\n\nReviewing JSONRPC requests") + } + #expect(snapshot.log.contains("Ran git diff\n\nReviewing JSONRPC requests")) + #expect(snapshot.log.contains("git diffReviewing JSONRPC requests") == false) + #expect(transport.displayedLogForTesting.contains("Ran git diff\n\nReviewing JSONRPC requests")) + #expect(transport.displayedLogForTesting.contains("git diffReviewing JSONRPC requests") == false) + #expect(transport.logAppendCountForTesting == appendCount + 1) + #expect(transport.logReloadCountForTesting == reloadCount) + } + @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws { let chat = makeReviewChatFixtureForTesting( id: "chat-markdown-append-fallback", @@ -4430,6 +4625,31 @@ struct ReviewUITests { return try await body() } + private func applyingDisplayChange(_ change: ReviewMonitorLog.Change, to text: String) -> String? { + switch change { + case .append(let append): + guard append.range.location == (text as NSString).length, + append.range.length == append.textUTF16Length + else { + return nil + } + return text + append.text + case .replace(let replacement): + let length = (text as NSString).length + guard replacement.range.location >= 0, + replacement.range.length >= 0, + NSMaxRange(replacement.range) <= length + else { + return nil + } + let mutable = NSMutableString(string: text) + mutable.replaceCharacters(in: replacement.range, with: replacement.text) + return mutable as String + case .reload: + return nil + } + } + @Test func logFindKeepsFirstAppendIntoEmptyVisibleLogLive() async throws { let chat = makeReviewChatFixtureForTesting( id: "chat-log-find-empty-append", From 3ca1da9f5f5d5a1164e2b4e8cb05c01e35601eb2 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:38:08 +0900 Subject: [PATCH 2/2] fix(review-log): invalidate full append suffix styling --- .../Detail/ReviewMonitorLogDocumentView.swift | 14 +++- .../Detail/ReviewMonitorLogScrollView.swift | 4 ++ .../ReviewMonitorCodexChatLogTarget.swift | 4 ++ ...ReviewMonitorTransportViewController.swift | 4 ++ Tests/ReviewUITests/ReviewUITests.swift | 67 +++++++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift index c6670826..4c550e2b 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogDocumentView.swift @@ -711,7 +711,7 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, } if block.kind.requiresMarkdownPresentationInvalidationOnAppend { - return block.range + return NSUnionRange(block.range, append.range) } return append.range } @@ -2726,6 +2726,18 @@ final class ReviewMonitorLogDocumentView: NSView, NSUserInterfaceValidations, return hitTest(NSPoint(x: rect.midX, y: rect.midY)) === self } + func fontPointSizeForFirstOccurrenceForTesting(_ text: String) -> CGFloat? { + layoutTextViewport(force: true) + let range = (textStorage.string as NSString).range(of: text) + guard range.location != NSNotFound, + range.location < textStorage.length, + let font = textStorage.attribute(.font, at: range.location, effectiveRange: nil) as? NSFont + else { + return nil + } + return font.pointSize + } + func toggleFirstCommandOutputPanelForTesting() { layoutTextViewport(force: true) guard let blockID = currentCommandOutputPanels.first?.blockID else { diff --git a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift index 36841468..c4312fc8 100644 --- a/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift +++ b/Sources/ReviewChatLogUI/Detail/ReviewMonitorLogScrollView.swift @@ -1561,6 +1561,10 @@ extension ReviewMonitorLogScrollView { logDocumentView.hitTestTargetsDocumentViewForFirstOccurrenceForTesting(text) } + func fontPointSizeForFirstLogOccurrenceForTesting(_ text: String) -> CGFloat? { + logDocumentView.fontPointSizeForFirstOccurrenceForTesting(text) + } + func toggleFirstCommandOutputPanelForTesting() { logDocumentView.toggleFirstCommandOutputPanelForTesting() } diff --git a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift index e8a33730..804bd71e 100644 --- a/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift +++ b/Sources/ReviewChatLogUI/ReviewMonitorCodexChatLogTarget.swift @@ -514,6 +514,10 @@ package final class ReviewMonitorCodexChatLogTarget { logScrollView.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text) } + func fontPointSizeForFirstLogOccurrenceForTesting(_ text: String) -> CGFloat? { + logScrollView.fontPointSizeForFirstLogOccurrenceForTesting(text) + } + func toggleFirstCommandOutputPanelForTesting() { logScrollView.toggleFirstCommandOutputPanelForTesting() } diff --git a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift index 1513d603..d789e100 100644 --- a/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift +++ b/Sources/ReviewUI/Detail/ReviewMonitorTransportViewController.swift @@ -388,6 +388,10 @@ final class ReviewMonitorTransportViewController: NSViewController { chatLogTarget.hitTestTargetsDocumentViewForFirstLogOccurrenceForTesting(text) } + func logFontPointSizeForFirstOccurrenceForTesting(_ text: String) -> CGFloat? { + chatLogTarget.fontPointSizeForFirstLogOccurrenceForTesting(text) + } + func toggleFirstLogCommandOutputPanelForTesting() { chatLogTarget.toggleFirstCommandOutputPanelForTesting() } diff --git a/Tests/ReviewUITests/ReviewUITests.swift b/Tests/ReviewUITests/ReviewUITests.swift index a14a2346..69094a0e 100644 --- a/Tests/ReviewUITests/ReviewUITests.swift +++ b/Tests/ReviewUITests/ReviewUITests.swift @@ -2894,6 +2894,73 @@ struct ReviewUITests { #expect(transport.logReloadCountForTesting == reloadCount) } + @Test func coalescedMarkdownAppendInvalidatesWholeDisplaySuffix() async throws { + let chat = makeReviewChatFixtureForTesting( + id: "chat-markdown-multi-block-append", + cwd: "/tmp/workspace-alpha", + title: "Uncommitted changes", + status: .running, + startedAt: Date(timeIntervalSince1970: 200), + chatEntries: [ + .init( + kind: .rawReasoning, + groupID: "reasoning_1", + text: "Need to inspect files." + ) + ] + ) + let store = CodexReviewStore.makePreviewStore() + store.loadForTesting(serverState: .running, fixtures: [chat]) + let harness = makeWindowHarness(store: store, contentSize: NSSize(width: 860, height: 520)) + let viewController = harness.viewController + let window = harness.window + defer { window.close() } + let transport = viewController.transportViewControllerForTesting + viewController.sidebarViewControllerForTesting.selectReviewChatForTesting(id: chat.chatID) + _ = try await awaitChatRenderForTesting( + chat, + in: transport, + allowIncrementalUpdate: false + ) + let appendCount = transport.logAppendCountForTesting + let reloadCount = transport.logReloadCountForTesting + + await appendChatLogEntryForTesting( + .init( + kind: .rawReasoning, + groupID: "reasoning_2", + text: "**Reviewing JSONRPC requests**\n\nI'm checking the response shape." + ), + to: chat.chatID, + turnID: chat.turnID + ) + await appendChatLogEntryForTesting( + .init( + kind: .rawReasoning, + groupID: "reasoning_3", + text: "# Follow-up heading\n\nMore details." + ), + to: chat.chatID, + turnID: chat.turnID + ) + + let snapshot = try await awaitChatRenderForTesting(chat, in: transport) { + $0.log.contains("Reviewing JSONRPC requests\n\nI'm checking the response shape.") + && $0.log.contains("Follow-up heading\n\nMore details.") + } + #expect(snapshot.log.contains("Reviewing JSONRPC requests\n\nI'm checking the response shape.")) + #expect(snapshot.log.contains("Follow-up heading\n\nMore details.")) + #expect(transport.logAppendCountForTesting == appendCount + 1) + #expect(transport.logReloadCountForTesting == reloadCount) + let headingFontSize = try #require( + transport.logFontPointSizeForFirstOccurrenceForTesting("Follow-up heading") + ) + let bodyFontSize = try #require( + transport.logFontPointSizeForFirstOccurrenceForTesting("More details.") + ) + #expect(headingFontSize > bodyFontSize) + } + @Test func selectedReviewChatMarkdownAppendReplacesTailBlockWithoutReload() async throws { let chat = makeReviewChatFixtureForTesting( id: "chat-markdown-append-fallback",