Files
2026-10-15 02:00:16 -03:00

218 lines
12 KiB
Rust

//! 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<Session>) {
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<Value, Error>) -> 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 `<id>-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, &params));
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");
}