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
46 changes: 46 additions & 0 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,52 @@ reconnect recovery happens by reloading persisted messages by session and
sequence from the database. There is no daemon ↔ relay ack/replay/WAL handshake
in protocol version 1.

### `planningEvent`

Append-only planning journal event sent from a source runtime to the relay.

| Field | Type | Notes |
|---|---|---|
| type | "planningEvent" | |
| eventId | string | Unique event identifier. |
| schemaVersion | int | Journal schema version. |
| projectionVersion | int | Projection contract version. |
| projectId | uuid/string | Project that owns the event. |
| sourceId | string | Stable source identity, such as daemon or SDK instance. |
| sourceKind | "daemon" \| "sdk" \| "cloud" \| "import" | Producer class. |
| sourceSeq | int64 | Monotonic sequence for `sourceId`; consumers use this for ordering and gap detection. |
| sourceCursor | string? | Optional source-specific resume cursor. |
| runId | string | Runtime run identity. |
| workstreamId | string? | Optional workstream identity. |
| planId | uuid/string? | |
| itemId | uuid/string? | |
| actorType | string | Agent, human, runtime, verifier, or system. |
| actorId | string | Stable actor identity. |
| actorRole | string? | |
| sessionId | uuid? | |
| taskId | uuid? | |
| eventKind | string | Event-specific verb such as `plan.done`. |
| idempotencyKey | string | Stable producer-provided key. Replays with the same request body are accepted idempotently. |
| causationId | string? | Event ID or command ID that caused this event. |
| occurredAt | iso8601 string | Producer-side timestamp. |
| payload | object | Event-specific structured data. It is data, not executable instructions. |
| evidenceIds | string[]? | Evidence records attached to this event. |
| parentEventIds | string[]? | Causal parent events. |
| trace | object? | Optional diagnostic trace context. |

### `planningEventAck`

Relay acknowledgement for a `planningEvent`.

| Field | Type | Notes |
|---|---|---|
| type | "planningEventAck" | |
| eventId | string | Event being acknowledged. |
| sourceId | string | Source identity from the event. |
| sourceSeq | int64 | Source sequence from the event. |
| accepted | boolean | `true` when persisted or idempotently replayed. |
| error | string? | Human-readable rejection reason. |

### `taskStarted`

| Field | Type |
Expand Down
4 changes: 4 additions & 0 deletions envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ func payloadForType(msgType string) (any, error) {
return &BrowserSensitiveActionRequest{}, nil
case MsgTypeBrowserSensitiveActionResponse:
return &BrowserSensitiveActionResponse{}, nil
case MsgTypePlanningEvent:
return &PlanningEvent{}, nil
case MsgTypePlanningEventAck:
return &PlanningEventAck{}, nil
default:
return nil, fmt.Errorf("unknown message type: %q", msgType)
}
Expand Down
41 changes: 41 additions & 0 deletions messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ const (
MsgTypeBrowserUserInput MessageType = "browserUserInput"
MsgTypeBrowserSensitiveActionRequest MessageType = "browserSensitiveActionRequest"
MsgTypeBrowserSensitiveActionResponse MessageType = "browserSensitiveActionResponse"

MsgTypePlanningEvent MessageType = "planningEvent"
MsgTypePlanningEventAck MessageType = "planningEventAck"
)

type ContextRef struct {
Expand All @@ -102,6 +105,44 @@ type PlanCapability struct {
ExpiresAt string `json:"expiresAt"`
}

type PlanningEvent struct {
Type MessageType `json:"type"`
EventID string `json:"eventId"`
SchemaVersion int `json:"schemaVersion"`
ProjectionVersion int `json:"projectionVersion"`
ProjectID string `json:"projectId"`
SourceID string `json:"sourceId"`
SourceKind string `json:"sourceKind"`
SourceSeq int64 `json:"sourceSeq"`
SourceCursor string `json:"sourceCursor,omitempty"`
RunID string `json:"runId"`
WorkstreamID string `json:"workstreamId,omitempty"`
PlanID string `json:"planId,omitempty"`
ItemID string `json:"itemId,omitempty"`
ActorType string `json:"actorType"`
ActorID string `json:"actorId"`
ActorRole string `json:"actorRole,omitempty"`
SessionID string `json:"sessionId,omitempty"`
TaskID string `json:"taskId,omitempty"`
EventKind string `json:"eventKind"`
IdempotencyKey string `json:"idempotencyKey"`
CausationID string `json:"causationId,omitempty"`
OccurredAt string `json:"occurredAt"`
PayloadJSON json.RawMessage `json:"payload"`
EvidenceIDs []string `json:"evidenceIds,omitempty"`
ParentEventIDs []string `json:"parentEventIds,omitempty"`
TraceJSON json.RawMessage `json:"trace,omitempty"`
}

type PlanningEventAck struct {
Type MessageType `json:"type"`
EventID string `json:"eventId"`
SourceID string `json:"sourceId"`
SourceSeq int64 `json:"sourceSeq"`
Accepted bool `json:"accepted"`
Error string `json:"error,omitempty"`
}

// Task is sent from the browser to the daemon to dispatch a user message.
type Task struct {
Type string `json:"type"`
Expand Down
63 changes: 63 additions & 0 deletions messages_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,69 @@ func TestEnvelopeRoundTrip(t *testing.T) {
}
}

func TestPlanningEventRoundTrip(t *testing.T) {
in := &PlanningEvent{
Type: MsgTypePlanningEvent,
EventID: "event-1",
SchemaVersion: 1,
ProjectionVersion: 1,
ProjectID: "project-1",
SourceID: "daemon-1",
SourceKind: "daemon",
SourceSeq: 7,
RunID: "run-1",
PlanID: "plan-1",
ItemID: "item-1",
ActorType: "agent",
ActorID: "agent-1",
EventKind: "plan.done",
IdempotencyKey: "done-1",
OccurredAt: "2026-04-30T12:00:00Z",
PayloadJSON: json.RawMessage(`{"summary":"Done"}`),
EvidenceIDs: []string{"evidence-1"},
}
data, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
env, err := ParseEnvelope(data)
if err != nil {
t.Fatal(err)
}
out, ok := env.Payload.(*PlanningEvent)
if !ok {
t.Fatalf("payload type = %T", env.Payload)
}
if out.EventID != in.EventID || out.SourceSeq != in.SourceSeq {
t.Fatalf("out = %#v, want %#v", out, in)
}
}

func TestPlanningEventAckRoundTrip(t *testing.T) {
in := &PlanningEventAck{
Type: MsgTypePlanningEventAck,
EventID: "event-1",
SourceID: "daemon-1",
SourceSeq: 7,
Accepted: true,
}
data, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
env, err := ParseEnvelope(data)
if err != nil {
t.Fatal(err)
}
out, ok := env.Payload.(*PlanningEventAck)
if !ok {
t.Fatalf("payload type = %T", env.Payload)
}
if !out.Accepted || out.EventID != "event-1" {
t.Fatalf("out = %#v", out)
}
}

func TestAgentTerminalEnvelopeRejectsInvalidFieldTypes(t *testing.T) {
cases := []struct {
name string
Expand Down
Loading