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 preview_tool(&self) -> &'static str {
48 match self.kind {
49 ManagedSourceKind::RustLibrary => PREVIEW_WRITE_FILE_RUST_LIB_TOOL,
50 ManagedSourceKind::WebLibrary => PREVIEW_WRITE_FILE_WEB_LIB_TOOL,
51 ManagedSourceKind::RustBinary => PREVIEW_WRITE_FILE_RUST_BIN_TOOL,
52 }
53 }
54
55 pub fn write_tool(&self) -> &'static str {
57 match self.kind {
58 ManagedSourceKind::RustLibrary => WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
59 ManagedSourceKind::WebLibrary => WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
60 ManagedSourceKind::RustBinary => WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
61 }
62 }
63
64 pub fn source_box_id(&self, session: &Session) -> anyhow::Result<BoxId> {
66 source_box_id(session, self.kind, &self.name).with_context(|| {
67 format!(
68 "the managed {} box for {:?} is no longer open",
69 self.kind.label(),
70 self.name
71 )
72 })
73 }
74
75 pub fn result_record(&self, ok: bool, result: &str) -> Value {
77 json!({
78 "tool":self.write_tool(),
79 "name":self.name,
80 "path":self.path,
81 "updateDescription":self.update_description,
82 "ok":ok,
83 "result":result,
84 })
85 }
86
87 pub fn capture(
89 &self,
90 session: &mut Session,
91 recorded_at: &str,
92 invocation_box_id: BoxId,
93 contents: String,
94 ) -> anyhow::Result<Value> {
95 let contents = normalize_captured_contents(contents)?;
96 session.update_box(
97 recorded_at,
98 invocation_box_id,
99 captured_write_box_content(self, contents.clone()),
100 )?;
101 session.summarize_box(recorded_at, invocation_box_id, self.summary())?;
102 Ok(self.backend_arguments(contents))
103 }
104
105 pub fn capture_subagent(
107 &self,
108 session: &mut Session,
109 recorded_at: &str,
110 contents: String,
111 ) -> anyhow::Result<Value> {
112 let contents = normalize_captured_contents(contents)?;
113 session.record(
114 recorded_at,
115 EventKind::Note {
116 label: "subagent_freeform_write_output".into(),
117 value: json!({
118 "tool":self.write_tool(),
119 "name":self.name,
120 "path":self.path,
121 "updateDescription":self.update_description,
122 "contents":contents,
123 }),
124 },
125 )?;
126 Ok(self.backend_arguments(contents))
127 }
128
129 fn backend_arguments(&self, contents: String) -> Value {
130 json!({
131 "name":self.name,
132 "path":self.path,
133 "contents":contents,
134 })
135 }
136
137 fn summary(&self) -> String {
138 format!(
139 "Kennedy called write-file on {} in {}, and she describes the update as: {}",
140 self.path, self.name, self.update_description
141 )
142 }
143}
144
145pub fn prepare_freeform_write(
149 session: &Session,
150 tool_name: &str,
151 arguments: &Value,
152) -> anyhow::Result<Option<FreeformWrite>> {
153 let Some(kind) = freeform_kind(tool_name) else {
154 return Ok(None);
155 };
156 validate_exact_arguments(arguments, &["name", "path", "updateDescription"])?;
157 let request = FreeformWrite {
158 kind,
159 name: nonempty_string(arguments, "name", 255)?,
160 path: nonempty_string(arguments, "path", 4_096)?,
161 update_description: nonempty_string(arguments, "updateDescription", 4_000)?,
162 };
163 ensure!(
164 !request.path.contains(['\r', '\n']),
165 "path must contain exactly one line"
166 );
167 ensure!(
168 !request.update_description.contains(['\r', '\n']),
169 "updateDescription must contain exactly one line"
170 );
171 ensure!(
172 source_box_id(session, kind, &request.name).is_some(),
173 "{} {:?} is not open in this Kennedy session. Call {} first.",
174 kind.label(),
175 request.name,
176 kind.open_tool()
177 );
178 Ok(Some(request))
179}
180
181pub fn source_box_id(session: &Session, kind: ManagedSourceKind, name: &str) -> Option<BoxId> {
183 session
184 .state()
185 .tools
186 .get(tool_instance(kind))?
187 .slots
188 .iter()
189 .find_map(|slot| {
190 if slot.retired {
191 return None;
192 }
193 let state = session.state().box_state(slot.box_id)?;
194 (logical_name(kind, state, &slot.slot) == name).then_some(slot.box_id)
195 })
196}
197
198pub fn apply_snapshot(
200 session: &mut Session,
201 recorded_at: &str,
202 snapshot: SourceSnapshot,
203) -> anyhow::Result<BoxId> {
204 let kind = snapshot.kind;
205 let current = session
206 .state()
207 .tools
208 .get(tool_instance(kind))
209 .cloned()
210 .unwrap_or_default();
211 let mut selected_slot = None;
212 let mut slots = Vec::with_capacity(current.slots.len() + 1);
213 let mut used_slots = current
214 .slots
215 .iter()
216 .map(|slot| slot.slot.clone())
217 .collect::<HashSet<_>>();
218
219 for slot in ¤t.slots {
220 let state = session
221 .state()
222 .box_state(slot.box_id)
223 .with_context(|| format!("managed {} slot box is missing", kind.label()))?;
224 let selected = selected_slot.is_none()
225 && !slot.retired
226 && logical_name(kind, state, &slot.slot) == snapshot.name;
227 if selected {
228 selected_slot = Some(slot.slot.clone());
229 slots.push(ToolSlotInput {
230 slot: slot.slot.clone(),
231 name: format!("Managed {} {}", kind.label(), snapshot.name),
232 content: source_box_content(kind, &snapshot),
233 retired: false,
234 });
235 } else {
236 slots.push(ToolSlotInput {
237 slot: slot.slot.clone(),
238 name: state.name.clone(),
239 content: state.canonical.content.clone(),
240 retired: slot.retired,
241 });
242 }
243 }
244
245 let selected_slot = selected_slot.unwrap_or_else(|| {
246 let slot = unique_slot(&snapshot.name, &mut used_slots);
247 slots.push(ToolSlotInput {
248 slot: slot.clone(),
249 name: format!("Managed {} {}", kind.label(), snapshot.name),
250 content: source_box_content(kind, &snapshot),
251 retired: false,
252 });
253 slot
254 });
255
256 session.apply_tool_slots(recorded_at, tool_instance(kind), slots)?;
257 session
258 .state()
259 .tools
260 .get(tool_instance(kind))
261 .and_then(|tool| {
262 tool.slots
263 .iter()
264 .find(|slot| slot.slot == selected_slot && !slot.retired)
265 })
266 .map(|slot| slot.box_id)
267 .with_context(|| format!("managed {} box was not installed", kind.label()))
268}
269
270fn freeform_kind(tool_name: &str) -> Option<ManagedSourceKind> {
271 match tool_name {
272 WRITE_FILE_FREEFORM_RUST_LIB_TOOL => Some(ManagedSourceKind::RustLibrary),
273 WRITE_FILE_FREEFORM_WEB_LIB_TOOL => Some(ManagedSourceKind::WebLibrary),
274 WRITE_FILE_FREEFORM_RUST_BIN_TOOL => Some(ManagedSourceKind::RustBinary),
275 _ => None,
276 }
277}
278
279fn tool_instance(kind: ManagedSourceKind) -> &'static str {
280 match kind {
281 ManagedSourceKind::RustLibrary => RUST_LIB_TOOL_INSTANCE,
282 ManagedSourceKind::WebLibrary => WEB_LIB_TOOL_INSTANCE,
283 ManagedSourceKind::RustBinary => RUST_BIN_TOOL_INSTANCE,
284 }
285}
286
287fn metadata_key(kind: ManagedSourceKind) -> &'static str {
288 match kind {
289 ManagedSourceKind::RustLibrary => "managedRustLibrary",
290 ManagedSourceKind::WebLibrary => "managedWebLibrary",
291 ManagedSourceKind::RustBinary => "managedRustBinary",
292 }
293}
294
295fn logical_name(
296 kind: ManagedSourceKind,
297 state: &kcode_session_history::chatend::BoxState,
298 fallback: &str,
299) -> String {
300 state
301 .canonical
302 .content
303 .metadata
304 .get(metadata_key(kind))
305 .and_then(Value::as_str)
306 .unwrap_or(fallback)
307 .to_owned()
308}
309
310fn source_box_content(kind: ManagedSourceKind, snapshot: &SourceSnapshot) -> BoxContent {
311 BoxContent {
312 text: snapshot.text.clone(),
313 objects: Vec::new(),
314 metadata: json!({metadata_key(kind):snapshot.name}),
315 }
316}
317
318fn captured_write_box_content(request: &FreeformWrite, contents: String) -> BoxContent {
319 BoxContent {
320 text: contents,
321 objects: Vec::new(),
322 metadata: json!({
323 "capturedFreeformOutput":true,
324 "toolName":request.write_tool(),
325 "arguments":{
326 "name":request.name,
327 "path":request.path,
328 "updateDescription":request.update_description,
329 },
330 }),
331 }
332}
333
334fn normalize_captured_contents(mut contents: String) -> anyhow::Result<String> {
335 let needs_final_newline = !contents.ends_with('\n');
336 let normalized_len = contents
337 .len()
338 .checked_add(usize::from(needs_final_newline))
339 .context("captured contents length overflow")?;
340 ensure!(
341 normalized_len <= MAX_CAPTURED_CONTENT_BYTES,
342 "normalized captured contents must not exceed {MAX_CAPTURED_CONTENT_BYTES} bytes"
343 );
344 if needs_final_newline {
345 contents.push('\n');
346 }
347 Ok(contents)
348}
349
350fn unique_slot(logical: &str, used: &mut HashSet<String>) -> String {
351 if used.insert(logical.to_owned()) {
352 return logical.to_owned();
353 }
354 let mut generation = 2_u64;
355 loop {
356 let candidate = format!("{logical}#generation-{generation}");
357 if used.insert(candidate.clone()) {
358 return candidate;
359 }
360 generation += 1;
361 }
362}
363
364fn validate_exact_arguments(value: &Value, required: &[&str]) -> anyhow::Result<()> {
365 let map = value
366 .as_object()
367 .context("arguments must be a JSON object")?;
368 ensure!(
369 required.iter().all(|key| map.contains_key(*key)) && map.len() == required.len(),
370 "expected exactly: {}",
371 required.join(", ")
372 );
373 Ok(())
374}
375
376fn nonempty_string(value: &Value, key: &str, max: usize) -> anyhow::Result<String> {
377 let value = value
378 .get(key)
379 .and_then(Value::as_str)
380 .with_context(|| format!("{key} must be a string"))?;
381 let trimmed = value.trim();
382 ensure!(
383 !trimmed.is_empty() && trimmed.chars().count() <= max,
384 "{key} must contain between 1 and {max} characters"
385 );
386 Ok(trimmed.into())
387}
388
389#[cfg(test)]
390mod tests {
391 use std::{
392 sync::atomic::{AtomicU64, Ordering},
393 time::{SystemTime, UNIX_EPOCH},
394 };
395
396 use kcode_dev_tools::{WRITE_FILE_FREEFORM_RUST_LIB_TOOL, WRITE_FILE_FREEFORM_WEB_LIB_TOOL};
397 use kcode_session_history::chatend::{BoxContent, BoxOwner, Representation, SessionKind};
398 use kcode_session_history::{Config, NewSession, SessionHistory};
399
400 use super::*;
401
402 static NEXT_SESSION_SECOND: AtomicU64 = AtomicU64::new(0);
403
404 fn test_session(label: &str) -> (std::path::PathBuf, Session) {
405 let root = std::env::temp_dir().join(format!(
406 "kcode-dev-tools-chatend-{label}-{}-{}",
407 std::process::id(),
408 SystemTime::now()
409 .duration_since(UNIX_EPOCH)
410 .unwrap()
411 .as_nanos()
412 ));
413 let history = SessionHistory::open(Config {
414 directory: root.join("sessions"),
415 completed_list: root.join("completed.jsonl"),
416 })
417 .unwrap();
418 let second = NEXT_SESSION_SECOND.fetch_add(1, Ordering::Relaxed);
419 assert!(second < 60);
420 let session = history
421 .create_session(NewSession {
422 kind: SessionKind::Conversation,
423 created_at: format!("2026-07-31T00:00:{second:02}Z"),
424 effective_context_tokens: 10_000,
425 channel: Value::Null,
426 })
427 .unwrap();
428 (root, session)
429 }
430
431 #[test]
432 fn snapshots_keep_one_stable_box_per_kind_and_project() {
433 let (root, mut session) = test_session("stable-boxes");
434 let rust_box = apply_snapshot(
435 &mut session,
436 "t1",
437 SourceSnapshot {
438 kind: ManagedSourceKind::RustLibrary,
439 name: "shared-name".into(),
440 text: "old Rust source".into(),
441 },
442 )
443 .unwrap();
444 let web_box = apply_snapshot(
445 &mut session,
446 "t2",
447 SourceSnapshot {
448 kind: ManagedSourceKind::WebLibrary,
449 name: "shared-name".into(),
450 text: "Web source".into(),
451 },
452 )
453 .unwrap();
454 let binary_box = apply_snapshot(
455 &mut session,
456 "t3",
457 SourceSnapshot {
458 kind: ManagedSourceKind::RustBinary,
459 name: "shared-name".into(),
460 text: "binary source".into(),
461 },
462 )
463 .unwrap();
464 let same_rust_box = apply_snapshot(
465 &mut session,
466 "t4",
467 SourceSnapshot {
468 kind: ManagedSourceKind::RustLibrary,
469 name: "shared-name".into(),
470 text: "new Rust source".into(),
471 },
472 )
473 .unwrap();
474
475 assert_eq!(same_rust_box, rust_box);
476 assert_ne!(rust_box, web_box);
477 assert_ne!(rust_box, binary_box);
478 assert_ne!(web_box, binary_box);
479 assert_eq!(session.state().tools[RUST_LIB_TOOL_INSTANCE].slots.len(), 1);
480 assert_eq!(session.state().tools[WEB_LIB_TOOL_INSTANCE].slots.len(), 1);
481 assert_eq!(session.state().tools[RUST_BIN_TOOL_INSTANCE].slots.len(), 1);
482 assert_eq!(
483 session
484 .state()
485 .box_state(rust_box)
486 .unwrap()
487 .canonical
488 .content
489 .text,
490 "new Rust source"
491 );
492 std::fs::remove_dir_all(root).unwrap();
493 }
494
495 #[test]
496 fn snapshot_updates_preserve_representation_choices() {
497 let (root, mut session) = test_session("representation");
498 let box_id = apply_snapshot(
499 &mut session,
500 "t1",
501 SourceSnapshot {
502 kind: ManagedSourceKind::RustLibrary,
503 name: "summary-lib".into(),
504 text: "old canonical source".into(),
505 },
506 )
507 .unwrap();
508 session
509 .summarize_box("t2", box_id, "Kennedy's retained library summary")
510 .unwrap();
511 apply_snapshot(
512 &mut session,
513 "t3",
514 SourceSnapshot {
515 kind: ManagedSourceKind::RustLibrary,
516 name: "summary-lib".into(),
517 text: "new canonical source".into(),
518 },
519 )
520 .unwrap();
521
522 let state = session.state().box_state(box_id).unwrap();
523 assert_eq!(state.canonical.content.text, "new canonical source");
524 assert!(state.stale());
525 assert!(matches!(
526 state.representation,
527 Representation::Summarized { .. }
528 ));
529 assert!(
530 session
531 .state()
532 .render()
533 .contains("Kennedy's retained library summary")
534 );
535 assert!(!session.state().render().contains("new canonical source"));
536 std::fs::remove_dir_all(root).unwrap();
537 }
538
539 #[test]
540 fn freeform_capture_is_exact_except_for_one_missing_final_newline() {
541 let (root, mut session) = test_session("freeform-capture");
542 apply_snapshot(
543 &mut session,
544 "t1",
545 SourceSnapshot {
546 kind: ManagedSourceKind::RustLibrary,
547 name: "example-lib".into(),
548 text: "existing source".into(),
549 },
550 )
551 .unwrap();
552 let request = prepare_freeform_write(
553 &session,
554 WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
555 &json!({
556 "name":"example-lib",
557 "path":"src/lib.rs",
558 "updateDescription":"Preserved raw Rust source",
559 }),
560 )
561 .unwrap()
562 .unwrap();
563 let invocation = session
564 .create_box(
565 "t2",
566 "Kennedy tool call",
567 BoxOwner::Kennedy,
568 BoxContent::text("call"),
569 )
570 .unwrap();
571 let arguments = request
572 .capture(
573 &mut session,
574 "t3",
575 invocation,
576 "\n//! leading newline\npub fn quote() -> &'static str { \"raw\\\\text\" }".into(),
577 )
578 .unwrap();
579
580 let exact = "\n//! leading newline\npub fn quote() -> &'static str { \"raw\\\\text\" }\n";
581 let state = session.state().box_state(invocation).unwrap();
582 assert_eq!(state.canonical.content.text, exact);
583 assert_eq!(arguments["contents"], exact);
584 assert_eq!(
585 state.canonical.content.metadata["toolName"],
586 request.write_tool()
587 );
588 assert!(session.state().render().contains(
589 "Kennedy called write-file on src/lib.rs in example-lib, and she describes the update as: Preserved raw Rust source"
590 ));
591 assert!(!session.state().render().contains("raw\\\\text"));
592 std::fs::remove_dir_all(root).unwrap();
593 }
594
595 #[test]
596 fn captured_contents_enforce_normalized_byte_limit_before_persistence() {
597 let (root, mut session) = test_session("capture-limit");
598 apply_snapshot(
599 &mut session,
600 "t1",
601 SourceSnapshot {
602 kind: ManagedSourceKind::RustLibrary,
603 name: "bounded-lib".into(),
604 text: "existing source".into(),
605 },
606 )
607 .unwrap();
608 let request = prepare_freeform_write(
609 &session,
610 WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
611 &json!({
612 "name":"bounded-lib",
613 "path":"src/lib.rs",
614 "updateDescription":"Bound captured source",
615 }),
616 )
617 .unwrap()
618 .unwrap();
619
620 let accepted_invocation = session
621 .create_box(
622 "t2",
623 "Accepted Kennedy tool call",
624 BoxOwner::Kennedy,
625 BoxContent::text("accepted call"),
626 )
627 .unwrap();
628 let arguments = request
629 .capture(
630 &mut session,
631 "t3",
632 accepted_invocation,
633 "a".repeat(MAX_CAPTURED_CONTENT_BYTES - 1),
634 )
635 .unwrap();
636 let accepted = arguments["contents"].as_str().unwrap();
637 assert_eq!(accepted.len(), MAX_CAPTURED_CONTENT_BYTES);
638 assert!(accepted.ends_with('\n'));
639
640 let rejected_invocation = session
641 .create_box(
642 "t4",
643 "Rejected Kennedy tool call",
644 BoxOwner::Kennedy,
645 BoxContent::text("unchanged call"),
646 )
647 .unwrap();
648 assert!(
649 request
650 .capture(
651 &mut session,
652 "t5",
653 rejected_invocation,
654 "b".repeat(MAX_CAPTURED_CONTENT_BYTES),
655 )
656 .is_err()
657 );
658 assert_eq!(
659 session
660 .state()
661 .box_state(rejected_invocation)
662 .unwrap()
663 .canonical
664 .content
665 .text,
666 "unchanged call"
667 );
668 std::fs::remove_dir_all(root).unwrap();
669 }
670
671 #[test]
672 fn freeform_metadata_is_strict_and_kind_specific() {
673 let (root, mut session) = test_session("freeform-validation");
674 apply_snapshot(
675 &mut session,
676 "t1",
677 SourceSnapshot {
678 kind: ManagedSourceKind::WebLibrary,
679 name: "example-ui".into(),
680 text: "existing source".into(),
681 },
682 )
683 .unwrap();
684 let valid = json!({
685 "name":"example-ui",
686 "path":"index.js",
687 "updateDescription":"Replace the entry module",
688 });
689 let request = prepare_freeform_write(&session, WRITE_FILE_FREEFORM_WEB_LIB_TOOL, &valid)
690 .unwrap()
691 .unwrap();
692 assert_eq!(request.kind(), ManagedSourceKind::WebLibrary);
693 assert_eq!(request.write_tool(), WRITE_FILE_FREEFORM_WEB_LIB_TOOL);
694
695 let mut extra = valid.clone();
696 extra["contents"] = json!("not accepted in the Ktool call");
697 assert!(
698 prepare_freeform_write(&session, WRITE_FILE_FREEFORM_WEB_LIB_TOOL, &extra).is_err()
699 );
700 let mut multiline_path = valid.clone();
701 multiline_path["path"] = json!("index.js\nanother");
702 assert!(
703 prepare_freeform_write(&session, WRITE_FILE_FREEFORM_WEB_LIB_TOOL, &multiline_path)
704 .is_err()
705 );
706 let mut multiline_description = valid;
707 multiline_description["updateDescription"] = json!("line one\nline two");
708 assert!(
709 prepare_freeform_write(
710 &session,
711 WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
712 &multiline_description,
713 )
714 .is_err()
715 );
716 assert!(
717 prepare_freeform_write(&session, "unrelated-tool", &Value::Null)
718 .unwrap()
719 .is_none()
720 );
721 std::fs::remove_dir_all(root).unwrap();
722 }
723}