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