use std::sync::atomic::{AtomicU64, Ordering};
use crate::agent::runner::{AbortRunnerOnDrop, summarize_actions};
use crate::extras::dirge_paths::ProjectPaths;
use crate::provider::AnyAgent;
const MIN_REVIEW_INTERVAL_SECS: u64 = 900;
static LAST_REVIEW: AtomicU64 = AtomicU64::new(0);
fn claim_review_slot(now: u64) -> Option<u64> {
let last = LAST_REVIEW.load(Ordering::Acquire);
if now.saturating_sub(last) < MIN_REVIEW_INTERVAL_SECS {
return None;
}
match LAST_REVIEW.compare_exchange(last, now, Ordering::AcqRel, Ordering::Acquire) {
Ok(_) => Some(last),
Err(_) => None, }
}
fn release_review_slot(prev: u64, ours: u64) {
let _ = LAST_REVIEW.compare_exchange(ours, prev, Ordering::AcqRel, Ordering::Acquire);
}
const COMBINED_REVIEW_PROMPT: &str = r#"Review the conversation above and update what we know about this project and how to work on it.
**CRITICAL: You have ONLY the `memory` and `skill` tools available.** Do not attempt to use read, write, edit, bash, or any other tools — they are not loaded.
**1. Update MEMORY (project facts, conventions, pitfalls):**
- What build/test commands were discovered or confirmed?
- What naming conventions, file layout patterns, or import styles were used?
- What architecture patterns emerged (how modules relate, error handling style)?
- What library quirks or tool behaviors were discovered?
- Were there any user corrections about how things should be done?
- Was something tried and failed? Capture what was attempted and WHY it failed.
Classify every entry you save with the `kind` parameter — it drives how memory ranks and what gets evicted first when the budget is full:
• `semantic` — a durable fact or preference ("this project pins the MSRV in rust-toolchain.toml").
• `procedural` — a how-to rule or convention ("run `cargo fmt --all` before committing"). Default for AGENTS.md-style guidance.
• `episodic` — a specific past event worth recalling ("the 0.3 cut broke because the lockfile wasn't regenerated").
• `identity` — who the user/agent is ("operator prefers terse, no-preamble handoffs").
• `working` — short-lived task context. Rarely worth saving and the FIRST to be evicted under budget pressure — prefer not to persist it.
When unsure, use `semantic` for facts and `procedural` for rules.
**1b. Record procedural OUTCOMES.** A `procedural` memory is a playbook, and its value is whether it actually works. If the conversation shows an existing procedural entry being applied and the result is clear, record it with `memory(action='mark', old_text='<id-or-substring>', outcome='success'|'failure')`:
• `success` — the rule was followed and the thing worked, or the user confirmed it ("thanks, that worked").
• `failure` — following the rule led to a broken or rejected result ("that didn't help", the step had to be undone).
This ranks proven playbooks above ones that fail in practice and keeps effective rules from decaying. Only mark when the transcript makes the outcome unambiguous; skip it otherwise. Marking applies to `procedural` entries only.
**1c. Supersede CONTRADICTED facts.** When the conversation shows an existing memory entry is now WRONG or outdated — the user changed a preference, a fact was corrected, an approach was abandoned — do NOT silently `replace` it (that erases the old fact). Use `memory(action='supersede', old_text='<id-or-substring>', content='<corrected fact>', harsh=<bool>)`:
• `harsh=false` (default) — a natural update: the user changed their mind or a fact simply changed. The new fact is written at normal confidence.
• `harsh=true` — the user flatly DENIED the old fact ("no, that's wrong", "we never did that"). The new fact is held at reduced confidence because the area is contested.
Supersession keeps the old entry as an audit record and removes it from active memory. Use `replace` only for rewording the SAME fact; use `supersede` when the fact itself changed.
**2. Update SKILLS (procedural improvements):**
Be ACTIVE — most sessions produce at least one skill update. A pass that does nothing is a missed learning opportunity.
Preference order — prefer the earliest that fits:
1. UPDATE A CURRENTLY-LOADED SKILL. If the conversation involved a skill that is already in the library, extend or correct it first.
2. UPDATE AN EXISTING UMBRELLA. If the new knowledge belongs under a broader topic that already has a skill, patch it.
3. ADD A SUPPORT FILE under an existing umbrella via the skill tool (references/, templates/, or scripts/).
4. CREATE A NEW CLASS-LEVEL UMBRELLA SKILL only when no existing skill covers the class.
Signals that warrant action:
• User corrected your style, approach, or workflow. Frustration signals like "stop doing X", "this is too verbose", "don't format like this", or an explicit "remember this" are FIRST-CLASS skill signals.
• Non-trivial technique, fix, workaround, or debugging pattern emerged.
• A skill that was loaded or consulted turned out wrong or outdated — PATCH IT NOW.
• A pattern repeated across the session that future sessions would benefit from.
Do NOT capture:
• Environment-dependent failures: missing binaries, "command not found", unconfigured credentials. The user can fix these — they are not durable rules.
• Negative claims about tools ("read tool is broken", "cannot use X"). These harden into refusals long after the actual problem was fixed.
• Session-specific transient errors that resolved before the conversation ended.
• One-off task narratives. "Analyze this PR" is not a class of work that warrants a skill.
Target shape of the library: CLASS-LEVEL skills with a rich SKILL.md. Not a long flat list of narrow one-session-one-skill entries.
"Nothing to save." is valid but should NOT be the default. Most coding sessions produce at least one learning."#;
pub(crate) struct ForkedRunOutcome {
pub tool_actions: Vec<String>,
pub error: Option<String>,
}
pub(crate) async fn drain_forked_runner(
runner: crate::agent::runner::AgentRunner,
pass: &'static str,
) -> ForkedRunOutcome {
let crate::agent::runner::AgentRunner {
event_rx,
task,
cancel_tx,
..
} = runner;
let _abort_guard = AbortRunnerOnDrop { task, cancel_tx };
let mut rx = event_rx;
let mut tool_actions: Vec<String> = Vec::new();
let mut error: Option<String> = None;
while let Some(event) = rx.recv().await {
use crate::event::AgentEvent;
match event {
AgentEvent::Error(msg) => {
tracing::warn!(
target: "dirge::review",
pass,
error = %msg,
"forked runner reported an error",
);
error.get_or_insert_with(|| msg.to_string());
}
AgentEvent::ToolCall { name, .. } => {
tool_actions.push(name.to_string());
}
AgentEvent::Done { .. } => break,
_ => {}
}
}
ForkedRunOutcome {
tool_actions,
error,
}
}
pub(crate) async fn run_background_review(
agent: AnyAgent,
_paths: ProjectPaths,
transcript: String,
review_prompt_override: Option<String>,
) {
let now = crate::time_util::now_unix_secs();
let Some(prev) = claim_review_slot(now) else {
tracing::debug!(
target: "dirge::review",
"Skipping background review — rate-limited or another caller won the race"
);
return;
};
let prompt = review_prompt_override.unwrap_or_else(|| COMBINED_REVIEW_PROMPT.to_string());
let review_runner = agent.spawn_review_runner(prompt, transcript);
let outcome = drain_forked_runner(review_runner, "background-review").await;
let had_error = outcome.error.is_some();
let tool_actions = outcome.tool_actions;
if !had_error && !tool_actions.is_empty() {
let summary = summarize_actions(&tool_actions);
tracing::info!(
target: "dirge::review",
actions = %summary,
"💾 Self-improvement review: {}",
summary
);
} else if !had_error {
tracing::info!(
target: "dirge::review",
"Background review completed — project knowledge updated"
);
}
if had_error && tool_actions.is_empty() {
release_review_slot(prev, now);
tracing::debug!(
target: "dirge::review",
"Released LAST_REVIEW slot — review produced no work"
);
}
}
pub(crate) async fn run_curator_review(
agent: AnyAgent,
paths: crate::extras::dirge_paths::ProjectPaths,
candidate_list: String,
) {
if candidate_list.contains("No agent-created skills") {
tracing::debug!(
target: "dirge::curator",
"Skipping curator LLM pass — no agent-created candidates"
);
return;
}
let prompt = format!(
"{}\n\n{}",
crate::extras::skills::curator::CURATOR_PROMPT,
candidate_list
);
let before_candidates = candidate_list.clone();
let started = std::time::SystemTime::now();
let started_rfc = chrono::Utc::now().to_rfc3339();
let runner = agent.spawn_curator_runner(prompt, String::new());
let outcome = drain_forked_runner(runner, "skills-curator").await;
let tool_actions = outcome.tool_actions;
let error_msg = outcome.error;
if error_msg.is_none() && !tool_actions.is_empty() {
let summary = summarize_actions(&tool_actions);
tracing::info!(
target: "dirge::curator",
actions = %summary,
"🗂 Skill curator pass: {}",
summary
);
}
let paths_for_report = paths.clone();
let after_candidates = tokio::task::spawn_blocking(move || {
crate::extras::skills::usage::UsageStore::load(&paths_for_report)
.ok()
.map(|store| crate::extras::skills::curator::render_candidate_list(&store))
.unwrap_or_else(|| String::from("(failed to render after-state)"))
})
.await
.unwrap_or_else(|_| String::from("(blocking task failed)"));
let elapsed_secs = started.elapsed().map(|d| d.as_secs_f64()).unwrap_or(0.0);
let report = crate::extras::skills::curator::CuratorReport {
started_at_rfc3339: started_rfc,
elapsed_secs,
before_candidates,
after_candidates,
tool_actions,
error: error_msg,
};
match crate::extras::skills::curator::write_curator_report(&paths, &report) {
Ok(run_dir) => {
tracing::info!(
target: "dirge::curator",
run_dir = %run_dir.display(),
"Curator report written"
);
}
Err(e) => {
tracing::warn!(
target: "dirge::curator",
error = %e,
"Failed to write curator report (continuing)"
);
}
}
}
pub(crate) async fn run_memory_curator_review(
agent: AnyAgent,
paths: crate::extras::dirge_paths::ProjectPaths,
report: crate::extras::memory_curator::MechanicalReport,
) {
if report.stale_candidates.is_empty() {
tracing::debug!(
target: "dirge::memory_curator",
"Skipping LLM consolidation pass — no stale candidates",
);
return;
}
let (memory_md, pitfalls_md) = match crate::extras::memory_db::SqliteMemoryStore::load(&paths) {
Ok(store) => (
store.rendered_for_curator("memory"),
store.rendered_for_curator("pitfalls"),
),
Err(e) => {
tracing::warn!(
target: "dirge::memory_curator",
error = %e,
"Failed to load memory store for curator input",
);
(String::new(), String::new())
}
};
let input =
crate::extras::memory_curator::render_curator_input(&report, &memory_md, &pitfalls_md);
let prompt = format!(
"{}\n\n{}",
crate::extras::memory_curator::MEMORY_CURATOR_PROMPT,
input
);
let started = std::time::SystemTime::now();
let started_iso = chrono::Utc::now().to_rfc3339();
let started_filename = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
let report_for_writer = report.clone();
let runner = agent.spawn_memory_curator_runner(prompt, String::new());
let outcome = drain_forked_runner(runner, "memory-curator").await;
let tool_actions = outcome.tool_actions;
let error_msg = outcome.error;
let elapsed_secs = started.elapsed().map(|d| d.as_secs_f64()).unwrap_or(0.0);
if error_msg.is_none() {
let summary = if tool_actions.is_empty() {
"no-op".to_string()
} else {
summarize_actions(&tool_actions)
};
tracing::info!(
target: "dirge::memory_curator",
actions = %summary,
elapsed = %elapsed_secs,
"🗂 Memory curator LLM pass: {summary}",
);
}
let llm_report = crate::extras::memory_curator::LlmCuratorReport {
started_at_iso: started_iso,
elapsed_secs,
stale_candidates: report_for_writer.stale_candidates,
tool_actions,
error: error_msg,
};
let report_dir = paths
.memory_dir()
.join(".curator_reports")
.join(&started_filename);
if let Err(e) = std::fs::create_dir_all(&report_dir) {
tracing::warn!(
target: "dirge::memory_curator",
error = %e,
"Failed to create LLM report directory",
);
return;
}
let report_path = report_dir.join("LLM_REPORT.md");
if let Err(e) = std::fs::write(&report_path, llm_report.to_markdown()) {
tracing::warn!(
target: "dirge::memory_curator",
error = %e,
"Failed to write LLM report",
);
}
}
pub fn maybe_fire_session_end(
agent: &crate::provider::AnyAgent,
session: &crate::session::Session,
) {
let Some(provider) = agent.memory_provider() else {
return;
};
let transcript = build_transcript(session);
provider.on_session_end(&transcript);
}
pub fn fire_pre_compress(
provider: &dyn crate::extras::memory_provider::MemoryProvider,
transcript: &str,
) -> String {
provider.on_pre_compress(transcript)
}
pub fn fire_memory_write(
provider: &dyn crate::extras::memory_provider::MemoryProvider,
action: &str,
target: &str,
payload: &str,
) {
provider.on_memory_write(action, target, payload);
}
pub fn maybe_fire_session_switch(
agent: &crate::provider::AnyAgent,
new_session_id: &str,
parent_session_id: &str,
reset: bool,
) {
let Some(provider) = agent.memory_provider() else {
return;
};
provider.on_session_switch(new_session_id, parent_session_id, reset);
}
pub fn build_transcript(session: &crate::session::Session) -> String {
build_transcript_from_slice(&session.messages)
}
pub fn build_transcript_from_slice(messages: &[crate::session::SessionMessage]) -> String {
let mut out = String::new();
for msg in messages {
match msg.role {
crate::session::MessageRole::User => {
out.push_str(&format!("User: {}\n\n", msg.content));
}
crate::session::MessageRole::Assistant => {
if !msg.content.is_empty() {
out.push_str(&format!("Assistant: {}\n", msg.content));
}
for tc in &msg.tool_calls {
let args_str =
serde_json::to_string(&tc.args).unwrap_or_else(|_| "{}".to_string());
out.push_str(&format!(" [Tool: {}({})]\n", tc.name, args_str));
match &tc.state {
crate::session::ToolCallState::Completed { result } => {
let truncated = truncate_tool_result(result);
out.push_str(&format!(" [Result: {}]\n", truncated));
}
crate::session::ToolCallState::Interrupted => {
out.push_str(" [Result: <interrupted>]\n");
}
crate::session::ToolCallState::Failed { error } => {
out.push_str(&format!(" [Result: <failed: {}>]\n", error));
}
}
}
if !msg.content.is_empty() || !msg.tool_calls.is_empty() {
out.push('\n');
}
}
crate::session::MessageRole::System => {
out.push_str(&format!("[System: {}]\n\n", msg.content));
}
}
}
out
}
fn truncate_tool_result(result: &str) -> String {
const MAX_TOOL_RESULT: usize = 2000;
if result.len() <= MAX_TOOL_RESULT {
result.to_string()
} else {
let truncated: String = result.chars().take(MAX_TOOL_RESULT).collect();
format!("{}… (truncated, {} bytes total)", truncated, result.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::session::{MessageRole, Session, ToolCallEntry, ToolCallState};
fn fake_runner() -> (
tokio::sync::mpsc::Sender<crate::event::AgentEvent>,
crate::agent::runner::AgentRunner,
) {
let (tx, event_rx) = tokio::sync::mpsc::channel(16);
let (cancel_tx, _cancel_rx) = tokio::sync::mpsc::channel(1);
let (interject_tx, _interject_rx) = tokio::sync::mpsc::channel(1);
let task = tokio::spawn(async {});
(
tx,
crate::agent::runner::AgentRunner {
event_rx,
task,
interject_tx,
cancel_tx,
},
)
}
#[tokio::test]
async fn drain_forked_runner_collects_actions_and_first_error() {
use crate::event::AgentEvent;
let (tx, runner) = fake_runner();
tx.send(AgentEvent::ToolCall {
id: "1".into(),
name: "memory".into(),
args: serde_json::json!({}),
})
.await
.unwrap();
tx.send(AgentEvent::Error("first failure".into()))
.await
.unwrap();
tx.send(AgentEvent::Error("second failure".into()))
.await
.unwrap();
tx.send(AgentEvent::ToolCall {
id: "2".into(),
name: "skill".into(),
args: serde_json::json!({}),
})
.await
.unwrap();
tx.send(AgentEvent::Done {
response: "done".into(),
tokens: 0,
cost: 0.0,
})
.await
.unwrap();
tx.send(AgentEvent::ToolCall {
id: "3".into(),
name: "ghost".into(),
args: serde_json::json!({}),
})
.await
.unwrap();
let outcome = drain_forked_runner(runner, "test-pass").await;
assert_eq!(
outcome.tool_actions,
vec!["memory".to_string(), "skill".to_string()],
"actions in order, nothing after Done",
);
assert_eq!(outcome.error.as_deref(), Some("first failure"));
}
#[tokio::test]
async fn drain_forked_runner_ends_on_channel_close() {
use crate::event::AgentEvent;
let (tx, runner) = fake_runner();
tx.send(AgentEvent::ToolCall {
id: "1".into(),
name: "memory".into(),
args: serde_json::json!({}),
})
.await
.unwrap();
drop(tx);
let outcome = drain_forked_runner(runner, "test-pass").await;
assert_eq!(outcome.tool_actions, vec!["memory".to_string()]);
assert!(outcome.error.is_none());
}
fn make_session() -> Session {
Session::new("test-provider", "test-model", 128_000)
}
use crate::agent::tools::ToolCache;
use crate::extras::memory_provider::MemoryProvider;
use crate::provider::{AnyAgent, AnyAgentInner};
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct RecordingEndProvider {
ends: Mutex<Vec<String>>,
}
impl MemoryProvider for RecordingEndProvider {
fn name(&self) -> &str {
"recording-end"
}
fn view(&self, _: &str) -> serde_json::Value {
serde_json::Value::Null
}
fn add(&self, _: &str, _: &str, _kind: Option<&str>) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn replace(
&self,
_: &str,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn remove(&self, _: &str, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn on_session_end(&self, transcript: &str) {
self.ends.lock().unwrap().push(transcript.to_string());
}
}
fn agent_with_recording_provider() -> (AnyAgent, Arc<RecordingEndProvider>) {
use rig::client::CompletionClient;
use rig::providers::openai;
let client = openai::CompletionsClient::builder()
.api_key("test-key")
.build()
.expect("openai client");
let model = client.completion_model("gpt-4o");
let inner_agent = rig::agent::AgentBuilder::new(model).build();
let provider = Arc::new(RecordingEndProvider::default());
let provider_dyn: Arc<dyn MemoryProvider> = provider.clone();
let agent = AnyAgent::new(
AnyAgentInner::OpenAI(inner_agent),
ToolCache::new(),
std::time::Duration::from_secs(300),
Vec::new(),
String::new(),
"gpt-4o".to_string(),
)
.with_memory_provider(provider_dyn);
(agent, provider)
}
#[test]
fn maybe_fire_session_end_fires_with_transcript_when_messages_present() {
let (agent, provider) = agent_with_recording_provider();
let mut session = make_session();
session.add_message(MessageRole::User, "hello");
session.add_message(MessageRole::Assistant, "hi back");
maybe_fire_session_end(&agent, &session);
let ends = provider.ends.lock().unwrap();
assert_eq!(ends.len(), 1, "exactly one end fire");
assert!(ends[0].contains("User: hello"));
assert!(ends[0].contains("Assistant: hi back"));
}
#[test]
fn maybe_fire_session_end_fires_even_on_empty_sessions() {
let (agent, provider) = agent_with_recording_provider();
let session = make_session();
maybe_fire_session_end(&agent, &session);
let ends = provider.ends.lock().unwrap();
assert_eq!(
ends.len(),
1,
"empty session must still fire the hook (dirge-2t18)"
);
assert!(
ends[0].is_empty(),
"transcript for an empty session must be empty: {:?}",
ends[0]
);
}
#[test]
fn maybe_fire_session_switch_propagates_ids_and_reset_flag() {
#[derive(Default)]
struct RecordingSwitchProvider {
switches: Mutex<Vec<(String, String, bool)>>,
}
impl MemoryProvider for RecordingSwitchProvider {
fn name(&self) -> &str {
"recording-switch"
}
fn view(&self, _: &str) -> serde_json::Value {
serde_json::Value::Null
}
fn add(
&self,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn replace(
&self,
_: &str,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn remove(&self, _: &str, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn on_session_switch(&self, new_id: &str, parent_id: &str, reset: bool) {
self.switches
.lock()
.unwrap()
.push((new_id.into(), parent_id.into(), reset));
}
}
use rig::client::CompletionClient;
use rig::providers::openai;
let client = openai::CompletionsClient::builder()
.api_key("test-key")
.build()
.unwrap();
let model = client.completion_model("gpt-4o");
let inner_agent = rig::agent::AgentBuilder::new(model).build();
let provider = Arc::new(RecordingSwitchProvider::default());
let provider_dyn: Arc<dyn MemoryProvider> = provider.clone();
let agent = AnyAgent::new(
AnyAgentInner::OpenAI(inner_agent),
ToolCache::new(),
std::time::Duration::from_secs(300),
Vec::new(),
String::new(),
"gpt-4o".to_string(),
)
.with_memory_provider(provider_dyn);
maybe_fire_session_switch(&agent, "new-id", "parent-id", false);
let switches = provider.switches.lock().unwrap();
assert_eq!(switches.len(), 1);
assert_eq!(
switches[0],
("new-id".into(), "parent-id".into(), false),
"ids + reset flag must propagate verbatim"
);
}
#[test]
fn maybe_fire_session_switch_noop_without_provider() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client = openai::CompletionsClient::builder()
.api_key("test-key")
.build()
.unwrap();
let model = client.completion_model("gpt-4o");
let inner_agent = rig::agent::AgentBuilder::new(model).build();
let agent = AnyAgent::new(
AnyAgentInner::OpenAI(inner_agent),
ToolCache::new(),
std::time::Duration::from_secs(300),
Vec::new(),
String::new(),
"gpt-4o".to_string(),
);
maybe_fire_session_switch(&agent, "n", "p", false);
}
#[test]
fn maybe_fire_session_end_noop_when_no_provider_attached() {
use rig::client::CompletionClient;
use rig::providers::openai;
let client = openai::CompletionsClient::builder()
.api_key("test-key")
.build()
.unwrap();
let model = client.completion_model("gpt-4o");
let inner_agent = rig::agent::AgentBuilder::new(model).build();
let agent = AnyAgent::new(
AnyAgentInner::OpenAI(inner_agent),
ToolCache::new(),
std::time::Duration::from_secs(300),
Vec::new(),
String::new(),
"gpt-4o".to_string(),
);
let mut session = make_session();
session.add_message(MessageRole::User, "hi");
maybe_fire_session_end(&agent, &session);
}
#[test]
fn transcript_includes_user_and_assistant() {
let mut s = make_session();
s.add_message(MessageRole::User, "how do I build this?");
s.add_message(MessageRole::Assistant, "Run cargo build");
let t = build_transcript(&s);
assert!(t.contains("User: how do I build this?"));
assert!(t.contains("Assistant: Run cargo build"));
}
#[test]
fn transcript_includes_tool_calls_and_results() {
let mut s = make_session();
s.add_message(MessageRole::User, "read the file");
let tc = ToolCallEntry {
id: "call-1".to_string(),
name: "read".to_string(),
args: serde_json::json!({"path": "/tmp/x"}),
state: ToolCallState::Completed {
result: "file contents here".to_string(),
},
};
s.add_message_with_tool_calls(MessageRole::Assistant, "Let me read that.", vec![tc]);
let t = build_transcript(&s);
assert!(t.contains("[Tool: read("));
assert!(t.contains("[Result: file contents here]"));
}
#[test]
fn transcript_truncates_large_tool_results() {
let mut s = make_session();
let big = "x".repeat(3000);
let tc = ToolCallEntry {
id: "c1".to_string(),
name: "bash".to_string(),
args: serde_json::json!({"cmd": "cat big.txt"}),
state: ToolCallState::Completed {
result: big.clone(),
},
};
s.add_message_with_tool_calls(MessageRole::Assistant, "", vec![tc]);
let t = build_transcript(&s);
assert!(t.contains("truncated"));
assert!(!t.contains(&big));
}
#[test]
fn transcript_includes_system_messages() {
let mut s = make_session();
s.add_message(
MessageRole::System,
"compaction summary: previous work on auth module",
);
s.add_message(MessageRole::User, "continue");
let t = build_transcript(&s);
assert!(t.contains("[System: compaction summary"));
assert!(t.contains("User: continue"));
}
#[test]
fn transcript_handles_interrupted_tool() {
let mut s = make_session();
let tc = ToolCallEntry {
id: "ci".to_string(),
name: "bash".to_string(),
args: serde_json::json!({}),
state: ToolCallState::Interrupted,
};
s.add_message_with_tool_calls(MessageRole::Assistant, "", vec![tc]);
let t = build_transcript(&s);
assert!(t.contains("<interrupted>"));
}
#[test]
fn review_prompt_contains_required_sections() {
assert!(COMBINED_REVIEW_PROMPT.contains("Preference order"));
assert!(COMBINED_REVIEW_PROMPT.contains("Do NOT capture"));
assert!(COMBINED_REVIEW_PROMPT.contains("Signals that warrant"));
assert!(COMBINED_REVIEW_PROMPT.contains("Environment-dependent"));
assert!(COMBINED_REVIEW_PROMPT.contains("CLASS-LEVEL skills"));
assert!(COMBINED_REVIEW_PROMPT.contains("Nothing to save"));
}
#[test]
fn review_prompt_override_is_accepted() {
let custom = "Custom review prompt";
assert_ne!(custom, COMBINED_REVIEW_PROMPT);
}
static LAST_REVIEW_LOCK: Mutex<()> = Mutex::new(());
fn reset_last_review() {
LAST_REVIEW.store(0, Ordering::Release);
}
#[test]
fn claim_review_slot_succeeds_when_unset() {
let _g = LAST_REVIEW_LOCK.lock().unwrap();
reset_last_review();
let now = 10_000;
let claimed = claim_review_slot(now);
assert_eq!(claimed, Some(0), "first call should claim with prev=0");
assert_eq!(
LAST_REVIEW.load(Ordering::Acquire),
now,
"timestamp advanced"
);
}
#[test]
fn claim_review_slot_rejects_inside_rate_limit_window() {
let _g = LAST_REVIEW_LOCK.lock().unwrap();
reset_last_review();
let t1 = 100_000;
assert!(claim_review_slot(t1).is_some(), "first call claims");
let t2 = t1 + (MIN_REVIEW_INTERVAL_SECS - 1);
assert!(
claim_review_slot(t2).is_none(),
"second call within window must be rate-limited"
);
let t3 = t1 + MIN_REVIEW_INTERVAL_SECS + 1;
assert!(
claim_review_slot(t3).is_some(),
"call past the window must succeed"
);
}
#[test]
fn release_review_slot_rolls_back_when_we_are_still_latest() {
let _g = LAST_REVIEW_LOCK.lock().unwrap();
reset_last_review();
let now = 200_000;
let prev = claim_review_slot(now).expect("claim");
assert_eq!(prev, 0);
assert_eq!(LAST_REVIEW.load(Ordering::Acquire), now);
release_review_slot(prev, now);
assert_eq!(
LAST_REVIEW.load(Ordering::Acquire),
prev,
"release rolls timestamp back so retry can run"
);
let retry = claim_review_slot(now + 1);
assert!(retry.is_some(), "retry must claim after rollback");
}
#[test]
fn release_review_slot_does_not_clobber_a_later_review() {
let _g = LAST_REVIEW_LOCK.lock().unwrap();
reset_last_review();
let t1 = 300_000;
let prev = claim_review_slot(t1).expect("first claim");
let t2 = t1 + 10 * MIN_REVIEW_INTERVAL_SECS;
LAST_REVIEW.store(t2, Ordering::Release);
release_review_slot(prev, t1);
assert_eq!(
LAST_REVIEW.load(Ordering::Acquire),
t2,
"stale release must NOT roll back a later review's timestamp"
);
}
#[test]
fn claim_review_slot_is_race_safe_under_concurrent_callers() {
let _g = LAST_REVIEW_LOCK.lock().unwrap();
reset_last_review();
let now = 500_000;
let winners = std::sync::atomic::AtomicUsize::new(0);
std::thread::scope(|s| {
for _ in 0..32 {
s.spawn(|| {
if claim_review_slot(now).is_some() {
winners.fetch_add(1, Ordering::Relaxed);
}
});
}
});
assert_eq!(
winners.load(Ordering::Relaxed),
1,
"exactly one concurrent caller must win the claim"
);
}
#[test]
fn fire_pre_compress_returns_provider_insight_verbatim() {
#[derive(Default)]
struct PreCompressProvider;
impl MemoryProvider for PreCompressProvider {
fn name(&self) -> &str {
"pre-compress"
}
fn view(&self, _: &str) -> serde_json::Value {
serde_json::Value::Null
}
fn add(
&self,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn replace(
&self,
_: &str,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn remove(&self, _: &str, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn on_pre_compress(&self, transcript: &str) -> String {
format!("saw {} chars", transcript.len())
}
}
let provider = PreCompressProvider;
let insight = fire_pre_compress(&provider, "hello world");
assert_eq!(insight, "saw 11 chars");
}
#[test]
fn fire_pre_compress_returns_empty_for_default_impl_provider() {
#[derive(Default)]
struct MinimalProvider;
impl MemoryProvider for MinimalProvider {
fn name(&self) -> &str {
"minimal"
}
fn view(&self, _: &str) -> serde_json::Value {
serde_json::Value::Null
}
fn add(
&self,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn replace(
&self,
_: &str,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn remove(&self, _: &str, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
}
let provider = MinimalProvider;
assert_eq!(fire_pre_compress(&provider, "anything"), "");
}
#[test]
fn fire_memory_write_forwards_action_target_and_payload() {
#[derive(Default)]
struct RecordingWriteProvider {
writes: Mutex<Vec<(String, String, String)>>,
}
impl MemoryProvider for RecordingWriteProvider {
fn name(&self) -> &str {
"recording-write"
}
fn view(&self, _: &str) -> serde_json::Value {
serde_json::Value::Null
}
fn add(
&self,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn replace(
&self,
_: &str,
_: &str,
_: &str,
_kind: Option<&str>,
) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn remove(&self, _: &str, _: &str) -> Result<serde_json::Value, String> {
Ok(serde_json::Value::Null)
}
fn on_memory_write(&self, action: &str, target: &str, payload: &str) {
self.writes
.lock()
.unwrap()
.push((action.into(), target.into(), payload.into()));
}
}
let provider = Arc::new(RecordingWriteProvider::default());
let dyn_provider: &dyn MemoryProvider = provider.as_ref();
fire_memory_write(dyn_provider, "add", "memory", "new fact");
fire_memory_write(dyn_provider, "replace", "pitfalls", "new pitfall");
fire_memory_write(dyn_provider, "remove", "memory", "old fact substring");
let writes = provider.writes.lock().unwrap();
assert_eq!(
*writes,
vec![
("add".into(), "memory".into(), "new fact".into()),
("replace".into(), "pitfalls".into(), "new pitfall".into()),
(
"remove".into(),
"memory".into(),
"old fact substring".into()
),
],
"fire_memory_write must forward (action, target, payload) verbatim in order",
);
}
}