Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Versioning convention (see [README.md](./README.md) for full policy):
- `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
- `CostPayload` shared wire type with additive cached-token, pricing-availability, and Codex credit estimate fields
- `RateLimitsUpdatedPayload` additive Codex plan and actual credit snapshot fields

## [v0.1.0] — 2026-04-29

Expand Down
19 changes: 19 additions & 0 deletions cost.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package protocol

// CostPayload is carried by AgentD "cost" messages. The first six fields are
// the historical wire contract. Later fields are additive and optional so older
// clients and daemons continue to interoperate.
type CostPayload struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
TurnCostUSD float64 `json:"turn_cost_usd"`
SessionTotal float64 `json:"session_total_usd"`
BudgetLimit float64 `json:"budget_limit_usd"` // 0 = no limit
BudgetRemain float64 `json:"budget_remain_usd"` // 0 = no limit
CachedInputTokens int `json:"cached_input_tokens,omitempty"`
ReasoningOutputTokens int `json:"reasoning_output_tokens,omitempty"`
PricingModel string `json:"pricing_model,omitempty"`
PricingAvailable bool `json:"pricing_available,omitempty"`
TurnCredits float64 `json:"turn_credits,omitempty"`
SessionTotalCredits float64 `json:"session_total_credits,omitempty"`
}
55 changes: 55 additions & 0 deletions cost_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package protocol_test

import (
"encoding/json"
"testing"

protocol "github.com/hishamkaram/agentd-protocol"
)

func TestCostPayloadBackwardCompatibleRoundTrip(t *testing.T) {
t.Parallel()

raw := []byte(`{"input_tokens":100,"output_tokens":50,"turn_cost_usd":0.01,"session_total_usd":0.02,"budget_limit_usd":5,"budget_remain_usd":4.98}`)
var got protocol.CostPayload
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal old payload: %v", err)
}
if got.InputTokens != 100 || got.OutputTokens != 50 || got.TurnCostUSD != 0.01 ||
got.SessionTotal != 0.02 || got.BudgetLimit != 5 || got.BudgetRemain != 4.98 {
t.Fatalf("old fields mismatch: %+v", got)
}
if got.CachedInputTokens != 0 || got.PricingAvailable || got.TurnCredits != 0 {
t.Fatalf("new fields should default to zero values: %+v", got)
}
}

func TestCostPayloadAdditiveFieldsRoundTrip(t *testing.T) {
t.Parallel()

in := protocol.CostPayload{
InputTokens: 1000,
CachedInputTokens: 250,
OutputTokens: 100,
ReasoningOutputTokens: 40,
TurnCostUSD: 0.1234,
SessionTotal: 0.5678,
BudgetLimit: 5,
BudgetRemain: 4.4322,
PricingModel: "gpt-5.5",
PricingAvailable: true,
TurnCredits: 1.25,
SessionTotalCredits: 2.5,
}
raw, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got protocol.CostPayload
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got != in {
t.Fatalf("round-trip mismatch\nwant: %+v\n got: %+v\n raw: %s", in, got, raw)
}
}
14 changes: 9 additions & 5 deletions ratelimits.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ type RateLimitBucket struct {
// MsgOutput text "rate limits updated" (preserves feature 185 behavior) and
// logs `codex_rate_limits_parse_failed` at warn level.
type RateLimitsUpdatedPayload struct {
PrimaryWindow *RateLimitBucket `json:"primary_window,omitempty"`
SecondaryWindow *RateLimitBucket `json:"secondary_window,omitempty"`
ByLimitID map[string]*RateLimitBucket `json:"by_limit_id,omitempty"`
SessionID string `json:"session_id"`
ReceivedAtMillis int64 `json:"received_at_ms"`
PrimaryWindow *RateLimitBucket `json:"primary_window,omitempty"`
SecondaryWindow *RateLimitBucket `json:"secondary_window,omitempty"`
ByLimitID map[string]*RateLimitBucket `json:"by_limit_id,omitempty"`
SessionID string `json:"session_id"`
ReceivedAtMillis int64 `json:"received_at_ms"`
PlanType string `json:"plan_type,omitempty"`
CreditsBalance string `json:"credits_balance,omitempty"`
CreditsHasCredits *bool `json:"credits_has_credits,omitempty"`
CreditsUnlimited *bool `json:"credits_unlimited,omitempty"`
}
28 changes: 20 additions & 8 deletions ratelimits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,15 @@ func TestRateLimitsUpdatedPayloadFields(t *testing.T) {
jsonTag string
}
cases := map[string]want{
"PrimaryWindow": {"*", "primary_window,omitempty"},
"SecondaryWindow": {"*", "secondary_window,omitempty"},
"ByLimitID": {"map[string]", "by_limit_id,omitempty"},
"SessionID": {"string", "session_id"},
"ReceivedAtMillis": {"int64", "received_at_ms"},
"PrimaryWindow": {"*", "primary_window,omitempty"},
"SecondaryWindow": {"*", "secondary_window,omitempty"},
"ByLimitID": {"map[string]", "by_limit_id,omitempty"},
"SessionID": {"string", "session_id"},
"ReceivedAtMillis": {"int64", "received_at_ms"},
"PlanType": {"string", "plan_type,omitempty"},
"CreditsBalance": {"string", "credits_balance,omitempty"},
"CreditsHasCredits": {"*", "credits_has_credits,omitempty"},
"CreditsUnlimited": {"*", "credits_unlimited,omitempty"},
}

if got := typ.NumField(); got != len(cases) {
Expand Down Expand Up @@ -155,8 +159,12 @@ func TestRateLimitsUpdatedPayloadRoundTrip(t *testing.T) {
"rpm-5h": {UsedPercent: 42.5, LabelID: "rpm-5h"},
"weekly": {UsedPercent: 12.0, LabelID: "weekly"},
},
SessionID: "sess-abc",
ReceivedAtMillis: 1713600000000,
SessionID: "sess-abc",
ReceivedAtMillis: 1713600000000,
PlanType: "plus",
CreditsBalance: "1200",
CreditsHasCredits: boolPtr(true),
CreditsUnlimited: boolPtr(false),
},
},
{
Expand Down Expand Up @@ -245,14 +253,18 @@ func TestRateLimitsUpdatedPayloadJSONKeys(t *testing.T) {
}
}

func boolPtr(v bool) *bool {
return &v
}

// TestRateLimitsUpdatedPayloadMalformedJSON verifies malformed input returns a
// Go unmarshal error rather than panicking. Per data-model.md the daemon-side
// parser falls back to the MsgOutput text path on error.
func TestRateLimitsUpdatedPayloadMalformedJSON(t *testing.T) {
t.Parallel()

badInputs := []string{
`{`, // truncated
`{`, // truncated
`{"primary_window": "not-an-object"}`, // type mismatch
`{"received_at_ms": "not-a-number"}`,
`null-garbage`,
Expand Down
Loading