huskies: merge 1125 story LLM session entity + assemble_prompt_context helper, wired into Matrix bot
This commit is contained in:
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user