//! The pending-changes queue (SPEC 25): dry runs, accept/reject flows, //! conflicts, the cap, and hostile params. #![allow(dead_code)] // shared fixtures: only some helpers are used here use notescraft_engine::{ AssistantGate, AssistantSettings, Decision, Error, GateSource, PendingApply, PendingChange, PendingKind, Registry, Session, apply_pending, compute_pending, register_core, run_as_assistant, }; use serde_json::{Value, json}; mod common; const ALPHA: &str = "---\nstatus: draft\ntags: [work]\n---\n\n# Alpha\n\nbody line\n"; /// A registry and a session whose assistant may write, with review on. fn spun_up(name: &str) -> (common::TempVault, Session, Registry) { let vault = common::temp_vault(name); common::seed(vault.path(), &[("alpha.md", ALPHA)]); let mut session = Session::open(vault.path()).expect("open the test vault"); session.set_assistant_settings(AssistantSettings { allow_write: true, allow_delete: true, review_writes: true, ..AssistantSettings::default() }); let mut registry = Registry::new(); register_core(&mut registry).expect("register the core commands"); (vault, session, registry) } /// Queues one `note.write` for `alpha.md` and answers with the pending id. fn queue_write(session: &mut Session, registry: &Registry, content: &str) -> u64 { let reply = run_as_assistant(session, registry, "note.write", &json!({ "path": "alpha.md", "content": content })).expect("queue the write"); reply.get("pending_id").and_then(Value::as_u64).expect("the reply carries the pending id") } /// Queues an arbitrary command and answers with the pending id. fn queue(session: &mut Session, registry: &Registry, id: &str, params: &Value) -> u64 { let reply = run_as_assistant(session, registry, id, params).expect("queue the command"); reply.get("pending_id").and_then(Value::as_u64).expect("the reply carries the pending id") } /// The newest queued change. fn last(session: &Session) -> PendingChange { session.pending_changes().last().cloned().expect("something is queued") } /// The queued change kind, read out for assertions. fn kind_name(session: &Session, id: u64) -> Option<&'static str> { session.pending_changes().iter().find(|change| change.id == id).map(|change| kind_of(&change.kind)) } fn kind_of(kind: &PendingKind) -> &'static str { match kind { PendingKind::Create => "create", PendingKind::Modify => "modify", PendingKind::Delete => "delete", PendingKind::Rename => "rename", PendingKind::Opaque { .. } => "opaque", } } /// Fails the test when the engine error is not the expected command failure. fn command_code(outcome: Result) -> (String, String) { match outcome { Err(Error::Command { source, .. }) => (source.code, source.message), other => panic!("expected a command error, found {other:?}"), } } #[test] fn dry_runs_compute_old_and_new_without_writing() { let (_vault, mut session, registry) = spun_up("queue-dry-run"); let before = session.read_note("alpha.md").expect("read alpha"); let write_id = queue_write(&mut session, ®istry, "replaced"); let pending = last(&session); assert_eq!(pending.id, write_id); assert_eq!(kind_name(&session, write_id), Some("modify")); assert_eq!(pending.old.as_deref(), Some(before.as_str()), "old is the disk text"); assert_eq!(pending.new.as_deref(), Some("replaced")); assert_eq!(session.read_note("alpha.md").expect("reread"), before, "nothing was written"); let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "fresh.md", "content": "created" })); let created = last(&session); assert_eq!(kind_name(&session, created.id), Some("create")); assert_eq!(created.new.as_deref(), Some("created")); assert!(created.old.is_none()); assert!(!session.vault().resolve("fresh.md").is_ok_and(|path| path.exists()), "create did not run"); let _ = queue(&mut session, ®istry, "note.delete", &json!({ "path": "alpha.md" })); let doomed = last(&session); assert_eq!(kind_name(&session, doomed.id), Some("delete")); assert_eq!(doomed.old.as_deref(), Some(before.as_str())); assert!(session.vault().resolve("alpha.md").is_ok_and(|path| path.exists()), "delete did not run"); let _ = queue(&mut session, ®istry, "note.rename", &json!({ "from": "alpha.md", "to": "alpha-moved.md" })); let renamed = last(&session); assert_eq!(kind_name(&session, renamed.id), Some("rename")); assert_eq!(renamed.path, "alpha.md"); assert_eq!(renamed.new.as_deref(), Some("alpha-moved.md")); let _ = queue(&mut session, ®istry, "note.property_set", &json!({ "path": "alpha.md", "key": "status", "value": "done", "type": "text" })); let property = last(&session); assert_eq!(kind_name(&session, property.id), Some("modify")); assert_eq!(property.old.as_deref(), Some(before.as_str())); let new_text = property.new.as_deref().expect("the dry run computed the text"); assert!(new_text.contains("status: done"), "{new_text}"); assert!(new_text.contains("# Alpha"), "the body survived the dry run"); assert_eq!(session.read_note("alpha.md").expect("reread"), before, "the property edit did not run"); } /// A write on a missing note queues as a create; a folder command and an /// invalid property value queue as opaque with a summary. #[test] fn opaque_kinds_carry_a_summary() { let (_vault, mut session, registry) = spun_up("queue-opaque"); let _ = queue(&mut session, ®istry, "note.write", &json!({ "path": "later.md", "content": "text" })); assert_eq!(kind_name(&session, last(&session).id), Some("create"), "a write to a missing note is a create"); let _ = queue(&mut session, ®istry, "folder.create", &json!({ "folder": "brand-new" })); let folder = last(&session); assert_eq!(kind_name(&session, folder.id), Some("opaque")); match folder.kind { PendingKind::Opaque { command, summary } => { assert_eq!(command, "folder.create"); assert!(summary.contains("folder.create"), "{summary}"); } _ => panic!("expected opaque"), } let _ = queue(&mut session, ®istry, "note.property_set", &json!({ "path": "alpha.md", "key": "when", "value": "not a date", "type": "date" })); assert_eq!(kind_name(&session, last(&session).id), Some("opaque"), "an invalid dry run queues as opaque"); } /// Accept applies the change through the normal write path. #[test] fn accept_applies_modify_create_delete_and_rename() { let (_vault, mut session, registry) = spun_up("queue-accept"); let write_id = queue_write(&mut session, ®istry, "replaced body"); let reply = registry.run("assistant.accept", &mut session, &json!({ "id": write_id })).expect("accept"); assert_eq!(reply.get("accepted").and_then(Value::as_array).map(Vec::len), Some(1)); assert_eq!(session.read_note("alpha.md").expect("read"), "replaced body"); assert_eq!(session.pending_changes().len(), 0, "applied changes leave the queue"); let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "brand-new.md", "content": "hello" })); let create_id = last(&session).id; registry.run("assistant.accept", &mut session, &json!({ "id": create_id })).expect("accept the create"); assert_eq!(session.read_note("brand-new.md").expect("read"), "hello"); let _ = queue(&mut session, ®istry, "note.delete", &json!({ "path": "brand-new.md" })); let delete_id = last(&session).id; registry.run("assistant.accept", &mut session, &json!({ "id": delete_id })).expect("accept the delete"); assert!(!session.vault().resolve("brand-new.md").is_ok_and(|path| path.exists()), "the note is gone"); let _ = queue(&mut session, ®istry, "note.rename", &json!({ "from": "alpha.md", "to": "renamed.md" })); let rename_id = last(&session).id; registry.run("assistant.accept", &mut session, &json!({ "id": rename_id })).expect("accept the rename"); assert!(session.vault().resolve("renamed.md").is_ok_and(|path| path.exists())); assert!(!session.vault().resolve("alpha.md").is_ok_and(|path| path.exists())); } /// A change whose note moved since is a conflict and stays queued. #[test] fn a_conflict_stays_queued_with_its_id() { let (_vault, mut session, registry) = spun_up("queue-conflict"); let write_id = queue_write(&mut session, ®istry, "the agent text"); // The user edits the note meanwhile. session.write_note("alpha.md", "---\nstatus: edited\n---\n\nmoved on\n").expect("the user writes"); let (code, _message) = command_code(registry.run("assistant.accept", &mut session, &json!({ "id": write_id }))); assert_eq!(code, "conflict"); assert_eq!(session.pending_changes().len(), 1, "the change stays queued"); assert_eq!(session.pending_changes().first().map(|change| change.id), Some(write_id), "same id"); // And the user can still reject it. registry.run("assistant.reject", &mut session, &json!({ "id": write_id })).expect("reject"); assert_eq!(session.pending_changes().len(), 0); } /// `accept all` reports conflicts as data and applies the rest. #[test] fn accept_all_reports_conflicts_as_data() { let (_vault, mut session, registry) = spun_up("queue-accept-all"); let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "one.md", "content": "one" })); let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "two.md", "content": "two" })); // A conflict is engineered by creating the file out of band. session.write_note("one.md", "already here").expect("the user writes"); let reply = registry.run("assistant.accept", &mut session, &json!({ "all": true })).expect("accept all"); let accepted = reply.get("accepted").and_then(Value::as_array).cloned().expect("the per-change report"); assert_eq!(accepted.len(), 2); assert!(accepted.iter().any(|row| row.get("conflict").is_some()), "the conflict is reported: {accepted:?}"); assert!(accepted.iter().any(|row| row.get("applied").is_some()), "the other change applied: {accepted:?}"); assert_eq!(session.read_note("two.md").expect("read"), "two", "two.md exists"); // The conflicted one stays queued. assert_eq!(session.pending_changes().len(), 1); } /// Reject drops one change; reject-all drops everything. #[test] fn reject_drops_one_or_every_pending_change() { let (_vault, mut session, registry) = spun_up("queue-reject"); let first = queue_write(&mut session, ®istry, "one"); let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": "x.md", "content": "x" })); let second = last(&session).id; assert_ne!(first, second); registry.run("assistant.reject", &mut session, &json!({ "id": first })).expect("reject one"); assert_eq!(session.pending_changes().len(), 1); assert_eq!(session.pending_changes().first().map(|change| change.id), Some(second)); registry.run("assistant.reject", &mut session, &json!({ "all": true })).expect("reject all"); assert_eq!(session.pending_changes().len(), 0); // Rejecting an unknown id is an error, not data. assert!(registry.run("assistant.reject", &mut session, &json!({ "id": 999 })).is_err()); } /// The queue caps at 200: the oldest is dropped with a notice. #[test] fn the_queue_caps_at_200_oldest_first() { let (_vault, mut session, registry) = spun_up("queue-cap"); for step in 0..201 { let path = format!("cap-{step}.md"); let _ = queue(&mut session, ®istry, "note.create", &json!({ "path": path, "content": "x" })); } assert_eq!(session.pending_changes().len(), 200, "the cap holds"); assert_eq!(session.pending_changes().first().map(|change| change.id), Some(2), "the oldest pending change was dropped"); assert!(session.notices().iter().any(|notice| notice.contains("queue is full")), "{:?}", session.notices()); assert_eq!(session.pending_changes().last().map(|change| change.id), Some(201), "ids keep counting up"); } /// Hostile params queue as opaque and never panic the gate. #[test] fn hostile_params_queue_as_opaque() { let (_vault, mut session, registry) = spun_up("queue-hostile"); let reply = run_as_assistant(&mut session, ®istry, "note.write", &Value::Null).expect("null params still queue"); assert_eq!(reply.get("queued").and_then(Value::as_bool), Some(true)); assert!(matches!(last(&session).kind, PendingKind::Opaque { .. }), "no path, no dry run"); let _ = queue(&mut session, ®istry, "note.write", &json!({ "path": 42, "content": [1, 2, 3] })); assert_eq!(kind_name(&session, last(&session).id), Some("opaque"), "typed params still queue"); let _ = queue(&mut session, ®istry, "note.write", &json!({ "path": "", "content": "" })); assert_eq!(kind_name(&session, last(&session).id), Some("create"), "empty path queues; accept reports the registry error"); } /// `apply_pending` is also callable directly: opaque stays queued, done applies. #[test] fn apply_pending_reports_done_and_opaque() { let (_vault, mut session, _registry) = spun_up("queue-apply-direct"); let change = compute_pending(&session, "note.write", &json!({ "path": "alpha.md", "content": "direct" })); match apply_pending(&mut session, &change) { Ok(PendingApply::Done { path }) => assert_eq!(path.as_deref(), Some("alpha.md")), other => panic!("expected done, got {other:?}"), } assert_eq!(session.read_note("alpha.md").expect("read"), "direct"); let opaque = compute_pending(&session, "folder.create", &json!({ "folder": "elsewhere" })); match apply_pending(&mut session, &opaque) { Ok(PendingApply::Opaque) => {} other => panic!("expected opaque, got {other:?}"), } } /// The session grant and the `assistant.*` commands agree with the gate. #[test] fn the_session_grant_and_status_commands_work() { let (_vault, mut session, registry) = spun_up("queue-grant"); // Without a grant, a closed gate denies the write. session.set_assistant_settings(AssistantSettings { allow_write: false, review_writes: true, ..AssistantSettings::default() }); assert!(run_as_assistant(&mut session, ®istry, "note.write", &json!({ "path": "alpha.md", "content": "x" })).is_err(), "deny without a grant"); // The user grants writes for the session. registry.run("assistant.grant_write", &mut session, &json!({ "session": true })).expect("grant"); let status = registry.run("assistant.status", &mut session, &Value::Null).expect("status"); assert_eq!(status.get("session_write").and_then(Value::as_bool), Some(true)); let write_id = queue_write(&mut session, ®istry, "with the grant"); assert_eq!(kind_name(&session, write_id), Some("modify")); // The pending list carries the diff for the panel. let pending = registry.run("assistant.pending", &mut session, &Value::Null).expect("pending"); assert_eq!(pending.get("count").and_then(Value::as_u64), Some(1)); // The same gate the tool call saw still decides for a probe. let decision = AssistantGate::check(GateSource::Assistant, &session, "note.write", &json!({ "path": "alpha.md", "content": "probe" })); assert!(matches!(decision, Decision::Queue(_))); // A grant-off status clears again. registry.run("assistant.grant_write", &mut session, &json!({ "session": false })).expect("clear the grant"); let status = registry.run("assistant.status", &mut session, &Value::Null).expect("status"); assert_eq!(status.get("session_write").and_then(Value::as_bool), Some(false)); assert!(run_as_assistant(&mut session, ®istry, "note.write", &json!({ "path": "alpha.md", "content": "x" })).is_err()); }