//! 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 `` 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 `` 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); let event_count = lines.len(); crate::slog!( "[llm-session] assemble_prompt_context session={session_id} new_events={event_count}" ); if lines.is_empty() { return String::new(); } let body = lines.join("\n"); format!("\n{body}\n\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 block. assert!( ctx.starts_with("\n"), "missing opening tag; got: {ctx}" ); assert!( ctx.ends_with("\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}"); } /// AC 4: two sleds each fire one transition; a session scoped `All` sees /// both events; a session scoped `Sleds([sled-A])` sees only sled-A's event. /// /// Simulates the gateway aggregate view by directly calling /// `append_event_log_entry` with two distinct sled IDs, then asserting /// scope-filtered assembly behaves correctly. #[test] fn scope_filter_all_sees_both_sleds_filter_sees_one() { crate::crdt_state::init_for_test(); let sled_a = "aaaaaaaaaaaaaaaa"; let sled_b = "bbbbbbbbbbbbbbbb"; // Each sled fires one pipeline transition. crate::crdt_state::append_event_log_entry( sled_a, 1_000_000.0, "10_story_alpha", "1_backlog", "2_current", "DepsMet", ); crate::crdt_state::append_event_log_entry( sled_b, 1_000_001.0, "20_story_beta", "2_current", "3_qa", "AgentCompleted", ); // Set up a session scoped to ALL sleds. crate::crdt_state::write_llm_session("room-scope-all", "Timmy", "all"); // Set up a session scoped to sled-A only. let sled_a_scope = format!("sleds:{sled_a}"); crate::crdt_state::write_llm_session("room-scope-sled-a", "Sally", &sled_a_scope); // All-scope session: both events must appear. let ctx_all = assemble_prompt_context("room-scope-all"); assert!( ctx_all.contains("10_story_alpha"), "All scope must contain sled-A event; got: {ctx_all}" ); assert!( ctx_all.contains("20_story_beta"), "All scope must contain sled-B event; got: {ctx_all}" ); // Sled-A-only session: only sled-A's event visible. let ctx_a = assemble_prompt_context("room-scope-sled-a"); assert!( ctx_a.contains("10_story_alpha"), "Sleds filter must contain sled-A event; got: {ctx_a}" ); assert!( !ctx_a.contains("20_story_beta"), "Sleds filter must NOT contain sled-B event; got: {ctx_a}" ); // Second call on both sessions: nothing new (high-water advanced). let ctx_all2 = assemble_prompt_context("room-scope-all"); assert!( ctx_all2.is_empty(), "All scope second call must be empty; got: {ctx_all2}" ); let ctx_a2 = assemble_prompt_context("room-scope-sled-a"); assert!( ctx_a2.is_empty(), "Sleds filter second call must be empty; got: {ctx_a2}" ); } /// Newly-added sled events appear in an All-scope session without /// restarting (AC 5 runtime pickup). #[test] fn scope_filter_all_picks_up_new_sled_at_runtime() { crate::crdt_state::init_for_test(); let sled_a = "cccccccccccccccc"; let sled_new = "dddddddddddddddd"; // Only sled-A exists initially. crate::crdt_state::append_event_log_entry( sled_a, 2_000_000.0, "30_story_first", "1_backlog", "2_current", "DepsMet", ); crate::crdt_state::write_llm_session("room-runtime-pickup", "Timmy", "all"); let ctx1 = assemble_prompt_context("room-runtime-pickup"); assert!( ctx1.contains("30_story_first"), "first event must appear; got: {ctx1}" ); // sled_new is adopted at runtime — its event is appended without restart. crate::crdt_state::append_event_log_entry( sled_new, 2_000_001.0, "40_story_second", "2_current", "3_qa", "AgentCompleted", ); let ctx2 = assemble_prompt_context("room-runtime-pickup"); assert!( ctx2.contains("40_story_second"), "newly adopted sled event must appear; got: {ctx2}" ); assert!( !ctx2.contains("30_story_first"), "old event must not reappear; got: {ctx2}" ); } }