use ort::session::Session;
use ort::value::Tensor;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use tokenizers::Tokenizer;
const PROTOCOL: u32 = 1;
const TOKENIZER_FILE: &str = "tokenizer.json";
const MODEL_FILE: &str = "kompress-fp32.onnx";
#[derive(Debug, Deserialize)]
struct HostRequest {
#[serde(default)]
config: Value,
#[serde(default)]
payload: Value,
}
#[derive(Debug, Serialize)]
struct HostResponse {
decision: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
payload: Option<Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct CompressConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub model_dir: String,
#[serde(default = "default_threshold")]
pub threshold: f32,
#[serde(default = "default_min_tokens")]
pub min_tokens: usize,
#[serde(default = "default_max_tokens")]
pub max_tokens: usize,
#[serde(default = "default_min_savings")]
pub min_savings: f32,
#[serde(default = "default_roles")]
pub roles: Vec<String>,
#[serde(default = "default_skip_structured")]
pub skip_structured: bool,
}
fn default_threshold() -> f32 {
0.7
}
fn default_min_tokens() -> usize {
200
}
fn default_max_tokens() -> usize {
1500
}
fn default_min_savings() -> f32 {
0.10
}
fn default_roles() -> Vec<String> {
vec!["tool".to_string()]
}
fn default_skip_structured() -> bool {
true
}
impl Default for CompressConfig {
fn default() -> Self {
Self {
enabled: false,
model_dir: String::new(),
threshold: default_threshold(),
min_tokens: default_min_tokens(),
max_tokens: default_max_tokens(),
min_savings: default_min_savings(),
roles: default_roles(),
skip_structured: default_skip_structured(),
}
}
}
impl CompressConfig {
fn resolved_model_dir(&self) -> PathBuf {
if !self.model_dir.trim().is_empty() {
return PathBuf::from(shellexpand(&self.model_dir));
}
if let Ok(dir) = std::env::var("KLAVA_COMPRESS_MODEL_DIR")
&& !dir.trim().is_empty()
{
return PathBuf::from(dir);
}
cache_root().join("kompress")
}
}
fn cache_root() -> PathBuf {
if let Ok(dir) = std::env::var("XDG_CACHE_HOME")
&& !dir.trim().is_empty()
{
return PathBuf::from(dir).join("klava");
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".cache").join("klava")
}
fn shellexpand(path: &str) -> String {
if path == "~" {
return std::env::var("HOME").unwrap_or_else(|_| path.to_string());
}
if let Some(rest) = path.strip_prefix("~/")
&& let Ok(home) = std::env::var("HOME")
{
return format!("{home}/{rest}");
}
path.to_string()
}
struct Model {
tokenizer: Tokenizer,
session: Mutex<Session>,
}
impl std::fmt::Debug for Model {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Model").finish_non_exhaustive()
}
}
impl Model {
fn load(dir: &Path) -> Result<Self, String> {
let tokenizer_path = dir.join(TOKENIZER_FILE);
let model_path = dir.join(MODEL_FILE);
let tokenizer = Tokenizer::from_file(&tokenizer_path)
.map_err(|e| format!("cannot load {}: {e}", tokenizer_path.display()))?;
let session = Session::builder()
.map_err(|e| format!("cannot create ONNX session: {e}"))?
.commit_from_file(&model_path)
.map_err(|e| format!("cannot load {}: {e}", model_path.display()))?;
Ok(Self {
tokenizer,
session: Mutex::new(session),
})
}
fn score(&self, text: &str) -> Result<(Vec<usize>, Vec<f32>), String> {
let encoding = self
.tokenizer
.encode(text, false)
.map_err(|e| format!("tokenizer failed: {e}"))?;
let ids: Vec<i64> = encoding.get_ids().iter().map(|&id| id as i64).collect();
let n = ids.len();
if n == 0 {
return Ok((Vec::new(), Vec::new()));
}
let inputs = ort::inputs![
"input_ids" => Tensor::from_array(([1usize, n], ids)).map_err(|e| e.to_string())?,
"attention_mask" => Tensor::from_array(([1usize, n], vec![1i64; n]))
.map_err(|e| e.to_string())?,
];
let mut session = self
.session
.lock()
.map_err(|_| "model session poisoned".to_string())?;
let outputs = session
.run(inputs)
.map_err(|e| format!("inference failed: {e}"))?;
let (_, scores) = outputs["final_scores"]
.try_extract_tensor::<f32>()
.map_err(|e| format!("unexpected model output: {e}"))?;
let offsets: Vec<usize> = encoding
.get_offsets()
.iter()
.flat_map(|&(start, end)| [start, end])
.collect();
Ok((offsets, scores.to_vec()))
}
}
pub struct Compressor {
config: CompressConfig,
model: OnceLock<Option<Model>>,
}
impl std::fmt::Debug for Compressor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Compressor")
.field("config", &self.config)
.finish_non_exhaustive()
}
}
impl Compressor {
pub fn new(config: CompressConfig) -> Self {
Self {
config,
model: OnceLock::new(),
}
}
fn model(&self) -> Option<&Model> {
self.model
.get_or_init(|| match Model::load(&self.config.resolved_model_dir()) {
Ok(model) => Some(model),
Err(e) => {
eprintln!("kompress: model unavailable, requests pass through: {e}");
None
}
})
.as_ref()
}
fn compress_text(&self, model: &Model, text: &str) -> Option<(String, usize, usize)> {
if self.config.skip_structured && looks_structured(text) {
eprintln!("kompress: skipping structured content");
return None;
}
let (offsets, scores) = match model.score(text) {
Ok(scored) => scored,
Err(e) => {
eprintln!("kompress: {e}");
return None;
}
};
let tokens = scores.len();
if offsets.len() != tokens * 2 {
eprintln!(
"kompress: {} score(s) but {} offset pair(s); skipping",
tokens,
offsets.len() / 2
);
return None;
}
if tokens < self.config.min_tokens || tokens > self.config.max_tokens {
eprintln!(
"kompress: skipping {} token message (window {}..={})",
tokens, self.config.min_tokens, self.config.max_tokens
);
return None;
}
let keep: Vec<bool> = scores.iter().map(|s| *s >= self.config.threshold).collect();
let kept = keep.iter().filter(|k| **k).count();
let saved = (tokens - kept) as f32 / tokens as f32;
if saved < self.config.min_savings {
eprintln!(
"kompress: only {:.0}% saved ({} of {} tokens), below {:.0}% floor",
saved * 100.0,
tokens - kept,
tokens,
self.config.min_savings * 100.0
);
return None;
}
let rebuilt = rebuild(text, &offsets, &scores, self.config.threshold);
if rebuilt.len() >= text.len() {
return None;
}
if !meaning_preserved(text, &rebuilt) {
return None;
}
Some((rebuilt, tokens, kept))
}
fn compress_content(&self, model: &Model, content: &mut Value) -> (usize, usize) {
match content {
Value::String(text) => match self.compress_text(model, text) {
Some((next, before, after)) => {
*text = next;
(before, after)
}
None => (0, 0),
},
Value::Array(parts) => {
let mut before = 0;
let mut after = 0;
for part in parts {
if let Some(Value::String(text)) = part.get_mut("text")
&& let Some((next, b, a)) = self.compress_text(model, text)
{
*text = next;
before += b;
after += a;
}
}
(before, after)
}
_ => (0, 0),
}
}
}
fn main() {
let mut raw = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut raw) {
eprintln!("kompress: cannot read request: {e}");
emit_continue();
return;
}
let request: HostRequest = match serde_json::from_str(&raw) {
Ok(request) => request,
Err(e) => {
eprintln!("kompress: invalid request JSON: {e}");
emit_continue();
return;
}
};
let config: CompressConfig = match serde_json::from_value(request.config.clone()) {
Ok(config) => config,
Err(e) => {
eprintln!("kompress: invalid config: {e}");
emit_continue();
return;
}
};
if !config.enabled {
emit_continue();
return;
}
let _ = PROTOCOL;
let compressor = Compressor::new(config);
match compressor.on_request(request.payload) {
Some(payload) => emit(HostResponse {
decision: "replace",
payload: Some(payload),
}),
None => emit_continue(),
}
}
impl Compressor {
fn on_request(&self, payload: Value) -> Option<Value> {
let model = self.model()?;
let messages = payload.get("messages").and_then(Value::as_array)?;
let originals = messages.clone();
let mut payload = payload;
let mut before_total = 0usize;
let mut after_total = 0usize;
let mut touched = 0usize;
if let Some(list) = payload.get_mut("messages").and_then(Value::as_array_mut) {
for (message, original) in list.iter_mut().zip(originals.iter()) {
let role = original.get("role").and_then(Value::as_str).unwrap_or("");
if !self.config.roles.iter().any(|r| r == role) {
continue;
}
let Some(content) = message.get_mut("content") else {
continue;
};
let (before, after) = self.compress_content(model, content);
if before > 0 && after < before {
before_total += before;
after_total += after;
touched += 1;
}
}
}
if touched == 0 || after_total >= before_total {
return None;
}
eprintln!(
"kompress: {touched} message(s), {before_total} -> {after_total} tokens ({:.0}% saved)",
100.0 * (1.0 - after_total as f32 / before_total as f32)
);
Some(payload)
}
}
fn emit(response: HostResponse) {
match serde_json::to_string(&response) {
Ok(text) => {
let mut out = std::io::stdout();
let _ = out.write_all(text.as_bytes());
let _ = out.write_all(b"\n");
let _ = out.flush();
}
Err(e) => eprintln!("kompress: cannot serialize response: {e}"),
}
}
fn emit_continue() {
emit(HostResponse {
decision: "continue",
payload: None,
})
}
fn looks_structured(text: &str) -> bool {
let mut lines = 0usize;
let mut structured = 0usize;
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
lines += 1;
if is_structural_line(line) {
structured += 1;
}
}
lines > 0 && structured as f32 / lines as f32 >= STRUCTURED_LINE_SHARE
}
const STRUCTURED_LINE_SHARE: f32 = 0.30;
fn is_structural_line(line: &str) -> bool {
if line.starts_with("+++") || line.starts_with("---") || line.starts_with("@@") {
return true;
}
if line.starts_with('{')
|| line.starts_with('[')
|| line.starts_with("}]")
|| line.starts_with("}, ")
|| line.ends_with('{')
|| line.ends_with("})")
|| line.ends_with(");")
{
return true;
}
let brackets = line
.chars()
.filter(|c| matches!(c, '{' | '}' | '(' | ')' | '[' | ']' | ';'))
.count();
if line.len() >= 20 && brackets * 10 >= line.len() {
return true;
}
const KEYWORDS: &[&str] = &[
"select", "insert", "update", "delete", "from", "where", "join", "group by", "order by",
"create", "alter", "drop", "fn ", "pub fn", "impl ", "struct ", "enum ", "let ", "const ",
"use ", "mod ", "def ", "class ", "import ", "package ", "func ", "var ", "return ",
"if (", "for (", "while (", "#!/", "$ ", ">>> ",
];
let lower = line.to_ascii_lowercase();
KEYWORDS.iter().any(|k| lower.starts_with(k))
}
const PROTECTED_WORDS: &[&str] = &[
"not",
"no",
"never",
"none",
"nothing",
"nobody",
"neither",
"nor",
"without",
"except",
"unless",
"only",
"must",
"mustn't",
"should",
"shouldn't",
"cannot",
"can't",
"don't",
"doesn't",
"didn't",
"isn't",
"aren't",
"won't",
"wouldn't",
"couldn't",
"may",
"might", "all",
"any",
"each",
"every",
"some",
"most",
"always",
"first",
"last",
"warning",
"warn",
"error",
"err",
"fatal",
"urgent",
"critical",
"important",
"caution",
"danger",
"dangerous",
"unsafe",
"deprecated",
"required",
"require",
"requires",
"missing",
"invalid",
"failed",
"failure",
"abort",
"aborts",
"denied",
"refused",
"forbidden",
"prohibited",
"mandatory",
];
fn meaning_preserved(original: &str, compressed: &str) -> bool {
let kept: Vec<&str> = compressed.split_whitespace().collect();
let kept_lower: Vec<String> = kept.iter().map(|w| w.to_ascii_lowercase()).collect();
for word in original.split_whitespace() {
let trimmed = word.trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != '-');
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_ascii_lowercase();
if PROTECTED_WORDS.contains(&lower.as_str())
&& !kept_lower.iter().any(|w| w.contains(&lower))
{
eprintln!("kompress: refusing rewrite, dropped protected word '{trimmed}'");
return false;
}
if trimmed.chars().any(|c| c.is_ascii_digit()) && !compressed.contains(trimmed) {
eprintln!("kompress: refusing rewrite, dropped number '{trimmed}'");
return false;
}
let looks_like_identifier = trimmed.len() > 3
&& (trimmed.contains('_')
|| trimmed.contains('-')
|| trimmed.contains('/')
|| trimmed.contains("::")
|| trimmed.contains('.'));
if looks_like_identifier && !compressed.contains(trimmed) {
eprintln!("kompress: refusing rewrite, dropped identifier '{trimmed}'");
return false;
}
let all_caps = trimmed.len() >= 3
&& trimmed.chars().filter(|c| c.is_alphabetic()).count() >= 3
&& trimmed
.chars()
.filter(|c| c.is_alphabetic())
.all(|c| c.is_ascii_uppercase());
if all_caps && !compressed.contains(trimmed) {
eprintln!("kompress: refusing rewrite, dropped marker '{trimmed}'");
return false;
}
}
true
}
fn rebuild(text: &str, offsets: &[usize], scores: &[f32], threshold: f32) -> String {
let bytes = text.as_bytes();
let len = bytes.len();
let mut keep = vec![true; len];
let mut index = 0usize;
while index < len {
if bytes[index].is_ascii_whitespace() {
index += 1;
continue;
}
let start = index;
while index < len && !bytes[index].is_ascii_whitespace() {
index += 1;
}
let end = index;
let mut overlapped = false;
let mut keep_word = false;
for (&[token_start, token_end], &score) in offsets.as_chunks::<2>().0.iter().zip(scores) {
if token_end <= start || token_start >= end || token_start == token_end {
continue;
}
overlapped = true;
if score >= threshold {
keep_word = true;
break;
}
}
if !overlapped || keep_word {
continue;
}
keep[start..end].fill(false);
let mut before = start;
while before > 0 && matches!(bytes[before - 1], b' ' | b'\t') {
before -= 1;
}
let newline_before = before > 0 && bytes[before - 1] == b'\n';
if before < start && !newline_before {
keep[before..start].fill(false);
} else {
let mut after = end;
while after < len && matches!(bytes[after], b' ' | b'\t') {
after += 1;
}
keep[end..after].fill(false);
}
}
let mut out = String::with_capacity(text.len());
for (byte_index, ch) in text.char_indices() {
let ch_end = byte_index + ch.len_utf8();
if keep[byte_index..ch_end].iter().any(|&k| k) {
out.push(ch);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn offsets(pairs: &[(usize, usize)]) -> Vec<usize> {
pairs.iter().flat_map(|&(a, b)| [a, b]).collect()
}
fn word_spans(words: &[&str], keep: &[bool]) -> (String, Vec<usize>, Vec<f32>) {
let mut text = String::new();
let mut offsets = Vec::new();
let mut scores = Vec::new();
for (i, word) in words.iter().enumerate() {
if i > 0 {
text.push(' ');
}
let start = text.len();
text.push_str(word);
offsets.extend([start, text.len()]);
scores.push(if keep[i] { 0.9 } else { 0.1 });
}
(text, offsets, scores)
}
#[test]
fn drops_low_scoring_words_and_keeps_the_rest() {
let (text, offsets, scores) =
word_spans(&["hello", "noise", "world"], &[true, false, true]);
assert_eq!(rebuild(&text, &offsets, &scores, 0.7), "hello world");
}
#[test]
fn keeps_everything_when_all_words_score_high() {
let (text, offsets, scores) = word_spans(&["a", "b"], &[true, true]);
assert_eq!(rebuild(&text, &offsets, &scores, 0.7), "a b");
}
#[test]
fn removes_everything_when_no_word_scores_high() {
let (text, offsets, scores) = word_spans(&["a", "b"], &[false, false]);
assert_eq!(rebuild(&text, &offsets, &scores, 0.7), "");
}
#[test]
fn a_word_survives_if_any_of_its_tokens_scores_high() {
let text = "EIO-7734";
let spans = offsets(&[(0, 2), (2, 3), (3, 8)]);
let scores = vec![0.1, 0.1, 0.95];
assert_eq!(rebuild(text, &spans, &scores, 0.7), "EIO-7734");
}
#[test]
fn removing_a_word_never_concatenates_neighbours() {
let (text, spans, scores) =
word_spans(&["information", "routine", "about"], &[true, false, true]);
assert_eq!(rebuild(&text, &spans, &scores, 0.7), "information about");
}
#[test]
fn removing_the_last_word_leaves_no_trailing_space() {
let (text, spans, scores) = word_spans(&["keep", "drop"], &[true, false]);
assert_eq!(rebuild(&text, &spans, &scores, 0.7), "keep");
}
#[test]
fn removing_the_first_word_leaves_no_leading_space() {
let (text, spans, scores) = word_spans(&["drop", "keep"], &[false, true]);
assert_eq!(rebuild(&text, &spans, &scores, 0.7), "keep");
}
#[test]
fn newlines_are_preserved_and_never_join_lines() {
let text = "keep drop\nkeep drop";
let spans = offsets(&[(0, 4), (5, 9), (10, 14), (15, 19)]);
let scores = vec![0.9, 0.1, 0.9, 0.1];
assert_eq!(rebuild(text, &spans, &scores, 0.7), "keep\nkeep");
}
#[test]
fn indentation_is_preserved() {
let text = " indented drop";
let spans = offsets(&[(0, 12), (13, 17)]);
let scores = vec![0.9, 0.1];
assert_eq!(rebuild(text, &spans, &scores, 0.7), " indented");
}
#[test]
fn rebuild_never_splits_a_multibyte_character() {
let text = "héllo wörld";
let spans = offsets(&[(0, 6), (7, 13)]);
let scores = vec![0.9, 0.9];
let out = rebuild(text, &spans, &scores, 0.7);
assert_eq!(out, "héllo wörld");
assert!(out.is_char_boundary(out.len()));
}
#[test]
fn rebuild_survives_out_of_range_offsets() {
let text = "ok";
let spans = offsets(&[(0, 99)]);
assert_eq!(rebuild(text, &spans, &[0.9], 0.7), "ok");
}
#[test]
fn rebuild_ignores_zero_width_spans() {
let text = "abc";
let spans = offsets(&[(0, 0), (0, 3)]);
assert_eq!(rebuild(text, &spans, &[0.1, 0.9], 0.7), "abc");
}
#[test]
fn words_with_no_token_are_kept() {
let text = "alpha beta";
let spans = offsets(&[(0, 5)]);
let scores = vec![0.1];
assert_eq!(rebuild(text, &spans, &scores, 0.7), "beta");
}
#[test]
fn sql_is_treated_as_structured() {
assert!(looks_structured(
"SELECT * FROM users WHERE id = 42 AND active = true;"
));
}
#[test]
fn rust_code_is_treated_as_structured() {
let code =
"fn parse(input: &str) -> Result<Vec<Token>, ParseError> {\n let x = todo!();\n}\n";
assert!(looks_structured(code));
}
#[test]
fn json_lines_are_treated_as_structured() {
assert!(looks_structured(
"{\"id\":1,\"name\":\"a\"}\n{\"id\":2,\"name\":\"b\"}"
));
}
#[test]
fn diffs_are_treated_as_structured() {
assert!(looks_structured(
"--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new"
));
}
#[test]
fn ordinary_logs_are_not_structured() {
let logs = (0..20)
.map(|i| {
format!(
"2026-10-18T12:00:{:02}Z INFO req={} status=200 ms={}",
i % 60,
i,
20 + i
)
})
.collect::<Vec<_>>()
.join("\n");
assert!(!looks_structured(&logs));
}
#[test]
fn prose_is_not_structured() {
assert!(!looks_structured(
"The quarterly report shows growth. We examined the data carefully."
));
}
#[test]
fn a_single_code_block_does_not_disqualify_a_log() {
let mut text = (0..9)
.map(|i| format!("2026-10-18T12:00:{:02}Z INFO req={} ok", i, i))
.collect::<Vec<_>>()
.join("\n");
text.push_str("\nfn main() {}");
assert!(!looks_structured(&text));
}
#[test]
fn guard_rejects_dropping_a_negation() {
assert!(!meaning_preserved(
"The migration must never run twice.",
"migration never"
));
}
#[test]
fn guard_rejects_dropping_an_identifier() {
assert!(!meaning_preserved(
"Configuration is missing the required field api_url, so startup aborts.",
"Configuration api_url,"
));
}
#[test]
fn guard_rejects_dropping_a_number() {
assert!(!meaning_preserved(
"Retry after 3 seconds.",
"Retry after seconds."
));
}
#[test]
fn guard_rejects_dropping_an_all_caps_marker() {
assert!(!meaning_preserved("URGENT: disk full", "disk full"));
}
#[test]
fn guard_rejects_dropping_a_severity_word() {
assert!(!meaning_preserved(
"WARNING: this is deprecated",
"this is deprecated"
));
}
#[test]
fn guard_allows_removing_plain_filler() {
assert!(meaning_preserved(
"routine background maintenance line describing routine events",
"routine background maintenance line"
));
}
#[test]
fn guard_rejects_filler_containing_a_negation() {
assert!(!meaning_preserved(
"routine background maintenance line with no new information",
"routine background maintenance line new information"
));
}
#[test]
fn guard_keeps_codes_and_numbers_intact() {
let text = "2026-10-18T12:00:00Z INFO req=17 status=200 ms=21 path=/api/v1/items/a";
let spans = offsets(&[(0, text.len())]);
let scores = vec![0.9];
let out = rebuild(text, &spans, &scores, 0.7);
assert!(meaning_preserved(text, &out), "out: {out}");
}
#[test]
fn guard_rejects_a_log_reduction_that_drops_values() {
let original = "2026-10-18T12:00:00Z INFO req=1 status=200";
let compressed = original.replace("status=200", "");
assert!(!meaning_preserved(original, &compressed));
}
#[test]
fn guard_allows_a_log_reduction_that_keeps_values() {
let original = "INFO INFO INFO req=1 status=200";
let compressed = "INFO req=1 status=200";
assert!(meaning_preserved(original, compressed));
}
#[test]
fn config_defaults_are_conservative() {
let cfg = CompressConfig::default();
assert!(!cfg.enabled, "expensive model must be opt-in");
assert_eq!(cfg.roles, vec!["tool"]);
assert_eq!(cfg.threshold, 0.7);
assert!(cfg.min_tokens < cfg.max_tokens);
}
#[test]
fn config_parses_partial_toml_table() {
let value = json!({"enabled": true, "threshold": 0.5, "roles": ["tool", "user"]});
let cfg: CompressConfig = serde_json::from_value(value).unwrap();
assert!(cfg.enabled);
assert_eq!(cfg.threshold, 0.5);
assert_eq!(cfg.roles, vec!["tool", "user"]);
assert_eq!(cfg.max_tokens, 1500);
}
#[test]
fn model_dir_prefers_explicit_config() {
let cfg = CompressConfig {
model_dir: "/tmp/explicit-kompress".into(),
..CompressConfig::default()
};
assert_eq!(
cfg.resolved_model_dir(),
PathBuf::from("/tmp/explicit-kompress")
);
}
#[test]
fn shellexpand_only_touches_a_leading_tilde() {
assert_eq!(shellexpand("/abs/~keep"), "/abs/~keep");
assert_eq!(shellexpand("relative/path"), "relative/path");
assert_eq!(shellexpand("~"), std::env::var("HOME").unwrap());
}
#[test]
fn continue_response_has_no_payload() {
let text = serde_json::to_string(&HostResponse {
decision: "continue",
payload: None,
})
.unwrap();
assert_eq!(text, r#"{"decision":"continue"}"#);
}
#[test]
fn replace_response_carries_the_payload() {
let text = serde_json::to_string(&HostResponse {
decision: "replace",
payload: Some(json!({"messages": []})),
})
.unwrap();
assert!(text.contains(r#""decision":"replace""#));
assert!(text.contains(r#""payload""#));
}
#[test]
fn a_host_request_parses_with_missing_fields() {
let req: HostRequest = serde_json::from_str("{}").unwrap();
assert!(req.config.is_null());
assert!(req.payload.is_null());
}
#[test]
fn a_host_request_parses_the_real_shape() {
let req: HostRequest = serde_json::from_str(
r#"{"protocol":1,"config":{"enabled":true},"payload":{"messages":[]}}"#,
)
.unwrap();
assert_eq!(req.config["enabled"], true);
assert!(req.payload["messages"].is_array());
}
}