huskies: merge 1125 story LLM session entity + assemble_prompt_context helper, wired into Matrix bot

This commit is contained in:
dave
2026-05-17 20:04:42 +00:00
parent ecd3f600d9
commit badd522d60
11 changed files with 454 additions and 14 deletions
+154
View File
@@ -0,0 +1,154 @@
//! LLM session management — CRDT-backed context assembly for bot prompts.
//!
//! The central export is [`assemble_prompt_context`], which reads new pipeline
//! transition events from the CRDT event log past the session's stored high-water
//! marks, wraps them in a `<system-reminder>` block for injection at the head of
//! the next LLM prompt, and atomically advances the marks so a mid-turn crash
//! cannot double-inject the same events.
/// Assemble a `<system-reminder>` block containing new pipeline-transition events
/// for `session_id` and atomically advance the high-water marks.
///
/// Reads events from the local sled's CRDT event log that have not yet been
/// injected into this session (tracked via per-sled high-water marks stored in
/// the `LlmSessionCrdt` entity). Returns an empty string when there are no new
/// events or the CRDT is not yet initialised.
pub fn assemble_prompt_context(session_id: &str) -> String {
let lines = crate::crdt_state::assemble_and_advance_session(session_id);
if lines.is_empty() {
return String::new();
}
let body = lines.join("\n");
format!("<system-reminder>\n{body}\n</system-reminder>\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_state::{PipelineEvent, PlanState, Stage, StoryId, TransitionFired};
fn make_fired(story_id: &str) -> TransitionFired {
TransitionFired {
story_id: StoryId(story_id.to_string()),
before: Stage::Backlog,
after: Stage::Coding {
claim: None,
plan: PlanState::Missing,
retries: 0,
},
event: PipelineEvent::DepsMet,
at: chrono::Utc::now(),
}
}
/// AC 4: fire a `TransitionFired` event, call `assemble_prompt_context` via
/// the session helper, assert the rendered output contains the event details.
/// A second call must return empty because the high-water was advanced.
#[test]
fn assemble_prompt_context_includes_new_events_and_advances_high_water() {
crate::crdt_state::init_for_test();
// Log two transition events for different stories.
crate::event_log::log_transition_event(&make_fired("42_story_foo"));
crate::event_log::log_transition_event(&make_fired("99_story_bar"));
let ctx = assemble_prompt_context("room-test-1");
// Must be wrapped in a <system-reminder> block.
assert!(
ctx.starts_with("<system-reminder>\n"),
"missing opening tag; got: {ctx}"
);
assert!(
ctx.ends_with("</system-reminder>\n"),
"missing closing tag; got: {ctx}"
);
// Both story IDs must appear in the rendered block.
assert!(
ctx.contains("42_story_foo"),
"first story missing; got: {ctx}"
);
assert!(
ctx.contains("99_story_bar"),
"second story missing; got: {ctx}"
);
// The pipeline_event label must appear.
assert!(ctx.contains("DepsMet"), "event label missing; got: {ctx}");
// Second call: high-water was advanced — no new events, returns empty.
let ctx2 = assemble_prompt_context("room-test-1");
assert!(
ctx2.is_empty(),
"second call must be empty after high-water advance; got: {ctx2}"
);
}
/// Different session IDs have independent high-water marks.
#[test]
fn assemble_prompt_context_sessions_are_independent() {
crate::crdt_state::init_for_test();
crate::event_log::log_transition_event(&make_fired("77_story_x"));
// Session A sees the event.
let ctx_a = assemble_prompt_context("room-session-a");
assert!(
ctx_a.contains("77_story_x"),
"session A must see the event; got: {ctx_a}"
);
// Session B also sees it (independent high-water).
let ctx_b = assemble_prompt_context("room-session-b");
assert!(
ctx_b.contains("77_story_x"),
"session B must see the event; got: {ctx_b}"
);
// Second call on A: already advanced.
let ctx_a2 = assemble_prompt_context("room-session-a");
assert!(
ctx_a2.is_empty(),
"session A second call must be empty; got: {ctx_a2}"
);
// But B's second call is also empty.
let ctx_b2 = assemble_prompt_context("room-session-b");
assert!(
ctx_b2.is_empty(),
"session B second call must be empty; got: {ctx_b2}"
);
}
/// Events logged after a prior advance are included in the next call.
#[test]
fn assemble_prompt_context_includes_events_logged_after_advance() {
crate::crdt_state::init_for_test();
crate::event_log::log_transition_event(&make_fired("10_story_old"));
// First call drains and advances.
let ctx1 = assemble_prompt_context("room-incremental");
assert!(ctx1.contains("10_story_old"), "got: {ctx1}");
// Log a new event after the advance.
crate::event_log::log_transition_event(&make_fired("20_story_new"));
let ctx2 = assemble_prompt_context("room-incremental");
assert!(
ctx2.contains("20_story_new"),
"new event must appear; got: {ctx2}"
);
assert!(
!ctx2.contains("10_story_old"),
"old event must not reappear; got: {ctx2}"
);
}
/// `assemble_prompt_context` returns empty string when there are no events.
#[test]
fn assemble_prompt_context_empty_when_no_events() {
crate::crdt_state::init_for_test();
let ctx = assemble_prompt_context("room-empty");
assert!(ctx.is_empty(), "must be empty with no events; got: {ctx}");
}
}