Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@
905 Sources/CmuxSSHURLRequest.swift
899 Sources/Panels/MarkdownWebRenderer.swift
885 Sources/Panels/TerminalPanel.swift
948 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
885 cmuxTests/SidebarWorkspaceDropPlannerTests.swift
877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift
871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift
868 Sources/Panels/BrowserScreenshotSnapshotter.swift
859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift
Expand All @@ -129,8 +129,8 @@
752 cmuxUITests/CloseWorkspaceCmdDUITests.swift
739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift
738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift
724 Sources/Panels/BrowserPopupWindowController.swift
722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
725 Sources/Panels/BrowserPopupWindowController.swift
764 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift
718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift
716 Sources/TaskManagerSnapshot.swift
715 Sources/AppleScriptSupport.swift
Expand Down Expand Up @@ -214,6 +214,7 @@
526 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/Workstream/WorkstreamStore.swift
525 Packages/macOS/CmuxSettings/Sources/CmuxSettings/SocketControl/SocketControlSettings.swift
524 CLI/CMUXCLI+AutoNaming.swift
524 Sources/Mobile/AgentChat/AgentChatTranscriptService.swift
520 CLI/CMUXCLI+AmpExtension.swift
520 cmuxTests/MainWindowVisibilityControllerTests.swift
519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift
Expand All @@ -225,11 +226,11 @@
509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift
507 Sources/TerminalControllerTopSupport.swift
506 Sources/App/MainWindowVisibilityController.swift
518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
505 cmuxUITests/DisplayResolutionRegressionUITests.swift
504 cmuxTests/TerminalNotificationSocketActionTests.swift
503 Sources/Settings/ConfigSource.swift
502 Sources/CmuxEventPublishing.swift
502 Sources/RemoteTmuxSessionMirror.swift
500 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
500 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
500 Sources/KeyboardShortcutRecorder.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ public actor FixtureChatEventSource: ChatEventSource {
case .reset:
backlog = []
terminalBacklog = []
case .streamingProse:
// A live preview is transient and not part of history; forward it to
// subscribers but never fold it into the backlog.
break
case .unknown:
break
case .stateChanged, .descriptorChanged:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ public final class ChatConversationStore {

@ObservationIgnored private var messages: [ChatMessage] = []
@ObservationIgnored private var pending: [ChatPendingOutbound] = []
/// Live, not-yet-committed preview of the agent's in-progress prose for the
/// current turn, scraped from the rendered terminal screen. Held outside
/// ``messages`` (so it never collides with window dedup/paging/seq) and
/// rendered as a trailing agent bubble. Cleared the instant the authoritative
/// agent prose lands via ``ChatSessionEvent/appended`` or an explicit
/// ``ChatSessionEvent/streamingProse`` `nil`, so it never duplicates a real
/// message.
@ObservationIgnored private var streamingMessage: ChatMessage?
@ObservationIgnored private var firstUnreadSeq: Int?
/// Terminal command-blocks for a `.terminal`-kind session, upserted by
/// id; `terminalBlockOrder` preserves arrival order. Unused (and the
Expand Down Expand Up @@ -458,6 +466,13 @@ public final class ChatConversationStore {
switch event {
case .appended(let newMessages):
reconcilePending(against: newMessages)
// The authoritative agent prose for the in-flight turn just landed:
// drop the live preview so the committed message takes over with no
// duplicate, even if the host's explicit clear is delayed or lost.
if streamingMessage != nil,
newMessages.contains(where: { $0.role == .agent && Self.isProse($0) }) {
streamingMessage = nil
}
// A live append whose seq regresses below the window tail means
// the transcript was truncated/replaced and the tailer reset;
// appending would corrupt window ordering. Re-anchor instead.
Expand Down Expand Up @@ -499,6 +514,14 @@ public final class ChatConversationStore {
// optimistic pending row it came from so it doesn't linger or leak.
reconcileTerminalPending(against: blocks)
reproject()
case .streamingProse(let message):
// The preview is a whole-value replace; an agent session only. A
// terminal session has no agent prose, so ignore it there.
guard descriptor.kind != .terminal else { break }
let next = message.flatMap { Self.isProse($0) ? $0 : nil }
guard next != streamingMessage else { break }
streamingMessage = next
Comment on lines +517 to +523

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fail closed on malformed .streamingProse payloads.

Line 521 only checks message kind (.prose) and will accept non-agent prose. Restrict this path to .agent prose so invalid frames are ignored instead of rendered.

As per path instructions, “Correctness-critical state must come from a single authoritative source … fail closed (disable/empty state) rather than guessing.”

🔧 Suggested fix
-            let next = message.flatMap { Self.isProse($0) ? $0 : nil }
+            let next = message.flatMap { ($0.role == .agent && Self.isProse($0)) ? $0 : nil }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift`
around lines 517 - 523, In the `.streamingProse` case within
ChatConversationStore, the message validation on line 521 only checks if the
message is prose using `Self.isProse($0)` but does not verify it is specifically
`.agent` prose. Modify the flatMap condition in the `let next = message.flatMap
{ ... }` line to additionally validate that the message kind is `.agent` in
addition to passing the isProse check, so that only valid agent prose is
accepted and malformed non-agent frames are ignored.

Source: Path instructions

reproject()
case .reset:
// The transcript was truncated/replaced on the Mac (tailer
// re-read from scratch). The window's seq space is void; clear
Expand All @@ -507,6 +530,8 @@ public final class ChatConversationStore {
// their retry and in-flight sends may still land in the new
// transcript and reconcile normally.
messages = []
// The preview belongs to the old seq space; drop it on re-anchor.
streamingMessage = nil
// Terminal blocks must clear here too: the terminal reproject()
// does not consult `messages`, so without this the synchronous
// reproject below would re-render stale blocks (and they'd persist
Expand Down Expand Up @@ -713,10 +738,27 @@ public final class ChatConversationStore {
+ pending.map(ChatTranscriptRow.pendingOutbound)
return
}
// The live preview renders as a trailing agent bubble after the
// committed window. Appending it to the projector input lets it group
// with adjacent agent prose exactly like a real message; it carries no
// window identity (never paged, deduped, or reconciled by id).
let projected: [ChatMessage]
if let streamingMessage, !messages.contains(where: { $0.id == streamingMessage.id }) {
projected = messages + [streamingMessage]
} else {
projected = messages
}
rows = projector.rows(
messages: messages,
messages: projected,
pending: pending,
firstUnreadSeq: firstUnreadSeq
)
}

/// Whether a message is renderable agent/user prose (used to settle the
/// live preview against the authoritative transcript line).
private static func isProse(_ message: ChatMessage) -> Bool {
if case .prose = message.kind { return true }
return false
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ public struct ChatSessionListReducer: Sendable {
// conversation's `ChatConversationStore` still consumes `stateChanged`
// directly for its own live state (it is not version-reconciled).
return sessions
case .appended, .updated, .terminalBlocks, .reset, .unknown:
case .appended, .updated, .terminalBlocks, .streamingProse, .reset, .unknown:
// Transcript-content frames don't affect the session list.
return sessions
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ public enum ChatSessionEvent: Sendable, Equatable {
/// reconnect is idempotent.
case terminalBlocks([TerminalCommandBlock])

/// A live, not-yet-committed preview of the agent's in-progress prose for
/// the current turn, scraped from the terminal's rendered screen while the
/// authoritative JSONL line has not been written yet. The payload replaces
/// any prior preview wholesale; `nil` clears it. It lives outside the
/// message window and is superseded the instant the authoritative agent
/// prose lands via ``appended``, so it never duplicates a real message.
case streamingProse(ChatMessage?)

/// The producing transcript was truncated or replaced; the session's
/// seq space restarted and clients must re-anchor from history.
case reset
Expand All @@ -30,6 +38,7 @@ extension ChatSessionEvent: Codable {
private enum CodingKeys: String, CodingKey {
case event
case messages
case message
case state
case descriptor
case blocks
Expand All @@ -41,6 +50,7 @@ extension ChatSessionEvent: Codable {
case stateChanged = "state_changed"
case descriptorChanged = "descriptor_changed"
case terminalBlocks = "terminal_blocks"
case streamingProse = "streaming_prose"
case reset
}

Expand All @@ -58,6 +68,8 @@ extension ChatSessionEvent: Codable {
self = .descriptorChanged(try container.decode(ChatSessionDescriptor.self, forKey: .descriptor))
case .terminalBlocks:
self = .terminalBlocks(try container.decode([TerminalCommandBlock].self, forKey: .blocks))
case .streamingProse:
self = .streamingProse(try container.decodeIfPresent(ChatMessage.self, forKey: .message))
case .reset:
self = .reset
case .none:
Expand Down Expand Up @@ -86,6 +98,9 @@ extension ChatSessionEvent: Codable {
case .terminalBlocks(let blocks):
try container.encode(EventName.terminalBlocks.rawValue, forKey: .event)
try container.encode(blocks, forKey: .blocks)
case .streamingProse(let message):
try container.encode(EventName.streamingProse.rawValue, forKey: .event)
try container.encodeIfPresent(message, forKey: .message)
case .reset:
try container.encode(EventName.reset.rawValue, forKey: .event)
case .unknown(let raw):
Expand Down
Loading
Loading