Skip to main content

kcode_dev_tools_chatend/
lib.rs

1//! Chatend projection for Kennedy's managed development tools.
2
3use std::collections::HashSet;
4
5use anyhow::{Context as _, ensure};
6pub use kcode_dev_tools::{ManagedSourceKind, SourceSnapshot};
7use kcode_dev_tools::{
8    PREVIEW_WRITE_FILE_RUST_BIN_TOOL, PREVIEW_WRITE_FILE_RUST_LIB_TOOL,
9    PREVIEW_WRITE_FILE_WEB_LIB_TOOL, WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
10    WRITE_FILE_FREEFORM_RUST_LIB_TOOL, WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
11};
12use kcode_session_history::{
13    Session,
14    chatend::{BoxContent, BoxId, EventKind, ToolSlotInput},
15};
16use serde_json::{Value, json};
17
18const RUST_LIB_TOOL_INSTANCE: &str = "managed-rust-libraries";
19const WEB_LIB_TOOL_INSTANCE: &str = "managed-web-libraries";
20const RUST_BIN_TOOL_INSTANCE: &str = "managed-rust-binaries";
21const MAX_CAPTURED_CONTENT_BYTES: usize = 4 * 1024 * 1024;
22
23/// A validated request to capture one complete source file from a provider.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct FreeformWrite {
26    kind: ManagedSourceKind,
27    name: String,
28    path: String,
29    update_description: String,
30}
31
32impl FreeformWrite {
33    /// The prompt acknowledgement that starts raw provider output capture.
34    pub fn acknowledgement(&self) -> String {
35        format!(
36            "Ready. Output the complete contents of {} only, with no Markdown fences or commentary.",
37            self.path
38        )
39    }
40
41    /// The managed source kind selected by the freeform tool name.
42    pub fn kind(&self) -> ManagedSourceKind {
43        self.kind
44    }
45
46    /// The exact managed project name selected by this request.
47    pub fn name(&self) -> &str {
48        &self.name
49    }
50
51    /// The non-mutating development tool used before the captured write.
52    pub fn preview_tool(&self) -> &'static str {
53        match self.kind {
54            ManagedSourceKind::RustLibrary => PREVIEW_WRITE_FILE_RUST_LIB_TOOL,
55            ManagedSourceKind::WebLibrary => PREVIEW_WRITE_FILE_WEB_LIB_TOOL,
56            ManagedSourceKind::RustBinary => PREVIEW_WRITE_FILE_RUST_BIN_TOOL,
57        }
58    }
59
60    /// The development tool that commits the captured file contents.
61    pub fn write_tool(&self) -> &'static str {
62        match self.kind {
63            ManagedSourceKind::RustLibrary => WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
64            ManagedSourceKind::WebLibrary => WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
65            ManagedSourceKind::RustBinary => WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
66        }
67    }
68
69    /// Resolve the still-open source box this request is allowed to replace.
70    pub fn source_box_id(&self, session: &Session) -> anyhow::Result<BoxId> {
71        source_box_id(session, self.kind, &self.name).with_context(|| {
72            format!(
73                "the managed {} box for {:?} is no longer open",
74                self.kind.label(),
75                self.name
76            )
77        })
78    }
79
80    /// Build the durable application note recorded after the captured write.
81    pub fn result_record(&self, ok: bool, result: &str) -> Value {
82        json!({
83            "tool":self.write_tool(),
84            "name":self.name,
85            "path":self.path,
86            "updateDescription":self.update_description,
87            "ok":ok,
88            "result":result,
89        })
90    }
91
92    /// Preserve a top-level provider capture and return its backend arguments.
93    pub fn capture(
94        &self,
95        session: &mut Session,
96        recorded_at: &str,
97        invocation_box_id: BoxId,
98        contents: String,
99    ) -> anyhow::Result<Value> {
100        let contents = normalize_captured_contents(contents)?;
101        session.update_box(
102            recorded_at,
103            invocation_box_id,
104            captured_write_box_content(self, contents.clone()),
105        )?;
106        session.summarize_box(recorded_at, invocation_box_id, self.summary())?;
107        Ok(self.backend_arguments(contents))
108    }
109
110    /// Preserve a subagent capture and return its backend arguments.
111    pub fn capture_subagent(
112        &self,
113        session: &mut Session,
114        recorded_at: &str,
115        contents: String,
116    ) -> anyhow::Result<Value> {
117        let contents = normalize_captured_contents(contents)?;
118        session.record(
119            recorded_at,
120            EventKind::Note {
121                label: "subagent_freeform_write_output".into(),
122                value: json!({
123                    "tool":self.write_tool(),
124                    "name":self.name,
125                    "path":self.path,
126                    "updateDescription":self.update_description,
127                    "contents":contents,
128                }),
129            },
130        )?;
131        Ok(self.backend_arguments(contents))
132    }
133
134    fn backend_arguments(&self, contents: String) -> Value {
135        json!({
136            "name":self.name,
137            "path":self.path,
138            "contents":contents,
139        })
140    }
141
142    fn summary(&self) -> String {
143        format!(
144            "Kennedy called write-file on {} in {}, and she describes the update as: {}",
145            self.path, self.name, self.update_description
146        )
147    }
148}
149
150/// Parse a managed freeform-write call and ensure that its source is open.
151///
152/// Unrelated tool names return `None` without inspecting their arguments.
153pub fn prepare_freeform_write(
154    session: &Session,
155    tool_name: &str,
156    arguments: &Value,
157) -> anyhow::Result<Option<FreeformWrite>> {
158    let Some(request) = decode_freeform_write(tool_name, arguments)? else {
159        return Ok(None);
160    };
161    ensure!(
162        source_box_id(session, request.kind, &request.name).is_some(),
163        "{} {:?} is not open in this Kennedy session. Call {} first.",
164        request.kind.label(),
165        request.name,
166        request.kind.open_tool()
167    );
168    Ok(Some(request))
169}
170
171/// Parse a managed freeform-write call without consulting Chatend.
172///
173/// Unrelated tool names return `None` without inspecting their arguments. The
174/// caller remains responsible for proving that the selected source is open in
175/// its own presentation context.
176pub fn decode_freeform_write(
177    tool_name: &str,
178    arguments: &Value,
179) -> anyhow::Result<Option<FreeformWrite>> {
180    let Some(kind) = freeform_kind(tool_name) else {
181        return Ok(None);
182    };
183    validate_exact_arguments(arguments, &["name", "path", "updateDescription"])?;
184    let request = FreeformWrite {
185        kind,
186        name: nonempty_string(arguments, "name", 255)?,
187        path: nonempty_string(arguments, "path", 4_096)?,
188        update_description: nonempty_string(arguments, "updateDescription", 4_000)?,
189    };
190    ensure!(
191        !request.path.contains(['\r', '\n']),
192        "path must contain exactly one line"
193    );
194    ensure!(
195        !request.update_description.contains(['\r', '\n']),
196        "updateDescription must contain exactly one line"
197    );
198    Ok(Some(request))
199}
200
201/// Find the active stable Chatend box for one managed project.
202pub fn source_box_id(session: &Session, kind: ManagedSourceKind, name: &str) -> Option<BoxId> {
203    session
204        .state()
205        .tools
206        .get(tool_instance(kind))?
207        .slots
208        .iter()
209        .find_map(|slot| {
210            if slot.retired {
211                return None;
212            }
213            let state = session.state().box_state(slot.box_id)?;
214            (logical_name(kind, state, &slot.slot) == name).then_some(slot.box_id)
215        })
216}
217
218/// Create or revise the stable Chatend source box represented by a snapshot.
219pub fn apply_snapshot(
220    session: &mut Session,
221    recorded_at: &str,
222    snapshot: SourceSnapshot,
223) -> anyhow::Result<BoxId> {
224    let kind = snapshot.kind;
225    let current = session
226        .state()
227        .tools
228        .get(tool_instance(kind))
229        .cloned()
230        .unwrap_or_default();
231    let mut selected_slot = None;
232    let mut selected_before = None;
233    let mut slots = Vec::with_capacity(current.slots.len() + 1);
234    let mut used_slots = current
235        .slots
236        .iter()
237        .map(|slot| slot.slot.clone())
238        .collect::<HashSet<_>>();
239
240    for slot in &current.slots {
241        let state = session
242            .state()
243            .box_state(slot.box_id)
244            .with_context(|| format!("managed {} slot box is missing", kind.label()))?;
245        let selected = selected_slot.is_none()
246            && !slot.retired
247            && logical_name(kind, state, &slot.slot) == snapshot.name;
248        if selected {
249            selected_slot = Some(slot.slot.clone());
250            selected_before = Some((slot.box_id, state.canonical.content.clone()));
251            slots.push(ToolSlotInput {
252                slot: slot.slot.clone(),
253                name: format!("Managed {} {}", kind.label(), snapshot.name),
254                content: source_box_content(kind, &snapshot),
255                retired: false,
256            });
257        } else {
258            slots.push(ToolSlotInput {
259                slot: slot.slot.clone(),
260                name: state.name.clone(),
261                content: state.canonical.content.clone(),
262                retired: slot.retired,
263            });
264        }
265    }
266
267    let selected_slot = selected_slot.unwrap_or_else(|| {
268        let slot = unique_slot(&snapshot.name, &mut used_slots);
269        slots.push(ToolSlotInput {
270            slot: slot.clone(),
271            name: format!("Managed {} {}", kind.label(), snapshot.name),
272            content: source_box_content(kind, &snapshot),
273            retired: false,
274        });
275        slot
276    });
277
278    session.apply_tool_slots(recorded_at, tool_instance(kind), slots)?;
279    let box_id = session
280        .state()
281        .tools
282        .get(tool_instance(kind))
283        .and_then(|tool| {
284            tool.slots
285                .iter()
286                .find(|slot| slot.slot == selected_slot && !slot.retired)
287        })
288        .map(|slot| slot.box_id)
289        .with_context(|| format!("managed {} box was not installed", kind.label()))?;
290    if let Some((previous_box_id, previous_content)) = selected_before
291        && box_id == previous_box_id
292        && session
293            .state()
294            .box_state(box_id)
295            .is_some_and(|state| state.canonical.content != previous_content)
296    {
297        session.rehydrate_box(recorded_at, box_id)?;
298    }
299    Ok(box_id)
300}
301
302fn freeform_kind(tool_name: &str) -> Option<ManagedSourceKind> {
303    match tool_name {
304        WRITE_FILE_FREEFORM_RUST_LIB_TOOL => Some(ManagedSourceKind::RustLibrary),
305        WRITE_FILE_FREEFORM_WEB_LIB_TOOL => Some(ManagedSourceKind::WebLibrary),
306        WRITE_FILE_FREEFORM_RUST_BIN_TOOL => Some(ManagedSourceKind::RustBinary),
307        _ => None,
308    }
309}
310
311fn tool_instance(kind: ManagedSourceKind) -> &'static str {
312    match kind {
313        ManagedSourceKind::RustLibrary => RUST_LIB_TOOL_INSTANCE,
314        ManagedSourceKind::WebLibrary => WEB_LIB_TOOL_INSTANCE,
315        ManagedSourceKind::RustBinary => RUST_BIN_TOOL_INSTANCE,
316    }
317}
318
319fn metadata_key(kind: ManagedSourceKind) -> &'static str {
320    match kind {
321        ManagedSourceKind::RustLibrary => "managedRustLibrary",
322        ManagedSourceKind::WebLibrary => "managedWebLibrary",
323        ManagedSourceKind::RustBinary => "managedRustBinary",
324    }
325}
326
327fn logical_name(
328    kind: ManagedSourceKind,
329    state: &kcode_session_history::chatend::BoxState,
330    fallback: &str,
331) -> String {
332    state
333        .canonical
334        .content
335        .metadata
336        .get(metadata_key(kind))
337        .and_then(Value::as_str)
338        .unwrap_or(fallback)
339        .to_owned()
340}
341
342fn source_box_content(kind: ManagedSourceKind, snapshot: &SourceSnapshot) -> BoxContent {
343    BoxContent {
344        text: snapshot.text.clone(),
345        objects: Vec::new(),
346        metadata: json!({metadata_key(kind):snapshot.name}),
347    }
348}
349
350fn captured_write_box_content(request: &FreeformWrite, contents: String) -> BoxContent {
351    BoxContent {
352        text: contents,
353        objects: Vec::new(),
354        metadata: json!({
355            "capturedFreeformOutput":true,
356            "toolName":request.write_tool(),
357            "arguments":{
358                "name":request.name,
359                "path":request.path,
360                "updateDescription":request.update_description,
361            },
362        }),
363    }
364}
365
366fn normalize_captured_contents(mut contents: String) -> anyhow::Result<String> {
367    let needs_final_newline = !contents.ends_with('\n');
368    let normalized_len = contents
369        .len()
370        .checked_add(usize::from(needs_final_newline))
371        .context("captured contents length overflow")?;
372    ensure!(
373        normalized_len <= MAX_CAPTURED_CONTENT_BYTES,
374        "normalized captured contents must not exceed {MAX_CAPTURED_CONTENT_BYTES} bytes"
375    );
376    if needs_final_newline {
377        contents.push('\n');
378    }
379    Ok(contents)
380}
381
382fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
383    if used.insert(logical.to_owned()) {
384        return logical.to_owned();
385    }
386    let mut generation = 2_u64;
387    loop {
388        let candidate = format!("{logical}#generation-{generation}");
389        if used.insert(candidate.clone()) {
390            return candidate;
391        }
392        generation += 1;
393    }
394}
395
396fn validate_exact_arguments(value: &Value, required: &[&str]) -> anyhow::Result<()> {
397    let map = value
398        .as_object()
399        .context("arguments must be a JSON object")?;
400    ensure!(
401        required.iter().all(|key| map.contains_key(*key)) && map.len() == required.len(),
402        "expected exactly: {}",
403        required.join(", ")
404    );
405    Ok(())
406}
407
408fn nonempty_string(value: &Value, key: &str, max: usize) -> anyhow::Result<String> {
409    let value = value
410        .get(key)
411        .and_then(Value::as_str)
412        .with_context(|| format!("{key} must be a string"))?;
413    let trimmed = value.trim();
414    ensure!(
415        !trimmed.is_empty() && trimmed.chars().count() <= max,
416        "{key} must contain between 1 and {max} characters"
417    );
418    Ok(trimmed.into())
419}
420
421#[cfg(test)]
422mod tests {
423    use std::{
424        sync::atomic::{AtomicU64, Ordering},
425        time::{SystemTime, UNIX_EPOCH},
426    };
427
428    use kcode_dev_tools::{WRITE_FILE_FREEFORM_RUST_LIB_TOOL, WRITE_FILE_FREEFORM_WEB_LIB_TOOL};
429    use kcode_session_history::chatend::{BoxContent, BoxOwner, Representation, SessionKind};
430    use kcode_session_history::{Config, NewSession, SessionHistory};
431
432    use super::*;
433
434    static NEXT_SESSION_SECOND: AtomicU64 = AtomicU64::new(0);
435
436    fn test_session(label: &str) -> (std::path::PathBuf, Session) {
437        let root = std::env::temp_dir().join(format!(
438            "kcode-dev-tools-chatend-{label}-{}-{}",
439            std::process::id(),
440            SystemTime::now()
441                .duration_since(UNIX_EPOCH)
442                .unwrap()
443                .as_nanos()
444        ));
445        let history = SessionHistory::open(Config {
446            directory: root.join("sessions"),
447            completed_list: root.join("completed.jsonl"),
448            provider_cost_compatibility: None,
449        })
450        .unwrap();
451        let second = NEXT_SESSION_SECOND.fetch_add(1, Ordering::Relaxed);
452        assert!(second < 60);
453        let session = history
454            .create_session(NewSession {
455                kind: SessionKind::Conversation,
456                created_at: format!("2026-07-31T00:00:{second:02}Z"),
457                effective_context_tokens: 10_000,
458                channel: Value::Null,
459            })
460            .unwrap();
461        (root, session)
462    }
463
464    #[test]
465    fn snapshots_keep_one_stable_box_per_kind_and_project() {
466        let (root, mut session) = test_session("stable-boxes");
467        let rust_box = apply_snapshot(
468            &mut session,
469            "t1",
470            SourceSnapshot {
471                kind: ManagedSourceKind::RustLibrary,
472                name: "shared-name".into(),
473                text: "old Rust source".into(),
474            },
475        )
476        .unwrap();
477        let web_box = apply_snapshot(
478            &mut session,
479            "t2",
480            SourceSnapshot {
481                kind: ManagedSourceKind::WebLibrary,
482                name: "shared-name".into(),
483                text: "Web source".into(),
484            },
485        )
486        .unwrap();
487        let binary_box = apply_snapshot(
488            &mut session,
489            "t3",
490            SourceSnapshot {
491                kind: ManagedSourceKind::RustBinary,
492                name: "shared-name".into(),
493                text: "binary source".into(),
494            },
495        )
496        .unwrap();
497        let same_rust_box = apply_snapshot(
498            &mut session,
499            "t4",
500            SourceSnapshot {
501                kind: ManagedSourceKind::RustLibrary,
502                name: "shared-name".into(),
503                text: "new Rust source".into(),
504            },
505        )
506        .unwrap();
507
508        assert_eq!(same_rust_box, rust_box);
509        assert_ne!(rust_box, web_box);
510        assert_ne!(rust_box, binary_box);
511        assert_ne!(web_box, binary_box);
512        assert_eq!(session.state().tools[RUST_LIB_TOOL_INSTANCE].slots.len(), 1);
513        assert_eq!(session.state().tools[WEB_LIB_TOOL_INSTANCE].slots.len(), 1);
514        assert_eq!(session.state().tools[RUST_BIN_TOOL_INSTANCE].slots.len(), 1);
515        assert_eq!(
516            session
517                .state()
518                .box_state(rust_box)
519                .unwrap()
520                .canonical
521                .content
522                .text,
523            "new Rust source"
524        );
525        std::fs::remove_dir_all(root).unwrap();
526    }
527
528    #[test]
529    fn snapshot_updates_rehydrate_only_the_changed_selected_box() {
530        let (root, mut session) = test_session("representation");
531        let selected_box = apply_snapshot(
532            &mut session,
533            "t1",
534            SourceSnapshot {
535                kind: ManagedSourceKind::RustLibrary,
536                name: "selected-lib".into(),
537                text: "old selected source".into(),
538            },
539        )
540        .unwrap();
541        let unrelated_box = apply_snapshot(
542            &mut session,
543            "t2",
544            SourceSnapshot {
545                kind: ManagedSourceKind::RustLibrary,
546                name: "unrelated-lib".into(),
547                text: "unrelated canonical source".into(),
548            },
549        )
550        .unwrap();
551        session
552            .summarize_box("t3", selected_box, "obsolete selected summary")
553            .unwrap();
554        session
555            .summarize_box("t4", unrelated_box, "retained unrelated summary")
556            .unwrap();
557        let selected_compact = session
558            .state()
559            .box_state(selected_box)
560            .unwrap()
561            .representation
562            .clone();
563        let unrelated_compact = session
564            .state()
565            .box_state(unrelated_box)
566            .unwrap()
567            .representation
568            .clone();
569
570        let unchanged_box = apply_snapshot(
571            &mut session,
572            "t5",
573            SourceSnapshot {
574                kind: ManagedSourceKind::RustLibrary,
575                name: "selected-lib".into(),
576                text: "old selected source".into(),
577            },
578        )
579        .unwrap();
580        let unchanged = session.state().box_state(selected_box).unwrap();
581        assert_eq!(unchanged_box, selected_box);
582        assert_eq!(&unchanged.representation, &selected_compact);
583        assert!(!unchanged.stale());
584        assert!(matches!(
585            &unchanged.representation,
586            Representation::Summarized { .. }
587        ));
588
589        let updated_box = apply_snapshot(
590            &mut session,
591            "t6",
592            SourceSnapshot {
593                kind: ManagedSourceKind::RustLibrary,
594                name: "selected-lib".into(),
595                text: "latest selected source".into(),
596            },
597        )
598        .unwrap();
599
600        let selected = session.state().box_state(selected_box).unwrap();
601        assert_eq!(updated_box, selected_box);
602        assert_eq!(selected.canonical.content.text, "latest selected source");
603        assert!(!selected.stale());
604        assert!(matches!(
605            &selected.representation,
606            Representation::Hydrated { .. }
607        ));
608        let unrelated = session.state().box_state(unrelated_box).unwrap();
609        assert_eq!(
610            unrelated.canonical.content.text,
611            "unrelated canonical source"
612        );
613        assert_eq!(&unrelated.representation, &unrelated_compact);
614        assert!(matches!(
615            &unrelated.representation,
616            Representation::Summarized { .. }
617        ));
618        let rendered = session.state().render();
619        assert!(rendered.contains("latest selected source"));
620        assert!(!rendered.contains("old selected source"));
621        assert!(!rendered.contains("obsolete selected summary"));
622        assert!(rendered.contains("retained unrelated summary"));
623        assert!(!rendered.contains("unrelated canonical source"));
624        std::fs::remove_dir_all(root).unwrap();
625    }
626
627    #[test]
628    fn freeform_capture_is_exact_except_for_one_missing_final_newline() {
629        let (root, mut session) = test_session("freeform-capture");
630        apply_snapshot(
631            &mut session,
632            "t1",
633            SourceSnapshot {
634                kind: ManagedSourceKind::RustLibrary,
635                name: "example-lib".into(),
636                text: "existing source".into(),
637            },
638        )
639        .unwrap();
640        let request = prepare_freeform_write(
641            &session,
642            WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
643            &json!({
644                "name":"example-lib",
645                "path":"src/lib.rs",
646                "updateDescription":"Preserved raw Rust source",
647            }),
648        )
649        .unwrap()
650        .unwrap();
651        let invocation = session
652            .create_box(
653                "t2",
654                "Kennedy tool call",
655                BoxOwner::Kennedy,
656                BoxContent::text("call"),
657            )
658            .unwrap();
659        let arguments = request
660            .capture(
661                &mut session,
662                "t3",
663                invocation,
664                "\n//! leading newline\npub fn quote() -> &'static str { \"raw\\\\text\" }".into(),
665            )
666            .unwrap();
667
668        let exact = "\n//! leading newline\npub fn quote() -> &'static str { \"raw\\\\text\" }\n";
669        let state = session.state().box_state(invocation).unwrap();
670        assert_eq!(state.canonical.content.text, exact);
671        assert_eq!(arguments["contents"], exact);
672        assert_eq!(
673            state.canonical.content.metadata["toolName"],
674            request.write_tool()
675        );
676        assert!(session.state().render().contains(
677            "Kennedy called write-file on src/lib.rs in example-lib, and she describes the update as: Preserved raw Rust source"
678        ));
679        assert!(!session.state().render().contains("raw\\\\text"));
680        std::fs::remove_dir_all(root).unwrap();
681    }
682
683    #[test]
684    fn captured_contents_enforce_normalized_byte_limit_before_persistence() {
685        let (root, mut session) = test_session("capture-limit");
686        apply_snapshot(
687            &mut session,
688            "t1",
689            SourceSnapshot {
690                kind: ManagedSourceKind::RustLibrary,
691                name: "bounded-lib".into(),
692                text: "existing source".into(),
693            },
694        )
695        .unwrap();
696        let request = prepare_freeform_write(
697            &session,
698            WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
699            &json!({
700                "name":"bounded-lib",
701                "path":"src/lib.rs",
702                "updateDescription":"Bound captured source",
703            }),
704        )
705        .unwrap()
706        .unwrap();
707
708        let accepted_invocation = session
709            .create_box(
710                "t2",
711                "Accepted Kennedy tool call",
712                BoxOwner::Kennedy,
713                BoxContent::text("accepted call"),
714            )
715            .unwrap();
716        let arguments = request
717            .capture(
718                &mut session,
719                "t3",
720                accepted_invocation,
721                "a".repeat(MAX_CAPTURED_CONTENT_BYTES - 1),
722            )
723            .unwrap();
724        let accepted = arguments["contents"].as_str().unwrap();
725        assert_eq!(accepted.len(), MAX_CAPTURED_CONTENT_BYTES);
726        assert!(accepted.ends_with('\n'));
727
728        let rejected_invocation = session
729            .create_box(
730                "t4",
731                "Rejected Kennedy tool call",
732                BoxOwner::Kennedy,
733                BoxContent::text("unchanged call"),
734            )
735            .unwrap();
736        assert!(
737            request
738                .capture(
739                    &mut session,
740                    "t5",
741                    rejected_invocation,
742                    "b".repeat(MAX_CAPTURED_CONTENT_BYTES),
743                )
744                .is_err()
745        );
746        assert_eq!(
747            session
748                .state()
749                .box_state(rejected_invocation)
750                .unwrap()
751                .canonical
752                .content
753                .text,
754            "unchanged call"
755        );
756        std::fs::remove_dir_all(root).unwrap();
757    }
758
759    #[test]
760    fn freeform_metadata_is_strict_and_kind_specific() {
761        let (root, mut session) = test_session("freeform-validation");
762        apply_snapshot(
763            &mut session,
764            "t1",
765            SourceSnapshot {
766                kind: ManagedSourceKind::WebLibrary,
767                name: "example-ui".into(),
768                text: "existing source".into(),
769            },
770        )
771        .unwrap();
772        let valid = json!({
773            "name":"example-ui",
774            "path":"index.js",
775            "updateDescription":"Replace the entry module",
776        });
777        let request = prepare_freeform_write(&session, WRITE_FILE_FREEFORM_WEB_LIB_TOOL, &valid)
778            .unwrap()
779            .unwrap();
780        assert_eq!(request.kind(), ManagedSourceKind::WebLibrary);
781        assert_eq!(request.write_tool(), WRITE_FILE_FREEFORM_WEB_LIB_TOOL);
782
783        let mut extra = valid.clone();
784        extra["contents"] = json!("not accepted in the Ktool call");
785        assert!(
786            prepare_freeform_write(&session, WRITE_FILE_FREEFORM_WEB_LIB_TOOL, &extra).is_err()
787        );
788        let mut multiline_path = valid.clone();
789        multiline_path["path"] = json!("index.js\nanother");
790        assert!(
791            prepare_freeform_write(&session, WRITE_FILE_FREEFORM_WEB_LIB_TOOL, &multiline_path)
792                .is_err()
793        );
794        let mut multiline_description = valid;
795        multiline_description["updateDescription"] = json!("line one\nline two");
796        assert!(
797            prepare_freeform_write(
798                &session,
799                WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
800                &multiline_description,
801            )
802            .is_err()
803        );
804        assert!(
805            prepare_freeform_write(&session, "unrelated-tool", &Value::Null)
806                .unwrap()
807                .is_none()
808        );
809        std::fs::remove_dir_all(root).unwrap();
810    }
811
812    #[test]
813    fn box_free_decode_does_not_require_session_history() {
814        let request = decode_freeform_write(
815            WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
816            &json!({
817                "name":"box-free",
818                "path":"src/lib.rs",
819                "updateDescription":"Replace the source",
820            }),
821        )
822        .unwrap()
823        .unwrap();
824
825        assert_eq!(request.kind(), ManagedSourceKind::RustLibrary);
826        assert_eq!(request.name(), "box-free");
827    }
828}