From 3107ad7df9d6169e1bbe87e9ef94e4e5d68f6e22 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 04:51:25 +0000 Subject: [PATCH 1/4] fix drafts: remove user-facing size and count caps Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-8436bc3dafead47b3ea8 --- .../AgentPanePromptJournal.swift | 18 +--- .../CmuxNextAgentPane/AgentUnsentDrafts.swift | 15 +-- .../AgentPaneUnsentPromptTests.swift | 15 +++ .../CmuxNextAppTests/UnsentDraftTests.swift | 29 +++--- cmux-tui/crates/acpmux/src/hub/composers.rs | 24 +---- cmux-tui/crates/acpmux/src/hub/mod.rs | 9 -- .../acpmux/tests/hub_integration/drafts.rs | 12 +-- .../acpmux/tests/hub_integration/surfaces.rs | 26 ++++++ .../cmux-tui-core/src/conversation_drafts.rs | 13 +-- .../src/server/conversation_resource.rs | 1 - .../src/server/conversation_resource_tests.rs | 3 +- .../src/agent-session/acpmux/Composer.tsx | 37 +------- .../src/agent-session/acpmux/composerDraft.ts | 50 ++-------- webviews/src/agent-session/acpmux/direct.ts | 12 +-- .../src/agent-session/acpmux/lostPrompt.ts | 9 +- .../test/composer-draft-orderings.test.tsx | 92 ++++++++----------- 16 files changed, 123 insertions(+), 242 deletions(-) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePromptJournal.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePromptJournal.swift index 0bc2164a47fd..fad942f5666e 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePromptJournal.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPanePromptJournal.swift @@ -16,8 +16,8 @@ import CmuxNextCompat /// prompt id at most once: its ledger and its session log answer one it has seen as `duplicate`. /// - A connection the daemon closed while the app runs drops the prompts its page sent: the pane /// shows each as not sent, with Retry, so the pane owns them from then on. -/// - An entry older than ``maximumAge`` is dropped, never sent. The file holds at most -/// ``maximumEntries``; a prompt larger than ``maximumParamsBytes`` is not kept. +/// - An entry older than ``maximumAge`` is dropped, never sent. All newer entries are kept so a +/// prompt of any size or a burst of prompts cannot disappear during a restart. /// /// The host sends again only what it recorded from a frame the page rules let through; the page /// can never set `resend` itself (``AcpmuxPaneMethods/knownParams``). The pane's Retry sends a @@ -28,8 +28,6 @@ import CmuxNextCompat public nonisolated final class AgentPanePromptJournal: Sendable { /// How long a prompt may wait for its send again; an older one is dropped. public static let maximumAge: TimeInterval = 24 * 60 * 60 - static let maximumEntries = 64 - static let maximumParamsBytes = 8 << 20 static let fileName = "agent-pane-unsent-prompts.json" /// The app's journal: `//agent-pane-unsent-prompts.json`. Nil @@ -119,12 +117,8 @@ public nonisolated final class AgentPanePromptJournal: Sendable { private static func key(_ daemon: String, _ promptId: String) -> String { daemon + "\u{0}" + promptId } /// Records a prompt the page sent on connection `owner`, on disk before it returns. Call off - /// the main thread. False when it is not kept (too large, or the connection ended). + /// the main thread. False only when the connection has already ended. func record(_ entry: Entry, owner: UUID) -> Bool { - guard entry.params.utf8.count <= Self.maximumParamsBytes else { - Self.logger.info("agent pane prompt not journaled: too large") - return false - } var kept = false change { state in guard !state.ended.contains(owner) else { return false } @@ -132,10 +126,6 @@ public nonisolated final class AgentPanePromptJournal: Sendable { state.entries.removeAll { Self.key($0.daemon, $0.promptId) == key } state.entries.append(entry) state.holders[key] = .page(owner) - while state.entries.count > Self.maximumEntries { - let dropped = state.entries.removeFirst() - state.holders[Self.key(dropped.daemon, dropped.promptId)] = nil - } kept = true return true } @@ -302,7 +292,7 @@ public nonisolated final class AgentPanePromptJournal: Sendable { Self.logger.error("agent pane prompt journal unreadable; set aside, starting empty") return } - state.entries = Array(document.entries.suffix(Self.maximumEntries)) + state.entries = document.entries state.pendingTrust = document.pendingTrust } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentUnsentDrafts.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentUnsentDrafts.swift index f5baf39b162c..d6d5a79802ba 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentUnsentDrafts.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentUnsentDrafts.swift @@ -14,10 +14,6 @@ import CmuxNextCompat /// set aside, never replaced by an empty or a partial one. public final class AgentUnsentDrafts { public static let fileName = "agent-unsent-drafts.json" - /// A longer text is not kept (acpmux's own draft limit, `Hub::MAX_COMPOSER_DRAFT_CHARS`; the web's - /// composerDraft.ts `MAX_DRAFT_CHARS`, pinned equal by composer-draft-orderings.test.tsx). - public static let maximumCharacters = 1_000_000 - nonisolated private static let logger = Logger(subsystem: "com.cmuxterm.app.next", category: "agent-pane.unsent-drafts") let file: URL? /// Saves leave the main thread: typing in a new chat saves every settled edit. @@ -143,21 +139,14 @@ public final class AgentUnsentDrafts { set(text, for: key) return } - // A merge past the limit is refused (`set`): the tab keeps what it had. set(Self.isBlank(text) ? kept : Self.merged(typed: text, kept: kept), for: key) } /// Keeps `text` as tab `key`'s draft; blank text forgets it (the tab's chat started or closed, - /// or its page cleared a draft it had). A text past ``maximumCharacters`` is refused, as acpmux - /// refuses one: the tab keeps the draft it had. Before (cmux#28204 review at b7cddbbf), it was - /// taken as no draft, so a merge past the limit removed the saved draft. - /// Returns false when the text was refused. + /// or its page cleared a draft it had). Draft text is written whole, with no user-facing size + /// ceiling. The return value remains true for compatibility with callers. @discardableResult public func set(_ text: String, for key: String) -> Bool { - guard text.unicodeScalars.count <= Self.maximumCharacters else { - Self.logger.error("unsent draft past the limit not kept; the previous one stays") - return false - } let value = Self.isBlank(text) ? nil : text guard drafts[key] != value else { return true } drafts[key] = value diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUnsentPromptTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUnsentPromptTests.swift index 3fdaf650dacb..9f89aa36a3e7 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUnsentPromptTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAgentPaneTests/AgentPaneUnsentPromptTests.swift @@ -280,4 +280,19 @@ struct AgentPaneUnsentPromptTests { await Self.barrier(pane, id: 2) #expect(Self.prompts(daemon, peer: 0).isEmpty) } + + @Test func aLargePromptAndMoreThan64PendingPromptsSurviveTheJournal() throws { + let url = Self.journalURL() + defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } + let journal = AgentPanePromptJournal(url: url) + let owner = UUID() + for index in 0..<65 { + #expect(journal.record(.init(daemon: "daemon", sessionId: "s-1", promptId: "p-\(index)", params: "{}", at: Date()), owner: owner)) + } + let large = String(repeating: "x", count: 9 * 1024 * 1024) + #expect(journal.record(.init(daemon: "daemon", sessionId: "s-1", promptId: "large", params: large, at: Date()), owner: owner)) + #expect(journal.entries().count == 66) + #expect(journal.entries().last?.params.utf8.count == 9 * 1024 * 1024) + #expect(AgentPanePromptJournal(url: url).entries().count == 66) + } } diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UnsentDraftTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UnsentDraftTests.swift index 9c0d126d52e3..c650461b1899 100644 --- a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UnsentDraftTests.swift +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/UnsentDraftTests.swift @@ -40,6 +40,15 @@ struct UnsentDraftTests { withExtendedLifetime(drafts) {} } + @Test func aMultiMegabyteDraftComesBackAfterARelaunch() throws { + let directory = try Self.directory() + defer { try? FileManager.default.removeItem(at: directory) } + let file = directory.appending(path: AgentUnsentDrafts.fileName), files = AgentPaneStateFiles() + let large = String(repeating: "x", count: 2 * 1024 * 1024) + AgentUnsentDrafts(file: file, files: files).set(large, for: "tab-large") + #expect(AgentUnsentDrafts(file: file, files: files).draft(for: "tab-large") == large) + } + /// cmux#32276: a new chat whose tab opens another chat leaves its text (typed, and prompts held /// for a trust answer) in its window's one slot, on disk: a second leftover adds to it, the /// window's next new chat takes it once, and nothing sends it. @@ -213,28 +222,18 @@ struct UnsentDraftTests { #expect(try Data(contentsOf: directory.appending(path: aside)) == readable) } - /// A text past the limit is refused, as acpmux refuses one: the saved draft stays, never nil. - @Test func aTextPastTheLimitKeepsTheSavedDraft() throws { + /// A draft larger than the former limit is retained and merged without loss. + @Test func aLargeTypedMergeKeepsTheSavedDraft() throws { let directory = try Self.directory() defer { try? FileManager.default.removeItem(at: directory) } let file = directory.appending(path: AgentUnsentDrafts.fileName), files = AgentPaneStateFiles() - let saved = String(repeating: "s", count: AgentUnsentDrafts.maximumCharacters - 1) + let saved = String(repeating: "s", count: 2 * 1024 * 1024) AgentUnsentDrafts(file: file, files: files).set(saved, for: "tab-1") let drafts = AgentUnsentDrafts(file: file, files: files) - // Typed before the page was handed it: the merge is past the limit, so nothing changes. drafts.typed("x", for: "tab-1") - #expect(drafts.draft(for: "tab-1") == saved) - // The refused merge left the draft undelivered: the page's empty text (it has not been - // handed the draft yet) does not clear it. - drafts.typed("", for: "tab-1") - #expect(drafts.draft(for: "tab-1") == saved) - drafts.set(saved + "\n\nx", for: "tab-1") - #expect(AgentUnsentDrafts(file: file, files: files).draft(for: "tab-1") == saved) - // At the limit it is kept. - let atLimit = String(repeating: "s", count: AgentUnsentDrafts.maximumCharacters) - drafts.set(atLimit, for: "tab-1") - #expect(AgentUnsentDrafts(file: file, files: files).draft(for: "tab-1") == atLimit) + #expect(drafts.draft(for: "tab-1") == saved + "\n\nx") + #expect(AgentUnsentDrafts(file: file, files: files).draft(for: "tab-1") == saved + "\n\nx") } /// The merge keeps both texts once, and leaves a text that already holds the other as it is. diff --git a/cmux-tui/crates/acpmux/src/hub/composers.rs b/cmux-tui/crates/acpmux/src/hub/composers.rs index cfbc38d22f90..921910fc1322 100644 --- a/cmux-tui/crates/acpmux/src/hub/composers.rs +++ b/cmux-tui/crates/acpmux/src/hub/composers.rs @@ -27,10 +27,6 @@ use std::collections::{BTreeMap, VecDeque}; /// Longest surface id or name, in characters. pub const MAX_SURFACE_FIELD_CHARS: usize = 64; -/// Longest unsent text one surface keeps, in characters. -pub const MAX_SURFACE_DRAFT_CHARS: usize = 100_000; -/// Surfaces one chat keeps a draft for; the one typed into least recently goes first. -const MAX_SURFACES: usize = 16; /// Sent prompts whose surface one chat remembers, for labelling the queue. const SENT_LEDGER: usize = 64; @@ -142,19 +138,13 @@ impl Hub { text: &str, client: &str, ) -> Result { - if text.chars().count() > MAX_SURFACE_DRAFT_CHARS { - return Err(RpcError::invalid_params(format!( - "a surface's draft exceeds {MAX_SURFACE_DRAFT_CHARS} characters" - ))); - } if session.moving().is_some() { return Err(queue::moving_error()); } let composing = !text.trim().is_empty(); - let (changed, evicted) = { + let changed = { let mut c = session.composers.lock().unwrap_or_else(|e| e.into_inner()); let before = c.drafts.get(&surface.id).map(|d| d.surface.clone()); - let mut evicted = None; if composing { c.typed += 1; let draft = Draft { @@ -164,21 +154,11 @@ impl Hub { order: c.typed, }; c.drafts.insert(surface.id.clone(), draft); - if c.drafts.len() > MAX_SURFACES - && let Some(oldest) = - c.drafts.values().min_by_key(|d| d.order).map(|d| d.surface.id.clone()) - { - evicted = c.drafts.remove(&oldest).map(|d| d.surface); - } } else { c.drafts.remove(&surface.id); } - (before.as_ref() != composing.then_some(&surface), evicted) + before.as_ref() != composing.then_some(&surface) }; - // A draft pushed out by too many surfaces stops composing as far as every client knows. - if let Some(gone) = evicted { - self.append(session, "mux", "composer", gone.composer(client, false)); - } if changed { self.append(session, "mux", "composer", surface.composer(client, composing)); } diff --git a/cmux-tui/crates/acpmux/src/hub/mod.rs b/cmux-tui/crates/acpmux/src/hub/mod.rs index ef9764895536..f26e3accde59 100644 --- a/cmux-tui/crates/acpmux/src/hub/mod.rs +++ b/cmux-tui/crates/acpmux/src/hub/mod.rs @@ -1961,9 +1961,6 @@ impl Hub { saved } - /// The largest composer draft the daemon will persist for one session. - pub const MAX_COMPOSER_DRAFT_CHARS: usize = 1_000_000; - /// Store the unsent composer text without adding it to the transcript. /// Whitespace-only input clears the draft while preserving whitespace in a /// non-empty draft exactly as typed. @@ -1972,12 +1969,6 @@ impl Hub { session: &Session, text: &str, ) -> Result, RpcError> { - if text.chars().count() > Self::MAX_COMPOSER_DRAFT_CHARS { - return Err(RpcError::invalid_params(format!( - "composer draft exceeds {} characters", - Self::MAX_COMPOSER_DRAFT_CHARS - ))); - } if session.moving().is_some() { return Err(RpcError::invalid_params(queue::moving_error().message)); } diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/drafts.rs b/cmux-tui/crates/acpmux/tests/hub_integration/drafts.rs index 083d9f352497..a2ab6df6ee43 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/drafts.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/drafts.rs @@ -25,12 +25,12 @@ async fn composer_draft_rpc_round_trip_and_clear() { let loaded = c.request(method::MUX_DRAFT_GET, json!({"sessionId": id})).await.unwrap(); assert!(loaded["draft"].is_null()); - let too_large = "x".repeat(acpmux::hub::Hub::MAX_COMPOSER_DRAFT_CHARS + 1); - assert!( - c.request(method::MUX_DRAFT_SET, json!({"sessionId": id, "text": too_large})) - .await - .is_err() - ); + let large = "x".repeat(3 * 1024 * 1024); + let saved = + c.request(method::MUX_DRAFT_SET, json!({"sessionId": id, "text": large})).await.unwrap(); + assert_eq!(saved["draft"].as_str().unwrap().len(), 3 * 1024 * 1024); + let loaded = c.request(method::MUX_DRAFT_GET, json!({"sessionId": id})).await.unwrap(); + assert_eq!(loaded["draft"].as_str().unwrap().len(), 3 * 1024 * 1024); } #[tokio::test] diff --git a/cmux-tui/crates/acpmux/tests/hub_integration/surfaces.rs b/cmux-tui/crates/acpmux/tests/hub_integration/surfaces.rs index d2c4124982ea..b156c55e8f82 100644 --- a/cmux-tui/crates/acpmux/tests/hub_integration/surfaces.rs +++ b/cmux-tui/crates/acpmux/tests/hub_integration/surfaces.rs @@ -161,6 +161,32 @@ async fn each_surface_keeps_its_own_draft_and_others_see_it_composing() { assert!(view["composers"].as_array().unwrap().is_empty(), "{view}"); } +#[tokio::test] +async fn surface_drafts_keep_large_text_and_all_surfaces() { + let (_hub, mut mac) = setup(PermissionPolicy::ApproveAll).await; + let id = new_session(&mut mac, "large-drafts").await; + let large = "x".repeat(3 * 1024 * 1024); + for index in 0..20 { + let text = if index == 0 { large.as_str() } else { "draft" }; + mac.request( + method::MUX_COMPOSER_SET, + json!({"sessionId": id, "surface": surface(&format!("surface-{index}"), "Surface"), "text": text}), + ) + .await + .unwrap(); + } + let view = mac.request(method::MUX_COMPOSERS, json!({"sessionId": id})).await.unwrap(); + let composers = view["composers"].as_array().unwrap(); + assert_eq!(composers.len(), 20); + assert_eq!( + composers.iter().find(|c| c["surfaceId"] == "surface-0").unwrap()["text"] + .as_str() + .unwrap() + .len(), + 3 * 1024 * 1024 + ); +} + #[tokio::test] async fn a_malformed_surface_is_refused_and_records_nothing() { let (hub, mut c) = setup(PermissionPolicy::ApproveAll).await; diff --git a/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs b/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs index 034a5b87985e..243a0572e409 100644 --- a/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs +++ b/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs @@ -8,10 +8,6 @@ use std::collections::HashMap; use std::time::Instant; -/// The largest delta text (`fresh` false). -pub(crate) const MAX_DELTA_BYTES: usize = 16 * 1024; -/// The largest whole-segment text (`fresh` true). -pub(crate) const MAX_FRESH_BYTES: usize = 64 * 1024; const RATE_PER_SECOND: f64 = 10.0; const BURST: f64 = 10.0; /// Turns remembered at once; the least recently used goes first. @@ -21,14 +17,12 @@ const MAX_TURNS: usize = 256; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum DraftRefusal { RateLimited, - TextTooLarge, } impl DraftRefusal { pub(crate) const fn reason(self) -> &'static str { match self { Self::RateLimited => "rate_limited", - Self::TextTooLarge => "text_too_large", } } } @@ -40,7 +34,6 @@ pub(crate) struct DraftAdmission<'a> { pub turn: &'a str, pub seq: u64, pub fresh: bool, - pub text_bytes: usize, } #[derive(Debug)] @@ -65,11 +58,7 @@ impl DraftGate { draft: DraftAdmission<'_>, now: Instant, ) -> Result { - let DraftAdmission { conversation, participant, turn, seq, fresh, text_bytes } = draft; - let limit = if fresh { MAX_FRESH_BYTES } else { MAX_DELTA_BYTES }; - if text_bytes > limit { - return Err(DraftRefusal::TextTooLarge); - } + let DraftAdmission { conversation, participant, turn, seq, .. } = draft; let key = (conversation.to_owned(), participant.to_owned(), turn.to_owned()); if !self.turns.contains_key(&key) && self.turns.len() >= MAX_TURNS { self.evict_oldest(); diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs index 3f61fc00c1dd..f4deca585b55 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs @@ -257,7 +257,6 @@ pub(super) fn publish_draft( turn, seq, fresh, - text_bytes: text.len(), }; let published = mux .admit_conversation_draft(draft, Instant::now()) diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource_tests.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource_tests.rs index e9b9c7c963b1..95f547f54bbc 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource_tests.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource_tests.rs @@ -209,7 +209,8 @@ fn typing_and_drafts_are_for_the_caller_itself() { (replay["value"]["published"].clone(), replay["replayed"].clone()), (json!(false), json!(true)) ); - assert_eq!(error(&draft(&chief, 2, &"x".repeat(16 * 1024 + 1), false)).1, "text_too_large"); + let large = "x".repeat(3 * 1024 * 1024); + assert_eq!(ok(&draft(&chief, 2, &large, false))["value"]["published"], true); let mut limited = false; for seq in 2..=12 { let response = draft(&chief, seq, "y", false); diff --git a/webviews/src/agent-session/acpmux/Composer.tsx b/webviews/src/agent-session/acpmux/Composer.tsx index 0afbdf7f7baf..cd4cad8c7a4e 100644 --- a/webviews/src/agent-session/acpmux/Composer.tsx +++ b/webviews/src/agent-session/acpmux/Composer.tsx @@ -80,11 +80,9 @@ import { hydratedDraft, lastQueuedDraftText, markDraftRead, - draftFits, mergedDraft, noteDraftEdited, noteDraftHeld, - noteDraftKeptApart, noteDraftSent, readDurableDraftState, readPersistedDraft, @@ -712,13 +710,7 @@ export function Composer({ pendingRef.current = pending; /// A new chat's text as the host keeps it (`onUnsentDraft`): the person's words only. The host keeps /// no attachment with it, so each attachment's token goes, a placeholder still being read included. - /// A new chat's kept draft (the handshake's) that did not fit the limit beside text typed before it - /// came (`draftFits`): the host keeps it, not the editor's text, until the editor is cleared, which - /// brings it back into the editor. Before (cmux#28204 review at b7cddbbf), the merge past the limit - /// went to the host, whose store turned it into nil and removed the kept draft. - const keptOverLimit = useRef(undefined); const unsentText = (value: string) => - keptOverLimit.current ?? withoutInlineTokens( value, (id) => @@ -781,15 +773,6 @@ export function Composer({ const edited = editedBeforeHydration.current || history.edited; editedBeforeHydration.current = false; const next = hydratedDraft({ current: currentText, restored: saved, ...history, edited }); - if (next.overflow) { - // acpmux's draft and the typed text do not fit the limit together: acpmux keeps its draft, - // the editor keeps the typing, and the chat stays unread, so no edit or quit writes the typed - // text over the saved draft; the next read tries again (deleting the typing brings it back). - editedBeforeHydration.current = true; - noteDraftKeptApart(session); - if (session) cachePersistedDraft(session, currentText); - return currentText; - } if (next.shown) { // Its own write (below) or none: the text effect does not write it again. restoringSession.current = next.text !== textRef.current; @@ -989,13 +972,6 @@ export function Composer({ if (draftWasRead(persistedSession.current)) return; const next = mergedDraft(typed, draft); if (next === typed) return; - if (!draftFits(next)) { - // Both do not fit the limit: the host keeps the kept draft and the editor the typing. - keptOverLimit.current = draft; - unsentDraft.current?.push(draft); - return; - } - keptOverLimit.current = undefined; notTyped(); field.current?.reset(next); setText(next); @@ -1032,7 +1008,7 @@ export function Composer({ // switch's new session always is, so moving to another chat mid-switch keeps that chat's draft. // Merged only before the page ever read this chat's draft; after it, what was typed wins. const initial = typedBefore.trim() - ? draftWasRead(sessionId) || !draftFits(mergedDraft(typedBefore, restored || draft)) + ? draftWasRead(sessionId) ? typedBefore : mergedDraft(typedBefore, restored || draft) : restored || carried || draft || ""; @@ -1148,17 +1124,6 @@ export function Composer({ }; }, [sessionId, onUnsentDraft]); useEffect(() => { - const kept = keptOverLimit.current; - if (kept !== undefined && !text.trim()) { - // The typing is gone (deleted, or sent): the kept draft comes back into the editor. - keptOverLimit.current = undefined; - notTyped(); - field.current?.reset(kept); - setText(kept); - setCaret(kept.length); - pendingCaret.current = kept.length; - return; - } unsentDraft.current?.push(unsentText(text)); }, [text, sessionId, onUnsentDraft]); const commands = snapshot.commands; diff --git a/webviews/src/agent-session/acpmux/composerDraft.ts b/webviews/src/agent-session/acpmux/composerDraft.ts index 75e395b08bad..c064cfc14db0 100644 --- a/webviews/src/agent-session/acpmux/composerDraft.ts +++ b/webviews/src/agent-session/acpmux/composerDraft.ts @@ -46,25 +46,6 @@ export function mergedDraft(typed: string, saved: string | undefined): string { /** What goes between a saved draft and text typed after it (`mergedDraft`). */ const DRAFT_SEPARATOR = "\n\n"; -/** - * The longest draft the stores keep, in characters: acpmux's `Hub::MAX_COMPOSER_DRAFT_CHARS` - * (cmux-tui/crates/acpmux/src/hub/mod.rs, which refuses a longer one) and the tab's kept unsent text - * (`AgentUnsentDrafts.maximumCharacters`, Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane). Those - * two own it; composer-draft-orderings.test.tsx pins all three equal. - */ -export const MAX_DRAFT_CHARS = 1_000_000; - -/** - * Whether the stores keep `text` (`MAX_DRAFT_CHARS`). Counted in code points, as acpmux counts; a - * UTF-16 length within the limit is within it in every count, so most texts are never walked. - */ -export function draftFits(text: string): boolean { - if (text.length <= MAX_DRAFT_CHARS) return true; - let count = 0; - for (const _ of text) if (++count > MAX_DRAFT_CHARS) return false; - return true; -} - /** * What the composer does when acpmux's draft read answers (`readDurableDraftState`). One rule: * once the person touched this chat's prompt (`local`: an edit or a send, in this mount or an @@ -75,11 +56,8 @@ export function draftFits(text: string): boolean { * Untouched, with nothing on its way, acpmux's draft fills the prompt; on the page's first read * whatever the prompt holds (the remount cache, an inherited draft) is kept beside it. * `text` is the prompt to show, `shown` whether it differs from the editor, and `write` whether it - * goes to acpmux. `overflow`: the two texts to merge do not fit the stores' limit together - * (`MAX_DRAFT_CHARS`), so nothing is merged or written: acpmux keeps its draft as it is, the editor - * keeps what was typed, and the chat stays unread (its edits stay in the remount cache) until a read - * finds a merge that fits (cmux#28204 review at b7cddbbf: a merge past the limit was written, and - * the store that refuses it removed the saved draft). + * goes to acpmux. Drafts are merged and written whole; transport framing is responsible for moving + * large values without imposing a user-facing ceiling. */ export function hydratedDraft(input: { current: string; @@ -92,19 +70,13 @@ export function hydratedDraft(input: { sent: boolean; /** A write of this page's is queued or unanswered. */ pendingWrite: boolean; - /** An earlier read's draft did not fit beside the typing (`overflow`) and was left out of the - * prompt: a send since then sent none of it. */ - keptApart?: boolean; -}): { text: string; shown: boolean; write: boolean; overflow?: true } { +}): { text: string; shown: boolean; write: boolean } { const { current, restored, read, edited, pendingWrite } = input; - // A send consumed acpmux's draft only when the prompt could hold it. After an overflow it did - // not, so the saved draft comes back instead of being cleared (cmux#28204 review at c736d691). - const sent = input.sent && !input.keptApart; + const sent = input.sent; // The prompt as it is; it goes to acpmux when acpmux holds something else and nothing is on its way. const keep = () => ({ text: current, shown: false, write: !pendingWrite && current !== (restored ?? "") }); const merge = () => { const text = mergedDraft(current, restored); - if (!draftFits(text)) return { text: current, shown: false, write: false, overflow: true as const }; return { text, shown: text !== current, write: text !== (restored ?? "") && Boolean(text.trim()) }; }; if (edited || sent || pendingWrite) { @@ -384,25 +356,18 @@ export async function readDurableDraft(sessionId: string | undefined): Promise(); -// `keptApart`: a read's draft did not fit beside the typing and stayed out of the prompt (hydratedDraft). const chatDrafts = new Map< string, - { read: boolean; edited: boolean; sent: boolean; generation: number; keptApart: boolean } + { read: boolean; edited: boolean; sent: boolean; generation: number } >(); function chatDraft(sessionId: string) { let entry = chatDrafts.get(sessionId); if (!entry) - chatDrafts.set(sessionId, (entry = { read: false, edited: false, sent: false, generation: 0, keptApart: false })); + chatDrafts.set(sessionId, (entry = { read: false, edited: false, sent: false, generation: 0 })); return entry; } -/** A read of `sessionId`'s draft did not fit beside the typing (`hydratedDraft` overflow): acpmux - * keeps it, and the prompt holds none of it until a read merges it. */ -export function noteDraftKeptApart(sessionId: string | undefined): void { - if (sessionId?.trim()) chatDraft(sessionId).keptApart = true; -} - /** acpmux answered `write`: when nothing of the page's is still on its way for that chat and no * edit or send came after it was queued, acpmux holds the prompt as it is, so the edits and sends * before it are settled, as a read settles them (`markDraftRead`). Before (cmux#28204 review at @@ -424,7 +389,6 @@ export function draftHistory(sessionId: string | undefined): { edited: boolean; sent: boolean; pendingWrite: boolean; - keptApart: boolean; } { const entry = sessionId ? chatDrafts.get(sessionId) : undefined; return { @@ -432,7 +396,6 @@ export function draftHistory(sessionId: string | undefined): { edited: entry?.edited ?? false, sent: entry?.sent ?? false, pendingWrite: hasPendingDraftWrite(sessionId), - keptApart: entry?.keptApart ?? false, }; } @@ -447,7 +410,6 @@ export function markDraftRead(sessionId: string | undefined): void { if (!sessionId?.trim()) return; const entry = chatDraft(sessionId); entry.read = true; - entry.keptApart = false; if (!hasPendingDraftWrite(sessionId)) { entry.edited = false; entry.sent = false; diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 0e5c1b5be1dd..39d7431239dc 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -101,7 +101,7 @@ import { startedCardsShown } from "./started/startedSessions"; import { OfferChildren } from "./offer/offerChildren"; import { errorMessage, startCancelled } from "./transportErrors"; import { failedSendText, retryHelps, sendCause } from "./sendFailure"; -import { isLostConnection, JOURNAL_PARAMS_BYTES, lostPrompt, type LostPrompt } from "./lostPrompt"; +import { isLostConnection, lostPrompt, type LostPrompt } from "./lostPrompt"; import { SessionPageError, SessionPager, type SessionPage, withUsage } from "./sessionPages"; import { isWarmableCwd } from "./warmFolders"; import { isPlumbingCall, showsNothing } from "./hiddenTools"; @@ -8692,15 +8692,9 @@ export class AcpmuxDirectClient { ); } /// Whether the host's prompt journal keeps a prompt of these blocks that this connection lost - /// (Swift `AgentPanePromptRelay`): only on a connection to this Mac's acpmux (a chat of another - /// Mac it relays included), and for a prompt within the journal's `maximumParamsBytes` (8 MiB). + /// (Swift `AgentPanePromptRelay`): only on a connection to this Mac's acpmux. private journalsPrompts(blocks: unknown): boolean { - if (!this.localOrigin) return false; - try { - return JSON.stringify(blocks).length < JOURNAL_PARAMS_BYTES; - } catch { - return false; - } + return this.localOrigin; } /// The `awaitResent` waits, with their chat: each settles false once this connection closes or /// the pane shows another chat. diff --git a/webviews/src/agent-session/acpmux/lostPrompt.ts b/webviews/src/agent-session/acpmux/lostPrompt.ts index c0b0c7dce51e..f4e10b8a9a9b 100644 --- a/webviews/src/agent-session/acpmux/lostPrompt.ts +++ b/webviews/src/agent-session/acpmux/lostPrompt.ts @@ -3,13 +3,8 @@ // Debug Settings agentPane.debug.lostPromptReplay is off) sends it again, once, on the pane's next // connection with the same promptId, and acpmux runs a promptId at most once. So the pane does not // ask the person to read the chat's saved state and press Enter again: the sender waits for the -// next connection and for the prompt to show there, and says "Not sent" only when it never does -// (a prompt the host did not keep: too large, a chat on another machine). Leo, 2026-10-21: "why -// does it say it's not responding?" - -/// The largest prompt the host's journal keeps (Swift `AgentPanePromptJournal.maximumParamsBytes`): -/// a larger one is not sent again, so its sender says "Not sent" at once. -export const JOURNAL_PARAMS_BYTES = 8 << 20; +// next connection and for the prompt to show there, and says "Not sent" only when it never does. +// Leo, 2026-10-21: "why does it say it's not responding?" /// How long a sender waits for the pane's next connection. export const LOST_PROMPT_CONNECT_MS = 30_000; diff --git a/webviews/test/composer-draft-orderings.test.tsx b/webviews/test/composer-draft-orderings.test.tsx index bda56d77c595..3cc584a97804 100644 --- a/webviews/test/composer-draft-orderings.test.tsx +++ b/webviews/test/composer-draft-orderings.test.tsx @@ -5,15 +5,13 @@ // never merged in; text typed before the page ever read the chat's draft is kept beside acpmux's; // an untouched prompt shows acpmux's draft. import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import path from "node:path"; import { JSDOM } from "jsdom"; import { act, createRef } from "react"; import type { Root } from "react-dom/client"; import { noChat } from "../src/gallery/fixtures/acpmux"; import type { MarkdownFieldElement } from "../src/agent-session/acpmux/MarkdownField"; import type { ComposerHandle } from "../src/agent-session/acpmux/Composer"; -import { MAX_DRAFT_CHARS, hydratedDraft, notifyDraftActionsChanged } from "../src/agent-session/acpmux/composerDraft"; +import { hydratedDraft, notifyDraftActionsChanged } from "../src/agent-session/acpmux/composerDraft"; const scope = globalThis as Record; const keys = [ @@ -465,31 +463,25 @@ test("a quit's read answers before the load's: the editor and acpmux both keep s expect(state.held).toBe("saved\n\ntyped!"); }); -// cmux#28204 review at b7cddbbf: a saved draft one character under the limit plus "x" typed before -// the read merged to a text past the limit. The stores refuse such a text (acpmux's draft_set errors; -// AgentUnsentDrafts.set turned it into nil, removing the saved draft), so a quit lost the draft. -// The chosen behaviour when "saved\n\ntyped" does not fit: no merge is written, the saved draft -// stays exactly as it was, and the editor keeps what the person typed. Nothing is lost. -// The limit as the stores keep it (acpmux's Hub::MAX_COMPOSER_DRAFT_CHARS, AgentUnsentDrafts). -const LIMIT = 1_000_000; -const tooLong = (text: string) => Array.from(text).length > LIMIT; - -test("the web's draft limit is acpmux's and AgentUnsentDrafts'", () => { - const repo = path.resolve(import.meta.dir, "../.."); - const rust = readFileSync(path.join(repo, "cmux-tui/crates/acpmux/src/hub/mod.rs"), "utf8"); - const swift = readFileSync( - path.join(repo, "Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentUnsentDrafts.swift"), - "utf8", - ); - const number = (match: RegExpMatchArray | null) => Number(match?.[1]?.replaceAll("_", "")); - expect(number(rust.match(/MAX_COMPOSER_DRAFT_CHARS: usize = ([\d_]+);/))).toBe(LIMIT); - expect(number(swift.match(/static let maximumCharacters = ([\d_]+)/))).toBe(LIMIT); - expect(MAX_DRAFT_CHARS).toBe(LIMIT); +test("a multi-megabyte saved draft merges and is written whole", () => { + const saved = "s".repeat(3 * 1024 * 1024); + const typed = "typed after restore"; + const merged = hydratedDraft({ + current: typed, + restored: saved, + read: false, + edited: true, + sent: false, + pendingWrite: false, + }); + expect(merged.text).toBe(`${saved}\n\n${typed}`); + expect(merged.write).toBe(true); + expect(merged.overflow).toBeUndefined(); }); -test("a saved draft at the limit and text typed before the read: nothing written over it, the typing kept", async () => { - const sessionId = "ordering-over-limit"; - const saved = "s".repeat(LIMIT - 1); +test("a large saved draft and text typed before the read are merged and written", async () => { + const sessionId = "ordering-large"; + const saved = "s".repeat(3 * 1024 * 1024); const { state, actions } = gatedDaemon(saved); state.holdReads = true; page().cmuxAcpmuxActions = actions as never; @@ -503,23 +495,20 @@ test("a saved draft at the limit and text typed before the read: nothing written for (const answer of state.reads.splice(0)) await act(async () => answer()); await settle(); await settle(); - expect(prompt()).toBe("x"); - expect(state.held).toBe(saved); - // A later key and a quit keep it too: the typed text never goes over the saved draft. + expect(prompt()).toBe(`${saved}\n\nx`); + expect(state.held).toBe(`${saved}\n\nx`); await type("y"); - await until(() => prompt() === "xy", "the next key"); + await until(() => prompt() === `${saved}\n\nxy`, "the next key"); await settle(); await act(async () => handle.current!.settleDraft()); await settle(); - expect(state.held).toBe(saved); - expect(state.written.some((text) => tooLong(text) || !text.trim())).toBe(false); + expect(state.held).toBe(`${saved}\n\nxy`); + expect(state.written.some((text) => text === `${saved}\n\nxy`)).toBe(true); }); -test("a saved draft left apart at the limit, then the typing sent: the saved draft stays and comes back", async () => { - // cmux#28204 review at c736d691: the send marked the prompt sent, and the next read took the - // empty prompt as the draft and wrote it over the saved one the overflow had kept. - const sessionId = "ordering-over-limit-sent"; - const saved = "s".repeat(LIMIT - 1); +test("a large merged saved draft clears after sending", async () => { + const sessionId = "ordering-large-sent"; + const saved = "s".repeat(3 * 1024 * 1024); const { state, actions } = gatedDaemon(saved); state.holdReads = true; page().cmuxAcpmuxActions = actions as never; @@ -533,15 +522,15 @@ test("a saved draft left apart at the limit, then the typing sent: the saved dra for (const answer of state.reads.splice(0)) await act(async () => answer()); await settle(); await settle(); - expect(prompt()).toBe("x"); + expect(prompt()).toBe(`${saved}\n\nx`); await act(async () => void handle.current!.send()); await until(() => prompt() === "", "the send's clear"); await settle(); - // The next read (actions reinstalled after a reconnect) finds the saved draft and shows it. + // A reconnect sees the cleared daemon draft. await act(async () => notifyDraftActionsChanged()); - await until(() => prompt() === saved, "the saved draft back"); + await until(() => prompt() === "", "the cleared draft"); await settle(); - expect(state.held).toBe(saved); + expect(state.held).toBeUndefined(); expect(state.written.some((text) => !text.trim())).toBe(false); }); @@ -617,9 +606,9 @@ test("a quit after a reconnect took another pane's draft never writes it over th expect(state.written).not.toContain("B"); }); -test("a saved draft and typed text that fit the limit exactly: merged and written", async () => { +test("a large saved draft and typed text are merged and written", async () => { const sessionId = "ordering-at-limit"; - const saved = "s".repeat(LIMIT - 3); + const saved = "s".repeat(3 * 1024 * 1024); const { state, actions } = gatedDaemon(saved); state.holdReads = true; page().cmuxAcpmuxActions = actions as never; @@ -634,13 +623,12 @@ test("a saved draft and typed text that fit the limit exactly: merged and writte await settle(); await settle(); const merged = `${saved}\n\nx`; - expect(merged.length).toBe(LIMIT); expect(prompt()).toBe(merged); expect(state.held).toBe(merged); }); -test("a new chat's kept draft at the limit arriving after the person typed: the host keeps it, the typing stays", async () => { - const saved = "k".repeat(LIMIT - 1); +test("a new chat's large kept draft arriving after the person typed is merged", async () => { + const saved = "k".repeat(3 * 1024 * 1024); const sent: string[] = []; const onUnsentDraft = (text: string) => void sent.push(text); const handle = await render(undefined, { onUnsentDraft }); @@ -652,19 +640,17 @@ test("a new chat's kept draft at the limit arriving after the person typed: the await render(undefined, { draft: saved, onUnsentDraft }, handle); await settle(); await settle(); - expect(prompt()).toBe("x"); + expect(prompt()).toBe(`${saved}\n\nx`); await type("y"); - await until(() => prompt() === "xy", "the next key"); + await until(() => prompt() === `${saved}\n\nxy`, "the next key"); await settle(); await act(async () => handle.current!.settleDraft()); const after = sent.slice(before); - // Nothing past the limit, and no blank, went to the host; what it holds last is the kept draft. - expect(after.some((text) => tooLong(text) || !text.trim())).toBe(false); - expect(after.at(-1)).toBe(saved); + expect(after.at(-1)).toBe(`${saved}\n\nxy`); }); -test("a new chat's kept draft and typed text that fit exactly: merged and sent", async () => { - const saved = "k".repeat(LIMIT - 3); +test("a new chat's large kept draft and typed text are merged and sent", async () => { + const saved = "k".repeat(3 * 1024 * 1024); const sent: string[] = []; const onUnsentDraft = (text: string) => void sent.push(text); const handle = await render(undefined, { onUnsentDraft }); From e74df1325fb8a3566f0b0fd50642392dcd4f6723 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 08:34:34 +0000 Subject: [PATCH 2/4] fix drafts: repair type checks and remove obsolete fields Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-8436bc3dafead47b3ea8 --- .../cmux-tui-core/src/conversation_drafts.rs | 3 +- .../src/server/conversation_resource.rs | 1 - .../src/agent-session/acpmux/composerDraft.ts | 20 ++++++----- webviews/src/agent-session/acpmux/direct.ts | 4 +-- .../test/composer-draft-orderings.test.tsx | 34 ++++++++++++++++--- 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs b/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs index 243a0572e409..a5a489a6f1a1 100644 --- a/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs +++ b/cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs @@ -33,7 +33,6 @@ pub(crate) struct DraftAdmission<'a> { pub participant: &'a str, pub turn: &'a str, pub seq: u64, - pub fresh: bool, } #[derive(Debug)] @@ -58,7 +57,7 @@ impl DraftGate { draft: DraftAdmission<'_>, now: Instant, ) -> Result { - let DraftAdmission { conversation, participant, turn, seq, .. } = draft; + let DraftAdmission { conversation, participant, turn, seq } = draft; let key = (conversation.to_owned(), participant.to_owned(), turn.to_owned()); if !self.turns.contains_key(&key) && self.turns.len() >= MAX_TURNS { self.evict_oldest(); diff --git a/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs b/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs index f4deca585b55..d5a85a616ad3 100644 --- a/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs +++ b/cmux-tui/crates/cmux-tui-core/src/server/conversation_resource.rs @@ -256,7 +256,6 @@ pub(super) fn publish_draft( participant: principal, turn, seq, - fresh, }; let published = mux .admit_conversation_draft(draft, Instant::now()) diff --git a/webviews/src/agent-session/acpmux/composerDraft.ts b/webviews/src/agent-session/acpmux/composerDraft.ts index c064cfc14db0..1700752be06b 100644 --- a/webviews/src/agent-session/acpmux/composerDraft.ts +++ b/webviews/src/agent-session/acpmux/composerDraft.ts @@ -74,10 +74,18 @@ export function hydratedDraft(input: { const { current, restored, read, edited, pendingWrite } = input; const sent = input.sent; // The prompt as it is; it goes to acpmux when acpmux holds something else and nothing is on its way. - const keep = () => ({ text: current, shown: false, write: !pendingWrite && current !== (restored ?? "") }); + const keep = () => ({ + text: current, + shown: false, + write: !pendingWrite && current !== (restored ?? ""), + }); const merge = () => { const text = mergedDraft(current, restored); - return { text, shown: text !== current, write: text !== (restored ?? "") && Boolean(text.trim()) }; + return { + text, + shown: text !== current, + write: text !== (restored ?? "") && Boolean(text.trim()), + }; }; if (edited || sent || pendingWrite) { if (sent || pendingWrite || read || !restored) return keep(); @@ -356,15 +364,11 @@ export async function readDurableDraft(sessionId: string | undefined): Promise(); -const chatDrafts = new Map< - string, - { read: boolean; edited: boolean; sent: boolean; generation: number } ->(); +const chatDrafts = new Map(); function chatDraft(sessionId: string) { let entry = chatDrafts.get(sessionId); - if (!entry) - chatDrafts.set(sessionId, (entry = { read: false, edited: false, sent: false, generation: 0 })); + if (!entry) chatDrafts.set(sessionId, (entry = { read: false, edited: false, sent: false, generation: 0 })); return entry; } diff --git a/webviews/src/agent-session/acpmux/direct.ts b/webviews/src/agent-session/acpmux/direct.ts index 39d7431239dc..c0fad3bb22ef 100644 --- a/webviews/src/agent-session/acpmux/direct.ts +++ b/webviews/src/agent-session/acpmux/direct.ts @@ -7066,7 +7066,7 @@ export class AcpmuxDirectClient { // The connection went before acpmux took it, on a chat of this Mac's acpmux: the host's prompt // journal sends it again, under its promptId, on the pane's next connection (lostPrompt.ts), // so its sender waits for it to show there instead of saying "Not sent" at once. - if (!taken && isLostConnection(error) && this.journalsPrompts(blocks)) + if (!taken && isLostConnection(error) && this.journalsPrompts()) Object.assign(error as object, { lostPrompt: { sessionId, promptId } satisfies LostPrompt }); // The folder's trust question (the one route); the pane's sender adds the re-send. if (here && trustCancellation === this.trustCancellation) this.routeTrustRefusal(error); @@ -8693,7 +8693,7 @@ export class AcpmuxDirectClient { } /// Whether the host's prompt journal keeps a prompt of these blocks that this connection lost /// (Swift `AgentPanePromptRelay`): only on a connection to this Mac's acpmux. - private journalsPrompts(blocks: unknown): boolean { + private journalsPrompts(): boolean { return this.localOrigin; } /// The `awaitResent` waits, with their chat: each settles false once this connection closes or diff --git a/webviews/test/composer-draft-orderings.test.tsx b/webviews/test/composer-draft-orderings.test.tsx index 3cc584a97804..ba3090e7cce3 100644 --- a/webviews/test/composer-draft-orderings.test.tsx +++ b/webviews/test/composer-draft-orderings.test.tsx @@ -35,7 +35,10 @@ let root: Root | undefined; let host: HTMLElement; beforeAll(() => { - dom = new JSDOM("
", { pretendToBeVisual: true, url: "https://cmux.test/" }); + dom = new JSDOM("
", { + pretendToBeVisual: true, + url: "https://cmux.test/", + }); const { window } = dom; Object.assign(scope, { ResizeObserver: class { @@ -57,7 +60,17 @@ beforeAll(() => { localStorage: window.localStorage, }); // jsdom lays nothing out and its Range has no rects: ProseMirror measures one to scroll. - const box = () => ({ left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0, toJSON: () => ({}) }); + const box = () => ({ + left: 0, + top: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + x: 0, + y: 0, + toJSON: () => ({}), + }); Object.assign(window.Range.prototype, { getClientRects: () => Object.assign([box()], { item: () => box() }), getBoundingClientRect: box, @@ -94,7 +107,12 @@ async function render( const { Composer } = await import("../src/agent-session/acpmux/Composer"); const snapshot = noChat( [], - sessionId ? { sessionId, summary: { sessionId, harness: "claude", cwd: "/Users/you/src/app", turnCount: 0 } } : {}, + sessionId + ? { + sessionId, + summary: { sessionId, harness: "claude", cwd: "/Users/you/src/app", turnCount: 0 }, + } + : {}, ); root ??= createRoot(host); await act(async () => @@ -407,7 +425,14 @@ for (const row of rows) { } test("hydratedDraft: the one rule", () => { - const base = { current: "", restored: undefined, read: false, edited: false, sent: false, pendingWrite: false }; + const base = { + current: "", + restored: undefined, + read: false, + edited: false, + sent: false, + pendingWrite: false, + }; // Untouched, read before: acpmux's draft fills the prompt. expect(hydratedDraft({ ...base, current: "old", restored: "new", read: true })).toEqual({ text: "new", @@ -476,7 +501,6 @@ test("a multi-megabyte saved draft merges and is written whole", () => { }); expect(merged.text).toBe(`${saved}\n\n${typed}`); expect(merged.write).toBe(true); - expect(merged.overflow).toBeUndefined(); }); test("a large saved draft and text typed before the read are merged and written", async () => { From a8fb4387e1929d31f8cdaf4ff3409db279ec12d5 Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 09:05:44 +0000 Subject: [PATCH 3/4] test drafts: expect persisted clearing after large draft send Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-8436bc3dafead47b3ea8 --- webviews/test/composer-draft-orderings.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/webviews/test/composer-draft-orderings.test.tsx b/webviews/test/composer-draft-orderings.test.tsx index ba3090e7cce3..4a6cb6398042 100644 --- a/webviews/test/composer-draft-orderings.test.tsx +++ b/webviews/test/composer-draft-orderings.test.tsx @@ -547,6 +547,8 @@ test("a large merged saved draft clears after sending", async () => { await settle(); await settle(); expect(prompt()).toBe(`${saved}\n\nx`); + expect(state.held).toBe(`${saved}\n\nx`); + expect(state.written.some((text) => !text.trim())).toBe(false); await act(async () => void handle.current!.send()); await until(() => prompt() === "", "the send's clear"); await settle(); @@ -555,7 +557,7 @@ test("a large merged saved draft clears after sending", async () => { await until(() => prompt() === "", "the cleared draft"); await settle(); expect(state.held).toBeUndefined(); - expect(state.written.some((text) => !text.trim())).toBe(false); + expect(state.written.at(-1)).toBe(""); }); test("a read asked before an edit's write was answered never puts the old draft back", async () => { From 124183c144e951f2bd870ac8d5b70f77d7a7e6ec Mon Sep 17 00:00:00 2001 From: Leo Li Date: Tue, 3 Nov 2026 09:12:00 +0000 Subject: [PATCH 4/4] fix drafts: remove catalog text limit before publication Co-authored-by: Codex <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Agent-Session: https://cmux.com/deeplink/agent?id=aid-8436bc3dafead47b3ea8 --- cmux-tui/bindings/cpp/.cmux-resource-api.json | 2 +- cmux-tui/bindings/go/.cmux-resource-api.json | 2 +- cmux-tui/bindings/java/.cmux-resource-api.json | 2 +- cmux-tui/bindings/python/.cmux-resource-api.json | 2 +- cmux-tui/bindings/rust/.cmux-resource-api.json | 2 +- cmux-tui/bindings/typescript/.cmux-resource-api.json | 2 +- cmux-tui/bindings/zig/.cmux-resource-api.json | 2 +- cmux-tui/spec/resource-api-v2.md | 4 ++-- cmux-tui/spec/resource-operations-v2.json | 5 ++--- 9 files changed, 11 insertions(+), 12 deletions(-) diff --git a/cmux-tui/bindings/cpp/.cmux-resource-api.json b/cmux-tui/bindings/cpp/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/cpp/.cmux-resource-api.json +++ b/cmux-tui/bindings/cpp/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/go/.cmux-resource-api.json b/cmux-tui/bindings/go/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/go/.cmux-resource-api.json +++ b/cmux-tui/bindings/go/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/java/.cmux-resource-api.json b/cmux-tui/bindings/java/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/java/.cmux-resource-api.json +++ b/cmux-tui/bindings/java/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/python/.cmux-resource-api.json b/cmux-tui/bindings/python/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/python/.cmux-resource-api.json +++ b/cmux-tui/bindings/python/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/rust/.cmux-resource-api.json b/cmux-tui/bindings/rust/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/rust/.cmux-resource-api.json +++ b/cmux-tui/bindings/rust/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/typescript/.cmux-resource-api.json b/cmux-tui/bindings/typescript/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/typescript/.cmux-resource-api.json +++ b/cmux-tui/bindings/typescript/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/bindings/zig/.cmux-resource-api.json b/cmux-tui/bindings/zig/.cmux-resource-api.json index 21bc2965c700..40a1900369a6 100644 --- a/cmux-tui/bindings/zig/.cmux-resource-api.json +++ b/cmux-tui/bindings/zig/.cmux-resource-api.json @@ -1,5 +1,5 @@ { - "catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35", + "catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4", "operations": { "agent.list": { "class": "read" diff --git a/cmux-tui/spec/resource-api-v2.md b/cmux-tui/spec/resource-api-v2.md index 345dcc69757c..8ddf1489bfd6 100644 --- a/cmux-tui/spec/resource-api-v2.md +++ b/cmux-tui/spec/resource-api-v2.md @@ -1279,8 +1279,8 @@ same owner and the same rules, with nothing added on the v2 path: - `typing` and `draft` publish ephemeral items for the caller itself; the conversation rev does not change. Only an agent participant publishes a draft (`not_agent` otherwise), at most 10 per second per turn (burst 10, - `rate_limited`), a delta of at most 16 KiB and a whole segment of at most - 64 KiB (`text_too_large`); a seq at or below the turn's last published seq + `rate_limited`). Delta and whole-segment text have no field-level size cap; + a seq at or below the turn's last published seq is a replay. Drafts appear only on `conversation.events`, never on the raw `subscribe` stream. - `events` streams one conversation: a `snapshot` item (summary, the last diff --git a/cmux-tui/spec/resource-operations-v2.json b/cmux-tui/spec/resource-operations-v2.json index 791da154f498..288c56a2a392 100644 --- a/cmux-tui/spec/resource-operations-v2.json +++ b/cmux-tui/spec/resource-operations-v2.json @@ -18563,8 +18563,7 @@ "required": true, "type": { "kind": "primitive", - "name": "string", - "max_length": 65536 + "name": "string" } }, "fresh": { @@ -18573,7 +18572,7 @@ "kind": "primitive", "name": "boolean" }, - "description": "true: text is the whole segment so far (or its last 64 KiB with truncated) and replaces it; false: text is a delta appended to the segment." + "description": "true: text is the whole segment so far and replaces it; false: text is a delta appended to the segment." }, "done": { "required": true,