diff --git a/supacode/CLIService/Shared/AgentNativeHookPayload.swift b/supacode/CLIService/Shared/AgentNativeHookPayload.swift index 484ab5e0..599016a1 100644 --- a/supacode/CLIService/Shared/AgentNativeHookPayload.swift +++ b/supacode/CLIService/Shared/AgentNativeHookPayload.swift @@ -80,16 +80,13 @@ nonisolated public enum AgentNativeHookDecodeError: Error, Equatable, Sendable { nonisolated public enum AgentNativeHookDecoder { public static let maximumPayloadBytes = 1_024 * 1_024 private static let maximumCWDBytes = 4 * 1_024 - private static let acceptedClaudeNotifications: Set = [ - "elicitation_dialog", - "idle_prompt", - "permission_prompt", - ] - - /// S3b runtimes report attention through `Notification` only. `idle_prompt` means the agent - /// is waiting rather than blocked on a person, and `auth_success` / background-task types are - /// informational, so neither may resolve a wait as `needs-input` (docs-ai 064.008). - private static let acceptedBlockingNotifications: Set = [ + /// `Notification` types that mean a person must act, for Claude Code and the S3b runtimes + /// alike. `idle_prompt` means the agent is waiting rather than blocked on a person — Claude + /// fires it 60 s after every turn at an empty composer, where it would displace the + /// `turn-ended` level idle waits read and wake `changed` waits — and `auth_success` / + /// background-task types are informational, so none may resolve a wait as `needs-input` + /// (docs-ai 064.008, 064.013). + private static let acceptedAttentionNotifications: Set = [ "elicitation_dialog", "permission_prompt", ] @@ -190,7 +187,7 @@ nonisolated public enum AgentNativeHookDecoder { let detail: String? if nativeEvent == "Notification" { guard let type = object["notification_type"] as? String, - acceptedBlockingNotifications.contains(type) + acceptedAttentionNotifications.contains(type) else { throw AgentNativeHookDecodeError.unsupportedEvent } @@ -233,7 +230,7 @@ nonisolated public enum AgentNativeHookDecoder { detail = boundedOptionalString(object["tool_name"] ?? object["reason"]) case "Notification": guard let type = object["notification_type"] as? String, - acceptedClaudeNotifications.contains(type) + acceptedAttentionNotifications.contains(type) else { throw AgentNativeHookDecodeError.unsupportedEvent } diff --git a/supacodeTests/AgentNativeHookPayloadTests.swift b/supacodeTests/AgentNativeHookPayloadTests.swift index 30e9cb61..0a925238 100644 --- a/supacodeTests/AgentNativeHookPayloadTests.swift +++ b/supacodeTests/AgentNativeHookPayloadTests.swift @@ -55,12 +55,26 @@ struct AgentNativeHookPayloadTests { #expect(signal.event == .needsInput) #expect(signal.detail == "permission_prompt") - let ignored = Data( - #"{"hook_event_name":"Notification","notification_type":"future_notice","session_id":"s","cwd":"/tmp/p"}"# + let elicitation = Data( + #"{"hook_event_name":"Notification","notification_type":"elicitation_dialog","session_id":"s","cwd":"/tmp/p"}"# .utf8 ) - #expect(throws: AgentNativeHookDecodeError.unsupportedEvent) { - try AgentNativeHookDecoder.decode(runtime: .claude, nativeEvent: "Notification", payload: ignored) + #expect( + try AgentNativeHookDecoder.decode(runtime: .claude, nativeEvent: "Notification", payload: elicitation).detail + == "elicitation_dialog" + ) + + // `idle_prompt` fires 60 s after a turn ended while the composer sits empty: the agent is + // waiting, not blocked on a person, and treating it as `needs-input` would displace the + // `turn-ended` level that idle waits rely on. + for rejected in ["idle_prompt", "auth_success", "future_notice"] { + let ignored = Data( + #"{"hook_event_name":"Notification","notification_type":"\#(rejected)","session_id":"s","cwd":"/tmp/p"}"# + .utf8 + ) + #expect(throws: AgentNativeHookDecodeError.unsupportedEvent) { + try AgentNativeHookDecoder.decode(runtime: .claude, nativeEvent: "Notification", payload: ignored) + } } }