Skip to main content

codex_hooks/
schema.rs

1use schemars::JsonSchema;
2use schemars::r#gen::SchemaGenerator;
3use schemars::r#gen::SchemaSettings;
4use schemars::schema::InstanceType;
5use schemars::schema::RootSchema;
6use schemars::schema::Schema;
7use schemars::schema::SchemaObject;
8use serde::Deserialize;
9use serde::Serialize;
10use serde_json::Map;
11use serde_json::Value;
12use std::path::Path;
13use std::path::PathBuf;
14
15use crate::events::common::SubagentHookContext;
16
17const GENERATED_DIR: &str = "generated";
18const POST_TOOL_USE_INPUT_FIXTURE: &str = "post-tool-use.command.input.schema.json";
19const POST_TOOL_USE_OUTPUT_FIXTURE: &str = "post-tool-use.command.output.schema.json";
20const PERMISSION_REQUEST_INPUT_FIXTURE: &str = "permission-request.command.input.schema.json";
21const PERMISSION_REQUEST_OUTPUT_FIXTURE: &str = "permission-request.command.output.schema.json";
22const POST_COMPACT_INPUT_FIXTURE: &str = "post-compact.command.input.schema.json";
23const POST_COMPACT_OUTPUT_FIXTURE: &str = "post-compact.command.output.schema.json";
24const PRE_TOOL_USE_INPUT_FIXTURE: &str = "pre-tool-use.command.input.schema.json";
25const PRE_TOOL_USE_OUTPUT_FIXTURE: &str = "pre-tool-use.command.output.schema.json";
26const PRE_COMPACT_INPUT_FIXTURE: &str = "pre-compact.command.input.schema.json";
27const PRE_COMPACT_OUTPUT_FIXTURE: &str = "pre-compact.command.output.schema.json";
28const SESSION_START_INPUT_FIXTURE: &str = "session-start.command.input.schema.json";
29const SESSION_START_OUTPUT_FIXTURE: &str = "session-start.command.output.schema.json";
30const SESSION_END_INPUT_FIXTURE: &str = "session-end.command.input.schema.json";
31const USER_PROMPT_SUBMIT_INPUT_FIXTURE: &str = "user-prompt-submit.command.input.schema.json";
32const USER_PROMPT_SUBMIT_OUTPUT_FIXTURE: &str = "user-prompt-submit.command.output.schema.json";
33const SUBAGENT_START_INPUT_FIXTURE: &str = "subagent-start.command.input.schema.json";
34const SUBAGENT_START_OUTPUT_FIXTURE: &str = "subagent-start.command.output.schema.json";
35const SUBAGENT_STOP_INPUT_FIXTURE: &str = "subagent-stop.command.input.schema.json";
36const SUBAGENT_STOP_OUTPUT_FIXTURE: &str = "subagent-stop.command.output.schema.json";
37const STOP_INPUT_FIXTURE: &str = "stop.command.input.schema.json";
38const STOP_OUTPUT_FIXTURE: &str = "stop.command.output.schema.json";
39
40#[derive(Debug, Clone, Serialize)]
41#[serde(transparent)]
42pub(crate) struct NullableString(Option<String>);
43
44impl NullableString {
45    pub(crate) fn from_path(path: Option<PathBuf>) -> Self {
46        Self(path.map(|path| path.display().to_string()))
47    }
48
49    pub(crate) fn from_string(value: Option<String>) -> Self {
50        Self(value)
51    }
52}
53
54impl JsonSchema for NullableString {
55    fn schema_name() -> String {
56        "NullableString".to_string()
57    }
58
59    fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
60        Schema::Object(SchemaObject {
61            instance_type: Some(vec![InstanceType::String, InstanceType::Null].into()),
62            ..Default::default()
63        })
64    }
65}
66
67#[derive(Debug, Clone, Default)]
68pub(crate) struct SubagentCommandInputFields {
69    pub agent_id: Option<String>,
70    pub agent_type: Option<String>,
71}
72
73impl From<Option<&SubagentHookContext>> for SubagentCommandInputFields {
74    fn from(value: Option<&SubagentHookContext>) -> Self {
75        match value {
76            Some(context) => Self {
77                agent_id: Some(context.agent_id.clone()),
78                agent_type: Some(context.agent_type.clone()),
79            },
80            None => Self::default(),
81        }
82    }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
86#[serde(rename_all = "camelCase")]
87#[serde(deny_unknown_fields)]
88pub(crate) struct HookUniversalOutputWire {
89    #[serde(default = "default_continue")]
90    pub r#continue: bool,
91    #[serde(default)]
92    pub stop_reason: Option<String>,
93    #[serde(default)]
94    pub suppress_output: bool,
95    #[serde(default)]
96    pub system_message: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
100pub(crate) enum HookEventNameWire {
101    #[serde(rename = "PreToolUse")]
102    PreToolUse,
103    #[serde(rename = "PermissionRequest")]
104    PermissionRequest,
105    #[serde(rename = "PostToolUse")]
106    PostToolUse,
107    #[serde(rename = "PreCompact")]
108    PreCompact,
109    #[serde(rename = "PostCompact")]
110    PostCompact,
111    #[serde(rename = "SessionStart")]
112    SessionStart,
113    #[serde(rename = "UserPromptSubmit")]
114    UserPromptSubmit,
115    #[serde(rename = "SubagentStart")]
116    SubagentStart,
117    #[serde(rename = "SubagentStop")]
118    SubagentStop,
119    #[serde(rename = "Stop")]
120    Stop,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
124#[serde(rename_all = "camelCase")]
125#[serde(deny_unknown_fields)]
126#[schemars(rename = "pre-tool-use.command.output")]
127pub(crate) struct PreToolUseCommandOutputWire {
128    #[serde(flatten)]
129    pub universal: HookUniversalOutputWire,
130    #[serde(default)]
131    pub decision: Option<PreToolUseDecisionWire>,
132    #[serde(default)]
133    pub reason: Option<String>,
134    #[serde(default)]
135    pub hook_specific_output: Option<PreToolUseHookSpecificOutputWire>,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
139#[serde(rename_all = "camelCase")]
140#[serde(deny_unknown_fields)]
141#[schemars(rename = "post-tool-use.command.output")]
142pub(crate) struct PostToolUseCommandOutputWire {
143    #[serde(flatten)]
144    pub universal: HookUniversalOutputWire,
145    #[serde(default)]
146    pub decision: Option<BlockDecisionWire>,
147    #[serde(default)]
148    pub reason: Option<String>,
149    #[serde(default)]
150    pub hook_specific_output: Option<PostToolUseHookSpecificOutputWire>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
154#[serde(rename_all = "camelCase")]
155#[serde(deny_unknown_fields)]
156#[schemars(rename = "permission-request.command.output")]
157pub(crate) struct PermissionRequestCommandOutputWire {
158    #[serde(flatten)]
159    pub universal: HookUniversalOutputWire,
160    #[serde(default)]
161    pub hook_specific_output: Option<PermissionRequestHookSpecificOutputWire>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
165#[serde(rename_all = "camelCase")]
166#[serde(deny_unknown_fields)]
167#[schemars(rename = "pre-compact.command.output")]
168pub(crate) struct PreCompactCommandOutputWire {
169    #[serde(flatten)]
170    pub universal: HookUniversalOutputWire,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
174#[serde(rename_all = "camelCase")]
175#[serde(deny_unknown_fields)]
176#[schemars(rename = "post-compact.command.output")]
177pub(crate) struct PostCompactCommandOutputWire {
178    #[serde(flatten)]
179    pub universal: HookUniversalOutputWire,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
183#[serde(rename_all = "camelCase")]
184#[serde(deny_unknown_fields)]
185pub(crate) struct PermissionRequestHookSpecificOutputWire {
186    #[schemars(schema_with = "permission_request_hook_event_name_schema")]
187    pub hook_event_name: HookEventNameWire,
188    #[serde(default)]
189    pub decision: Option<PermissionRequestDecisionWire>,
190}
191
192#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
193#[serde(rename_all = "camelCase")]
194#[serde(deny_unknown_fields)]
195pub(crate) struct PermissionRequestDecisionWire {
196    pub behavior: PermissionRequestBehaviorWire,
197    /// Reserved for a future input-rewrite capability.
198    ///
199    /// PermissionRequest hooks currently fail closed if this field is present.
200    #[serde(default)]
201    pub updated_input: Option<Value>,
202    /// Reserved for a future permission-rewrite capability.
203    ///
204    /// PermissionRequest hooks currently fail closed if this field is present.
205    #[serde(default)]
206    pub updated_permissions: Option<Value>,
207    #[serde(default)]
208    pub message: Option<String>,
209    /// Reserved for future short-circuiting semantics.
210    ///
211    /// PermissionRequest hooks currently fail closed if this field is `true`.
212    #[serde(default)]
213    pub interrupt: bool,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
217pub(crate) enum PermissionRequestBehaviorWire {
218    #[serde(rename = "allow")]
219    Allow,
220    #[serde(rename = "deny")]
221    Deny,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
225#[serde(rename_all = "camelCase")]
226#[serde(deny_unknown_fields)]
227pub(crate) struct PostToolUseHookSpecificOutputWire {
228    #[schemars(schema_with = "post_tool_use_hook_event_name_schema")]
229    pub hook_event_name: HookEventNameWire,
230    #[serde(default)]
231    pub additional_context: Option<String>,
232    #[serde(default)]
233    #[serde(rename = "updatedMCPToolOutput")]
234    pub updated_mcp_tool_output: Option<Value>,
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
238#[serde(rename_all = "camelCase")]
239#[serde(deny_unknown_fields)]
240pub(crate) struct PreToolUseHookSpecificOutputWire {
241    #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
242    pub hook_event_name: HookEventNameWire,
243    #[serde(default)]
244    pub permission_decision: Option<PreToolUsePermissionDecisionWire>,
245    #[serde(default)]
246    pub permission_decision_reason: Option<String>,
247    #[serde(default)]
248    pub updated_input: Option<Value>,
249    #[serde(default)]
250    pub additional_context: Option<String>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
254pub(crate) enum PreToolUsePermissionDecisionWire {
255    #[serde(rename = "allow")]
256    Allow,
257    #[serde(rename = "deny")]
258    Deny,
259    #[serde(rename = "ask")]
260    Ask,
261}
262
263#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
264pub(crate) enum PreToolUseDecisionWire {
265    #[serde(rename = "approve")]
266    Approve,
267    #[serde(rename = "block")]
268    Block,
269}
270
271#[derive(Debug, Clone, Serialize, JsonSchema)]
272#[serde(deny_unknown_fields)]
273#[schemars(rename = "pre-tool-use.command.input")]
274pub(crate) struct PreToolUseCommandInput {
275    pub session_id: String,
276    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
277    pub turn_id: String,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub agent_id: Option<String>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub agent_type: Option<String>,
282    pub transcript_path: NullableString,
283    pub cwd: String,
284    #[schemars(schema_with = "pre_tool_use_hook_event_name_schema")]
285    pub hook_event_name: String,
286    pub model: String,
287    #[schemars(schema_with = "permission_mode_schema")]
288    pub permission_mode: String,
289    pub tool_name: String,
290    pub tool_input: Value,
291    pub tool_use_id: String,
292}
293
294#[derive(Debug, Clone, Serialize, JsonSchema)]
295#[serde(deny_unknown_fields)]
296#[schemars(rename = "permission-request.command.input")]
297pub(crate) struct PermissionRequestCommandInput {
298    pub session_id: String,
299    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
300    pub turn_id: String,
301    #[serde(skip_serializing_if = "Option::is_none")]
302    pub agent_id: Option<String>,
303    #[serde(skip_serializing_if = "Option::is_none")]
304    pub agent_type: Option<String>,
305    pub transcript_path: NullableString,
306    pub cwd: String,
307    #[schemars(schema_with = "permission_request_hook_event_name_schema")]
308    pub hook_event_name: String,
309    pub model: String,
310    #[schemars(schema_with = "permission_mode_schema")]
311    pub permission_mode: String,
312    pub tool_name: String,
313    pub tool_input: Value,
314}
315
316#[derive(Debug, Clone, Serialize, JsonSchema)]
317#[serde(deny_unknown_fields)]
318#[schemars(rename = "post-tool-use.command.input")]
319pub(crate) struct PostToolUseCommandInput {
320    pub session_id: String,
321    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
322    pub turn_id: String,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub agent_id: Option<String>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub agent_type: Option<String>,
327    pub transcript_path: NullableString,
328    pub cwd: String,
329    #[schemars(schema_with = "post_tool_use_hook_event_name_schema")]
330    pub hook_event_name: String,
331    pub model: String,
332    #[schemars(schema_with = "permission_mode_schema")]
333    pub permission_mode: String,
334    pub tool_name: String,
335    pub tool_input: Value,
336    pub tool_response: Value,
337    pub tool_use_id: String,
338}
339
340#[derive(Debug, Clone, Serialize, JsonSchema)]
341#[serde(deny_unknown_fields)]
342#[schemars(rename = "pre-compact.command.input")]
343pub(crate) struct PreCompactCommandInput {
344    pub session_id: String,
345    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
346    pub turn_id: String,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub agent_id: Option<String>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub agent_type: Option<String>,
351    pub transcript_path: NullableString,
352    pub cwd: String,
353    #[schemars(schema_with = "pre_compact_hook_event_name_schema")]
354    pub hook_event_name: String,
355    pub model: String,
356    #[schemars(schema_with = "compaction_trigger_schema")]
357    pub trigger: String,
358}
359
360#[derive(Debug, Clone, Serialize, JsonSchema)]
361#[serde(deny_unknown_fields)]
362#[schemars(rename = "post-compact.command.input")]
363pub(crate) struct PostCompactCommandInput {
364    pub session_id: String,
365    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
366    pub turn_id: String,
367    #[serde(skip_serializing_if = "Option::is_none")]
368    pub agent_id: Option<String>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub agent_type: Option<String>,
371    pub transcript_path: NullableString,
372    pub cwd: String,
373    #[schemars(schema_with = "post_compact_hook_event_name_schema")]
374    pub hook_event_name: String,
375    pub model: String,
376    #[schemars(schema_with = "compaction_trigger_schema")]
377    pub trigger: String,
378}
379
380#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
381#[serde(rename_all = "camelCase")]
382#[serde(deny_unknown_fields)]
383#[schemars(rename = "session-start.command.output")]
384pub(crate) struct SessionStartCommandOutputWire {
385    #[serde(flatten)]
386    pub universal: HookUniversalOutputWire,
387    #[serde(default)]
388    pub hook_specific_output: Option<SessionStartHookSpecificOutputWire>,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
392#[serde(rename_all = "camelCase")]
393#[serde(deny_unknown_fields)]
394pub(crate) struct SessionStartHookSpecificOutputWire {
395    #[schemars(schema_with = "session_start_hook_event_name_schema")]
396    pub hook_event_name: HookEventNameWire,
397    #[serde(default)]
398    pub additional_context: Option<String>,
399}
400
401#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
402#[serde(rename_all = "camelCase")]
403#[serde(deny_unknown_fields)]
404#[schemars(rename = "subagent-start.command.output")]
405pub(crate) struct SubagentStartCommandOutputWire {
406    #[serde(flatten)]
407    pub universal: HookUniversalOutputWire,
408    #[serde(default)]
409    pub hook_specific_output: Option<SubagentStartHookSpecificOutputWire>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
413#[serde(rename_all = "camelCase")]
414#[serde(deny_unknown_fields)]
415pub(crate) struct SubagentStartHookSpecificOutputWire {
416    #[schemars(schema_with = "subagent_start_hook_event_name_schema")]
417    pub hook_event_name: HookEventNameWire,
418    #[serde(default)]
419    pub additional_context: Option<String>,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
423#[serde(rename_all = "camelCase")]
424#[serde(deny_unknown_fields)]
425#[schemars(rename = "user-prompt-submit.command.output")]
426pub(crate) struct UserPromptSubmitCommandOutputWire {
427    #[serde(flatten)]
428    pub universal: HookUniversalOutputWire,
429    #[serde(default)]
430    pub decision: Option<BlockDecisionWire>,
431    #[serde(default)]
432    pub reason: Option<String>,
433    #[serde(default)]
434    pub hook_specific_output: Option<UserPromptSubmitHookSpecificOutputWire>,
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
438#[serde(rename_all = "camelCase")]
439#[serde(deny_unknown_fields)]
440pub(crate) struct UserPromptSubmitHookSpecificOutputWire {
441    #[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")]
442    pub hook_event_name: HookEventNameWire,
443    #[serde(default)]
444    pub additional_context: Option<String>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
448#[serde(rename_all = "camelCase")]
449#[serde(deny_unknown_fields)]
450#[schemars(rename = "stop.command.output")]
451pub(crate) struct StopCommandOutputWire {
452    #[serde(flatten)]
453    pub universal: HookUniversalOutputWire,
454    #[serde(default)]
455    pub decision: Option<BlockDecisionWire>,
456    /// Claude requires `reason` when `decision` is `block`; we enforce that
457    /// semantic rule during output parsing rather than in the JSON schema.
458    #[serde(default)]
459    pub reason: Option<String>,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
463#[serde(rename_all = "camelCase")]
464#[serde(deny_unknown_fields)]
465#[schemars(rename = "subagent-stop.command.output")]
466pub(crate) struct SubagentStopCommandOutputWire {
467    #[serde(flatten)]
468    pub universal: HookUniversalOutputWire,
469    #[serde(default)]
470    pub decision: Option<BlockDecisionWire>,
471    /// Claude requires `reason` when `decision` is `block`; we enforce that
472    /// semantic rule during output parsing rather than in the JSON schema.
473    #[serde(default)]
474    pub reason: Option<String>,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
478pub(crate) enum BlockDecisionWire {
479    #[serde(rename = "block")]
480    Block,
481}
482
483#[derive(Debug, Clone, Serialize, JsonSchema)]
484#[serde(deny_unknown_fields)]
485#[schemars(rename = "session-start.command.input")]
486pub(crate) struct SessionStartCommandInput {
487    pub session_id: String,
488    pub transcript_path: NullableString,
489    pub cwd: String,
490    #[schemars(schema_with = "session_start_hook_event_name_schema")]
491    pub hook_event_name: String,
492    pub model: String,
493    #[schemars(schema_with = "permission_mode_schema")]
494    pub permission_mode: String,
495    #[schemars(schema_with = "session_start_source_schema")]
496    pub source: String,
497}
498
499#[derive(Debug, Clone, Serialize, JsonSchema)]
500#[serde(deny_unknown_fields)]
501#[schemars(rename = "session-end.command.input")]
502pub(crate) struct SessionEndCommandInput {
503    pub session_id: String,
504    pub transcript_path: NullableString,
505    pub cwd: String,
506    #[schemars(schema_with = "session_end_hook_event_name_schema")]
507    pub hook_event_name: String,
508    #[schemars(schema_with = "session_end_reason_schema")]
509    pub reason: String,
510}
511
512impl SessionStartCommandInput {
513    pub(crate) fn new(
514        session_id: impl Into<String>,
515        transcript_path: Option<PathBuf>,
516        cwd: impl Into<String>,
517        model: impl Into<String>,
518        permission_mode: impl Into<String>,
519        source: impl Into<String>,
520    ) -> Self {
521        Self {
522            session_id: session_id.into(),
523            transcript_path: NullableString::from_path(transcript_path),
524            cwd: cwd.into(),
525            hook_event_name: "SessionStart".to_string(),
526            model: model.into(),
527            permission_mode: permission_mode.into(),
528            source: source.into(),
529        }
530    }
531}
532
533#[derive(Debug, Clone, Serialize, JsonSchema)]
534#[serde(deny_unknown_fields)]
535#[schemars(rename = "subagent-start.command.input")]
536pub(crate) struct SubagentStartCommandInput {
537    pub session_id: String,
538    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
539    pub turn_id: String,
540    pub transcript_path: NullableString,
541    pub cwd: String,
542    #[schemars(schema_with = "subagent_start_hook_event_name_schema")]
543    pub hook_event_name: String,
544    pub model: String,
545    #[schemars(schema_with = "permission_mode_schema")]
546    pub permission_mode: String,
547    pub agent_id: String,
548    pub agent_type: String,
549}
550
551#[derive(Debug, Clone, Serialize, JsonSchema)]
552#[serde(deny_unknown_fields)]
553#[schemars(rename = "user-prompt-submit.command.input")]
554pub(crate) struct UserPromptSubmitCommandInput {
555    pub session_id: String,
556    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
557    pub turn_id: String,
558    #[serde(skip_serializing_if = "Option::is_none")]
559    pub agent_id: Option<String>,
560    #[serde(skip_serializing_if = "Option::is_none")]
561    pub agent_type: Option<String>,
562    pub transcript_path: NullableString,
563    pub cwd: String,
564    #[schemars(schema_with = "user_prompt_submit_hook_event_name_schema")]
565    pub hook_event_name: String,
566    pub model: String,
567    #[schemars(schema_with = "permission_mode_schema")]
568    pub permission_mode: String,
569    pub prompt: String,
570}
571
572#[derive(Debug, Clone, Serialize, JsonSchema)]
573#[serde(deny_unknown_fields)]
574#[schemars(rename = "stop.command.input")]
575pub(crate) struct StopCommandInput {
576    pub session_id: String,
577    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
578    pub turn_id: String,
579    pub transcript_path: NullableString,
580    pub cwd: String,
581    #[schemars(schema_with = "stop_hook_event_name_schema")]
582    pub hook_event_name: String,
583    pub model: String,
584    #[schemars(schema_with = "permission_mode_schema")]
585    pub permission_mode: String,
586    pub stop_hook_active: bool,
587    pub last_assistant_message: NullableString,
588}
589
590#[derive(Debug, Clone, Serialize, JsonSchema)]
591#[serde(deny_unknown_fields)]
592#[schemars(rename = "subagent-stop.command.input")]
593pub(crate) struct SubagentStopCommandInput {
594    pub session_id: String,
595    /// Codex extension: expose the active turn id to internal turn-scoped hooks.
596    pub turn_id: String,
597    pub transcript_path: NullableString,
598    pub agent_transcript_path: NullableString,
599    pub cwd: String,
600    #[schemars(schema_with = "subagent_stop_hook_event_name_schema")]
601    pub hook_event_name: String,
602    pub model: String,
603    #[schemars(schema_with = "permission_mode_schema")]
604    pub permission_mode: String,
605    pub stop_hook_active: bool,
606    pub agent_id: String,
607    pub agent_type: String,
608    pub last_assistant_message: NullableString,
609}
610
611pub fn write_schema_fixtures(schema_root: &Path) -> anyhow::Result<()> {
612    let generated_dir = schema_root.join(GENERATED_DIR);
613    ensure_empty_dir(&generated_dir)?;
614
615    write_schema(
616        &generated_dir.join(POST_TOOL_USE_INPUT_FIXTURE),
617        schema_json::<PostToolUseCommandInput>()?,
618    )?;
619    write_schema(
620        &generated_dir.join(POST_TOOL_USE_OUTPUT_FIXTURE),
621        schema_json::<PostToolUseCommandOutputWire>()?,
622    )?;
623    write_schema(
624        &generated_dir.join(PERMISSION_REQUEST_INPUT_FIXTURE),
625        schema_json::<PermissionRequestCommandInput>()?,
626    )?;
627    write_schema(
628        &generated_dir.join(PERMISSION_REQUEST_OUTPUT_FIXTURE),
629        schema_json::<PermissionRequestCommandOutputWire>()?,
630    )?;
631    write_schema(
632        &generated_dir.join(POST_COMPACT_INPUT_FIXTURE),
633        schema_json::<PostCompactCommandInput>()?,
634    )?;
635    write_schema(
636        &generated_dir.join(POST_COMPACT_OUTPUT_FIXTURE),
637        schema_json::<PostCompactCommandOutputWire>()?,
638    )?;
639    write_schema(
640        &generated_dir.join(PRE_COMPACT_INPUT_FIXTURE),
641        schema_json::<PreCompactCommandInput>()?,
642    )?;
643    write_schema(
644        &generated_dir.join(PRE_COMPACT_OUTPUT_FIXTURE),
645        schema_json::<PreCompactCommandOutputWire>()?,
646    )?;
647    write_schema(
648        &generated_dir.join(PRE_TOOL_USE_INPUT_FIXTURE),
649        schema_json::<PreToolUseCommandInput>()?,
650    )?;
651    write_schema(
652        &generated_dir.join(PRE_TOOL_USE_OUTPUT_FIXTURE),
653        schema_json::<PreToolUseCommandOutputWire>()?,
654    )?;
655    write_schema(
656        &generated_dir.join(SESSION_START_INPUT_FIXTURE),
657        schema_json::<SessionStartCommandInput>()?,
658    )?;
659    write_schema(
660        &generated_dir.join(SESSION_START_OUTPUT_FIXTURE),
661        schema_json::<SessionStartCommandOutputWire>()?,
662    )?;
663    write_schema(
664        &generated_dir.join(SESSION_END_INPUT_FIXTURE),
665        schema_json::<SessionEndCommandInput>()?,
666    )?;
667    write_schema(
668        &generated_dir.join(USER_PROMPT_SUBMIT_INPUT_FIXTURE),
669        schema_json::<UserPromptSubmitCommandInput>()?,
670    )?;
671    write_schema(
672        &generated_dir.join(USER_PROMPT_SUBMIT_OUTPUT_FIXTURE),
673        schema_json::<UserPromptSubmitCommandOutputWire>()?,
674    )?;
675    write_schema(
676        &generated_dir.join(SUBAGENT_START_INPUT_FIXTURE),
677        schema_json::<SubagentStartCommandInput>()?,
678    )?;
679    write_schema(
680        &generated_dir.join(SUBAGENT_START_OUTPUT_FIXTURE),
681        schema_json::<SubagentStartCommandOutputWire>()?,
682    )?;
683    write_schema(
684        &generated_dir.join(SUBAGENT_STOP_INPUT_FIXTURE),
685        schema_json::<SubagentStopCommandInput>()?,
686    )?;
687    write_schema(
688        &generated_dir.join(SUBAGENT_STOP_OUTPUT_FIXTURE),
689        schema_json::<SubagentStopCommandOutputWire>()?,
690    )?;
691    write_schema(
692        &generated_dir.join(STOP_INPUT_FIXTURE),
693        schema_json::<StopCommandInput>()?,
694    )?;
695    write_schema(
696        &generated_dir.join(STOP_OUTPUT_FIXTURE),
697        schema_json::<StopCommandOutputWire>()?,
698    )?;
699
700    Ok(())
701}
702
703fn write_schema(path: &Path, json: Vec<u8>) -> anyhow::Result<()> {
704    std::fs::write(path, json)?;
705    Ok(())
706}
707
708fn ensure_empty_dir(dir: &Path) -> anyhow::Result<()> {
709    if dir.exists() {
710        std::fs::remove_dir_all(dir)?;
711    }
712    std::fs::create_dir_all(dir)?;
713    Ok(())
714}
715
716fn schema_json<T>() -> anyhow::Result<Vec<u8>>
717where
718    T: JsonSchema,
719{
720    let schema = schema_for_type::<T>();
721    let value = serde_json::to_value(schema)?;
722    let value = canonicalize_json(&value);
723    Ok(serde_json::to_vec_pretty(&value)?)
724}
725
726fn schema_for_type<T>() -> RootSchema
727where
728    T: JsonSchema,
729{
730    SchemaSettings::draft07()
731        .with(|settings| {
732            settings.option_add_null_type = false;
733        })
734        .into_generator()
735        .into_root_schema_for::<T>()
736}
737
738fn canonicalize_json(value: &Value) -> Value {
739    match value {
740        Value::Array(items) => Value::Array(items.iter().map(canonicalize_json).collect()),
741        Value::Object(map) => {
742            let mut entries: Vec<_> = map.iter().collect();
743            entries.sort_by_key(|(key, _)| *key);
744            let mut sorted = Map::with_capacity(map.len());
745            for (key, child) in entries {
746                sorted.insert(key.clone(), canonicalize_json(child));
747            }
748            Value::Object(sorted)
749        }
750        _ => value.clone(),
751    }
752}
753
754fn session_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
755    string_const_schema("SessionStart")
756}
757
758fn session_end_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
759    string_const_schema("SessionEnd")
760}
761
762fn session_end_reason_schema(_gen: &mut SchemaGenerator) -> Schema {
763    string_const_schema("other")
764}
765
766fn post_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
767    string_const_schema("PostToolUse")
768}
769
770fn pre_compact_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
771    string_const_schema("PreCompact")
772}
773
774fn post_compact_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
775    string_const_schema("PostCompact")
776}
777
778fn pre_tool_use_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
779    string_const_schema("PreToolUse")
780}
781
782fn permission_request_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
783    string_const_schema("PermissionRequest")
784}
785
786fn user_prompt_submit_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
787    string_const_schema("UserPromptSubmit")
788}
789
790fn subagent_start_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
791    string_const_schema("SubagentStart")
792}
793
794fn subagent_stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
795    string_const_schema("SubagentStop")
796}
797
798fn stop_hook_event_name_schema(_gen: &mut SchemaGenerator) -> Schema {
799    string_const_schema("Stop")
800}
801
802fn permission_mode_schema(_gen: &mut SchemaGenerator) -> Schema {
803    string_enum_schema(&[
804        "default",
805        "acceptEdits",
806        "plan",
807        "dontAsk",
808        "bypassPermissions",
809    ])
810}
811
812fn session_start_source_schema(_gen: &mut SchemaGenerator) -> Schema {
813    string_enum_schema(&["startup", "resume", "clear", "compact"])
814}
815
816fn compaction_trigger_schema(_gen: &mut SchemaGenerator) -> Schema {
817    string_enum_schema(&["manual", "auto"])
818}
819
820fn string_const_schema(value: &str) -> Schema {
821    let mut schema = SchemaObject {
822        instance_type: Some(InstanceType::String.into()),
823        ..Default::default()
824    };
825    schema.const_value = Some(Value::String(value.to_string()));
826    Schema::Object(schema)
827}
828
829fn string_enum_schema(values: &[&str]) -> Schema {
830    let mut schema = SchemaObject {
831        instance_type: Some(InstanceType::String.into()),
832        ..Default::default()
833    };
834    schema.enum_values = Some(
835        values
836            .iter()
837            .map(|value| Value::String((*value).to_string()))
838            .collect(),
839    );
840    Schema::Object(schema)
841}
842
843fn default_continue() -> bool {
844    true
845}
846
847#[cfg(test)]
848mod tests {
849    use super::NullableString;
850    use super::PERMISSION_REQUEST_INPUT_FIXTURE;
851    use super::PERMISSION_REQUEST_OUTPUT_FIXTURE;
852    use super::POST_COMPACT_INPUT_FIXTURE;
853    use super::POST_COMPACT_OUTPUT_FIXTURE;
854    use super::POST_TOOL_USE_INPUT_FIXTURE;
855    use super::POST_TOOL_USE_OUTPUT_FIXTURE;
856    use super::PRE_COMPACT_INPUT_FIXTURE;
857    use super::PRE_COMPACT_OUTPUT_FIXTURE;
858    use super::PRE_TOOL_USE_INPUT_FIXTURE;
859    use super::PRE_TOOL_USE_OUTPUT_FIXTURE;
860    use super::PermissionRequestCommandInput;
861    use super::PermissionRequestCommandOutputWire;
862    use super::PostCompactCommandInput;
863    use super::PostToolUseCommandInput;
864    use super::PostToolUseCommandOutputWire;
865    use super::PreCompactCommandInput;
866    use super::PreToolUseCommandInput;
867    use super::PreToolUseCommandOutputWire;
868    use super::SESSION_END_INPUT_FIXTURE;
869    use super::SESSION_START_INPUT_FIXTURE;
870    use super::SESSION_START_OUTPUT_FIXTURE;
871    use super::STOP_INPUT_FIXTURE;
872    use super::STOP_OUTPUT_FIXTURE;
873    use super::SUBAGENT_START_INPUT_FIXTURE;
874    use super::SUBAGENT_START_OUTPUT_FIXTURE;
875    use super::SUBAGENT_STOP_INPUT_FIXTURE;
876    use super::SUBAGENT_STOP_OUTPUT_FIXTURE;
877    use super::SessionStartCommandOutputWire;
878    use super::StopCommandInput;
879    use super::SubagentCommandInputFields;
880    use super::SubagentStartCommandInput;
881    use super::SubagentStartCommandOutputWire;
882    use super::SubagentStopCommandInput;
883    use super::USER_PROMPT_SUBMIT_INPUT_FIXTURE;
884    use super::USER_PROMPT_SUBMIT_OUTPUT_FIXTURE;
885    use super::UserPromptSubmitCommandInput;
886    use super::UserPromptSubmitCommandOutputWire;
887    use super::schema_json;
888    use super::write_schema_fixtures;
889    use crate::events::common::SubagentHookContext;
890    use pretty_assertions::assert_eq;
891    use schemars::JsonSchema;
892    use serde_json::Value;
893    use serde_json::json;
894    use tempfile::TempDir;
895
896    fn expected_fixture(name: &str) -> &'static str {
897        match name {
898            POST_TOOL_USE_INPUT_FIXTURE => {
899                include_str!("../schema/generated/post-tool-use.command.input.schema.json")
900            }
901            POST_TOOL_USE_OUTPUT_FIXTURE => {
902                include_str!("../schema/generated/post-tool-use.command.output.schema.json")
903            }
904            PERMISSION_REQUEST_INPUT_FIXTURE => {
905                include_str!("../schema/generated/permission-request.command.input.schema.json")
906            }
907            PERMISSION_REQUEST_OUTPUT_FIXTURE => {
908                include_str!("../schema/generated/permission-request.command.output.schema.json")
909            }
910            POST_COMPACT_INPUT_FIXTURE => {
911                include_str!("../schema/generated/post-compact.command.input.schema.json")
912            }
913            POST_COMPACT_OUTPUT_FIXTURE => {
914                include_str!("../schema/generated/post-compact.command.output.schema.json")
915            }
916            PRE_COMPACT_INPUT_FIXTURE => {
917                include_str!("../schema/generated/pre-compact.command.input.schema.json")
918            }
919            PRE_COMPACT_OUTPUT_FIXTURE => {
920                include_str!("../schema/generated/pre-compact.command.output.schema.json")
921            }
922            PRE_TOOL_USE_INPUT_FIXTURE => {
923                include_str!("../schema/generated/pre-tool-use.command.input.schema.json")
924            }
925            PRE_TOOL_USE_OUTPUT_FIXTURE => {
926                include_str!("../schema/generated/pre-tool-use.command.output.schema.json")
927            }
928            SESSION_START_INPUT_FIXTURE => {
929                include_str!("../schema/generated/session-start.command.input.schema.json")
930            }
931            SESSION_START_OUTPUT_FIXTURE => {
932                include_str!("../schema/generated/session-start.command.output.schema.json")
933            }
934            SESSION_END_INPUT_FIXTURE => {
935                include_str!("../schema/generated/session-end.command.input.schema.json")
936            }
937            USER_PROMPT_SUBMIT_INPUT_FIXTURE => {
938                include_str!("../schema/generated/user-prompt-submit.command.input.schema.json")
939            }
940            USER_PROMPT_SUBMIT_OUTPUT_FIXTURE => {
941                include_str!("../schema/generated/user-prompt-submit.command.output.schema.json")
942            }
943            SUBAGENT_START_INPUT_FIXTURE => {
944                include_str!("../schema/generated/subagent-start.command.input.schema.json")
945            }
946            SUBAGENT_START_OUTPUT_FIXTURE => {
947                include_str!("../schema/generated/subagent-start.command.output.schema.json")
948            }
949            SUBAGENT_STOP_INPUT_FIXTURE => {
950                include_str!("../schema/generated/subagent-stop.command.input.schema.json")
951            }
952            SUBAGENT_STOP_OUTPUT_FIXTURE => {
953                include_str!("../schema/generated/subagent-stop.command.output.schema.json")
954            }
955            STOP_INPUT_FIXTURE => {
956                include_str!("../schema/generated/stop.command.input.schema.json")
957            }
958            STOP_OUTPUT_FIXTURE => {
959                include_str!("../schema/generated/stop.command.output.schema.json")
960            }
961            _ => panic!("unexpected fixture name: {name}"),
962        }
963    }
964
965    fn normalize_newlines(value: &str) -> String {
966        value.replace("\r\n", "\n")
967    }
968
969    fn assert_output_hook_event_name_const<T: JsonSchema>(definition: &str, expected: &str) {
970        let schema: Value =
971            serde_json::from_slice(&schema_json::<T>().expect("serialize hook output schema"))
972                .expect("parse hook output schema");
973
974        assert_eq!(
975            schema["definitions"][definition]["properties"]["hookEventName"],
976            json!({
977                "const": expected,
978                "type": "string",
979            })
980        );
981    }
982
983    #[test]
984    fn generated_hook_schemas_match_fixtures() {
985        let temp_dir = TempDir::new().expect("create temp dir");
986        let schema_root = temp_dir.path().join("schema");
987        write_schema_fixtures(&schema_root).expect("write generated hook schemas");
988
989        for fixture in [
990            POST_TOOL_USE_INPUT_FIXTURE,
991            POST_TOOL_USE_OUTPUT_FIXTURE,
992            PERMISSION_REQUEST_INPUT_FIXTURE,
993            PERMISSION_REQUEST_OUTPUT_FIXTURE,
994            POST_COMPACT_INPUT_FIXTURE,
995            POST_COMPACT_OUTPUT_FIXTURE,
996            PRE_COMPACT_INPUT_FIXTURE,
997            PRE_COMPACT_OUTPUT_FIXTURE,
998            PRE_TOOL_USE_INPUT_FIXTURE,
999            PRE_TOOL_USE_OUTPUT_FIXTURE,
1000            SESSION_START_INPUT_FIXTURE,
1001            SESSION_START_OUTPUT_FIXTURE,
1002            SESSION_END_INPUT_FIXTURE,
1003            USER_PROMPT_SUBMIT_INPUT_FIXTURE,
1004            USER_PROMPT_SUBMIT_OUTPUT_FIXTURE,
1005            SUBAGENT_START_INPUT_FIXTURE,
1006            SUBAGENT_START_OUTPUT_FIXTURE,
1007            SUBAGENT_STOP_INPUT_FIXTURE,
1008            SUBAGENT_STOP_OUTPUT_FIXTURE,
1009            STOP_INPUT_FIXTURE,
1010            STOP_OUTPUT_FIXTURE,
1011        ] {
1012            let expected = normalize_newlines(expected_fixture(fixture));
1013            let actual = std::fs::read_to_string(schema_root.join("generated").join(fixture))
1014                .unwrap_or_else(|err| panic!("read generated schema {fixture}: {err}"));
1015            let actual = normalize_newlines(&actual);
1016            assert_eq!(expected, actual, "fixture should match generated schema");
1017        }
1018    }
1019
1020    #[test]
1021    fn hook_specific_output_event_names_are_event_specific_in_output_schemas() {
1022        assert_output_hook_event_name_const::<PermissionRequestCommandOutputWire>(
1023            "PermissionRequestHookSpecificOutputWire",
1024            "PermissionRequest",
1025        );
1026        assert_output_hook_event_name_const::<PostToolUseCommandOutputWire>(
1027            "PostToolUseHookSpecificOutputWire",
1028            "PostToolUse",
1029        );
1030        assert_output_hook_event_name_const::<PreToolUseCommandOutputWire>(
1031            "PreToolUseHookSpecificOutputWire",
1032            "PreToolUse",
1033        );
1034        assert_output_hook_event_name_const::<SessionStartCommandOutputWire>(
1035            "SessionStartHookSpecificOutputWire",
1036            "SessionStart",
1037        );
1038        assert_output_hook_event_name_const::<SubagentStartCommandOutputWire>(
1039            "SubagentStartHookSpecificOutputWire",
1040            "SubagentStart",
1041        );
1042        assert_output_hook_event_name_const::<UserPromptSubmitCommandOutputWire>(
1043            "UserPromptSubmitHookSpecificOutputWire",
1044            "UserPromptSubmit",
1045        );
1046    }
1047
1048    #[test]
1049    fn turn_scoped_hook_inputs_include_codex_turn_id_extension() {
1050        // Codex intentionally diverges from Claude's public hook docs here so
1051        // internal hook consumers can key off the active turn.
1052        let pre_tool_use: Value = serde_json::from_slice(
1053            &schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
1054        )
1055        .expect("parse pre tool use input schema");
1056        let post_tool_use: Value = serde_json::from_slice(
1057            &schema_json::<PostToolUseCommandInput>()
1058                .expect("serialize post tool use input schema"),
1059        )
1060        .expect("parse post tool use input schema");
1061        let pre_compact: Value = serde_json::from_slice(
1062            &schema_json::<PreCompactCommandInput>().expect("serialize pre compact input schema"),
1063        )
1064        .expect("parse pre compact input schema");
1065        let post_compact: Value = serde_json::from_slice(
1066            &schema_json::<PostCompactCommandInput>().expect("serialize post compact input schema"),
1067        )
1068        .expect("parse post compact input schema");
1069        let permission_request: Value = serde_json::from_slice(
1070            &schema_json::<PermissionRequestCommandInput>()
1071                .expect("serialize permission request input schema"),
1072        )
1073        .expect("parse permission request input schema");
1074        let user_prompt_submit: Value = serde_json::from_slice(
1075            &schema_json::<UserPromptSubmitCommandInput>()
1076                .expect("serialize user prompt submit input schema"),
1077        )
1078        .expect("parse user prompt submit input schema");
1079        let subagent_start: Value = serde_json::from_slice(
1080            &schema_json::<SubagentStartCommandInput>()
1081                .expect("serialize subagent start input schema"),
1082        )
1083        .expect("parse subagent start input schema");
1084        let subagent_stop: Value = serde_json::from_slice(
1085            &schema_json::<SubagentStopCommandInput>()
1086                .expect("serialize subagent stop input schema"),
1087        )
1088        .expect("parse subagent stop input schema");
1089        let stop: Value = serde_json::from_slice(
1090            &schema_json::<StopCommandInput>().expect("serialize stop input schema"),
1091        )
1092        .expect("parse stop input schema");
1093
1094        for schema in [
1095            &pre_tool_use,
1096            &permission_request,
1097            &post_tool_use,
1098            &pre_compact,
1099            &post_compact,
1100            &user_prompt_submit,
1101            &subagent_start,
1102            &subagent_stop,
1103            &stop,
1104        ] {
1105            assert_eq!(schema["properties"]["turn_id"]["type"], "string");
1106            assert!(
1107                schema["required"]
1108                    .as_array()
1109                    .expect("schema required fields")
1110                    .contains(&Value::String("turn_id".to_string()))
1111            );
1112        }
1113    }
1114
1115    #[test]
1116    fn subagent_context_fields_are_optional_for_hooks_that_run_inside_subagents() {
1117        let schemas = [
1118            schema_json::<PreToolUseCommandInput>().expect("serialize pre tool use input schema"),
1119            schema_json::<PermissionRequestCommandInput>()
1120                .expect("serialize permission request input schema"),
1121            schema_json::<PostToolUseCommandInput>().expect("serialize post tool use input schema"),
1122            schema_json::<PreCompactCommandInput>().expect("serialize pre compact input schema"),
1123            schema_json::<PostCompactCommandInput>().expect("serialize post compact input schema"),
1124            schema_json::<UserPromptSubmitCommandInput>()
1125                .expect("serialize user prompt submit input schema"),
1126        ];
1127
1128        for schema in schemas {
1129            let schema: Value = serde_json::from_slice(&schema).expect("parse hook input schema");
1130            assert_eq!(schema["properties"]["agent_id"]["type"], "string");
1131            assert_eq!(schema["properties"]["agent_type"]["type"], "string");
1132            let required = schema["required"]
1133                .as_array()
1134                .expect("schema required fields");
1135            assert!(!required.contains(&Value::String("agent_id".to_string())));
1136            assert!(!required.contains(&Value::String("agent_type".to_string())));
1137        }
1138    }
1139
1140    #[test]
1141    fn subagent_context_fields_serialize_flat_and_omit_when_absent() {
1142        let subagent = SubagentCommandInputFields::from(Some(&SubagentHookContext {
1143            agent_id: "agent-1".to_string(),
1144            agent_type: "worker".to_string(),
1145        }));
1146        let input = PreToolUseCommandInput {
1147            session_id: "session-1".to_string(),
1148            turn_id: "turn-1".to_string(),
1149            agent_id: subagent.agent_id,
1150            agent_type: subagent.agent_type,
1151            transcript_path: NullableString::from_path(/*path*/ None),
1152            cwd: "/tmp".to_string(),
1153            hook_event_name: "PreToolUse".to_string(),
1154            model: "gpt-test".to_string(),
1155            permission_mode: "default".to_string(),
1156            tool_name: "Bash".to_string(),
1157            tool_input: json!({ "command": "echo hello" }),
1158            tool_use_id: "tool-1".to_string(),
1159        };
1160
1161        assert_eq!(
1162            serde_json::to_value(input).expect("serialize subagent hook input"),
1163            json!({
1164                "session_id": "session-1",
1165                "turn_id": "turn-1",
1166                "agent_id": "agent-1",
1167                "agent_type": "worker",
1168                "transcript_path": null,
1169                "cwd": "/tmp",
1170                "hook_event_name": "PreToolUse",
1171                "model": "gpt-test",
1172                "permission_mode": "default",
1173                "tool_name": "Bash",
1174                "tool_input": { "command": "echo hello" },
1175                "tool_use_id": "tool-1",
1176            })
1177        );
1178
1179        let root_input = PreToolUseCommandInput {
1180            session_id: "session-1".to_string(),
1181            turn_id: "turn-1".to_string(),
1182            agent_id: None,
1183            agent_type: None,
1184            transcript_path: NullableString::from_path(/*path*/ None),
1185            cwd: "/tmp".to_string(),
1186            hook_event_name: "PreToolUse".to_string(),
1187            model: "gpt-test".to_string(),
1188            permission_mode: "default".to_string(),
1189            tool_name: "Bash".to_string(),
1190            tool_input: json!({ "command": "echo hello" }),
1191            tool_use_id: "tool-1".to_string(),
1192        };
1193        let root_input = serde_json::to_value(root_input).expect("serialize root hook input");
1194        assert_eq!(root_input.get("agent_id"), None);
1195        assert_eq!(root_input.get("agent_type"), None);
1196    }
1197}