Skip to main content

leviath_runtime/
custom_region.rs

1//! Runtime side of script-backed custom regions (`RegionKind::Custom`).
2//!
3//! The scripting layer ([`leviath_scripting::region_hook`]) compiles and runs
4//! the hooks; this module owns everything on the runtime side of that JSON
5//! boundary: building the `ctx` objects a hook receives, interpreting each
6//! hook's returned value, and every fallback. The contract is that a hook
7//! failure can never fail an inference or lose a write:
8//!
9//! - `render` failure → the region renders as a Temporary-style block
10//!   (`[{name}]:\n…`, `Never` cache hint) and a warning names the script.
11//! - `on_write` failure → the entry is accepted unchanged.
12//! - `on_overflow` failure or invalid indices → oldest-first eviction.
13
14use std::sync::Arc;
15
16use leviath_core::{EntryKind, Region, RegionEntry};
17use leviath_scripting::region_hook::{RegionScript, run_on_overflow, run_on_write, run_render};
18
19/// Stage-level metadata threaded into `render(ctx)`. `Default` (empty name,
20/// zero iterations, empty model) is used by callers with no stage context -
21/// the transition-choice request and plain `assemble()` in tests.
22#[derive(Debug, Clone, Default)]
23pub struct AssembleMeta {
24    /// Current stage name (`AgentState::current_stage`).
25    pub stage_name: String,
26    /// Inference count within the current stage (`StageProgress::iterations`).
27    pub stage_iterations: usize,
28    /// Model id serving this stage.
29    pub model: String,
30}
31
32/// What `on_write` decided about an incoming entry.
33pub(crate) enum OnWriteOutcome {
34    /// Store the entry with this (possibly replaced) content and token count.
35    Accept(String, usize),
36    /// The script declined the entry; report success to the writer.
37    Drop,
38}
39
40/// Serialize one region entry for a hook ctx. Typed metadata crosses as plain
41/// data so a script can key decisions off tool ids/names, but it is read-only:
42/// hooks return instructions (drop indices, replacement text), never entries.
43fn entry_to_json(entry: &RegionEntry) -> serde_json::Value {
44    let mut obj = serde_json::json!({
45        "content": entry.content,
46        "tokens": entry.tokens,
47        "timestamp": entry.timestamp,
48        "key": entry.key,
49    });
50    let (kind, extra) = match &entry.kind {
51        EntryKind::Text => ("text", None),
52        EntryKind::UserMessage => ("user_message", None),
53        EntryKind::AssistantTurn { tool_calls } => (
54            "assistant_turn",
55            Some((
56                "tool_calls",
57                serde_json::to_value(tool_calls).unwrap_or_default(),
58            )),
59        ),
60        EntryKind::ToolResult {
61            tool_call_id,
62            tool_name,
63            is_error,
64        } => {
65            obj["tool_call_id"] = serde_json::json!(tool_call_id);
66            obj["tool_name"] = serde_json::json!(tool_name);
67            obj["is_error"] = serde_json::json!(is_error);
68            ("tool_result", None)
69        }
70    };
71    obj["kind"] = serde_json::json!(kind);
72    if let Some((k, v)) = extra {
73        obj[k] = v;
74    }
75    obj
76}
77
78/// The `region` sub-object shared by all three hook ctx shapes.
79fn region_to_json(region: &Region) -> serde_json::Value {
80    serde_json::json!({
81        "name": region.name,
82        "budget": region.max_tokens,
83        "current_tokens": region.current_tokens,
84        "entry_count": region.content.len(),
85    })
86}
87
88/// The Temporary-style block a custom region falls back to whenever its hook
89/// can't run or misbehaves - identical to the plain-`assemble` arm, so the
90/// region is never silently dropped.
91fn fallback_block(region: &Region) -> leviath_providers::SystemBlock {
92    let text = region
93        .content
94        .iter()
95        .map(|e| e.content.as_str())
96        .collect::<Vec<_>>()
97        .join("\n\n");
98    leviath_providers::SystemBlock {
99        text: format!("[{}]:\n{}", region.name, text),
100        cache_hint: leviath_core::CacheHint::Never,
101    }
102}
103
104/// Render a custom region through its script, appending the results to the
105/// caller's block/message accumulators. Any failure falls back to the
106/// Temporary-style block with a warning; success is followed by a warn-only
107/// token re-check against the region's budget (no truncation - the opt-in
108/// exact-token preflight remains the hard guard).
109#[allow(clippy::too_many_arguments)]
110pub(crate) fn render_custom_region(
111    region: &Region,
112    script: Option<&Arc<RegionScript>>,
113    persistent: bool,
114    meta: &AssembleMeta,
115    window_current: usize,
116    window_max: usize,
117    system_blocks: &mut Vec<leviath_providers::SystemBlock>,
118    messages: &mut Vec<leviath_providers::Message>,
119) {
120    let Some(script) = script else {
121        // No compiled script on the window (plain `assemble()` callers, or a
122        // spawn path that skipped resolution). Same shape as a hook failure.
123        if !region.content.is_empty() {
124            tracing::warn!(
125                region = %region.name,
126                "custom region has no compiled script; rendering fallback block"
127            );
128            system_blocks.push(fallback_block(region));
129        }
130        return;
131    };
132
133    let ctx = serde_json::json!({
134        "region": region_to_json(region),
135        "entries": region.content.iter().map(entry_to_json).collect::<Vec<_>>(),
136        "stage_name": meta.stage_name,
137        "stage_iterations": meta.stage_iterations,
138        "model": meta.model,
139        "window": { "total_tokens": window_current, "max_tokens": window_max },
140    });
141
142    let rendered = match run_render(script, ctx) {
143        Ok(value) => value,
144        Err(e) => {
145            tracing::warn!(
146                region = %region.name,
147                script = %script.path,
148                error = %e,
149                "custom region render failed; using fallback block"
150            );
151            if !region.content.is_empty() {
152                system_blocks.push(fallback_block(region));
153            }
154            return;
155        }
156    };
157
158    match parse_render_output(&rendered, persistent) {
159        Ok((blocks, msgs)) => {
160            let emitted_tokens: usize = blocks
161                .iter()
162                .map(|b| leviath_core::estimate_tokens(&b.text))
163                .chain(msgs.iter().map(|m| {
164                    match &m.content {
165                        leviath_providers::MessageContent::Text(t) => {
166                            leviath_core::estimate_tokens(t)
167                        }
168                        leviath_providers::MessageContent::Blocks(bs) => bs
169                            .iter()
170                            .map(|b| match b {
171                                leviath_providers::ContentBlock::Text { text } => {
172                                    leviath_core::estimate_tokens(text)
173                                }
174                                leviath_providers::ContentBlock::ToolUse { input, .. } => {
175                                    leviath_core::estimate_tokens(&input.to_string())
176                                }
177                                leviath_providers::ContentBlock::ToolResult { content, .. } => {
178                                    leviath_core::estimate_tokens(content)
179                                }
180                            })
181                            .sum(),
182                    }
183                }))
184                .sum();
185            if emitted_tokens > region.max_tokens {
186                tracing::warn!(
187                    region = %region.name,
188                    script = %script.path,
189                    emitted_tokens,
190                    budget = region.max_tokens,
191                    "custom region render exceeds its budget; sending anyway \
192                     (enable exact_token_counting for a hard guard)"
193                );
194            }
195            system_blocks.extend(blocks);
196            messages.extend(msgs);
197        }
198        Err(reason) => {
199            tracing::warn!(
200                region = %region.name,
201                script = %script.path,
202                reason = %reason,
203                "custom region render returned an invalid shape; using fallback block"
204            );
205            if !region.content.is_empty() {
206                system_blocks.push(fallback_block(region));
207            }
208        }
209    }
210}
211
212/// Interpret `render`'s returned value: a string (one system block) or an
213/// object with optional `system` (string or array of strings) and `messages`
214/// (array of message objects). Strict on shape - any surprise is an `Err`,
215/// which the caller turns into the fallback block.
216fn parse_render_output(
217    value: &serde_json::Value,
218    persistent: bool,
219) -> Result<
220    (
221        Vec<leviath_providers::SystemBlock>,
222        Vec<leviath_providers::Message>,
223    ),
224    String,
225> {
226    // A persistent region's rendered output is expected stable → cacheable.
227    let hint = if persistent {
228        leviath_core::CacheHint::Always
229    } else {
230        leviath_core::CacheHint::UntilChanged
231    };
232    let block = |text: &str| leviath_providers::SystemBlock {
233        text: text.to_string(),
234        cache_hint: hint,
235    };
236
237    match value {
238        serde_json::Value::String(s) => {
239            let blocks = if s.is_empty() { vec![] } else { vec![block(s)] };
240            Ok((blocks, vec![]))
241        }
242        serde_json::Value::Object(obj) => {
243            let mut blocks = Vec::new();
244            match obj.get("system") {
245                None | Some(serde_json::Value::Null) => {}
246                Some(serde_json::Value::String(s)) => {
247                    if !s.is_empty() {
248                        blocks.push(block(s));
249                    }
250                }
251                Some(serde_json::Value::Array(items)) => {
252                    for item in items {
253                        match item {
254                            serde_json::Value::String(s) if !s.is_empty() => blocks.push(block(s)),
255                            serde_json::Value::String(_) => {}
256                            other => {
257                                return Err(format!(
258                                    "system array items must be strings, found {other}"
259                                ));
260                            }
261                        }
262                    }
263                }
264                Some(other) => {
265                    return Err(format!(
266                        "system must be a string or array of strings, found {other}"
267                    ));
268                }
269            }
270            let mut messages = Vec::new();
271            match obj.get("messages") {
272                None | Some(serde_json::Value::Null) => {}
273                Some(serde_json::Value::Array(items)) => {
274                    for item in items {
275                        messages.push(message_from_json(item)?);
276                    }
277                }
278                Some(other) => return Err(format!("messages must be an array, found {other}")),
279            }
280            Ok((blocks, messages))
281        }
282        other => Err(format!(
283            "render must return a string or #{{ system, messages }} map, found {other}"
284        )),
285    }
286}
287
288/// Build one provider message from a script-emitted message object. Three
289/// accepted shapes, constructed with the same wire types the built-in
290/// SlidingWindow arm emits (so a Rhai recreation of it is byte-identical):
291///
292/// - `{ role, content }` - plain text, role `user` or `assistant`
293/// - `{ role: "assistant", content?, tool_calls: [{id, name, arguments}] }`
294/// - `{ role: "user", tool_results: [{tool_call_id, content, is_error?}] }`
295fn message_from_json(value: &serde_json::Value) -> Result<leviath_providers::Message, String> {
296    let obj = value
297        .as_object()
298        .ok_or_else(|| format!("each message must be a map, found {value}"))?;
299    let role = obj
300        .get("role")
301        .and_then(|r| r.as_str())
302        .ok_or("each message needs a role of \"user\" or \"assistant\"")?;
303    if role != "user" && role != "assistant" {
304        return Err(format!(
305            "message role must be user or assistant, found {role}"
306        ));
307    }
308
309    let content_str = match obj.get("content") {
310        None | Some(serde_json::Value::Null) => None,
311        Some(serde_json::Value::String(s)) => Some(s.clone()),
312        Some(other) => return Err(format!("message content must be a string, found {other}")),
313    };
314
315    if let Some(calls) = obj.get("tool_calls") {
316        if role != "assistant" {
317            return Err("tool_calls are only valid on an assistant message".to_string());
318        }
319        let calls = calls
320            .as_array()
321            .ok_or_else(|| format!("tool_calls must be an array, found {calls}"))?;
322        let mut blocks = Vec::new();
323        if let Some(text) = content_str.filter(|s| !s.is_empty()) {
324            blocks.push(leviath_providers::ContentBlock::Text { text });
325        }
326        for call in calls {
327            let call = call
328                .as_object()
329                .ok_or_else(|| format!("each tool_call must be a map, found {call}"))?;
330            let id = call
331                .get("id")
332                .and_then(|v| v.as_str())
333                .ok_or("each tool_call needs a string id")?;
334            let name = call
335                .get("name")
336                .and_then(|v| v.as_str())
337                .ok_or("each tool_call needs a string name")?;
338            blocks.push(leviath_providers::ContentBlock::ToolUse {
339                id: id.to_string(),
340                name: name.to_string(),
341                input: call
342                    .get("arguments")
343                    .cloned()
344                    .unwrap_or(serde_json::Value::Object(Default::default())),
345                thought_signature: call
346                    .get("thought_signature")
347                    .and_then(|v| v.as_str())
348                    .map(String::from),
349            });
350        }
351        return Ok(leviath_providers::Message {
352            role: "assistant".to_string(),
353            content: leviath_providers::MessageContent::Blocks(blocks),
354            cache_breakpoint: false,
355        });
356    }
357
358    if let Some(results) = obj.get("tool_results") {
359        if role != "user" {
360            return Err("tool_results are only valid on a user message".to_string());
361        }
362        let results = results
363            .as_array()
364            .ok_or_else(|| format!("tool_results must be an array, found {results}"))?;
365        let mut blocks = Vec::new();
366        for result in results {
367            let result = result
368                .as_object()
369                .ok_or_else(|| format!("each tool_result must be a map, found {result}"))?;
370            let id = result
371                .get("tool_call_id")
372                .and_then(|v| v.as_str())
373                .ok_or("each tool_result needs a string tool_call_id")?;
374            let content = result
375                .get("content")
376                .and_then(|v| v.as_str())
377                .ok_or("each tool_result needs string content")?;
378            blocks.push(leviath_providers::ContentBlock::ToolResult {
379                tool_use_id: id.to_string(),
380                content: content.to_string(),
381                is_error: result
382                    .get("is_error")
383                    .and_then(|v| v.as_bool())
384                    .unwrap_or(false),
385            });
386        }
387        return Ok(leviath_providers::Message {
388            role: "user".to_string(),
389            content: leviath_providers::MessageContent::Blocks(blocks),
390            cache_breakpoint: false,
391        });
392    }
393
394    let content =
395        content_str.ok_or("a message without tool_calls/tool_results needs string content")?;
396    Ok(leviath_providers::Message {
397        role: role.to_string(),
398        content: content.into(),
399        cache_breakpoint: false,
400    })
401}
402
403/// Run `on_write` for an entry headed into a custom region. Failure of any
404/// kind accepts the entry unchanged - a script bug must not lose writes.
405pub(crate) fn apply_on_write(
406    script: &RegionScript,
407    region: &Region,
408    content: String,
409    tokens: usize,
410    kind: &EntryKind,
411) -> OnWriteOutcome {
412    let kind_str = match kind {
413        EntryKind::Text => "text",
414        EntryKind::UserMessage => "user_message",
415        EntryKind::AssistantTurn { .. } => "assistant_turn",
416        EntryKind::ToolResult { .. } => "tool_result",
417    };
418    let ctx = serde_json::json!({
419        "region": region_to_json(region),
420        "entry": { "content": content, "kind": kind_str, "tokens": tokens },
421    });
422    match run_on_write(script, ctx) {
423        Ok(serde_json::Value::String(replacement)) => {
424            let tokens = leviath_core::estimate_tokens(&replacement);
425            OnWriteOutcome::Accept(replacement, tokens)
426        }
427        Ok(serde_json::Value::Bool(false)) => OnWriteOutcome::Drop,
428        Ok(serde_json::Value::Bool(true)) | Ok(serde_json::Value::Null) => {
429            OnWriteOutcome::Accept(content, tokens)
430        }
431        Ok(other) => {
432            tracing::warn!(
433                region = %region.name,
434                script = %script.path,
435                returned = %other,
436                "on_write must return a string, true/false, or unit; accepting entry unchanged"
437            );
438            OnWriteOutcome::Accept(content, tokens)
439        }
440        Err(e) => {
441            tracing::warn!(
442                region = %region.name,
443                script = %script.path,
444                error = %e,
445                "on_write failed; accepting entry unchanged"
446            );
447            OnWriteOutcome::Accept(content, tokens)
448        }
449    }
450}
451
452/// Ask `on_overflow` which entries to drop, validate the answer, and apply it.
453/// Returns the tokens freed (0 when the hook is absent, fails, or returns an
454/// invalid/empty answer - callers fall back to oldest-first for the rest).
455pub(crate) fn apply_overflow(
456    script: &RegionScript,
457    region: &mut Region,
458    needed_tokens: usize,
459) -> usize {
460    let ctx = serde_json::json!({
461        "region": region_to_json(region),
462        "entries": region.content.iter().map(entry_to_json).collect::<Vec<_>>(),
463        "needed_tokens": needed_tokens,
464    });
465    let value = match run_on_overflow(script, ctx) {
466        Ok(v) => v,
467        Err(e) => {
468            tracing::warn!(
469                region = %region.name,
470                script = %script.path,
471                error = %e,
472                "on_overflow failed; falling back to oldest-first eviction"
473            );
474            return 0;
475        }
476    };
477    let Some(indices) = valid_drop_indices(&value, region.content.len()) else {
478        tracing::warn!(
479            region = %region.name,
480            script = %script.path,
481            returned = %value,
482            "on_overflow must return an array of in-range entry indices; \
483             falling back to oldest-first eviction"
484        );
485        return 0;
486    };
487
488    let mut freed = 0;
489    // Descending order keeps earlier indices valid while removing.
490    for index in indices.into_iter().rev() {
491        let entry = region.content.remove(index);
492        freed += entry.tokens;
493    }
494    region.current_tokens = region.current_tokens.saturating_sub(freed);
495    freed
496}
497
498/// Validate an `on_overflow` return value into a sorted, deduped index list.
499/// `None` when the shape is wrong or any index is out of range.
500fn valid_drop_indices(value: &serde_json::Value, len: usize) -> Option<Vec<usize>> {
501    let items = value.as_array()?;
502    let mut indices = Vec::with_capacity(items.len());
503    for item in items {
504        let index = item.as_u64()? as usize;
505        if index >= len {
506            return None;
507        }
508        indices.push(index);
509    }
510    indices.sort_unstable();
511    indices.dedup();
512    Some(indices)
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::test_support::with_tracing;
519    use leviath_core::RegionKind;
520    use leviath_scripting::region_hook::compile;
521    use serde_json::json;
522
523    fn script(src: &str) -> Arc<RegionScript> {
524        Arc::new(compile("test.rhai", src).unwrap())
525    }
526
527    fn region_with(entries: &[(&str, EntryKind)]) -> Region {
528        let mut region = Region::new(
529            "brain".to_string(),
530            RegionKind::Custom {
531                script: "test.rhai".to_string(),
532                persistent: false,
533            },
534            1000,
535        );
536        for (content, kind) in entries {
537            region
538                .add_typed_entry(content.to_string(), 10, kind.clone())
539                .unwrap();
540        }
541        region
542    }
543
544    fn render(
545        region: &Region,
546        script: Option<&Arc<RegionScript>>,
547        persistent: bool,
548    ) -> (
549        Vec<leviath_providers::SystemBlock>,
550        Vec<leviath_providers::Message>,
551    ) {
552        let mut blocks = Vec::new();
553        let mut messages = Vec::new();
554        with_tracing(|| {
555            render_custom_region(
556                region,
557                script,
558                persistent,
559                &AssembleMeta {
560                    stage_name: "plan".to_string(),
561                    stage_iterations: 2,
562                    model: "m1".to_string(),
563                },
564                50,
565                2000,
566                &mut blocks,
567                &mut messages,
568            )
569        });
570        (blocks, messages)
571    }
572
573    // ─── entry_to_json ───────────────────────────────────────────────────
574
575    #[test]
576    fn entry_to_json_serializes_all_kinds() {
577        let mut region = region_with(&[
578            ("plain", EntryKind::Text),
579            ("hi", EntryKind::UserMessage),
580            (
581                "calling",
582                EntryKind::AssistantTurn {
583                    tool_calls: vec![leviath_core::SerializedToolCall {
584                        id: "c1".to_string(),
585                        name: "shell".to_string(),
586                        arguments: json!({"command": "ls"}),
587                        thought_signature: None,
588                    }],
589                },
590            ),
591            (
592                "result",
593                EntryKind::ToolResult {
594                    tool_call_id: "c1".to_string(),
595                    tool_name: "shell".to_string(),
596                    is_error: true,
597                },
598            ),
599        ]);
600        region.content[0].key = Some("k".to_string());
601
602        let entries: Vec<_> = region.content.iter().map(entry_to_json).collect();
603        assert_eq!(entries[0]["kind"], json!("text"));
604        assert_eq!(entries[0]["key"], json!("k"));
605        assert_eq!(entries[0]["tokens"], json!(10));
606        assert_eq!(entries[1]["kind"], json!("user_message"));
607        assert_eq!(entries[2]["kind"], json!("assistant_turn"));
608        assert_eq!(entries[2]["tool_calls"][0]["id"], json!("c1"));
609        assert_eq!(entries[3]["kind"], json!("tool_result"));
610        assert_eq!(entries[3]["tool_call_id"], json!("c1"));
611        assert_eq!(entries[3]["is_error"], json!(true));
612    }
613
614    // ─── render: happy paths ─────────────────────────────────────────────
615
616    #[test]
617    fn render_string_becomes_one_block_with_persistence_hint() {
618        let region = region_with(&[("x", EntryKind::Text)]);
619        let s = script("fn render(ctx) { `<${ctx.region.name}>` }");
620
621        let (blocks, messages) = render(&region, Some(&s), false);
622        assert_eq!(blocks.len(), 1);
623        assert_eq!(blocks[0].text, "<brain>");
624        assert_eq!(blocks[0].cache_hint, leviath_core::CacheHint::UntilChanged);
625        assert!(messages.is_empty());
626
627        let (blocks, _) = render(&region, Some(&s), true);
628        assert_eq!(blocks[0].cache_hint, leviath_core::CacheHint::Always);
629    }
630
631    #[test]
632    fn render_map_emits_system_array_and_typed_messages() {
633        // A script that recreates the SlidingWindow wire shapes: assistant
634        // text+tool_use, then a user tool_result message - built-ins parity.
635        let src = r#"
636            fn render(ctx) {
637                #{
638                    system: ["s1", "", "s2"],
639                    messages: [
640                        #{ role: "user", content: "hello" },
641                        #{ role: "assistant", content: "thinking", tool_calls: [
642                            #{ id: "c1", name: "shell", arguments: #{ command: "ls" } },
643                        ] },
644                        #{ role: "user", tool_results: [
645                            #{ tool_call_id: "c1", content: "file_a", is_error: false },
646                        ] },
647                    ],
648                }
649            }
650        "#;
651        let region = region_with(&[("x", EntryKind::Text)]);
652        let (blocks, messages) = render(&region, Some(&script(src)), false);
653
654        assert_eq!(
655            blocks.iter().map(|b| b.text.as_str()).collect::<Vec<_>>(),
656            vec!["s1", "s2"],
657            "empty system strings are skipped"
658        );
659        assert_eq!(messages.len(), 3);
660        assert_eq!(messages[0].role, "user");
661        // Assert the wire shapes through serde - no enum destructuring, so
662        // there are no never-taken match arms for the coverage gate.
663        let assistant = serde_json::to_value(&messages[1].content).unwrap();
664        assert_eq!(assistant[0], json!({ "type": "text", "text": "thinking" }));
665        assert_eq!(assistant[1]["type"], json!("tool_use"));
666        assert_eq!(assistant[1]["id"], json!("c1"));
667        assert_eq!(assistant[1]["name"], json!("shell"));
668        let results = serde_json::to_value(&messages[2].content).unwrap();
669        assert_eq!(results[0]["type"], json!("tool_result"));
670        assert_eq!(results[0]["tool_use_id"], json!("c1"));
671        assert_eq!(results[0]["content"], json!("file_a"));
672        assert_eq!(results[0]["is_error"], json!(false));
673    }
674
675    #[test]
676    fn render_map_accepts_single_system_string_and_null_fields() {
677        let src = r#"fn render(ctx) { #{ system: "solo", messages: () } }"#;
678        let region = region_with(&[("x", EntryKind::Text)]);
679        let (blocks, messages) = render(&region, Some(&script(src)), false);
680        assert_eq!(blocks.len(), 1);
681        assert_eq!(blocks[0].text, "solo");
682        assert!(messages.is_empty());
683    }
684
685    #[test]
686    fn render_empty_map_and_empty_string_emit_nothing() {
687        let region = region_with(&[("x", EntryKind::Text)]);
688        for src in ["fn render(ctx) { #{} }", "fn render(ctx) { \"\" }"] {
689            let (blocks, messages) = render(&region, Some(&script(src)), false);
690            assert!(blocks.is_empty(), "src: {src}");
691            assert!(messages.is_empty());
692        }
693    }
694
695    #[test]
696    fn render_sees_stage_meta_and_window_fields() {
697        let src = r#"
698            fn render(ctx) {
699                `${ctx.stage_name}|${ctx.stage_iterations}|${ctx.model}|${ctx.window.total_tokens}|${ctx.window.max_tokens}`
700            }
701        "#;
702        let region = region_with(&[("x", EntryKind::Text)]);
703        let (blocks, _) = render(&region, Some(&script(src)), false);
704        assert_eq!(blocks[0].text, "plan|2|m1|50|2000");
705    }
706
707    #[test]
708    fn render_over_budget_warns_but_still_emits() {
709        // Budget is 1000 tokens; the script emits ~2000 tokens of output. The
710        // result is kept (warn-only per the design), not truncated.
711        let src = r#"fn render(ctx) { let s = "x"; s.pad(8000, 'x'); s }"#;
712        let region = region_with(&[("x", EntryKind::Text)]);
713        let (blocks, _) = render(&region, Some(&script(src)), false);
714        assert_eq!(blocks.len(), 1);
715        assert_eq!(blocks[0].text.len(), 8000);
716    }
717
718    // ─── render: fallbacks ───────────────────────────────────────────────
719
720    #[test]
721    fn render_missing_script_falls_back_to_temporary_style() {
722        let region = region_with(&[("a", EntryKind::Text), ("b", EntryKind::Text)]);
723        let (blocks, messages) = render(&region, None, false);
724        assert_eq!(blocks.len(), 1);
725        assert_eq!(blocks[0].text, "[brain]:\na\n\nb");
726        assert_eq!(blocks[0].cache_hint, leviath_core::CacheHint::Never);
727        assert!(messages.is_empty());
728    }
729
730    #[test]
731    fn render_missing_script_on_empty_region_emits_nothing() {
732        let region = region_with(&[]);
733        let (blocks, messages) = render(&region, None, false);
734        assert!(blocks.is_empty());
735        assert!(messages.is_empty());
736    }
737
738    #[test]
739    fn render_runtime_error_falls_back() {
740        let region = region_with(&[("kept", EntryKind::Text)]);
741        let s = script("fn render(ctx) { throw \"broken\" }");
742        let (blocks, _) = render(&region, Some(&s), false);
743        assert_eq!(blocks.len(), 1);
744        assert_eq!(blocks[0].text, "[brain]:\nkept");
745        assert_eq!(blocks[0].cache_hint, leviath_core::CacheHint::Never);
746    }
747
748    #[test]
749    fn render_error_on_empty_region_emits_nothing() {
750        let region = region_with(&[]);
751        let s = script("fn render(ctx) { throw \"broken\" }");
752        let (blocks, _) = render(&region, Some(&s), false);
753        assert!(blocks.is_empty());
754    }
755
756    #[test]
757    fn render_invalid_shapes_fall_back() {
758        let region = region_with(&[("kept", EntryKind::Text)]);
759        for src in [
760            "fn render(ctx) { 42 }",
761            "fn render(ctx) { true }",
762            "fn render(ctx) { [1, 2] }",
763            "fn render(ctx) { }",
764            "fn render(ctx) { #{ system: 42 } }",
765            "fn render(ctx) { #{ system: [1] } }",
766            "fn render(ctx) { #{ messages: \"not an array\" } }",
767            "fn render(ctx) { #{ messages: [42] } }",
768            "fn render(ctx) { #{ messages: [#{ content: \"no role\" }] } }",
769            "fn render(ctx) { #{ messages: [#{ role: \"system\", content: \"bad role\" }] } }",
770            "fn render(ctx) { #{ messages: [#{ role: \"user\", content: 42 }] } }",
771            "fn render(ctx) { #{ messages: [#{ role: \"user\" }] } }",
772            "fn render(ctx) { #{ messages: [#{ role: \"user\", tool_calls: [] }] } }",
773            "fn render(ctx) { #{ messages: [#{ role: \"assistant\", tool_calls: 42 }] } }",
774            "fn render(ctx) { #{ messages: [#{ role: \"assistant\", tool_calls: [42] }] } }",
775            "fn render(ctx) { #{ messages: [#{ role: \"assistant\", tool_calls: [#{ name: \"n\" }] }] } }",
776            "fn render(ctx) { #{ messages: [#{ role: \"assistant\", tool_calls: [#{ id: \"i\" }] }] } }",
777            "fn render(ctx) { #{ messages: [#{ role: \"assistant\", tool_results: [] }] } }",
778            "fn render(ctx) { #{ messages: [#{ role: \"user\", tool_results: 42 }] } }",
779            "fn render(ctx) { #{ messages: [#{ role: \"user\", tool_results: [42] }] } }",
780            "fn render(ctx) { #{ messages: [#{ role: \"user\", tool_results: [#{ content: \"c\" }] }] } }",
781            "fn render(ctx) { #{ messages: [#{ role: \"user\", tool_results: [#{ tool_call_id: \"i\" }] }] } }",
782        ] {
783            let (blocks, messages) = render(&region, Some(&script(src)), false);
784            assert_eq!(blocks.len(), 1, "src must fall back: {src}");
785            assert_eq!(blocks[0].text, "[brain]:\nkept", "src: {src}");
786            assert!(messages.is_empty(), "src: {src}");
787        }
788    }
789
790    #[test]
791    fn render_invalid_shape_on_empty_region_emits_nothing() {
792        // The invalid-shape fallback has nothing to fall back TO when the
793        // region is empty - no block at all.
794        let region = region_with(&[]);
795        let (blocks, messages) = render(&region, Some(&script("fn render(ctx) { 42 }")), false);
796        assert!(blocks.is_empty());
797        assert!(messages.is_empty());
798    }
799
800    #[test]
801    fn render_empty_single_system_string_is_skipped() {
802        let src = r#"fn render(ctx) { #{ system: "" } }"#;
803        let region = region_with(&[("x", EntryKind::Text)]);
804        let (blocks, messages) = render(&region, Some(&script(src)), false);
805        assert!(blocks.is_empty());
806        assert!(messages.is_empty());
807    }
808
809    #[test]
810    fn render_tool_call_passes_thought_signature_through() {
811        let src = r#"
812            fn render(ctx) {
813                #{ messages: [#{ role: "assistant", tool_calls: [
814                    #{ id: "c", name: "n", thought_signature: "sig123" },
815                ] }] }
816            }
817        "#;
818        let region = region_with(&[("x", EntryKind::Text)]);
819        let (_, messages) = render(&region, Some(&script(src)), false);
820        let blocks = serde_json::to_value(&messages[0].content).unwrap();
821        assert_eq!(blocks[0]["thought_signature"], json!("sig123"));
822    }
823
824    #[test]
825    fn on_write_ctx_reports_every_entry_kind() {
826        // The kind string the script sees matches the entry's typed kind.
827        let src = r#"
828            fn render(ctx) { "" }
829            fn on_write(ctx) { ctx.entry.kind }
830        "#;
831        for (kind, expected) in [
832            (EntryKind::Text, "text"),
833            (EntryKind::UserMessage, "user_message"),
834            (
835                EntryKind::AssistantTurn { tool_calls: vec![] },
836                "assistant_turn",
837            ),
838            (
839                EntryKind::ToolResult {
840                    tool_call_id: "c".to_string(),
841                    tool_name: "t".to_string(),
842                    is_error: false,
843                },
844                "tool_result",
845            ),
846        ] {
847            let replaced = on_write_kind(src, "x", &kind);
848            assert_eq!(
849                replaced.map(|(content, _)| content),
850                Some(expected.to_string())
851            );
852        }
853    }
854
855    #[test]
856    fn render_assistant_tool_call_defaults_arguments_and_signature() {
857        let src = r#"
858            fn render(ctx) {
859                #{ messages: [#{ role: "assistant", tool_calls: [#{ id: "c", name: "n" }] }] }
860            }
861        "#;
862        let region = region_with(&[("x", EntryKind::Text)]);
863        let (_, messages) = render(&region, Some(&script(src)), false);
864        let blocks = serde_json::to_value(&messages[0].content).unwrap();
865        assert_eq!(blocks[0]["type"], json!("tool_use"));
866        assert_eq!(blocks[0]["input"], json!({}));
867        // Indexing a missing key yields Null, so this covers absent-or-null
868        // without a short-circuit branch the coverage gate can't see taken.
869        assert_eq!(blocks[0]["thought_signature"], serde_json::Value::Null);
870    }
871
872    // ─── on_write ────────────────────────────────────────────────────────
873
874    /// Run `apply_on_write` and collapse the outcome to an Option - both
875    /// enum arms are exercised across this suite (through this one shared
876    /// mapping), so it has no never-taken branch.
877    fn on_write_kind(src: &str, content: &str, kind: &EntryKind) -> Option<(String, usize)> {
878        let region = region_with(&[]);
879        let outcome =
880            with_tracing(|| apply_on_write(&script(src), &region, content.to_string(), 5, kind));
881        match outcome {
882            OnWriteOutcome::Accept(content, tokens) => Some((content, tokens)),
883            OnWriteOutcome::Drop => None,
884        }
885    }
886
887    fn on_write_of(src: &str, content: &str) -> Option<(String, usize)> {
888        on_write_kind(src, content, &EntryKind::Text)
889    }
890
891    #[test]
892    fn on_write_replaces_accepts_and_drops() {
893        let replaced = on_write_of(
894            "fn render(ctx) { \"\" }\nfn on_write(ctx) { ctx.entry.content.to_upper() }",
895            "hi",
896        );
897        assert_eq!(
898            replaced,
899            Some(("HI".to_string(), leviath_core::estimate_tokens("HI")))
900        );
901
902        for accept_body in ["true", ""] {
903            let src = format!("fn render(ctx) {{ \"\" }}\nfn on_write(ctx) {{ {accept_body} }}");
904            assert_eq!(
905                on_write_of(&src, "orig"),
906                Some(("orig".to_string(), 5)),
907                "body {accept_body:?} accepts unchanged with original tokens"
908            );
909        }
910
911        assert_eq!(
912            on_write_of("fn render(ctx) { \"\" }\nfn on_write(ctx) { false }", "x"),
913            None,
914            "false drops the entry"
915        );
916    }
917
918    #[test]
919    fn on_write_invalid_return_and_error_accept_unchanged() {
920        for src in [
921            "fn render(ctx) { \"\" }\nfn on_write(ctx) { 42 }",
922            "fn render(ctx) { \"\" }\nfn on_write(ctx) { throw \"bad\" }",
923        ] {
924            assert_eq!(
925                on_write_of(src, "keep"),
926                Some(("keep".to_string(), 5)),
927                "src: {src}"
928            );
929        }
930    }
931
932    // ─── on_overflow / apply_overflow ────────────────────────────────────
933
934    #[test]
935    fn apply_overflow_drops_chosen_indices() {
936        let mut region = region_with(&[
937            ("a", EntryKind::Text),
938            ("b", EntryKind::Text),
939            ("c", EntryKind::Text),
940        ]);
941        // Duplicate + unordered indices are deduped and applied safely.
942        let s = script("fn render(ctx) { \"\" }\nfn on_overflow(ctx) { [2, 0, 2] }");
943        let freed = with_tracing(|| apply_overflow(&s, &mut region, 15));
944        assert_eq!(freed, 20);
945        assert_eq!(region.content.len(), 1);
946        assert_eq!(region.content[0].content, "b");
947        assert_eq!(region.current_tokens, 10);
948    }
949
950    #[test]
951    fn apply_overflow_error_and_invalid_shapes_free_nothing() {
952        for src in [
953            "fn render(ctx) { \"\" }\nfn on_overflow(ctx) { throw \"bad\" }",
954            "fn render(ctx) { \"\" }\nfn on_overflow(ctx) { \"not an array\" }",
955            "fn render(ctx) { \"\" }\nfn on_overflow(ctx) { [\"x\"] }",
956            "fn render(ctx) { \"\" }\nfn on_overflow(ctx) { [99] }",
957        ] {
958            let mut region = region_with(&[("a", EntryKind::Text)]);
959            let freed = with_tracing(|| apply_overflow(&script(src), &mut region, 5));
960            assert_eq!(freed, 0, "src: {src}");
961            assert_eq!(region.content.len(), 1, "content untouched: {src}");
962        }
963    }
964
965    #[test]
966    fn overflow_ctx_carries_needed_tokens_and_entries() {
967        let src = r#"
968            fn render(ctx) { "" }
969            fn on_overflow(ctx) {
970                if ctx.needed_tokens == 7 && ctx.entries.len() == 2 { [0] } else { [] }
971            }
972        "#;
973        let mut region = region_with(&[("a", EntryKind::Text), ("b", EntryKind::Text)]);
974        let freed = with_tracing(|| apply_overflow(&script(src), &mut region, 7));
975        assert_eq!(freed, 10);
976    }
977}