AtlatestRepositoryapiary

apiary / tree / testtest-tools.sgl

1(import (sigil test)
2 (sigil string)
3 (sigil dotenv)
4 (sigil process)
5 (sigil mcp server)
6 (sigil mcp protocol)
7 (apiary markdown-irc)
8 ;; for `enclave-config`, which `enclave-bridge-run!` takes
9 (apiary enclave)
10 (apiary tools))
12;; A literal backslash-n as it actually arrives from an LLM caller:
13;; the two characters #\\ and #\n, NOT a real newline.
14(define lit-nl (string #\\ #\n))
16;; Drive a tools/list JSON-RPC request through the server and read
17;; the entries the response advertises. This is the same path the
18;; MCP client hits during init — proves clients see the full tool
19;; surface even when the bridge hasn't connected.
20(define (advertised-tool-names server)
21 (let* ((req (jsonrpc-request id: 1 method: "tools/list" params: '()))
22 (resp (mcp-server-handle-message server req))
23 (result (jsonrpc-response-result resp))
24 (tools (or (assoc-ref 'tools result) '())))
25 (map (lambda (t) (assoc-ref 'name t)) tools)))
27;; ============================================================
28;; Bridge state defaults
29;; ============================================================
31(test-group "enclave-bridge-state"
32 (test "defaults"
33 (let ((s (make-enclave-bridge-state)))
34 (assert-false (enclave-bridge-state-conn s))
35 (assert-false (enclave-bridge-state-config s))
36 (assert-equal 'leader (enclave-bridge-state-mode s))
37 (assert-false (enclave-bridge-state-owner-nick s))
38 (assert-false (enclave-bridge-state-reports-to s))
39 (assert-equal '() (enclave-bridge-state-subordinates s))
40 (assert-equal '() (enclave-bridge-state-listened-peers s)))))
42;; ============================================================
43;; Mention syntax parser
44;; ============================================================
46(test-group "parse-mention-prefix"
47 (test "extracts nick from @-prefixed mention"
48 (call-with-values
49 (lambda () (parse-mention-prefix "@alice: hello there"))
50 (lambda (addressee body)
51 (assert-equal "alice" addressee)
52 (assert-equal "hello there" body))))
54 (test "tolerates dashes and underscores in nick"
55 (call-with-values
56 (lambda () (parse-mention-prefix "@worker-bot_3: ack"))
57 (lambda (addressee body)
58 (assert-equal "worker-bot_3" addressee)
59 (assert-equal "ack" body))))
61 (test "rejects bare nick:body when my-nick not provided (legacy mode)"
62 ;; Without my-nick context, only the legacy @-prefixed form is
63 ;; recognized — bare nick:body falls through to broadcast.
64 (call-with-values
65 (lambda () (parse-mention-prefix "alice: hello"))
66 (lambda (addressee body)
67 (assert-false addressee)
68 (assert-equal "alice: hello" body))))
70 (test "rejects unprefixed text"
71 (call-with-values
72 (lambda () (parse-mention-prefix "no prefix here"))
73 (lambda (addressee body)
74 (assert-false addressee)
75 (assert-equal "no prefix here" body))))
77 (test "rejects @ alone"
78 (call-with-values
79 (lambda () (parse-mention-prefix "@"))
80 (lambda (addressee body)
81 (assert-false addressee)
82 (assert-equal "@" body))))
84 (test "rejects @: with no nick"
85 (call-with-values
86 (lambda () (parse-mention-prefix "@: empty"))
87 (lambda (addressee body)
88 (assert-false addressee)
89 (assert-equal "@: empty" body))))
91 (test "rejects colon without trailing space"
92 (call-with-values
93 (lambda () (parse-mention-prefix "@alice:no-space"))
94 (lambda (addressee body)
95 (assert-false addressee)
96 (assert-equal "@alice:no-space" body))))
98 (test "rejects nick with invalid chars"
99 (call-with-values
100 (lambda () (parse-mention-prefix "@[admin]: text"))
101 (lambda (addressee body)
102 (assert-false addressee)
103 (assert-equal "@[admin]: text" body))))
105 (test "tolerates colons in body (no ambiguity)"
106 (call-with-values
107 (lambda () (parse-mention-prefix "@bee-3: STARTED: phase 1"))
108 (lambda (addressee body)
109 (assert-equal "bee-3" addressee)
110 (assert-equal "STARTED: phase 1" body))))
112 (test "handles non-string input"
113 (call-with-values
114 (lambda () (parse-mention-prefix #f))
115 (lambda (addressee body)
116 (assert-false addressee)
117 (assert-false body)))))
119;; ============================================================
120;; IRC-native bare mention parsing (v0.1.6+)
121;;
122;; With my-nick passed, parse-mention-prefix recognizes the IRC-native
123;; bare `<my-nick>: <body>` form. Tokens that don't match my-nick fall
124;; through to broadcast — apiary doesn't track channel membership, so
125;; the my-nick gate is the load-bearing safety net against
126;; misclassifying prose like "note: write this down" as a mention.
127;; ============================================================
129(test-group "parse-mention-prefix (IRC-native bare form)"
130 (test "bare nick:body is mention when nick equals my-nick"
131 (call-with-values
132 (lambda () (parse-mention-prefix "quinn: ping" "quinn"))
133 (lambda (addressee body)
134 (assert-equal "quinn" addressee)
135 (assert-equal "ping" body))))
137 (test "bare match is case-insensitive"
138 (call-with-values
139 (lambda () (parse-mention-prefix "Quinn: PING" "quinn"))
140 (lambda (addressee body)
141 (assert-equal "quinn" addressee)
142 (assert-equal "PING" body))))
144 (test "bare nick:body is broadcast when nick != my-nick"
145 (call-with-values
146 (lambda () (parse-mention-prefix "alice: hello" "quinn"))
147 (lambda (addressee body)
148 (assert-false addressee)
149 (assert-equal "alice: hello" body))))
151 (test "broadcast: prose-shaped nick:body (not actually a mention)"
152 (call-with-values
153 (lambda () (parse-mention-prefix "note: write this down" "quinn"))
154 (lambda (addressee body)
155 (assert-false addressee)
156 (assert-equal "note: write this down" body))))
158 (test "broadcast: token has whitespace (foo bar: ...)"
159 (call-with-values
160 (lambda () (parse-mention-prefix "foo bar: hello" "foo"))
161 (lambda (addressee body)
162 (assert-false addressee)
163 (assert-equal "foo bar: hello" body))))
165 (test "broadcast: no space after colon (key:value)"
166 (call-with-values
167 (lambda () (parse-mention-prefix "quinn:value" "quinn"))
168 (lambda (addressee body)
169 (assert-false addressee)
170 (assert-equal "quinn:value" body))))
172 (test "broadcast: mid-line nick: pattern is not a mention"
173 (call-with-values
174 (lambda () (parse-mention-prefix "Hello, quinn: how are you?" "quinn"))
175 (lambda (addressee body)
176 (assert-false addressee)
177 (assert-equal "Hello, quinn: how are you?" body))))
179 (test "legacy @<nick>: form still parsed for any nick (deprecation)"
180 ;; During the v0.2.x deprecation window, legacy @-prefixed mentions
181 ;; continue to parse for any token regardless of my-nick. Removed
182 ;; in v0.3.0 cleanup.
183 (call-with-values
184 (lambda () (parse-mention-prefix "@bee-3: ack" "quinn"))
185 (lambda (addressee body)
186 (assert-equal "bee-3" addressee)
187 (assert-equal "ack" body))))
189 (test "bare body may be empty"
190 (call-with-values
191 (lambda () (parse-mention-prefix "quinn: " "quinn"))
192 (lambda (addressee body)
193 (assert-equal "quinn" addressee)
194 (assert-equal "" body)))))
196;; ============================================================
197;; Mention formatter (v0.1.6: bare nick: form, no @ prefix)
198;; ============================================================
200(test-group "format-mention"
201 (test "wraps body with bare <nick>: prefix (no @)"
202 (assert-equal "bee-3: please proceed"
203 (format-mention "bee-3" "please proceed")))
205 (test "round-trips through parse-mention-prefix when my-nick matches"
206 (call-with-values
207 (lambda () (parse-mention-prefix
208 (format-mention "worker-7" "ack")
209 "worker-7"))
210 (lambda (addressee body)
211 (assert-equal "worker-7" addressee)
212 (assert-equal "ack" body)))))
214;; ============================================================
215;; Literal backslash-n normalization (outbound newline fix)
216;;
217;; LLM callers frequently pass a literal "\n" (two chars: #\\ #\n)
218;; where they mean a line break. Without normalization the literal
219;; never triggers the per-line split, and — on the DM path — the
220;; markdown->mIRC escape pass then strips the backslash, leaving a
221;; stray "n" where the break belonged (observed 2026-07-08:
222;; "...marketing.nnTrashed..."). normalize-literal-newlines runs at
223;; both outbound entry points BEFORE any markdown translation or line
224;; split, so send-message (DM) and send-channel both benefit.
225;; ============================================================
227(test-group "normalize-literal-newlines"
228 (test "converts a literal backslash-n to a real newline"
229 (assert-equal "a\nb"
230 (normalize-literal-newlines (string-append "a" lit-nl "b"))))
232 (test "a real newline is left untouched (no double-conversion)"
233 (assert-equal "a\nb"
234 (normalize-literal-newlines "a\nb")))
236 (test "converts every literal occurrence"
237 (assert-equal "one\ntwo\nthree"
238 (normalize-literal-newlines
239 (string-append "one" lit-nl "two" lit-nl "three"))))
241 (test "no literal present: string returned unchanged"
242 (assert-equal "no breaks here"
243 (normalize-literal-newlines "no breaks here")))
245 (test "a lone backslash (not followed by n) is preserved"
246 (assert-equal (string #\\ #\x)
247 (normalize-literal-newlines (string #\\ #\x))))
249 (test "non-string input passes through"
250 (assert-false (normalize-literal-newlines #f)))
252 ;; End-to-end shape checks — these mirror what the two outbound
253 ;; helpers do to the text before handing it to enclave-post-multiline.
255 (test "send-channel path: normalized literal splits into multiple lines"
256 ;; enclave-bridge-send-channel! normalizes, then splits on real "\n".
257 (let* ((raw (string-append "first" lit-nl "second"))
258 (lines (string-split (normalize-literal-newlines raw) "\n")))
259 (assert-equal 2 (length lines))
260 (assert-equal "first" (car lines))
261 (assert-equal "second" (cadr lines))))
263 (test "send-message (DM) path: normalize before markdown, then split"
264 ;; enclave-bridge-send-dm! normalizes, runs markdown->irc, then
265 ;; splits. Before the fix the markdown escape pass ate the
266 ;; backslash and the whole message stayed a single "firstnsecond"
267 ;; line. After it, two lines survive.
268 (let* ((raw (string-append "first" lit-nl "second"))
269 (rendered (markdown->irc (normalize-literal-newlines raw)))
270 (lines (string-split rendered "\n")))
271 (assert-equal 2 (length lines))
272 (assert-equal "first" (car lines))
273 (assert-equal "second" (cadr lines))))
275 (test "regression: without normalization the DM path collapses to one line"
276 ;; Documents the bug: feeding the raw literal straight to
277 ;; markdown->irc yields a single line whose break became a bare "n".
278 (let* ((raw (string-append "first" lit-nl "second"))
279 (rendered (markdown->irc raw))
280 (lines (string-split rendered "\n")))
281 (assert-equal 1 (length lines))
282 (assert-equal "firstnsecond" (car lines)))))
284;; ============================================================
285;; Trusted-set predicate
286;; ============================================================
288;; ============================================================
289;; register-apiary-tools! — capability declaration
290;;
291;; Registration is gated on APIARY_MODE alone, not on
292;; enclave-config-ready or on a live bridge connection. The MCP
293;; tools/list response at init must expose the full mode-appropriate
294;; tool set so clients with `tools.listChanged: false` see the right
295;; surface from the first reply. Tool *calls* that need a live
296;; bridge handle that runtime concern themselves.
297;; ============================================================
299(test-group "register-apiary-tools! mode-driven registration"
300 (test "leader mode registers all 9 tools, no live bridge required"
301 (let* ((server (mcp-server name: "x" version: "1.0"))
302 (state (make-enclave-bridge-state)))
303 (set-enclave-bridge-state-mode! state 'leader)
304 (register-apiary-tools! server state)
305 (let ((tool-names (advertised-tool-names server)))
306 (assert-equal 9 (length tool-names))
307 (assert-true (member "spawn-worker" tool-names))
308 (assert-true (member "revoke-worker" tool-names))
309 (assert-true (member "rotate-token" tool-names))
310 (assert-true (member "list-members" tool-names))
311 (assert-true (member "send-channel" tool-names))
312 (assert-true (member "send-message" tool-names))
313 (assert-true (member "send-react" tool-names))
314 (assert-true (member "listen-peer" tool-names))
315 (assert-true (member "unlisten-peer" tool-names)))))
317 (test "worker mode registers only the 4 worker tools"
318 (let* ((server (mcp-server name: "x" version: "1.0"))
319 (state (make-enclave-bridge-state)))
320 (set-enclave-bridge-state-mode! state 'worker)
321 (register-apiary-tools! server state)
322 (let ((tool-names (advertised-tool-names server)))
323 (assert-equal 4 (length tool-names))
324 (assert-true (member "send-channel" tool-names))
325 (assert-true (member "send-react" tool-names))
326 (assert-true (member "listen-peer" tool-names))
327 (assert-true (member "unlisten-peer" tool-names))
328 ;; Leader-only tools must NOT appear in worker mode.
329 (assert-false (member "spawn-worker" tool-names))
330 (assert-false (member "send-message" tool-names))
331 (assert-false (member "rotate-token" tool-names)))))
333 (test "registration is independent of enclave-config / bridge state"
334 ;; No config set, no conn — registration should still succeed
335 ;; and the full leader tool set should appear.
336 (let* ((server (mcp-server name: "x" version: "1.0"))
337 (state (make-enclave-bridge-state)))
338 (set-enclave-bridge-state-mode! state 'leader)
339 (assert-false (enclave-bridge-state-config state))
340 (assert-false (enclave-bridge-state-conn state))
341 (register-apiary-tools! server state)
342 (assert-equal 9 (length (advertised-tool-names server))))))
344(test-group "tool-call error path before bridge connects"
345 (test "send-channel returns informative error when bridge isn't connected"
346 (let ((state (make-enclave-bridge-state)))
347 ;; No conn set — direct call into the helper exercises the
348 ;; same path the registered tool's lambda hits.
349 (let ((result (enclave-bridge-send-channel! state #f "hello")))
350 (assert-true (string? result))
351 (assert-true (string-contains? result "not connected"))))))
353;; ============================================================
354;; Bug A — spawn-worker groups defaulting
355;;
356;; or-empty-env (caller-arg env-name) is the resolution shape used
357;; by spawn-worker for its `groups` argument: explicit non-empty
358;; arg wins, else the env var, else #f. Empty string is treated
359;; as "not provided" (the same as #f) so an MCP caller passing
360;; `groups: ""` falls back to the env default rather than
361;; sending an explicit no-group request.
362;; ============================================================
364(test-group "or-empty-env (groups defaulting helper)"
365 (test "non-empty arg wins over env"
366 (setenv! "APIARY_TEST_GROUP_AB" "from-env")
367 (assert-equal "from-arg"
368 (or-empty-env "from-arg" "APIARY_TEST_GROUP_AB"))
369 (setenv! "APIARY_TEST_GROUP_AB" ""))
371 (test "missing arg falls back to env"
372 (setenv! "APIARY_TEST_GROUP_AC" "ops-workers")
373 (assert-equal "ops-workers"
374 (or-empty-env #f "APIARY_TEST_GROUP_AC"))
375 (setenv! "APIARY_TEST_GROUP_AC" ""))
377 (test "empty-string arg falls back to env (NOT treated as explicit)"
378 (setenv! "APIARY_TEST_GROUP_AD" "ops-workers")
379 (assert-equal "ops-workers"
380 (or-empty-env "" "APIARY_TEST_GROUP_AD"))
381 (setenv! "APIARY_TEST_GROUP_AD" ""))
383 (test "missing arg + unset env returns #f"
384 ;; Use a never-touched name so it's reliably unset.
385 (assert-false (or-empty-env #f "APIARY_TEST_GROUP_NEVER_SET")))
387 (test "missing arg + empty-string env returns #f"
388 (setenv! "APIARY_TEST_GROUP_AF" "")
389 (assert-false (or-empty-env #f "APIARY_TEST_GROUP_AF")))
391 (test "non-string arg falls back to env"
392 (setenv! "APIARY_TEST_GROUP_AG" "fallback")
393 (assert-equal "fallback"
394 (or-empty-env 42 "APIARY_TEST_GROUP_AG"))
395 (setenv! "APIARY_TEST_GROUP_AG" "")))
397(test-group "env-or-false"
398 (test "returns env value when set and non-empty"
399 (setenv! "APIARY_TEST_EOB" "yes")
400 (assert-equal "yes" (env-or-false "APIARY_TEST_EOB"))
401 (setenv! "APIARY_TEST_EOB" ""))
403 (test "returns #f when unset"
404 (assert-false (env-or-false "APIARY_TEST_EOB_NEVER_SET")))
406 (test "returns #f when set to empty string"
407 (setenv! "APIARY_TEST_EOB_EMPTY" "")
408 (assert-false (env-or-false "APIARY_TEST_EOB_EMPTY"))))
410(test-group "trusted-sender?"
411 (test "owner is trusted (case-insensitive)"
412 (let ((s (make-enclave-bridge-state)))
413 (set-enclave-bridge-state-owner-nick! s "daviwil")
414 (assert-true (trusted-sender? s "daviwil"))
415 (assert-true (trusted-sender? s "DAVIWIL"))
416 (assert-true (trusted-sender? s "Daviwil"))
417 (assert-false (trusted-sender? s "stranger"))))
419 (test "reports-to is trusted"
420 (let ((s (make-enclave-bridge-state)))
421 (set-enclave-bridge-state-reports-to! s "quinn")
422 (assert-true (trusted-sender? s "quinn"))
423 (assert-true (trusted-sender? s "QUINN"))
424 (assert-false (trusted-sender? s "other"))))
426 (test "subordinates are trusted"
427 (let ((s (make-enclave-bridge-state)))
428 (set-enclave-bridge-state-subordinates!
429 s (list "worker-1" "worker-2"))
430 (assert-true (trusted-sender? s "worker-1"))
431 (assert-true (trusted-sender? s "WORKER-2"))
432 (assert-false (trusted-sender? s "worker-3"))))
434 (test "listened-peers are trusted"
435 (let ((s (make-enclave-bridge-state)))
436 (set-enclave-bridge-state-listened-peers!
437 s (list "bee-3" "bee-7"))
438 (assert-true (trusted-sender? s "bee-3"))
439 (assert-true (trusted-sender? s "BEE-7"))
440 (assert-false (trusted-sender? s "bee-99"))))
442 (test "non-string sender returns #f"
443 (let ((s (make-enclave-bridge-state)))
444 (set-enclave-bridge-state-owner-nick! s "daviwil")
445 (assert-false (trusted-sender? s #f))
446 (assert-false (trusted-sender? s '()))))
448 (test "empty trusted set rejects everyone"
449 (let ((s (make-enclave-bridge-state)))
450 (assert-false (trusted-sender? s "anyone"))
451 (assert-false (trusted-sender? s "owner")))))
454;; ============================================================
455;; Tool-facing link status
456;;
457;; The defect: every tool call that could not reach the channel
458;; returned the string "Error: enclave bridge not connected",
459;; whether the bridge was two seconds from succeeding or would
460;; never connect again for the life of the process. Those are
461;; different facts and a caller has to be able to act on the
462;; difference — a process that will never recover reading as a
463;; transient blip is what let an outage look survivable for a day
464;; and a half.
465;; ============================================================
467(define (contains? haystack needle)
468 (and (string? haystack)
469 (>= (string-length haystack) (string-length needle))
470 (let loop ((i 0))
471 (cond
472 ((> (+ i (string-length needle)) (string-length haystack)) #f)
473 ((string=? needle (substring haystack i
474 (+ i (string-length needle))))
475 #t)
476 (else (loop (+ i 1)))))))
478(test-group "bridge-unavailable-message"
480 (test "terminal says it will NOT connect and asks for operator action"
481 (let ((s (make-enclave-bridge-state)))
482 (set-enclave-bridge-state-link-state! s 'terminal)
483 (set-enclave-bridge-state-link-detail! s "incomplete configuration")
484 (let ((msg (bridge-unavailable-message s)))
485 (assert-true (contains? msg "will NOT connect"))
486 (assert-true (contains? msg "incomplete configuration"))
487 ;; It must NOT imply a retry that is not happening.
488 (assert-false (contains? msg "retrying")))))
490 (test "retrying names the attempt, the last failure and the next try"
491 (let ((s (make-enclave-bridge-state)))
492 (set-enclave-bridge-state-link-state! s 'connecting)
493 (set-enclave-bridge-state-link-detail!
494 s "connect timed out: connection timed out at 10.0.0.1:6697")
495 (set-enclave-bridge-state-link-attempt! s 4)
496 (set-enclave-bridge-state-link-next-delay! s 8)
497 (let ((msg (bridge-unavailable-message s)))
498 (assert-true (contains? msg "retrying"))
499 (assert-true (contains? msg "attempt 4"))
500 (assert-true (contains? msg "connect timed out"))
501 (assert-true (contains? msg "next attempt in 8s"))
502 ;; And it must NOT claim the situation is hopeless.
503 (assert-false (contains? msg "will NOT connect")))))
505 (test "terminal and retrying are DISTINGUISHABLE"
506 ;; The single property the whole change exists to provide. Asserted
507 ;; directly so it cannot regress into two differently-worded
508 ;; strings that a caller still cannot act on differently.
509 (let ((terminal (make-enclave-bridge-state))
510 (retrying (make-enclave-bridge-state)))
511 (set-enclave-bridge-state-link-state! terminal 'terminal)
512 (set-enclave-bridge-state-link-detail! terminal "same detail")
513 (set-enclave-bridge-state-link-state! retrying 'connecting)
514 (set-enclave-bridge-state-link-detail! retrying "same detail")
515 (assert-false (string=? (bridge-unavailable-message terminal)
516 (bridge-unavailable-message retrying)))))
518 ;; ----------------------------------------------------------------
519 ;; The tests above exercise the FORMATTER only: they write
520 ;; link-detail through a setter and then assert the string renders
521 ;; it. Every one of them passed while the real plumbing was
522 ;; overwriting the classified failure with the literal word
523 ;; "reconnecting" before each backoff sleep — a bug an adversarial
524 ;; reviewer found and these tests structurally could not.
525 ;;
526 ;; A test that writes a value through a setter and then asserts the
527 ;; formatter rendered it is testing the formatter. The tests below
528 ;; drive `set-link-status!` in the SEQUENCE the connect machinery
529 ;; drives it, which is where the defect lived.
530 ;; ----------------------------------------------------------------
532 (test "a scheduled attempt does NOT erase the last classified failure"
533 ;; The exact regression. Sequence per retry iteration is:
534 ;; report 'connecting (attempt scheduled, no new diagnosis)
535 ;; ... sleep, up to 60s ...
536 ;; attempt
537 ;; report the classified outcome
538 ;; A tool call landing in the sleep — which is most of them, since
539 ;; the sleep dominates the attempt by an order of magnitude — must
540 ;; still see the real reason.
541 (let ((s (make-enclave-bridge-state)))
542 ;; A real failure lands.
543 (set-link-status! s 'connecting
544 "connect timed out: 10.0.0.1:6697" 3 8)
545 ;; Next iteration schedules an attempt with NO new diagnosis.
546 (set-link-status! s 'connecting #f 4 16)
547 (let ((msg (bridge-unavailable-message s)))
548 (assert-true (contains? msg "connect timed out"))
549 (assert-false (contains? msg "last failure: reconnecting"))
550 ;; The freshly-scheduled attempt/delay DO update.
551 (assert-true (contains? msg "attempt 4"))
552 (assert-true (contains? msg "next attempt in 16s")))))
554 (test "a successful connect leaves no failure text behind"
555 ;; link-detail is only ever rendered as "last failure: <detail>",
556 ;; so writing a success string into it produces "last failure:
557 ;; connected" the next time the session drops.
558 (let ((s (make-enclave-bridge-state)))
559 (set-link-status! s 'connecting "connect timed out: host:6697" 3 8)
560 (set-link-status! s 'connected "" 0 #f)
561 ;; Session drops; nothing has reported in yet.
562 (let ((msg (bridge-unavailable-message s)))
563 (assert-false (contains? msg "last failure: connected"))
564 (assert-false (contains? msg "connect timed out"))
565 ;; And it must be honest about how long detection can take.
566 (assert-true (contains? msg "90s")))))
568 (test "a fresh state defaults to idle, and idle reads as retrying"
569 ;; 'idle means the connect goroutine has not reported yet, which
570 ;; is a "not yet" rather than a "never" — reporting it as terminal
571 ;; would be the same lie in the other direction.
572 (let ((s (make-enclave-bridge-state)))
573 (assert-equal 'idle (enclave-bridge-state-link-state s))
574 (assert-true (contains? (bridge-unavailable-message s)
575 "retrying")))))
578;; ============================================================
579;; The start-up retry loop
580;;
581;; This was the largest new control-flow addition in the change and
582;; had NO coverage at all — not in this suite and not in the smoke
583;; harness, which calls `enclave-bridge-start!` rather than
584;; `enclave-bridge-run!`. Found by adversarial review, not by me.
585;;
586;; A loop whose only exit is success cannot be tested by letting it
587;; run, so both terminating paths are what get asserted here.
588;; ============================================================
590(test-group "enclave-bridge-run!"
592 (test "an unconfigured bridge is TERMINAL, and does not spin"
593 ;; No configuration is arriving later in this process's life, so
594 ;; retrying would burn the scheduler forever on something that
595 ;; cannot succeed AND would report "retrying" while doing it.
596 (let* ((s (make-enclave-bridge-state))
597 (result (enclave-bridge-run! s #f (enclave-config)
598 'leader #f #f)))
599 (assert-false result)
600 (assert-equal 'terminal (enclave-bridge-state-link-state s))
601 (let ((msg (bridge-unavailable-message s)))
602 (assert-true (contains? msg "will NOT connect"))
603 (assert-false (contains? msg "retrying")))))
605 (test "a stop request ends the loop instead of connecting anyway"
606 ;; Without this the loop's only exit is success, so a process asked
607 ;; to shut down mid-outage would keep retrying and could establish
608 ;; a connection after the request to stop. The config here is
609 ;; deliberately COMPLETE, so 'terminal cannot be reached by the
610 ;; unconfigured path above — the stop flag is the only way out, and
611 ;; a regression that ignored it would hang this test rather than
612 ;; failing it quietly.
613 (let ((s (make-enclave-bridge-state)))
614 (set-enclave-bridge-state-stop?! s #t)
615 (let ((result (enclave-bridge-run!
616 s #f
617 (enclave-config host: "127.0.0.1" port: 1
618 user: "n" token: "t")
619 'leader #f #f)))
620 (assert-false result)
621 (assert-equal 'terminal (enclave-bridge-state-link-state s))))))
624;; ============================================================
625;; Post-result vocabulary
626;; ============================================================
628(test-group "post-result vocabulary"
630 (test "qualify-post-result says nothing about delivery when unconfirmed"
631 ;; The tool-facing vocabulary is part of the contract. The
632 ;; confirmed rendering may claim delivery; the unconfirmed one
633 ;; must not, and specifically must not say "queued" — nothing
634 ;; holds a copy, so a caller told that would wait instead of
635 ;; re-sending.
636 (assert-equal "Broadcast to #chan (1 line)"
637 (qualify-post-result 'confirmed
638 "Broadcast to #chan (1 line)"
639 "Wrote to #chan (1 line)"))
640 (let ((out (qualify-post-result 'unconfirmed
641 "Broadcast to #chan (1 line)"
642 "Wrote to #chan (1 line)")))
643 (assert-true (string-contains? out "UNCONFIRMED"))
644 (assert-true (string-contains? out "Wrote to #chan"))
645 (assert-false (string-contains? out "Broadcast"))
646 (assert-false (string-contains? out "ueued"))))
648 (test "the UNCONFIRMED reason matches why it was unconfirmed"
649 ;; An earlier version claimed "the server did not answer within
650 ;; 3s" for every outcome, including ones where no PING was sent
651 ;; and no budget was ever spent. The verb was honest and the
652 ;; diagnosis was invented.
653 (let ((budget (qualify-post-result 'unconfirmed "ok" "wrote"))
654 (stale (qualify-post-result 'no-evidence "ok" "wrote"))
655 (down (qualify-post-result 'link-down "ok" "wrote"))
656 (swapped (qualify-post-result 'link-changed "ok" "wrote")))
657 (assert-true (string-contains? budget "did not answer within"))
658 (assert-false (string-contains? stale "did not answer within"))
659 (assert-true (string-contains? stale "link is unresponsive"))
660 (assert-true (string-contains? down "down or re-establishing"))
661 (assert-true (string-contains? swapped "was replaced"))
662 ;; whatever the reason, none of them claims delivery
663 (for-each (lambda (s) (assert-false (string-contains? s "ok")))
664 (list budget stale down swapped))
665 ;; and none of them stutters: the reason is a noun phrase and
666 ;; the consequence is its own sentence, so no "..., so X, so
667 ;; Y ..." can reach the agent that reads this.
668 (for-each (lambda (s) (assert-false (string-contains? s ", so ")))
669 (list budget stale down swapped)))))