use anyhow::{anyhow, bail, Context, Result};
#[cfg(test)]
use cv_core::scan::{ceil_char, floor_char};
use serde_json::{json, Value};
use std::time::Duration;
#[derive(Debug, Clone, Default)]
pub(crate) struct DigestOptions {
pub model: Option<String>,
pub project: bool,
pub context_bytes: Option<usize>,
}
#[derive(Debug, Clone)]
pub(crate) struct Digest {
pub text: String,
pub sent: usize,
pub transcript: usize,
pub limit: TranscriptLimit,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TranscriptLimit {
pub bytes: usize,
pub source: String,
}
impl std::fmt::Display for TranscriptLimit {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} bytes, from {}", self.bytes, self.source)
}
}
const DIGEST_MAX_TOKENS: usize = 4096;
const SYSTEM_PROMPT: &str = "You are distilling an AI coding-agent session into durable project \
memory. Extract only what's worth remembering weeks later: decisions + rationale, gotchas/bugs and \
their fixes, where key things live (files/functions), conventions, and unresolved threads. Output \
terse Markdown suitable for a MEMORY.md. No fluff, no recap of the whole conversation.";
pub(crate) fn available_provider() -> Option<&'static str> {
provider_from_env().map(|p| p.name())
}
pub(crate) fn digest(session: &cv_core::Session, opts: &DigestOptions) -> Result<Digest> {
let provider = provider_from_env().ok_or_else(no_provider)?;
let model = opts
.model
.clone()
.unwrap_or_else(|| provider.default_model().to_string());
let limit = match opts.context_bytes {
Some(bytes) => TranscriptLimit {
bytes,
source: "--context-bytes".into(),
},
None => {
let window = context_window(&provider, &model)?;
let overhead = SYSTEM_PROMPT.len() + frame(opts, "", true).len();
let bytes = window.checked_sub(DIGEST_MAX_TOKENS + overhead).with_context(|| {
format!("{model}'s {window}-token context leaves no room for a transcript; pass --context-bytes")
})?;
TranscriptLimit {
bytes,
source: format!("{model}'s {window}-token context ({})", provider.name()),
}
}
};
let built = build_prompt(session, opts, limit.bytes)?;
let text = call_model(&provider, &model, &built.prompt)?;
Ok(Digest {
text,
sent: built.sent,
transcript: built.transcript,
limit,
})
}
fn context_window(provider: &Provider, model: &str) -> Result<usize> {
let agent = agent(Duration::from_secs(30));
let ask = |what: &str| {
format!(
"asking {} for {model}'s context window ({what}); or pass --context-bytes",
provider.name()
)
};
match provider {
Provider::Anthropic { key } => {
let req = agent
.get(&format!("https://api.anthropic.com/v1/models/{model}"))
.set("x-api-key", key)
.set("anthropic-version", "2023-06-01");
let v = read_json(req.call(), "Anthropic").with_context(|| ask("GET /v1/models"))?;
anthropic_window(&v).with_context(|| ask("max_input_tokens"))
}
Provider::OpenRouter { key } => {
let req = openrouter_headers(agent.get("https://openrouter.ai/api/v1/models"), key);
let v = read_json(req.call(), "OpenRouter").with_context(|| ask("GET /api/v1/models"))?;
openrouter_window(&v, model).with_context(|| ask("context_length"))
}
Provider::LmStudio { base } => {
let root = base.strip_suffix("/v1").unwrap_or(base);
let v = read_json(agent.get(&format!("{root}/api/v0/models")).call(), "LM Studio")
.with_context(|| ask(&format!("GET {root}/api/v0/models")))?;
lmstudio_window(&v, model).with_context(|| ask("max_context_length"))
}
}
}
fn token_count(v: &Value) -> Option<usize> {
v.as_u64().and_then(|n| usize::try_from(n).ok())
}
fn anthropic_window(v: &Value) -> Result<usize> {
token_count(&v["max_input_tokens"]).context("the model object has no max_input_tokens")
}
fn openrouter_window(v: &Value, model: &str) -> Result<usize> {
let entry = v["data"]
.as_array()
.and_then(|models| models.iter().find(|m| m["id"].as_str() == Some(model)))
.with_context(|| format!("{model} is not in the model list"))?;
token_count(&entry["context_length"]).context("the model entry has no context_length")
}
fn lmstudio_window(v: &Value, model: &str) -> Result<usize> {
let models = v["data"].as_array().context("no data array")?;
let loaded: Vec<&Value> = models
.iter()
.filter(|m| m["state"].as_str() == Some("loaded"))
.collect();
let entry = match models.iter().find(|m| m["id"].as_str() == Some(model)) {
Some(entry) => entry,
None => match loaded.as_slice() {
[only] if model == "local-model" => *only,
_ => bail!("{model} is not one model the server lists"),
},
};
token_count(&entry["loaded_context_length"])
.or_else(|| token_count(&entry["max_context_length"]))
.context("the model entry has no context length")
}
enum Provider {
OpenRouter {
key: String,
},
Anthropic {
key: String,
},
LmStudio {
base: String,
},
}
impl Provider {
fn name(&self) -> &'static str {
match self {
Provider::OpenRouter { .. } => "openrouter",
Provider::Anthropic { .. } => "anthropic",
Provider::LmStudio { .. } => "lmstudio",
}
}
fn default_model(&self) -> &'static str {
match self {
Provider::OpenRouter { .. } => "anthropic/claude-3.5-haiku",
Provider::Anthropic { .. } => "claude-3-5-haiku-latest",
Provider::LmStudio { .. } => "local-model",
}
}
}
fn egress_notice(provider: &Provider, model: &str, approx_bytes: usize) {
let kb = approx_bytes.div_ceil(1024);
match provider {
Provider::LmStudio { base } => {
eprintln!(
"✦ sending ~{kb} KB of transcript to model {model} at {base} (configured OpenAI-compatible server)"
);
}
_ => {
eprintln!(
"✦ sending ~{kb} KB of transcript to {}/{model} — leaves this machine; \
transcripts can contain secrets (set LMSTUDIO_API_BASE for local/offline)",
provider.name()
);
}
}
}
fn provider_from_env() -> Option<Provider> {
if let Ok(base) = cv_core::env::var("LMSTUDIO_API_BASE") {
let base = base.trim();
if !base.is_empty() {
let base = if matches!(base, "1" | "true" | "default" | "local") {
"http://localhost:1234/v1".to_string()
} else {
base.trim_end_matches('/').to_string()
};
return Some(Provider::LmStudio { base });
}
}
if let Ok(key) = cv_core::env::var("OPENROUTER_API_KEY") {
if !key.trim().is_empty() {
return Some(Provider::OpenRouter { key });
}
}
if let Ok(key) = cv_core::env::var("ANTHROPIC_API_KEY") {
if !key.trim().is_empty() {
return Some(Provider::Anthropic { key });
}
}
None
}
struct Prompt {
prompt: String,
sent: usize,
transcript: usize,
}
fn build_prompt(session: &cv_core::Session, opts: &DigestOptions, limit: usize) -> Result<Prompt> {
let mut output = TranscriptBudget::new(limit);
cv_core::render::write_markdown(session, &mut output, &session.resolver())?;
let whole = output.total;
let (transcript, truncated) = output.finish()?;
Ok(Prompt {
prompt: frame(opts, &transcript, truncated),
sent: transcript.len(),
transcript: whole,
})
}
fn frame(opts: &DigestOptions, transcript: &str, truncated: bool) -> String {
let scope = if opts.project {
"Distill this into durable PROJECT memory (it may span more than one session)."
} else {
"Distill this single session into durable project memory."
};
let mut prompt = String::new();
prompt.push_str(scope);
prompt.push_str("\n\n");
if truncated {
prompt.push_str(
"NOTE: the transcript was long and has been truncated; the middle was elided and \
replaced with a `[… transcript truncated …]` marker. Distill from what remains.\n\n",
);
}
prompt.push_str("Here is the session transcript:\n\n");
prompt.push_str("<transcript>\n");
prompt.push_str(transcript);
prompt.push_str("\n</transcript>\n");
prompt
}
struct TranscriptBudget {
limit: usize,
head: Vec<u8>,
tail: std::collections::VecDeque<u8>,
truncated: bool,
total: usize,
}
const TRANSCRIPT_MARKER: &str = "\n\n[… transcript truncated …]\n\n";
impl TranscriptBudget {
fn new(limit: usize) -> Self {
Self {
limit,
head: Vec::new(),
tail: std::collections::VecDeque::new(),
truncated: false,
total: 0,
}
}
fn keep_tail(&mut self, bytes: &[u8]) {
let usable = self.limit.saturating_sub(TRANSCRIPT_MARKER.len());
let capacity = usable - usable * 2 / 3;
if bytes.len() >= capacity {
self.tail.clear();
self.tail.extend(bytes[bytes.len() - capacity..].iter().copied());
} else {
let excess = (self.tail.len() + bytes.len()).saturating_sub(capacity);
self.tail.drain(..excess);
self.tail.extend(bytes.iter().copied());
}
}
fn finish(mut self) -> Result<(String, bool)> {
if !self.truncated {
return Ok((String::from_utf8(self.head)?, false));
}
if let Err(error) = std::str::from_utf8(&self.head) {
if error.error_len().is_some() {
bail!("Markdown renderer emitted invalid UTF8");
}
self.head.truncate(error.valid_up_to());
}
let start = self
.tail
.iter()
.position(|byte| byte & 0xc0 != 0x80)
.unwrap_or(self.tail.len());
let mut output = String::from_utf8(self.head)?;
output.push_str(TRANSCRIPT_MARKER);
output.push_str(std::str::from_utf8(&self.tail.make_contiguous()[start..])?);
Ok((output, true))
}
}
impl std::io::Write for TranscriptBudget {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.total += bytes.len();
if !self.truncated && bytes.len() <= self.limit.saturating_sub(self.head.len()) {
self.head.extend_from_slice(bytes);
} else {
if !self.truncated {
self.truncated = true;
let saved = std::mem::take(&mut self.head);
self.keep_tail(&saved);
let head_limit = self.limit.saturating_sub(TRANSCRIPT_MARKER.len()) * 2 / 3;
self.head.extend_from_slice(&saved[..saved.len().min(head_limit)]);
let remaining = head_limit.saturating_sub(self.head.len());
self.head.extend_from_slice(&bytes[..bytes.len().min(remaining)]);
}
self.keep_tail(bytes);
}
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[cfg(test)]
fn budget(s: &str, budget: usize) -> (String, bool) {
if s.len() <= budget {
return (s.to_string(), false);
}
const MARKER: &str = TRANSCRIPT_MARKER;
let usable = budget.saturating_sub(MARKER.len());
let head_len = usable * 2 / 3;
let tail_len = usable - head_len;
let head_end = floor_char(s, head_len);
let tail_start = ceil_char(s, s.len().saturating_sub(tail_len));
let mut out = String::with_capacity(budget);
out.push_str(&s[..head_end]);
out.push_str(MARKER);
out.push_str(&s[tail_start..]);
(out, true)
}
fn agent(timeout: Duration) -> ureq::Agent {
ureq::AgentBuilder::new().timeout(timeout).build()
}
fn openrouter_headers(req: ureq::Request, key: &str) -> ureq::Request {
req.set("Authorization", &format!("Bearer {key}"))
.set("HTTP-Referer", "https://github.com/emberian/cv")
.set("X-Title", "clustervision")
}
fn call_model(provider: &Provider, model: &str, prompt: &str) -> Result<String> {
egress_notice(provider, model, prompt.len());
let agent = agent(Duration::from_secs(120));
match provider {
Provider::OpenRouter { key } => {
let body = json!({
"model": model,
"max_tokens": DIGEST_MAX_TOKENS,
"messages": [
{ "role": "system", "content": SYSTEM_PROMPT },
{ "role": "user", "content": prompt },
],
});
let req = openrouter_headers(agent.post("https://openrouter.ai/api/v1/chat/completions"), key);
let v = read_json(req.send_json(body), "OpenRouter")?;
extract_openai_text(&v, "OpenRouter")
}
Provider::Anthropic { key } => {
let body = json!({
"model": model,
"max_tokens": DIGEST_MAX_TOKENS,
"system": SYSTEM_PROMPT,
"messages": [
{ "role": "user", "content": prompt },
],
});
let req = agent
.post("https://api.anthropic.com/v1/messages")
.set("x-api-key", key)
.set("anthropic-version", "2023-06-01");
let v = read_json(req.send_json(body), "Anthropic")?;
extract_anthropic_text(&v)
}
Provider::LmStudio { base } => {
let body = json!({
"model": model,
"max_tokens": DIGEST_MAX_TOKENS,
"messages": [
{ "role": "system", "content": SYSTEM_PROMPT },
{ "role": "user", "content": prompt },
],
});
let v = read_json(
agent.post(&format!("{base}/chat/completions")).send_json(body),
"LM Studio",
)
.with_context(|| format!("POST to LM Studio at {base} (is the local server running?)"))?;
extract_openai_text(&v, "LM Studio")
}
}
}
fn no_provider() -> anyhow::Error {
anyhow!(
"no LLM provider: set OPENROUTER_API_KEY, ANTHROPIC_API_KEY, or LMSTUDIO_API_BASE \
(=local for a free local LM Studio server)"
)
}
fn extract_openai_text(v: &Value, who: &str) -> Result<String> {
v["choices"][0]["message"]["content"]
.as_str()
.filter(|text| !text.is_empty())
.map(str::to_string)
.ok_or_else(|| anyhow!("{who} response missing message content: {v}"))
}
fn extract_anthropic_text(v: &Value) -> Result<String> {
let text: String = v["content"]
.as_array()
.map(|blocks| {
blocks
.iter()
.filter_map(|b| b["text"].as_str())
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default();
if text.is_empty() {
bail!("Anthropic response missing text content: {v}");
}
Ok(text)
}
fn read_json(resp: Result<ureq::Response, ureq::Error>, who: &str) -> Result<Value> {
let text = match resp {
Ok(r) => r
.into_string()
.with_context(|| format!("reading {who} response body"))?,
Err(ureq::Error::Status(code, r)) => {
let text = r.into_string().unwrap_or_default();
bail!("{who} API returned {code}: {text}");
}
Err(e) => return Err(e).with_context(|| format!("POST to {who}")),
};
serde_json::from_str(&text).with_context(|| format!("parsing {who} JSON response: {text}"))
}
#[derive(Debug, Clone, Default)]
pub(crate) struct GenerateOptions {
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub system: Option<String>,
}
pub(crate) fn generate(session: &cv_core::Session, opts: &GenerateOptions) -> Result<String> {
let provider = provider_from_env().ok_or_else(no_provider)?;
let model = opts
.model
.clone()
.unwrap_or_else(|| provider.default_model().to_string());
let messages = to_chat_messages(session)?;
if messages.is_empty() {
bail!("nothing to continue from: the session has no renderable messages");
}
call_chat(
&provider,
&model,
opts.system.as_deref(),
&messages,
opts.max_tokens.unwrap_or(4096),
)
}
#[derive(Clone, Debug, PartialEq, serde::Serialize)]
struct ChatMessage {
role: String,
content: String,
}
impl ChatMessage {
fn new(role: &str, content: &str) -> Self {
Self {
role: role.to_owned(),
content: content.to_owned(),
}
}
}
fn to_chat_messages(session: &cv_core::Session) -> Result<Vec<ChatMessage>> {
use cv_core::ir::{Block, Role};
let reads = session.source_reads();
let resolver = session.resolver();
let mut out = Vec::new();
for m in &session.messages {
let role = match m.role {
Role::System => "system",
Role::Assistant => "assistant",
Role::User | Role::Tool => "user",
};
let mut text = String::new();
for b in &m.content {
match b {
Block::Text { text: t } | Block::Thinking { text: t, .. } => {
t.try_for_each_chunk(&resolver, 64 * 1024, |part| {
text.push_str(part);
Ok(())
})?;
text.push('\n');
}
Block::ToolUse { name, input, .. } => {
text.push_str(&format!("[tool call: {name} "));
cv_core::projection::append_json(&mut text, input)?;
text.push_str("]\n");
}
Block::ToolResult { content, .. } => {
text.push_str("[tool result] ");
content.try_for_each_chunk(&resolver, 64 * 1024, |part| {
text.push_str(part);
Ok(())
})?;
text.push('\n');
}
Block::File { .. } | Block::Image { .. } => {}
}
}
let text = text.trim();
if text.is_empty() {
continue;
}
out.push(ChatMessage::new(role, text));
}
anyhow::ensure!(
reads.fresh(&mut cv_core::source::Probe::default()),
"session sources changed during chat projection"
);
Ok(out)
}
fn call_chat(
provider: &Provider,
model: &str,
system: Option<&str>,
messages: &[ChatMessage],
max_tokens: u32,
) -> Result<String> {
let approx: usize = messages.iter().map(|m| m.content.len()).sum();
egress_notice(provider, model, approx);
let agent = agent(Duration::from_secs(180));
let openai_call = |who: &str, url: String, bearer: Option<&str>| -> Result<String> {
let mut msgs: Vec<ChatMessage> = Vec::new();
if let Some(s) = system {
if !s.is_empty() {
msgs.push(ChatMessage::new("system", s));
}
}
msgs.extend(messages.iter().cloned());
let body = json!({ "model": model, "messages": msgs, "max_tokens": max_tokens });
let mut req = agent.post(&url);
if let Some(b) = bearer {
req = openrouter_headers(req, b);
}
let v = read_json(req.send_json(body), "chat").with_context(|| format!("POST to {url}"))?;
extract_openai_text(&v, who)
};
match provider {
Provider::OpenRouter { key } => openai_call(
"OpenRouter",
"https://openrouter.ai/api/v1/chat/completions".into(),
Some(key),
),
Provider::LmStudio { base } => openai_call("LM Studio", format!("{base}/chat/completions"), None),
Provider::Anthropic { key } => {
let (sys, msgs) = normalize_for_anthropic(system, messages)?;
let mut body = json!({ "model": model, "max_tokens": max_tokens, "messages": msgs });
if let Some(sys) = sys {
body["system"] = json!(sys);
}
let req = agent
.post("https://api.anthropic.com/v1/messages")
.set("x-api-key", key)
.set("anthropic-version", "2023-06-01");
let v = read_json(req.send_json(body), "Anthropic")?;
extract_anthropic_text(&v)
}
}
}
fn normalize_for_anthropic(
system: Option<&str>,
messages: &[ChatMessage],
) -> Result<(Option<String>, Vec<ChatMessage>)> {
let mut sys = system.unwrap_or("").to_string();
let mut msgs: Vec<ChatMessage> = Vec::new();
for m in messages {
let role = m.role.as_str();
if role == "system" {
if !sys.is_empty() {
sys.push('\n');
}
sys.push_str(&m.content);
continue;
}
let role = if role == "assistant" { "assistant" } else { "user" };
let content = &m.content;
match msgs.last_mut() {
Some(prev) if prev.role == role => {
prev.content.push('\n');
prev.content.push_str(content);
}
_ => msgs.push(ChatMessage::new(role, content)),
}
}
if msgs.first().is_some_and(|m| m.role == "assistant") {
msgs.insert(0, ChatMessage::new("user", "(continue the transcript below)"));
}
if msgs.is_empty() {
bail!("nothing to send to Anthropic after normalizing messages");
}
Ok(((!sys.is_empty()).then_some(sys), msgs))
}
#[cfg(test)]
mod tests {
use super::*;
use cv_core::ir::{Block, Harness, Message, Role, Session};
const TEST_LIMIT: usize = 60_000;
#[test]
fn context_windows_read_each_providers_own_field() {
let anthropic = json!({"id": "claude-x", "max_input_tokens": 200_000, "max_tokens": 64_000});
assert_eq!(anthropic_window(&anthropic).unwrap(), 200_000);
assert!(anthropic_window(&json!({"id": "claude-x"})).is_err());
let openrouter = json!({"data": [
{"id": "a/one", "context_length": 8192},
{"id": "a/two", "context_length": 131_072},
]});
assert_eq!(openrouter_window(&openrouter, "a/two").unwrap(), 131_072);
assert!(openrouter_window(&openrouter, "a/three").is_err());
let lmstudio = json!({"data": [
{"id": "big", "state": "not-loaded", "max_context_length": 131_072},
{"id": "small", "state": "loaded", "max_context_length": 32_768, "loaded_context_length": 4096},
]});
assert_eq!(lmstudio_window(&lmstudio, "big").unwrap(), 131_072);
assert_eq!(lmstudio_window(&lmstudio, "small").unwrap(), 4096);
assert_eq!(lmstudio_window(&lmstudio, "local-model").unwrap(), 4096);
assert!(lmstudio_window(&lmstudio, "absent").is_err());
}
#[test]
fn a_prompt_under_the_limit_reports_the_whole_transcript_sent() {
let s = sample_session();
let built = build_prompt(&s, &DigestOptions::default(), TEST_LIMIT).unwrap();
assert_eq!(built.sent, built.transcript);
let cut = build_prompt(&s, &DigestOptions::default(), 64).unwrap();
assert_eq!(cut.transcript, built.transcript);
assert!(cut.sent < cut.transcript && cut.prompt.contains("transcript truncated"));
}
fn msg(role: Role, text: &str) -> Message {
let mut m = Message::new(role);
m.content.push(Block::Text {
text: text.to_string().into(),
});
m
}
fn sample_session() -> Session {
Session {
messages: vec![
msg(Role::User, "How do I fix the build?"),
msg(Role::Assistant, "The bug lives in src/lib.rs; pin reqwest to 0.12."),
],
..Session::new("test-session", Harness::Claude)
}
}
#[test]
fn source_tool_arguments_match_owned_chat_and_reject_changed_or_deleted_source() {
let world = crate::test_world("llm-json-boundary");
let path = world.base.join("arguments.json");
let value = json!({"command": "echo \"月🌙é\"\n".repeat(300), "tail": "complete argument"});
let bytes = serde_json::to_vec(&value).unwrap();
let make_session = |input| {
let mut session = sample_session();
let mut message = Message::new(Role::Assistant);
message.content.push(Block::ToolUse {
id: "call".into(),
name: "Bash".into(),
input,
namespace: None,
});
session.messages.push(message);
session
};
let expected = to_chat_messages(&make_session(value.into())).unwrap();
for deleted in [false, true] {
std::fs::write(&path, &bytes).unwrap();
let session = make_session(cv_core::Json::source(&path, 0, bytes.len() as u64).unwrap());
assert_eq!(to_chat_messages(&session).unwrap(), expected);
if deleted {
std::fs::remove_file(&path).unwrap();
} else {
std::fs::write(&path, b"{} ").unwrap();
}
assert!(
to_chat_messages(&session).is_err(),
"must reject stale arguments before egress"
);
assert!(build_prompt(&session, &DigestOptions::default(), TEST_LIMIT).is_err());
}
}
#[test]
fn chat_generation_reads_checked_text_and_results_without_materializing_session() {
let world = crate::test_world("llm-retained-text");
let path = world.base.join("text.json");
let text = "λ🌙 checked text\n".repeat(1000);
let wire = serde_json::to_vec(&text).unwrap();
std::fs::write(&path, &wire).unwrap();
let source = cv_core::Json::source(&path, 0, wire.len() as u64).unwrap();
let retained = source.with_view(|view| view.source_text()).unwrap().unwrap();
let make = |text: cv_core::Text| {
let mut session = sample_session();
let mut message = Message::new(Role::Tool);
message.content = vec![
Block::Text { text: text.clone() },
Block::Thinking {
text: text.clone(),
signature: None,
encrypted: None,
redacted: false,
},
Block::ToolResult {
tool_use_id: "call".into(),
tool_name: None,
status: None,
is_error: false,
details: None,
content: text,
},
];
session.messages.push(message);
session
};
let expected = to_chat_messages(&make(text.into())).unwrap();
let session = make(retained);
assert_eq!(to_chat_messages(&session).unwrap(), expected);
std::fs::remove_file(&path).unwrap();
assert!(
to_chat_messages(&session).is_err(),
"generation silently omitted stale text"
);
}
#[test]
fn streamed_prompt_budget_matches_whole_render_at_chunk_and_unicode_boundaries() {
use std::io::Write;
for text in ["small".to_string(), "λ🌙\nbody".repeat(20_000)] {
for chunk in [1, 3, 4096, text.len()] {
let mut writer = TranscriptBudget::new(TEST_LIMIT);
for bytes in text.as_bytes().chunks(chunk) {
writer.write_all(bytes).unwrap();
}
assert_eq!(writer.finish().unwrap(), budget(&text, TEST_LIMIT));
}
}
let mut session = sample_session();
session.messages.push(msg(Role::Assistant, &"λ🌙\nbody".repeat(20_000)));
let expected = budget(&cv_core::render::to_markdown(&session).unwrap(), TEST_LIMIT);
let built = build_prompt(&session, &DigestOptions::default(), TEST_LIMIT).unwrap();
let prompt = built.prompt;
assert_eq!(built.transcript, cv_core::render::to_markdown(&session).unwrap().len());
assert_eq!(built.sent, expected.0.len());
assert!(expected.1);
assert!(prompt.contains(&format!("<transcript>\n{}\n</transcript>", expected.0)));
}
#[test]
fn prompt_contains_transcript_and_instructions() {
let s = sample_session();
let opts = DigestOptions::default();
let prompt = build_prompt(&s, &opts, TEST_LIMIT).unwrap().prompt;
assert!(prompt.contains("How do I fix the build?"));
assert!(prompt.contains("src/lib.rs"));
assert!(prompt.contains("durable project memory"));
assert!(prompt.contains("<transcript>"));
assert!(prompt.contains("</transcript>"));
assert!(!prompt.contains("transcript truncated"));
}
#[test]
fn project_scope_changes_framing() {
let s = sample_session();
let opts = DigestOptions {
project: true,
..Default::default()
};
let prompt = build_prompt(&s, &opts, TEST_LIMIT).unwrap().prompt;
assert!(prompt.contains("PROJECT memory"));
}
#[test]
fn budget_truncates_head_and_tail_with_marker() {
let big = format!("HEAD_MARKER{}TAIL_MARKER", "x".repeat(200_000));
let (out, truncated) = budget(&big, TEST_LIMIT);
assert!(truncated);
assert!(out.len() <= TEST_LIMIT + 64);
assert!(out.starts_with("HEAD_MARKER"));
assert!(out.ends_with("TAIL_MARKER"));
assert!(out.contains("transcript truncated"));
}
#[test]
fn budget_passthrough_when_small() {
let (out, truncated) = budget("small", TEST_LIMIT);
assert!(!truncated);
assert_eq!(out, "small");
}
#[test]
fn budget_respects_char_boundaries() {
let big = format!("é{}é", "あ".repeat(100_000));
let (out, truncated) = budget(&big, TEST_LIMIT);
assert!(truncated);
assert!(out.contains("transcript truncated"));
}
#[test]
fn chat_messages_flatten_roles_and_blocks() {
let mut s = sample_session();
let mut sysm = Message::new(Role::System);
sysm.content.push(Block::Text {
text: "be terse".to_string().into(),
});
s.messages.insert(0, sysm);
let mut toolm = Message::new(Role::Tool);
toolm.content.push(Block::ToolResult {
tool_use_id: "t1".into(),
content: "exit 0".to_string().into(),
is_error: false,
tool_name: None,
status: None,
details: None,
});
s.messages.push(toolm);
s.messages.push(Message::new(Role::User));
let mut asst = Message::new(Role::Assistant);
asst.content.push(Block::ToolUse {
id: "t2".into(),
name: "Bash".into(),
input: serde_json::json!({"command": "ls"}).into(),
namespace: None,
});
s.messages.push(asst);
let msgs = to_chat_messages(&s).unwrap();
let roles: Vec<&str> = msgs.iter().map(|m| m.role.as_str()).collect();
assert_eq!(roles, ["system", "user", "assistant", "user", "assistant"]);
assert_eq!(msgs[0].content, "be terse");
assert!(msgs[3].content.contains("[tool result] exit 0"));
assert!(msgs[4].content.contains("[tool call: Bash"));
}
fn m(role: &str, content: &str) -> ChatMessage {
ChatMessage::new(role, content)
}
#[test]
fn anthropic_hoists_system_messages() {
let msgs = [m("system", "rule one"), m("user", "hi"), m("system", "rule two")];
let (sys, out) = normalize_for_anthropic(Some("base"), &msgs).unwrap();
assert_eq!(sys.as_deref(), Some("base\nrule one\nrule two"));
assert_eq!(out.len(), 1);
assert_eq!(out[0], m("user", "hi"));
let (sys, _) = normalize_for_anthropic(None, &[m("user", "hi")]).unwrap();
assert!(sys.is_none());
}
#[test]
fn anthropic_coalesces_same_role_runs() {
let msgs = [
m("user", "a"),
m("user", "b"),
m("assistant", "c"),
m("assistant", "d"),
m("user", "e"),
];
let (_, out) = normalize_for_anthropic(None, &msgs).unwrap();
assert_eq!(
out,
vec![m("user", "a\nb"), m("assistant", "c\nd"), m("user", "e")],
"runs must merge so roles strictly alternate"
);
}
#[test]
fn anthropic_guarantees_leading_user() {
let (_, out) = normalize_for_anthropic(None, &[m("assistant", "previously…")]).unwrap();
assert_eq!(out[0].role, "user", "first message must be user");
assert_eq!(out[1], m("assistant", "previously…"));
assert_eq!(out.len(), 2);
}
#[test]
fn anthropic_normalizes_unknown_roles_to_user_and_rejects_empty() {
let msgs = [m("user", "run it"), m("tool", "exit 0")];
let (_, out) = normalize_for_anthropic(None, &msgs).unwrap();
assert_eq!(out, vec![m("user", "run it\nexit 0")]);
assert!(normalize_for_anthropic(None, &[m("system", "rules")]).is_err());
assert!(normalize_for_anthropic(Some("sys"), &[]).is_err());
}
#[test]
fn the_no_provider_refusal_names_every_variable_provider_from_env_reads() {
let message = no_provider().to_string();
for name in ["OPENROUTER_API_KEY", "ANTHROPIC_API_KEY", "LMSTUDIO_API_BASE"] {
assert!(message.contains(name), "{message}");
}
}
#[test]
fn openai_extraction_happy_and_sad() {
let good = json!({"choices": [{"message": {"role": "assistant", "content": "hi"}}]});
assert_eq!(extract_openai_text(&good, "X").unwrap(), "hi");
for bad in [
json!({}),
json!({"choices": []}),
json!({"choices": [{"message": {}}]}),
json!({"choices": [{"message": {"content": null}}]}),
json!({"choices": [{"message": {"content": ""}, "finish_reason":"content_filter"}]}),
json!({"choices": [{"message": {"content": 42}}]}),
json!({"error": {"message": "rate limited"}}),
] {
let err = extract_openai_text(&bad, "TestProv").unwrap_err().to_string();
assert!(err.contains("TestProv"), "error must name the provider: {err}");
}
}
#[test]
fn anthropic_extraction_joins_text_blocks_and_rejects_empty() {
let good = json!({"content": [
{"type": "text", "text": "part one "},
{"type": "tool_use", "id": "t", "name": "x", "input": {}},
{"type": "text", "text": "part two"},
]});
assert_eq!(extract_anthropic_text(&good).unwrap(), "part one part two");
for bad in [
json!({}),
json!({"content": []}),
json!({"content": [{"type": "tool_use", "id": "t", "name": "x", "input": {}}]}),
json!({"content": [{"type": "text", "text": ""}]}),
] {
assert!(extract_anthropic_text(&bad).is_err(), "should reject: {bad}");
}
}
#[test]
#[ignore]
fn live_digest() {
let s = sample_session();
let out = digest(&s, &DigestOptions::default()).expect("digest failed");
assert!(!out.text.trim().is_empty(), "expected non-empty digest");
eprintln!(
"--- digest ({} of {} bytes, limit {}) ---\n{}",
out.sent, out.transcript, out.limit, out.text
);
}
}