v0.11.1 ab30a57

protocol_test.go RawHistoryFolder

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 TestProtocolResultShape(t *testing.T) {
237	result := TurnResultPayload{
238		Status:       "succeeded",
239		Summary:      "implemented parser",
240		Output:       "full output",
241		Structured:   json.RawMessage(`{"ok":true}`),
242		Artifacts:    []ArtifactReference{{Name: "patch", Ref: "subagent://a/patch"}},
243		ChangedFiles: []string{"protocol.go"},
244		Usage:        map[string]any{"input_tokens": 3},
245	}
246	e := NewEventEnvelope(EventTurnResult, "a", "t", result)
247	var decoded TurnResultPayload
248	if err := e.DecodePayload(&decoded); err != nil {
249		t.Fatalf("DecodePayload: %v", err)
250	}
251	if decoded.Status != "succeeded" || len(decoded.Artifacts) != 1 || decoded.Artifacts[0].Ref != "subagent://a/patch" {
252		t.Fatalf("decoded result = %+v", decoded)
253	}
254}
255
256func TestProtocolTimestampIsRFC3339(t *testing.T) {
257	e := NewEventEnvelope(EventAgentReady, "a", "", nil)
258	wire, err := MarshalEnvelope(e)
259	if err != nil {
260		t.Fatalf("MarshalEnvelope: %v", err)
261	}
262	var fields map[string]json.RawMessage
263	if err := json.Unmarshal(wire, &fields); err != nil {
264		t.Fatal(err)
265	}
266	var timestamp string
267	if err := json.Unmarshal(fields["timestamp"], &timestamp); err != nil {
268		t.Fatal(err)
269	}
270	if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil {
271		t.Fatalf("timestamp %q is not RFC3339Nano: %v", timestamp, err)
272	}
273}
274
275func TestProtocolKnownNames(t *testing.T) {
276	if !IsCommandName(CommandAgentPing) || !IsCommandName(CommandTurnStart) || !IsCommandName(CommandTurnCancel) || !IsCommandName(CommandAgentShutdown) {
277		t.Fatal("one or more canonical command names are not registered")
278	}
279	if !IsEventName(EventAgentReady) || !IsEventName(EventAgentHeartbeat) || !IsEventName(EventTurnStarted) || !IsEventName(EventTurnProgress) || !IsEventName(EventToolStarted) || !IsEventName(EventToolFinished) || !IsEventName(EventMessageDelta) || !IsEventName(EventTurnResult) || !IsEventName(EventTurnFailed) || !IsEventName(EventAgentIdle) || !IsEventName(EventAgentExited) {
280		t.Fatal("one or more canonical event names are not registered")
281	}
282	if IsEventName("future.event") || IsCommandName("future.command") {
283		t.Fatal("future names should not be reported as known names")
284	}
285}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close