@pi-in-go/pigpen-herdr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,772 @@
1
+ package herdr_test
2
+
3
+ // Ported case for case from components/herdr/tests/herdr.test.ts (35 tests, at
4
+ // 6685dcd): same inputs, same expectations, driven through the real Go SDK.
5
+ //
6
+ // One TypeScript feature has no Go equivalent yet: the `herdr:blocked` bus
7
+ // event. PiG's Go SDK has the `pi.events` bridge since 0.4.0 (Extension.Events),
8
+ // but the reporter does not listen to it yet. Its two cases are kept as skipped,
9
+ // named tests so the gap stays visible; the overlap counting the first one exercises
10
+ // is also covered with prompts.
11
+ //
12
+ // TestReviewAdditions holds cases the TypeScript suite did not have; each one
13
+ // fails on a mutation the ported cases let through.
14
+
15
+ import (
16
+ "math"
17
+ "os"
18
+ "path/filepath"
19
+ "slices"
20
+ "strings"
21
+ "testing"
22
+ "time"
23
+
24
+ herdr "github.com/MichaelKinsy/pigpen/components/herdr/extensions/herdr"
25
+ )
26
+
27
+ func start(t *testing.T, mode string) (*fakeHost, *fakeHerdr) {
28
+ t.Helper()
29
+ h := installFakeHerdr(t)
30
+ return startHost(t, herdr.Extension(), mode), h
31
+ }
32
+
33
+ func startTUI(t *testing.T) (*fakeHost, *fakeHerdr) {
34
+ t.Helper()
35
+ return start(t, "tui")
36
+ }
37
+
38
+ func startSession(t *testing.T) (*fakeHost, *fakeHerdr) {
39
+ t.Helper()
40
+ host, h := startTUI(t)
41
+ host.fire("session_start", map[string]any{"reason": "startup"})
42
+ return host, h
43
+ }
44
+
45
+ func eq(t *testing.T, got, want []string) {
46
+ t.Helper()
47
+ if strings.Join(got, "|") != strings.Join(want, "|") || len(got) != len(want) {
48
+ t.Fatalf("got %q, want %q", got, want)
49
+ }
50
+ }
51
+
52
+ func mustFlag(t *testing.T, c call, name string) string {
53
+ t.Helper()
54
+ v, ok := flag(c, name)
55
+ if !ok {
56
+ t.Fatalf("%s missing from %q", name, c.args)
57
+ }
58
+ return v
59
+ }
60
+
61
+ func absent(t *testing.T, c call, name string) {
62
+ t.Helper()
63
+ if v, ok := flag(c, name); ok {
64
+ t.Fatalf("%s = %q, want it omitted (%q)", name, v, c.args)
65
+ }
66
+ }
67
+
68
+ func TestOutsideHerdr(t *testing.T) {
69
+ for _, tc := range []struct {
70
+ name string
71
+ mutate func(t *testing.T)
72
+ }{
73
+ {"HERDR_ENV unset", func(t *testing.T) { unset(t, "HERDR_ENV") }},
74
+ {"HERDR_ENV is not 1", func(t *testing.T) { t.Setenv("HERDR_ENV", "0") }},
75
+ {"HERDR_PANE_ID unset", func(t *testing.T) { unset(t, "HERDR_PANE_ID") }},
76
+ {"HERDR_BIN_PATH unset", func(t *testing.T) { unset(t, "HERDR_BIN_PATH") }},
77
+ {"HERDR_BIN_PATH empty", func(t *testing.T) { t.Setenv("HERDR_BIN_PATH", "") }},
78
+ {"HERDR_SOCKET_PATH unset", func(t *testing.T) { unset(t, "HERDR_SOCKET_PATH") }},
79
+ {"HERDR_SOCKET_PATH empty", func(t *testing.T) { t.Setenv("HERDR_SOCKET_PATH", "") }},
80
+ } {
81
+ t.Run("registers nothing and calls nothing when "+tc.name, func(t *testing.T) {
82
+ h := installFakeHerdr(t)
83
+ tc.mutate(t)
84
+ host := startHost(t, herdr.Extension(), "tui")
85
+ if n := len(host.register.Handlers); n != 0 {
86
+ t.Fatalf("registered %d handlers outside herdr", n)
87
+ }
88
+ if len(host.register.Tools) != 0 || len(host.register.Commands) != 0 {
89
+ t.Fatalf("registered tools or commands outside herdr")
90
+ }
91
+ host.fire("session_start", map[string]any{"reason": "startup"})
92
+ host.fire("agent_start", nil)
93
+ settle()
94
+ if got := h.calls(); len(got) != 0 {
95
+ t.Fatalf("herdr called outside herdr: %v", got)
96
+ }
97
+ })
98
+ }
99
+ }
100
+
101
+ func TestHeadlessModes(t *testing.T) {
102
+ for _, mode := range []string{"rpc", "json", "print"} {
103
+ t.Run("reports nothing in "+mode+" mode", func(t *testing.T) {
104
+ host, h := start(t, mode)
105
+ host.fire("session_start", map[string]any{"reason": "startup"})
106
+ host.fire("agent_start", nil)
107
+ host.fire("ui_prompt_start", map[string]any{"kind": "confirm"})
108
+ settle()
109
+ if got := h.calls(); len(got) != 0 {
110
+ t.Fatalf("reported in %s mode: %v", mode, got)
111
+ }
112
+ })
113
+ }
114
+ }
115
+
116
+ func TestStateReports(t *testing.T) {
117
+ t.Run("reports idle, working, idle in order with the pig identity and an increasing seq", func(t *testing.T) {
118
+ host, h := startTUI(t)
119
+ host.fire("session_start", map[string]any{"reason": "startup"})
120
+ h.waitForCalls(1)
121
+ host.fire("agent_start", nil)
122
+ h.waitForCalls(2)
123
+ host.fire("agent_settled", nil)
124
+ all := h.waitForCalls(3)
125
+
126
+ eq(t, states(all), []string{"idle", "working", "idle"})
127
+ for _, c := range all {
128
+ eq(t, c.args[:3], []string{"pane", "report-agent", "p_7"})
129
+ if got := mustFlag(t, c, "--source"); got != "custom:pig" {
130
+ t.Fatalf("source = %q", got)
131
+ }
132
+ if got := mustFlag(t, c, "--agent"); got != "pig" {
133
+ t.Fatalf("agent = %q", got)
134
+ }
135
+ }
136
+ order := seqs(t, all)
137
+ for i, n := range order {
138
+ if n <= 0 || n > 1<<53-1 {
139
+ t.Fatalf("seq %d is not a safe positive integer", n)
140
+ }
141
+ if i > 0 && n <= order[i-1] {
142
+ t.Fatalf("seq not increasing: %v", order)
143
+ }
144
+ }
145
+ })
146
+
147
+ t.Run("does not repeat an unchanged state", func(t *testing.T) {
148
+ host, h := startSession(t)
149
+ host.fire("agent_settled", nil)
150
+ host.fire("agent_settled", nil)
151
+ settle()
152
+ eq(t, states(h.calls()), []string{"idle"})
153
+ })
154
+
155
+ t.Run("does not report idle while the agent is still running", func(t *testing.T) {
156
+ host, h := startSession(t)
157
+ host.fire("agent_start", nil)
158
+ h.waitForCalls(2)
159
+ host.setState(func(s *hostState) { s.idle = false })
160
+ host.fire("agent_settled", nil)
161
+ settle()
162
+ eq(t, states(h.calls()), []string{"idle", "working"})
163
+ })
164
+
165
+ t.Run("starts as working when a reload lands mid-turn", func(t *testing.T) {
166
+ host, h := startTUI(t)
167
+ host.setState(func(s *hostState) { s.idle = false })
168
+ host.fire("session_start", map[string]any{"reason": "reload"})
169
+ eq(t, states(h.waitForCalls(1)), []string{"working"})
170
+ })
171
+
172
+ t.Run("keeps seq increasing across reporter instances in one process", func(t *testing.T) {
173
+ host, h := startSession(t)
174
+ h.waitForCalls(1)
175
+ host.fire("session_shutdown", map[string]any{"reason": "new"})
176
+ second := startHost(t, herdr.Extension(), "tui")
177
+ second.fire("session_start", map[string]any{"reason": "new"})
178
+ all := h.waitForCalls(2)
179
+ order := seqs(t, all)
180
+ if order[1] <= order[0] {
181
+ t.Fatalf("seq did not increase across instances: %v", order)
182
+ }
183
+ })
184
+
185
+ t.Run("sends only the newest state when calls queue behind a slow herdr, still in order", func(t *testing.T) {
186
+ host, h := startTUI(t)
187
+ h.slow(300 * time.Millisecond)
188
+ host.fire("session_start", map[string]any{"reason": "startup"}) // in flight
189
+ host.fire("agent_start", nil) // queued, then replaced
190
+ host.fire("ui_prompt_start", map[string]any{"kind": "confirm", "title": "Run it?"}) // queued, then replaced
191
+ host.fire("ui_prompt_end", map[string]any{"kind": "confirm", "title": "Run it?"})
192
+ host.fire("agent_settled", nil) // the newest state
193
+ h.waitForCalls(2)
194
+ settle()
195
+ final := h.calls()
196
+ eq(t, states(final), []string{"idle", "idle"})
197
+ if len(final) != 2 {
198
+ t.Fatalf("intermediate states must be dropped, got %d calls", len(final))
199
+ }
200
+ order := seqs(t, final)
201
+ if order[1] <= order[0] {
202
+ t.Fatalf("seq not increasing: %v", order)
203
+ }
204
+ })
205
+
206
+ t.Run("survives a herdr that fails", func(t *testing.T) {
207
+ host, h := startTUI(t)
208
+ h.failWith(3)
209
+ host.fire("session_start", map[string]any{"reason": "startup"})
210
+ host.fire("agent_start", nil)
211
+ h.waitForCalls(1)
212
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
213
+ all := h.calls()
214
+ if got := all[len(all)-1].verb(); got != "release-agent" {
215
+ t.Fatalf("last call = %q, want release-agent", got)
216
+ }
217
+ })
218
+
219
+ t.Run("survives a herdr binary that does not exist", func(t *testing.T) {
220
+ installFakeHerdr(t)
221
+ t.Setenv("HERDR_BIN_PATH", filepath.Join(t.TempDir(), "missing"))
222
+ host := startHost(t, herdr.Extension(), "tui")
223
+ host.fire("session_start", map[string]any{"reason": "startup"})
224
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
225
+ })
226
+ }
227
+
228
+ // TestResumeCommand covers the published "Report the resume command" contract
229
+ // (https://herdr.dev/docs/add-herdr-support/): the command after `--`, next to
230
+ // --agent-session-id, on every state report; first word a plain command; no
231
+ // apostrophe or control character; at most 64 arguments and 8 KiB.
232
+ func TestResumeCommand(t *testing.T) {
233
+ t.Run("attaches the exact session file to every state report, after all options", func(t *testing.T) {
234
+ host, h := startSession(t)
235
+ h.waitForCalls(1)
236
+ host.fire("agent_start", nil)
237
+ h.waitForCalls(2)
238
+ host.fire("agent_settled", nil)
239
+ all := h.waitForCalls(3)
240
+ for _, c := range all {
241
+ argv, ok := c.resume()
242
+ if !ok {
243
+ t.Fatalf("no resume command in %q", c.args)
244
+ }
245
+ eq(t, argv, []string{"pig", "--session", "/home/u/.pig/sessions/s1.jsonl"})
246
+ if got := mustFlag(t, c, "--agent-session-id"); got != "s1" {
247
+ t.Fatalf("session id = %q", got)
248
+ }
249
+ // Everything before the separator is an option of report-agent, so the separator is last-but-the-command.
250
+ if i := len(c.args) - 4; c.args[i] != "--" {
251
+ t.Fatalf("`--` is not directly before the command: %q", c.args)
252
+ }
253
+ }
254
+ })
255
+
256
+ t.Run("does not use a model flag (herdr's own pi integration does not)", func(t *testing.T) {
257
+ host, h := startSession(t)
258
+ argv, _ := h.waitForCalls(1)[0].resume()
259
+ for _, a := range argv {
260
+ if strings.HasPrefix(a, "--model") || strings.HasPrefix(a, "-m") {
261
+ t.Fatalf("resume command carries a model flag: %q", argv)
262
+ }
263
+ }
264
+ _ = host
265
+ })
266
+
267
+ t.Run("follows a session replacement", func(t *testing.T) {
268
+ host, h := startSession(t)
269
+ h.waitForCalls(1)
270
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = "/x/s2.jsonl", "s2" })
271
+ host.fire("session_start", map[string]any{"reason": "new"})
272
+ argv, _ := h.waitForCalls(2)[1].resume()
273
+ eq(t, argv, []string{"pig", "--session", "/x/s2.jsonl"})
274
+ })
275
+
276
+ t.Run("a Windows session file is a plain argument", func(t *testing.T) {
277
+ host, h := startTUI(t)
278
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = `C:\Users\me\.pig\s3.jsonl`, "s3" })
279
+ host.fire("session_start", map[string]any{"reason": "startup"})
280
+ argv, _ := h.waitForCalls(1)[0].resume()
281
+ eq(t, argv, []string{"pig", "--session", `C:\Users\me\.pig\s3.jsonl`})
282
+ })
283
+
284
+ // `pig --no-session` has an id but no session file. `pig --session-id <id>`
285
+ // would not bring that session back: it creates a new, persisted session with
286
+ // the id, against the user's --no-session. So there is nothing to resume.
287
+ t.Run("an in-memory session (an id, no session file) reports no command", func(t *testing.T) {
288
+ host, h := startTUI(t)
289
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = "", "abc-123_x.y" })
290
+ host.fire("session_start", map[string]any{"reason": "startup"})
291
+ c := h.waitForCalls(1)[0]
292
+ if argv, ok := c.resume(); ok {
293
+ t.Fatalf("sent a resume command for an in-memory session: %q", argv)
294
+ }
295
+ if got := mustFlag(t, c, "--agent-session-id"); got != "abc-123_x.y" {
296
+ t.Fatalf("session id = %q", got)
297
+ }
298
+ })
299
+
300
+ for _, tc := range []struct {
301
+ name, file string
302
+ }{
303
+ {"an apostrophe", "/home/o'brien/.pig/sessions/s1.jsonl"},
304
+ {"a control character", "/home/u/.pig/sessions/s\x07.jsonl"},
305
+ {"a C1 control character", "/home/u/.pig/sessions/s\u0085.jsonl"},
306
+ {"a DEL", "/home/u/.pig/sessions/s\x7f.jsonl"},
307
+ {"more than 8 KiB", "/" + strings.Repeat("d", 9000) + "/s1.jsonl"},
308
+ } {
309
+ t.Run("falls back to --session-id when the session file has "+tc.name, func(t *testing.T) {
310
+ host, h := startTUI(t)
311
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = tc.file, "s1" })
312
+ host.fire("session_start", map[string]any{"reason": "startup"})
313
+ argv, ok := h.waitForCalls(1)[0].resume()
314
+ if !ok {
315
+ t.Fatal("no resume command")
316
+ }
317
+ eq(t, argv, []string{"pig", "--session-id", "s1"})
318
+ })
319
+ }
320
+
321
+ for _, tc := range []struct{ name, file, id string }{
322
+ {"nothing known", "", ""},
323
+ {"relative file, no id", "rel.jsonl", ""},
324
+ {"apostrophe file, id with a space", "/a/b'c.jsonl", "has space"},
325
+ {"apostrophe file, id with an apostrophe", "/a/b'c.jsonl", "it's"},
326
+ {"no file, id that starts with a dash", "", "-rf"},
327
+ {"no file, id with a control character", "", "a\x01b"},
328
+ } {
329
+ t.Run("sends no command when neither the file nor the id can be used: "+tc.name, func(t *testing.T) {
330
+ host, h := startTUI(t)
331
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = tc.file, tc.id })
332
+ host.fire("session_start", map[string]any{"reason": "startup"})
333
+ c := h.waitForCalls(1)[0]
334
+ if argv, ok := c.resume(); ok {
335
+ t.Fatalf("sent a resume command %q", argv)
336
+ }
337
+ mustFlag(t, c, "--state")
338
+ })
339
+ }
340
+
341
+ t.Run("never sends a resume command with the release", func(t *testing.T) {
342
+ host, h := startSession(t)
343
+ h.waitForCalls(1)
344
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
345
+ all := h.waitForCalls(2)
346
+ if _, ok := all[1].resume(); ok || all[1].verb() != "release-agent" {
347
+ t.Fatalf("release carries a resume command or is not last: %q", all[1].args)
348
+ }
349
+ })
350
+
351
+ t.Run("herdr before 0.9.2 rejects `--`: retries the same report once without it, then stops sending it", func(t *testing.T) {
352
+ host, h := startTUI(t)
353
+ h.refuseResume("old")
354
+ host.fire("session_start", map[string]any{"reason": "startup"})
355
+ all := h.waitForCalls(2)
356
+ if _, ok := all[0].resume(); !ok {
357
+ t.Fatalf("first attempt has no resume command: %q", all[0].args)
358
+ }
359
+ if _, ok := all[1].resume(); ok {
360
+ t.Fatalf("retry still has the resume command: %q", all[1].args)
361
+ }
362
+ eq(t, states(all), []string{"idle", "idle"})
363
+ if a, b := mustFlag(t, all[0], "--seq"), mustFlag(t, all[1], "--seq"); a != b {
364
+ t.Fatalf("retry seq %s != first seq %s (the refused report was not applied)", b, a)
365
+ }
366
+ if got := mustFlag(t, all[1], "--agent-session-id"); got != "s1" {
367
+ t.Fatalf("retry lost the session id: %q", all[1].args)
368
+ }
369
+ host.fire("agent_start", nil)
370
+ h.waitForCalls(3)
371
+ host.fire("agent_settled", nil)
372
+ h.waitForCalls(4)
373
+ settle()
374
+ all = h.calls()
375
+ if len(all) != 4 {
376
+ t.Fatalf("expected exactly 4 calls (no further retries): %v", all)
377
+ }
378
+ for _, c := range all[2:] {
379
+ if _, ok := c.resume(); ok {
380
+ t.Fatalf("a later report still carries the resume command: %q", c.args)
381
+ }
382
+ }
383
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
384
+ rel := h.waitForCalls(5)[4]
385
+ if rel.verb() != "release-agent" {
386
+ t.Fatalf("calls = %v", verbs(h.calls()))
387
+ }
388
+ })
389
+
390
+ t.Run("invalid_resume_argv from herdr 0.9.2+ gets the same one silent fallback", func(t *testing.T) {
391
+ host, h := startTUI(t)
392
+ h.refuseResume("invalid")
393
+ host.fire("session_start", map[string]any{"reason": "startup"})
394
+ all := h.waitForCalls(2)
395
+ if _, ok := all[1].resume(); ok {
396
+ t.Fatalf("retry still has the resume command: %q", all[1].args)
397
+ }
398
+ host.fire("agent_start", nil)
399
+ h.waitForCalls(3)
400
+ settle()
401
+ all = h.calls()
402
+ if len(all) != 3 {
403
+ t.Fatalf("expected 3 calls: %v", all)
404
+ }
405
+ if _, ok := all[2].resume(); ok {
406
+ t.Fatalf("later report carries the refused command: %q", all[2].args)
407
+ }
408
+ })
409
+
410
+ t.Run("an unknown-option failure that is not about `--` is not a verdict on resume", func(t *testing.T) {
411
+ host, h := startTUI(t)
412
+ h.refuseResume("message-option") // herdr before 0.9.0 rejecting an attached --message=value
413
+ host.fire("session_start", map[string]any{"reason": "startup"})
414
+ h.waitForCalls(1)
415
+ settle()
416
+ if n := len(h.calls()); n != 1 {
417
+ t.Fatalf("retried: %d calls", n)
418
+ }
419
+ host.fire("agent_start", nil)
420
+ c := h.waitForCalls(2)[1]
421
+ if _, ok := c.resume(); !ok {
422
+ t.Fatalf("gave up on resume: %q", c.args)
423
+ }
424
+ })
425
+
426
+ t.Run("an unrelated failure neither retries nor gives up on resume", func(t *testing.T) {
427
+ host, h := startTUI(t)
428
+ h.refuseResume("broken")
429
+ host.fire("session_start", map[string]any{"reason": "startup"})
430
+ h.waitForCalls(1)
431
+ settle()
432
+ if n := len(h.calls()); n != 1 {
433
+ t.Fatalf("retried an unrelated failure: %d calls", n)
434
+ }
435
+ host.fire("agent_start", nil)
436
+ c := h.waitForCalls(2)[1]
437
+ if _, ok := c.resume(); !ok {
438
+ t.Fatalf("stopped sending the resume command after an unrelated failure: %q", c.args)
439
+ }
440
+ })
441
+
442
+ t.Run("a hung herdr is not retried and the timeout still ends the call", func(t *testing.T) {
443
+ host, h := startTUI(t)
444
+ h.hang()
445
+ host.fire("session_start", map[string]any{"reason": "startup"})
446
+ begin := time.Now()
447
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
448
+ if took := time.Since(begin); took > 8*time.Second {
449
+ t.Fatalf("quit took %v behind a hung herdr", took)
450
+ }
451
+ for _, c := range h.calls() {
452
+ if c.verb() == "report-agent" {
453
+ t.Fatalf("a hung report was logged: %q", c.args)
454
+ }
455
+ }
456
+ })
457
+ }
458
+
459
+ func TestBlocked(t *testing.T) {
460
+ t.Run("reports blocked with the prompt title while a confirmation is open, then resumes", func(t *testing.T) {
461
+ host, h := startSession(t)
462
+ host.fire("agent_start", nil)
463
+ h.waitForCalls(2)
464
+ prompt := map[string]any{"reason": "ui_prompt", "kind": "confirm", "title": "Allow rm -rf build?"}
465
+ host.fire("ui_prompt_start", prompt)
466
+ blocked := h.waitForCalls(3)
467
+ if got := mustFlag(t, blocked[2], "--state"); got != "blocked" {
468
+ t.Fatalf("state = %q", got)
469
+ }
470
+ if got := mustFlag(t, blocked[2], "--message"); got != "Allow rm -rf build?" {
471
+ t.Fatalf("message = %q", got)
472
+ }
473
+
474
+ host.fire("ui_prompt_end", prompt)
475
+ resumed := h.waitForCalls(4)
476
+ if got := mustFlag(t, resumed[3], "--state"); got != "working" {
477
+ t.Fatalf("state = %q", got)
478
+ }
479
+ absent(t, resumed[3], "--message")
480
+ })
481
+
482
+ t.Run("reports blocked for an untitled custom prompt using its kind and returns to idle", func(t *testing.T) {
483
+ host, h := startSession(t)
484
+ host.fire("ui_prompt_start", map[string]any{"kind": "custom"})
485
+ all := h.waitForCalls(2)
486
+ if got := mustFlag(t, all[1], "--message"); got != "custom" {
487
+ t.Fatalf("message = %q", got)
488
+ }
489
+ host.fire("ui_prompt_end", map[string]any{"kind": "custom"})
490
+ eq(t, states(h.waitForCalls(3)), []string{"idle", "blocked", "idle"})
491
+ })
492
+
493
+ t.Run("sends an ordinary message as two arguments, the only form herdr before 0.9.0 parses", func(t *testing.T) {
494
+ host, h := startSession(t)
495
+ host.fire("ui_prompt_start", map[string]any{"kind": "confirm", "title": "Apply the plan?"})
496
+ all := h.waitForCalls(2)
497
+ i := -1
498
+ for n, arg := range all[1].args {
499
+ if strings.HasPrefix(arg, "--message=") {
500
+ t.Fatalf("attached form %q is unknown to herdr before 0.9.0: %q", arg, all[1].args)
501
+ }
502
+ if arg == "--message" {
503
+ i = n
504
+ }
505
+ }
506
+ if i < 0 || all[1].args[i+1] != "Apply the plan?" {
507
+ t.Fatalf("no --message <value> pair in %q", all[1].args)
508
+ }
509
+ })
510
+
511
+ // Every herdr CLI (0.6 to 0.9.3) takes the argument after --message as its
512
+ // value whatever it starts with, but herdr before 0.9.0 refuses the attached
513
+ // --message=<text> form ("unknown option") and drops the whole report.
514
+ for _, title := range []string{"--force?", "-n: dry run first?", "-"} {
515
+ t.Run("sends a message that starts with a dash as --message <text>, which every herdr parses: "+title, func(t *testing.T) {
516
+ host, h := startSession(t)
517
+ host.fire("ui_prompt_start", map[string]any{"kind": "input", "title": title})
518
+ all := h.waitForCalls(2)
519
+ for n, arg := range all[1].args {
520
+ if strings.HasPrefix(arg, "--message=") {
521
+ t.Fatalf("attached form %q is unknown to herdr before 0.9.0: %q", arg, all[1].args)
522
+ }
523
+ if arg == "--message" && n+1 < len(all[1].args) && all[1].args[n+1] == title {
524
+ return
525
+ }
526
+ }
527
+ t.Fatalf("no --message %q pair in %q", title, all[1].args)
528
+ })
529
+ }
530
+
531
+ t.Run("sends a message that is exactly -- attached, so herdr 0.9.2+ cannot take it for the resume separator", func(t *testing.T) {
532
+ host, h := startSession(t)
533
+ host.fire("ui_prompt_start", map[string]any{"kind": "input", "title": "--"})
534
+ c := h.waitForCalls(2)[1]
535
+ if !slices.Contains(c.args, "--message=--") {
536
+ t.Fatalf("no --message=-- in %q", c.args)
537
+ }
538
+ // herdr splits at the first `--`: it must be the separator before the command.
539
+ argv, _ := c.resume()
540
+ eq(t, argv, []string{"pig", "--session", "/home/u/.pig/sessions/s1.jsonl"})
541
+ })
542
+
543
+ t.Run("shortens a long multi-line title to one line", func(t *testing.T) {
544
+ host, h := startSession(t)
545
+ host.fire("ui_prompt_start", map[string]any{"kind": "editor", "title": "first line\n" + strings.Repeat("x", 400)})
546
+ message := mustFlag(t, h.waitForCalls(2)[1], "--message")
547
+ if strings.Contains(message, "\n") {
548
+ t.Fatalf("message has a newline: %q", message)
549
+ }
550
+ if n := len([]rune(message)); n > 120 {
551
+ t.Fatalf("message is %d characters, want at most 120", n)
552
+ }
553
+ if !strings.HasPrefix(message, "first line x") {
554
+ t.Fatalf("message = %q", message)
555
+ }
556
+ })
557
+
558
+ t.Run("stays blocked until every overlapping block ends", func(t *testing.T) {
559
+ // TypeScript overlaps a prompt with a `herdr:blocked` bus block. The Go SDK
560
+ // has no bus, so two overlapping prompts exercise the same counting.
561
+ host, h := startSession(t)
562
+ host.fire("ui_prompt_start", map[string]any{"kind": "confirm", "title": "A"})
563
+ h.waitForCalls(2)
564
+ host.fire("ui_prompt_start", map[string]any{"kind": "select", "title": "approval"})
565
+ h.waitForCalls(3)
566
+ host.fire("ui_prompt_end", map[string]any{"kind": "confirm"})
567
+ settle()
568
+ eq(t, states(h.calls()), []string{"idle", "blocked", "blocked"})
569
+ host.fire("ui_prompt_end", map[string]any{"kind": "select"})
570
+ all := h.waitForCalls(4)
571
+ eq(t, states(all), []string{"idle", "blocked", "blocked", "idle"})
572
+ if got := mustFlag(t, all[2], "--message"); got != "approval" {
573
+ t.Fatalf("message = %q", got)
574
+ }
575
+ })
576
+
577
+ t.Run("ignores an unmatched prompt end", func(t *testing.T) {
578
+ host, h := startSession(t)
579
+ host.fire("ui_prompt_end", map[string]any{"kind": "confirm"})
580
+ settle()
581
+ eq(t, states(h.calls()), []string{"idle"})
582
+ })
583
+
584
+ t.Run("herdr:blocked bus block overlaps a prompt (not yet ported: no bus listener)", func(t *testing.T) {
585
+ t.Skip("not yet ported: the reporter does not listen to herdr:blocked on pi.events (the bridge is in the Go SDK since PiG 0.4.0, Extension.Events)")
586
+ })
587
+ }
588
+
589
+ func TestSession(t *testing.T) {
590
+ t.Run("reports the session file and id with every state", func(t *testing.T) {
591
+ host, h := startSession(t)
592
+ host.fire("agent_start", nil)
593
+ for _, c := range h.waitForCalls(2) {
594
+ if got := mustFlag(t, c, "--agent-session-path"); got != "/home/u/.pig/sessions/s1.jsonl" {
595
+ t.Fatalf("session path = %q", got)
596
+ }
597
+ if got := mustFlag(t, c, "--agent-session-id"); got != "s1" {
598
+ t.Fatalf("session id = %q", got)
599
+ }
600
+ }
601
+ })
602
+
603
+ t.Run("follows a session replacement", func(t *testing.T) {
604
+ host, h := startSession(t)
605
+ h.waitForCalls(1)
606
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = "/x/s2.jsonl", "s2" })
607
+ host.fire("session_start", map[string]any{"reason": "new"})
608
+ all := h.waitForCalls(2)
609
+ if got := mustFlag(t, all[1], "--agent-session-path"); got != "/x/s2.jsonl" {
610
+ t.Fatalf("session path = %q", got)
611
+ }
612
+ if got := mustFlag(t, all[1], "--agent-session-id"); got != "s2" {
613
+ t.Fatalf("session id = %q", got)
614
+ }
615
+ })
616
+
617
+ t.Run("omits a relative session path and an unavailable session", func(t *testing.T) {
618
+ host, h := startTUI(t)
619
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = "relative.jsonl", "" })
620
+ host.fire("session_start", map[string]any{"reason": "startup"})
621
+ first := h.waitForCalls(1)[0]
622
+ absent(t, first, "--agent-session-path")
623
+ absent(t, first, "--agent-session-id")
624
+ })
625
+
626
+ t.Run("tolerates a session manager that throws", func(t *testing.T) {
627
+ host, h := startTUI(t)
628
+ host.setState(func(s *hostState) { s.sessionFailure = true })
629
+ host.fire("session_start", map[string]any{"reason": "startup"})
630
+ eq(t, states(h.waitForCalls(1)), []string{"idle"})
631
+ })
632
+ }
633
+
634
+ func TestRelease(t *testing.T) {
635
+ t.Run("releases the pane on quit as the last call, with a higher seq", func(t *testing.T) {
636
+ host, h := startSession(t)
637
+ host.fire("agent_start", nil)
638
+ h.waitForCalls(2)
639
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
640
+ all := h.calls()
641
+ release := all[len(all)-1]
642
+ eq(t, release.args[:3], []string{"pane", "release-agent", "p_7"})
643
+ if got := mustFlag(t, release, "--source"); got != "custom:pig" {
644
+ t.Fatalf("source = %q", got)
645
+ }
646
+ if got := mustFlag(t, release, "--agent"); got != "pig" {
647
+ t.Fatalf("agent = %q", got)
648
+ }
649
+ order := seqs(t, all)
650
+ var most int64 = math.MinInt64
651
+ for _, n := range order[:len(order)-1] {
652
+ most = max(most, n)
653
+ }
654
+ if order[len(order)-1] <= most {
655
+ t.Fatalf("release seq %d is not above the reports %v", order[len(order)-1], order)
656
+ }
657
+ })
658
+
659
+ t.Run("waits for an in-flight report and drops a queued one so the release is last", func(t *testing.T) {
660
+ host, h := startTUI(t)
661
+ h.slow(300 * time.Millisecond)
662
+ host.fire("session_start", map[string]any{"reason": "startup"}) // in flight
663
+ host.fire("agent_start", nil) // queued; must be dropped
664
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
665
+ eq(t, verbs(h.calls()), []string{"report-agent", "release-agent"})
666
+ })
667
+
668
+ t.Run("sends no report after the release", func(t *testing.T) {
669
+ host, h := startSession(t)
670
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
671
+ host.fire("agent_start", nil)
672
+ host.fire("agent_settled", nil)
673
+ settle()
674
+ eq(t, verbs(h.calls()), []string{"report-agent", "release-agent"})
675
+ })
676
+
677
+ for _, reason := range []string{"reload", "new", "resume", "fork"} {
678
+ t.Run("does not release on "+reason+", and goes quiet so it cannot race its successor", func(t *testing.T) {
679
+ host, h := startSession(t)
680
+ h.waitForCalls(1)
681
+ host.fire("session_shutdown", map[string]any{"reason": reason})
682
+ host.fire("agent_start", nil)
683
+ settle()
684
+ eq(t, verbs(h.calls()), []string{"report-agent"})
685
+ })
686
+ }
687
+
688
+ t.Run("stops listening for herdr:blocked on shutdown (not yet ported: no bus listener)", func(t *testing.T) {
689
+ t.Skip("not yet ported: the reporter has no herdr:blocked bus listener to unsubscribe (the bridge is in the Go SDK since PiG 0.4.0, Extension.Events)")
690
+ })
691
+
692
+ t.Run("does not release a pane it never reported on", func(t *testing.T) {
693
+ host, h := start(t, "print")
694
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
695
+ settle()
696
+ if got := h.calls(); len(got) != 0 {
697
+ t.Fatalf("released a pane it never reported on: %v", got)
698
+ }
699
+ })
700
+ }
701
+
702
+ func TestReviewAdditions(t *testing.T) {
703
+ t.Run("reports again after a session replacement in the same extension instance", func(t *testing.T) {
704
+ // A host may keep the extension alive across /new, /resume, /fork and
705
+ // /reload: the shutdown silences it, and the next session_start must
706
+ // open a fresh reporting epoch.
707
+ host, h := startSession(t)
708
+ h.waitForCalls(1)
709
+ host.fire("session_shutdown", map[string]any{"reason": "new"})
710
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = "/x/s2.jsonl", "s2" })
711
+ host.fire("session_start", map[string]any{"reason": "new"})
712
+ all := h.waitForCalls(2)
713
+ if got := mustFlag(t, all[1], "--agent-session-id"); got != "s2" {
714
+ t.Fatalf("session id = %q", got)
715
+ }
716
+ host.fire("agent_start", nil)
717
+ eq(t, states(h.waitForCalls(3)), []string{"idle", "idle", "working"})
718
+ })
719
+
720
+ t.Run("an unmatched prompt end does not hide the next prompt", func(t *testing.T) {
721
+ host, h := startSession(t)
722
+ host.fire("ui_prompt_end", map[string]any{"kind": "confirm"})
723
+ host.fire("ui_prompt_start", map[string]any{"kind": "confirm", "title": "Run it?"})
724
+ all := h.waitForCalls(2)
725
+ eq(t, states(all), []string{"idle", "blocked"})
726
+ })
727
+
728
+ t.Run("seeds seq from the clock so it keeps increasing across restarts", func(t *testing.T) {
729
+ // herdr keeps the highest seq per source across agent restarts; a
730
+ // restarted process must not start below it.
731
+ before := time.Now().UnixMicro()
732
+ _, h := startSession(t)
733
+ if got := seqs(t, h.waitForCalls(1))[0]; got < before {
734
+ t.Fatalf("seq %d is below the clock %d (microseconds)", got, before)
735
+ }
736
+ })
737
+
738
+ t.Run("refreshes the session identity when a turn starts", func(t *testing.T) {
739
+ host, h := startSession(t)
740
+ h.waitForCalls(1)
741
+ host.setState(func(s *hostState) { s.sessionFile, s.sessionID = "/x/s3.jsonl", "s3" })
742
+ host.fire("agent_start", nil)
743
+ working := h.waitForCalls(2)[1]
744
+ if got := mustFlag(t, working, "--agent-session-path"); got != "/x/s3.jsonl" {
745
+ t.Fatalf("session path = %q", got)
746
+ }
747
+ })
748
+
749
+ t.Run("bounds a hung herdr call so quit still releases", func(t *testing.T) {
750
+ // Inherently about 3 s: the bound under test is the 3 s call timeout.
751
+ host, h := startTUI(t)
752
+ h.hang()
753
+ host.fire("session_start", map[string]any{"reason": "startup"}) // hangs until killed
754
+ begin := time.Now()
755
+ host.fire("session_shutdown", map[string]any{"reason": "quit"})
756
+ if took := time.Since(begin); took > 8*time.Second {
757
+ t.Fatalf("quit took %v behind a hung herdr", took)
758
+ }
759
+ all := h.calls()
760
+ if len(all) == 0 || all[len(all)-1].verb() != "release-agent" {
761
+ t.Fatalf("calls = %v, want the release last", all)
762
+ }
763
+ })
764
+ }
765
+
766
+ func unset(t *testing.T, name string) {
767
+ t.Helper()
768
+ t.Setenv(name, "") // registers the restore
769
+ if err := os.Unsetenv(name); err != nil {
770
+ t.Fatal(err)
771
+ }
772
+ }