2026-05-17 20:04:42 +00:00
|
|
|
//! 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);
|
2026-05-17 20:23:11 +00:00
|
|
|
let event_count = lines.len();
|
|
|
|
|
crate::slog!(
|
|
|
|
|
"[llm-session] assemble_prompt_context session={session_id} new_events={event_count}"
|
|
|
|
|
);
|
2026-05-17 20:04:42 +00:00
|
|
|
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}");
|
|
|
|
|
}
|
|
|
|
|
}
|