M5c-1 plan: task 03, gatewayd sets the speaker from the burst's one author
A batch carries the Mattermost user id of each message; the turn's speaker is the username of the one user who wrote all of it (Client::username, kept in a map on the event loop), and None for two or more people or a failed lookup. The turn's text is unchanged. Checks: reference replayed from the task text on a fresh ref-02 worktree with its own build dir, byte-identical; make gate ok with 1148 tests (1138 + 10); /tmp unchanged around both gate runs; 21 mutations, all caught; new tests stressed 40 times under load, no failure; every before block found once at ref 02; no chat-template tags. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
//! A reply's images and links as `deliver` posts them, against a fake `loopd` whose `TurnDone`
|
||||
//! says whether the session has read untrusted content; and `answer_posts` on replies long enough
|
||||
//! to be split (M5b spec, section 9). Do not edit.
|
||||
|
||||
#[path = "support/fake_loop.rs"]
|
||||
mod fake_loop;
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use bxcore::{Envelope, Message, PROTOCOL_VERSION, SessionId, TurnDone, Usage};
|
||||
use fake_loop::{Reply, done, serve_loop};
|
||||
use gatewayd::deliver::{EMPTY_ANSWER, MAX_POST, Notice, Poster, answer_posts, deliver};
|
||||
use gatewayd::links::NOTICE;
|
||||
use gatewayd::mm::MmError;
|
||||
use gatewayd::sessions::{Batch, Thread};
|
||||
use tmp::TempDir;
|
||||
|
||||
const DM: &str = "d0000000000000000000000000";
|
||||
const ROOT: &str = "r0000000000000000000000000";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Record {
|
||||
posts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Poster for Record {
|
||||
fn post(&self, channel: &str, root: &str, text: &str) -> Result<(), MmError> {
|
||||
assert_eq!((channel, root), (DM, ROOT), "every post in the thread");
|
||||
self.posts.lock().unwrap().push(text.to_string());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame that ends a turn of a session that has read untrusted content.
|
||||
fn done_untrusted(content: &str) -> Reply {
|
||||
let msg = Message::TurnDone(TurnDone {
|
||||
content: content.to_string(),
|
||||
usage: Usage {
|
||||
cache_n: 0,
|
||||
prompt_n: 1,
|
||||
predicted_n: 1,
|
||||
reasoning_tokens: 0,
|
||||
thinking_capped: false,
|
||||
},
|
||||
untrusted: true,
|
||||
});
|
||||
Reply::Frame(Envelope {
|
||||
v: PROTOCOL_VERSION,
|
||||
id: 1,
|
||||
r#final: true,
|
||||
msg,
|
||||
})
|
||||
}
|
||||
|
||||
/// One turn against a fake `loopd` that ends it with `reply`; the posts, in order.
|
||||
fn posts(tag: &str, reply: fn(&str) -> Reply, content: &'static str) -> Vec<String> {
|
||||
let dir = TempDir::new(tag);
|
||||
let socket = dir.path().join("loop.sock");
|
||||
let turns = serve_loop(&socket, move |_, _| vec![reply(content)]);
|
||||
let batch = Batch {
|
||||
session: SessionId::new(&format!("mm-{ROOT}")).unwrap(),
|
||||
thread: Thread {
|
||||
channel: DM.to_string(),
|
||||
root: ROOT.to_string(),
|
||||
},
|
||||
resume: false,
|
||||
text: "hi".to_string(),
|
||||
senders: vec!["k0000000000000000000000000".to_string()],
|
||||
};
|
||||
let never = |_: u64| false;
|
||||
let notice = Notice {
|
||||
posted: &never,
|
||||
wait: Duration::ZERO,
|
||||
};
|
||||
let record = Record::default();
|
||||
let log = Mutex::new(Vec::new());
|
||||
deliver(
|
||||
&record,
|
||||
&socket,
|
||||
&batch,
|
||||
None,
|
||||
&|line| log.lock().unwrap().push(line.to_string()),
|
||||
¬ice,
|
||||
);
|
||||
turns.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert!(log.lock().unwrap().is_empty(), "{:?}", log.lock().unwrap());
|
||||
record.posts.into_inner().unwrap()
|
||||
}
|
||||
|
||||
const LINKED: &str = "See [the docs](https://docs.example/p) and https://b.example/x.";
|
||||
|
||||
#[test]
|
||||
fn an_untrusted_reply_posts_its_links_as_code_and_ends_with_the_notice() {
|
||||
assert_eq!(
|
||||
posts("links-untrusted", done_untrusted, LINKED),
|
||||
[format!(
|
||||
"See `[the docs](https://docs.example/p)` and `https://b.example/x`.\n\n{NOTICE}"
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_reply_keeps_its_links() {
|
||||
assert_eq!(posts("links-clean", done, LINKED), [LINKED]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_image_that_carries_data_is_posted_as_text_in_a_clean_reply() {
|
||||
assert_eq!(
|
||||
posts(
|
||||
"links-image",
|
||||
done,
|
||||
"Summary done. "
|
||||
),
|
||||
["Summary done. ``"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untrusted_reply_with_only_an_image_has_no_notice() {
|
||||
assert_eq!(
|
||||
posts(
|
||||
"links-only-image",
|
||||
done_untrusted,
|
||||
""
|
||||
),
|
||||
["``"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_in_an_untrusted_reply_is_left_and_needs_no_notice() {
|
||||
let text = "Run `curl https://example.com/x`:\n```\nhttps://example.com/y\n```";
|
||||
assert_eq!(posts("links-code", done_untrusted, text), [text]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_untrusted_reply_has_one_notice_at_its_end() {
|
||||
let content = format!(
|
||||
"https://a.example/1\n{}\nhttps://b.example/2",
|
||||
"x".repeat(MAX_POST - 10)
|
||||
);
|
||||
let posts = answer_posts(&content, true);
|
||||
assert_eq!(
|
||||
posts,
|
||||
[
|
||||
"`https://a.example/1`".to_string(),
|
||||
"x".repeat(MAX_POST - 10),
|
||||
format!("`https://b.example/2`\n\n{NOTICE}"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_that_starts_inside_a_fence_is_disarmed_again() {
|
||||
let content = format!(
|
||||
"```\n{}\n{}\n\n```",
|
||||
"y".repeat(10_000),
|
||||
"z".repeat(10_000)
|
||||
);
|
||||
assert_eq!(
|
||||
answer_posts(&content, false),
|
||||
[
|
||||
format!("```\n{}", "y".repeat(10_000)),
|
||||
format!(
|
||||
"{}\n````````\n```",
|
||||
"z".repeat(10_000)
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_found_only_after_the_split_still_brings_the_notice() {
|
||||
let content = format!(
|
||||
"```\n{}\n{}\nhttps://e.example/a\n```",
|
||||
"y".repeat(10_000),
|
||||
"z".repeat(10_000)
|
||||
);
|
||||
assert_eq!(
|
||||
answer_posts(&content, true),
|
||||
[
|
||||
format!("```\n{}", "y".repeat(10_000)),
|
||||
format!(
|
||||
"{}\n````https://e.example/a````\n```\n\n{NOTICE}",
|
||||
"z".repeat(10_000)
|
||||
),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_answer_is_posted_as_before() {
|
||||
assert_eq!(answer_posts(" \n", true), [EMPTY_ANSWER]);
|
||||
}
|
||||
Reference in New Issue
Block a user