1package subagents
2
3import (
4 "bufio"
5 "bytes"
6 "encoding/json"
7 "errors"
8 "io"
9 "testing"
10 "time"
11)
12
13func TestProtocolEnvelopeRoundTrip(t *testing.T) {
14 e := NewEventEnvelope(EventTurnResult, "agent-123", "turn-4", map[string]any{
15 "status": "succeeded",
16 "output": "done",
17 })
18 if e.Version != ProtocolVersion {
19 t.Fatalf("version = %d, want %d", e.Version, ProtocolVersion)
20 }
21 if e.MessageID == "" || !IsMessageID(e.MessageID) {
22 t.Fatalf("message id = %q, want a UUID", e.MessageID)
23 }
24 if e.Timestamp.IsZero() {
25 t.Fatal("constructor did not set timestamp")
26 }
27 if err := e.Validate(); err != nil {
28 t.Fatalf("Validate: %v", err)
29 }
30
31 line, err := MarshalJSONL(e)
32 if err != nil {
33 t.Fatalf("MarshalJSONL: %v", err)
34 }
35 if !bytes.HasSuffix(line, []byte{'\n'}) {
36 t.Fatal("JSONL message is not newline terminated")
37 }
38 var wire map[string]any
39 if err := json.Unmarshal(bytes.TrimSpace(line), &wire); err != nil {
40 t.Fatalf("wire JSON: %v", err)
41 }
42 for _, field := range []string{"version", "type", "message_id", "agent_id", "turn_id", "timestamp", "payload"} {
43 if _, ok := wire[field]; !ok {
44 t.Errorf("wire message lacks %q", field)
45 }
46 }
47
48 got, err := ParseJSONL(line)
49 if err != nil {
50 t.Fatalf("ParseJSONL: %v", err)
51 }
52 if got.Version != e.Version || got.Type != e.Type || got.MessageID != e.MessageID ||
53 got.AgentID != e.AgentID || got.TurnID != e.TurnID {
54 t.Fatalf("metadata changed on round trip: got %+v, want %+v", got, e)
55 }
56 var result TurnResultPayload
57 if err := got.DecodePayload(&result); err != nil {
58 t.Fatalf("DecodePayload: %v", err)
59 }
60 if result.Status != "succeeded" || result.Output != "done" {
61 t.Fatalf("decoded payload = %+v", result)
62 }
63}
64
65func TestProtocolPreservesUnknownEventAndPayload(t *testing.T) {
66 input := `{"version":1,"type":"future.event","message_id":"msg-future","agent_id":"agent-1","turn_id":"turn-1","timestamp":"2026-08-04T12:00:00Z","future_top":{"enabled":true},"payload":{"known":"yes","future":{"nested":[1,2,{"x":true}]}}}`
67 e, err := ParseEnvelope([]byte(input))
68 if err != nil {
69 t.Fatalf("ParseEnvelope: %v", err)
70 }
71 if e.Type != "future.event" {
72 t.Fatalf("type = %q", e.Type)
73 }
74 if e.IsEvent() {
75 t.Fatal("unknown event was reported as a known event")
76 }
77 if _, ok := e.Unknown["future_top"]; !ok {
78 t.Fatalf("unknown top-level field was not retained: %#v", e.Unknown)
79 }
80 fields, err := e.PayloadFields()
81 if err != nil {
82 t.Fatalf("PayloadFields: %v", err)
83 }
84 if string(fields["future"]) != `{"nested":[1,2,{"x":true}]}` {
85 t.Fatalf("unknown payload field changed: %s", fields["future"])
86 }
87
88 encoded, err := MarshalEnvelope(e)
89 if err != nil {
90 t.Fatalf("MarshalEnvelope: %v", err)
91 }
92 var roundTripped map[string]any
93 if err := json.Unmarshal(encoded, &roundTripped); err != nil {
94 t.Fatalf("round-trip JSON: %v", err)
95 }
96 if _, ok := roundTripped["future_top"]; !ok {
97 t.Fatal("unknown top-level field was dropped")
98 }
99 payload, ok := roundTripped["payload"].(map[string]any)
100 if !ok || payload["future"] == nil {
101 t.Fatalf("unknown payload was dropped: %#v", roundTripped["payload"])
102 }
103}
104
105func TestProtocolJSONCommandsAndDirectionChecks(t *testing.T) {
106 command := NewCommand(CommandTurnStart, "agent-1", "turn-2", TurnStartPayload{Prompt: "hello"})
107 line, err := MarshalJSONL(command)
108 if err != nil {
109 t.Fatalf("MarshalJSONL: %v", err)
110 }
111 got, err := ParseCommand(string(line))
112 if err != nil {
113 t.Fatalf("ParseCommand: %v", err)
114 }
115 if !got.IsCommand() || got.IsEvent() || got.Type != CommandTurnStart {
116 t.Fatalf("direction = command:%v event:%v type:%q", got.IsCommand(), got.IsEvent(), got.Type)
117 }
118 if _, err := ParseEvent(line); !errors.Is(err, ErrNotEvent) {
119 t.Fatalf("ParseEvent(command) = %v, want ErrNotEvent", err)
120 }
121
122 event := NewEventEnvelope(EventAgentHeartbeat, "agent-1", "", AgentHeartbeatPayload{Activity: "idle"})
123 eventLine, err := MarshalJSONL(event)
124 if err != nil {
125 t.Fatalf("Marshal event: %v", err)
126 }
127 if _, err := ParseCommand(string(eventLine)); !errors.Is(err, ErrNotCommand) {
128 t.Fatalf("ParseCommand(event) = %v, want ErrNotCommand", err)
129 }
130}
131
132func TestProtocolStreamReadWrite(t *testing.T) {
133 first := NewCommand(CommandAgentPing, "agent-1", "", nil)
134 second := NewEventEnvelope(EventAgentReady, "agent-1", "", nil)
135 var stream bytes.Buffer
136 if err := WriteEnvelope(&stream, first); err != nil {
137 t.Fatalf("write first: %v", err)
138 }
139 if err := WriteEnvelope(&stream, second); err != nil {
140 t.Fatalf("write second: %v", err)
141 }
142 reader := bufio.NewReader(&stream)
143 gotFirst, err := ReadEnvelope(reader)
144 if err != nil {
145 t.Fatalf("read first: %v", err)
146 }
147 gotSecond, err := ReadEnvelope(reader)
148 if err != nil {
149 t.Fatalf("read second: %v", err)
150 }
151 if gotFirst.Type != CommandAgentPing || gotSecond.Type != EventAgentReady {
152 t.Fatalf("stream types = %q, %q", gotFirst.Type, gotSecond.Type)
153 }
154 if _, err := ReadEnvelope(reader); !errors.Is(err, io.EOF) {
155 t.Fatalf("read after stream = %v, want EOF", err)
156 }
157}
158
159func TestProtocolMessageIDsAreUnique(t *testing.T) {
160 seen := make(map[string]struct{}, 100)
161 for i := 0; i < 100; i++ {
162 id := NewMessageID()
163 if !IsMessageID(id) {
164 t.Fatalf("NewMessageID returned non-UUID %q", id)
165 }
166 if _, exists := seen[id]; exists {
167 t.Fatalf("duplicate message id %q", id)
168 }
169 seen[id] = struct{}{}
170 }
171}
172
173func TestProtocolPayloadMutationKeepsMessageID(t *testing.T) {
174 e := NewCommand(CommandTurnStart, "agent-1", "turn-1", TurnStartPayload{Prompt: "before"})
175 id := e.MessageID
176 if err := e.SetPayload(TurnStartPayload{Prompt: "after"}); err != nil {
177 t.Fatalf("SetPayload: %v", err)
178 }
179 if e.MessageID != id {
180 t.Fatalf("SetPayload changed message id from %q to %q", id, e.MessageID)
181 }
182}
183
184func TestProtocolUnsupportedVersion(t *testing.T) {
185 _, err := ParseEnvelope([]byte(`{"version":2,"type":"future.event","message_id":"m","agent_id":"a","timestamp":"2026-08-04T12:00:00Z","payload":{}}`))
186 if !errors.Is(err, ErrUnsupportedProtocolVersion) {
187 t.Fatalf("ParseEnvelope version 2 = %v, want unsupported version", err)
188 }
189}
190
191func TestProtocolEmptyAndMalformedInputs(t *testing.T) {
192 for _, input := range []string{"", " ", "not a command"} {
193 if _, err := ParseCommand(input); err == nil {
194 t.Errorf("ParseCommand(%q) succeeded", input)
195 }
196 }
197 if _, err := ParseEnvelope([]byte(`{"payload":{}}`)); err == nil {
198 t.Fatal("ParseEnvelope without type succeeded")
199 }
200 if _, err := ParseEnvelope([]byte(`{"version":1,"type":"x","payload":`)); err == nil {
201 t.Fatal("ParseEnvelope malformed JSON succeeded")
202 }
203
204 invalid := NewEnvelope("x", "agent-1", "turn-1", func() {})
205 if err := invalid.Validate(); err == nil {
206 t.Fatal("NewEnvelope accepted an unmarshalable payload")
207 }
208 if _, err := MarshalEnvelope(invalid); err == nil {
209 t.Fatal("MarshalEnvelope accepted an unmarshalable payload")
210 }
211
212 zeroVersion := Envelope{
213 Type: "x",
214 MessageID: "message-1",
215 AgentID: "agent-1",
216 Timestamp: time.Now().UTC(),
217 Payload: json.RawMessage(`{}`),
218 }
219 wire, err := MarshalEnvelope(zeroVersion)
220 if err != nil {
221 t.Fatalf("MarshalEnvelope zero version: %v", err)
222 }
223 var fields map[string]json.RawMessage
224 if err := json.Unmarshal(wire, &fields); err != nil {
225 t.Fatalf("decode zero-version envelope: %v", err)
226 }
227 var version int
228 if err := json.Unmarshal(fields["version"], &version); err != nil {
229 t.Fatalf("decode version: %v", err)
230 }
231 if version != ProtocolVersion {
232 t.Fatalf("version = %d, want %d", version, ProtocolVersion)
233 }
234}
235
236func TestAgentShutdownPayloadRoundTripsKnownOrigin(t *testing.T) {
237 for _, origin := range []ShutdownOrigin{
238 ShutdownOriginTargeted,
239 ShutdownOriginSession,
240 ShutdownOriginDeadline,
241 ShutdownOriginProcess,
242 } {
243 e := NewCommand(CommandAgentShutdown, "agent-1", "turn-1", AgentShutdownPayload{Origin: origin})
244 var decoded AgentShutdownPayload
245 if err := e.DecodePayload(&decoded); err != nil {
246 t.Fatalf("decode %q shutdown payload: %v", origin, err)
247 }
248 if decoded.Origin != origin {
249 t.Fatalf("shutdown origin = %q, want %q", decoded.Origin, origin)
250 }
251 }
252}
253
254func TestProtocolResultShape(t *testing.T) {
255 result := TurnResultPayload{
256 Status: "succeeded",
257 Summary: "implemented parser",
258 Output: "full output",
259 Structured: json.RawMessage(`{"ok":true}`),
260 Artifacts: []ArtifactReference{{Name: "patch", Ref: "subagent://a/patch"}},
261 ChangedFiles: []string{"protocol.go"},
262 Usage: map[string]any{"input_tokens": 3},
263 }
264 e := NewEventEnvelope(EventTurnResult, "a", "t", result)
265 var decoded TurnResultPayload
266 if err := e.DecodePayload(&decoded); err != nil {
267 t.Fatalf("DecodePayload: %v", err)
268 }
269 if decoded.Status != "succeeded" || len(decoded.Artifacts) != 1 || decoded.Artifacts[0].Ref != "subagent://a/patch" {
270 t.Fatalf("decoded result = %+v", decoded)
271 }
272}
273
274func TestProtocolTimestampIsRFC3339(t *testing.T) {
275 e := NewEventEnvelope(EventAgentReady, "a", "", nil)
276 wire, err := MarshalEnvelope(e)
277 if err != nil {
278 t.Fatalf("MarshalEnvelope: %v", err)
279 }
280 var fields map[string]json.RawMessage
281 if err := json.Unmarshal(wire, &fields); err != nil {
282 t.Fatal(err)
283 }
284 var timestamp string
285 if err := json.Unmarshal(fields["timestamp"], ×tamp); err != nil {
286 t.Fatal(err)
287 }
288 if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
289 t.Fatalf("timestamp %q is not RFC3339Nano: %v", timestamp, err)
290 }
291}
292
293func TestProtocolKnownNames(t *testing.T) {
294 if !IsCommandName(CommandAgentPing) || !IsCommandName(CommandTurnStart) || !IsCommandName(CommandTurnCancel) || !IsCommandName(CommandAgentShutdown) {
295 t.Fatal("one or more canonical command names are not registered")
296 }
297 if !IsEventName(EventAgentReady) || !IsEventName(EventAgentHeartbeat) || !IsEventName(EventTurnStarted) || !IsEventName(EventTurnProgress) || !IsEventName(EventToolStarted) || !IsEventName(EventToolFinished) || !IsEventName(EventMessageDelta) || !IsEventName(EventTurnResult) || !IsEventName(EventTurnFailed) || !IsEventName(EventAgentIdle) || !IsEventName(EventAgentExited) {
298 t.Fatal("one or more canonical event names are not registered")
299 }
300 if IsEventName("future.event") || IsCommandName("future.command") {
301 t.Fatal("future names should not be reported as known names")
302 }
303}