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