Skip to content
Merged
49 changes: 44 additions & 5 deletions Native/OptChat/optchat-chief/src/brain/inbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,13 @@ impl Brain {
}
}

/// Spec 5a.2: an edit or a retraction of a message the log holds is
/// logged as a note that names its line (`edit of <id>: <text>`,
/// `retracted <id>`), so the next turn's delta carries it and the next
/// re-render folds it in. A reaction on the Chief's own messages goes to
/// Spec 5a.2: an edit or a deletion (the owner's retraction) of a message
/// the log holds is logged as a note that names its line (`edit of <id>:
/// <text>`, `deleted <id>`), so the next turn's delta carries it and the
/// next re-render folds it in. A deleted message's own text becomes the
/// memory's tombstone at once (`delete_from_memory`), so the Memory
/// Inspector and the next view no longer show its line; the summaries
/// above it are built again by the change book's worker. A reaction on the Chief's own messages goes to
/// `on_chief_reactions` (reaction_wake.rs); another update logs nothing. A message still queued
/// takes the edited text, or leaves the queue when retracted. The note's key
/// names the change, so a replayed update is logged once.
Expand Down Expand Up @@ -156,7 +159,9 @@ impl Brain {
let (text, change) = match (&message.retracted_at, &message.edited_at) {
(Some(_), _) => {
self.reply_not_needed(std::slice::from_ref(&message.id));
(optchat_core::retracted(target), "retracted".to_owned())
// The key stays `:retracted`, so a deletion an older build
// logged is not logged again.
(optchat_core::deleted(target), "retracted".to_owned())
}
(None, Some(at)) => (
optchat_core::edit_of(target, &message_text(message)),
Expand All @@ -173,6 +178,28 @@ impl Brain {
"logging the change of message {target} failed: {e}"
));
}
if message.retracted_at.is_some() {
self.delete_from_memory(target);
}
}

/// Writes logged message `target` over with the tombstone through the
/// memory's change book (backup, journal, stale summaries built again on
/// the editor's worker), before the next turn renders. Once per message:
/// a replay or a catch-up does not delete again a message the owner put
/// back in the inspector, and a write that failed is tried again then.
fn delete_from_memory(&self, target: u64) {
let Some(editor) = &self.memory_editor else {
return;
};
if editor.has_deleted(target) {
return;
}
if let Err((code, why)) = editor.delete_message(target) {
(self.log)(&format!(
"deleting message {target} from the memory failed ({code}): {why}"
));
}
}

/// A tapback or emoji someone else put on one of the Chief's own messages
Expand Down Expand Up @@ -293,6 +320,18 @@ impl Brain {
}
self.summary = Some(summary);
self.handled = from;
// A deletion made while the brain was not listening (a restart,
// a lost connection): the newest page's retracted messages go
// through the live path, which writes each one once.
let me = &self.settings.membership.participant;
let retracted: Vec<Message> = messages
.iter()
.filter(|m| m.seq <= from && m.retracted_at.is_some() && &m.author != me)
.cloned()
.collect();
for message in &retracted {
self.on_message_updated(message);
}
for message in pending {
self.handle(message);
}
Expand Down
21 changes: 21 additions & 0 deletions Native/OptChat/optchat-chief/src/brain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,10 @@ pub struct Brain {
repeat_denials: u32,
/// The rights table and the audit (rights_gate.rs); None: off.
rights: Option<RightsCtx>,
/// The owner's change book for this Chief's memory (inspect/edit.rs): a
/// message the person deletes in the chat is written over with its
/// tombstone there, journaled and undoable like an inspector delete.
memory_editor: Option<Arc<crate::inspect::edit::Editor>>,
/// Writes the log's description of each turn image (None: references only).
describer: Option<Arc<dyn images::Describe>>,
/// A user's reaction that may wake the Chief (reaction_wake.rs).
Expand Down Expand Up @@ -1187,6 +1191,7 @@ impl Brain {
steer_due: None,
reply_waits: reply_ledger::REPLY_RETRY_WAITS.to_vec(),
rights: None,
memory_editor: None,
};
// A host can exit after sub_done persists Done but before deliver
// queues the report. Recover that durable outbox before accepting new
Expand Down Expand Up @@ -1221,6 +1226,22 @@ impl Brain {
self
}

/// The memory's change book (`inspect/edit.rs`): a deleted chat message
/// becomes its tombstone in the memory, so the Memory Inspector and the
/// next re-rendered view no longer hold its text.
pub fn with_memory_editor(
mut self,
editor: Option<Arc<crate::inspect::edit::Editor>>,
) -> Brain {
self.set_memory_editor(editor);
self
}

/// `with_memory_editor` on a built brain.
pub fn set_memory_editor(&mut self, editor: Option<Arc<crate::inspect::edit::Editor>>) {
self.memory_editor = editor;
}

/// Writes the monitoring trace (`trace.rs`).
/// A turn that waits for the compactor writes how far it is here.
pub fn with_settle_status(mut self, status: crate::settle_status::SettleStatus) -> Brain {
Expand Down
10 changes: 7 additions & 3 deletions Native/OptChat/optchat-chief/src/host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1635,13 +1635,16 @@ fn start(
Arc::new(|line: &str| log(line)),
);
}
// The memory's one change book: the inspector's edits and the chat's
// deleted messages (brain/inbox.rs) share its journal and rebuild worker.
let memory_editor = crate::inspect::edit::Editor::open(chat.clone(), &paths.root);
crate::tools::serve_all(
&paths.tools_socket,
crate::tools::Served {
memory: chat.clone(),
orchestrator,
control: Some(control),
inspector: start_inspector(paths, &chat, &claude_text),
inspector: start_inspector(paths, &chat, &claude_text, &memory_editor),
chief: Some(chief_tools.clone()),
// The turn names of `Settings::turn_prefix`, the subagent names
// of the sub preset (`optchat-sub-<home id>-<id>`).
Expand Down Expand Up @@ -1803,6 +1806,7 @@ fn start(
)
.on_turn_end(Arc::new(move |key: &str| persister.turn_ended(key)))
.with_trace(trace.clone())
.with_memory_editor(Some(memory_editor.clone()))
.with_settle_status(crate::settle_status::SettleStatus::new(
&paths.settle_status,
))
Expand Down Expand Up @@ -2327,20 +2331,20 @@ fn start_inspector(
paths: &Paths,
chat: &Arc<OptChat>,
system_text: &str,
editor: &Arc<crate::inspect::edit::Editor>,
) -> Option<Arc<crate::inspect::Inspector>> {
let _ = std::fs::remove_file(&paths.inspector);
if env("OPTCHAT_INSPECTOR").as_deref() == Some("0") {
return None;
}
let editor = crate::inspect::edit::Editor::open(chat.clone(), &paths.root);
let inspector = Arc::new(
crate::inspect::Inspector::new(
chat.clone(),
paths.traces.clone(),
paths.settle_status.clone(),
system_text.to_owned(),
)
.with_editor(editor),
.with_editor(editor.clone()),
);
if env("OPTCHAT_INSPECTOR_WEB").as_deref() != Some("1") {
log("memory inspector: native (through the daemon); web page off");
Expand Down
15 changes: 15 additions & 0 deletions Native/OptChat/optchat-chief/src/inspect/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ impl Editor {
}
}

/// Deletes message `id` (the person deleted it in the chat): its text
/// becomes `TOMBSTONE`, as `/api/memory/delete` writes it. A message that
/// already holds the tombstone answers 400 "nothing changed".
pub fn delete_message(&self, id: u64) -> Answer {
self.change("delete", Target::Message(id), TOMBSTONE)
}

/// Whether a delete of message `id` is in the change book (undone too).
pub fn has_deleted(&self, id: u64) -> bool {
self.lock()
.changes
.iter()
.any(|c| c.op == "delete" && c.target == Target::Message(id))
}

/// The current text of `target`, or 404.
fn current(&self, target: Target) -> Result<String, (u16, String)> {
match target {
Expand Down
191 changes: 191 additions & 0 deletions Native/OptChat/optchat-chief/tests/chat_delete.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
//! A message the person deletes in the Chief chat (https://github.com/manaflow-ai/cmux/issues/36019):
//! the app retracts it at its owner, and the Chief's memory forgets it. The
//! next turn's input names the line as `deleted <id>` after `</chat>`, the
//! memory's copy of the message holds the tombstone (what the Memory
//! Inspector shows), the change book journals it like an inspector delete,
//! and all of it holds after the host restarts.

mod common;

use std::sync::Arc;

use cmux_conversation::Change;
use common::*;
use optchat_chief::brain::Input;
use optchat_chief::daemon::DaemonEvent;
use optchat_chief::inspect::edit::{Editor, JOURNAL, TOMBSTONE};
use serde_json::Value;

const SECRET: &str = "my bank PIN is 4417, keep it handy";

fn joined(blocks: &[Value]) -> String {
blocks
.iter()
.map(|b| b["text"].as_str().unwrap_or_default())
.collect()
}

fn with_editor(h: &mut Harness) -> Arc<Editor> {
let editor = Editor::open(h.chat.clone(), h.dir.path());
h.brain.set_memory_editor(Some(editor.clone()));
editor
}

#[test]
fn a_deleted_chat_message_leaves_the_chiefs_memory_and_stays_deleted_after_a_restart() {
let mut h = Harness::new(default_script());
let editor = with_editor(&mut h);
h.connect();
let first = h.say("user_local", SECRET);
h.settle_posts();
let target = h
.log()
.iter()
.position(|(kind, text)| kind == "user" && text == SECRET)
.expect("the message is logged") as u64;

// The person deletes it: the owner retracts it and the brain hears the update.
let mut deleted = first.clone();
deleted.parts = Vec::new();
deleted.retracted_at = Some("2026-11-04T12:00:00.000Z".into());
h.owner.lock().unwrap().messages[0] = deleted.clone();
h.brain.step(Input::from(DaemonEvent::Changed {
conversation: CONV.into(),
change: Change::MessageUpdated {
message: deleted.clone(),
},
}));

// The memory's copy is the tombstone, journaled as a delete.
assert_eq!(h.chat.message(target).unwrap().1, TOMBSTONE);
let changes = editor.changes();
let list = changes["changes"].as_array().unwrap();
assert_eq!(list.len(), 1, "one change: {changes}");
assert_eq!(list[0]["op"], "delete");
assert_eq!(list[0]["target"]["message"], target);
assert!(
h.dir.path().join(JOURNAL).exists(),
"the change book journals it"
);

// The next turn names the line as deleted and holds the text nowhere.
h.say("user_local", "what did I tell you before?");
h.settle_posts();
let prompts = h.agents.inner.lock().unwrap().prompts.clone();
let next = joined(prompts.last().expect("a second turn"));
let after = &next[next.find("</chat>").expect("the view") + "</chat>".len()..];
assert!(
after
.lines()
.any(|l| l.ends_with(&format!("|deleted {target}"))),
"the turn's input names line {target} as deleted after </chat>: {after}"
);
assert!(
!next.contains("4417"),
"the deleted text is in no part of the turn's input: {next}"
);

// A replayed update (a reconnect's catch-up) changes nothing again.
h.brain.step(Input::from(DaemonEvent::Changed {
conversation: CONV.into(),
change: Change::MessageUpdated { message: deleted },
}));
assert_eq!(
editor.changes()["changes"].as_array().unwrap().len(),
1,
"a replay is not a second delete"
);

// After a restart the memory still holds only the tombstone.
drop(editor);
let Harness {
dir,
chat,
owner,
brain,
..
} = h;
drop(brain);
chat.shutdown();
drop(chat);
let mut h = Harness::in_dir(dir, default_script(), owner);
let editor = with_editor(&mut h);
assert_eq!(h.chat.message(target).unwrap().1, TOMBSTONE);
assert!(
!h.log().iter().any(|(_, text)| text.contains("4417")),
"no line of the memory holds the deleted text"
);
let changes = editor.changes();
assert_eq!(
changes["changes"][0]["op"], "delete",
"the reopened change book lists the delete: {changes}"
);
}

/// Restarts the host over the same memory and owner, with a change book.
fn restart(h: Harness) -> (Harness, Arc<Editor>) {
let Harness {
dir,
chat,
owner,
brain,
..
} = h;
drop(brain);
chat.shutdown();
drop(chat);
let mut h = Harness::in_dir(dir, default_script(), owner);
let editor = with_editor(&mut h);
(h, editor)
}

#[test]
fn a_message_deleted_while_the_chief_was_down_leaves_its_memory_on_reconnect_and_an_undo_holds() {
let mut h = Harness::new(default_script());
h.connect();
h.say("user_local", SECRET);
h.settle_posts();
let target = h
.log()
.iter()
.position(|(kind, text)| kind == "user" && text == SECRET)
.expect("the message is logged") as u64;

// The person deletes it while the Chief host is not running: the brain
// hears no update, only the owner holds the retraction.
{
let mut owner = h.owner.lock().unwrap();
let m = &mut owner.messages[0];
m.parts = Vec::new();
m.retracted_at = Some("2026-11-04T12:00:00.000Z".into());
}
let (mut h, editor) = restart(h);
h.connect();
assert_eq!(
h.chat.message(target).unwrap().1,
TOMBSTONE,
"the reconnect's catch-up deletes it from the memory"
);
assert!(
h.log()
.iter()
.any(|(kind, text)| kind == "note" && text == &format!("deleted {target}")),
"the deletion is logged as a note naming the line"
);

// The owner puts the text back in the Memory Inspector; the next
// reconnect does not delete it again.
let change = editor.changes()["changes"][0]["id"].as_u64().unwrap();
editor
.answer("/api/memory/undo", &[("change".into(), change.to_string())])
.expect("the undo");
assert_eq!(h.chat.message(target).unwrap().1, SECRET);
let (mut h, editor) = restart(h);
h.connect();
assert_eq!(
h.chat.message(target).unwrap().1,
SECRET,
"an undone delete stays undone"
);
assert_eq!(editor.changes()["changes"].as_array().unwrap().len(), 1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,13 @@ extension HomeMirror {
case .sendMessage(let id, _, _, _):
if case .failed = entry.state { failed.insert(id) }
pendingSend[id] = entry
case .retractMessage(let message, let id):
// Delete or Undo Send leaves the preview at the press, not at the owner's echo (#36019);
// a refused retract is discarded from the log, which shows the preview again.
if summaries[id]?.lastMessage?.id == message {
summaries[id]?.lastMessage?.parts = []
summaries[id]?.lastMessage?.retractedAt = entry.intent.issuedAt
}
default:
break
}
Expand Down
Loading
Loading