Skip to main content

tidepool_mcp/
lib.rs

1//! MCP (Model Context Protocol) server library for Tidepool.
2//!
3//! Wraps `tidepool-runtime` in an MCP server exposing `run_haskell`,
4//! `compile_haskell`, and `eval` tools. Generic over effect handler stacks
5//! via `TidepoolMcpServer<H>`.
6
7use dyn_clone::{clone_trait_object, DynClone};
8use rmcp::{
9    model::*, service::RequestContext, ErrorData as McpError, RoleServer, ServerHandler, ServiceExt,
10};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::marker::PhantomData;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicU64, Ordering};
17use std::sync::Arc;
18use tidepool_bridge::{FromCore, ToCore};
19use tidepool_runtime::DispatchEffect;
20use tokio::io::{stdin, stdout};
21use tokio::time::{timeout, Duration};
22
23const EVAL_TIMEOUT_SECS: u64 = 120;
24const MAX_CONCURRENT_EVALS: usize = 4;
25
26// ---------------------------------------------------------------------------
27// Effect metadata — lives next to the handler, discovered via trait
28// ---------------------------------------------------------------------------
29
30/// Static metadata describing a Haskell effect type.
31///
32/// Each effect handler that wants to participate in the MCP templating system
33/// implements `DescribeEffect` to provide its Haskell-side type declaration.
34#[derive(Debug, Clone, Copy)]
35pub struct EffectDecl {
36    /// Haskell GADT type name, e.g. `"Console"`.
37    pub type_name: &'static str,
38    /// Human-readable description of what this effect does.
39    pub description: &'static str,
40    /// Haskell GADT constructor declarations (one per line inside `data T a where`).
41    pub constructors: &'static [&'static str],
42    /// Extra Haskell type/function definitions emitted before the GADT.
43    /// Use for supporting types (e.g. `data Lang = ...`) and helper functions.
44    pub type_defs: &'static [&'static str],
45    /// Thin curried helper definitions emitted after the `type M` alias.
46    /// Each string is one or more lines of Haskell (signature + definition).
47    pub helpers: &'static [&'static str],
48}
49
50/// Parsed constructor info extracted from an EffectDecl constructor string.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ParsedConstructor {
53    pub name: String,
54    pub arity: u32,
55}
56
57/// Parse `"GitLog :: Text -> Int -> Git [Value]"` → `ParsedConstructor { name: "GitLog", arity: 2 }`
58///
59/// Arity = number of `->` in the type signature (each `->` separates one argument from the rest).
60pub fn parse_constructor(decl: &str) -> Result<ParsedConstructor, String> {
61    let (name_part, type_part) = decl
62        .split_once("::")
63        .ok_or_else(|| format!("constructor decl must contain '::': {:?}", decl))?;
64    let name = name_part.trim().to_string();
65    let arity = type_part.matches("->").count() as u32;
66    Ok(ParsedConstructor { name, arity })
67}
68
69/// Trait for effect handlers that can describe their Haskell-side type.
70pub trait DescribeEffect {
71    fn effect_decl() -> EffectDecl;
72}
73
74/// Trait for collecting effect declarations from an HList of handlers.
75pub trait CollectEffectDecls {
76    fn collect_decls() -> Vec<EffectDecl>;
77}
78
79impl CollectEffectDecls for frunk::HNil {
80    fn collect_decls() -> Vec<EffectDecl> {
81        Vec::new()
82    }
83}
84
85impl<H, T> CollectEffectDecls for frunk::HCons<H, T>
86where
87    H: DescribeEffect,
88    T: CollectEffectDecls,
89{
90    fn collect_decls() -> Vec<EffectDecl> {
91        let mut decls = vec![H::effect_decl()];
92        decls.extend(T::collect_decls());
93        decls
94    }
95}
96
97// ---------------------------------------------------------------------------
98// Standard effect declarations
99// ---------------------------------------------------------------------------
100
101/// Console effect: print text output.
102pub fn console_decl() -> EffectDecl {
103    EffectDecl {
104        type_name: "Console",
105        description: "Print text output.",
106        constructors: &["Print :: Text -> Console ()"],
107        type_defs: &[],
108        helpers: &[],
109    }
110}
111
112/// Key-value store effect.
113pub fn kv_decl() -> EffectDecl {
114    EffectDecl {
115        type_name: "KV",
116        description:
117            "Persistent key-value store. State survives across calls within one server session.",
118        constructors: &[
119            "KvGet :: Text -> KV (Maybe Value)",
120            "KvSet :: Text -> Value -> KV ()",
121            "KvDelete :: Text -> KV ()",
122            "KvKeys :: KV [Text]",
123        ],
124        type_defs: &[],
125        helpers: &[
126            "kvGet :: Text -> M (Maybe Value)\nkvGet = send . KvGet",
127            "kvSet :: Text -> Value -> M ()\nkvSet k v = send (KvSet k v)",
128            "kvDel :: Text -> M ()\nkvDel = send . KvDelete",
129            "kvKeys :: M [Text]\nkvKeys = send KvKeys",
130        ],
131    }
132}
133
134/// File I/O effect (sandboxed).
135pub fn fs_decl() -> EffectDecl {
136    EffectDecl {
137        type_name: "Fs",
138        description: "Read and write files (sandboxed to server working directory).",
139        constructors: &[
140            "FsRead :: Text -> Fs Text",
141            "FsWrite :: Text -> Text -> Fs ()",
142            "FsListDir :: Text -> Fs [Text]",
143            "FsGlob :: Text -> Fs [Text]",
144            "FsExists :: Text -> Fs Bool",
145            "FsMetadata :: Text -> Fs (Int, Bool, Bool)",
146        ],
147        type_defs: &[],
148        helpers: &[
149            "fsRead :: Text -> M Text\nfsRead = send . FsRead",
150            "fsWrite :: Text -> Text -> M ()\nfsWrite f c = send (FsWrite f c)",
151            "fsListDir :: Text -> M [Text]\nfsListDir = send . FsListDir",
152            "fsGlob :: Text -> M [Text]\nfsGlob = send . FsGlob",
153            "fsExists :: Text -> M Bool\nfsExists = send . FsExists",
154            "fsMetadata :: Text -> M (Int, Bool, Bool)\nfsMetadata = send . FsMetadata",
155        ],
156    }
157}
158
159/// Structural grep (ast-grep) effect.
160pub fn sg_decl() -> EffectDecl {
161    EffectDecl {
162        type_name: "SG",
163        description: concat!(
164            "Structural code search and rewrite via ast-grep. ",
165            "Use patterns with $VAR for single-node captures and $$$VAR for multi-node. ",
166            "Paths are relative to server working directory.",
167        ),
168        type_defs: &[
169            "data Lang = Rust | Python | TypeScript | JavaScript | Go | Java | C | Cpp | Haskell | Nix | Html | Css | Json | Yaml | Toml",
170            "data Match = Match { mText :: Text, mFile :: Text, mLine :: Int, mVars :: [(Text, Text)], mReplacement :: Text }",
171            "instance ToJSON Match where\n  toJSON (Match t f l vs r) = object ([\"text\" .= t, \"file\" .= f, \"line\" .= l] ++ (if null vs then [] else [\"vars\" .= toJSON (Map.fromList vs)]) ++ (if T.null r then [] else [\"replacement\" .= r]))",
172            "var :: Match -> Text -> Text",
173            "var (Match _ _ _ vs _) k = case [v | (k', v) <- vs, k' == k] of { (x:_) -> x; _ -> \"\" }",
174        ],
175        constructors: &[
176            "SgFind    :: Lang -> Text -> [Text] -> SG [Match]",
177            "SgPreview :: Lang -> Text -> Text -> [Text] -> SG [Match]",
178            "SgReplace :: Lang -> Text -> Text -> [Text] -> SG Int",
179            "SgRuleFind    :: Lang -> Value -> [Text] -> SG [Match]",
180            "SgRuleReplace :: Lang -> Value -> Text -> [Text] -> SG Int",
181        ],
182        helpers: &[
183            "sgFind :: Lang -> Text -> [Text] -> M [Match]\nsgFind l p fs = send (SgFind l p fs)",
184            "sgPreview :: Lang -> Text -> Text -> [Text] -> M [Match]\nsgPreview l p r fs = send (SgPreview l p r fs)",
185            "sgReplace :: Lang -> Text -> Text -> [Text] -> M Int\nsgReplace l p r fs = send (SgReplace l p r fs)",
186            "sgRuleFind :: Lang -> Value -> [Text] -> M [Match]\nsgRuleFind l r fs = send (SgRuleFind l r fs)",
187            "sgRuleReplace :: Lang -> Value -> Text -> [Text] -> M Int\nsgRuleReplace l r rw fs = send (SgRuleReplace l r rw fs)",
188            "rPat :: Text -> Value\nrPat p = object [\"pattern\" .= p]",
189            "rKind :: Text -> Value\nrKind k = object [\"kind\" .= k]",
190            "rRegex :: Text -> Value\nrRegex r = object [\"regex\" .= r]",
191            "rHas :: Value -> Value\nrHas r = object [\"has\" .= r]",
192            "rInside :: Value -> Value\nrInside r = object [\"inside\" .= r]",
193            "rFollows :: Value -> Value\nrFollows r = object [\"follows\" .= r]",
194            "rPrecedes :: Value -> Value\nrPrecedes r = object [\"precedes\" .= r]",
195            "rAll :: [Value] -> Value\nrAll rs = object [\"all\" .= rs]",
196            "rAny :: [Value] -> Value\nrAny rs = object [\"any\" .= rs]",
197            "rNot :: Value -> Value\nrNot r = object [\"not\" .= r]",
198            // Object merge (primary combinator) — left-biased key union
199            "infixr 6 .+.\n(.+.) :: Value -> Value -> Value\n(.+.) (Object a) (Object b) = Object (KM.unionWith const a b)\n(.+.) a _ = a",
200            // Conjunction / Disjunction
201            "infixr 5 .&.\n(.&.) :: Value -> Value -> Value\na .&. b = object [\"all\" .= [a, b]]",
202            "infixr 4 .|.\n(.|.) :: Value -> Value -> Value\na .|. b = object [\"any\" .= [a, b]]",
203            // Relational operators
204            "infixl 7 ?>\n(?>) :: Value -> Value -> Value\nparent ?> child = parent .+. rHas child",
205            "infixl 7 <?\n(<?) :: Value -> Value -> Value\nchild <? ancestor = child .+. rInside ancestor",
206            // Extra field helpers
207            "rField :: Text -> Value\nrField name = object [\"field\" .= name]",
208            "rStopBy :: Text -> Value\nrStopBy s = object [\"stopBy\" .= s]",
209        ],
210    }
211}
212
213/// Http effect: fetch JSON from HTTP endpoints.
214pub fn http_decl() -> EffectDecl {
215    EffectDecl {
216        type_name: "Http",
217        description: "Fetch JSON from HTTP endpoints. Returns response body as Value.",
218        constructors: &[
219            "HttpGet :: Text -> Http Value",
220            "HttpPost :: Text -> Value -> Http Value",
221            "HttpRequest :: Text -> Text -> [(Text,Text)] -> Text -> Http Value",
222        ],
223        type_defs: &[],
224        helpers: &[
225            "httpGet :: Text -> M Value\nhttpGet = send . HttpGet",
226            "httpPost :: Text -> Value -> M Value\nhttpPost url body = send (HttpPost url body)",
227            "httpReq :: Text -> Text -> [(Text,Text)] -> Text -> M Value\nhttpReq method url headers body = send (HttpRequest method url headers body)",
228        ],
229    }
230}
231
232/// Exec effect: run shell commands.
233pub fn exec_decl() -> EffectDecl {
234    EffectDecl {
235        type_name: "Exec",
236        description: "Run shell commands and capture output.",
237        constructors: &[
238            "Run :: Text -> Exec (Int, Text, Text)",
239            "RunIn :: Text -> Text -> Exec (Int, Text, Text)",
240            "RunJson :: Text -> Exec Value",
241        ],
242        type_defs: &[],
243        helpers: &[
244            "run :: Text -> M (Int, Text, Text)\nrun = send . Run",
245            "runIn :: Text -> Text -> M (Int, Text, Text)\nrunIn dir cmd = send (RunIn dir cmd)",
246        ],
247    }
248}
249
250/// Meta effect: self-mirror for querying runtime metadata.
251pub fn meta_decl() -> EffectDecl {
252    EffectDecl {
253        type_name: "Meta",
254        description:
255            "Self-mirror for the runtime. Query constructors, primops, effects, diagnostics.",
256        constructors: &[
257            "MetaConstructors :: Meta [(Text, Int)]",
258            "MetaLookupCon    :: Text -> Meta (Maybe (Int, Int))",
259            "MetaPrimOps      :: Meta [Text]",
260            "MetaEffects      :: Meta [Text]",
261            "MetaDiagnostics  :: Meta [Text]",
262            "MetaVersion      :: Meta Text",
263            "MetaHelp         :: Meta [Text]",
264        ],
265        type_defs: &[],
266        helpers: &[
267            "metaConstructors :: M [(Text, Int)]\nmetaConstructors = send MetaConstructors",
268            "metaLookupCon :: Text -> M (Maybe (Int, Int))\nmetaLookupCon = send . MetaLookupCon",
269            "metaPrimOps :: M [Text]\nmetaPrimOps = send MetaPrimOps",
270            "metaEffects :: M [Text]\nmetaEffects = send MetaEffects",
271            "metaDiagnostics :: M [Text]\nmetaDiagnostics = send MetaDiagnostics",
272            "metaVersion :: M Text\nmetaVersion = send MetaVersion",
273            "metaHelp :: M [Text]\nmetaHelp = send MetaHelp",
274        ],
275    }
276}
277
278/// Ask effect: suspend execution to ask the calling LLM a question.
279pub fn ask_decl() -> EffectDecl {
280    EffectDecl {
281        type_name: "Ask",
282        description: "Suspend execution and ask the calling LLM a question. The LLM calls the resume tool with an answer, and execution continues.",
283        constructors: &["Ask :: Text -> Ask Value"],
284        type_defs: &[],
285        helpers: &[
286            "ask :: Text -> M Value\nask = send . Ask",
287        ],
288    }
289}
290
291/// Git effect: native repository access via libgit2.
292pub fn git_decl() -> EffectDecl {
293    EffectDecl {
294        type_name: "Git",
295        description: "Native git repository access via libgit2.",
296        constructors: &[
297            "GitLog      :: Text -> Int -> Git [Value]",
298            "GitShow     :: Text -> Git Value",
299            "GitDiff     :: Text -> Git [Value]",
300            "GitBlame    :: Text -> Int -> Int -> Git [Value]",
301            "GitTree     :: Text -> Text -> Git [Value]",
302            "GitBranches :: Git [Value]",
303        ],
304        type_defs: &[],
305        helpers: &[
306            "gitLog :: Text -> Int -> M [Value]\ngitLog ref n = send (GitLog ref n)",
307            "gitShow :: Text -> M Value\ngitShow = send . GitShow",
308            "gitDiff :: Text -> M [Value]\ngitDiff = send . GitDiff",
309            "gitBlame :: Text -> Int -> Int -> M [Value]\ngitBlame file s e = send (GitBlame file s e)",
310            "gitTree :: Text -> Text -> M [Value]\ngitTree hash path = send (GitTree hash path)",
311            "gitBranches :: M [Value]\ngitBranches = send GitBranches",
312        ],
313    }
314}
315
316/// LLM effect: call a fast LLM (Haiku) for classification, extraction, or judgment.
317pub fn llm_decl() -> EffectDecl {
318    EffectDecl {
319        type_name: "Llm",
320        description: "Call a fast LLM (Haiku) for classification, extraction, or judgment.",
321        constructors: &[
322            "LlmChat       :: Text -> Llm Text",
323            "LlmStructured :: Text -> Value -> Llm Value",
324        ],
325        type_defs: &[
326            "data Schema = SObj [(Text, Schema)] | SArr Schema | SStr | SNum | SBool | SEnum [Text] | SOpt Schema",
327        ],
328        helpers: &[
329            "llm :: Text -> M Text\nllm = send . LlmChat",
330            "llmJson :: Text -> Schema -> M Value\nllmJson prompt schema = send (LlmStructured prompt (schemaToValue schema))",
331            "isOpt :: Schema -> Bool\nisOpt (SOpt _) = True\nisOpt _ = False",
332            "innerSchema :: Schema -> Schema\ninnerSchema (SOpt s) = s\ninnerSchema s = s",
333            "schemaToValue :: Schema -> Value\nschemaToValue SStr = object [\"type\" .= (\"string\" :: Text)]\nschemaToValue SNum = object [\"type\" .= (\"number\" :: Text)]\nschemaToValue SBool = object [\"type\" .= (\"boolean\" :: Text)]\nschemaToValue (SEnum vs) = object [\"type\" .= (\"string\" :: Text), \"enum\" .= vs]\nschemaToValue (SArr item) = object [\"type\" .= (\"array\" :: Text), \"items\" .= schemaToValue item]\nschemaToValue (SOpt s) = schemaToValue s\nschemaToValue (SObj fields) = object [\"type\" .= (\"object\" :: Text), \"properties\" .= object (map (\\(k,s) -> k .= schemaToValue (innerSchema s)) fields), \"required\" .= map fst (filter (not . isOpt . snd) fields)]",
334        ],
335    }
336}
337
338/// All standard effects in canonical order.
339pub fn standard_decls() -> Vec<EffectDecl> {
340    vec![
341        console_decl(),
342        kv_decl(),
343        fs_decl(),
344        sg_decl(),
345        http_decl(),
346        exec_decl(),
347        meta_decl(),
348        git_decl(),
349        llm_decl(),
350        ask_decl(),
351    ]
352}
353
354// ---------------------------------------------------------------------------
355// Request types
356// ---------------------------------------------------------------------------
357
358/// Request parameters for the `eval` tool.
359///
360/// Provide a Haskell do-block as a single string. The server wraps it in a
361/// full module with the effect stack type, LANGUAGE pragmas, and imports.
362#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
363pub struct EvalRequest {
364    /// Haskell do-notation code. Each line is indented into a do-block.
365    /// Use `pure x` as the last line to return a value.
366    /// Use `send (Constructor args)` to invoke effects.
367    pub code: String,
368    /// Additional Haskell imports, one per line (e.g. "Data.List (sort)").
369    #[serde(default)]
370    pub imports: String,
371    /// Top-level helper definitions placed before the main do-block.
372    /// Function definitions only — custom `data` declarations are not supported.
373    #[serde(default)]
374    pub helpers: String,
375    /// Optional JSON input injected as `input :: Aeson.Value` binding.
376    #[serde(default)]
377    pub input: Option<serde_json::Value>,
378    /// Optional maximum character budget for paginated output.
379    /// Controls both `say` output and return value truncation.
380    /// Default: 4096.
381    #[serde(default)]
382    pub max_len: Option<u32>,
383}
384
385/// Request parameters for the `resume` tool.
386///
387/// Used to continue a suspended evaluation that hit an `Ask` effect.
388#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
389pub struct ResumeRequest {
390    /// The continuation ID returned by a suspended eval call.
391    pub continuation_id: String,
392    /// The response text to feed back to the suspended Haskell program.
393    pub response: String,
394}
395
396// ---------------------------------------------------------------------------
397// Templating
398// ---------------------------------------------------------------------------
399
400/// Generate the Haskell module preamble that wraps user code in `eval` calls.
401///
402/// Emits: language pragmas, `module Expr`, standard imports (`Tidepool.Prelude`,
403/// `Control.Monad.Freer`, qualified `Data.Text`/`Data.Map`/etc.), the user `Library`
404/// import (if present), GADT declarations for each registered effect, the `type M`
405/// alias over the full effect list, and thin helper functions (e.g. `say`, `kvGet`).
406///
407/// When `user_library` is true and both `Llm` and `Ask` effects are present, also
408/// emits the heuristic combinator definitions (`Q`, `??`, `pick`, `yn`, etc.).
409pub fn build_preamble(effects: &[EffectDecl], user_library: bool) -> String {
410    let mut out = String::new();
411    out.push_str("{-# LANGUAGE NoImplicitPrelude, OverloadedStrings, DataKinds, TypeOperators, FlexibleContexts, FlexibleInstances, GADTs, PartialTypeSignatures, ScopedTypeVariables #-}\n");
412    out.push_str("module Expr where\n");
413    out.push_str("import Tidepool.Prelude hiding (error)\n");
414    out.push_str("import qualified Data.Text as T\n");
415    out.push_str("import qualified Data.Map.Strict as Map\n");
416    out.push_str("import qualified Data.Set as Set\n");
417    out.push_str("import qualified Tidepool.Aeson.KeyMap as KM\n");
418    out.push_str("import qualified Data.List as L\n");
419    out.push_str("import qualified Tidepool.Text as TT\n");
420    out.push_str("import qualified Tidepool.Table as Tab\n");
421    out.push_str("import Control.Monad.Freer hiding (run)\n");
422    if user_library {
423        out.push_str("import Library\n");
424    }
425    out.push_str("import qualified Prelude as P\n");
426    out.push_str("default (Int, Text)\n");
427    out.push_str("error :: Text -> a\nerror = P.error . T.unpack\n");
428    out.push('\n');
429
430    for eff in effects {
431        for td in eff.type_defs {
432            out.push_str(td);
433            out.push('\n');
434        }
435        out.push_str(&format!("data {} a where\n", eff.type_name));
436        for ctor in eff.constructors {
437            out.push_str(&format!("  {}\n", ctor));
438        }
439        out.push('\n');
440    }
441
442    // Type alias so helpers can write `M a` instead of `Eff '[Console, KV, Fs] a`
443    if !effects.is_empty() {
444        let names: Vec<&str> = effects.iter().map(|e| e.type_name).collect();
445        out.push_str(&format!("type M = Eff '[{}]\n\n", names.join(", ")));
446    }
447
448    // Emit thin effect helpers
449    let has_helpers = effects.iter().any(|e| !e.helpers.is_empty());
450    if has_helpers {
451        for eff in effects {
452            for h in eff.helpers {
453                out.push_str(h);
454                out.push('\n');
455            }
456        }
457        out.push('\n');
458    }
459
460    // Pagination support — auto-truncation of large eval results
461    if !effects.is_empty() {
462        let has_ask = effects.iter().any(|e| e.type_name == "Ask");
463        let has_console = effects.iter().any(|e| e.type_name == "Console");
464        let has_kv = effects.iter().any(|e| e.type_name == "KV");
465
466        out.push_str("-- Pagination\n");
467        out.push_str(concat!("showI :: Int -> Text\n", "showI n = show n\n",));
468        // say: normal Print effect + char counter in KV (when available)
469        if has_console && has_kv {
470            out.push_str(concat!(
471                "say :: Text -> M ()\n",
472                "say t = do\n",
473                "  send (Print t)\n",
474                "  v <- kvGet \"__sayChars\"\n",
475                "  let cur = case v of { Just b -> case b ^? _Number of { Just n -> round n; _ -> 0 }; Nothing -> 0 }\n",
476                "  kvSet \"__sayChars\" (toJSON (cur + T.length t))\n",
477            ));
478        } else if has_console {
479            out.push_str(concat!("say :: Text -> M ()\n", "say = send . Print\n",));
480        }
481
482        out.push_str(concat!(
483            "valSize :: Value -> Int\n",
484            "valSize v = case v of\n",
485            "  String t -> T.length t + 2\n",
486            "  Number _ -> 8\n",
487            "  Bool b -> if b then 4 else 5\n",
488            "  Null -> 4\n",
489            "  Array xs -> arrSz xs 2\n",
490            "  Object m -> objSz (KM.toList m) 2\n",
491        ));
492        out.push_str(concat!(
493            "arrSz :: [Value] -> Int -> Int\n",
494            "arrSz [] acc = acc\n",
495            "arrSz [x] acc = acc + valSize x\n",
496            "arrSz (x:xs) acc = arrSz xs (acc + valSize x + 2)\n",
497        ));
498        out.push_str(concat!(
499            "objSz :: [(Key, Value)] -> Int -> Int\n",
500            "objSz [] acc = acc\n",
501            "objSz [(k,v)] acc = acc + T.length (KM.toText k) + 4 + valSize v\n",
502            "objSz ((k,v):rest) acc = objSz rest (acc + T.length (KM.toText k) + 4 + valSize v + 2)\n",
503        ));
504        out.push_str(concat!(
505            "truncArr :: Int -> Int -> [Value] -> ([Value], Int, [(Int, Value)])\n",
506            "truncArr _ nid [] = ([], nid, [])\n",
507            "truncArr bud nid (x:xs)\n",
508            "  | bud <= 30 = ([marker], nid + 1, [(nid, Array (x:xs))])\n",
509            "  | sz <= bud = let (r, nid', s) = truncArr (bud - sz - 2) nid xs in (x : r, nid', s)\n",
510            "  | otherwise = let m = String (\"[~\" <> showI sz <> \" chars -> stub_\" <> showI nid <> \"]\")\n",
511            "                    (r, nid', s) = truncArr (bud - 50) (nid + 1) xs\n",
512            "                in (m : r, nid', (nid, x) : s)\n",
513            "  where sz = valSize x\n",
514            "        n = 1 + length xs\n",
515            "        tsz = sz + arrSz xs 0\n",
516            "        marker = String (\"[\" <> showI n <> \" more, ~\" <> showI tsz <> \" chars -> stub_\" <> showI nid <> \"]\")\n",
517        ));
518        out.push_str(concat!(
519            "truncKvs :: Int -> Int -> [(Key, Value)] -> ([(Key, Value)], Int, [(Int, Value)])\n",
520            "truncKvs _ nid [] = ([], nid, [])\n",
521            "truncKvs bud nid ((k,v):rest)\n",
522            "  | bud <= 30 = ([(KM.fromText \"...\", String marker)], nid + 1, [(nid, object (map (\\(k',v') -> KM.toText k' .= v') ((k,v):rest)))])\n",
523            "  | sz <= bud = let (r, nid', s) = truncKvs (bud - sz - 2) nid rest in ((k,v) : r, nid', s)\n",
524            "  | otherwise = let m = String (\"[~\" <> showI (valSize v) <> \" chars -> stub_\" <> showI nid <> \"]\")\n",
525            "                    (r, nid', s) = truncKvs (bud - 50) (nid + 1) rest\n",
526            "                in ((k, m) : r, nid', (nid, v) : s)\n",
527            "  where sz = T.length (KM.toText k) + 4 + valSize v\n",
528            "        n = 1 + length rest\n",
529            "        tsz = sz + objSz rest 0\n",
530            "        marker = \"[\" <> showI n <> \" more fields, ~\" <> showI tsz <> \" chars -> stub_\" <> showI nid <> \"]\"\n",
531        ));
532        out.push_str(concat!(
533            "truncGo :: Int -> Int -> Value -> (Value, Int, [(Int, Value)])\n",
534            "truncGo bud nid v\n",
535            "  | valSize v <= bud = (v, nid, [])\n",
536            "  | otherwise = case v of\n",
537            "      Array xs -> let (items, nid', stubs) = truncArr bud nid xs in (Array items, nid', stubs)\n",
538            "      Object m -> let (pairs, nid', stubs) = truncKvs bud nid (KM.toList m)\n",
539            "                  in (object (map (\\(k',v') -> KM.toText k' .= v') pairs), nid', stubs)\n",
540            "      String t -> let keep = max' 10 (bud - 30)\n",
541            "                  in (String (T.take keep t <> \"...[\" <> showI (T.length t) <> \" chars]\"), nid, [])\n",
542            "      _ -> (v, nid, [])\n",
543        ));
544        out.push_str(concat!(
545            "truncVal :: Int -> Value -> (Value, [(Int, Value)])\n",
546            "truncVal budget val = let (v, _, stubs) = truncGo budget 0 val in (v, stubs)\n",
547        ));
548        out.push_str(concat!(
549            "lookupStub :: Int -> [(Int, Value)] -> Maybe Value\n",
550            "lookupStub _ [] = Nothing\n",
551            "lookupStub sid ((k,v):rest) = if sid == k then Just v else lookupStub sid rest\n",
552        ));
553
554        if has_ask {
555            out.push_str(concat!(
556                "paginateResult :: Int -> Value -> M Value\n",
557                "paginateResult budget val\n",
558                "  | valSize val <= budget = pure val\n",
559                "  | otherwise = do\n",
560                "      let (truncated, stubs) = truncVal budget val\n",
561                "      case stubs of\n",
562                "        [] -> pure truncated\n",
563                "        _ -> do\n",
564                "          let stubInfo = Array (map (\\(sid, sv) -> object [\"id\" .= (\"stub_\" <> showI sid), \"size\" .= toJSON (valSize sv)]) stubs)\n",
565                "          resp <- ask (\"[Pagination] truncated: \" <> show truncated <> \" stubs: \" <> show stubInfo)\n",
566                "          case resp ^? _String of\n",
567                "            Just s -> case parseIntM (T.drop 5 s) of\n",
568                "              Just sid -> case lookupStub sid stubs of\n",
569                "                Just subtree -> paginateResult budget subtree\n",
570                "                Nothing -> pure truncated\n",
571                "              Nothing -> pure truncated\n",
572                "            _ -> pure truncated\n",
573            ));
574        } else {
575            out.push_str(concat!(
576                "paginateResult :: Int -> Value -> M Value\n",
577                "paginateResult budget val\n",
578                "  | valSize val <= budget = pure val\n",
579                "  | otherwise = let (truncated, _) = truncVal budget val in pure truncated\n",
580            ));
581        }
582        out.push('\n');
583    }
584
585    // Effect orchestration helpers (require M, Value, Text, ask, kvGet, say, etc.)
586    if user_library && !effects.is_empty() {
587        out.push_str("-- Effect orchestration (from Library preamble)\n");
588        out.push_str(concat!(
589            "converse :: (s -> Value -> Either a (Text, s)) -> Text -> s -> M a\n",
590            "converse decide firstQ s0 = do\n",
591            "  v <- ask firstQ\n",
592            "  case decide s0 v of\n",
593            "    Left a        -> pure a\n",
594            "    Right (q, s') -> converse decide q s'\n",
595        ));
596        out.push_str(concat!(
597            "askUntil :: (Value -> Maybe a) -> Text -> M a\n",
598            "askUntil check prompt = do\n",
599            "  v <- ask prompt\n",
600            "  case check v of\n",
601            "    Just a  -> pure a\n",
602            "    Nothing -> askUntil check (prompt <> \" (invalid, try again)\")\n",
603        ));
604        out.push_str(concat!(
605            "askChoice :: Text -> [(Text, a)] -> M a\n",
606            "askChoice prompt choices = do\n",
607            "  let choiceText = T.intercalate \", \" (map fst choices)\n",
608            "  v <- ask (prompt <> \" [\" <> choiceText <> \"]\")\n",
609            "  let answer = case v ^? _String of { Just s -> s; _ -> \"\" }\n",
610            "  case lookup answer choices of\n",
611            "    Just a  -> pure a\n",
612            "    Nothing -> askChoice prompt choices\n",
613        ));
614        out.push_str(concat!(
615            "confirm :: Text -> M Bool\n",
616            "confirm prompt = do\n",
617            "  v <- ask (prompt <> \" [yes/no]\")\n",
618            "  let answer = case v ^? _String of { Just s -> toLower s; _ -> \"\" }\n",
619            "  pure (answer == \"yes\" || answer == \"y\")\n",
620        ));
621        out.push_str(concat!(
622            "repl :: Text -> (Text -> M (Maybe a)) -> M a\n",
623            "repl prompt dispatch = do\n",
624            "  v <- ask prompt\n",
625            "  let cmd = case v ^? _String of { Just s -> s; _ -> \"\" }\n",
626            "  r <- dispatch cmd\n",
627            "  case r of\n",
628            "    Just a  -> pure a\n",
629            "    Nothing -> repl prompt dispatch\n",
630        ));
631        out.push_str(concat!(
632            "memo :: Text -> M Value -> M Value\n",
633            "memo k compute = do\n",
634            "  cached <- kvGet k\n",
635            "  case cached of\n",
636            "    Just v  -> pure v\n",
637            "    Nothing -> do { v <- compute; kvSet k v; pure v }\n",
638        ));
639        out.push_str(concat!(
640            "kvModify :: Text -> (Maybe Value -> Value) -> M Value\n",
641            "kvModify k f = do\n",
642            "  old <- kvGet k\n",
643            "  let new = f old\n",
644            "  kvSet k new\n",
645            "  pure new\n",
646        ));
647        out.push_str(concat!(
648            "kvIncr :: Text -> M Int\n",
649            "kvIncr k = do\n",
650            "  old <- kvGet k\n",
651            "  let n = case old >>= (^? _Int) of { Just i -> i; _ -> 0 }\n",
652            "  let n' = n + 1\n",
653            "  kvSet k (toJSON n')\n",
654            "  pure n'\n",
655        ));
656        out.push_str(concat!(
657            "kvAppend :: Text -> Value -> M [Value]\n",
658            "kvAppend k v = do\n",
659            "  old <- kvGet k\n",
660            "  let xs = case old >>= (^? _Array) of { Just arr -> arr; _ -> [] }\n",
661            "  let xs' = xs ++ [v]\n",
662            "  kvSet k (toJSON xs')\n",
663            "  pure xs'\n",
664        ));
665        out.push_str(concat!(
666            "supervised :: Text -> M Value -> (Value -> Maybe a) -> M a\n",
667            "supervised label body check = do\n",
668            "  say (\"[\" <> label <> \"] running...\")\n",
669            "  v <- body\n",
670            "  case check v of\n",
671            "    Just a  -> say (\"[\" <> label <> \"] done\") >> pure a\n",
672            "    Nothing -> do\n",
673            "      correction <- ask (\"[\" <> label <> \"] result: \" <> show v <> \"\\nHow should I adjust?\")\n",
674            "      supervised label body check\n",
675        ));
676        out.push_str(concat!(
677            "gather :: [(Text, Value -> a)] -> M [a]\n",
678            "gather [] = pure []\n",
679            "gather ((q, parse):rest) = do\n",
680            "  v <- ask q\n",
681            "  as <- gather rest\n",
682            "  pure (parse v : as)\n",
683        ));
684        out.push_str(concat!(
685            "mapFiles :: [Text] -> (Text -> Text -> M Text) -> M [Text]\n",
686            "mapFiles paths transform = mapM (\\p -> do\n",
687            "  content <- fsRead p\n",
688            "  result <- transform p content\n",
689            "  fsWrite p result\n",
690            "  pure p) paths\n",
691        ));
692        out.push_str(concat!(
693            "searchProcess :: Lang -> Text -> [Text] -> (Match -> M a) -> M [a]\n",
694            "searchProcess lang pat paths process = do\n",
695            "  matches <- sgFind lang pat paths\n",
696            "  mapM process matches\n",
697        ));
698        out.push_str(concat!(
699            "readGlob :: Text -> M [(Text, Text)]\n",
700            "readGlob pat = fsGlob pat >>= mapM (\\p -> (,) p <$> fsRead p)\n",
701        ));
702        out.push_str(concat!(
703            "runChecked :: Text -> M Text\n",
704            "runChecked cmd = do\n",
705            "  (ec, out, err) <- run cmd\n",
706            "  if ec == 0 then pure out\n",
707            "  else error (\"command failed: \" <> cmd <> \"\\n\" <> err)\n",
708        ));
709        out.push_str(concat!(
710            "runJson :: Text -> M Value\n",
711            "runJson = send . RunJson\n",
712        ));
713        out.push_str(concat!(
714            "mapFile :: Text -> (Text -> Text) -> M ()\n",
715            "mapFile path f = fsRead path >>= \\c -> fsWrite path (f c)\n",
716        ));
717        out.push_str(concat!(
718            "mapFileM :: Text -> (Text -> M Text) -> M ()\n",
719            "mapFileM path f = fsRead path >>= f >>= fsWrite path\n",
720        ));
721        out.push_str(concat!(
722            "searchFiles :: Text -> Text -> M [(Text, Int, Text)]\n",
723            "searchFiles pat needle = do\n",
724            "  files <- fsGlob pat\n",
725            "  fmap concat $ forM files $ \\path -> do\n",
726            "    content <- fsRead path\n",
727            "    let ls = zip [(1::Int)..] (T.lines content)\n",
728            "    pure [(path, n, l) | (n, l) <- ls, T.isInfixOf needle l]\n",
729        ));
730        out.push_str(concat!(
731            "lineCount :: Text -> M Int\n",
732            "lineCount path = length . T.lines <$> fsRead path\n",
733        ));
734        out.push_str(concat!(
735            "fileContains :: Text -> Text -> M Bool\n",
736            "fileContains path needle = T.isInfixOf needle <$> fsRead path\n",
737        ));
738        out.push_str(concat!(
739            "kvAll :: M [(Text, Value)]\n",
740            "kvAll = do\n",
741            "  ks <- kvKeys\n",
742            "  vs <- mapM kvGet ks\n",
743            "  pure (zipWith (\\k mv -> (k, maybe Null id mv)) ks vs)\n",
744        ));
745        out.push_str(concat!(
746            "kvClear :: M ()\n",
747            "kvClear = kvKeys >>= mapM_ kvDel\n",
748        ));
749        out.push_str(concat!(
750            "runAll :: [Text] -> M [(Int, Text, Text)]\n",
751            "runAll = mapM run\n",
752        ));
753
754        // --- Git-aware analysis + codebase search helpers ---
755
756        // extLang: detect ast-grep Lang from file extension
757        out.push_str(concat!(
758            "extLang :: Text -> Maybe Lang\n",
759            "extLang f\n",
760            "  | T.isSuffixOf \".rs\" f = Just Rust\n",
761            "  | T.isSuffixOf \".py\" f = Just Python\n",
762            "  | T.isSuffixOf \".ts\" f = Just TypeScript\n",
763            "  | T.isSuffixOf \".tsx\" f = Just TypeScript\n",
764            "  | T.isSuffixOf \".js\" f = Just JavaScript\n",
765            "  | T.isSuffixOf \".jsx\" f = Just JavaScript\n",
766            "  | T.isSuffixOf \".go\" f = Just Go\n",
767            "  | T.isSuffixOf \".java\" f = Just Java\n",
768            "  | T.isSuffixOf \".c\" f = Just C\n",
769            "  | T.isSuffixOf \".cpp\" f = Just Cpp\n",
770            "  | T.isSuffixOf \".cc\" f = Just Cpp\n",
771            "  | T.isSuffixOf \".hs\" f = Just Haskell\n",
772            "  | T.isSuffixOf \".nix\" f = Just Nix\n",
773            "  | otherwise = Nothing\n",
774        ));
775
776        // funcPattern: ast-grep pattern for function definitions per language
777        out.push_str(concat!(
778            "funcPattern :: Lang -> Text\n",
779            "funcPattern Rust = \"fn $NAME\"\n",
780            "funcPattern Python = \"def $NAME\"\n",
781            "funcPattern Go = \"func $NAME\"\n",
782            "funcPattern JavaScript = \"function $NAME\"\n",
783            "funcPattern TypeScript = \"function $NAME\"\n",
784            "funcPattern Java = \"$TYPE $NAME($$$PARAMS)\"\n",
785            "funcPattern C = \"$TYPE $NAME($$$PARAMS)\"\n",
786            "funcPattern Cpp = \"$TYPE $NAME($$$PARAMS)\"\n",
787            "funcPattern Haskell = \"$NAME $$$ARGS = $$$BODY\"\n",
788            "funcPattern _ = \"$NAME\"\n",
789        ));
790
791        // changedFunctions: git diff → changed files → function defs in each
792        out.push_str(concat!(
793            "changedFunctions :: Text -> M [Value]\n",
794            "changedFunctions ref = do\n",
795            "  diffs <- gitDiff ref\n",
796            "  let entries = catMaybes $ map (\\d -> do\n",
797            "        p <- d ?. \"path\" >>= asText\n",
798            "        s <- d ?. \"status\" >>= asText\n",
799            "        lang <- extLang p\n",
800            "        Just (p, s, lang)) diffs\n",
801            "  forM entries $ \\(p, s, lang) -> do\n",
802            "    fns <- if s == \"D\" then pure []\n",
803            "           else do\n",
804            "             ms <- sgFind lang (funcPattern lang) [p]\n",
805            "             pure $ map (\\m -> object [\"name\" .= var m \"NAME\", \"line\" .= mLine m]) ms\n",
806            "    pure $ object [\"file\" .= p, \"status\" .= s, \"functions\" .= fns]\n",
807        ));
808
809        // reviewCommit: enrich a commit with function-level detail
810        out.push_str(concat!(
811            "reviewCommit :: Text -> M Value\n",
812            "reviewCommit hash = do\n",
813            "  meta <- gitShow hash\n",
814            "  diffs <- gitDiff hash\n",
815            "  let h = maybe \"\" id (meta ?. \"hash\" >>= asText)\n",
816            "  let subj = maybe \"\" id (meta ?. \"subject\" >>= asText)\n",
817            "  let auth = maybe \"\" id (meta ?. \"author\" >>= asText)\n",
818            "  let dt = maybe 0 id (meta ?. \"date\" >>= asInt)\n",
819            "  let bod = maybe \"\" id (meta ?. \"body\" >>= asText)\n",
820            "  let entries = catMaybes $ map (\\d -> do\n",
821            "        p <- d ?. \"path\" >>= asText\n",
822            "        s <- d ?. \"status\" >>= asText\n",
823            "        Just (p, s, extLang p)) diffs\n",
824            "  files <- forM entries $ \\(p, s, ml) -> do\n",
825            "    fns <- case ml of\n",
826            "      Nothing -> pure []\n",
827            "      Just lang -> if s == \"D\" then pure []\n",
828            "        else do\n",
829            "          ms <- sgFind lang (funcPattern lang) [p]\n",
830            "          pure $ map (\\m -> object [\"name\" .= var m \"NAME\", \"line\" .= mLine m]) ms\n",
831            "    pure $ object [\"file\" .= p, \"status\" .= s, \"functions\" .= fns]\n",
832            "  pure $ object [\"hash\" .= h, \"subject\" .= subj, \"author\" .= auth,\n",
833            "                 \"date\" .= dt, \"body\" .= bod, \"files\" .= files]\n",
834        ));
835
836        // staleBranches: find branches older than N days
837        out.push_str(concat!(
838            "staleBranches :: Int -> M [Value]\n",
839            "staleBranches maxDays = do\n",
840            "  branches <- gitBranches\n",
841            "  (_, nowStr, _) <- run \"date +%s\"\n",
842            "  let now = maybe 0 id (parseIntM (T.strip nowStr))\n",
843            "  results <- fmap catMaybes $ forM branches $ \\b -> do\n",
844            "    let mname = b ?. \"name\" >>= asText\n",
845            "    let mcommit = b ?. \"commit\" >>= asText\n",
846            "    let isHead = maybe False id (b ?. \"is_head\" >>= asBool)\n",
847            "    case (mname, mcommit) of\n",
848            "      (Just name, Just c) -> do\n",
849            "        info <- gitShow c\n",
850            "        let date = maybe 0 id (info ?. \"date\" >>= asInt)\n",
851            "        let auth = maybe \"\" id (info ?. \"author\" >>= asText)\n",
852            "        let subj = maybe \"\" id (info ?. \"subject\" >>= asText)\n",
853            "        let age = quot (now - date) 86400\n",
854            "        if age >= maxDays\n",
855            "          then pure $ Just $ object [\"name\" .= name, \"author\" .= auth,\n",
856            "                 \"subject\" .= subj, \"days_old\" .= age, \"is_head\" .= isHead]\n",
857            "          else pure Nothing\n",
858            "      _ -> pure Nothing\n",
859            "  pure $ sortBy (\\a b -> let ga = maybe 0 id (a ?. \"days_old\" >>= asInt)\n",
860            "                             gb = maybe 0 id (b ?. \"days_old\" >>= asInt)\n",
861            "                         in compare gb ga) results\n",
862        ));
863
864        // findAndPreview: structured ast-grep preview
865        out.push_str(concat!(
866            "findAndPreview :: Lang -> Text -> Text -> [Text] -> M [Value]\n",
867            "findAndPreview lang pat repl paths = do\n",
868            "  ms <- sgPreview lang pat repl paths\n",
869            "  pure $ map (\\m -> object [\"file\" .= mFile m, \"line\" .= mLine m,\n",
870            "    \"original\" .= mText m, \"replacement\" .= mReplacement m]) ms\n",
871        ));
872
873        // todoScan: find TODO/FIXME/HACK/XXX with git blame attribution
874        out.push_str(concat!(
875            "todoScan :: Text -> M [Value]\n",
876            "todoScan pat = do\n",
877            "  files <- fsGlob pat\n",
878            "  fmap concat $ forM files $ \\path -> do\n",
879            "    content <- fsRead path\n",
880            "    let ls = zip [(1::Int)..] (T.lines content)\n",
881            "    let hits = concatMap (\\(n, l) ->\n",
882            "          let tags = filter (\\t -> T.isInfixOf t l) [\"TODO\", \"FIXME\", \"HACK\", \"XXX\"]\n",
883            "          in map (\\t -> (n, l, t)) tags) ls\n",
884            "    forM hits $ \\(n, l, tag) -> do\n",
885            "      bl <- gitBlame path n n\n",
886            "      let auth = case bl of { (x:_) -> maybe \"\" id (x ?. \"author\" >>= asText); _ -> \"\" }\n",
887            "      let comm = case bl of { (x:_) -> maybe \"\" id (x ?. \"commit\" >>= asText); _ -> \"\" }\n",
888            "      pure $ object [\"file\" .= path, \"line\" .= n, \"tag\" .= tag,\n",
889            "        \"text\" .= T.strip l, \"author\" .= auth, \"commit\" .= comm]\n",
890        ));
891
892        // deadCode: find unreferenced function definitions
893        out.push_str(concat!(
894            "deadCode :: Lang -> Text -> M [Value]\n",
895            "deadCode lang pat = do\n",
896            "  ms <- sgFind lang (funcPattern lang) [pat]\n",
897            "  let defs = take 50 $ catMaybes $ map (\\m ->\n",
898            "        let n = var m \"NAME\" in\n",
899            "        if T.null n then Nothing\n",
900            "        else Just (mFile m, mLine m, n)) ms\n",
901            "  fmap catMaybes $ forM defs $ \\(file, line, name) -> do\n",
902            "    refs <- searchFiles pat name\n",
903            "    let others = filter (\\(f, n, _) -> not (f == file && n == line)) refs\n",
904            "    if null others then pure $ Just $ object [\"file\" .= file, \"line\" .= line, \"name\" .= name]\n",
905            "    else pure Nothing\n",
906        ));
907
908        // --- Heuristic combinators: Q a (Haiku-first, Ask-on-uncertainty) ---
909
910        let has_llm = effects.iter().any(|e| e.type_name == "Llm");
911        let has_ask_eff = effects.iter().any(|e| e.type_name == "Ask");
912        if has_llm && has_ask_eff {
913            out.push_str("-- Heuristic combinators\n");
914            out.push_str(concat!(
915                "data Q a = Q Schema (Value -> a) Double\n",
916                "data Judged a = Sure a | Unsure Double a\n",
917            ));
918            out.push_str(concat!(
919                "instance Functor Q where\n",
920                "  fmap f (Q s p t) = Q s (f . p) t\n",
921            ));
922            out.push_str(concat!(
923                "instance Applicative Q where\n",
924                "  pure a = Q (SObj []) (const a) 0.6\n",
925                "  Q (SObj fs1) p1 t1 <*> Q (SObj fs2) p2 t2 = Q (SObj (fs1 ++ fs2)) (\\v -> p1 v (p2 v)) (if t1 >= t2 then t1 else t2)\n",
926                "  Q s1 p1 t1 <*> Q s2 p2 t2 = Q s1 (\\v -> p1 v (p2 v)) (if t1 >= t2 then t1 else t2)\n",
927            ));
928            // Internal helpers: augment schema with rubric, extract confidence, strip rubric
929            out.push_str(concat!(
930                "h_aug :: Schema -> Schema\n",
931                "h_aug (SObj fs) = SObj (fs ++ [(\"_understood\", SBool), (\"_confident\", SBool), (\"_unambiguous\", SBool)])\n",
932                "h_aug s = SObj [(\"value\", s), (\"_understood\", SBool), (\"_confident\", SBool), (\"_unambiguous\", SBool)]\n",
933            ));
934            out.push_str(concat!(
935                "h_conf :: Value -> Double\n",
936                "h_conf v =\n",
937                "  let b k = case v ^? key k . _Bool of { Just True -> 1.0; _ -> 0.0 }\n",
938                "  in (b \"_understood\" + b \"_confident\" + b \"_unambiguous\") / 3.0\n",
939            ));
940            out.push_str(concat!(
941                "h_strip :: Value -> Value\n",
942                "h_strip (Object kvs) = Object (KM.delete (KM.fromText \"_unambiguous\") (KM.delete (KM.fromText \"_confident\") (KM.delete (KM.fromText \"_understood\") kvs)))\n",
943                "h_strip v = v\n",
944            ));
945            // ?? operator: ask Haiku, auto-escalate on low confidence
946            out.push_str(concat!(
947                "infixl 1 ??\n",
948                "(??) :: Q a -> Text -> M a\n",
949                "(Q schema parse threshold) ?? prompt = do\n",
950                "  r <- llmJson prompt (h_aug schema)\n",
951                "  let c = h_conf r\n",
952                "  v <- if c >= threshold then pure (h_strip r)\n",
953                "       else ask (prompt <> \"\\n[haiku \" <> pack (showDouble c) <> \"]: \" <> show (h_strip r))\n",
954                "  pure (parse v)\n",
955            ));
956            // ?! operator: ask with evidence, returns Judged
957            out.push_str(concat!(
958                "infixl 1 ?!\n",
959                "(?!) :: Q a -> Text -> M (Judged a)\n",
960                "(Q schema parse threshold) ?! prompt = do\n",
961                "  r <- llmJson prompt (h_aug schema)\n",
962                "  let c = h_conf r\n",
963                "  if c >= threshold\n",
964                "    then pure (Sure (parse (h_strip r)))\n",
965                "    else do\n",
966                "      v <- ask (prompt <> \"\\n[haiku \" <> pack (showDouble c) <> \"]: \" <> show (h_strip r))\n",
967                "      pure (Unsure c (parse v))\n",
968            ));
969            // Smart constructors
970            out.push_str(concat!(
971                "pick :: [Text] -> Q Text\n",
972                "pick cats = Q (SObj [(\"pick\", SEnum cats)]) (\\v -> case v ^? key \"pick\" . _String of { Just s -> s; _ -> error \"Q: missing 'pick' in response\" }) 0.6\n",
973            ));
974            out.push_str(concat!(
975                "yn :: Q Bool\n",
976                "yn = Q (SObj [(\"answer\", SBool)]) (\\v -> case v ^? key \"answer\" . _Bool of { Just b -> b; _ -> error \"Q: missing 'answer' in response\" }) 0.6\n",
977            ));
978            out.push_str(concat!(
979                "obj :: Schema -> Q Value\n",
980                "obj s = Q s id 0.6\n",
981            ));
982            out.push_str(concat!(
983                "txt :: Text -> Q Text\n",
984                "txt k = Q (SObj [(k, SStr)]) (\\v -> case v ^? key k . _String of { Just s -> s; _ -> error (\"Q: missing '\" <> k <> \"' in response\") }) 0.6\n",
985            ));
986            out.push_str(concat!(
987                "num :: Text -> Q Double\n",
988                "num k = Q (SObj [(k, SNum)]) (\\v -> case v ^? key k . _Number of { Just n -> n; _ -> error (\"Q: missing '\" <> k <> \"' in response\") }) 0.6\n",
989            ));
990            out.push_str(concat!(
991                "bar :: Double -> Q a -> Q a\n",
992                "bar t (Q s p _) = Q s p t\n",
993            ));
994            // Batch helpers
995            out.push_str(concat!(
996                "triage :: Q b -> (a -> Text) -> [a] -> M [(a, b)]\n",
997                "triage q render = mapM (\\x -> (,) x <$> (q ?? render x))\n",
998            ));
999            out.push_str(concat!(
1000                "findTally :: Eq a => a -> [(a, Int)] -> Maybe [(a, Int)]\n",
1001                "findTally _ [] = Nothing\n",
1002                "findTally x ((k, n):rest) = if x == k then Just ((k, n + 1) : rest) else case findTally x rest of { Just rest' -> Just ((k, n) : rest'); Nothing -> Nothing }\n",
1003            ));
1004            out.push_str(concat!(
1005                "tallyList :: Eq a => [a] -> [(a, Int)]\n",
1006                "tallyList = foldl' (\\acc x -> case findTally x acc of { Just acc' -> acc'; Nothing -> acc ++ [(x, 1)] }) []\n",
1007            ));
1008            out.push_str(concat!(
1009                "survey :: Eq b => Q b -> (a -> Text) -> [a] -> M [(b, Int)]\n",
1010                "survey q render xs = do\n",
1011                "  bs <- mapM (\\x -> q ?? render x) xs\n",
1012                "  pure (tallyList bs)\n",
1013            ));
1014            out.push_str(concat!(
1015                "sift :: Q Bool -> (a -> Text) -> [a] -> M ([a], [a])\n",
1016                "sift q render xs = do\n",
1017                "  tagged <- mapM (\\x -> (,) x <$> (q ?? render x)) xs\n",
1018                "  pure (map fst (filter snd tagged), map fst (filter (not . snd) tagged))\n",
1019            ));
1020        }
1021
1022        out.push('\n');
1023    }
1024
1025    out
1026}
1027
1028/// Qualified aeson imports for MCP eval. Unqualified symbols now come from Tidepool.Prelude.
1029/// These provide `Aeson.` prefix (used by json_to_haskell for input injection) and
1030/// qualified access to KeyMap/Vector for power users.
1031pub fn aeson_imports() -> String {
1032    concat!(
1033        "qualified Tidepool.Aeson as Aeson\n",
1034        "qualified Tidepool.Aeson.KeyMap as KM\n",
1035    )
1036    .into()
1037}
1038
1039pub fn build_effect_stack_type(effects: &[EffectDecl]) -> String {
1040    if effects.is_empty() {
1041        "'[]".to_string()
1042    } else {
1043        let names: Vec<&str> = effects.iter().map(|e| e.type_name).collect();
1044        format!("'[{}]", names.join(", "))
1045    }
1046}
1047
1048fn build_eval_tool_description(effects: &[EffectDecl]) -> String {
1049    let mut desc = String::from(concat!(
1050        "Write Haskell do-notation in `code`. The server wraps it in a module ",
1051        "with the effect stack, pragmas, and imports. ",
1052        "Use `pure x` as the last line to return a value. ",
1053        "Use `send (Constructor args)` to invoke effects. ",
1054        "First call is slow (~2s). Subsequent calls are cached.\n",
1055        "Return values are automatically rendered to JSON by the Rust runtime \u{2014} ",
1056        "Int becomes a number, [Char] becomes a string, Bool becomes true/false, ",
1057        "lists become arrays, etc. Prefer `pure x` over `send (Print (show x))` ",
1058        "for returning results.",
1059    ));
1060
1061    if !effects.is_empty() {
1062        desc.push_str("\nAvailable effects (use `send` to invoke):\n");
1063        for eff in effects {
1064            desc.push_str(&format!("\n{}: {}\n", eff.type_name, eff.description));
1065            for ctor in eff.constructors {
1066                desc.push_str(&format!("  {}\n", ctor));
1067            }
1068        }
1069
1070        // List built-in helpers
1071        let has_console = effects.iter().any(|e| e.type_name == "Console");
1072        let has_helpers = has_console || effects.iter().any(|e| !e.helpers.is_empty());
1073        if has_helpers {
1074            desc.push_str("\nBuilt-in helpers (always available, no need to define):\n");
1075            if has_console {
1076                desc.push_str("  say :: Text -> M ()\n");
1077            }
1078            for eff in effects {
1079                for h in eff.helpers {
1080                    // Extract just the type signature line
1081                    if let Some(sig) = h.lines().next() {
1082                        desc.push_str(&format!("  {}\n", sig));
1083                    }
1084                }
1085            }
1086            desc.push_str(
1087                "\nPrefer helpers over raw `send`: `say \"hi\"` not `send (Print \"hi\")`.\n",
1088            );
1089            desc.push_str("Use `>>=` chains and `<$>`/`<*>` for dense composition. Named bindings as escape hatch.\n");
1090            desc.push('\n');
1091            desc.push_str(concat!(
1092                "User library: `Library` is auto-imported from `.tidepool/lib/Library.hs`. ",
1093                "Other modules in `.tidepool/lib/` can be imported explicitly via the `imports` field.\n\n",
1094                "Prelude polymorphic ops: `len` for length of Text or [a], ",
1095                "`isNull` for emptiness of Text or [a], ",
1096                "`stake`/`sdrop` for take/drop on both Text and [a]. ",
1097                "`intercalate` joins Text (not lists). ",
1098                "`joinText` is an alias. `tReverse` reverses Text. ",
1099                "List-only: `length`, `take`, `drop`, `null` remain unchanged.",
1100            ));
1101        }
1102
1103        let has_llm = effects.iter().any(|e| e.type_name == "Llm");
1104        let has_ask_desc = effects.iter().any(|e| e.type_name == "Ask");
1105        if has_llm && has_ask_desc {
1106            desc.push_str(concat!(
1107                "\n\nHeuristic combinators (Library, auto-imported):\n",
1108                "  Q a — first-class question (schema + parser + confidence gate)\n",
1109                "  pick cats ?? prompt      -- classify (M Text)\n",
1110                "  yn ?? prompt             -- yes/no (M Bool)\n",
1111                "  obj schema ?? prompt     -- structured extraction (M Value)\n",
1112                "  txt \"field\" ?? prompt    -- single text field (M Text)\n",
1113                "  num \"field\" ?? prompt    -- single number field (M Double)\n",
1114                "  (,) <$> pick cs <*> num \"n\" ?? p  -- Applicative: merged schema, one call\n",
1115                "  bar 0.95 q ?? prompt     -- raise threshold\n",
1116                "  q ?! prompt             -- returns Sure a | Unsure Double a\n",
1117                "  triage q render items    -- batch: [(item, answer)]\n",
1118                "  survey q render items    -- tally: [(answer, count)]\n",
1119                "  sift yn render items     -- partition: ([true], [false])\n",
1120            ));
1121        }
1122    }
1123
1124    desc
1125}
1126
1127pub fn template_haskell(
1128    preamble: &str,
1129    effect_stack: &str,
1130    code: &str,
1131    imports: &str,
1132    helpers: &str,
1133    input: Option<&serde_json::Value>,
1134    budget: Option<u32>,
1135) -> String {
1136    let mut out = String::new();
1137
1138    // Preamble contains: pragmas, module header, standard imports, default decl,
1139    // data declarations, type alias. User imports must go after standard imports
1140    // (after "import Control.Monad.Freer\n") and before "default".
1141    if !imports.is_empty() {
1142        let insert_point = preamble.find("default (Int").unwrap_or(preamble.len());
1143        out.push_str(&preamble[..insert_point]);
1144        for imp in imports.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
1145            out.push_str(&format!("import {}\n", imp));
1146        }
1147        out.push_str(&preamble[insert_point..]);
1148    } else {
1149        out.push_str(preamble);
1150    }
1151
1152    // Marker for user code section (used by error formatting to trim preamble)
1153    out.push_str("-- [user]\n");
1154
1155    if !helpers.is_empty() {
1156        out.push_str(helpers);
1157        if !helpers.ends_with('\n') {
1158            out.push('\n');
1159        }
1160        out.push('\n');
1161    }
1162
1163    // Inject input binding if provided
1164    if let Some(val) = input {
1165        out.push_str("input :: Aeson.Value\n");
1166        out.push_str(&format!("input = {}\n\n", json_to_haskell(val)));
1167    }
1168
1169    out.push_str(&format!("result :: Eff {} Value\n", effect_stack));
1170    out.push_str("result = do\n");
1171    if budget.is_some() {
1172        out.push_str("  kvSet \"__sayChars\" (toJSON (0 :: Int))\n");
1173    }
1174    out.push_str("  _r <- do\n");
1175    for line in code.lines() {
1176        out.push_str(&format!("    {}\n", line));
1177    }
1178    if let Some(b) = budget {
1179        out.push_str("  _scV <- kvGet \"__sayChars\"\n");
1180        out.push_str("  let _sayC = case _scV of { Just b -> case b ^? _Number of { Just n -> round n; _ -> 0 }; Nothing -> 0 }\n");
1181        out.push_str(&format!(
1182            "  paginateResult (max' 100 ({} - _sayC)) (toJSON _r)\n",
1183            b
1184        ));
1185    } else {
1186        out.push_str("  paginateResult 4096 (toJSON _r)\n");
1187    }
1188
1189    out
1190}
1191
1192/// Render a serde_json::Value as a Haskell aeson literal expression.
1193fn json_to_haskell(val: &serde_json::Value) -> String {
1194    match val {
1195        serde_json::Value::Null => "Aeson.Null".into(),
1196        serde_json::Value::Bool(b) => {
1197            format!("Aeson.Bool {}", if *b { "True" } else { "False" })
1198        }
1199        serde_json::Value::Number(n) => {
1200            format!("Aeson.Number (fromIntegral ({} :: Int))", n)
1201        }
1202        serde_json::Value::String(s) => {
1203            let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
1204            format!("Aeson.String \"{}\"", escaped)
1205        }
1206        serde_json::Value::Array(arr) => {
1207            let elems: Vec<String> = arr.iter().map(json_to_haskell).collect();
1208            format!("toJSON [{}]", elems.join(", "))
1209        }
1210        serde_json::Value::Object(map) => {
1211            let pairs: Vec<String> = map
1212                .iter()
1213                .map(|(k, v)| {
1214                    let escaped_k = k.replace('\\', "\\\\").replace('"', "\\\"");
1215                    format!("\"{}\" .= {}", escaped_k, json_to_haskell(v))
1216                })
1217                .collect();
1218            format!("object [{}]", pairs.join(", "))
1219        }
1220    }
1221}
1222
1223// ---------------------------------------------------------------------------
1224// Error formatting
1225// ---------------------------------------------------------------------------
1226
1227fn format_panic_payload(payload: Box<dyn std::any::Any + Send>) -> String {
1228    if let Some(s) = payload.downcast_ref::<String>() {
1229        s.clone()
1230    } else if let Some(s) = payload.downcast_ref::<&str>() {
1231        s.to_string()
1232    } else {
1233        "unknown panic".to_string()
1234    }
1235}
1236
1237fn format_error_with_source(title: &str, error: &str, source: &str) -> String {
1238    // Extract user-written code: everything after the "-- [user]" marker.
1239    let user_section = source
1240        .find("-- [user]\n")
1241        .map(|pos| &source[pos + "-- [user]\n".len()..])
1242        .unwrap_or(source);
1243    format!(
1244        "## {}\n{}\n\n## User Code\n```haskell\n{}\n```",
1245        title, error, user_section
1246    )
1247}
1248
1249// ---------------------------------------------------------------------------
1250// Import blocklist
1251// ---------------------------------------------------------------------------
1252
1253/// Blocked module prefixes. Returns the module name if the import should be rejected.
1254fn rejected_import(import_str: &str) -> Option<&str> {
1255    const BLOCKED: &[&str] = &[
1256        "System.IO.Unsafe",
1257        "System.IO",
1258        "System.Process",
1259        "System.Posix",
1260        "System.Directory",
1261        "System.Environment",
1262        "GHC.IO",
1263        "GHC.Conc",
1264        "Foreign",
1265        "Network",
1266        "Control.Concurrent",
1267    ];
1268    // Extract module name: skip 'qualified' if present, then take the first token
1269    let mut parts = import_str.split_whitespace();
1270    let mut module = parts.next().unwrap_or("");
1271    if module == "qualified" {
1272        module = parts.next().unwrap_or("");
1273    }
1274    // Remove anything from '(' onwards (for imports like "Data.Map (Map)")
1275    let module = module.split('(').next().unwrap_or("").trim();
1276
1277    for prefix in BLOCKED {
1278        if module.starts_with(prefix) {
1279            return Some(module);
1280        }
1281    }
1282    None
1283}
1284
1285// ---------------------------------------------------------------------------
1286// Output capture
1287// ---------------------------------------------------------------------------
1288
1289/// Captured output from effect handlers (e.g., Console Print).
1290///
1291/// Clone is cheap (Arc-backed). Thread-safe for use across spawn_blocking.
1292#[derive(Clone, Default)]
1293pub struct CapturedOutput {
1294    lines: Arc<std::sync::Mutex<Vec<String>>>,
1295}
1296
1297impl CapturedOutput {
1298    pub fn new() -> Self {
1299        Self::default()
1300    }
1301
1302    /// Push a line of output.
1303    pub fn push(&self, line: String) {
1304        self.lines
1305            .lock()
1306            .unwrap_or_else(|e| {
1307                tracing::warn!("CapturedOutput mutex was poisoned, recovering");
1308                e.into_inner()
1309            })
1310            .push(line);
1311    }
1312
1313    /// Drain all captured lines, returning them and clearing the buffer.
1314    pub fn drain(&self) -> Vec<String> {
1315        let mut lines = self.lines.lock().unwrap_or_else(|e| {
1316            tracing::warn!("CapturedOutput mutex was poisoned, recovering");
1317            e.into_inner()
1318        });
1319        std::mem::take(&mut *lines)
1320    }
1321
1322    /// Snapshot current captured lines without clearing the buffer.
1323    pub fn snapshot(&self) -> Vec<String> {
1324        self.lines
1325            .lock()
1326            .unwrap_or_else(|e| {
1327                tracing::warn!("CapturedOutput mutex was poisoned, recovering");
1328                e.into_inner()
1329            })
1330            .clone()
1331    }
1332}
1333
1334// ---------------------------------------------------------------------------
1335// Ask effect — channel-based suspension
1336// ---------------------------------------------------------------------------
1337
1338/// Messages from the eval thread to the MCP server.
1339enum SessionMessage {
1340    /// The program hit an Ask effect and is waiting for a response.
1341    Suspended { prompt: String },
1342    /// The program completed successfully.
1343    Completed { result: String },
1344    /// The program encountered an error.
1345    Error { error: String },
1346}
1347
1348/// A suspended evaluation session, waiting for a resume call.
1349struct EvalSession {
1350    /// Send a response string to unblock the eval thread's Ask handler.
1351    response_tx: std::sync::mpsc::Sender<String>,
1352    /// Receive the next message (Completed, Suspended, or Error) from the eval thread.
1353    session_rx: tokio::sync::mpsc::UnboundedReceiver<SessionMessage>,
1354    /// The Haskell source code, for error formatting on resume.
1355    source: Arc<str>,
1356    /// When this session was created, for eviction ordering.
1357    created_at: std::time::Instant,
1358    /// Output capture for this session.
1359    captured_output: CapturedOutput,
1360}
1361
1362/// Wraps an existing effect dispatcher and intercepts the Ask effect tag.
1363///
1364/// When the Ask tag is hit, sends a `Suspended` message via the session channel
1365/// and blocks the current thread until a response arrives.
1366struct AskDispatcher {
1367    inner: Box<dyn McpEffectHandler>,
1368    ask_tag: u64,
1369    session_tx: tokio::sync::mpsc::UnboundedSender<SessionMessage>,
1370    response_rx: std::sync::mpsc::Receiver<String>,
1371}
1372
1373impl DispatchEffect<CapturedOutput> for AskDispatcher {
1374    fn dispatch(
1375        &mut self,
1376        tag: u64,
1377        request: &tidepool_eval::value::Value,
1378        cx: &tidepool_effect::dispatch::EffectContext<'_, CapturedOutput>,
1379    ) -> Result<tidepool_eval::value::Value, tidepool_effect::error::EffectError> {
1380        if tag == self.ask_tag {
1381            // Extract prompt from Ask constructor: Con(Ask, [prompt_val])
1382            let prompt = extract_ask_prompt(request, cx.table())
1383                .map_err(tidepool_effect::error::EffectError::Handler)?;
1384
1385            // Signal suspension to the MCP server
1386            let _ = self.session_tx.send(SessionMessage::Suspended { prompt });
1387
1388            // Block until the MCP server sends a response via the resume tool
1389            let response = self.response_rx.recv().map_err(|_| {
1390                tidepool_effect::error::EffectError::Handler(
1391                    "Ask session closed (timeout or client disconnected)".into(),
1392                )
1393            })?;
1394
1395            // Parse response as JSON → aeson Value; plain text wraps as Aeson.String
1396            let json_val: serde_json::Value =
1397                serde_json::from_str(&response).unwrap_or(serde_json::Value::String(response));
1398            let core_val = json_val
1399                .to_value(cx.table())
1400                .map_err(tidepool_effect::error::EffectError::Bridge)?;
1401            Ok(core_val)
1402        } else {
1403            self.inner.dispatch(tag, request, cx)
1404        }
1405    }
1406}
1407
1408/// Extract the prompt string from an Ask request Value.
1409///
1410/// The request is `Con(Ask, [prompt_val])` where `prompt_val` is a Text value.
1411/// Returns an error if the prompt cannot be extracted (e.g., unevaluated closure
1412/// due to a crash in the string-building expression).
1413fn extract_ask_prompt(
1414    request: &tidepool_eval::value::Value,
1415    table: &tidepool_repr::DataConTable,
1416) -> Result<String, String> {
1417    use tidepool_eval::value::Value;
1418
1419    if let Value::Con(_, fields) = request {
1420        if let Some(prompt_val) = fields.first() {
1421            // Try using FromCore (handles Text, LitString, [Char])
1422            match String::from_value(prompt_val, table) {
1423                Ok(s) => return Ok(s),
1424                Err(e) => {
1425                    // Provide diagnostic: the prompt text couldn't be extracted,
1426                    // likely because the string-building expression crashed
1427                    // (e.g., unresolved external, partial evaluation).
1428                    return Err(format!(
1429                        "ask prompt could not be evaluated to Text: {e}. \
1430                         The expression passed to `ask` likely crashed during evaluation \
1431                         (check for unresolved externals or runtime errors in the prompt string)."
1432                    ));
1433                }
1434            }
1435        }
1436    }
1437    Err(format!(
1438        "ask received unexpected request shape (expected Con(Ask, [text])): {:?}",
1439        request
1440    ))
1441}
1442
1443// ---------------------------------------------------------------------------
1444// Server internals
1445// ---------------------------------------------------------------------------
1446
1447/// Trait combining effect dispatch with cloning for the MCP server.
1448pub trait McpEffectHandler:
1449    DispatchEffect<CapturedOutput> + DynClone + Send + Sync + 'static
1450{
1451}
1452clone_trait_object!(McpEffectHandler);
1453
1454impl<T> McpEffectHandler for T where
1455    T: DispatchEffect<CapturedOutput> + Clone + Send + Sync + 'static
1456{
1457}
1458
1459/// Generic MCP server wrapper that compiles and runs Haskell via Tidepool.
1460#[derive(Clone)]
1461pub struct TidepoolMcpServer<H> {
1462    inner: TidepoolMcpServerImpl,
1463    _phantom: PhantomData<H>,
1464}
1465
1466/// Non-generic internal implementation to satisfy trait requirements.
1467#[derive(Clone)]
1468pub struct TidepoolMcpServerImpl {
1469    handler_factory: Arc<dyn McpEffectHandler>,
1470    include: Vec<PathBuf>,
1471    haskell_preamble: String,
1472    effect_stack_type: String,
1473    eval_tool_description: String,
1474    // User library support
1475    has_user_library: bool,
1476    // Ask effect support
1477    ask_tag: u64,
1478    // Effect names for error annotation (indexed by tag)
1479    effect_names: Vec<String>,
1480    continuations: Arc<std::sync::Mutex<HashMap<String, EvalSession>>>,
1481    next_cont_id: Arc<AtomicU64>,
1482    eval_semaphore: Arc<tokio::sync::Semaphore>,
1483}
1484
1485impl TidepoolMcpServerImpl {
1486    fn next_continuation_id(&self) -> String {
1487        let id = self.next_cont_id.fetch_add(1, Ordering::Relaxed);
1488        format!("cont_{}", id)
1489    }
1490
1491    /// Evict the oldest continuation, freeing its semaphore permit.
1492    /// Dropping `EvalSession` drops `response_tx` → blocked eval thread's
1493    /// `response_rx.recv()` returns Err → thread exits → permit freed.
1494    fn evict_oldest_continuation(&self) {
1495        let mut conts = self.continuations.lock().unwrap_or_else(|e| e.into_inner());
1496        if let Some(oldest_key) = conts
1497            .iter()
1498            .min_by_key(|(_, s)| s.created_at)
1499            .map(|(k, _)| k.clone())
1500        {
1501            tracing::info!(cont_id = %oldest_key, "evicting oldest continuation under pressure");
1502            conts.remove(&oldest_key);
1503        }
1504    }
1505
1506    async fn handle_session_result(
1507        &self,
1508        op: &str,
1509        mut session_rx: tokio::sync::mpsc::UnboundedReceiver<SessionMessage>,
1510        source: Arc<str>,
1511        response_tx: std::sync::mpsc::Sender<String>,
1512        captured_output: CapturedOutput,
1513    ) -> Result<CallToolResult, McpError> {
1514        let eval_timeout = Duration::from_secs(EVAL_TIMEOUT_SECS);
1515        match timeout(eval_timeout, session_rx.recv()).await {
1516            Ok(Some(message)) => {
1517                let output = match &message {
1518                    SessionMessage::Completed { .. } | SessionMessage::Error { .. } => {
1519                        captured_output.drain()
1520                    }
1521                    SessionMessage::Suspended { .. } => captured_output.snapshot(),
1522                };
1523
1524                match message {
1525                    SessionMessage::Completed { result } => {
1526                        tracing::info!("{} completed", op);
1527                        let mut response = String::new();
1528                        if !output.is_empty() {
1529                            response.push_str("## Output\n");
1530                            for line in &output {
1531                                response.push_str(line);
1532                                response.push('\n');
1533                            }
1534                            response.push_str("\n## Result\n");
1535                        }
1536                        response.push_str(&result);
1537                        Ok(CallToolResult::success(vec![Content::text(response)]))
1538                    }
1539                    SessionMessage::Suspended { prompt } => {
1540                        tracing::info!(prompt = %prompt, "{} suspended on Ask", op);
1541                        let cont_id = self.next_continuation_id();
1542                        let mut json_obj = serde_json::json!({
1543                            "suspended": true,
1544                            "continuation_id": cont_id,
1545                            "prompt": prompt,
1546                        });
1547                        if !output.is_empty() {
1548                            if let Some(obj) = json_obj.as_object_mut() {
1549                                obj.insert("output".into(), serde_json::Value::from(output));
1550                            }
1551                        }
1552                        self.continuations
1553                            .lock()
1554                            .unwrap_or_else(|e| {
1555                                tracing::warn!("continuation store mutex was poisoned, recovering");
1556                                e.into_inner()
1557                            })
1558                            .insert(
1559                                cont_id.clone(),
1560                                EvalSession {
1561                                    response_tx,
1562                                    session_rx,
1563                                    source: Arc::clone(&source),
1564                                    created_at: std::time::Instant::now(),
1565                                    captured_output,
1566                                },
1567                            );
1568                        Ok(CallToolResult::success(vec![Content::text(
1569                            json_obj.to_string(),
1570                        )]))
1571                    }
1572                    SessionMessage::Error { error } => {
1573                        let mut error_msg = format_error_with_source("Error", &error, &source);
1574                        if !output.is_empty() {
1575                            error_msg.push_str("\n\n## Output So Far\n");
1576                            for line in &output {
1577                                error_msg.push_str(line);
1578                                error_msg.push('\n');
1579                            }
1580                        }
1581                        tracing::error!("{} failed: {}", op, error);
1582                        Ok(CallToolResult::error(vec![Content::text(error_msg)]))
1583                    }
1584                }
1585            }
1586            Ok(None) => {
1587                tracing::error!("{} thread crashed", op);
1588                let mut crash_info = String::new();
1589                let crash_log = async {
1590                    use tokio::io::{AsyncReadExt, AsyncSeekExt};
1591                    let mut file = tokio::fs::File::open(".tidepool/crash.log").await.ok()?;
1592                    let meta = file.metadata().await.ok()?;
1593                    let len = meta.len();
1594                    const MAX_CRASH_LOG_BYTES: u64 = 65536;
1595                    if len > MAX_CRASH_LOG_BYTES {
1596                        file.seek(std::io::SeekFrom::End(-(MAX_CRASH_LOG_BYTES as i64)))
1597                            .await
1598                            .ok()?;
1599                    }
1600                    let mut buf = Vec::new();
1601                    file.read_to_end(&mut buf).await.ok()?;
1602                    Some(String::from_utf8_lossy(&buf).into_owned())
1603                }
1604                .await;
1605
1606                if let Some(content) = crash_log {
1607                    let lines: Vec<&str> = content.lines().rev().take(5).collect();
1608                    if !lines.is_empty() {
1609                        crash_info.push_str("\n\n## Recent Crash Log Entries\n```\n");
1610                        for line in lines.into_iter().rev() {
1611                            crash_info.push_str(line);
1612                            crash_info.push('\n');
1613                        }
1614                        crash_info.push_str("```\n");
1615                    }
1616                }
1617                let error_msg = format_error_with_source(
1618                    "Crash",
1619                    &format!(
1620                        "{} thread crashed (likely SIGILL from exhausted case branch or SIGSEGV from invalid memory access). Set RUST_LOG=debug for JIT diagnostics on stderr.{}",
1621                        op, crash_info
1622                    ),
1623                    &source,
1624                );
1625                Ok(CallToolResult::error(vec![Content::text(error_msg)]))
1626            }
1627            Err(_elapsed) => {
1628                tracing::error!("{} timed out after {}s", op, EVAL_TIMEOUT_SECS);
1629                let error_msg = format_error_with_source(
1630                    "Timeout",
1631                    &format!(
1632                        "{} timed out after {}s. This usually means an infinite loop or unbounded recursion.",
1633                        op, EVAL_TIMEOUT_SECS
1634                    ),
1635                    &source,
1636                );
1637                Ok(CallToolResult::error(vec![Content::text(error_msg)]))
1638            }
1639        }
1640    }
1641
1642    async fn eval(&self, req: EvalRequest) -> Result<CallToolResult, McpError> {
1643        tracing::info!(len = req.code.len(), "eval request");
1644
1645        // Reject unsafe/IO imports before compilation
1646        for imp in req
1647            .imports
1648            .lines()
1649            .map(|l| l.trim())
1650            .filter(|l| !l.is_empty())
1651        {
1652            if let Some(module) = rejected_import(imp) {
1653                return Ok(CallToolResult::error(vec![Content::text(format!(
1654                    "Blocked import: `{}` is not available in the Tidepool sandbox.",
1655                    module,
1656                ))]));
1657            }
1658        }
1659
1660        let mut all_imports = aeson_imports();
1661        all_imports.push_str(&req.imports);
1662        let source: Arc<str> = template_haskell(
1663            &self.haskell_preamble,
1664            &self.effect_stack_type,
1665            &req.code,
1666            &all_imports,
1667            &req.helpers,
1668            req.input.as_ref(),
1669            Some(req.max_len.unwrap_or(4096)),
1670        )
1671        .into();
1672
1673        let handlers = dyn_clone::clone_box(&*self.handler_factory);
1674        let include_refs: Vec<PathBuf> = self.include.clone();
1675        let source_for_blocking = Arc::clone(&source);
1676        let captured = CapturedOutput::new();
1677        let captured_for_blocking = captured.clone();
1678        let ask_tag = self.ask_tag;
1679        let effect_names = self.effect_names.clone();
1680
1681        // Create channels for Ask effect communication
1682        let (session_tx, session_rx) = tokio::sync::mpsc::unbounded_channel::<SessionMessage>();
1683        let (response_tx, response_rx) = std::sync::mpsc::channel::<String>();
1684
1685        let permit = match self.eval_semaphore.clone().try_acquire_owned() {
1686            Ok(p) => p,
1687            Err(_) => {
1688                // All slots busy — evict oldest suspended eval to free a permit
1689                self.evict_oldest_continuation();
1690                // Brief yield to let the evicted thread's permit release propagate
1691                tokio::task::yield_now().await;
1692                self.eval_semaphore
1693                    .clone()
1694                    .try_acquire_owned()
1695                    .map_err(|_| {
1696                        McpError::internal_error(
1697                            "Server busy: too many concurrent evaluations. Please try again in a moment.",
1698                            None,
1699                        )
1700                    })?
1701            }
1702        };
1703
1704        // Spawn eval thread — does NOT join; communicates via channels
1705        let thread_session_tx = session_tx;
1706        let _handle = std::thread::Builder::new()
1707            .name("tidepool-eval".into())
1708            .stack_size(32 * 1024 * 1024)
1709            .spawn(move || {
1710                let _permit = permit;
1711                // Install signal handlers so SIGILL/SIGSEGV from JIT code
1712                // are caught via sigsetjmp/siglongjmp instead of killing
1713                // the whole server process.
1714                tidepool_codegen::signal_safety::install();
1715
1716                let include_paths: Vec<&Path> = include_refs.iter().map(|p| p.as_path()).collect();
1717                let mut ask_dispatcher = AskDispatcher {
1718                    inner: handlers,
1719                    ask_tag,
1720                    session_tx: thread_session_tx.clone(),
1721                    response_rx,
1722                };
1723
1724                let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1725                    tidepool_runtime::compile_and_run(
1726                        &source_for_blocking,
1727                        "result",
1728                        &include_paths,
1729                        &mut ask_dispatcher,
1730                        &captured_for_blocking,
1731                    )
1732                }));
1733
1734                match result {
1735                    Ok(Ok(eval_result)) => {
1736                        let _ = thread_session_tx.send(SessionMessage::Completed {
1737                            result: eval_result.to_string_pretty(),
1738                        });
1739                    }
1740                    Ok(Err(e)) => {
1741                        let diagnostics = tidepool_runtime::drain_diagnostics();
1742                        let mut error_detail = e.to_string();
1743                        // Annotate UnhandledEffect with effect names
1744                        if let Some(tag_str) = error_detail.strip_prefix("Unhandled effect at tag ")
1745                        {
1746                            if let Ok(tag) = tag_str.trim().parse::<usize>() {
1747                                if tag < effect_names.len() {
1748                                    let effect_name = &effect_names[tag];
1749                                    error_detail =
1750                                        format!("{} (effect: {})", error_detail, effect_name);
1751                                }
1752                            }
1753                            let effects_list: String = effect_names
1754                                .iter()
1755                                .enumerate()
1756                                .map(|(i, name)| format!("  {} = {}", i, name))
1757                                .collect::<Vec<_>>()
1758                                .join("\n");
1759                            error_detail
1760                                .push_str(&format!("\n\nRegistered effects:\n{}", effects_list));
1761                        }
1762                        if !diagnostics.is_empty() {
1763                            error_detail.push_str("\n\n## JIT Diagnostics\n");
1764                            for d in &diagnostics {
1765                                error_detail.push_str(d);
1766                                error_detail.push('\n');
1767                            }
1768                        }
1769                        let _ = thread_session_tx.send(SessionMessage::Error {
1770                            error: error_detail,
1771                        });
1772                    }
1773                    Err(panic_payload) => {
1774                        let diagnostics = tidepool_runtime::drain_diagnostics();
1775                        let mut error_detail = format_panic_payload(panic_payload);
1776                        if !diagnostics.is_empty() {
1777                            error_detail.push_str("\n\n## JIT Diagnostics\n");
1778                            for d in &diagnostics {
1779                                error_detail.push_str(d);
1780                                error_detail.push('\n');
1781                            }
1782                        }
1783                        let _ = thread_session_tx.send(SessionMessage::Error {
1784                            error: error_detail,
1785                        });
1786                    }
1787                }
1788            })
1789            .map_err(|e| McpError::internal_error(format!("thread spawn error: {}", e), None))?;
1790
1791        // Await first message from the eval thread
1792        self.handle_session_result("eval", session_rx, source, response_tx, captured)
1793            .await
1794    }
1795
1796    async fn resume(&self, req: ResumeRequest) -> Result<CallToolResult, McpError> {
1797        tracing::info!(continuation_id = %req.continuation_id, "resume request");
1798
1799        let session = {
1800            let mut conts = self.continuations.lock().unwrap_or_else(|e| {
1801                tracing::warn!("continuation store mutex was poisoned, recovering");
1802                e.into_inner()
1803            });
1804            conts.remove(&req.continuation_id).ok_or_else(|| {
1805                McpError::invalid_params(
1806                    format!(
1807                        "Unknown or expired continuation_id: {}",
1808                        req.continuation_id
1809                    ),
1810                    None,
1811                )
1812            })?
1813        };
1814
1815        // Send the response to the blocked eval thread
1816        session
1817            .response_tx
1818            .send(req.response)
1819            .map_err(|_| McpError::internal_error("eval thread is no longer running", None))?;
1820
1821        let source = session.source.clone();
1822        let response_tx = session.response_tx.clone();
1823        let captured = session.captured_output.clone();
1824
1825        // Await the next message from the eval thread
1826        self.handle_session_result("resume", session.session_rx, source, response_tx, captured)
1827            .await
1828    }
1829}
1830
1831impl ServerHandler for TidepoolMcpServerImpl {
1832    fn get_info(&self) -> ServerInfo {
1833        ServerInfo {
1834            instructions: Some(self.eval_tool_description.clone()),
1835            capabilities: ServerCapabilities::builder().enable_tools().build(),
1836            ..Default::default()
1837        }
1838    }
1839
1840    async fn call_tool(
1841        &self,
1842        request: CallToolRequestParams,
1843        _context: RequestContext<RoleServer>,
1844    ) -> Result<CallToolResult, McpError> {
1845        let args = request.arguments.unwrap_or_default();
1846        match request.name.as_ref() {
1847            "eval" => {
1848                let req: EvalRequest = serde_json::from_value(serde_json::Value::Object(args))
1849                    .map_err(|e| {
1850                        McpError::invalid_params(format!("invalid params: {}", e), None)
1851                    })?;
1852                self.eval(req).await
1853            }
1854            "resume" => {
1855                let req: ResumeRequest = serde_json::from_value(serde_json::Value::Object(args))
1856                    .map_err(|e| {
1857                        McpError::invalid_params(format!("invalid params: {}", e), None)
1858                    })?;
1859                self.resume(req).await
1860            }
1861            _ => Err(McpError {
1862                code: ErrorCode::METHOD_NOT_FOUND,
1863                message: format!("Tool not found: {}", request.name).into(),
1864                data: None,
1865            }),
1866        }
1867    }
1868
1869    async fn list_tools(
1870        &self,
1871        _request: Option<PaginatedRequestParams>,
1872        _context: RequestContext<RoleServer>,
1873    ) -> Result<ListToolsResult, McpError> {
1874        fn schema_to_map(
1875            schema: schemars::Schema,
1876        ) -> Result<Arc<serde_json::Map<String, serde_json::Value>>, McpError> {
1877            let json = serde_json::to_value(&schema).map_err(|e| {
1878                McpError::internal_error(format!("Failed to serialize schema: {}", e), None)
1879            })?;
1880            match json {
1881                serde_json::Value::Object(o) => Ok(Arc::new(o)),
1882                _ => Ok(Arc::new(serde_json::Map::new())),
1883            }
1884        }
1885
1886        let tools = vec![
1887            Tool {
1888                name: "eval".into(),
1889                title: None,
1890                description: Some(self.eval_tool_description.clone().into()),
1891                input_schema: schema_to_map(schemars::schema_for!(EvalRequest))?,
1892                output_schema: None,
1893                annotations: None,
1894                icons: None,
1895                meta: None,
1896                execution: None,
1897            },
1898            Tool {
1899                name: "resume".into(),
1900                title: None,
1901                description: Some(
1902                    "Resume a suspended Haskell evaluation. When eval returns \
1903                     {\"suspended\": true, \"continuation_id\": \"...\", \"prompt\": \"...\"}, \
1904                     call this tool with the continuation_id and your response to the prompt."
1905                        .into(),
1906                ),
1907                input_schema: schema_to_map(schemars::schema_for!(ResumeRequest))?,
1908                output_schema: None,
1909                annotations: None,
1910                icons: None,
1911                meta: None,
1912                execution: None,
1913            },
1914        ];
1915
1916        Ok(ListToolsResult {
1917            tools,
1918            next_cursor: None,
1919            meta: None,
1920        })
1921    }
1922}
1923
1924// ---------------------------------------------------------------------------
1925// Public API
1926// ---------------------------------------------------------------------------
1927
1928impl<H> TidepoolMcpServer<H>
1929where
1930    H: DispatchEffect<CapturedOutput> + Clone + Send + Sync + 'static + CollectEffectDecls,
1931{
1932    /// Create a new server with the given effect handler stack.
1933    ///
1934    /// Effect declarations are collected automatically from handlers that
1935    /// implement `DescribeEffect`.
1936    pub fn new(handler: H) -> Self {
1937        let mut decls = H::collect_decls();
1938        let ask_tag = decls.len() as u64;
1939        decls.push(ask_decl());
1940        let effect_names: Vec<String> = decls.iter().map(|d| d.type_name.to_string()).collect();
1941        Self {
1942            inner: TidepoolMcpServerImpl {
1943                handler_factory: Arc::new(handler),
1944                include: Vec::new(),
1945                haskell_preamble: build_preamble(&decls, false),
1946                effect_stack_type: build_effect_stack_type(&decls),
1947                eval_tool_description: build_eval_tool_description(&decls),
1948                has_user_library: false,
1949                ask_tag,
1950                effect_names,
1951                continuations: Arc::new(std::sync::Mutex::new(HashMap::new())),
1952                next_cont_id: Arc::new(AtomicU64::new(1)),
1953                eval_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_EVALS)),
1954            },
1955            _phantom: PhantomData,
1956        }
1957    }
1958
1959    /// Add include paths for Haskell module resolution.
1960    pub fn with_include(mut self, paths: Vec<PathBuf>) -> Self {
1961        self.inner.include = paths;
1962        self
1963    }
1964
1965    /// Add the bundled Tidepool prelude to the include paths.
1966    ///
1967    /// Looks for the prelude in this order:
1968    /// 1. `TIDEPOOL_PRELUDE_DIR` environment variable
1969    /// 2. The provided fallback path
1970    ///
1971    /// The prelude provides source definitions for common Prelude functions
1972    /// (reverse, splitAt, sort, etc.) whose GHC base library workers lack
1973    /// unfoldings in .hi files.
1974    pub fn with_prelude(mut self, fallback: PathBuf) -> Self {
1975        let prelude_dir = std::env::var_os("TIDEPOOL_PRELUDE_DIR")
1976            .map(PathBuf::from)
1977            .unwrap_or(fallback);
1978        self.inner.include.push(prelude_dir);
1979
1980        // Probe for user library directory
1981        let user_lib = PathBuf::from(".tidepool/lib");
1982        if user_lib.is_dir() {
1983            self.inner.has_user_library = user_lib.join("Library.hs").exists();
1984            self.inner.include.push(user_lib);
1985            if self.inner.has_user_library {
1986                // Rebuild preamble with user library import
1987                let mut decls = H::collect_decls();
1988                decls.push(ask_decl());
1989                self.inner.haskell_preamble = build_preamble(&decls, true);
1990                // Append note to tool description
1991                self.inner.eval_tool_description.push_str(
1992                    "\n\nUser library: `Library` is auto-imported from `.tidepool/lib/Library.hs`. \
1993                     Other modules in `.tidepool/lib/` can be imported explicitly via the `imports` field."
1994                );
1995            }
1996        }
1997
1998        self
1999    }
2000
2001    /// Start the MCP server on stdio transport.
2002    pub async fn serve_stdio(self) -> Result<(), Box<dyn std::error::Error>> {
2003        self.inner
2004            .serve((stdin(), stdout()))
2005            .await?
2006            .waiting()
2007            .await?;
2008        Ok(())
2009    }
2010
2011    /// Start the MCP server on streamable HTTP transport.
2012    pub async fn serve_http(
2013        self,
2014        addr: std::net::SocketAddr,
2015    ) -> Result<(), Box<dyn std::error::Error>> {
2016        use rmcp::transport::streamable_http_server::{
2017            session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService,
2018        };
2019        use std::sync::Arc;
2020
2021        let template = self.inner;
2022        let config = StreamableHttpServerConfig::default();
2023        let cancel = config.cancellation_token.clone();
2024        let service = StreamableHttpService::new(
2025            move || Ok(template.clone()),
2026            Arc::new(LocalSessionManager::default()),
2027            config,
2028        );
2029        async fn health() -> axum::Json<serde_json::Value> {
2030            axum::Json(serde_json::json!({"status": "ok"}))
2031        }
2032
2033        let router = axum::Router::new()
2034            .route("/health", axum::routing::get(health))
2035            .nest_service("/mcp", service);
2036        let listener = tokio::net::TcpListener::bind(addr).await?;
2037        eprintln!(
2038            "Tidepool MCP v{} listening on http://{}/mcp",
2039            env!("CARGO_PKG_VERSION"),
2040            addr,
2041        );
2042        axum::serve(listener, router)
2043            .with_graceful_shutdown(async move {
2044                tokio::signal::ctrl_c().await.ok();
2045                cancel.cancel();
2046            })
2047            .await?;
2048        Ok(())
2049    }
2050}
2051
2052// ---------------------------------------------------------------------------
2053// Tests
2054// ---------------------------------------------------------------------------
2055
2056#[cfg(test)]
2057mod tests {
2058    use super::*;
2059
2060    #[test]
2061    fn test_eval_request_string_code() {
2062        let json = serde_json::json!({"code": "let x = 1\npure x"});
2063        let req: EvalRequest = serde_json::from_value(json).unwrap();
2064        assert_eq!(req.code, "let x = 1\npure x");
2065        assert!(req.imports.is_empty());
2066        assert!(req.helpers.is_empty());
2067    }
2068
2069    #[test]
2070    fn test_eval_request_string_imports() {
2071        let json = serde_json::json!({"code": "pure 42", "imports": "Data.List (sort)\nData.Char"});
2072        let req: EvalRequest = serde_json::from_value(json).unwrap();
2073        assert_eq!(req.imports, "Data.List (sort)\nData.Char");
2074    }
2075
2076    #[test]
2077    fn test_rejected_imports() {
2078        assert!(rejected_import("System.IO.Unsafe (unsafePerformIO)").is_some());
2079        assert!(rejected_import("System.Process (callCommand)").is_some());
2080        assert!(rejected_import("System.Posix.Signals").is_some());
2081        assert!(rejected_import("GHC.IO.Handle").is_some());
2082        assert!(rejected_import("Network.Socket").is_some());
2083        assert!(rejected_import("Control.Concurrent (forkIO)").is_some());
2084        assert!(rejected_import("Foreign.Ptr").is_some());
2085        // Safe imports should pass
2086        assert!(rejected_import("Data.List (sort)").is_none());
2087        assert!(rejected_import("Data.Map.Strict").is_none());
2088        assert!(rejected_import("Tidepool.Text").is_none());
2089        assert!(rejected_import("qualified Data.Text as T").is_none());
2090    }
2091
2092    #[test]
2093    fn test_build_preamble() {
2094        let effects = vec![
2095            EffectDecl {
2096                type_name: "Console",
2097                description: "Print output",
2098                constructors: &["Print :: Text -> Console ()"],
2099                type_defs: &[],
2100                helpers: &[],
2101            },
2102            EffectDecl {
2103                type_name: "KV",
2104                description: "Key-value store",
2105                constructors: &[
2106                    "KvGet :: Text -> KV (Maybe Text)",
2107                    "KvSet :: Text -> Text -> KV ()",
2108                ],
2109                type_defs: &[],
2110                helpers: &[],
2111            },
2112        ];
2113        let preamble = build_preamble(&effects, false);
2114        assert!(preamble.contains("data Console a where"));
2115        assert!(preamble.contains("  Print :: Text -> Console ()"));
2116        assert!(preamble.contains("data KV a where"));
2117    }
2118
2119    #[test]
2120    fn test_build_effect_stack_type() {
2121        let effects = vec![
2122            EffectDecl {
2123                type_name: "Console",
2124                description: "",
2125                constructors: &[],
2126                type_defs: &[],
2127                helpers: &[],
2128            },
2129            EffectDecl {
2130                type_name: "KV",
2131                description: "",
2132                constructors: &[],
2133                type_defs: &[],
2134                helpers: &[],
2135            },
2136            EffectDecl {
2137                type_name: "Fs",
2138                description: "",
2139                constructors: &[],
2140                type_defs: &[],
2141                helpers: &[],
2142            },
2143        ];
2144        assert_eq!(build_effect_stack_type(&effects), "'[Console, KV, Fs]");
2145        assert_eq!(build_effect_stack_type(&[]), "'[]");
2146    }
2147
2148    #[test]
2149    fn test_template_haskell() {
2150        let effects = vec![EffectDecl {
2151            type_name: "Console",
2152            description: "",
2153            constructors: &["Print :: Text -> Console ()"],
2154            type_defs: &[],
2155            helpers: &[],
2156        }];
2157        let preamble = build_preamble(&effects, false);
2158        let stack = build_effect_stack_type(&effects);
2159        let source = "let x = 42\npure x";
2160
2161        let result = template_haskell(&preamble, &stack, source, "", "", None, None);
2162
2163        assert!(result.contains("module Expr where"));
2164        assert!(result.contains("import Control.Monad.Freer hiding (run)"));
2165        assert!(result.contains("data Console a where"));
2166        assert!(result.contains("result :: Eff '[Console] Value"));
2167        assert!(result.contains("result = do"));
2168        assert!(result.contains("  let x = 42"));
2169        assert!(result.contains("  pure x"));
2170    }
2171
2172    #[test]
2173    fn test_eval_tool_description_includes_effects() {
2174        let effects = vec![EffectDecl {
2175            type_name: "Console",
2176            description: "Print to console",
2177            constructors: &["Print :: Text -> Console ()"],
2178            type_defs: &[],
2179            helpers: &["say :: Text -> M ()\nsay = send . Print"],
2180        }];
2181        let desc = build_eval_tool_description(&effects);
2182        assert!(desc.contains("Console: Print to console"));
2183        assert!(desc.contains("Print :: Text -> Console ()"));
2184        assert!(desc.contains("say :: Text -> M ()"));
2185        assert!(desc.contains("Built-in helpers"));
2186    }
2187
2188    #[test]
2189    fn test_preamble_includes_helpers() {
2190        let decls = standard_decls();
2191        let preamble = build_preamble(&decls, false);
2192        assert!(preamble.contains("say :: Text -> M ()\nsay t"));
2193        assert!(preamble.contains("kvGet :: Text -> M (Maybe Value)\nkvGet = send . KvGet"));
2194        assert!(preamble.contains("fsRead :: Text -> M Text\nfsRead = send . FsRead"));
2195        assert!(preamble.contains("httpGet :: Text -> M Value\nhttpGet = send . HttpGet"));
2196        assert!(preamble.contains(
2197            "metaConstructors :: M [(Text, Int)]\nmetaConstructors = send MetaConstructors"
2198        ));
2199        assert!(preamble.contains("metaVersion :: M Text\nmetaVersion = send MetaVersion"));
2200        assert!(preamble.contains("ask :: Text -> M Value\nask = send . Ask"));
2201    }
2202
2203    #[test]
2204    fn test_format_panic_payload() {
2205        use std::any::Any;
2206
2207        let s = "string panic".to_string();
2208        let payload: Box<dyn Any + Send> = Box::new(s);
2209        assert_eq!(format_panic_payload(payload), "string panic");
2210
2211        let s = "str panic";
2212        let payload: Box<dyn Any + Send> = Box::new(s);
2213        assert_eq!(format_panic_payload(payload), "str panic");
2214
2215        let payload: Box<dyn Any + Send> = Box::new(42);
2216        assert_eq!(format_panic_payload(payload), "unknown panic");
2217    }
2218
2219    #[test]
2220    fn test_format_error_with_source() {
2221        let title = "Error";
2222        let error = "Type mismatch";
2223        let source = "preamble stuff\n-- [user]\nresult = do\n  pure 42\n";
2224        let formatted = format_error_with_source(title, error, source);
2225
2226        assert!(formatted.contains("## Error"));
2227        assert!(formatted.contains("Type mismatch"));
2228        assert!(formatted.contains("## User Code"));
2229        assert!(formatted.contains("```haskell\nresult = do\n  pure 42\n\n```"));
2230        // Preamble should be trimmed
2231        assert!(!formatted.contains("preamble stuff"));
2232    }
2233
2234    #[test]
2235    fn test_format_error_no_marker_shows_full() {
2236        let formatted = format_error_with_source("Error", "oops", "full source");
2237        assert!(formatted.contains("full source"));
2238    }
2239
2240    #[test]
2241    fn test_ask_decl() {
2242        let decl = ask_decl();
2243        assert_eq!(decl.type_name, "Ask");
2244        assert_eq!(decl.constructors.len(), 1);
2245        assert!(decl.constructors[0].contains("Ask :: Text -> Ask Value"));
2246    }
2247
2248    #[test]
2249    fn test_standard_decls_includes_ask() {
2250        let decls = standard_decls();
2251        assert_eq!(decls.len(), 10);
2252        assert_eq!(decls[4].type_name, "Http");
2253        assert_eq!(decls[5].type_name, "Exec");
2254        assert_eq!(decls[6].type_name, "Meta");
2255        assert_eq!(decls[7].type_name, "Git");
2256        assert_eq!(decls[8].type_name, "Llm");
2257        assert_eq!(decls[9].type_name, "Ask");
2258    }
2259
2260    #[test]
2261    fn test_resume_request_parse() {
2262        let json = serde_json::json!({
2263            "continuation_id": "cont_1",
2264            "response": "hello"
2265        });
2266        let req: ResumeRequest = serde_json::from_value(json).unwrap();
2267        assert_eq!(req.continuation_id, "cont_1");
2268        assert_eq!(req.response, "hello");
2269    }
2270
2271    #[test]
2272    fn test_ask_in_preamble() {
2273        let decls = standard_decls();
2274        let preamble = build_preamble(&decls, false);
2275        assert!(preamble.contains("data Ask a where"));
2276        assert!(preamble.contains("  Ask :: Text -> Ask Value"));
2277        assert!(preamble
2278            .contains("type M = Eff '[Console, KV, Fs, SG, Http, Exec, Meta, Git, Llm, Ask]"));
2279    }
2280
2281    #[test]
2282    fn test_ask_in_effect_stack_type() {
2283        let decls = standard_decls();
2284        let stack = build_effect_stack_type(&decls);
2285        assert_eq!(
2286            stack,
2287            "'[Console, KV, Fs, SG, Http, Exec, Meta, Git, Llm, Ask]"
2288        );
2289    }
2290
2291    #[test]
2292    fn test_preamble_hides_run_from_freer() {
2293        let decls = standard_decls();
2294        let preamble = build_preamble(&decls, false);
2295        assert!(preamble.contains("import Control.Monad.Freer hiding (run)"));
2296        // Our run helper should still be present
2297        assert!(preamble.contains("run :: Text -> M (Int, Text, Text)\nrun = send . Run"));
2298    }
2299
2300    #[test]
2301    fn test_preamble_text_error_shadow() {
2302        let decls = standard_decls();
2303        let preamble = build_preamble(&decls, false);
2304        // Prelude error (String-based) is hidden
2305        assert!(preamble.contains("import Tidepool.Prelude hiding (error)"));
2306        // Text-taking error is defined via qualified Prelude
2307        assert!(preamble.contains("import qualified Prelude as P"));
2308        assert!(preamble.contains("error :: Text -> a\nerror = P.error . T.unpack"));
2309    }
2310
2311    #[test]
2312    fn test_exec_decl_has_run_json() {
2313        let decl = exec_decl();
2314        assert_eq!(decl.type_name, "Exec");
2315        assert!(decl
2316            .constructors
2317            .iter()
2318            .any(|c| c.contains("RunJson :: Text -> Exec Value")));
2319        assert!(decl
2320            .constructors
2321            .iter()
2322            .any(|c| c.contains("Run :: Text -> Exec (Int, Text, Text)")));
2323        assert!(decl
2324            .constructors
2325            .iter()
2326            .any(|c| c.contains("RunIn :: Text -> Text -> Exec (Int, Text, Text)")));
2327    }
2328
2329    #[test]
2330    fn test_preamble_orchestration_helpers() {
2331        let decls = standard_decls();
2332        let preamble = build_preamble(&decls, true);
2333        // runChecked uses our Text error, not String error
2334        assert!(preamble.contains("runChecked :: Text -> M Text"));
2335        assert!(preamble.contains("else error (\"command failed: \""));
2336        // runJson is a thin wrapper over the effect constructor
2337        assert!(preamble.contains("runJson :: Text -> M Value\nrunJson = send . RunJson"));
2338        // File manipulation helpers
2339        assert!(preamble.contains("mapFile :: Text -> (Text -> Text) -> M ()"));
2340        assert!(preamble.contains("mapFileM :: Text -> (Text -> M Text) -> M ()"));
2341        assert!(preamble.contains("searchFiles :: Text -> Text -> M [(Text, Int, Text)]"));
2342        assert!(preamble.contains("lineCount :: Text -> M Int"));
2343        assert!(preamble.contains("fileContains :: Text -> Text -> M Bool"));
2344        // KV batch helpers
2345        assert!(preamble.contains("kvAll :: M [(Text, Value)]"));
2346        assert!(preamble.contains("kvClear :: M ()"));
2347        assert!(preamble.contains("runAll :: [Text] -> M [(Int, Text, Text)]"));
2348        // Git-aware analysis helpers
2349        assert!(preamble.contains("extLang :: Text -> Maybe Lang"));
2350        assert!(preamble.contains("funcPattern :: Lang -> Text"));
2351        assert!(preamble.contains("changedFunctions :: Text -> M [Value]"));
2352        assert!(preamble.contains("reviewCommit :: Text -> M Value"));
2353        assert!(preamble.contains("staleBranches :: Int -> M [Value]"));
2354        assert!(preamble.contains("findAndPreview :: Lang -> Text -> Text -> [Text] -> M [Value]"));
2355        assert!(preamble.contains("todoScan :: Text -> M [Value]"));
2356        assert!(preamble.contains("deadCode :: Lang -> Text -> M [Value]"));
2357        // Heuristic combinators
2358        assert!(preamble.contains("data Q a = Q Schema (Value -> a) Double"));
2359        assert!(preamble.contains("data Judged a = Sure a | Unsure Double a"));
2360        assert!(preamble.contains("(??) :: Q a -> Text -> M a"));
2361        assert!(preamble.contains("(?!) :: Q a -> Text -> M (Judged a)"));
2362        assert!(preamble.contains("pick :: [Text] -> Q Text"));
2363        assert!(preamble.contains("yn :: Q Bool"));
2364        assert!(preamble.contains("obj :: Schema -> Q Value"));
2365        assert!(preamble.contains("txt :: Text -> Q Text"));
2366        assert!(preamble.contains("num :: Text -> Q Double"));
2367        assert!(preamble.contains("bar :: Double -> Q a -> Q a"));
2368        assert!(preamble.contains("triage :: Q b -> (a -> Text) -> [a] -> M [(a, b)]"));
2369        assert!(preamble.contains("survey :: Eq b => Q b -> (a -> Text) -> [a] -> M [(b, Int)]"));
2370        assert!(preamble.contains("sift :: Q Bool -> (a -> Text) -> [a] -> M ([a], [a])"));
2371    }
2372
2373    #[test]
2374    fn test_preamble_no_orchestration_without_library() {
2375        let decls = standard_decls();
2376        let preamble = build_preamble(&decls, false);
2377        // Orchestration helpers only appear with user_library=true
2378        assert!(!preamble.contains("runChecked"));
2379        assert!(!preamble.contains("runJson :: Text -> M Value"));
2380    }
2381
2382    #[test]
2383    fn test_preamble_sg_rule_operators() {
2384        let decls = standard_decls();
2385        let preamble = build_preamble(&decls, false);
2386        // Object merge operator
2387        assert!(preamble.contains("infixr 6 .+."));
2388        assert!(preamble.contains("(.+.) :: Value -> Value -> Value"));
2389        assert!(preamble.contains("KM.unionWith const"));
2390        // Conjunction / disjunction
2391        assert!(preamble.contains("infixr 5 .&."));
2392        assert!(preamble.contains("infixr 4 .|."));
2393        // Relational operators
2394        assert!(preamble.contains("infixl 7 ?>"));
2395        assert!(preamble.contains("infixl 7 <?"));
2396        // Extra helpers
2397        assert!(preamble.contains("rField :: Text -> Value"));
2398        assert!(preamble.contains("rStopBy :: Text -> Value"));
2399    }
2400
2401    #[test]
2402    fn test_parse_constructor_no_args() {
2403        let p = parse_constructor("GitBranches :: Git [Value]").unwrap();
2404        assert_eq!(
2405            p,
2406            ParsedConstructor {
2407                name: "GitBranches".into(),
2408                arity: 0
2409            }
2410        );
2411    }
2412
2413    #[test]
2414    fn test_parse_constructor_two_args() {
2415        let p = parse_constructor("GitLog :: Text -> Int -> Git [Value]").unwrap();
2416        assert_eq!(
2417            p,
2418            ParsedConstructor {
2419                name: "GitLog".into(),
2420                arity: 2
2421            }
2422        );
2423    }
2424
2425    #[test]
2426    fn test_parse_constructor_nested_types() {
2427        let p =
2428            parse_constructor("HttpRequest :: Text -> Text -> [(Text,Text)] -> Text -> Http Value")
2429                .unwrap();
2430        assert_eq!(
2431            p,
2432            ParsedConstructor {
2433                name: "HttpRequest".into(),
2434                arity: 4
2435            }
2436        );
2437    }
2438
2439    #[test]
2440    fn test_preamble_required_imports() {
2441        let decls = standard_decls();
2442        let preamble = build_preamble(&decls, false);
2443        assert!(preamble.contains("import Tidepool.Prelude hiding (error)"));
2444        assert!(preamble.contains("import qualified Data.Text as T"));
2445        assert!(preamble.contains("import Control.Monad.Freer hiding (run)"));
2446        assert!(preamble.contains("import qualified Tidepool.Aeson.KeyMap as KM"));
2447    }
2448
2449    #[test]
2450    fn test_template_haskell_truncation() {
2451        let effects = vec![EffectDecl {
2452            type_name: "Console",
2453            description: "",
2454            constructors: &["Print :: Text -> Console ()"],
2455            type_defs: &[],
2456            helpers: &[],
2457        }];
2458        let preamble = build_preamble(&effects, false);
2459        let stack = build_effect_stack_type(&effects);
2460        let source = "pure 42";
2461
2462        // With budget
2463        let result = template_haskell(&preamble, &stack, source, "", "", None, Some(1024));
2464        assert!(result.contains("kvSet \"__sayChars\" (toJSON (0 :: Int))"));
2465        assert!(result.contains("paginateResult (max' 100 (1024 - _sayC)) (toJSON _r)"));
2466
2467        // Without budget (defaults to 4096)
2468        let result = template_haskell(&preamble, &stack, source, "", "", None, None);
2469        assert!(result.contains("paginateResult 4096 (toJSON _r)"));
2470    }
2471
2472    #[test]
2473    fn test_template_haskell_input() {
2474        let effects = vec![EffectDecl {
2475            type_name: "Console",
2476            description: "",
2477            constructors: &["Print :: Text -> Console ()"],
2478            type_defs: &[],
2479            helpers: &[],
2480        }];
2481        let preamble = build_preamble(&effects, false);
2482        let stack = build_effect_stack_type(&effects);
2483        let source = "pure 42";
2484        let input = serde_json::json!({"val": 123});
2485
2486        let result = template_haskell(&preamble, &stack, source, "", "", Some(&input), None);
2487
2488        assert!(result.contains("input :: Aeson.Value"));
2489        assert!(
2490            result.contains("input = object [\"val\" .= Aeson.Number (fromIntegral (123 :: Int))]")
2491        );
2492    }
2493
2494    #[test]
2495    fn test_eval_timeout_value() {
2496        assert_eq!(EVAL_TIMEOUT_SECS, 120);
2497    }
2498
2499    #[test]
2500    fn test_effect_decls_basic_validation() {
2501        let console = console_decl();
2502        assert_eq!(console.type_name, "Console");
2503        assert!(console.constructors[0].contains("Print"));
2504
2505        let kv = kv_decl();
2506        assert_eq!(kv.type_name, "KV");
2507        assert!(kv.constructors.iter().any(|c| c.contains("KvGet")));
2508
2509        let fs = fs_decl();
2510        assert_eq!(fs.type_name, "Fs");
2511        assert!(fs.constructors.iter().any(|c| c.contains("FsRead")));
2512
2513        let http = http_decl();
2514        assert_eq!(http.type_name, "Http");
2515        assert!(http.constructors.iter().any(|c| c.contains("HttpGet")));
2516    }
2517
2518    #[test]
2519    fn test_eval_request_helpers() {
2520        let json = serde_json::json!({
2521            "code": "pure 42",
2522            "helpers": "foo :: Int -> Int\nfoo x = x + 1"
2523        });
2524        let req: EvalRequest = serde_json::from_value(json).unwrap();
2525        assert_eq!(req.helpers, "foo :: Int -> Int\nfoo x = x + 1");
2526    }
2527
2528    #[test]
2529    fn test_eval_request_input() {
2530        let json = serde_json::json!({
2531            "code": "pure 42",
2532            "input": {"key": "value", "num": 123}
2533        });
2534        let req: EvalRequest = serde_json::from_value(json).unwrap();
2535        assert!(req.input.is_some());
2536        let input = req.input.unwrap();
2537        assert_eq!(input["key"], "value");
2538        assert_eq!(input["num"], 123);
2539    }
2540
2541    #[test]
2542    fn test_json_to_haskell() {
2543        let val = serde_json::json!({
2544            "str": "hello",
2545            "bool": true,
2546            "null": null,
2547            "num": 42,
2548            "arr": [1, 2],
2549            "obj": {"a": 1}
2550        });
2551        let haskell = json_to_haskell(&val);
2552        assert!(haskell.contains("\"str\" .= Aeson.String \"hello\""));
2553        assert!(haskell.contains("\"bool\" .= Aeson.Bool True"));
2554        assert!(haskell.contains("\"null\" .= Aeson.Null"));
2555        assert!(haskell.contains("\"num\" .= Aeson.Number (fromIntegral (42 :: Int))"));
2556        assert!(haskell.contains("\"arr\" .= toJSON [Aeson.Number (fromIntegral (1 :: Int)), Aeson.Number (fromIntegral (2 :: Int))]"));
2557        assert!(
2558            haskell.contains("\"obj\" .= object [\"a\" .= Aeson.Number (fromIntegral (1 :: Int))]")
2559        );
2560    }
2561
2562    #[tokio::test]
2563    async fn test_handle_session_result_completed() {
2564        let server = create_mock_server();
2565        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2566        let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2567        let source: Arc<str> = "test source".into();
2568        let captured = CapturedOutput::new();
2569        captured.push("log1".into());
2570
2571        tx.send(SessionMessage::Completed {
2572            result: "42".into(),
2573        })
2574        .unwrap();
2575
2576        let res = server
2577            .handle_session_result("eval", rx, source, resp_tx, captured)
2578            .await
2579            .unwrap();
2580        assert_eq!(res.is_error, Some(false));
2581        let text = match &res.content[0].raw {
2582            RawContent::Text(t) => &t.text,
2583            _ => panic!("Expected text content"),
2584        };
2585        assert!(text.contains("## Output\nlog1\n"));
2586        assert!(text.contains("\n## Result\n42"));
2587    }
2588
2589    #[tokio::test]
2590    async fn test_handle_session_result_suspended() {
2591        let server = create_mock_server();
2592        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2593        let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2594        let source: Arc<str> = "test source".into();
2595        let captured = CapturedOutput::new();
2596
2597        tx.send(SessionMessage::Suspended {
2598            prompt: "what is your name?".into(),
2599        })
2600        .unwrap();
2601
2602        let res = server
2603            .handle_session_result("eval", rx, source, resp_tx, captured)
2604            .await
2605            .unwrap();
2606        assert_eq!(res.is_error, Some(false));
2607        let text = match &res.content[0].raw {
2608            RawContent::Text(t) => &t.text,
2609            _ => panic!("Expected text content"),
2610        };
2611        let json: serde_json::Value = serde_json::from_str(text).unwrap();
2612        assert_eq!(json["suspended"], true);
2613        assert_eq!(json["prompt"], "what is your name?");
2614        assert!(json["continuation_id"]
2615            .as_str()
2616            .unwrap()
2617            .starts_with("cont_"));
2618
2619        // Check if it's in the continuations map
2620        let cont_id = json["continuation_id"].as_str().unwrap();
2621        let conts = server.continuations.lock().unwrap();
2622        assert!(conts.contains_key(cont_id));
2623    }
2624
2625    #[tokio::test]
2626    async fn test_handle_session_result_error() {
2627        let server = create_mock_server();
2628        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2629        let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2630        let source: Arc<str> = "test source".into();
2631        let captured = CapturedOutput::new();
2632
2633        tx.send(SessionMessage::Error {
2634            error: "oops".into(),
2635        })
2636        .unwrap();
2637
2638        let res = server
2639            .handle_session_result("eval", rx, source, resp_tx, captured)
2640            .await
2641            .unwrap();
2642        assert_eq!(res.is_error, Some(true));
2643        let text = match &res.content[0].raw {
2644            RawContent::Text(t) => &t.text,
2645            _ => panic!("Expected text content"),
2646        };
2647        assert!(text.contains("## Error"));
2648        assert!(text.contains("oops"));
2649    }
2650
2651    #[tokio::test]
2652    async fn test_handle_session_result_crash() {
2653        let server = create_mock_server();
2654        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2655        let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2656        let source: Arc<str> = "test source".into();
2657        let captured = CapturedOutput::new();
2658
2659        // Close the channel without sending anything
2660        drop(tx);
2661
2662        let res = server
2663            .handle_session_result("eval", rx, source, resp_tx, captured)
2664            .await
2665            .unwrap();
2666        assert_eq!(res.is_error, Some(true));
2667        let text = match &res.content[0].raw {
2668            RawContent::Text(t) => &t.text,
2669            _ => panic!("Expected text content"),
2670        };
2671        assert!(text.contains("## Crash"));
2672        assert!(text.contains("eval thread crashed"));
2673    }
2674
2675    #[tokio::test]
2676    async fn test_handle_session_result_timeout() {
2677        tokio::time::pause();
2678
2679        let server = create_mock_server();
2680        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
2681        let (resp_tx, _resp_rx) = std::sync::mpsc::channel();
2682        let source: Arc<str> = "test source".into();
2683        let captured = CapturedOutput::new();
2684
2685        let handle = tokio::spawn(async move {
2686            server
2687                .handle_session_result("eval", rx, source, resp_tx, captured)
2688                .await
2689        });
2690
2691        // Advance time past EVAL_TIMEOUT_SECS
2692        tokio::time::advance(Duration::from_secs(EVAL_TIMEOUT_SECS + 1)).await;
2693
2694        let res = handle.await.unwrap().unwrap();
2695        assert_eq!(res.is_error, Some(true));
2696        let text = match &res.content[0].raw {
2697            RawContent::Text(t) => &t.text,
2698            _ => panic!("Expected text content"),
2699        };
2700        assert!(text.contains("## Timeout"));
2701        assert!(text.contains("timed out"));
2702    }
2703
2704    fn create_mock_server() -> TidepoolMcpServerImpl {
2705        #[derive(Clone)]
2706        struct MockHandler;
2707        impl DispatchEffect<CapturedOutput> for MockHandler {
2708            fn dispatch(
2709                &mut self,
2710                _tag: u64,
2711                _request: &tidepool_eval::value::Value,
2712                _cx: &tidepool_effect::EffectContext<'_, CapturedOutput>,
2713            ) -> Result<tidepool_eval::value::Value, tidepool_effect::error::EffectError>
2714            {
2715                Ok(tidepool_eval::value::Value::Lit(
2716                    tidepool_repr::Literal::LitInt(0),
2717                ))
2718            }
2719        }
2720
2721        TidepoolMcpServerImpl {
2722            handler_factory: Arc::new(MockHandler),
2723            include: Vec::new(),
2724            haskell_preamble: String::new(),
2725            effect_stack_type: String::new(),
2726            eval_tool_description: String::new(),
2727            has_user_library: false,
2728            ask_tag: 0,
2729            effect_names: Vec::new(),
2730            continuations: Arc::new(std::sync::Mutex::new(HashMap::new())),
2731            next_cont_id: Arc::new(AtomicU64::new(1)),
2732            eval_semaphore: Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_EVALS)),
2733        }
2734    }
2735
2736    #[test]
2737    fn test_rejected_import_edge_cases() {
2738        // Qualified unsafe
2739        assert!(rejected_import("qualified System.IO.Unsafe as Safe").is_some());
2740        // Extra whitespace
2741        assert!(rejected_import("  System.IO.Unsafe  ").is_some());
2742        // Safe Data imports
2743        assert!(rejected_import("Data.Map (Map, fromList)").is_none());
2744        // Tidepool modules
2745        assert!(rejected_import("Tidepool.Table").is_none());
2746        // Empty string
2747        assert!(rejected_import("").is_none());
2748    }
2749
2750    #[test]
2751    fn test_format_error_with_source_multiline() {
2752        let title = "Compile Error";
2753        let error = "Variable not in scope: x";
2754        let source = "module Test where\n-- [user]\nmain = do\n  print x\n  print y\n  print z";
2755        let formatted = format_error_with_source(title, error, source);
2756
2757        assert!(formatted.contains("## Compile Error"));
2758        assert!(formatted.contains("Variable not in scope: x"));
2759        assert!(formatted.contains("## User Code"));
2760        assert!(formatted.contains("main = do\n  print x\n  print y\n  print z"));
2761        assert!(!formatted.contains("module Test where"));
2762    }
2763
2764    #[test]
2765    fn test_format_error_empty_source() {
2766        let formatted = format_error_with_source("Error", "msg", "");
2767        assert!(formatted.contains("## Error"));
2768        assert!(formatted.contains("msg"));
2769        assert!(formatted.contains("## User Code"));
2770    }
2771
2772    #[test]
2773    fn test_captured_output_drain() {
2774        let output = CapturedOutput::new();
2775        output.push("line 1".to_string());
2776        output.push("line 2".to_string());
2777
2778        let drained = output.drain();
2779        assert_eq!(drained, vec!["line 1", "line 2"]);
2780
2781        let empty = output.drain();
2782        assert!(empty.is_empty());
2783    }
2784}