|
1
|
package protocol_test |
|
2
|
|
|
3
|
import ( |
|
4
|
"encoding/json" |
|
5
|
"testing" |
|
6
|
|
|
7
|
"github.com/conflicthq/scuttlebot/pkg/protocol" |
|
8
|
) |
|
9
|
|
|
10
|
func TestRoundTrip(t *testing.T) { |
|
11
|
type testPayload struct { |
|
12
|
Task string `json:"task"` |
|
13
|
} |
|
14
|
|
|
15
|
env, err := protocol.New(protocol.TypeTaskCreate, "claude-01", testPayload{Task: "write tests"}) |
|
16
|
if err != nil { |
|
17
|
t.Fatalf("New: %v", err) |
|
18
|
} |
|
19
|
|
|
20
|
data, err := protocol.Marshal(env) |
|
21
|
if err != nil { |
|
22
|
t.Fatalf("Marshal: %v", err) |
|
23
|
} |
|
24
|
|
|
25
|
got, err := protocol.Unmarshal(data) |
|
26
|
if err != nil { |
|
27
|
t.Fatalf("Unmarshal: %v", err) |
|
28
|
} |
|
29
|
|
|
30
|
if got.V != protocol.Version { |
|
31
|
t.Errorf("V: got %d, want %d", got.V, protocol.Version) |
|
32
|
} |
|
33
|
if got.Type != protocol.TypeTaskCreate { |
|
34
|
t.Errorf("Type: got %q, want %q", got.Type, protocol.TypeTaskCreate) |
|
35
|
} |
|
36
|
if got.ID == "" { |
|
37
|
t.Error("ID is empty") |
|
38
|
} |
|
39
|
if got.From != "claude-01" { |
|
40
|
t.Errorf("From: got %q, want %q", got.From, "claude-01") |
|
41
|
} |
|
42
|
if got.TS == 0 { |
|
43
|
t.Error("TS is zero") |
|
44
|
} |
|
45
|
|
|
46
|
var p testPayload |
|
47
|
if err := protocol.UnmarshalPayload(got, &p); err != nil { |
|
48
|
t.Fatalf("UnmarshalPayload: %v", err) |
|
49
|
} |
|
50
|
if p.Task != "write tests" { |
|
51
|
t.Errorf("payload.Task: got %q, want %q", p.Task, "write tests") |
|
52
|
} |
|
53
|
} |
|
54
|
|
|
55
|
func TestUnmarshalInvalid(t *testing.T) { |
|
56
|
cases := []struct { |
|
57
|
name string |
|
58
|
json string |
|
59
|
}{ |
|
60
|
{"not json", `not json`}, |
|
61
|
{"wrong version", `{"v":99,"type":"task.create","id":"01HX","from":"agent","ts":1}`}, |
|
62
|
{"missing type", `{"v":1,"id":"01HX","from":"agent","ts":1}`}, |
|
63
|
{"missing id", `{"v":1,"type":"task.create","from":"agent","ts":1}`}, |
|
64
|
{"missing from", `{"v":1,"type":"task.create","id":"01HX","ts":1}`}, |
|
65
|
} |
|
66
|
|
|
67
|
for _, tc := range cases { |
|
68
|
t.Run(tc.name, func(t *testing.T) { |
|
69
|
_, err := protocol.Unmarshal([]byte(tc.json)) |
|
70
|
if err == nil { |
|
71
|
t.Error("expected error, got nil") |
|
72
|
} |
|
73
|
}) |
|
74
|
} |
|
75
|
} |
|
76
|
|
|
77
|
func TestNewGeneratesUniqueIDs(t *testing.T) { |
|
78
|
seen := make(map[string]bool) |
|
79
|
for i := 0; i < 100; i++ { |
|
80
|
env, err := protocol.New(protocol.TypeAgentHello, "agent", nil) |
|
81
|
if err != nil { |
|
82
|
t.Fatalf("New: %v", err) |
|
83
|
} |
|
84
|
if seen[env.ID] { |
|
85
|
t.Errorf("duplicate ID: %s", env.ID) |
|
86
|
} |
|
87
|
seen[env.ID] = true |
|
88
|
} |
|
89
|
} |
|
90
|
|
|
91
|
func TestNilPayload(t *testing.T) { |
|
92
|
env, err := protocol.New(protocol.TypeAgentBye, "agent-01", nil) |
|
93
|
if err != nil { |
|
94
|
t.Fatalf("New: %v", err) |
|
95
|
} |
|
96
|
|
|
97
|
data, err := protocol.Marshal(env) |
|
98
|
if err != nil { |
|
99
|
t.Fatalf("Marshal: %v", err) |
|
100
|
} |
|
101
|
|
|
102
|
got, err := protocol.Unmarshal(data) |
|
103
|
if err != nil { |
|
104
|
t.Fatalf("Unmarshal: %v", err) |
|
105
|
} |
|
106
|
|
|
107
|
if len(got.Payload) != 0 { |
|
108
|
t.Errorf("expected empty payload, got %s", got.Payload) |
|
109
|
} |
|
110
|
} |
|
111
|
|
|
112
|
func TestMatchesRecipient(t *testing.T) { |
|
113
|
cases := []struct { |
|
114
|
name string |
|
115
|
to []string |
|
116
|
nick string |
|
117
|
agentType string |
|
118
|
want bool |
|
119
|
}{ |
|
120
|
// backwards compat |
|
121
|
{"empty to matches all", nil, "claude-1", "worker", true}, |
|
122
|
|
|
123
|
// @all |
|
124
|
{"@all matches worker", []string{"@all"}, "claude-1", "worker", true}, |
|
125
|
{"@all matches operator", []string{"@all"}, "glengoolie", "operator", true}, |
|
126
|
|
|
127
|
// role tokens |
|
128
|
{"@workers matches worker", []string{"@workers"}, "claude-1", "worker", true}, |
|
129
|
{"@workers no match orchestrator", []string{"@workers"}, "claude-1", "orchestrator", false}, |
|
130
|
{"@operators matches operator", []string{"@operators"}, "glengoolie", "operator", true}, |
|
131
|
{"@orchestrators matches orchestrator", []string{"@orchestrators"}, "claude-1", "orchestrator", true}, |
|
132
|
{"@observers matches observer", []string{"@observers"}, "sentinel", "observer", true}, |
|
133
|
|
|
134
|
// prefix glob |
|
135
|
{"@claude-* matches claude-1", []string{"@claude-*"}, "claude-1", "worker", true}, |
|
136
|
{"@claude-* matches claude-sonnet", []string{"@claude-*"}, "claude-sonnet", "worker", true}, |
|
137
|
{"@claude-* no match codex-1", []string{"@claude-*"}, "codex-1", "worker", false}, |
|
138
|
{"@gemini-* matches gemini-pro", []string{"@gemini-*"}, "gemini-pro", "worker", true}, |
|
139
|
|
|
140
|
// exact nick |
|
141
|
{"exact nick match", []string{"codex-7"}, "codex-7", "worker", true}, |
|
142
|
{"exact nick no match", []string{"codex-7"}, "codex-8", "worker", false}, |
|
143
|
|
|
144
|
// OR semantics |
|
145
|
{"OR: second token matches", []string{"@operators", "codex-7"}, "codex-7", "worker", true}, |
|
146
|
{"OR: first token matches", []string{"@workers", "codex-7"}, "claude-1", "worker", true}, |
|
147
|
{"OR: none match", []string{"@operators", "codex-7"}, "claude-1", "worker", false}, |
|
148
|
} |
|
149
|
|
|
150
|
for _, tc := range cases { |
|
151
|
t.Run(tc.name, func(t *testing.T) { |
|
152
|
env := &protocol.Envelope{ |
|
153
|
V: protocol.Version, |
|
154
|
Type: protocol.TypeTaskCreate, |
|
155
|
ID: "test", |
|
156
|
From: "orchestrator", |
|
157
|
To: tc.to, |
|
158
|
TS: 1, |
|
159
|
} |
|
160
|
got := protocol.MatchesRecipient(env, tc.nick, tc.agentType) |
|
161
|
if got != tc.want { |
|
162
|
t.Errorf("MatchesRecipient(%v, %q, %q) = %v, want %v", tc.to, tc.nick, tc.agentType, got, tc.want) |
|
163
|
} |
|
164
|
}) |
|
165
|
} |
|
166
|
} |
|
167
|
|
|
168
|
func TestNewTo(t *testing.T) { |
|
169
|
env, err := protocol.NewTo(protocol.TypeTaskCreate, "orchestrator-1", []string{"@workers", "@claude-*"}, nil) |
|
170
|
if err != nil { |
|
171
|
t.Fatalf("NewTo: %v", err) |
|
172
|
} |
|
173
|
if len(env.To) != 2 { |
|
174
|
t.Fatalf("To length: got %d, want 2", len(env.To)) |
|
175
|
} |
|
176
|
if env.To[0] != "@workers" || env.To[1] != "@claude-*" { |
|
177
|
t.Errorf("To: got %v", env.To) |
|
178
|
} |
|
179
|
|
|
180
|
// round-trip |
|
181
|
data, err := protocol.Marshal(env) |
|
182
|
if err != nil { |
|
183
|
t.Fatalf("Marshal: %v", err) |
|
184
|
} |
|
185
|
got, err := protocol.Unmarshal(data) |
|
186
|
if err != nil { |
|
187
|
t.Fatalf("Unmarshal: %v", err) |
|
188
|
} |
|
189
|
if len(got.To) != 2 || got.To[0] != "@workers" { |
|
190
|
t.Errorf("round-trip To: got %v", got.To) |
|
191
|
} |
|
192
|
} |
|
193
|
|
|
194
|
func TestToOmittedWhenEmpty(t *testing.T) { |
|
195
|
env, err := protocol.New(protocol.TypeAgentHello, "agent", nil) |
|
196
|
if err != nil { |
|
197
|
t.Fatalf("New: %v", err) |
|
198
|
} |
|
199
|
data, err := protocol.Marshal(env) |
|
200
|
if err != nil { |
|
201
|
t.Fatalf("Marshal: %v", err) |
|
202
|
} |
|
203
|
var raw map[string]any |
|
204
|
if err := json.Unmarshal(data, &raw); err != nil { |
|
205
|
t.Fatalf("json.Unmarshal: %v", err) |
|
206
|
} |
|
207
|
if _, ok := raw["to"]; ok { |
|
208
|
t.Error("expected 'to' key to be omitted when empty") |
|
209
|
} |
|
210
|
} |
|
211
|
|
|
212
|
func TestAllMessageTypes(t *testing.T) { |
|
213
|
types := []string{ |
|
214
|
protocol.TypeTaskCreate, |
|
215
|
protocol.TypeTaskUpdate, |
|
216
|
protocol.TypeTaskComplete, |
|
217
|
protocol.TypeAgentHello, |
|
218
|
protocol.TypeAgentBye, |
|
219
|
} |
|
220
|
for _, msgType := range types { |
|
221
|
t.Run(msgType, func(t *testing.T) { |
|
222
|
env, err := protocol.New(msgType, "agent", json.RawMessage(`{"key":"val"}`)) |
|
223
|
if err != nil { |
|
224
|
t.Fatalf("New: %v", err) |
|
225
|
} |
|
226
|
data, err := protocol.Marshal(env) |
|
227
|
if err != nil { |
|
228
|
t.Fatalf("Marshal: %v", err) |
|
229
|
} |
|
230
|
got, err := protocol.Unmarshal(data) |
|
231
|
if err != nil { |
|
232
|
t.Fatalf("Unmarshal: %v", err) |
|
233
|
} |
|
234
|
if got.Type != msgType { |
|
235
|
t.Errorf("Type: got %q, want %q", got.Type, msgType) |
|
236
|
} |
|
237
|
}) |
|
238
|
} |
|
239
|
} |
|
240
|
|