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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: `<Application Support>/<bundle id>/agent-pane-unsent-prompts.json`. Nil
Expand Down Expand Up @@ -119,23 +117,15 @@ 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 }
let key = Self.key(entry.daemon, entry.promptId)
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
}
Expand Down Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/cpp/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/go/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/java/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/python/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/rust/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/typescript/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
2 changes: 1 addition & 1 deletion cmux-tui/bindings/zig/.cmux-resource-api.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"catalog_sha256": "d7a6a0362158942b8efffd1c76b1e6d7b8595c0120aba6e37ef9a63246f95b35",
"catalog_sha256": "1db002488f669676b17afab5d8694c9a86a1a2bfc7742f7efc8c470d759c82e4",
"operations": {
"agent.list": {
"class": "read"
Expand Down
24 changes: 2 additions & 22 deletions cmux-tui/crates/acpmux/src/hub/composers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -142,19 +138,13 @@ impl Hub {
text: &str,
client: &str,
) -> Result<Value, RpcError> {
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 {
Expand All @@ -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));
}
Expand Down
9 changes: 0 additions & 9 deletions cmux-tui/crates/acpmux/src/hub/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -1972,12 +1969,6 @@ impl Hub {
session: &Session,
text: &str,
) -> Result<Option<String>, 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));
}
Expand Down
12 changes: 6 additions & 6 deletions cmux-tui/crates/acpmux/tests/hub_integration/drafts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
26 changes: 26 additions & 0 deletions cmux-tui/crates/acpmux/tests/hub_integration/surfaces.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 1 addition & 13 deletions cmux-tui/crates/cmux-tui-core/src/conversation_drafts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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",
}
}
}
Expand All @@ -39,8 +33,6 @@ pub(crate) struct DraftAdmission<'a> {
pub participant: &'a str,
pub turn: &'a str,
pub seq: u64,
pub fresh: bool,
pub text_bytes: usize,
}

#[derive(Debug)]
Expand All @@ -65,11 +57,7 @@ impl DraftGate {
draft: DraftAdmission<'_>,
now: Instant,
) -> Result<bool, DraftRefusal> {
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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,6 @@ pub(super) fn publish_draft(
participant: principal,
turn,
seq,
fresh,
text_bytes: text.len(),
};
let published = mux
.admit_conversation_draft(draft, Instant::now())
Expand Down
Loading
Loading