1use 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#[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 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 pub fn kind(&self) -> ManagedSourceKind {
43 self.kind
44 }
45
46 pub fn name(&self) -> &str {
48 &self.name
49 }
50
51 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 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 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 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 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 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
150pub 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
171pub 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
201pub 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
218pub 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 ¤t.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}