218 lines
12 KiB
Rust
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, ¶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");
|
|
}
|