1use std::borrow::Cow;
17
18use maud::html;
19use serde_json::Value;
20
21use crate::{
22 render::Scribe,
23 tools::{Setting, plain, printed, prose},
24};
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum GlossKind {
31 Hook,
32 Rule,
33 Skill,
34 Command,
35 Plan,
36 Note,
37}
38
39impl GlossKind {
40 pub fn label(self) -> &'static str {
41 match self {
42 GlossKind::Hook => "hook",
43 GlossKind::Rule => "rule",
44 GlossKind::Skill => "skill",
45 GlossKind::Command => "command",
46 GlossKind::Plan => "plan",
47 GlossKind::Note => "note",
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Body {
61 Prose(String),
64 Printed(String),
66 Plain(String),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Firing {
76 event: String,
80 command: Option<String>,
85}
86
87impl Firing {
88 fn joins(&self, other: &Firing) -> bool {
93 self.event == other.event
94 && match (&self.command, &other.command) {
95 (Some(mine), Some(theirs)) => mine == theirs,
96 _ => true,
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct Gloss {
105 pub kind: GlossKind,
106 pub gist: Option<String>,
109 pub notes: Vec<String>,
112 pub body: Vec<Body>,
116 pub firing: Option<Firing>,
117}
118
119impl Gloss {
120 fn new(kind: GlossKind) -> Self {
121 Self {
122 kind,
123 gist: None,
124 notes: Vec::new(),
125 body: Vec::new(),
126 firing: None,
127 }
128 }
129
130 fn firing(mut self, event: Option<&str>, command: Option<&str>) -> Self {
132 self.firing = event.map(|event| Firing {
133 event: event.to_owned(),
134 command: command.map(str::to_owned),
135 });
136 self
137 }
138
139 pub(crate) fn absorb(&mut self, other: Gloss) {
146 if self.gist.is_none() {
147 self.gist = other.gist;
148 }
149 self.body.extend(other.body);
150 for note in other.notes {
151 if !self.notes.contains(¬e) {
152 self.notes.push(note);
153 }
154 }
155 if let (Some(firing), Some(joined)) = (&mut self.firing, other.firing)
156 && firing.command.is_none()
157 {
158 firing.command = joined.command;
159 }
160 }
161
162 pub(crate) fn ran_by(&mut self, command: &str) {
176 self.kind = GlossKind::Skill;
177 if self.body.is_empty()
178 && let Some(said) = self.gist.take()
179 {
180 self.body.push(Body::Prose(said));
181 }
182 self.gist = Some(command.trim_start_matches('/').to_owned());
183 }
184
185 pub(crate) fn same_firing_as(&self, other: &Gloss) -> bool {
188 self.kind == GlossKind::Hook
189 && other.kind == GlossKind::Hook
190 && match (&self.firing, &other.firing) {
191 (Some(mine), Some(theirs)) => mine.joins(theirs),
192 _ => false,
193 }
194 }
195
196 fn gist(mut self, gist: impl Into<String>) -> Self {
197 self.gist = Some(gist.into());
198 self
199 }
200
201 fn maybe_gist(self, gist: Option<impl Into<String>>) -> Self {
202 match gist {
203 Some(gist) => self.gist(gist),
204 None => self,
205 }
206 }
207
208 fn note(mut self, note: impl Into<String>) -> Self {
209 self.notes.push(note.into());
210 self
211 }
212
213 fn maybe_note(self, note: Option<impl Into<String>>) -> Self {
214 match note {
215 Some(note) => self.note(note),
216 None => self,
217 }
218 }
219
220 fn prose(mut self, body: impl Into<String>) -> Self {
221 self.body.push(Body::Prose(body.into()));
222 self
223 }
224
225 fn printed(mut self, body: impl Into<String>) -> Self {
226 self.body.push(Body::Printed(body.into()));
227 self
228 }
229
230 fn plain(mut self, body: impl Into<String>) -> Self {
231 self.body.push(Body::Plain(body.into()));
232 self
233 }
234
235 fn maybe_plain(self, body: Option<impl Into<String>>) -> Self {
236 match body {
237 Some(body) => self.plain(body),
238 None => self,
239 }
240 }
241
242 fn maybe_prose(self, body: Option<impl Into<String>>) -> Self {
243 match body {
244 Some(body) => self.prose(body),
245 None => self,
246 }
247 }
248
249 fn is_bare(&self) -> bool {
252 self.gist.is_none() && self.body.is_empty()
253 }
254}
255
256pub fn setting(scribe: &Scribe, gloss: &Gloss) -> Setting {
260 let setting = Setting::new().maybe_gist(gloss.gist.as_deref());
261 let setting = gloss
262 .notes
263 .iter()
264 .fold(setting, |setting, note| setting.note(note.as_str()));
265 if gloss.body.is_empty() {
266 return setting;
267 }
268 setting.body(html! {
269 @for body in &gloss.body {
270 (match body {
271 Body::Prose(text) => prose(scribe, text),
272 Body::Printed(text) => printed(scribe, text),
273 Body::Plain(text) => plain(scribe, text),
274 })
275 }
276 })
277}
278
279pub fn attachment(attachment: &Value) -> Option<Gloss> {
284 let gloss = match text(attachment, "type")? {
285 "hook_success" => hook_output(attachment)?,
286 "hook_system_message" => Gloss::new(GlossKind::Hook)
287 .firing(text(attachment, "toolUseID"), None)
288 .maybe_gist(text(attachment, "content"))
289 .maybe_note(text(attachment, "hookName")),
290 "hook_additional_context" => Gloss::new(GlossKind::Hook)
297 .firing(text(attachment, "toolUseID"), None)
298 .maybe_gist(text(attachment, "hookName"))
299 .note("added context")
300 .printed(paragraphs(attachment.get("content")?)?),
301 "hook_cancelled" => Gloss::new(GlossKind::Hook)
302 .firing(text(attachment, "toolUseID"), text(attachment, "command"))
303 .maybe_gist(text(attachment, "hookName"))
304 .maybe_note(text(attachment, "command"))
305 .note(
306 if flag(attachment, "timedOut") {
307 "timed out"
308 } else {
309 "cancelled"
310 },
311 ),
312 "nested_memory" => Gloss::new(GlossKind::Rule)
320 .maybe_gist(subject(attachment))
321 .maybe_note(text(attachment.get("content")?, "type").map(str::to_lowercase))
322 .prose(text(attachment.get("content")?, "content")?),
323 "plan_mode" => plan_entered(attachment)?,
324 "plan_mode_exit" => plan_left(
325 attachment,
326 if flag(attachment, "planExists") {
327 "left, plan written"
328 } else {
329 "left, no plan written"
330 },
331 ),
332 "edited_text_file" => Gloss::new(GlossKind::Note)
335 .maybe_gist(text(attachment, "filename"))
336 .note("edited outside the session")
337 .plain(text(attachment, "snippet")?),
338 "file" => Gloss::new(GlossKind::Note)
339 .maybe_gist(subject(attachment))
340 .note("attached")
341 .prose(text(attachment.get("content")?.get("file")?, "content")?),
342 "directory" => Gloss::new(GlossKind::Note)
343 .maybe_gist(subject(attachment))
344 .note("attached")
345 .plain(text(attachment, "content")?),
346 "read_truncation_notice" => Gloss::new(GlossKind::Note)
349 .gist("read truncated")
350 .plain(text(attachment, "banner")?),
351 "goal_status" => Gloss::new(GlossKind::Note)
352 .maybe_gist(text(attachment, "condition"))
353 .note(
354 if flag(attachment, "met") {
355 "met"
356 } else {
357 "not met"
358 },
359 ),
360 _ => return None,
361 };
362 (!gloss.is_bare()).then_some(gloss)
363}
364
365fn hook_output(attachment: &Value) -> Option<Gloss> {
376 let contributed = text(attachment, "content")
377 .or_else(|| text(attachment, "stdout").filter(|stdout| !is_protocol(stdout)))
378 .map(str::trim)
379 .filter(|contributed| !contributed.is_empty());
380 let printed = text(attachment, "stderr")
381 .map(str::trim)
382 .filter(|printed| !printed.is_empty());
383 let said: Vec<&str> = contributed.into_iter().chain(printed).collect();
384 if said.is_empty() {
385 return None;
386 }
387 let exit = attachment.get("exitCode").and_then(Value::as_i64);
388 Some(
389 Gloss::new(GlossKind::Hook)
390 .firing(text(attachment, "toolUseID"), text(attachment, "command"))
391 .maybe_gist(text(attachment, "hookName"))
392 .maybe_note(text(attachment, "command"))
393 .maybe_note(
394 exit.filter(|code| *code != 0)
395 .map(|code| format!("exit {code}")),
396 )
397 .plain(said.join("\n")),
398 )
399}
400
401const HARNESS_CONTROLS: &[&str] = &[
418 "/copy",
419 "/skills",
420 "/agents",
421 "/login",
422 "/logout",
423 "/resume",
424 "/config",
425 "/status",
426 "/cost",
427 "/doctor",
428 "/help",
429 "/terminal-setup",
430];
431
432fn controls_the_harness(name: &str) -> bool {
433 HARNESS_CONTROLS.contains(&name)
434}
435
436fn is_protocol(stdout: &str) -> bool {
440 serde_json::from_str::<Value>(stdout.trim()).is_ok_and(|value| {
441 value.get("systemMessage").is_some() || value.get("hookSpecificOutput").is_some()
442 })
443}
444
445fn plan_left(attachment: &Value, state: &str) -> Gloss {
454 match text(attachment, "planFilePath") {
455 Some(path) => Gloss::new(GlossKind::Plan).gist(path).note(state),
456 None => Gloss::new(GlossKind::Plan).gist(state),
457 }
458}
459
460fn plan_entered(attachment: &Value) -> Option<Gloss> {
468 (text(attachment, "reminderType") == Some("full")).then(|| {
469 Gloss::new(GlossKind::Plan)
470 .gist("entered plan mode")
471 .maybe_note(flag(attachment, "isSubAgent").then_some("subagent"))
472 })
473}
474
475pub fn system(system: &Value) -> Option<Gloss> {
479 let said = text(system, "content").map(str::trim).unwrap_or_default();
480 let gloss = match text(system, "subtype")? {
481 "local_command" => {
482 let printed = unwrap_tag(said, "local-command-stdout")?.trim();
483 (!printed.is_empty())
484 .then(|| Gloss::new(GlossKind::Command).gist("output").plain(printed))?
485 }
486 "model_refusal_fallback" => Gloss::new(GlossKind::Note)
489 .gist("model switched")
490 .maybe_note(
491 text(system, "originalModel")
492 .zip(text(system, "fallbackModel"))
493 .map(|(from, to)| format!("{from} → {to}")),
494 )
495 .prose(said),
496 "informational" | "away_summary" => told(GlossKind::Note, said),
497 _ => return None,
498 };
499 (!gloss.is_bare()).then_some(gloss)
500}
501
502#[derive(Debug, Clone, PartialEq, Eq)]
507pub enum Wrapped {
508 Note(Gloss),
510 Nothing,
514}
515
516pub fn wrapped(said: &str, is_meta: bool) -> Option<Wrapped> {
519 let said = strip_tag(said, "local-command-caveat");
522 let said = said.trim();
523 if let Some(wrapped) = command(said) {
524 return Some(wrapped);
525 }
526 if !is_meta {
527 return None;
528 }
529 Some(match meta(said) {
530 Some(gloss) => Wrapped::Note(gloss),
531 None => Wrapped::Nothing,
532 })
533}
534
535fn meta(said: &str) -> Option<Gloss> {
541 const SKILL_OPENING: &str = "Base directory for this skill:";
542 const IMAGE_SCALING: (&str, &str) = ("[Image: original ", "Multiply coordinates");
546
547 if said.is_empty() || (said.starts_with(IMAGE_SCALING.0) && said.contains(IMAGE_SCALING.1)) {
548 return None;
549 }
550 let Some(rest) = said.strip_prefix(SKILL_OPENING) else {
551 return Some(told(GlossKind::Note, said));
552 };
553 let (directory, instructions) = rest.trim_start().split_once('\n').unwrap_or((rest, ""));
556 Some(
557 Gloss::new(GlossKind::Skill)
558 .maybe_gist(directory.trim().rsplit('/').next())
559 .maybe_prose(Some(instructions.trim_start()).filter(|body| !body.is_empty())),
560 )
561}
562
563fn command(said: &str) -> Option<Wrapped> {
572 let name = unwrap_tag(said, "command-name")?.trim();
573 if name.is_empty() {
574 return None;
575 }
576 if controls_the_harness(name) {
577 return Some(Wrapped::Nothing);
578 }
579 let args = unwrap_tag(said, "command-args").unwrap_or_default().trim();
580 let printed = unwrap_tag(said, "local-command-stdout")
581 .unwrap_or_default()
582 .trim();
583 Some(Wrapped::Note(
584 Gloss::new(GlossKind::Command)
585 .gist(name)
586 .maybe_note((!args.is_empty()).then_some(args))
587 .maybe_plain((!printed.is_empty()).then_some(printed)),
588 ))
589}
590
591fn told(kind: GlossKind, said: &str) -> Gloss {
595 const AT_A_GLANCE: usize = 90;
596
597 let opening = said.lines().next().unwrap_or(said).trim();
598 if said.lines().count() == 1 && said.chars().count() <= AT_A_GLANCE {
599 return Gloss::new(kind).gist(opening);
600 }
601 Gloss::new(kind).gist(opening).prose(said)
602}
603
604fn subject(attachment: &Value) -> Option<&str> {
606 ["displayPath", "path", "filename"]
607 .iter()
608 .find_map(|field| text(attachment, field))
609}
610
611fn paragraphs(content: &Value) -> Option<String> {
614 let joined = content
615 .as_array()?
616 .iter()
617 .filter_map(Value::as_str)
618 .collect::<Vec<&str>>()
619 .join("\n\n");
620 (!joined.trim().is_empty()).then_some(joined)
621}
622
623fn unwrap_tag<'a>(text: &'a str, tag: &str) -> Option<&'a str> {
626 let opening = format!("<{tag}>");
627 let closing = format!("</{tag}>");
628 let from = text.find(&opening)? + opening.len();
629 let to = text[from..].find(&closing)? + from;
630 Some(&text[from..to])
631}
632
633fn strip_tag<'a>(text: &'a str, tag: &str) -> Cow<'a, str> {
636 let closing = format!("</{tag}>");
637 let Some(from) = text.find(&format!("<{tag}>")) else {
638 return Cow::Borrowed(text);
639 };
640 let Some(to) = text[from..]
641 .find(&closing)
642 .map(|at| at + from + closing.len())
643 else {
644 return Cow::Borrowed(text);
645 };
646 Cow::Owned(format!("{}{}", &text[..from], &text[to..]))
647}
648
649fn text<'a>(value: &'a Value, field: &str) -> Option<&'a str> {
650 value.get(field)?.as_str()
651}
652
653fn flag(value: &Value, field: &str) -> bool {
654 value.get(field).and_then(Value::as_bool).unwrap_or(false)
655}
656
657#[cfg(test)]
658mod tests {
659 use serde_json::json;
660
661 use super::*;
662
663 fn noted(said: &str) -> Option<Gloss> {
666 match command(said) {
667 Some(Wrapped::Note(gloss)) => Some(gloss),
668 _ => None,
669 }
670 }
671
672 #[test]
673 fn a_slash_command_is_read_out_of_its_wrapper() {
674 let gloss = noted(
675 "<command-message>debug-gha</command-message>\n\
676 <command-name>/debug-gha</command-name>",
677 )
678 .expect("the wrapper names a command");
679
680 assert_eq!(gloss.kind, GlossKind::Command);
681 assert_eq!(gloss.gist.as_deref(), Some("/debug-gha"));
682 assert!(gloss.notes.is_empty());
683 assert!(gloss.body.is_empty());
684 }
685
686 #[test]
687 fn a_command_carries_its_arguments_and_what_it_printed() {
688 let gloss = noted(
691 "<command-name>/loop</command-name>\n\
692 <command-message>loop</command-message>\n\
693 <command-args>5m /babysit-prs</command-args>\n\
694 <local-command-stdout>scheduled</local-command-stdout>",
695 )
696 .expect("the wrapper names a command");
697
698 assert_eq!(gloss.gist.as_deref(), Some("/loop"));
699 assert_eq!(gloss.notes, ["5m /babysit-prs"]);
700 assert_eq!(gloss.body, [Body::Plain("scheduled".into())]);
701 }
702
703 #[test]
704 fn a_command_that_only_controls_the_harness_is_not_set() {
705 assert_eq!(
710 command(
711 "<command-name>/copy</command-name>\n\
712 <local-command-stdout>Copied to clipboard (464 characters, 7 lines)\
713 </local-command-stdout>"
714 ),
715 Some(Wrapped::Nothing)
716 );
717 assert_eq!(
718 command("<command-name>/config</command-name>"),
719 Some(Wrapped::Nothing)
720 );
721 }
722
723 #[test]
724 fn a_command_that_changes_the_conversation_is_still_set() {
725 let gloss = noted("<command-name>/compact</command-name>")
728 .expect("a command that changes the conversation is a gloss");
729
730 assert_eq!(gloss.gist.as_deref(), Some("/compact"));
731 }
732
733 #[test]
734 fn a_command_that_printed_nothing_has_no_fold() {
735 let gloss = noted(
736 "<command-name>/clear</command-name>\n\
737 <command-args></command-args>\n\
738 <local-command-stdout></local-command-stdout>",
739 )
740 .expect("the wrapper names a command");
741
742 assert!(gloss.notes.is_empty());
743 assert!(gloss.body.is_empty());
744 }
745
746 #[test]
747 fn the_caveat_wrapping_a_command_is_not_set() {
748 let said = "<local-command-caveat>Caveat: The messages below were generated \
751 by the user while running local commands.</local-command-caveat>\n\
752 <command-name>/review</command-name>";
753
754 let Some(Wrapped::Note(gloss)) = wrapped(said, true) else {
755 panic!("the command should survive the caveat");
756 };
757 assert_eq!(gloss.kind, GlossKind::Command);
758 assert_eq!(gloss.gist.as_deref(), Some("/review"));
759 }
760
761 #[test]
762 fn a_caveat_with_nothing_left_under_it_is_dropped_rather_than_left_as_a_turn() {
763 assert_eq!(
767 wrapped(
768 "<local-command-caveat>Caveat: do not respond.</local-command-caveat>",
769 true
770 ),
771 Some(Wrapped::Nothing)
772 );
773 }
774
775 #[test]
776 fn what_the_user_actually_typed_is_left_in_the_conversation() {
777 assert_eq!(wrapped("Does the formatter run on nightly?", false), None);
778 }
779
780 #[test]
781 fn a_skill_is_named_by_its_directory_and_holds_its_instructions() {
782 let gloss = meta(
783 "Base directory for this skill: /home/jtk/.claude/skills/debug-gha\n\n\
784 # Debug GitHub Actions Runs\n\nThe objective is to **fix** the failing run.",
785 )
786 .expect("a skill body is a gloss");
787
788 assert_eq!(gloss.kind, GlossKind::Skill);
789 assert_eq!(gloss.gist.as_deref(), Some("debug-gha"));
790 assert_eq!(
791 gloss.body,
792 [Body::Prose(
793 "# Debug GitHub Actions Runs\n\nThe objective is to **fix** the failing run."
794 .into()
795 )]
796 );
797 }
798
799 #[test]
800 fn a_short_harness_note_is_stated_on_the_line_with_no_fold() {
801 let gloss = meta("Continue from where you left off").expect("a note is a gloss");
802
803 assert_eq!(gloss.kind, GlossKind::Note);
804 assert_eq!(
805 gloss.gist.as_deref(),
806 Some("Continue from where you left off")
807 );
808 assert!(gloss.body.is_empty());
809 }
810
811 #[test]
812 fn how_an_image_was_scaled_for_the_model_is_not_set() {
813 assert_eq!(
817 meta(
818 "[Image: original 1400x2006, displayed at 1396x2000. \
819 Multiply coordinates by 1.00 to map to original image.]"
820 ),
821 None
822 );
823 }
824
825 #[test]
826 fn a_hook_sets_what_it_contributed_and_what_it_printed_to_stderr() {
827 let gloss = attachment(&json!({
828 "type": "hook_success",
829 "hookName": "SessionStart:clear",
830 "command": "claude-branch-journal",
831 "content": "",
832 "stdout": "",
833 "stderr": "No journal yet for branch 'wide-margins'\n",
834 "exitCode": 0,
835 }))
836 .expect("a hook that printed something is a gloss");
837
838 assert_eq!(gloss.kind, GlossKind::Hook);
839 assert_eq!(gloss.gist.as_deref(), Some("SessionStart:clear"));
840 assert_eq!(gloss.notes, ["claude-branch-journal"]);
841 assert_eq!(
842 gloss.body,
843 [Body::Plain(
844 "No journal yet for branch 'wide-margins'".into()
845 )]
846 );
847 }
848
849 #[test]
850 fn a_hooks_contribution_is_set_once_though_it_is_recorded_twice() {
851 let gloss = attachment(&json!({
854 "type": "hook_success",
855 "hookName": "SessionStart:clear",
856 "content": "Current git status:\n\n## wide-margins",
857 "stdout": "Current git status:\n\n## wide-margins\n",
858 "stderr": "",
859 "exitCode": 0,
860 }))
861 .expect("a hook that contributed context is a gloss");
862
863 assert_eq!(
864 gloss.body,
865 [Body::Plain("Current git status:\n\n## wide-margins".into())]
866 );
867 }
868
869 #[test]
870 fn a_failing_hook_says_what_it_exited_with() {
871 let gloss = attachment(&json!({
872 "type": "hook_success",
873 "hookName": "PreToolUse:Bash",
874 "command": "claude-read-check",
875 "stdout": "use the Read tool",
876 "exitCode": 2,
877 }))
878 .expect("a hook that printed something is a gloss");
879
880 assert_eq!(gloss.notes, ["claude-read-check", "exit 2"]);
881 }
882
883 #[test]
884 fn hook_output_in_the_control_protocol_is_left_to_the_notes_it_asks_for() {
885 assert_eq!(
888 attachment(&json!({
889 "type": "hook_success",
890 "hookName": "Stop",
891 "command": "claude-stop",
892 "content": "",
893 "stdout": "{\"systemMessage\": \"finishing pass\", \
894 \"hookSpecificOutput\": {\"additionalContext\": \"…\"}}",
895 "stderr": "",
896 "exitCode": 0,
897 })),
898 None
899 );
900 }
901
902 #[test]
903 fn context_a_hook_injected_is_joined_into_one_document() {
904 let gloss = attachment(&json!({
905 "type": "hook_additional_context",
906 "hookName": "Stop",
907 "content": ["first thing", "second thing"],
908 }))
909 .expect("injected context is a gloss");
910
911 assert_eq!(gloss.gist.as_deref(), Some("Stop"));
912 assert_eq!(gloss.notes, ["added context"]);
913 assert_eq!(
917 gloss.body,
918 [Body::Printed("first thing\n\nsecond thing".into())]
919 );
920 }
921
922 #[test]
923 fn a_rule_pulled_into_context_is_named_by_the_path_written_for_a_reader() {
924 let gloss = attachment(&json!({
925 "type": "nested_memory",
926 "path": "/home/jtk/dotfiles/claude/rules/rust.md",
927 "displayPath": "claude/rules/rust.md",
928 "content": {
929 "path": "/home/jtk/dotfiles/claude/rules/rust.md",
930 "type": "User",
931 "content": "# Rust Style Guide\n\nUse the latest stable edition.",
932 },
933 }))
934 .expect("a memory file is a gloss");
935
936 assert_eq!(gloss.kind, GlossKind::Rule);
937 assert_eq!(gloss.gist.as_deref(), Some("claude/rules/rust.md"));
938 assert_eq!(gloss.notes, ["user"]);
939 assert_eq!(
940 gloss.body,
941 [Body::Prose(
942 "# Rust Style Guide\n\nUse the latest stable edition.".into()
943 )]
944 );
945 }
946
947 #[test]
948 fn plan_mode_marks_its_boundaries_and_says_whether_a_plan_was_written() {
949 let entered = attachment(&json!({
950 "type": "plan_mode",
951 "reminderType": "full",
952 "isSubAgent": false,
953 "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
954 "planExists": false,
955 }))
956 .expect("entering plan mode is a gloss");
957 assert_eq!(entered.kind, GlossKind::Plan);
958 assert_eq!(entered.gist.as_deref(), Some("entered plan mode"));
961 assert!(entered.notes.is_empty());
962
963 let written = attachment(&json!({
964 "type": "plan_mode_exit",
965 "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
966 "planExists": true,
967 }))
968 .expect("leaving plan mode is a gloss");
969 assert_eq!(
972 written.gist.as_deref(),
973 Some("/home/jtk/.claude/plans/wide-margins.md")
974 );
975 assert_eq!(written.notes, ["left, plan written"]);
976
977 let unwritten = attachment(&json!({
978 "type": "plan_mode_exit",
979 "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
980 "planExists": false,
981 }))
982 .expect("leaving plan mode is a gloss");
983 assert_eq!(unwritten.notes, ["left, no plan written"]);
984 }
985
986 #[test]
987 fn a_plan_boundary_with_no_file_is_named_by_what_happened() {
988 let gloss = attachment(&json!({ "type": "plan_mode_exit", "planExists": false }))
991 .expect("leaving plan mode is a gloss even with no file named");
992
993 assert_eq!(gloss.gist.as_deref(), Some("left, no plan written"));
994 assert!(gloss.notes.is_empty());
995 }
996
997 #[test]
998 fn a_reminder_that_plan_mode_is_still_on_is_not_a_boundary() {
999 assert_eq!(
1000 attachment(&json!({
1001 "type": "plan_mode",
1002 "reminderType": "sparse",
1003 "planFilePath": "/home/jtk/.claude/plans/wide-margins.md",
1004 "planExists": true,
1005 })),
1006 None
1007 );
1008 }
1009
1010 #[test]
1011 fn a_file_edited_outside_the_session_is_set() {
1012 let gloss = attachment(&json!({
1013 "type": "edited_text_file",
1014 "filename": "/home/jtk/projects/scriptorium/src/gloss.rs",
1015 "snippet": "9\tfn setting() {}",
1016 }))
1017 .expect("an external edit is a gloss");
1018
1019 assert_eq!(gloss.kind, GlossKind::Note);
1020 assert_eq!(gloss.notes, ["edited outside the session"]);
1021 assert_eq!(gloss.body, [Body::Plain("9\tfn setting() {}".into())]);
1022 }
1023
1024 #[test]
1025 fn scaffolding_the_reader_cannot_act_on_is_not_set() {
1026 for kind in [
1027 "task_reminder",
1028 "skill_listing",
1029 "deferred_tools_delta",
1030 "agent_listing_delta",
1031 "command_permissions",
1032 "date_change",
1033 ] {
1034 assert_eq!(
1035 attachment(&json!({ "type": kind, "content": "…" })),
1036 None,
1037 "{kind} should stay unset"
1038 );
1039 }
1040 }
1041
1042 #[test]
1043 fn a_local_commands_output_is_set_and_an_empty_one_is_not() {
1044 let gloss = system(&json!({
1045 "subtype": "local_command",
1046 "content": "<local-command-stdout>Copied to clipboard</local-command-stdout>",
1047 }))
1048 .expect("a command that printed something is a gloss");
1049
1050 assert_eq!(gloss.kind, GlossKind::Command);
1051 assert_eq!(gloss.body, [Body::Plain("Copied to clipboard".into())]);
1052
1053 assert_eq!(
1054 system(&json!({
1055 "subtype": "local_command",
1056 "content": "<local-command-stdout></local-command-stdout>",
1057 })),
1058 None
1059 );
1060 }
1061
1062 #[test]
1063 fn a_model_swapped_under_the_conversation_is_set() {
1064 let gloss = system(&json!({
1065 "subtype": "model_refusal_fallback",
1066 "originalModel": "claude-fable-5",
1067 "fallbackModel": "claude-opus-4-8",
1068 "content": "The safeguards flagged this message.",
1069 }))
1070 .expect("a fallback is a gloss");
1071
1072 assert_eq!(gloss.gist.as_deref(), Some("model switched"));
1073 assert_eq!(gloss.notes, ["claude-fable-5 → claude-opus-4-8"]);
1074 }
1075
1076 #[test]
1077 fn a_systems_own_bookkeeping_is_not_set() {
1078 for subtype in ["turn_duration", "stop_hook_summary"] {
1079 assert_eq!(
1080 system(&json!({ "subtype": subtype, "content": "…" })),
1081 None,
1082 "{subtype} should stay unset"
1083 );
1084 }
1085 }
1086}