@@ -35,7 +35,22 @@ pub fn run_hook(args: &[String]) {
3535 let _ = std:: io:: stdin ( ) . read_to_string ( & mut buf) ;
3636 let v: Value = serde_json:: from_str ( & buf) . unwrap_or ( Value :: Null ) ;
3737
38- let event = first_str ( & v, & [ "hook_event_name" , "agent_action_name" , "hookEventName" , "eventName" ] ) ;
38+ // Antigravity sends no event-name field; infer state from discriminator fields
39+ // (mirrors AntigravityHookPayload.makeEvent in the macOS core).
40+ let event = if agent == "antigravity" {
41+ if v. get ( "terminationReason" ) . is_some ( ) || v. get ( "fullyIdle" ) . is_some ( ) {
42+ Some ( "done" . to_string ( ) )
43+ } else if v. get ( "toolCall" ) . is_some ( )
44+ || v. get ( "invocationNum" ) . is_some ( )
45+ || v. get ( "stepIdx" ) . is_some ( )
46+ {
47+ Some ( "working" . to_string ( ) )
48+ } else {
49+ None
50+ }
51+ } else {
52+ first_str ( & v, & [ "hook_event_name" , "agent_action_name" , "hookEventName" , "eventName" ] )
53+ } ;
3954 let session = first_str ( & v, & [ "session_id" , "conversation_id" , "trajectory_id" , "sessionId" , "conversationId" ] ) ;
4055 let project = first_str ( & v, & [ "cwd" , "projectRoot" ] )
4156 . or_else ( || {
@@ -45,6 +60,14 @@ pub fn run_hook(args: &[String]) {
4560 . and_then ( |x| x. as_str ( ) )
4661 . map ( String :: from)
4762 } )
63+ . or_else ( || {
64+ // Antigravity uses camelCase workspacePaths (array), not workspace_roots
65+ v. get ( "workspacePaths" )
66+ . and_then ( |a| a. as_array ( ) )
67+ . and_then ( |a| a. iter ( ) . find ( |x| x. as_str ( ) . map ( |s| !s. is_empty ( ) ) . unwrap_or ( false ) ) )
68+ . and_then ( |x| x. as_str ( ) )
69+ . map ( String :: from)
70+ } )
4871 . unwrap_or_default ( ) ;
4972
5073 if session. as_deref ( ) . unwrap_or ( "" ) . is_empty ( ) && event. as_deref ( ) . unwrap_or ( "" ) . is_empty ( ) {
@@ -204,7 +227,7 @@ fn flag(args: &[String], name: &str) -> Option<String> {
204227 None
205228}
206229
207- fn first_str ( v : & Value , keys : & [ & str ] ) -> Option < String > {
230+ pub ( super ) fn first_str ( v : & Value , keys : & [ & str ] ) -> Option < String > {
208231 for k in keys {
209232 if let Some ( s) = v. get ( * k) . and_then ( |x| x. as_str ( ) ) {
210233 if !s. is_empty ( ) {
@@ -215,6 +238,114 @@ fn first_str(v: &Value, keys: &[&str]) -> Option<String> {
215238 None
216239}
217240
241+ #[ cfg( test) ]
242+ mod tests {
243+ use serde_json:: json;
244+
245+ fn infer_antigravity_event ( v : & serde_json:: Value ) -> Option < String > {
246+ if v. get ( "terminationReason" ) . is_some ( ) || v. get ( "fullyIdle" ) . is_some ( ) {
247+ Some ( "done" . to_string ( ) )
248+ } else if v. get ( "toolCall" ) . is_some ( )
249+ || v. get ( "invocationNum" ) . is_some ( )
250+ || v. get ( "stepIdx" ) . is_some ( )
251+ {
252+ Some ( "working" . to_string ( ) )
253+ } else {
254+ None
255+ }
256+ }
257+
258+ fn extract_project ( v : & serde_json:: Value ) -> String {
259+ super :: first_str ( v, & [ "cwd" , "projectRoot" ] )
260+ . or_else ( || {
261+ v. get ( "workspace_roots" )
262+ . and_then ( |a| a. as_array ( ) )
263+ . and_then ( |a| a. first ( ) )
264+ . and_then ( |x| x. as_str ( ) )
265+ . map ( String :: from)
266+ } )
267+ . or_else ( || {
268+ v. get ( "workspacePaths" )
269+ . and_then ( |a| a. as_array ( ) )
270+ . and_then ( |a| a. iter ( ) . find ( |x| x. as_str ( ) . map ( |s| !s. is_empty ( ) ) . unwrap_or ( false ) ) )
271+ . and_then ( |x| x. as_str ( ) )
272+ . map ( String :: from)
273+ } )
274+ . unwrap_or_default ( )
275+ }
276+
277+ // --- Antigravity event inference ---
278+
279+ #[ test]
280+ fn antigravity_step_idx_is_working ( ) {
281+ let v = json ! ( { "conversationId" : "c1" , "workspacePaths" : [ "/Users/me/proj" ] , "stepIdx" : 0 , "toolCall" : { "name" : "run_command" } } ) ;
282+ assert_eq ! ( infer_antigravity_event( & v) , Some ( "working" . to_string( ) ) ) ;
283+ }
284+
285+ #[ test]
286+ fn antigravity_invocation_num_is_working ( ) {
287+ let v = json ! ( { "conversationId" : "c3" , "invocationNum" : 2 , "initialNumSteps" : 5 } ) ;
288+ assert_eq ! ( infer_antigravity_event( & v) , Some ( "working" . to_string( ) ) ) ;
289+ }
290+
291+ #[ test]
292+ fn antigravity_termination_reason_is_done ( ) {
293+ let v = json ! ( { "conversationId" : "c2" , "executionNum" : 1 , "terminationReason" : "model_stop" , "fullyIdle" : true } ) ;
294+ assert_eq ! ( infer_antigravity_event( & v) , Some ( "done" . to_string( ) ) ) ;
295+ }
296+
297+ #[ test]
298+ fn antigravity_fully_idle_alone_is_done ( ) {
299+ let v = json ! ( { "conversationId" : "c4" , "fullyIdle" : true } ) ;
300+ assert_eq ! ( infer_antigravity_event( & v) , Some ( "done" . to_string( ) ) ) ;
301+ }
302+
303+ #[ test]
304+ fn antigravity_done_takes_priority_over_working_fields ( ) {
305+ // terminationReason present alongside stepIdx — done wins (stop event).
306+ let v = json ! ( { "conversationId" : "c5" , "terminationReason" : "model_stop" , "stepIdx" : 3 } ) ;
307+ assert_eq ! ( infer_antigravity_event( & v) , Some ( "done" . to_string( ) ) ) ;
308+ }
309+
310+ #[ test]
311+ fn antigravity_no_discriminator_returns_none ( ) {
312+ let v = json ! ( { "conversationId" : "c6" , "workspacePaths" : [ "/proj" ] } ) ;
313+ assert_eq ! ( infer_antigravity_event( & v) , None ) ;
314+ }
315+
316+ // --- project extraction ---
317+
318+ #[ test]
319+ fn project_from_cwd ( ) {
320+ let v = json ! ( { "cwd" : "/home/user/proj" , "eventName" : "PreToolUse" } ) ;
321+ assert_eq ! ( extract_project( & v) , "/home/user/proj" ) ;
322+ }
323+
324+ #[ test]
325+ fn project_from_workspace_roots ( ) {
326+ let v = json ! ( { "workspace_roots" : [ "/home/user/proj" ] } ) ;
327+ assert_eq ! ( extract_project( & v) , "/home/user/proj" ) ;
328+ }
329+
330+ #[ test]
331+ fn project_from_workspace_paths_camel_case ( ) {
332+ let v = json ! ( { "conversationId" : "c1" , "workspacePaths" : [ "/Users/me/proj" ] , "stepIdx" : 0 } ) ;
333+ assert_eq ! ( extract_project( & v) , "/Users/me/proj" ) ;
334+ }
335+
336+ #[ test]
337+ fn project_skips_empty_workspace_paths_entries ( ) {
338+ let v = json ! ( { "workspacePaths" : [ "" , "/Users/me/proj" ] } ) ;
339+ assert_eq ! ( extract_project( & v) , "/Users/me/proj" ) ;
340+ }
341+
342+ #[ test]
343+ fn project_empty_when_no_field ( ) {
344+ let v = json ! ( { "conversationId" : "c1" , "stepIdx" : 0 } ) ;
345+ assert_eq ! ( extract_project( & v) , "" ) ;
346+ }
347+ }
348+
218349/// Minimal HTTP POST to the local listener, bounded by short timeouts so a hook
219350/// never hangs the agent that invoked it.
220351fn post ( body : & str ) -> std:: io:: Result < ( ) > {
0 commit comments