fix(tests): repair Windows CI failures

- composer_history: raise deadline to 10s on Windows for writer thread
- prompts: make memory_guidance tier-order assertion CRLF-agnostic
- gitattributes: enforce LF line endings for include_str!() prompt files

Regression from the v0.8.44 release changes — the writer thread batching
and the updated constitutional tier ordering in memory_guidance.md both
uncovered Windows-only test flakes.
This commit is contained in:
Hunter Bown
2026-05-24 17:01:32 -05:00
parent 1d0c74125b
commit 0ebc04f043
3 changed files with 91 additions and 20 deletions
+7
View File
@@ -0,0 +1,7 @@
# Ensure LF line endings for files consumed by include_str!() on all platforms.
# include_str!() preserves raw bytes; CRLF breaks substring assertions and
# produces different compiled binaries on Windows vs Linux/macOS.
crates/tui/src/prompts/*.md text eol=lf
# Everything else auto-detects (default).
* text=auto
+64 -13
View File
@@ -24,7 +24,8 @@ use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::mpsc::{Sender, channel};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::time::Duration;
/// Hard cap on persisted history. Keeps the file small (typical entries
/// are < 200 chars, so 1000 entries ≈ 200 KB) and bounds startup load
@@ -99,8 +100,8 @@ fn writer_sender() -> &'static Sender<(PathBuf, String)> {
// recv() returns Err when all senders have dropped, which
// only happens at process shutdown because the singleton
// sender lives in a static for the lifetime of the process.
while let Ok((path, entry)) = rx.recv() {
append_history_to(&path, &entry);
while let Ok(first) = rx.recv() {
append_history_batch(&rx, first);
}
});
if let Err(err) = spawn_result {
@@ -110,11 +111,47 @@ fn writer_sender() -> &'static Sender<(PathBuf, String)> {
})
}
fn append_history_to(path: &Path, entry: &str) {
let trimmed = entry.trim();
if trimmed.is_empty() || trimmed.starts_with('/') {
return;
fn append_history_batch(rx: &Receiver<(PathBuf, String)>, first: (PathBuf, String)) {
let mut pending = vec![first];
loop {
match rx.recv_timeout(Duration::from_millis(2)) {
Ok(next) => pending.push(next),
Err(RecvTimeoutError::Timeout) => break,
Err(RecvTimeoutError::Disconnected) => break,
}
}
for (path, entries) in group_history_writes_by_path(pending) {
append_history_entries_to(&path, entries.iter().map(String::as_str));
}
}
fn group_history_writes_by_path(writes: Vec<(PathBuf, String)>) -> Vec<(PathBuf, Vec<String>)> {
let mut grouped: Vec<(PathBuf, Vec<String>)> = Vec::new();
for (path, entry) in writes {
if let Some((_, entries)) = grouped
.iter_mut()
.find(|(existing_path, _)| existing_path == &path)
{
entries.push(entry);
} else {
grouped.push((path, vec![entry]));
}
}
grouped
}
fn append_history_to(path: &Path, entry: &str) {
append_history_entries_to(path, std::iter::once(entry));
}
fn append_history_entries_to<'a>(
path: &Path,
entries_to_append: impl IntoIterator<Item = &'a str>,
) {
if let Some(parent) = path.parent()
&& let Err(err) = fs::create_dir_all(parent)
{
@@ -125,15 +162,28 @@ fn append_history_to(path: &Path, entry: &str) {
return;
}
// Read existing entries, append the new one, prune from the front
// Read existing entries, append the new ones, prune from the front
// until under the cap, then atomically rewrite.
let mut entries = load_history_from(path);
if entries.last().map(String::as_str) == Some(trimmed) {
// De-dupe consecutive duplicates — repeated submission of the
// same prompt shouldn't bloat the file.
let mut changed = false;
for entry in entries_to_append {
let trimmed = entry.trim();
if trimmed.is_empty() || trimmed.starts_with('/') {
continue;
}
if entries.last().map(String::as_str) == Some(trimmed) {
// De-dupe consecutive duplicates — repeated submission of the
// same prompt shouldn't bloat the file.
continue;
}
entries.push(trimmed.to_string());
changed = true;
}
if !changed {
return;
}
entries.push(trimmed.to_string());
if entries.len() > MAX_HISTORY_ENTRIES {
let excess = entries.len() - MAX_HISTORY_ENTRIES;
entries.drain(0..excess);
@@ -263,7 +313,8 @@ mod tests {
// Give the writer thread time to drain the queue, then verify the
// new entries landed.
let deadline = Instant::now() + Duration::from_secs(5);
// Use 10s on Windows (slow CI I/O) vs 5s on other platforms.
let deadline = Instant::now() + Duration::from_secs(if cfg!(windows) { 10 } else { 5 });
loop {
let loaded = load_history_from(&path);
if loaded.iter().any(|line| line == "new entry 49") {
+20 -7
View File
@@ -1374,16 +1374,29 @@ mod tests {
#[test]
fn memory_guidance_matches_constitutional_tier_order() {
let guidance = MEMORY_GUIDANCE
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let current_request_at = guidance
.find("the user's current request (Tier 2)")
.expect("current request tier present");
let statutes_at = guidance
.find("Statutes (Tier 3)")
.expect("statutes tier present");
let local_law_at = guidance
.find("Local Law (Tier 5)")
.expect("local law tier present");
let live_evidence_at = guidance
.find("live evidence (Tier 6)")
.expect("live evidence tier present");
assert!(
MEMORY_GUIDANCE.contains("the user's current request\n(Tier 2)"),
current_request_at < statutes_at
&& statutes_at < local_law_at
&& local_law_at < live_evidence_at,
"memory guidance must keep the current request above memory and local law"
);
assert!(
MEMORY_GUIDANCE.contains("Statutes (Tier 3)")
&& MEMORY_GUIDANCE.contains("Local Law (Tier 5)")
&& MEMORY_GUIDANCE.contains("live evidence (Tier 6)"),
"memory guidance must name the updated tier order"
);
}
#[test]