1use std::collections::HashMap;
103
104use ppe::praxis_policy_core::cmf::constants::{
105 HOOK_CMF_LLM_INPUT, HOOK_CMF_LLM_OUTPUT, HOOK_CMF_PROMPT_POST_INVOKE,
106 HOOK_CMF_PROMPT_PRE_INVOKE, HOOK_CMF_RESOURCE_POST_FETCH, HOOK_CMF_RESOURCE_PRE_FETCH,
107 HOOK_CMF_TOOL_POST_INVOKE, HOOK_CMF_TOOL_PRE_INVOKE,
108};
109use ppe::praxis_policy_core::cmf::{ContentPart, Message, MessagePayload, Resource, Role};
110use ppe::praxis_policy_core::hooks::payload::PluginPayload;
111use serde::Serialize;
112use serde::de::DeserializeOwned;
113use serde_json::Value;
114
115use super::payloads::{
116 PromptPostFetchPayload, PromptPreFetchPayload, ResourcePostFetchPayload,
117 ResourcePreFetchPayload, ToolPostInvokePayload, ToolPreInvokePayload,
118};
119use crate::error::HostError;
120
121const SHIMMED_HOOKS: &[(&str, Shim)] = &[
124 (HOOK_CMF_TOOL_PRE_INVOKE, Shim::ToolPreInvoke),
125 (HOOK_CMF_TOOL_POST_INVOKE, Shim::ToolPostInvoke),
126 (HOOK_CMF_PROMPT_PRE_INVOKE, Shim::PromptPreFetch),
127 (HOOK_CMF_PROMPT_POST_INVOKE, Shim::PromptPostFetch),
128 (HOOK_CMF_RESOURCE_PRE_FETCH, Shim::ResourcePreFetch),
129 (HOOK_CMF_RESOURCE_POST_FETCH, Shim::ResourcePostFetch),
130];
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134enum Shim {
135 ToolPreInvoke,
136 ToolPostInvoke,
137 PromptPreFetch,
138 PromptPostFetch,
139 ResourcePreFetch,
140 ResourcePostFetch,
141}
142
143impl Shim {
144 fn for_hook(hook_name: &str) -> Option<Self> {
145 SHIMMED_HOOKS
146 .iter()
147 .find(|(name, _)| *name == hook_name)
148 .map(|(_, shim)| *shim)
149 }
150
151 fn legacy_hook(self) -> &'static str {
154 match self {
155 Self::ToolPreInvoke => "tool_pre_invoke",
156 Self::ToolPostInvoke => "tool_post_invoke",
157 Self::PromptPreFetch => "prompt_pre_fetch",
158 Self::PromptPostFetch => "prompt_post_fetch",
159 Self::ResourcePreFetch => "resource_pre_fetch",
160 Self::ResourcePostFetch => "resource_post_fetch",
161 }
162 }
163
164 fn is_post(self) -> bool {
167 matches!(
168 self,
169 Self::ToolPostInvoke | Self::PromptPostFetch | Self::ResourcePostFetch
170 )
171 }
172
173 fn part_name(self) -> &'static str {
175 match self {
176 Self::ToolPreInvoke => "tool_call",
177 Self::ToolPostInvoke => "tool_result",
178 Self::PromptPreFetch => "prompt_request",
179 Self::PromptPostFetch => "prompt_result",
180 Self::ResourcePreFetch => "resource or resource_ref",
181 Self::ResourcePostFetch => "resource",
182 }
183 }
184
185 fn matches(self, part: &ContentPart) -> bool {
187 match self {
188 Self::ToolPreInvoke => matches!(part, ContentPart::ToolCall { .. }),
189 Self::ToolPostInvoke => matches!(part, ContentPart::ToolResult { .. }),
190 Self::PromptPreFetch => matches!(part, ContentPart::PromptRequest { .. }),
191 Self::PromptPostFetch => matches!(part, ContentPart::PromptResult { .. }),
192 Self::ResourcePreFetch | Self::ResourcePostFetch => matches!(
197 part,
198 ContentPart::Resource { .. } | ContentPart::ResourceRef { .. }
199 ),
200 }
201 }
202}
203
204pub fn is_cmf_hook(hook_name: &str) -> bool {
210 Shim::for_hook(hook_name).is_some()
211 || hook_name == HOOK_CMF_LLM_INPUT
212 || hook_name == HOOK_CMF_LLM_OUTPUT
213}
214
215pub fn legacy_hook_name(hook_name: &str) -> Option<&'static str> {
218 Shim::for_hook(hook_name).map(Shim::legacy_hook)
219}
220
221#[derive(Debug)]
224pub struct ShimmedPayload {
225 shim: Shim,
226 hook_name: String,
227 original: MessagePayload,
230 part: usize,
232 json: Value,
233}
234
235impl ShimmedPayload {
236 pub fn legacy_hook(&self) -> &'static str {
238 self.shim.legacy_hook()
239 }
240
241 pub fn payload(&self) -> &Value {
244 &self.json
245 }
246
247 pub fn apply_modified(&self, modified: Value) -> Result<MessagePayload, HostError> {
254 let mut payload = self.original.clone();
255 let part = payload
256 .message
257 .content
258 .get_mut(self.part)
259 .ok_or_else(|| self.error("the original content part is gone"))?;
260
261 match (self.shim, part) {
262 (Shim::ToolPreInvoke, ContentPart::ToolCall { content }) => {
263 let legacy: ToolPreInvokePayload = self.parse(modified)?;
264 if legacy.headers.as_ref().is_some_and(|h| !h.is_empty()) {
265 return Err(self.error(
266 "the plugin returned `headers`, which a CMF tool call cannot carry",
267 ));
268 }
269 content.name = legacy.name;
270 content.arguments = legacy.args.unwrap_or_default();
271 },
272 (Shim::ToolPostInvoke, ContentPart::ToolResult { content }) => {
273 let legacy: ToolPostInvokePayload = self.parse(modified)?;
274 if content.is_error && !result_says_error(&legacy.result) {
275 return Err(self.error(
276 "the plugin cleared `isError` on an error tool result, and the CMF \
277 `is_error` flag is not mapped back",
278 ));
279 }
280 content.is_error = content.is_error || result_says_error(&legacy.result);
283 content.tool_name = legacy.name;
284 content.content = legacy.result;
285 },
286 (Shim::PromptPreFetch, ContentPart::PromptRequest { content }) => {
287 let legacy: PromptPreFetchPayload = self.parse(modified)?;
288 content.name = legacy.prompt_id;
289 content.arguments = legacy
290 .args
291 .unwrap_or_default()
292 .into_iter()
293 .map(|(k, v)| (k, Value::String(v)))
294 .collect();
295 },
296 (Shim::PromptPostFetch, ContentPart::PromptResult { content }) => {
297 let legacy: PromptPostFetchPayload = self.parse(modified)?;
298 content.messages = self.messages_from_legacy(legacy.result, &content.messages)?;
299 content.prompt_name = legacy.prompt_id;
300 },
301 (Shim::ResourcePreFetch, ContentPart::Resource { content }) => {
302 let uri = self.resource_pre_uri(modified)?;
303 if uri != content.uri
307 && (content.is_embedded()
308 || content.mime_type.is_some()
309 || content.size_bytes.is_some())
310 {
311 return Err(self.error(
312 "the plugin rewrote the URI of a resource that carries content, \
313 mime_type or size_bytes from the original URI",
314 ));
315 }
316 content.uri = uri;
317 },
318 (Shim::ResourcePreFetch, ContentPart::ResourceRef { content }) => {
319 content.uri = self.resource_pre_uri(modified)?;
320 },
321 (Shim::ResourcePostFetch, ContentPart::Resource { content }) => {
322 let legacy: ResourcePostFetchPayload = self.parse(modified)?;
323 let before = content.content.clone();
324 let original_uri = std::mem::replace(&mut content.uri, legacy.uri);
325 match legacy.content {
326 Value::Null => content.content = None,
327 Value::String(text) => content.content = Some(text),
328 Value::Object(fields) => {
329 if fields.get("blob").is_some_and(|b| !b.is_null()) {
330 return Err(self.error(
331 "the plugin returned resource `content.blob`; binary resource \
332 content is not carried by the shim",
333 ));
334 }
335 if fields.get("id").is_some_and(|id| {
339 id.as_str() != Some(content.resource_request_id.as_str())
340 }) {
341 return Err(self.error(
342 "the plugin changed `content.id`, which is not mapped back",
343 ));
344 }
345 if fields.get("uri").is_some_and(|u| {
346 u.as_str() != Some(content.uri.as_str())
347 && u.as_str() != Some(original_uri.as_str())
348 }) {
349 return Err(self.error(
350 "the plugin changed `content.uri` to a value other than `uri`, \
351 and only `uri` is mapped back",
352 ));
353 }
354 content.content = match fields.get("text") {
355 None | Some(Value::Null) => None,
356 Some(Value::String(text)) => Some(text.clone()),
357 Some(_) => {
358 return Err(self.error("`content.text` must be a string or null"));
359 },
360 };
361 if let Some(mime) = fields.get("mime_type") {
362 content.mime_type = match mime {
363 Value::Null => None,
364 Value::String(m) => Some(m.clone()),
365 _ => {
366 return Err(
367 self.error("`content.mime_type` must be a string or null")
368 );
369 },
370 };
371 }
372 },
373 _ => return Err(self.error("`content` must be an object, a string, or null")),
374 }
375 if content.content != before && content.size_bytes.is_some() {
378 content.size_bytes = content.content.as_ref().map(|t| t.len() as u64);
379 }
380 },
381 _ => return Err(self.error("the original content part changed kind")),
382 }
383
384 Ok(payload)
385 }
386
387 fn resource_pre_uri(&self, modified: Value) -> Result<String, HostError> {
388 let legacy: ResourcePreFetchPayload = self.parse(modified)?;
389 if legacy.metadata.as_ref().is_some_and(|m| !m.is_empty()) {
390 return Err(self.error(
391 "the plugin returned resource `metadata`, which a CMF resource part cannot carry",
392 ));
393 }
394 Ok(legacy.uri)
395 }
396
397 fn messages_from_legacy(
403 &self,
404 result: Value,
405 original: &[Message],
406 ) -> Result<Vec<Message>, HostError> {
407 let Value::Object(result) = result else {
408 return Err(self.error("`result` must be an object with a `messages` list"));
409 };
410 if result.get("description").is_some_and(|d| !d.is_null()) {
411 return Err(self.error(
412 "the plugin returned a prompt result `description`, which a CMF prompt result \
413 cannot carry",
414 ));
415 }
416 let Some(Value::Array(items)) = result.get("messages") else {
417 return Err(self.error("`result.messages` must be a list"));
418 };
419
420 items
421 .iter()
422 .enumerate()
423 .map(|(i, item)| {
424 let role = item
425 .get("role")
426 .cloned()
427 .and_then(|r| serde_json::from_value::<Role>(r).ok())
428 .ok_or_else(|| {
429 self.error(&format!("`result.messages[{i}].role` is not a CMF role"))
430 })?;
431 let text = match item.get("content") {
432 Some(Value::Object(c))
433 if c.get("type").and_then(Value::as_str) == Some("text") =>
434 {
435 c.get("text").and_then(Value::as_str)
436 },
437 _ => None,
438 }
439 .ok_or_else(|| {
440 self.error(&format!(
441 "`result.messages[{i}].content` must be text content \
442 ({{\"type\": \"text\", \"text\": ...}})"
443 ))
444 })?;
445
446 let mut message = original
447 .get(i)
448 .cloned()
449 .unwrap_or_else(|| Message::text(role, ""));
450 message.role = role;
451 message.content = vec![ContentPart::Text {
452 text: text.to_string(),
453 }];
454 Ok(message)
455 })
456 .collect()
457 }
458
459 fn parse<T: DeserializeOwned>(&self, modified: Value) -> Result<T, HostError> {
460 serde_json::from_value(modified).map_err(|e| HostError::Protocol {
461 message: format!(
462 "worker returned a modified payload that does not match the legacy hook '{}' \
463 (dispatched as '{}'): {e}",
464 self.shim.legacy_hook(),
465 self.hook_name
466 ),
467 })
468 }
469
470 fn error(&self, message: &str) -> HostError {
471 shim_error(&self.hook_name, self.shim.legacy_hook(), message)
472 }
473}
474
475fn shim_error(hook_name: &str, legacy: &str, message: &str) -> HostError {
476 HostError::LegacyShim {
477 hook: hook_name.to_string(),
478 message: format!("translating to the legacy '{legacy}' payload: {message}"),
479 }
480}
481
482pub fn to_legacy(
489 hook_name: &str,
490 payload: &dyn PluginPayload,
491) -> Result<ShimmedPayload, HostError> {
492 let Some(shim) = Shim::for_hook(hook_name) else {
493 return Err(HostError::LegacyShim {
494 hook: hook_name.to_string(),
495 message: "it has no legacy hook equivalent, so only a worker that claims the \
496 `cmf_hooks` feature can run it. Upgrade the cpex package in the plugin's \
497 venv, or remove the hook from the plugin's `hooks:` list."
498 .into(),
499 });
500 };
501 let legacy = shim.legacy_hook();
502
503 let original = payload
504 .as_any()
505 .downcast_ref::<MessagePayload>()
506 .ok_or_else(|| shim_error(hook_name, legacy, "the payload is not a CMF MessagePayload"))?
507 .clone();
508
509 let candidates: Vec<usize> = original
510 .message
511 .content
512 .iter()
513 .enumerate()
514 .filter(|(_, p)| shim.matches(p))
515 .map(|(i, _)| i)
516 .collect();
517 let part = match candidates.as_slice() {
518 [only] => *only,
519 [] => {
520 return Err(shim_error(
521 hook_name,
522 legacy,
523 &format!("the message has no {} content part", shim.part_name()),
524 ));
525 },
526 many => {
527 return Err(shim_error(
528 hook_name,
529 legacy,
530 &format!(
531 "the message has {} {} content parts and the legacy payload holds one",
532 many.len(),
533 shim.part_name()
534 ),
535 ));
536 },
537 };
538
539 if let Some(sibling) = original
544 .message
545 .content
546 .iter()
547 .enumerate()
548 .find(|(i, p)| shim.is_post() && *i != part && carries_content(p))
549 .map(|(_, p)| p)
550 {
551 return Err(shim_error(
552 hook_name,
553 legacy,
554 &format!(
555 "the message holds a {} content part beside the {} part, which the legacy \
556 payload cannot carry, so a plugin could not inspect or redact it",
557 part_kind(sibling),
558 shim.part_name()
559 ),
560 ));
561 }
562
563 let err = |message: &str| shim_error(hook_name, legacy, message);
564 let json = match (shim, &original.message.content[part]) {
565 (Shim::ToolPreInvoke, ContentPart::ToolCall { content }) => {
566 if is_set(&content.namespace) {
567 return Err(err(
568 "the tool call has a `namespace`, which the legacy payload cannot carry; \
569 a plugin that authorizes by bare `name` would approve it in any namespace",
570 ));
571 }
572 to_json(
573 &ToolPreInvokePayload {
574 name: content.name.clone(),
575 args: Some(content.arguments.clone()),
576 headers: None,
577 },
578 &err,
579 )?
580 },
581 (Shim::ToolPostInvoke, ContentPart::ToolResult { content }) => {
582 if content.is_error && !result_says_error(&content.content) {
583 return Err(err(
584 "the tool result has `is_error` set, and its `content` does not carry \
585 `\"isError\": true` for the legacy plugin to see",
586 ));
587 }
588 to_json(
589 &ToolPostInvokePayload {
590 name: content.tool_name.clone(),
591 result: content.content.clone(),
592 },
593 &err,
594 )?
595 },
596 (Shim::PromptPreFetch, ContentPart::PromptRequest { content }) => {
597 if is_set(&content.server_id) {
598 return Err(err(
599 "the prompt request has a `server_id`, which the legacy payload cannot \
600 carry; a plugin that authorizes by bare `prompt_id` would approve it on \
601 any server",
602 ));
603 }
604 let mut args = HashMap::with_capacity(content.arguments.len());
607 for (key, value) in &content.arguments {
608 let Value::String(s) = value else {
609 return Err(err(&format!(
610 "prompt argument '{key}' is not a string, and the legacy payload's \
611 `args` is dict[str, str]"
612 )));
613 };
614 args.insert(key.clone(), s.clone());
615 }
616 to_json(
617 &PromptPreFetchPayload {
618 prompt_id: content.name.clone(),
619 args: Some(args),
620 },
621 &err,
622 )?
623 },
624 (Shim::PromptPostFetch, ContentPart::PromptResult { content }) => {
625 if is_set(&content.content) {
628 return Err(err(
629 "the prompt result has rendered `content`, which the legacy payload cannot \
630 carry, so a plugin could not inspect or redact it",
631 ));
632 }
633 if is_set(&content.error_message) {
634 return Err(err(
635 "the prompt result has an `error_message`, which the legacy payload cannot \
636 carry, so a plugin could not inspect or redact it",
637 ));
638 }
639 if content.is_error {
640 return Err(err(
641 "the prompt result has `is_error` set, which the legacy payload cannot carry",
642 ));
643 }
644 let mut messages = Vec::with_capacity(content.messages.len());
645 for (i, message) in content.messages.iter().enumerate() {
646 let [ContentPart::Text { text }] = message.content.as_slice() else {
647 return Err(err(&format!(
648 "prompt result message {i} is not a single text part, and the legacy \
649 prompt message holds one text content"
650 )));
651 };
652 messages.push(serde_json::json!({
653 "role": message.role,
654 "content": { "type": "text", "text": text },
655 }));
656 }
657 to_json(
658 &PromptPostFetchPayload {
659 prompt_id: content.prompt_name.clone(),
660 result: serde_json::json!({ "messages": messages, "description": null }),
661 },
662 &err,
663 )?
664 },
665 (Shim::ResourcePreFetch, ContentPart::Resource { content }) => {
666 refuse_resource_version(content, &err)?;
667 to_json(
668 &ResourcePreFetchPayload {
669 uri: content.uri.clone(),
670 metadata: None,
671 },
672 &err,
673 )?
674 },
675 (Shim::ResourcePreFetch, ContentPart::ResourceRef { content }) => to_json(
676 &ResourcePreFetchPayload {
677 uri: content.uri.clone(),
678 metadata: None,
679 },
680 &err,
681 )?,
682 (Shim::ResourcePostFetch, ContentPart::Resource { content }) => {
683 refuse_resource_version(content, &err)?;
684 if content.blob.is_some() {
685 return Err(err(
686 "the resource carries binary `blob` content, which the shim does not \
687 translate",
688 ));
689 }
690 to_json(
692 &ResourcePostFetchPayload {
693 uri: content.uri.clone(),
694 content: serde_json::json!({
695 "type": "resource",
696 "id": content.resource_request_id,
697 "uri": content.uri,
698 "mime_type": content.mime_type,
699 "text": content.content,
700 }),
701 },
702 &err,
703 )?
704 },
705 (Shim::ResourcePostFetch, ContentPart::ResourceRef { .. }) => {
706 return Err(err(
707 "the message holds a resource_ref with no content, and the legacy post-fetch \
708 payload carries the content",
709 ));
710 },
711 _ => return Err(err("unexpected content part")),
714 };
715
716 Ok(ShimmedPayload {
717 shim,
718 hook_name: hook_name.to_string(),
719 original,
720 part,
721 json,
722 })
723}
724
725fn is_set(field: &Option<String>) -> bool {
728 field.as_deref().is_some_and(|s| !s.is_empty())
729}
730
731fn carries_content(part: &ContentPart) -> bool {
734 match part {
735 ContentPart::Text { text } | ContentPart::Thinking { text } => !text.trim().is_empty(),
736 _ => true,
737 }
738}
739
740fn part_kind(part: &ContentPart) -> &'static str {
742 match part {
743 ContentPart::Text { .. } => "text",
744 ContentPart::Thinking { .. } => "thinking",
745 ContentPart::ToolCall { .. } => "tool_call",
746 ContentPart::ToolResult { .. } => "tool_result",
747 ContentPart::Resource { .. } => "resource",
748 ContentPart::ResourceRef { .. } => "resource_ref",
749 ContentPart::PromptRequest { .. } => "prompt_request",
750 ContentPart::PromptResult { .. } => "prompt_result",
751 ContentPart::Image { .. } => "image",
752 ContentPart::Video { .. } => "video",
753 ContentPart::Audio { .. } => "audio",
754 ContentPart::Document { .. } => "document",
755 }
756}
757
758fn result_says_error(result: &Value) -> bool {
761 result.get("isError") == Some(&Value::Bool(true))
762}
763
764fn refuse_resource_version(
765 resource: &Resource,
766 err: &dyn Fn(&str) -> HostError,
767) -> Result<(), HostError> {
768 if is_set(&resource.version) {
769 return Err(err(
770 "the resource has a `version`, which the legacy payload cannot carry; a plugin \
771 that authorizes by `uri` would approve any revision",
772 ));
773 }
774 Ok(())
775}
776
777fn to_json<T: Serialize>(value: &T, err: &dyn Fn(&str) -> HostError) -> Result<Value, HostError> {
778 serde_json::to_value(value).map_err(|e| err(&format!("could not serialize: {e}")))
779}
780
781#[cfg(test)]
782mod tests {
783 use ppe::praxis_policy_core::cmf::{
784 Channel, PromptRequest, PromptResult, Resource, ResourceReference, ResourceType, ToolCall,
785 ToolResult,
786 };
787 use serde_json::json;
788
789 use super::*;
790
791 fn context_text() -> ContentPart {
795 ContentPart::Text {
796 text: "unrelated context".into(),
797 }
798 }
799
800 fn message_around(part: ContentPart) -> MessagePayload {
803 let mut message = Message::with_content(
804 Role::Assistant,
805 vec![
806 ContentPart::Text {
807 text: String::new(),
808 },
809 part,
810 ContentPart::Thinking { text: " ".into() },
811 ],
812 );
813 message.channel = Some(Channel::Commentary);
814 MessagePayload { message }
815 }
816
817 fn tool_call(args: Value) -> ContentPart {
818 ContentPart::ToolCall {
819 content: ToolCall {
820 tool_call_id: "call-1".into(),
821 name: "search".into(),
822 arguments: serde_json::from_value(args).unwrap(),
823 namespace: None,
824 },
825 }
826 }
827
828 fn tool_result(content: Value) -> ContentPart {
829 ContentPart::ToolResult {
830 content: ToolResult {
831 tool_call_id: "call-1".into(),
832 tool_name: "search".into(),
833 content,
834 is_error: false,
835 },
836 }
837 }
838
839 fn prompt_request(args: Value) -> ContentPart {
840 ContentPart::PromptRequest {
841 content: PromptRequest {
842 prompt_request_id: "pr-1".into(),
843 name: "greeting".into(),
844 arguments: serde_json::from_value(args).unwrap(),
845 server_id: None,
846 },
847 }
848 }
849
850 fn prompt_result(messages: Vec<Message>) -> ContentPart {
851 ContentPart::PromptResult {
852 content: PromptResult {
853 prompt_request_id: "pr-1".into(),
854 prompt_name: "greeting".into(),
855 messages,
856 content: None,
857 is_error: false,
858 error_message: None,
859 },
860 }
861 }
862
863 fn resource(text: Option<&str>) -> Resource {
864 Resource {
865 resource_request_id: "res-1".into(),
866 uri: "file:///tmp/a.txt".into(),
867 name: Some("a".into()),
868 description: None,
869 resource_type: ResourceType::File,
870 content: text.map(str::to_string),
871 blob: None,
872 mime_type: Some("text/plain".into()),
873 size_bytes: Some(5),
874 annotations: HashMap::from([("k".into(), json!("v"))]),
875 version: None,
876 }
877 }
878
879 fn resource_ref() -> ContentPart {
880 ContentPart::ResourceRef {
881 content: ResourceReference {
882 resource_request_id: "res-1".into(),
883 uri: "file:///tmp/a.txt".into(),
884 name: None,
885 resource_type: ResourceType::File,
886 range_start: Some(1),
887 range_end: None,
888 selector: None,
889 },
890 }
891 }
892
893 fn json_of(payload: &MessagePayload) -> Value {
894 serde_json::to_value(payload).unwrap()
895 }
896
897 fn assert_surroundings_kept(payload: &MessagePayload) {
900 let json = json_of(payload);
901 assert_eq!(
902 json["message"]["content"][0],
903 json!({"content_type": "text", "text": ""})
904 );
905 assert_eq!(
906 json["message"]["content"][2],
907 json!({"content_type": "thinking", "text": " "})
908 );
909 assert_eq!(json["message"]["role"], "assistant");
910 assert_eq!(json["message"]["channel"], "commentary");
911 assert_eq!(payload.message.content.len(), 3);
912 }
913
914 fn part_json(payload: &MessagePayload) -> Value {
916 json_of(payload)["message"]["content"][1].clone()
917 }
918
919 fn assert_shim_error(err: HostError, hook: &str, needle: &str) {
920 let text = err.to_string();
921 assert!(matches!(err, HostError::LegacyShim { .. }), "got {err:?}");
922 assert!(text.contains(hook), "the error must name the hook: {text}");
923 assert!(text.contains(needle), "expected '{needle}' in: {text}");
924 }
925
926 #[test]
929 fn the_six_tool_prompt_and_resource_hooks_map_to_legacy_names() {
930 for (cmf, legacy) in [
931 ("cmf.tool_pre_invoke", "tool_pre_invoke"),
932 ("cmf.tool_post_invoke", "tool_post_invoke"),
933 ("cmf.prompt_pre_invoke", "prompt_pre_fetch"),
934 ("cmf.prompt_post_invoke", "prompt_post_fetch"),
935 ("cmf.resource_pre_fetch", "resource_pre_fetch"),
936 ("cmf.resource_post_fetch", "resource_post_fetch"),
937 ] {
938 assert_eq!(legacy_hook_name(cmf), Some(legacy), "{cmf}");
939 assert!(is_cmf_hook(cmf));
940 }
941 }
942
943 #[test]
944 fn every_cmf_hook_ppe_dispatches_is_covered() {
945 for hook in ppe::praxis_policy_core::hooks::builtin_hook_types() {
948 let name = hook.as_str();
949 if name.starts_with("cmf.") {
950 assert!(
951 is_cmf_hook(name),
952 "'{name}' is not covered by the shim table"
953 );
954 }
955 }
956 }
957
958 #[test]
959 fn non_cmf_hooks_are_not_shimmed() {
960 for hook in [
961 "tool_pre_invoke",
962 "identity.resolve",
963 "token.delegate",
964 "cmf.custom",
965 ] {
966 assert!(!is_cmf_hook(hook), "{hook}");
967 assert_eq!(legacy_hook_name(hook), None, "{hook}");
968 }
969 }
970
971 #[test]
974 fn tool_pre_invoke_sends_name_and_args() {
975 let payload = message_around(tool_call(json!({"q": "rust", "n": 3})));
976 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
977
978 assert_eq!(shimmed.legacy_hook(), "tool_pre_invoke");
979 assert_eq!(
980 shimmed.payload(),
981 &json!({"name": "search", "args": {"q": "rust", "n": 3}})
982 );
983 }
984
985 #[test]
986 fn tool_pre_invoke_unchanged_round_trip_preserves_the_message() {
987 let payload = message_around(tool_call(json!({"q": "rust"})));
988 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
989
990 let back = shimmed
993 .apply_modified(json!({"name": "search", "args": {"q": "rust"}, "headers": null}))
994 .unwrap();
995 assert_eq!(json_of(&back), json_of(&payload));
996 }
997
998 #[test]
999 fn tool_pre_invoke_modified_args_map_back() {
1000 let payload = message_around(tool_call(json!({"q": "alice@example.com"})));
1001 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1002
1003 let back = shimmed
1004 .apply_modified(
1005 json!({"name": "search", "args": {"q": "[REDACTED]", "extra": 1}, "headers": {}}),
1006 )
1007 .unwrap();
1008 assert_eq!(
1009 part_json(&back),
1010 json!({"content_type": "tool_call", "content": {
1011 "tool_call_id": "call-1",
1012 "name": "search",
1013 "arguments": {"q": "[REDACTED]", "extra": 1},
1014 }})
1015 );
1016 assert_surroundings_kept(&back);
1017 }
1018
1019 #[test]
1020 fn tool_pre_invoke_returned_headers_fail_closed() {
1021 let payload = message_around(tool_call(json!({})));
1022 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1023 let err = shimmed
1024 .apply_modified(json!({"name": "search", "args": {}, "headers": {"X-Injected": "1"}}))
1025 .unwrap_err();
1026 assert_shim_error(err, "cmf.tool_pre_invoke", "headers");
1027 }
1028
1029 #[test]
1030 fn tool_pre_invoke_without_a_tool_call_fails_closed() {
1031 let payload = MessagePayload {
1032 message: Message::text(Role::User, "what is the weather?"),
1033 };
1034 let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1035 assert_shim_error(err, "cmf.tool_pre_invoke", "no tool_call content part");
1036 }
1037
1038 #[test]
1039 fn tool_pre_invoke_with_two_tool_calls_fails_closed() {
1040 let payload = MessagePayload {
1041 message: Message::with_content(
1042 Role::Assistant,
1043 vec![tool_call(json!({})), tool_call(json!({}))],
1044 ),
1045 };
1046 let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1047 assert_shim_error(err, "cmf.tool_pre_invoke", "2 tool_call content parts");
1048 }
1049
1050 #[test]
1051 fn a_modified_payload_missing_required_fields_is_a_protocol_error() {
1052 let payload = message_around(tool_call(json!({})));
1053 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1054 let err = shimmed.apply_modified(json!({"args": {}})).unwrap_err();
1055 assert!(matches!(err, HostError::Protocol { .. }), "got {err:?}");
1056 assert!(err.to_string().contains("tool_pre_invoke"), "{err}");
1057 }
1058
1059 #[test]
1062 fn tool_post_invoke_sends_name_and_result() {
1063 let result = json!({"content": [{"type": "text", "text": "3 hits"}], "isError": false});
1064 let payload = message_around(tool_result(result.clone()));
1065 let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1066
1067 assert_eq!(shimmed.legacy_hook(), "tool_post_invoke");
1068 assert_eq!(
1069 shimmed.payload(),
1070 &json!({"name": "search", "result": result})
1071 );
1072
1073 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1074 assert_eq!(json_of(&back), json_of(&payload));
1075 }
1076
1077 #[test]
1078 fn tool_post_invoke_modified_result_maps_back() {
1079 let payload = message_around(tool_result(json!({"text": "ssn 123-45-6789"})));
1080 let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1081
1082 let back = shimmed
1083 .apply_modified(json!({"name": "search", "result": {"text": "ssn [REDACTED]"}}))
1084 .unwrap();
1085 assert_eq!(
1086 part_json(&back),
1087 json!({"content_type": "tool_result", "content": {
1088 "tool_call_id": "call-1",
1089 "tool_name": "search",
1090 "content": {"text": "ssn [REDACTED]"},
1091 "is_error": false,
1092 }})
1093 );
1094 assert_surroundings_kept(&back);
1095 }
1096
1097 #[test]
1098 fn tool_post_invoke_without_a_tool_result_fails_closed() {
1099 let payload = message_around(tool_call(json!({})));
1100 let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1101 assert_shim_error(err, "cmf.tool_post_invoke", "no tool_result content part");
1102 }
1103
1104 #[test]
1107 fn prompt_pre_invoke_sends_prompt_id_and_string_args() {
1108 let payload = message_around(prompt_request(json!({"lang": "en"})));
1109 let shimmed = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap();
1110
1111 assert_eq!(shimmed.legacy_hook(), "prompt_pre_fetch");
1112 assert_eq!(
1113 shimmed.payload(),
1114 &json!({"prompt_id": "greeting", "args": {"lang": "en"}})
1115 );
1116
1117 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1118 assert_eq!(json_of(&back), json_of(&payload));
1119 }
1120
1121 #[test]
1122 fn prompt_pre_invoke_modified_args_map_back() {
1123 let payload = message_around(prompt_request(json!({"lang": "en", "who": "bob"})));
1124 let shimmed = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap();
1125
1126 let back = shimmed
1127 .apply_modified(json!({"prompt_id": "greeting", "args": {"lang": "fr"}}))
1128 .unwrap();
1129 assert_eq!(
1130 part_json(&back),
1131 json!({"content_type": "prompt_request", "content": {
1132 "prompt_request_id": "pr-1",
1133 "name": "greeting",
1134 "arguments": {"lang": "fr"},
1135 }})
1136 );
1137 assert_surroundings_kept(&back);
1138 }
1139
1140 #[test]
1141 fn prompt_pre_invoke_with_a_non_string_argument_fails_closed() {
1142 let payload = message_around(prompt_request(json!({"count": 5})));
1143 let err = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap_err();
1144 assert_shim_error(err, "cmf.prompt_pre_invoke", "'count' is not a string");
1145 }
1146
1147 #[test]
1148 fn prompt_pre_invoke_without_a_prompt_request_fails_closed() {
1149 let payload = message_around(tool_call(json!({})));
1150 let err = to_legacy("cmf.prompt_pre_invoke", &payload).unwrap_err();
1151 assert_shim_error(
1152 err,
1153 "cmf.prompt_pre_invoke",
1154 "no prompt_request content part",
1155 );
1156 }
1157
1158 #[test]
1161 fn prompt_post_invoke_sends_mcp_shaped_messages() {
1162 let payload = message_around(prompt_result(vec![
1163 Message::text(Role::System, "be brief"),
1164 Message::text(Role::User, "hi bob"),
1165 ]));
1166 let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1167
1168 assert_eq!(shimmed.legacy_hook(), "prompt_post_fetch");
1169 assert_eq!(
1170 shimmed.payload(),
1171 &json!({"prompt_id": "greeting", "result": {
1172 "messages": [
1173 {"role": "system", "content": {"type": "text", "text": "be brief"}},
1174 {"role": "user", "content": {"type": "text", "text": "hi bob"}},
1175 ],
1176 "description": null,
1177 }})
1178 );
1179
1180 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1181 assert_eq!(json_of(&back), json_of(&payload));
1182 }
1183
1184 #[test]
1185 fn prompt_post_invoke_modified_messages_map_back() {
1186 let mut first = Message::text(Role::User, "hi bob");
1187 first.channel = Some(Channel::Final);
1188 let payload = message_around(prompt_result(vec![first]));
1189 let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1190
1191 let back = shimmed
1194 .apply_modified(json!({"prompt_id": "greeting", "result": {
1195 "messages": [
1196 {"role": "user", "content": {"type": "text", "text": "hi [NAME]", "annotations": null}},
1197 {"role": "assistant", "content": {"type": "text", "text": "appended"}},
1198 ],
1199 "description": null,
1200 }}))
1201 .unwrap();
1202
1203 let part = part_json(&back);
1204 assert_eq!(
1205 part["content"]["prompt_request_id"], "pr-1",
1206 "unmapped fields survive"
1207 );
1208 let messages = &part["content"]["messages"];
1209 assert_eq!(messages[0]["content"][0]["text"], "hi [NAME]");
1210 assert_eq!(
1211 messages[0]["channel"], "final",
1212 "the original message's channel is kept"
1213 );
1214 assert_eq!(messages[1]["role"], "assistant");
1215 assert_eq!(messages[1]["content"][0]["text"], "appended");
1216 assert_surroundings_kept(&back);
1217 }
1218
1219 #[test]
1220 fn prompt_post_invoke_with_a_multipart_message_fails_closed() {
1221 let payload = message_around(prompt_result(vec![Message::with_content(
1222 Role::User,
1223 vec![context_text(), context_text()],
1224 )]));
1225 let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1226 assert_shim_error(err, "cmf.prompt_post_invoke", "not a single text part");
1227 }
1228
1229 #[test]
1230 fn prompt_post_invoke_returned_description_fails_closed() {
1231 let payload = message_around(prompt_result(vec![]));
1232 let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1233 let err = shimmed
1234 .apply_modified(
1235 json!({"prompt_id": "greeting", "result": {"messages": [], "description": "x"}}),
1236 )
1237 .unwrap_err();
1238 assert_shim_error(err, "cmf.prompt_post_invoke", "description");
1239 }
1240
1241 #[test]
1242 fn prompt_post_invoke_returned_non_text_content_fails_closed() {
1243 let payload = message_around(prompt_result(vec![]));
1244 let shimmed = to_legacy("cmf.prompt_post_invoke", &payload).unwrap();
1245 let err = shimmed
1246 .apply_modified(json!({"prompt_id": "greeting", "result": {"messages": [
1247 {"role": "user", "content": {"type": "image", "data": "..."}}
1248 ]}}))
1249 .unwrap_err();
1250 assert_shim_error(err, "cmf.prompt_post_invoke", "messages[0].content");
1251 }
1252
1253 #[test]
1256 fn resource_pre_fetch_sends_the_uri_from_a_reference() {
1257 let payload = message_around(resource_ref());
1258 let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1259
1260 assert_eq!(shimmed.legacy_hook(), "resource_pre_fetch");
1261 assert_eq!(shimmed.payload(), &json!({"uri": "file:///tmp/a.txt"}));
1262
1263 let back = shimmed
1265 .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {}}))
1266 .unwrap();
1267 assert_eq!(json_of(&back), json_of(&payload));
1268 }
1269
1270 fn unfetched_resource() -> Resource {
1273 Resource {
1274 mime_type: None,
1275 size_bytes: None,
1276 ..resource(None)
1277 }
1278 }
1279
1280 #[test]
1281 fn resource_pre_fetch_modified_uri_maps_back_onto_an_unfetched_resource() {
1282 let payload = message_around(ContentPart::Resource {
1283 content: unfetched_resource(),
1284 });
1285 let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1286
1287 let back = shimmed
1288 .apply_modified(json!({"uri": "file:///safe/a.txt", "metadata": {}}))
1289 .unwrap();
1290 let mut expected = unfetched_resource();
1291 expected.uri = "file:///safe/a.txt".into();
1292 assert_eq!(
1293 part_json(&back),
1294 serde_json::to_value(ContentPart::Resource { content: expected }).unwrap()
1295 );
1296 assert_surroundings_kept(&back);
1297 }
1298
1299 #[test]
1300 fn resource_pre_fetch_returned_metadata_fails_closed() {
1301 let payload = message_around(resource_ref());
1302 let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1303 let err = shimmed
1304 .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {"etag": "x"}}))
1305 .unwrap_err();
1306 assert_shim_error(err, "cmf.resource_pre_fetch", "metadata");
1307 }
1308
1309 #[test]
1310 fn resource_pre_fetch_without_a_resource_fails_closed() {
1311 let payload = MessagePayload {
1312 message: Message::text(Role::User, "no resource here"),
1313 };
1314 let err = to_legacy("cmf.resource_pre_fetch", &payload).unwrap_err();
1315 assert_shim_error(err, "cmf.resource_pre_fetch", "no resource or resource_ref");
1316 }
1317
1318 #[test]
1321 fn resource_post_fetch_sends_legacy_resource_content() {
1322 let payload = message_around(ContentPart::Resource {
1323 content: resource(Some("hello")),
1324 });
1325 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1326
1327 assert_eq!(shimmed.legacy_hook(), "resource_post_fetch");
1328 assert_eq!(
1329 shimmed.payload(),
1330 &json!({"uri": "file:///tmp/a.txt", "content": {
1331 "type": "resource",
1332 "id": "res-1",
1333 "uri": "file:///tmp/a.txt",
1334 "mime_type": "text/plain",
1335 "text": "hello",
1336 }})
1337 );
1338
1339 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1340 assert_eq!(json_of(&back), json_of(&payload));
1341 }
1342
1343 #[test]
1344 fn resource_post_fetch_modified_content_maps_back() {
1345 let payload = message_around(ContentPart::Resource {
1346 content: resource(Some("password=hunter2")),
1347 });
1348 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1349
1350 let back = shimmed
1351 .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {
1352 "type": "resource", "id": "res-1", "uri": "file:///tmp/a.txt",
1353 "mime_type": "text/plain", "text": "password=[REDACTED]", "blob": null,
1354 }}))
1355 .unwrap();
1356 let mut expected = resource(Some("password=[REDACTED]"));
1357 expected.size_bytes = Some("password=[REDACTED]".len() as u64);
1358 assert_eq!(
1359 part_json(&back),
1360 serde_json::to_value(ContentPart::Resource { content: expected }).unwrap(),
1361 "the text changes and the size follows it; name and annotations survive"
1362 );
1363 assert_surroundings_kept(&back);
1364 }
1365
1366 #[test]
1367 fn resource_post_fetch_accepts_plain_string_content() {
1368 let payload = message_around(ContentPart::Resource {
1369 content: resource(Some("a")),
1370 });
1371 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1372 let back = shimmed
1373 .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": "b"}))
1374 .unwrap();
1375 assert_eq!(part_json(&back)["content"]["content"], "b");
1376 }
1377
1378 #[test]
1379 fn resource_post_fetch_with_a_blob_fails_closed() {
1380 let mut binary = resource(None);
1381 binary.blob = Some(vec![0, 1, 2]);
1382 let payload = message_around(ContentPart::Resource { content: binary });
1383 let err = to_legacy("cmf.resource_post_fetch", &payload).unwrap_err();
1384 assert_shim_error(err, "cmf.resource_post_fetch", "blob");
1385 }
1386
1387 #[test]
1388 fn resource_post_fetch_with_only_a_reference_fails_closed() {
1389 let payload = message_around(resource_ref());
1390 let err = to_legacy("cmf.resource_post_fetch", &payload).unwrap_err();
1391 assert_shim_error(
1392 err,
1393 "cmf.resource_post_fetch",
1394 "resource_ref with no content",
1395 );
1396 }
1397
1398 #[test]
1399 fn resource_post_fetch_returned_blob_fails_closed() {
1400 let payload = message_around(ContentPart::Resource {
1401 content: resource(Some("a")),
1402 });
1403 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1404 let err = shimmed
1405 .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {"blob": "AAEC"}}))
1406 .unwrap_err();
1407 assert_shim_error(err, "cmf.resource_post_fetch", "blob");
1408 }
1409
1410 #[test]
1413 fn tool_pre_invoke_with_a_namespace_fails_closed() {
1414 let mut part = tool_call(json!({}));
1415 if let ContentPart::ToolCall { content } = &mut part {
1416 content.namespace = Some("other-tenant".into());
1417 }
1418 let err = to_legacy("cmf.tool_pre_invoke", &message_around(part)).unwrap_err();
1419 assert_shim_error(err, "cmf.tool_pre_invoke", "namespace");
1420 }
1421
1422 #[test]
1423 fn tool_pre_invoke_with_an_empty_namespace_is_unqualified() {
1424 let mut part = tool_call(json!({}));
1425 if let ContentPart::ToolCall { content } = &mut part {
1426 content.namespace = Some(String::new());
1427 }
1428 let payload = message_around(part);
1429 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1430 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1431 assert_eq!(json_of(&back), json_of(&payload));
1432 }
1433
1434 fn error_tool_result(content: Value) -> ContentPart {
1435 let mut part = tool_result(content);
1436 if let ContentPart::ToolResult { content } = &mut part {
1437 content.is_error = true;
1438 }
1439 part
1440 }
1441
1442 #[test]
1443 fn tool_post_invoke_error_the_result_does_not_show_fails_closed() {
1444 let payload = message_around(error_tool_result(json!({"text": "boom"})));
1445 let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1446 assert_shim_error(err, "cmf.tool_post_invoke", "is_error");
1447 }
1448
1449 #[test]
1450 fn tool_post_invoke_error_the_result_shows_round_trips() {
1451 let payload = message_around(error_tool_result(json!({"content": [], "isError": true})));
1452 let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1453 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1454 assert_eq!(json_of(&back), json_of(&payload));
1455 }
1456
1457 #[test]
1458 fn tool_post_invoke_clearing_is_error_fails_closed() {
1459 let payload = message_around(error_tool_result(json!({"content": [], "isError": true})));
1460 let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1461 let err = shimmed
1462 .apply_modified(json!({"name": "search", "result": {"content": [], "isError": false}}))
1463 .unwrap_err();
1464 assert_shim_error(err, "cmf.tool_post_invoke", "isError");
1465 }
1466
1467 #[test]
1468 fn prompt_pre_invoke_with_a_server_id_fails_closed() {
1469 let mut part = prompt_request(json!({}));
1470 if let ContentPart::PromptRequest { content } = &mut part {
1471 content.server_id = Some("other-server".into());
1472 }
1473 let err = to_legacy("cmf.prompt_pre_invoke", &message_around(part)).unwrap_err();
1474 assert_shim_error(err, "cmf.prompt_pre_invoke", "server_id");
1475 }
1476
1477 fn prompt_result_with(edit: impl FnOnce(&mut PromptResult)) -> MessagePayload {
1478 let mut part = prompt_result(vec![Message::text(Role::User, "hi bob")]);
1479 if let ContentPart::PromptResult { content } = &mut part {
1480 edit(content);
1481 }
1482 message_around(part)
1483 }
1484
1485 #[test]
1486 fn prompt_post_invoke_with_rendered_content_fails_closed() {
1487 let payload = prompt_result_with(|r| r.content = Some("hi bob".into()));
1489 let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1490 assert_shim_error(err, "cmf.prompt_post_invoke", "`content`");
1491 }
1492
1493 #[test]
1494 fn prompt_post_invoke_with_an_error_message_fails_closed() {
1495 let payload = prompt_result_with(|r| r.error_message = Some("failed for bob".into()));
1496 let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1497 assert_shim_error(err, "cmf.prompt_post_invoke", "error_message");
1498 }
1499
1500 #[test]
1501 fn prompt_post_invoke_with_is_error_fails_closed() {
1502 let payload = prompt_result_with(|r| r.is_error = true);
1503 let err = to_legacy("cmf.prompt_post_invoke", &payload).unwrap_err();
1504 assert_shim_error(err, "cmf.prompt_post_invoke", "is_error");
1505 }
1506
1507 #[test]
1508 fn resource_hooks_with_a_version_fail_closed() {
1509 let mut versioned = resource(Some("a"));
1510 versioned.version = Some("v1".into());
1511 let payload = message_around(ContentPart::Resource { content: versioned });
1512 for hook in ["cmf.resource_pre_fetch", "cmf.resource_post_fetch"] {
1513 let err = to_legacy(hook, &payload).unwrap_err();
1514 assert_shim_error(err, hook, "version");
1515 }
1516 }
1517
1518 #[test]
1519 fn resource_pre_fetch_unchanged_uri_keeps_embedded_content() {
1520 let payload = message_around(ContentPart::Resource {
1521 content: resource(Some("hello")),
1522 });
1523 let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1524 let back = shimmed
1525 .apply_modified(json!({"uri": "file:///tmp/a.txt", "metadata": {}}))
1526 .unwrap();
1527 assert_eq!(json_of(&back), json_of(&payload));
1528 }
1529
1530 #[test]
1531 fn resource_pre_fetch_uri_rewrite_on_fetched_content_fails_closed() {
1532 let embedded = resource(Some("hello"));
1533 let mut sized = unfetched_resource();
1534 sized.size_bytes = Some(5);
1535 let mut typed = unfetched_resource();
1536 typed.mime_type = Some("text/plain".into());
1537 let mut binary = unfetched_resource();
1538 binary.blob = Some(vec![1]);
1539 for res in [embedded, sized, typed, binary] {
1540 let payload = message_around(ContentPart::Resource { content: res });
1541 let shimmed = to_legacy("cmf.resource_pre_fetch", &payload).unwrap();
1542 let err = shimmed
1543 .apply_modified(json!({"uri": "file:///safe/a.txt", "metadata": {}}))
1544 .unwrap_err();
1545 assert_shim_error(err, "cmf.resource_pre_fetch", "rewrote the URI");
1546 }
1547 }
1548
1549 #[test]
1550 fn resource_post_fetch_changed_content_id_fails_closed() {
1551 let payload = message_around(ContentPart::Resource {
1552 content: resource(Some("a")),
1553 });
1554 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1555 let err = shimmed
1556 .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {
1557 "id": "res-2", "uri": "file:///tmp/a.txt", "text": "a",
1558 }}))
1559 .unwrap_err();
1560 assert_shim_error(err, "cmf.resource_post_fetch", "content.id");
1561 }
1562
1563 #[test]
1564 fn resource_post_fetch_content_uri_alone_changed_fails_closed() {
1565 let payload = message_around(ContentPart::Resource {
1566 content: resource(Some("a")),
1567 });
1568 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1569 let err = shimmed
1570 .apply_modified(json!({"uri": "file:///tmp/a.txt", "content": {
1571 "id": "res-1", "uri": "file:///safe/a.txt", "text": "a",
1572 }}))
1573 .unwrap_err();
1574 assert_shim_error(err, "cmf.resource_post_fetch", "content.uri");
1575 }
1576
1577 #[test]
1578 fn resource_post_fetch_top_level_uri_change_maps_back() {
1579 let payload = message_around(ContentPart::Resource {
1580 content: resource(Some("a")),
1581 });
1582 let shimmed = to_legacy("cmf.resource_post_fetch", &payload).unwrap();
1583 let back = shimmed
1584 .apply_modified(json!({"uri": "file:///safe/a.txt", "content": {
1585 "id": "res-1", "uri": "file:///tmp/a.txt", "text": "a",
1586 }}))
1587 .unwrap();
1588 assert_eq!(part_json(&back)["content"]["uri"], "file:///safe/a.txt");
1589 assert_eq!(
1590 part_json(&back)["content"]["size_bytes"],
1591 5,
1592 "text unchanged"
1593 );
1594 }
1595
1596 #[test]
1597 fn tool_post_invoke_a_plugin_setting_is_error_sets_the_flag() {
1598 let payload = message_around(tool_result(json!({"content": [], "isError": false})));
1599 let shimmed = to_legacy("cmf.tool_post_invoke", &payload).unwrap();
1600 let back = shimmed
1601 .apply_modified(json!({"name": "search", "result": {"content": [], "isError": true}}))
1602 .unwrap();
1603 assert_eq!(part_json(&back)["content"]["is_error"], true);
1604 assert_eq!(part_json(&back)["content"]["content"]["isError"], true);
1605 }
1606
1607 fn each_hook_with_its_part() -> Vec<(&'static str, ContentPart)> {
1611 vec![
1612 ("cmf.tool_pre_invoke", tool_call(json!({}))),
1613 ("cmf.tool_post_invoke", tool_result(json!({"text": "ok"}))),
1614 ("cmf.prompt_pre_invoke", prompt_request(json!({}))),
1615 ("cmf.prompt_post_invoke", prompt_result(vec![])),
1616 ("cmf.resource_pre_fetch", resource_ref()),
1617 (
1618 "cmf.resource_post_fetch",
1619 ContentPart::Resource {
1620 content: resource(Some("a")),
1621 },
1622 ),
1623 ]
1624 }
1625
1626 fn is_post_hook(hook: &str) -> bool {
1628 hook.contains("_post_")
1629 }
1630
1631 fn texty_siblings() -> [ContentPart; 2] {
1633 [
1634 ContentPart::Text {
1635 text: "SSN 123-45-6789".into(),
1636 },
1637 ContentPart::Thinking {
1638 text: "SSN 123-45-6789".into(),
1639 },
1640 ]
1641 }
1642
1643 #[test]
1644 fn a_sibling_text_part_fails_closed_on_post_hooks() {
1645 for (hook, part) in each_hook_with_its_part()
1648 .into_iter()
1649 .filter(|(h, _)| is_post_hook(h))
1650 {
1651 for sibling in texty_siblings() {
1652 let payload = MessagePayload {
1653 message: Message::with_content(Role::Assistant, vec![sibling, part.clone()]),
1654 };
1655 let err = to_legacy(hook, &payload).unwrap_err();
1656 assert_shim_error(err, hook, "beside the");
1657 }
1658 }
1659 }
1660
1661 #[test]
1662 fn a_sibling_text_part_passes_through_on_pre_hooks() {
1663 for (hook, part) in each_hook_with_its_part()
1666 .into_iter()
1667 .filter(|(h, _)| !is_post_hook(h))
1668 {
1669 for sibling in texty_siblings() {
1670 let payload = MessagePayload {
1671 message: Message::with_content(
1672 Role::Assistant,
1673 vec![sibling.clone(), part.clone()],
1674 ),
1675 };
1676 let shimmed = to_legacy(hook, &payload).unwrap();
1677 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1678 assert_eq!(back.message.content.len(), 2, "{hook}");
1679 assert_eq!(
1680 serde_json::to_value(&back.message.content[0]).unwrap(),
1681 serde_json::to_value(&sibling).unwrap(),
1682 "{hook}"
1683 );
1684 assert_eq!(
1685 json_of(&back),
1686 json_of(&payload),
1687 "{hook}: an unmodified round trip changes nothing"
1688 );
1689 }
1690 }
1691 }
1692
1693 #[test]
1694 fn a_pre_hook_rewrite_keeps_the_narration_sibling() {
1695 let narration = ContentPart::Text {
1696 text: "Let me look that up.".into(),
1697 };
1698 let payload = MessagePayload {
1699 message: Message::with_content(
1700 Role::Assistant,
1701 vec![narration.clone(), tool_call(json!({"q": "a"}))],
1702 ),
1703 };
1704 let shimmed = to_legacy("cmf.tool_pre_invoke", &payload).unwrap();
1705 let back = shimmed
1706 .apply_modified(json!({"name": "search", "args": {"q": "b"}}))
1707 .unwrap();
1708 let json = json_of(&back);
1709 assert_eq!(
1710 json["message"]["content"][0],
1711 serde_json::to_value(&narration).unwrap()
1712 );
1713 assert_eq!(
1714 json["message"]["content"][1]["content"]["arguments"]["q"],
1715 "b"
1716 );
1717 }
1718
1719 #[test]
1720 fn a_sibling_part_of_another_kind_fails_closed() {
1721 let payload = MessagePayload {
1722 message: Message::with_content(
1723 Role::Assistant,
1724 vec![
1725 tool_result(json!({"text": "ok"})),
1726 ContentPart::ToolCall {
1727 content: ToolCall {
1728 tool_call_id: "call-2".into(),
1729 name: "other".into(),
1730 arguments: HashMap::new(),
1731 namespace: None,
1732 },
1733 },
1734 ],
1735 ),
1736 };
1737 let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1738 assert_shim_error(err, "cmf.tool_post_invoke", "tool_call content part beside");
1739 }
1740
1741 #[test]
1742 fn a_sibling_refusal_names_no_content() {
1743 let payload = MessagePayload {
1744 message: Message::with_content(
1745 Role::Assistant,
1746 vec![
1747 ContentPart::Text {
1748 text: "SECRET-SIBLING-TEXT".into(),
1749 },
1750 tool_result(json!({"text": "ok"})),
1751 ],
1752 ),
1753 };
1754 let err = to_legacy("cmf.tool_post_invoke", &payload).unwrap_err();
1755 assert!(!err.to_string().contains("SECRET-SIBLING-TEXT"), "{err}");
1756 }
1757
1758 #[test]
1759 fn empty_sibling_parts_are_kept() {
1760 for (hook, part) in each_hook_with_its_part() {
1761 let payload = message_around(part);
1762 let shimmed = to_legacy(hook, &payload).unwrap();
1763 let back = shimmed.apply_modified(shimmed.payload().clone()).unwrap();
1764 assert_surroundings_kept(&back);
1765 }
1766 }
1767
1768 #[test]
1771 fn llm_hooks_have_no_legacy_equivalent_and_fail_closed() {
1772 let payload = MessagePayload {
1773 message: Message::text(Role::User, "hello"),
1774 };
1775 for hook in ["cmf.llm_input", "cmf.llm_output"] {
1776 assert!(is_cmf_hook(hook));
1777 let err = to_legacy(hook, &payload).unwrap_err();
1778 assert_shim_error(err, hook, "cmf_hooks");
1779 }
1780 }
1781
1782 #[test]
1783 fn a_non_message_payload_fails_closed() {
1784 let payload = ToolPreInvokePayload {
1785 name: "search".into(),
1786 ..Default::default()
1787 };
1788 let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1789 assert_shim_error(err, "cmf.tool_pre_invoke", "not a CMF MessagePayload");
1790 }
1791
1792 #[test]
1793 fn shim_errors_carry_no_payload_values() {
1794 let payload = MessagePayload {
1796 message: Message::text(Role::User, "SECRET-USER-TEXT"),
1797 };
1798 let err = to_legacy("cmf.tool_pre_invoke", &payload).unwrap_err();
1799 assert!(!err.to_string().contains("SECRET-USER-TEXT"), "{err}");
1800 }
1801}