1use halter_protocol::{HookOutputEntry, HookOutputKind};
4use serde::Deserialize;
5use serde_json::Value;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
8pub enum HandlerPriorityGroup {
10 SdkBeforePlugins,
12 PluginFiles,
14 SdkAfterPlugins,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
19pub struct HandlerPriority {
21 pub group: HandlerPriorityGroup,
22 pub plugin_load_order: usize,
23 pub event_declaration_index: usize,
24 pub matcher_group_index: usize,
25 pub hook_index_within_group: usize,
26}
27
28#[derive(Debug, Clone)]
29pub struct MergeInput {
31 pub handler_id: String,
32 pub priority: HandlerPriority,
33 pub output: HookOutput,
34}
35
36#[derive(Debug, Clone, Default, PartialEq)]
37pub struct HookMergedOutcome {
39 pub stop_reason: Option<String>,
40 pub block_reason: Option<String>,
41 pub permission_decision: Option<PermissionDecision>,
42 pub permission_decision_reason: Option<String>,
43 pub updated_input: Option<Value>,
44 pub updated_output: Option<Value>,
45 pub additional_context: Vec<String>,
46 pub system_messages: Vec<String>,
47 pub suppress_output: bool,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct MergeConflict {
53 pub field: &'static str,
54 pub winner: String,
55 pub loser: String,
56}
57
58#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
59pub struct HookOutput {
61 #[serde(default, rename = "continue")]
62 pub continue_execution: Option<bool>,
63 #[serde(default, rename = "suppressOutput")]
64 pub suppress_output: Option<bool>,
65 #[serde(default)]
66 pub decision: Option<HookDecision>,
67 #[serde(default)]
68 pub reason: Option<String>,
69 #[serde(default, rename = "stopReason")]
70 pub stop_reason: Option<String>,
71 #[serde(default, rename = "systemMessage")]
72 pub system_message: Option<String>,
73 #[serde(default, rename = "hookSpecificOutput")]
74 pub hook_specific_output: Option<HookSpecificOutput>,
75}
76
77#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "snake_case")]
79pub enum HookDecision {
81 Approve,
83 Block,
85}
86
87#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
88pub struct HookSpecificOutput {
90 #[serde(default, rename = "hookEventName")]
91 pub hook_event_name: Option<String>,
92 #[serde(default, rename = "permissionDecision")]
93 pub permission_decision: Option<PermissionDecision>,
94 #[serde(default, rename = "permissionDecisionReason")]
95 pub permission_decision_reason: Option<String>,
96 #[serde(default, rename = "updatedInput")]
97 pub updated_input: Option<Value>,
98 #[serde(default, rename = "updatedMCPToolOutput")]
99 pub updated_mcp_tool_output: Option<Value>,
100 #[serde(default, rename = "additionalContext")]
101 pub additional_context: Option<String>,
102}
103
104#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
110#[serde(rename_all = "snake_case")]
111pub enum PermissionDecision {
112 Passthrough,
113 Allow,
114 Ask,
115 Deny,
116}
117
118pub fn merge_outputs(inputs: &[MergeInput]) -> (HookMergedOutcome, Vec<MergeConflict>) {
123 let mut ordered: Vec<&MergeInput> = inputs.iter().collect();
126 ordered.sort_by(|left, right| left.priority.cmp(&right.priority));
127
128 let mut merged = HookMergedOutcome::default();
129 let mut conflicts = Vec::new();
130 let mut winning_updated_input: Option<String> = None;
131 let mut winning_updated_output: Option<String> = None;
132 let mut winning_permission: Option<(PermissionDecision, String, Option<String>)> = None;
133
134 for input in &ordered {
135 let reason = non_empty(input.output.reason.clone());
136 let stop_reason = non_empty(input.output.stop_reason.clone());
137
138 if matches!(input.output.continue_execution, Some(false)) && merged.stop_reason.is_none() {
139 merged.stop_reason = stop_reason
140 .clone()
141 .or(reason.clone())
142 .or_else(|| Some(default_stop_reason().to_owned()));
143 }
144
145 if matches!(input.output.decision, Some(HookDecision::Block))
146 && merged.block_reason.is_none()
147 {
148 merged.block_reason = Some(
149 reason
150 .clone()
151 .unwrap_or_else(|| default_block_reason().to_owned()),
152 );
153 }
154
155 if let Some(permission_decision) = input
156 .output
157 .hook_specific_output
158 .as_ref()
159 .and_then(|output| output.permission_decision)
160 {
161 let permission_reason = input
162 .output
163 .hook_specific_output
164 .as_ref()
165 .and_then(|output| non_empty(output.permission_decision_reason.clone()));
166 match &winning_permission {
167 Some((current, _, _)) if *current >= permission_decision => {}
168 _ => {
169 winning_permission = Some((
170 permission_decision,
171 input.handler_id.clone(),
172 permission_reason.clone(),
173 ));
174 merged.permission_decision = Some(permission_decision);
175 merged.permission_decision_reason = permission_reason;
176 }
177 }
178 }
179
180 if let Some(updated_input) = input
181 .output
182 .hook_specific_output
183 .as_ref()
184 .and_then(|output| output.updated_input.clone())
185 {
186 if merged.updated_input.is_none() {
187 merged.updated_input = Some(updated_input);
188 winning_updated_input = Some(input.handler_id.clone());
189 } else if let Some(winner) = winning_updated_input.as_ref() {
190 conflicts.push(MergeConflict {
191 field: "updated_input",
192 winner: winner.clone(),
193 loser: input.handler_id.clone(),
194 });
195 }
196 }
197
198 if let Some(updated_output) = input
199 .output
200 .hook_specific_output
201 .as_ref()
202 .and_then(|output| output.updated_mcp_tool_output.clone())
203 {
204 if merged.updated_output.is_none() {
205 merged.updated_output = Some(updated_output);
206 winning_updated_output = Some(input.handler_id.clone());
207 } else if let Some(winner) = winning_updated_output.as_ref() {
208 conflicts.push(MergeConflict {
209 field: "updated_output",
210 winner: winner.clone(),
211 loser: input.handler_id.clone(),
212 });
213 }
214 }
215
216 if let Some(context) = input
217 .output
218 .hook_specific_output
219 .as_ref()
220 .and_then(|output| output.additional_context.clone())
221 .filter(|value| !value.trim().is_empty())
222 {
223 merged.additional_context.push(context);
224 }
225
226 if let Some(message) = input
227 .output
228 .system_message
229 .clone()
230 .filter(|value| !value.trim().is_empty())
231 {
232 merged.system_messages.push(message);
233 }
234
235 if matches!(input.output.suppress_output, Some(true)) {
236 merged.suppress_output = true;
237 }
238 }
239
240 if let Some((decision, _, reason)) = winning_permission
241 && matches!(decision, PermissionDecision::Deny | PermissionDecision::Ask)
242 && merged.block_reason.is_none()
243 {
244 merged.block_reason =
245 Some(reason.unwrap_or_else(|| default_permission_block_reason(decision).to_owned()));
246 }
247
248 (merged, conflicts)
249}
250
251pub fn summary_entries(output: &HookOutput) -> Vec<HookOutputEntry> {
253 let mut entries = Vec::new();
254 if let Some(reason) = output
255 .reason
256 .clone()
257 .filter(|value| !value.trim().is_empty())
258 {
259 let kind = if matches!(output.decision, Some(HookDecision::Block)) {
260 HookOutputKind::Stop
261 } else {
262 HookOutputKind::Warning
263 };
264 entries.push(HookOutputEntry { kind, text: reason });
265 }
266 if let Some(stop_reason) = output
267 .stop_reason
268 .clone()
269 .filter(|value| !value.trim().is_empty())
270 {
271 entries.push(HookOutputEntry {
272 kind: HookOutputKind::Stop,
273 text: stop_reason,
274 });
275 }
276 if let Some(system_message) = output
277 .system_message
278 .clone()
279 .filter(|value| !value.trim().is_empty())
280 {
281 entries.push(HookOutputEntry {
282 kind: HookOutputKind::Feedback,
283 text: system_message,
284 });
285 }
286 if let Some(context) = output
287 .hook_specific_output
288 .as_ref()
289 .and_then(|value| value.additional_context.clone())
290 .filter(|value| !value.trim().is_empty())
291 {
292 entries.push(HookOutputEntry {
293 kind: HookOutputKind::Context,
294 text: context,
295 });
296 }
297 entries
298}
299
300fn non_empty(value: Option<String>) -> Option<String> {
301 value.and_then(|value| {
302 let trimmed = value.trim();
303 (!trimmed.is_empty()).then(|| trimmed.to_owned())
304 })
305}
306
307fn default_stop_reason() -> &'static str {
308 "hook requested stop"
309}
310
311fn default_block_reason() -> &'static str {
312 "hook blocked without explanation"
313}
314
315fn default_permission_block_reason(decision: PermissionDecision) -> &'static str {
316 match decision {
317 PermissionDecision::Deny => "hook denied permission without explanation",
318 PermissionDecision::Ask => "hook requested permission confirmation without explanation",
319 PermissionDecision::Allow | PermissionDecision::Passthrough => {
320 "hook blocked without explanation"
321 }
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use serde_json::json;
328
329 use super::*;
330
331 fn priority(
332 group: HandlerPriorityGroup,
333 plugin_load_order: usize,
334 event_declaration_index: usize,
335 matcher_group_index: usize,
336 hook_index_within_group: usize,
337 ) -> HandlerPriority {
338 HandlerPriority {
339 group,
340 plugin_load_order,
341 event_declaration_index,
342 matcher_group_index,
343 hook_index_within_group,
344 }
345 }
346
347 fn merge_input(handler_id: &str, priority: HandlerPriority, output: HookOutput) -> MergeInput {
348 MergeInput {
349 handler_id: handler_id.to_owned(),
350 priority,
351 output,
352 }
353 }
354
355 #[test]
356 fn merge_prefers_highest_priority_updated_input() {
357 let (merged, conflicts) = merge_outputs(&[
358 merge_input(
359 "plugin-a",
360 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
361 HookOutput {
362 hook_specific_output: Some(HookSpecificOutput {
363 updated_input: Some(json!({"command": "echo a"})),
364 ..HookSpecificOutput::default()
365 }),
366 ..HookOutput::default()
367 },
368 ),
369 merge_input(
370 "plugin-b",
371 priority(HandlerPriorityGroup::PluginFiles, 1, 0, 0, 0),
372 HookOutput {
373 hook_specific_output: Some(HookSpecificOutput {
374 updated_input: Some(json!({"command": "echo b"})),
375 ..HookSpecificOutput::default()
376 }),
377 ..HookOutput::default()
378 },
379 ),
380 ]);
381
382 assert_eq!(merged.updated_input, Some(json!({"command": "echo a"})));
383 assert_eq!(conflicts.len(), 1);
384 assert_eq!(conflicts[0].winner, "plugin-a");
385 assert_eq!(conflicts[0].loser, "plugin-b");
386 }
387
388 #[test]
389 fn merge_synthesizes_block_reason_when_reason_is_missing() {
390 let (merged, conflicts) = merge_outputs(&[merge_input(
391 "plugin-a",
392 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
393 HookOutput {
394 decision: Some(HookDecision::Block),
395 ..HookOutput::default()
396 },
397 )]);
398
399 assert_eq!(
400 merged.block_reason.as_deref(),
401 Some("hook blocked without explanation")
402 );
403 assert!(conflicts.is_empty());
404 }
405
406 #[test]
407 fn merge_uses_default_stop_reason_when_continue_stops_without_reason() {
408 let (merged, conflicts) = merge_outputs(&[merge_input(
409 "plugin-a",
410 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
411 HookOutput {
412 continue_execution: Some(false),
413 ..HookOutput::default()
414 },
415 )]);
416
417 assert_eq!(merged.stop_reason.as_deref(), Some("hook requested stop"));
418 assert!(conflicts.is_empty());
419 }
420
421 #[test]
422 fn merge_prefers_strongest_permission_decision() {
423 let (merged, conflicts) = merge_outputs(&[
424 merge_input(
425 "allow",
426 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
427 HookOutput {
428 hook_specific_output: Some(HookSpecificOutput {
429 permission_decision: Some(PermissionDecision::Allow),
430 permission_decision_reason: Some("allow".to_owned()),
431 ..HookSpecificOutput::default()
432 }),
433 ..HookOutput::default()
434 },
435 ),
436 merge_input(
437 "deny",
438 priority(HandlerPriorityGroup::PluginFiles, 1, 0, 0, 0),
439 HookOutput {
440 hook_specific_output: Some(HookSpecificOutput {
441 permission_decision: Some(PermissionDecision::Deny),
442 permission_decision_reason: Some("deny".to_owned()),
443 ..HookSpecificOutput::default()
444 }),
445 ..HookOutput::default()
446 },
447 ),
448 ]);
449
450 assert_eq!(merged.permission_decision, Some(PermissionDecision::Deny));
451 assert_eq!(merged.permission_decision_reason.as_deref(), Some("deny"));
452 assert_eq!(merged.block_reason.as_deref(), Some("deny"));
453 assert!(conflicts.is_empty());
454 }
455
456 #[test]
457 fn merge_synthesizes_permission_block_reason_when_missing() {
458 let (merged, conflicts) = merge_outputs(&[merge_input(
459 "deny",
460 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
461 HookOutput {
462 hook_specific_output: Some(HookSpecificOutput {
463 permission_decision: Some(PermissionDecision::Deny),
464 ..HookSpecificOutput::default()
465 }),
466 ..HookOutput::default()
467 },
468 )]);
469
470 assert_eq!(merged.permission_decision, Some(PermissionDecision::Deny));
471 assert_eq!(
472 merged.block_reason.as_deref(),
473 Some("hook denied permission without explanation")
474 );
475 assert!(conflicts.is_empty());
476 }
477
478 #[test]
479 fn merge_orders_context_and_system_messages_by_priority() {
480 let (merged, conflicts) = merge_outputs(&[
481 merge_input(
482 "sdk-before",
483 priority(HandlerPriorityGroup::SdkBeforePlugins, 0, 0, 0, 0),
484 HookOutput {
485 system_message: Some("sdk-before-message".to_owned()),
486 hook_specific_output: Some(HookSpecificOutput {
487 additional_context: Some("sdk-before-context".to_owned()),
488 ..HookSpecificOutput::default()
489 }),
490 ..HookOutput::default()
491 },
492 ),
493 merge_input(
494 "plugin",
495 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
496 HookOutput {
497 system_message: Some("plugin-message".to_owned()),
498 hook_specific_output: Some(HookSpecificOutput {
499 additional_context: Some("plugin-context".to_owned()),
500 ..HookSpecificOutput::default()
501 }),
502 ..HookOutput::default()
503 },
504 ),
505 merge_input(
506 "sdk-after",
507 priority(HandlerPriorityGroup::SdkAfterPlugins, 0, 0, 0, 0),
508 HookOutput {
509 system_message: Some("sdk-after-message".to_owned()),
510 hook_specific_output: Some(HookSpecificOutput {
511 additional_context: Some("sdk-after-context".to_owned()),
512 ..HookSpecificOutput::default()
513 }),
514 ..HookOutput::default()
515 },
516 ),
517 ]);
518
519 assert_eq!(
520 merged.additional_context,
521 vec![
522 "sdk-before-context".to_owned(),
523 "plugin-context".to_owned(),
524 "sdk-after-context".to_owned(),
525 ]
526 );
527 assert_eq!(
528 merged.system_messages,
529 vec![
530 "sdk-before-message".to_owned(),
531 "plugin-message".to_owned(),
532 "sdk-after-message".to_owned(),
533 ]
534 );
535 assert!(conflicts.is_empty());
536 }
537
538 #[test]
539 fn merge_uses_full_priority_tuple_for_tie_breaks() {
540 let (merged, conflicts) = merge_outputs(&[
541 merge_input(
542 "later-matcher",
543 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 1, 0),
544 HookOutput {
545 hook_specific_output: Some(HookSpecificOutput {
546 updated_input: Some(json!({"command": "echo later"})),
547 ..HookSpecificOutput::default()
548 }),
549 ..HookOutput::default()
550 },
551 ),
552 merge_input(
553 "earlier-matcher",
554 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 1),
555 HookOutput {
556 hook_specific_output: Some(HookSpecificOutput {
557 updated_input: Some(json!({"command": "echo earlier"})),
558 ..HookSpecificOutput::default()
559 }),
560 ..HookOutput::default()
561 },
562 ),
563 ]);
564
565 assert_eq!(
566 merged.updated_input,
567 Some(json!({"command": "echo earlier"}))
568 );
569 assert_eq!(conflicts.len(), 1);
570 assert_eq!(conflicts[0].winner, "earlier-matcher");
571 assert_eq!(conflicts[0].loser, "later-matcher");
572 }
573
574 #[test]
575 fn merge_prefers_earlier_event_declaration_index() {
576 let (merged, conflicts) = merge_outputs(&[
577 merge_input(
578 "later-event",
579 priority(HandlerPriorityGroup::PluginFiles, 0, 1, 0, 0),
580 HookOutput {
581 hook_specific_output: Some(HookSpecificOutput {
582 updated_input: Some(json!({"command": "echo later-event"})),
583 ..HookSpecificOutput::default()
584 }),
585 ..HookOutput::default()
586 },
587 ),
588 merge_input(
589 "earlier-event",
590 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
591 HookOutput {
592 hook_specific_output: Some(HookSpecificOutput {
593 updated_input: Some(json!({"command": "echo earlier-event"})),
594 ..HookSpecificOutput::default()
595 }),
596 ..HookOutput::default()
597 },
598 ),
599 ]);
600
601 assert_eq!(
602 merged.updated_input,
603 Some(json!({"command": "echo earlier-event"}))
604 );
605 assert_eq!(conflicts.len(), 1);
606 assert_eq!(conflicts[0].winner, "earlier-event");
607 }
608
609 #[test]
610 fn merge_prefers_earlier_hook_index_within_group() {
611 let (merged, conflicts) = merge_outputs(&[
612 merge_input(
613 "later-hook",
614 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 1),
615 HookOutput {
616 hook_specific_output: Some(HookSpecificOutput {
617 updated_input: Some(json!({"command": "echo later-hook"})),
618 ..HookSpecificOutput::default()
619 }),
620 ..HookOutput::default()
621 },
622 ),
623 merge_input(
624 "earlier-hook",
625 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
626 HookOutput {
627 hook_specific_output: Some(HookSpecificOutput {
628 updated_input: Some(json!({"command": "echo earlier-hook"})),
629 ..HookSpecificOutput::default()
630 }),
631 ..HookOutput::default()
632 },
633 ),
634 ]);
635
636 assert_eq!(
637 merged.updated_input,
638 Some(json!({"command": "echo earlier-hook"}))
639 );
640 assert_eq!(conflicts.len(), 1);
641 assert_eq!(conflicts[0].winner, "earlier-hook");
642 }
643
644 #[test]
645 fn merge_prefers_earlier_priority_for_same_permission_strength() {
646 let (merged, conflicts) = merge_outputs(&[
647 merge_input(
648 "earlier",
649 priority(HandlerPriorityGroup::PluginFiles, 0, 0, 0, 0),
650 HookOutput {
651 hook_specific_output: Some(HookSpecificOutput {
652 permission_decision: Some(PermissionDecision::Ask),
653 permission_decision_reason: Some("earlier".to_owned()),
654 ..HookSpecificOutput::default()
655 }),
656 ..HookOutput::default()
657 },
658 ),
659 merge_input(
660 "later",
661 priority(HandlerPriorityGroup::PluginFiles, 1, 0, 0, 0),
662 HookOutput {
663 hook_specific_output: Some(HookSpecificOutput {
664 permission_decision: Some(PermissionDecision::Ask),
665 permission_decision_reason: Some("later".to_owned()),
666 ..HookSpecificOutput::default()
667 }),
668 ..HookOutput::default()
669 },
670 ),
671 ]);
672
673 assert_eq!(merged.permission_decision, Some(PermissionDecision::Ask));
674 assert_eq!(
675 merged.permission_decision_reason.as_deref(),
676 Some("earlier")
677 );
678 assert_eq!(merged.block_reason.as_deref(), Some("earlier"));
679 assert!(conflicts.is_empty());
680 }
681}