Skip to main content

pond/
render.rs

1//! Canonical text-transcript rendering for `pond_search` / `pond_get`
2//! responses, shared by the MCP transport and the `pond` CLI so both surfaces
3//! emit one identical readable format (spec.md#protocol). The structured
4//! HTTP/JSON path renders nothing here; this is the plain-text view.
5
6use crate::handlers::default_excludes_subagents;
7use crate::wire::{
8    GetRequest, GetResponse, GetResult, MessageView, PartKind, PartSummary, ResponsePart,
9    SearchModeWire, SearchRequest, SearchResponse, SortBy,
10};
11
12/// Which surface a transcript renders for. The format is identical; only the
13/// follow-up vocabulary differs - the MCP tools are `pond_get` /
14/// `pond_sql_query` with `key=value` args, the CLI verbs are
15/// `pond get --message-id <ID>` / `pond sql`. Without this a human at the
16/// terminal is told to run tool syntax their shell rejects.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Surface {
19    Mcp,
20    Cli,
21}
22
23/// `1 message` / `2 messages`: a count with a correctly pluralized noun.
24fn count_noun(count: usize, noun: &str) -> String {
25    if count == 1 {
26        format!("{count} {noun}")
27    } else {
28        format!("{count} {noun}s")
29    }
30}
31
32/// Footer for a `pond_get` session response listing the session's spawn-only
33/// subagents. Each subagent is its own session (spec.md#datasets) addressable
34/// by the printed id, so the caller can open any with `pond_get(session_id)`;
35/// without this they are invisible from the MCP surface.
36pub fn render_subagents_footer(children: &[crate::wire::Session], surface: Surface) -> String {
37    use std::fmt::Write;
38    let how = match surface {
39        Surface::Mcp => "pass an id to pond_get(session_id=...)",
40        Surface::Cli => "pass an id to `pond get --session-id <ID>`",
41    };
42    let mut out = String::new();
43    let _ = writeln!(out);
44    let _ = writeln!(out, "subagents ({}) - {how}:", children.len());
45    for child in children {
46        let _ = writeln!(out, "  {} | {}", child.id, child.source_agent);
47    }
48    out
49}
50
51/// `YYYY-MM-DD HH:MM:SSZ` - compact, sortable, timezone-explicit.
52fn fmt_ts(ts: &chrono::DateTime<chrono::Utc>) -> String {
53    ts.format("%Y-%m-%d %H:%M:%SZ").to_string()
54}
55
56/// Inner string of an `Extracted<String>` option, or `?` when the source
57/// carried none (spec.md#model-no-synthesis: absence is real, not a blank).
58fn opt_name(value: &Option<crate::adapter::extract::Extracted<String>>) -> &str {
59    value.as_deref().map(String::as_str).unwrap_or("?")
60}
61
62/// Append each line of `body` to `out`, so escaped `\n` in stored text
63/// renders as real line breaks. A trailing blank line in the source is
64/// dropped (lines() already does this).
65fn push_lines(out: &mut String, body: &str, indent: &str) {
66    use std::fmt::Write;
67    for line in body.lines() {
68        let _ = writeln!(out, "{indent}{line}");
69    }
70}
71
72/// Char ceiling for a rendered `pond_search` transcript (spec.md#search).
73/// Enforced as per-session fair-share truncation that always renders every
74/// returned session's top hit - never a whole-response guillotine. The
75/// structured response (HTTP) is unaffected; this bounds only the agent
76/// transcript. Soft: a single session's header + one hit may nudge past it.
77const SEARCH_TRANSCRIPT_BUDGET: usize = 10_000;
78
79pub fn render_search_transcript(
80    response: &SearchResponse,
81    request: &SearchRequest,
82    surface: Surface,
83) -> String {
84    use std::fmt::Write;
85    let prefix = match surface {
86        Surface::Mcp => "pond_search",
87        Surface::Cli => "pond search",
88    };
89    let subagent_note = match (default_excludes_subagents(&request.filters), surface) {
90        (false, _) => "",
91        (true, Surface::Mcp) => {
92            " Subagent sessions excluded; reach them via pond_sql_query (parent_session_id)."
93        }
94        (true, Surface::Cli) => {
95            " Subagent sessions excluded; reach them via `pond sql` (parent_session_id)."
96        }
97    };
98    let recency_note = if matches!(request.sort_by, SortBy::Recency) {
99        " Sorted by recency (newest first) - rank is NOT match strength."
100    } else {
101        ""
102    };
103    if response.sessions.is_empty() {
104        // spec.md#search-absence-honesty: name the scope size and the
105        // recovery path - a zero-hit response must distinguish "nothing
106        // relevant exists" from "the filters excluded everything" from "the
107        // store simply has nothing stored yet".
108        if response.searchable_in_scope == 0 {
109            let scoped = request.filters.project.is_some()
110                || request.filters.session_id.is_some()
111                || request.filters.from_date.is_some()
112                || request.filters.to_date.is_some();
113            if scoped {
114                return format!(
115                    "{prefix}: 0 searchable messages in scope - the filters exclude \
116                     everything before retrieval. Widen or drop project/date filters.\
117                     {subagent_note}\n"
118                );
119            }
120            // No filters were set: the corpus itself is empty, so pointing
121            // at filters would send the user chasing settings they never made.
122            return match surface {
123                Surface::Cli => format!(
124                    "{prefix}: no sessions stored yet - run `pond init` to set up \
125                     adapters, then `pond sync` to import your history.\n"
126                ),
127                Surface::Mcp => format!(
128                    "{prefix}: the store has no searchable messages yet (nothing \
129                     ingested so far). Not an absence signal about the topic.\n"
130                ),
131            };
132        }
133        let fts_hint = match surface {
134            Surface::Mcp => {
135                " For exact strings or identifiers, try pond_sql_query: SELECT \
136                 message_id, session_id, search_text FROM messages WHERE \
137                 contains_tokens(search_text, '...')."
138            }
139            Surface::Cli => {
140                " For exact strings or identifiers, try: pond sql \"SELECT \
141                 message_id, session_id, search_text FROM messages WHERE \
142                 contains_tokens(search_text, '...')\"."
143            }
144        };
145        return format!(
146            "{prefix}: no matches for {:?} across {} in \
147             scope.{subagent_note}{fts_hint}\n",
148            request.query,
149            count_noun(response.searchable_in_scope, "searchable message"),
150        );
151    }
152    let shown: usize = response.sessions.iter().map(|s| s.matches.len()).sum();
153    // Vector mode ranks by similarity and ALWAYS returns the nearest rows,
154    // even when none truly match (cosine bands for present vs absent content
155    // overlap, so there is deliberately no score cutoff) - so call them
156    // "nearest", not "matching", or a gibberish query looks like confident
157    // relevance. fts requires real token overlap, so "matching" is honest there.
158    let vector_mode = matches!(request.mode, SearchModeWire::Vector);
159    let head_noun = if vector_mode {
160        "nearest message"
161    } else {
162        "matching message"
163    };
164    let mut out = String::new();
165    let _ = writeln!(
166        out,
167        "{prefix}: {} ({} searchable in scope), showing {} from {}.{}{}",
168        count_noun(response.matched_total, head_noun),
169        response.searchable_in_scope,
170        count_noun(shown, "hit"),
171        count_noun(response.sessions.len(), "session"),
172        subagent_note,
173        recency_note,
174    );
175    let order = if matches!(request.sort_by, SortBy::Recency) {
176        "newest session first"
177    } else {
178        "ordered by best hit"
179    };
180    let full_hint = match surface {
181        Surface::Mcp => "pond_get <message_id> for full",
182        Surface::Cli => "`pond get --message-id <ID>` for full",
183    };
184    let mode_note = match (vector_mode, surface) {
185        (false, _) => "",
186        (true, Surface::Cli) => {
187            " Vector mode returns the closest rows by meaning even when none are strong; for exact-word matching use --mode fts."
188        }
189        (true, Surface::Mcp) => {
190            " Vector mode returns the closest rows by meaning even when none are strong; for exact-word matching set mode=\"fts\"."
191        }
192    };
193    let _ = writeln!(
194        out,
195        "key: session rules group hits by session, {order}; within a session, messages are newest-first. \"--- [n] score | role | time | message_id | project | agent | session ---\" delimits each hit + matched text. {full_hint}; raise limit for more (no pagination).{mode_note}"
196    );
197    let mut index = 0;
198    let n_sessions = response.sessions.len();
199    for (session_index, session) in response.sessions.iter().enumerate() {
200        // Highest score among the session's matches. Not `matches.first()`:
201        // matches render newest-first, so the first need not be the best.
202        let best = session
203            .matches
204            .iter()
205            .map(|hit| hit.score)
206            .fold(0.0_f64, f64::max);
207        let _ = writeln!(out);
208        let _ = writeln!(
209            out,
210            "{}",
211            rule_line(&format!(
212                "session [{}] best {:.2} | {}/{} matched | {} | {} | {}",
213                session_index + 1,
214                best,
215                session.matched_message_count,
216                session.session_messages_count,
217                session.project,
218                session.source_agent,
219                session.session_id,
220            )),
221        );
222        // Even share of the remaining budget across the sessions still to
223        // render, so all of them surface at least their newest hit (never a
224        // whole-response guillotine). Extra hits in a session stop once its
225        // share is spent; the first hit always renders.
226        let remaining = SEARCH_TRANSCRIPT_BUDGET.saturating_sub(out.len());
227        let share = remaining / (n_sessions - session_index);
228        let session_start = out.len();
229        let mut rendered = 0usize;
230        for hit in &session.matches {
231            if rendered > 0 && out.len().saturating_sub(session_start) >= share {
232                break;
233            }
234            index += 1;
235            let _ = writeln!(out);
236            let _ = writeln!(
237                out,
238                "{}",
239                rule_line(&format!(
240                    "[{index}] {:.2} | {} | {} | {} | {} | {} | {}",
241                    hit.score,
242                    hit.role.as_str(),
243                    fmt_ts(&hit.timestamp),
244                    hit.message_id,
245                    session.project,
246                    session.source_agent,
247                    session.session_id,
248                )),
249            );
250            push_lines(&mut out, &hit.text, "");
251            rendered += 1;
252        }
253        // Intra-session supersession signal (spec.md#search): when the char
254        // budget cut this session's matches short, point the agent at the
255        // session's latest state, which may revise these older hits.
256        let omitted = session.matches.len() - rendered;
257        if omitted > 0 {
258            let latest_hint = match surface {
259                Surface::Mcp => "read with session_from=end",
260                Surface::Cli => "read with `pond get --session-id <ID> --session-from end`",
261            };
262            let _ = writeln!(
263                out,
264                "... {omitted} more match(es) in this session not shown (char budget); \
265                 {latest_hint} for the session's latest state"
266            );
267        }
268    }
269    out
270}
271
272pub fn render_get_transcript(
273    response: &GetResponse,
274    request: &GetRequest,
275    surface: Surface,
276) -> String {
277    use std::fmt::Write;
278    let prefix = match surface {
279        Surface::Mcp => "pond_get",
280        Surface::Cli => "pond get",
281    };
282    let session = &response.session;
283    let mut out = String::new();
284    match &response.result {
285        GetResult::Session {
286            messages,
287            before_remaining,
288            after_remaining,
289        } => {
290            let _ = writeln!(
291                out,
292                "{prefix}: session {}, {}.",
293                session.id,
294                count_noun(messages.len(), "message"),
295            );
296            let (expand_hint, page_hint) = match surface {
297                Surface::Mcp => (
298                    "pond_get message_id=<id> to expand any tool body",
299                    "Page with session_before_message_id / session_after_message_id.",
300                ),
301                Surface::Cli => (
302                    "`pond get --message-id <ID>` to expand any tool body",
303                    "Page with --session-before-message-id / --session-after-message-id.",
304                ),
305            };
306            let _ = writeln!(
307                out,
308                "key: \"--- [n] role | time | message_id ---\" delimits each message; \"->\" tool call, \"<-\" result; {expand_hint}. {page_hint}"
309            );
310            // Top marker: earlier messages precede this page (page up).
311            if *before_remaining > 0
312                && let Some(first) = messages.first()
313            {
314                let page_up = match surface {
315                    Surface::Mcp => format!("pass session_before_message_id={}", first.id),
316                    Surface::Cli => {
317                        format!("pass --session-before-message-id {}", first.id)
318                    }
319                };
320                let _ = writeln!(
321                    out,
322                    "... {before_remaining} earlier messages; {page_up} to page up",
323                );
324            }
325            for (idx, message) in messages.iter().enumerate() {
326                let _ = writeln!(out);
327                render_message(
328                    &mut out,
329                    idx + 1,
330                    message,
331                    None,
332                    &message.parts_summary,
333                    false,
334                );
335            }
336            let _ = writeln!(out);
337            let _ = writeln!(
338                out,
339                "session {} | {} | {}",
340                session.id, session.source_agent, session.project,
341            );
342            // Bottom marker: later messages follow this page (page down).
343            if *after_remaining > 0
344                && let Some(last) = messages.last()
345            {
346                let page_down = match surface {
347                    Surface::Mcp => format!("pass session_after_message_id={}", last.id),
348                    Surface::Cli => format!("pass --session-after-message-id {}", last.id),
349                };
350                let _ = writeln!(
351                    out,
352                    "... {after_remaining} later messages; {page_down} to page down",
353                );
354            }
355        }
356        GetResult::Message {
357            target,
358            target_parts,
359            target_parts_remaining,
360            siblings,
361        } => {
362            let _ = writeln!(
363                out,
364                "{prefix}: thread around {} in session {} (context -{}/+{}).",
365                target.id,
366                session.id,
367                request.message_context_before,
368                request.message_context_after,
369            );
370            let expand_hint = match surface {
371                Surface::Mcp => "pond_get message_id=<id> to expand any line",
372                Surface::Cli => "`pond get --message-id <ID>` to expand any line",
373            };
374            let _ = writeln!(
375                out,
376                "key: \"--- [n] role | time | message_id ---\" delimits each message; \">\" = the one you requested; \"->\" tool call, \"<-\" result. {expand_hint}."
377            );
378            // Interleave target with siblings, ordered by (timestamp, id) to
379            // match storage - codex writes many messages at the same
380            // timestamp, so the id is the real tiebreak (a bare timestamp
381            // sort scrambles them). Drop context siblings with nothing to
382            // render (carrier turns with no text/content); the requested
383            // target always stays, even if empty.
384            let mut thread: Vec<(&MessageView, bool)> =
385                siblings.iter().map(|view| (view, false)).collect();
386            thread.push((target, true));
387            thread.sort_by(|a, b| {
388                a.0.timestamp
389                    .cmp(&b.0.timestamp)
390                    .then_with(|| a.0.id.cmp(&b.0.id))
391            });
392            thread.retain(|(view, is_target)| *is_target || message_has_content(view));
393            for (idx, (view, is_target)) in thread.iter().enumerate() {
394                let _ = writeln!(out);
395                // Only the target carries full parts; siblings render as
396                // conversational text + one-line summaries.
397                let parts: Option<&[ResponsePart]> = is_target.then_some(target_parts.as_slice());
398                render_message(
399                    &mut out,
400                    idx + 1,
401                    view,
402                    parts,
403                    &view.parts_summary,
404                    *is_target,
405                );
406            }
407            let _ = writeln!(out);
408            let _ = writeln!(
409                out,
410                "session {} | {} | {}",
411                session.id, session.source_agent, session.project,
412            );
413            if *target_parts_remaining > 0 {
414                let _ = writeln!(
415                    out,
416                    "... {} more parts of {} omitted (response budget)",
417                    target_parts_remaining, target.id,
418                );
419            }
420        }
421    }
422    out
423}
424
425/// Whether a message view has anything to render below its header: real
426/// text/content or a one-line part summary. Used to drop empty carrier
427/// turns from message-scope context.
428fn message_has_content(view: &MessageView) -> bool {
429    view.text.as_deref().is_some_and(|t| !t.trim().is_empty())
430        || view
431            .content
432            .as_deref()
433            .is_some_and(|c| !c.trim().is_empty())
434        || !view.parts_summary.is_empty()
435}
436
437/// Target column width for a delimiter-rule header.
438const RULE_WIDTH: usize = 72;
439
440/// Wrap `inner` as a delimiter rule: `--- {inner} ----...` padded to
441/// [`RULE_WIDTH`] (always at least a 3-dash tail when `inner` is already
442/// wide). Used for both search hits and get message headers.
443fn rule_line(inner: &str) -> String {
444    let head = format!("--- {inner} ");
445    let pad = RULE_WIDTH.saturating_sub(head.chars().count()).max(3);
446    format!("{head}{}", "-".repeat(pad))
447}
448
449/// One message block: an indexed `--- [n] role | time | id ---` delimiter
450/// rule (unambiguous even when the body has blank lines or `##` headings),
451/// then text/content as real lines, then parts - full bodies when `parts`
452/// is present, else one-line summaries.
453fn render_message(
454    out: &mut String,
455    index: usize,
456    view: &MessageView,
457    parts: Option<&[ResponsePart]>,
458    summary: &[PartSummary],
459    is_target: bool,
460) {
461    use std::fmt::Write;
462    let marker = if is_target { "> " } else { "" };
463    let _ = writeln!(
464        out,
465        "{}",
466        rule_line(&format!(
467            "[{index}] {marker}{} | {} | {}",
468            view.role.as_str(),
469            fmt_ts(&view.timestamp),
470            view.id,
471        )),
472    );
473    if let Some(text) = &view.text {
474        push_lines(out, text, "");
475    }
476    if let Some(content) = &view.content {
477        push_lines(out, content, "");
478    }
479    match parts {
480        Some(parts) => {
481            for part in parts {
482                render_part_full(out, part);
483            }
484        }
485        None => {
486            for part in summary {
487                render_part_summary(out, part);
488            }
489        }
490    }
491}
492
493fn render_part_full(out: &mut String, part: &ResponsePart) {
494    use std::fmt::Write;
495    match &part.kind {
496        PartKind::Text { text } => {
497            if let Some(text) = text {
498                push_lines(out, text, "");
499            }
500        }
501        PartKind::Reasoning { text } => {
502            let _ = writeln!(out, "  (reasoning)");
503            if let Some(text) = text {
504                push_lines(out, text, "  ");
505            }
506        }
507        PartKind::ToolCall {
508            name,
509            call_id,
510            params,
511            ..
512        } => {
513            let _ = writeln!(out, "  -> {} [{}]", opt_name(name), opt_name(call_id));
514            push_lines(out, &value_to_text(params), "     ");
515        }
516        PartKind::ToolResult {
517            name,
518            call_id,
519            is_failure,
520            result,
521        } => {
522            let status = if *is_failure { "failed" } else { "ok" };
523            let _ = writeln!(
524                out,
525                "  <- {} [{}] ({status})",
526                opt_name(name),
527                opt_name(call_id),
528            );
529            push_lines(out, &value_to_text(result), "     ");
530        }
531        PartKind::File {
532            media_type,
533            file_name,
534            ..
535        } => {
536            let label = file_name
537                .as_deref()
538                .or(media_type.as_deref())
539                .unwrap_or("file");
540            let _ = writeln!(out, "  [file {label}]");
541        }
542        PartKind::ToolApprovalRequest { approval_id, .. } => {
543            let _ = writeln!(out, "  [approval request {approval_id}]");
544        }
545        PartKind::ToolApprovalResponse {
546            approval_id,
547            approved,
548            ..
549        } => {
550            let verb = if *approved { "approved" } else { "denied" };
551            let _ = writeln!(out, "  [approval {approval_id} {verb}]");
552        }
553    }
554}
555
556fn render_part_summary(out: &mut String, summary: &PartSummary) {
557    use std::fmt::Write;
558    let label = summary.label.as_deref().unwrap_or("");
559    let call = summary
560        .call_id
561        .as_deref()
562        .map(|id| format!(" [{id}]"))
563        .unwrap_or_default();
564    match summary.kind.as_str() {
565        "tool_call" => {
566            let _ = writeln!(out, "  -> {label}{call}");
567        }
568        "tool_result" => {
569            let _ = writeln!(out, "  <- {label}{call}");
570        }
571        "file" => {
572            let _ = writeln!(out, "  [file {label}]");
573        }
574        other => {
575            let _ = writeln!(out, "  [{other} {label}]");
576        }
577    }
578}
579
580/// Render a tool param/result `Value` for the transcript: a JSON string
581/// shows as its text; anything else as compact JSON. `null` shows nothing.
582fn value_to_text(value: &serde_json::Value) -> String {
583    match value {
584        serde_json::Value::String(text) => text.clone(),
585        serde_json::Value::Null => String::new(),
586        other => serde_json::to_string(other).unwrap_or_default(),
587    }
588}
589
590#[cfg(test)]
591mod tests {
592    #![allow(clippy::expect_used, clippy::unwrap_used)]
593
594    use super::*;
595    use crate::wire::{Role, SearchFilters, SearchModeWire, SearchResult, SessionFrom};
596
597    #[test]
598    fn get_transcript_marks_target_and_renders_tool_parts() {
599        let ts = chrono::DateTime::from_timestamp(0, 0).unwrap();
600        let tool_call: ResponsePart = serde_json::from_value(serde_json::json!({
601            "id": "p1", "ordinal": 0, "provenance": "conversational",
602            "type": "tool_call", "name": "Bash", "call_id": "toolu_x",
603            "params": { "command": "ls" }, "provider_executed": false,
604        }))
605        .unwrap();
606        let tool_result: ResponsePart = serde_json::from_value(serde_json::json!({
607            "id": "p2", "ordinal": 1, "provenance": "conversational",
608            "type": "tool_result", "name": "Bash", "call_id": "toolu_x",
609            "is_failure": false, "result": "file.txt",
610        }))
611        .unwrap();
612        let target = MessageView {
613            id: "m1".to_owned(),
614            role: crate::wire::Role::Assistant,
615            timestamp: ts,
616            text: Some("Let me list files.".to_owned()),
617            content: None,
618            parts_summary: Vec::new(),
619        };
620        let response = GetResponse {
621            session: crate::wire::GetSession {
622                id: "s1".to_owned(),
623                source_agent: "claude-code".to_owned(),
624                project: "/p".to_owned(),
625                created_at: ts,
626            },
627            result: GetResult::Message {
628                target,
629                target_parts: vec![tool_call, tool_result],
630                target_parts_remaining: 0,
631                siblings: Vec::new(),
632            },
633        };
634        let request = GetRequest {
635            protocol_version: crate::PROTOCOL_VERSION,
636            namespace: None,
637            session_id: None,
638            message_id: Some("m1".to_owned()),
639            session_limit: 20,
640            session_from: SessionFrom::default(),
641            session_after_message_id: None,
642            session_before_message_id: None,
643            message_context_before: 3,
644            message_context_after: 3,
645        };
646
647        let transcript = crate::render::render_get_transcript(&response, &request, Surface::Mcp);
648        assert!(transcript.contains("--- [1] > assistant | 1970-01-01 00:00:00Z | m1 ---"));
649        assert!(transcript.contains("Let me list files."));
650        assert!(transcript.contains("  -> Bash [toolu_x]"));
651        assert!(transcript.contains("  <- Bash [toolu_x] (ok)"));
652        assert!(transcript.contains("session s1 | claude-code | /p"));
653    }
654
655    #[test]
656    fn search_transcript_renders_header_and_hits() {
657        let response = SearchResponse {
658            sessions: vec![crate::wire::SearchSession {
659                session_id: "s1".to_owned(),
660                project: "pond".to_owned(),
661                source_agent: "claude-code".to_owned(),
662                session_messages_count: 2,
663                matched_message_count: 1,
664                matches: vec![SearchResult {
665                    message_id: "m1".to_owned(),
666                    role: Role::User,
667                    timestamp: chrono::DateTime::from_timestamp(0, 0).unwrap(),
668                    text: "hello\nworld".to_owned(),
669                    score: 1.0,
670                    parts_summary: Vec::new(),
671                }],
672            }],
673            matched_total: 1,
674            searchable_in_scope: 2,
675            has_more: false,
676        };
677        let request = SearchRequest {
678            protocol_version: crate::PROTOCOL_VERSION,
679            namespace: None,
680            query: "hi".to_owned(),
681            mode: SearchModeWire::Vector,
682            sort_by: SortBy::Relevance,
683            filters: SearchFilters::default(),
684            limit: 10,
685        };
686
687        let transcript = crate::render::render_search_transcript(&response, &request, Surface::Mcp);
688        assert!(transcript.starts_with(
689            "pond_search: 1 nearest message (2 searchable in scope), showing 1 hit from 1 \
690             session."
691        ));
692        // Vector mode names the closest-rows caveat and points at fts.
693        assert!(transcript.contains("Vector mode returns the closest rows"));
694        assert!(
695            transcript.contains("key: session rules group hits by session, ordered by best hit")
696        );
697        assert!(
698            transcript
699                .contains("--- session [1] best 1.00 | 1/2 matched | pond | claude-code | s1")
700        );
701        // Hit lines stay flat and indexed so callers can still extract
702        // message_id from the same delimiter shape.
703        assert!(
704            transcript.contains(
705                "--- [1] 1.00 | user | 1970-01-01 00:00:00Z | m1 | pond | claude-code | s1"
706            )
707        );
708        // Stored "\n" renders as a real line break, not an escape.
709        assert!(transcript.contains("hello\nworld"));
710    }
711
712    #[test]
713    fn search_transcript_budget_keeps_every_session_and_footers_the_truncated_one() {
714        let big = "x".repeat(600);
715        let hit = |id: usize| SearchResult {
716            message_id: format!("m{id}"),
717            role: Role::Assistant,
718            timestamp: chrono::DateTime::from_timestamp(id as i64, 0).unwrap(),
719            text: big.clone(),
720            score: 0.9,
721            parts_summary: Vec::new(),
722        };
723        let session = |id: &str, matches: Vec<SearchResult>| crate::wire::SearchSession {
724            session_id: id.to_owned(),
725            project: "pond".to_owned(),
726            source_agent: "claude-code".to_owned(),
727            session_messages_count: 100,
728            matched_message_count: matches.len(),
729            matches,
730        };
731        // One fat session whose matches alone exceed the budget, plus five
732        // more that must each still surface their top hit.
733        let mut sessions = vec![session("fat", (0..40).map(hit).collect())];
734        for s in 1..=5 {
735            sessions.push(session(&format!("s{s}"), vec![hit(s * 1000)]));
736        }
737        let response = SearchResponse {
738            sessions,
739            matched_total: 45,
740            searchable_in_scope: 200,
741            has_more: false,
742        };
743        let request = SearchRequest {
744            protocol_version: crate::PROTOCOL_VERSION,
745            namespace: None,
746            query: "x".to_owned(),
747            mode: SearchModeWire::Vector,
748            sort_by: SortBy::Relevance,
749            filters: SearchFilters::default(),
750            limit: 10,
751        };
752        let transcript = crate::render::render_search_transcript(&response, &request, Surface::Mcp);
753
754        // Bounded near the budget (soft: each session's guaranteed top hit
755        // can nudge its share, so allow a per-session overshoot margin).
756        assert!(
757            transcript.len() < SEARCH_TRANSCRIPT_BUDGET + 3_000,
758            "transcript {} exceeds the soft budget",
759            transcript.len(),
760        );
761        // Never a whole-response guillotine: every returned session renders.
762        for id in ["fat", "s1", "s2", "s3", "s4", "s5"] {
763            assert!(
764                transcript.contains(&format!("| {id}\n"))
765                    || transcript.contains(&format!("| {id} ")),
766                "session {id} did not render",
767            );
768        }
769        // The fat session was cut short -> supersession footer pointing at
770        // the session's latest state.
771        assert!(transcript.contains("more match(es) in this session not shown (char budget)"));
772        assert!(transcript.contains("session_from=end"));
773    }
774}