//! The assistant transcripts (SPEC 25): append, list, read, delete, the id //! rules, the part overflow at the 8 MiB cap, and hostile ids. #![allow(dead_code)] // shared fixtures: only some helpers are used here use notescraft_engine::{ChatLine, ChatRole, Error, MAX_CHAT_BYTES, Registry, Session, ToolCard, register_core}; use serde_json::{Value, json}; mod common; const ALPHA: &str = "# Alpha\n\nbody\n"; fn spun_up(name: &str) -> (common::TempVault, Session, Registry) { let vault = common::temp_vault(name); common::seed(vault.path(), &[("alpha.md", ALPHA)]); let session = Session::open(vault.path()).expect("open the test vault"); let mut registry = Registry::new(); register_core(&mut registry).expect("register the core commands"); (vault, session, registry) } /// The code of a command error, as a transport reports it. fn code_of(outcome: Result) -> String { match outcome { Err(Error::Command { source, .. }) => source.code, Err(Error::InvalidParams { .. }) => "invalid_params".to_owned(), other => panic!("expected a command error, found {other:?}"), } } /// One user line and one tool line, appended and read back. #[test] fn lines_round_trip_and_the_tool_card_survives() { let (vault, mut session, _registry) = spun_up("chat-roundtrip"); session.chat_append("chat-1", &ChatLine::message(10, ChatRole::User, "please fix alpha.md")).expect("append the user line"); let card = ToolCard { title: String::from("write_note"), status: String::from("DONE"), path: Some(String::from("alpha.md")) }; session.chat_append("chat-1", &ChatLine::tool_line(11, card, "wrote the note")).expect("append the tool line"); session.chat_append("chat-1", &ChatLine::message(12, ChatRole::Agent, "done")).expect("append the agent line"); let lines = session.chat_read("chat-1").expect("read back"); assert_eq!(lines.len(), 3); assert_eq!(lines.first().map(|line| (line.ts, line.role)), Some((10, ChatRole::User))); assert_eq!(lines.first().expect("a line").text, "please fix alpha.md"); let tool = lines.get(1).expect("the tool line"); assert_eq!(tool.role, ChatRole::Tool); match &tool.tool { Some(card) => { assert_eq!(card.title, "write_note"); assert_eq!(card.status, "DONE"); assert_eq!(card.path.as_deref(), Some("alpha.md")); } None => panic!("the tool line carries its card"), } // The transcript lives under the reserved folder, never in the vault. let file = vault.path().join(".notescraft/chat/chat-1.jsonl"); assert!(file.exists(), "the transcript is at {}", file.display()); } /// Session ids are `[a-z0-9-]{1,64}`; everything else is refused with `chat`. #[test] fn session_ids_are_validated() { let (_vault, mut session, _registry) = spun_up("chat-ids"); for bad in ["", "Uppercase", "with space", "slash/x", "dot.name", "x".repeat(65).as_str()] { let outcome = session.chat_append(bad, &ChatLine::message(1, ChatRole::User, "hello")); assert!(matches!(outcome, Err(Error::Chat(_))), "`{bad}` must be refused"); } for good in ["a", "agent-run-42", "x".repeat(64).as_str()] { session.chat_append(good, &ChatLine::message(1, ChatRole::User, "hello")).expect("append"); } assert_eq!(session.chat_read("agent-run-42").expect("read").len(), 1); } /// The chat list is newest first and previews the first user text. #[test] fn the_chat_list_is_newest_first_with_a_preview() { let (_vault, mut session, _registry) = spun_up("chat-list"); session.chat_append("older", &ChatLine::message(1, ChatRole::System, "the agent started")).expect("append"); session.chat_append("older", &ChatLine::message(2, ChatRole::User, "older question that no one answers")).expect("append"); std::thread::sleep(std::time::Duration::from_millis(1100)); session.chat_append("newer", &ChatLine::message(3, ChatRole::User, "newer question")).expect("append"); let list = session.chat_list().expect("list"); assert_eq!(list.len(), 2, "{list:?}"); assert_eq!(list.first().map(|entry| entry.id.as_str()), Some("newer"), "{list:?}"); assert_eq!(list.first().map(|entry| entry.preview.as_str()), Some("newer question")); assert_eq!(list.get(1).map(|entry| entry.preview.as_str()), Some("older question that no one answers"), "the first user line, not the system line"); // Previews are truncated at 80 characters. let long = "y".repeat(200); session.chat_append("older", &ChatLine::message(4, ChatRole::User, long.as_str())).expect("append"); // The preview stays on the *first* user line of the session. let list = session.chat_list().expect("list"); assert_eq!(list.get(1).map(|entry| entry.preview.as_str()), Some("older question that no one answers")); } /// One part holds at most 8 MiB; the next line starts `-2.jsonl`, and /// reading stitches the parts back together in order. #[test] fn a_part_overflows_at_the_cap_and_the_next_part_starts() { let (vault, mut session, _registry) = spun_up("chat-overflow"); let big = "x".repeat(MAX_CHAT_BYTES - 64); session.chat_append("fat", &ChatLine::message(1, ChatRole::User, big.as_str())).expect("append the big line"); let first = vault.path().join(".notescraft/chat/fat.jsonl"); assert!(first.exists()); let size = std::fs::metadata(&first).expect("stat the part").len(); assert!(size <= MAX_CHAT_BYTES as u64, "the part stayed under the cap: {size}"); let small = "z".repeat(200); session.chat_append("fat", &ChatLine::message(2, ChatRole::Agent, small.as_str())).expect("append the small line"); let second = vault.path().join(".notescraft/chat/fat-2.jsonl"); assert!(second.exists(), "the overflow part exists"); let lines = session.chat_read("fat").expect("read across parts"); assert_eq!(lines.len(), 2); assert!(lines.first().expect("first").text.starts_with("xxx"), "the big text is in part one"); assert_eq!(lines.get(1).map(|line| line.text.as_str()), Some(small.as_str()), "order is preserved"); // One line alone bigger than a part is refused outright. let huge = "y".repeat(MAX_CHAT_BYTES + 1); assert!(matches!(session.chat_append("fat", &ChatLine::message(3, ChatRole::User, huge.as_str())), Err(Error::Chat(_)))); } /// A transcript that lost its shape is a `chat` error, never a guessed line. #[test] fn a_corrupt_part_is_a_chat_error() { let (vault, session, _registry) = spun_up("chat-corrupt"); let dir = vault.path().join(".notescraft/chat"); std::fs::create_dir_all(&dir).expect("create the chat folder"); std::fs::write(dir.join("broken.jsonl"), "{\"ts\":1,\"role\":\"user\",\"text\":\"ok\"}\nnot json\n").expect("seed the broken part"); assert!(matches!(session.chat_read("broken"), Err(Error::Chat(_))), "a broken object is an error"); std::fs::write(dir.join("broken.jsonl"), "{\"ts\":1,\"role\":\"user\",\"text\":\"ok\"}\n").expect("repair the part"); assert_eq!(session.chat_read("broken").expect("read").len(), 1); } /// Delete removes every part; reading or deleting afterwards is `not_found`. #[test] fn delete_removes_every_part() { let (_vault, mut session, _registry) = spun_up("chat-delete"); let big = "x".repeat(MAX_CHAT_BYTES - 64); session.chat_append("gone", &ChatLine::message(1, ChatRole::User, big.as_str())).expect("append"); session.chat_append("gone", &ChatLine::message(2, ChatRole::Agent, "z".repeat(200).as_str())).expect("append"); let removed = session.chat_delete("gone").expect("delete"); assert_eq!(removed, 2, "both parts went away"); assert!(matches!(session.chat_read("gone"), Err(Error::NotFound(_)))); assert_eq!(session.chat_list().expect("list").len(), 0); assert!(matches!(session.chat_delete("gone"), Err(Error::NotFound(_)))); assert!(matches!(session.chat_read("never-there"), Err(Error::NotFound(_)))); } /// A read-only session cannot append but can read (SPEC 4.2). #[test] fn a_read_only_session_cannot_append() { let (vault, mut writable, _registry) = spun_up("chat-readonly"); writable.chat_append("ro", &ChatLine::message(1, ChatRole::User, "before")).expect("append"); let mut session = Session::open_read_only(vault.path()).expect("open read-only"); assert_eq!(session.chat_read("ro").expect("read").len(), 1, "reading still works"); assert!(matches!(session.chat_append("ro", &ChatLine::message(2, ChatRole::User, "no")), Err(Error::ReadOnly))); } /// The four `assistant.chat_*` commands drive the transcript over the one /// command surface, whoever asks (SPEC 25). #[test] fn the_chat_commands_append_list_read_and_delete() { let (vault, mut session, registry) = spun_up("chat-commands"); let appended = registry .run("assistant.chat_append", &mut session, &json!({ "id": "run-1", "role": "user", "text": "please fix alpha.md", "ts": 42 })) .expect("append"); assert_eq!(appended.get("appended").and_then(Value::as_bool), Some(true)); assert_eq!(appended.get("id").and_then(Value::as_str), Some("run-1")); registry .run( "assistant.chat_append", &mut session, &json!({ "id": "run-1", "role": "tool", "text": "wrote it", "tool": { "title": "note_write", "status": "DONE", "path": "alpha.md" } }), ) .expect("append the tool line"); let read = registry.run("assistant.chat_read", &mut session, &json!({ "id": "run-1" })).expect("read"); let lines = read.get("lines").and_then(Value::as_array).expect("the lines"); assert_eq!(lines.len(), 2); assert_eq!(lines.first().and_then(|line| line.get("ts")).and_then(Value::as_u64), Some(42)); assert_eq!(lines.get(1).and_then(|line| line.get("tool")).and_then(|tool| tool.get("title")).and_then(Value::as_str), Some("note_write")); let list = registry.run("assistant.chat_list", &mut session, &Value::Null).expect("list"); assert_eq!(list.get("count").and_then(Value::as_u64), Some(1)); let sessions = list.get("sessions").and_then(Value::as_array).expect("the sessions"); assert_eq!(sessions.first().and_then(|entry| entry.get("preview")).and_then(Value::as_str), Some("please fix alpha.md")); let deleted = registry.run("assistant.chat_delete", &mut session, &json!({ "id": "run-1" })).expect("delete"); assert_eq!(deleted.get("removed").and_then(Value::as_u64), Some(1)); assert!(!vault.path().join(".notescraft/chat/run-1.jsonl").exists(), "the part is gone"); } /// Hostile params are `invalid_params` or the `chat` error, never a panic. #[test] fn hostile_chat_params_are_refused() { let (_vault, mut session, registry) = spun_up("chat-commands-hostile"); let cases = [ ("assistant.chat_append", json!({ "role": "user", "text": "x" })), ("assistant.chat_append", json!({ "id": "run-2", "role": "wizard", "text": "x" })), ("assistant.chat_append", json!({ "id": "run-2", "role": "user", "text": 7 })), ("assistant.chat_append", json!({ "id": "UPPER", "role": "user", "text": "x" })), ("assistant.chat_append", json!({ "id": "run-2", "role": "user", "text": "x", "tool": "no card" })), ("assistant.chat_read", json!({})), ("assistant.chat_delete", json!({ "id": "no-such-session" })), ("assistant.chat_read", json!({ "id": "no-such-session" })), ]; for (id, params) in cases { let code = code_of(registry.run(id, &mut session, ¶ms)); assert!(code == "invalid_params" || code == "chat" || code == "not_found", "{id} {params}: {code}"); } // A tool card without a title is refused as a card, not as a line. let refused = registry.run("assistant.chat_append", &mut session, &json!({ "id": "run-3", "role": "tool", "text": "x", "tool": { "status": "DONE" } })); assert_eq!(code_of(refused), "invalid_params"); // An agent may not hand itself the review commands over the wire. let decision = notescraft_engine::AssistantGate::check(notescraft_engine::GateSource::Assistant, &session, "assistant.chat_delete", &json!({ "id": "run-1" })); assert!(matches!(decision, notescraft_engine::Decision::Allow), "the transcript commands are the agent's own surface"); }