From e08958a88365b4803067166a925edbfcbde8efd4 Mon Sep 17 00:00:00 2001 From: Hesham Karm <24391550+hishamkaram@users.noreply.github.com> Date: Sat, 16 May 2026 09:56:12 +0200 Subject: [PATCH 1/2] feat(protocol): add push notification result controls --- control.go | 30 +++++++++++++++++++--- protocol_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/control.go b/control.go index 35d70fa..b3d1fd0 100644 --- a/control.go +++ b/control.go @@ -25,6 +25,7 @@ const ( CtrlEntitlementUpdate ControlType = "entitlement_update" CtrlEntitlementViolation ControlType = "entitlement_violation" CtrlPushNotify ControlType = "push_notify" + CtrlPushNotifyResult ControlType = "push_notify_result" ) // ControlMessage is the wire format for relay control protocol messages. @@ -117,9 +118,32 @@ type ClientCountPayload struct { // carry the relay session ID; the relay derives that from the registered daemon // connection to prevent session spoofing. type PushNotifyPayload struct { - NavSessionID string `json:"nav_session_id,omitempty"` - Type string `json:"type"` - Summary string `json:"summary"` + NavSessionID string `json:"nav_session_id,omitempty"` + Type string `json:"type"` + Summary string `json:"summary"` + NotificationID string `json:"notification_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + CreatedAtUnixMS int64 `json:"created_at_unix_ms,omitempty"` + Attempt int `json:"attempt,omitempty"` +} + +const ( + PushNotifyStatusAccepted = "accepted" + PushNotifyStatusRetryable = "retryable" + PushNotifyStatusPermanent = "permanent" +) + +// PushNotifyResultPayload is sent by the relay to the daemon after a +// daemon-originated push notification request reaches the relay. It reports the +// relay/provider result so the daemon can stop or retry tracked attempts. +type PushNotifyResultPayload struct { + NotificationID string `json:"notification_id,omitempty"` + TraceID string `json:"trace_id,omitempty"` + Type string `json:"type"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + ProviderStatusCode int `json:"provider_status_code,omitempty"` + DeliveredAtUnixMS int64 `json:"delivered_at_unix_ms,omitempty"` } // KeyRotatePayload is sent by the daemon to the relay to update the session's diff --git a/protocol_test.go b/protocol_test.go index 3c02b89..28e06ab 100644 --- a/protocol_test.go +++ b/protocol_test.go @@ -83,9 +83,13 @@ func TestPushNotifyPayloadRoundtrip(t *testing.T) { t.Parallel() payload := protocol.PushNotifyPayload{ - NavSessionID: "agent-session-1", - Type: "approval_request", - Summary: "AgentD needs your approval", + NavSessionID: "agent-session-1", + Type: "approval_request", + Summary: "AgentD needs your approval", + NotificationID: "push-123", + TraceID: "4bf92f3577b34da6a3ce929d0e0e4736", + CreatedAtUnixMS: 1778915600000, + Attempt: 2, } raw, err := json.Marshal(payload) if err != nil { @@ -121,6 +125,60 @@ func TestPushNotifyPayloadRoundtrip(t *testing.T) { } } +func TestPushNotifyPayloadBackwardCompatible(t *testing.T) { + t.Parallel() + + raw := []byte(`{"nav_session_id":"agent-session-1","type":"question","summary":"AgentD has a question"}`) + var got protocol.PushNotifyPayload + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal legacy PushNotifyPayload: %v", err) + } + if got.NotificationID != "" || got.TraceID != "" || got.CreatedAtUnixMS != 0 || got.Attempt != 0 { + t.Fatalf("legacy optional fields = id:%q trace:%q created:%d attempt:%d, want zero values", + got.NotificationID, got.TraceID, got.CreatedAtUnixMS, got.Attempt) + } + if got.Type != "question" || got.Summary != "AgentD has a question" || got.NavSessionID != "agent-session-1" { + t.Fatalf("legacy payload decoded incorrectly: %+v", got) + } +} + +func TestPushNotifyResultPayloadRoundtrip(t *testing.T) { + t.Parallel() + + payload := protocol.PushNotifyResultPayload{ + NotificationID: "push-123", + TraceID: "4bf92f3577b34da6a3ce929d0e0e4736", + Type: "question", + Status: protocol.PushNotifyStatusAccepted, + Reason: "provider_accepted", + ProviderStatusCode: 201, + DeliveredAtUnixMS: 1778915600123, + } + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal PushNotifyResultPayload: %v", err) + } + msg := protocol.ControlMessage{Type: protocol.CtrlPushNotifyResult, Payload: raw} + encoded, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal ControlMessage: %v", err) + } + var decoded protocol.ControlMessage + if err := json.Unmarshal(encoded, &decoded); err != nil { + t.Fatalf("unmarshal ControlMessage: %v", err) + } + if decoded.Type != protocol.CtrlPushNotifyResult { + t.Fatalf("Type = %q, want %q", decoded.Type, protocol.CtrlPushNotifyResult) + } + var got protocol.PushNotifyResultPayload + if err := json.Unmarshal(decoded.Payload, &got); err != nil { + t.Fatalf("unmarshal PushNotifyResultPayload: %v", err) + } + if got != payload { + t.Fatalf("payload = %+v, want %+v", got, payload) + } +} + func TestRegisterPayloadRoundtrip(t *testing.T) { t.Parallel() original := protocol.RegisterPayload{ @@ -498,6 +556,7 @@ func TestControlTypeConstants(t *testing.T) { protocol.CtrlEntitlementUpdate: "entitlement_update", protocol.CtrlEntitlementViolation: "entitlement_violation", protocol.CtrlPushNotify: "push_notify", + protocol.CtrlPushNotifyResult: "push_notify_result", } for ct, want := range expected { if string(ct) != want { From e42111afe5fc41182b3d12ee5d3bd5156a7ff16c Mon Sep 17 00:00:00 2001 From: Hesham Karm <24391550+hishamkaram@users.noreply.github.com> Date: Sat, 16 May 2026 10:20:11 +0200 Subject: [PATCH 2/2] docs(protocol): document push notification results --- CHANGELOG.md | 3 +++ README.md | 8 +++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0a2cf0..0f12ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ Versioning convention (see [README.md](./README.md) for full policy): - `MsgReplayRequest`, `MsgReplayComplete` constants for durable journal recovery - `ReplayRequest`, `ReplayCompletePayload` +- `CtrlPushNotifyResult` control message for relay-to-daemon Web Push delivery results +- `PushNotifyPayload` tracking fields: `notification_id`, `trace_id`, `created_at_unix_ms`, `attempt` +- `PushNotifyResultPayload` for accepted/retryable/permanent Web Push result acknowledgements ## [v0.1.0] — 2026-04-29 diff --git a/README.md b/README.md index 5dcf21b..e3951d1 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ A tiny Go module containing the shared wire protocol types used by both the [Age | File | Types | Purpose | |------|-------|---------| | `envelope.go` | `RelayEnvelope` | Encrypted message envelope (sid, seq, enc, tid) | -| `control.go` | `ControlMessage`, `ControlType` (12 constants), 11 payload structs | Relay control protocol | +| `control.go` | `ControlMessage`, `ControlType` (16 constants), 15 payload structs | Relay control protocol | | `policy.go` | `PolicyJSON`, `PolicyMatchJSON` | Policy rule wire format | | `capabilities.go` | `AgentCapability` | Per-agent feature flags the daemon emits to the PWA (MCP reconnect, session-scoped approvals, free-text replies, etc.) | | `codex_sandbox.go` | `CodexSandboxMode` | Shared Codex per-session sandbox literals for daemon and PWA runtime controls | @@ -37,7 +37,8 @@ A tiny Go module containing the shared wire protocol types used by both the [Age ``` register · join · heartbeat · ack · error · sync_policies status_update · audit_entry · deactivate_developer · client_connected -client_count · key_rotate +client_count · key_rotate · entitlement_update · entitlement_violation +push_notify · push_notify_result ``` ### Payload Types @@ -46,7 +47,8 @@ client_count · key_rotate RegisterPayload · JoinPayload · AckPayload · ErrorPayload StatusUpdatePayload · AuditEntryPayload · DeactivateDeveloperPayload ClientConnectedPayload · ClientCountPayload · KeyRotatePayload -SyncPoliciesPayload +SyncPoliciesPayload · EntitlementUpdatePayload · EntitlementViolationPayload +PushNotifyPayload · PushNotifyResultPayload ``` ## Usage