@agent-relay/wrapper 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.
Files changed (115) hide show
  1. package/dist/__fixtures__/claude-outputs.d.ts +49 -0
  2. package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
  3. package/dist/__fixtures__/claude-outputs.js +443 -0
  4. package/dist/__fixtures__/claude-outputs.js.map +1 -0
  5. package/dist/__fixtures__/codex-outputs.d.ts +9 -0
  6. package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
  7. package/dist/__fixtures__/codex-outputs.js +94 -0
  8. package/dist/__fixtures__/codex-outputs.js.map +1 -0
  9. package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
  10. package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
  11. package/dist/__fixtures__/gemini-outputs.js +144 -0
  12. package/dist/__fixtures__/gemini-outputs.js.map +1 -0
  13. package/dist/__fixtures__/index.d.ts +68 -0
  14. package/dist/__fixtures__/index.d.ts.map +1 -0
  15. package/dist/__fixtures__/index.js +44 -0
  16. package/dist/__fixtures__/index.js.map +1 -0
  17. package/dist/auth-detection.d.ts +49 -0
  18. package/dist/auth-detection.d.ts.map +1 -0
  19. package/dist/auth-detection.js +199 -0
  20. package/dist/auth-detection.js.map +1 -0
  21. package/dist/base-wrapper.d.ts +225 -0
  22. package/dist/base-wrapper.d.ts.map +1 -0
  23. package/dist/base-wrapper.js +572 -0
  24. package/dist/base-wrapper.js.map +1 -0
  25. package/dist/client.d.ts +254 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +801 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/id-generator.d.ts +35 -0
  30. package/dist/id-generator.d.ts.map +1 -0
  31. package/dist/id-generator.js +60 -0
  32. package/dist/id-generator.js.map +1 -0
  33. package/dist/idle-detector.d.ts +110 -0
  34. package/dist/idle-detector.d.ts.map +1 -0
  35. package/dist/idle-detector.js +304 -0
  36. package/dist/idle-detector.js.map +1 -0
  37. package/dist/inbox.d.ts +37 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +73 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +37 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +47 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/parser.d.ts +236 -0
  46. package/dist/parser.d.ts.map +1 -0
  47. package/dist/parser.js +1238 -0
  48. package/dist/parser.js.map +1 -0
  49. package/dist/prompt-composer.d.ts +67 -0
  50. package/dist/prompt-composer.d.ts.map +1 -0
  51. package/dist/prompt-composer.js +168 -0
  52. package/dist/prompt-composer.js.map +1 -0
  53. package/dist/relay-pty-orchestrator.d.ts +407 -0
  54. package/dist/relay-pty-orchestrator.d.ts.map +1 -0
  55. package/dist/relay-pty-orchestrator.js +1885 -0
  56. package/dist/relay-pty-orchestrator.js.map +1 -0
  57. package/dist/shared.d.ts +201 -0
  58. package/dist/shared.d.ts.map +1 -0
  59. package/dist/shared.js +341 -0
  60. package/dist/shared.js.map +1 -0
  61. package/dist/stuck-detector.d.ts +161 -0
  62. package/dist/stuck-detector.d.ts.map +1 -0
  63. package/dist/stuck-detector.js +402 -0
  64. package/dist/stuck-detector.js.map +1 -0
  65. package/dist/tmux-resolver.d.ts +55 -0
  66. package/dist/tmux-resolver.d.ts.map +1 -0
  67. package/dist/tmux-resolver.js +175 -0
  68. package/dist/tmux-resolver.js.map +1 -0
  69. package/dist/tmux-wrapper.d.ts +345 -0
  70. package/dist/tmux-wrapper.d.ts.map +1 -0
  71. package/dist/tmux-wrapper.js +1747 -0
  72. package/dist/tmux-wrapper.js.map +1 -0
  73. package/dist/trajectory-integration.d.ts +292 -0
  74. package/dist/trajectory-integration.d.ts.map +1 -0
  75. package/dist/trajectory-integration.js +979 -0
  76. package/dist/trajectory-integration.js.map +1 -0
  77. package/dist/wrapper-types.d.ts +41 -0
  78. package/dist/wrapper-types.d.ts.map +1 -0
  79. package/dist/wrapper-types.js +7 -0
  80. package/dist/wrapper-types.js.map +1 -0
  81. package/package.json +63 -0
  82. package/src/__fixtures__/claude-outputs.ts +471 -0
  83. package/src/__fixtures__/codex-outputs.ts +99 -0
  84. package/src/__fixtures__/gemini-outputs.ts +151 -0
  85. package/src/__fixtures__/index.ts +47 -0
  86. package/src/auth-detection.ts +244 -0
  87. package/src/base-wrapper.test.ts +540 -0
  88. package/src/base-wrapper.ts +741 -0
  89. package/src/client.test.ts +262 -0
  90. package/src/client.ts +984 -0
  91. package/src/id-generator.test.ts +71 -0
  92. package/src/id-generator.ts +69 -0
  93. package/src/idle-detector.test.ts +390 -0
  94. package/src/idle-detector.ts +370 -0
  95. package/src/inbox.test.ts +233 -0
  96. package/src/inbox.ts +89 -0
  97. package/src/index.ts +170 -0
  98. package/src/parser.regression.test.ts +251 -0
  99. package/src/parser.test.ts +1359 -0
  100. package/src/parser.ts +1477 -0
  101. package/src/prompt-composer.test.ts +219 -0
  102. package/src/prompt-composer.ts +231 -0
  103. package/src/relay-pty-orchestrator.test.ts +1027 -0
  104. package/src/relay-pty-orchestrator.ts +2270 -0
  105. package/src/shared.test.ts +221 -0
  106. package/src/shared.ts +454 -0
  107. package/src/stuck-detector.test.ts +303 -0
  108. package/src/stuck-detector.ts +511 -0
  109. package/src/tmux-resolver.test.ts +104 -0
  110. package/src/tmux-resolver.ts +207 -0
  111. package/src/tmux-wrapper.test.ts +316 -0
  112. package/src/tmux-wrapper.ts +2010 -0
  113. package/src/trajectory-detection.test.ts +151 -0
  114. package/src/trajectory-integration.ts +1261 -0
  115. package/src/wrapper-types.ts +45 -0
@@ -0,0 +1,1359 @@
1
+ /**
2
+ * Unit tests for PTY Output Parser
3
+ */
4
+
5
+ import { describe, it, expect, beforeEach } from 'vitest';
6
+ import { OutputParser, parseSummaryFromOutput, parseSessionEndFromOutput, parseRelayMetadataFromOutput } from './parser.js';
7
+
8
+ describe('OutputParser', () => {
9
+ let parser: OutputParser;
10
+
11
+ beforeEach(() => {
12
+ parser = new OutputParser();
13
+ });
14
+
15
+ describe('Inline format - ->relay:target message', () => {
16
+ it('parses basic inline relay command', () => {
17
+ const result = parser.parse('->relay:agent2 Hello there\n');
18
+
19
+ expect(result.commands).toHaveLength(1);
20
+ expect(result.commands[0]).toMatchObject({
21
+ to: 'agent2',
22
+ kind: 'message',
23
+ body: 'Hello there',
24
+ raw: '->relay:agent2 Hello there',
25
+ });
26
+ expect(result.output).toBe('');
27
+ });
28
+
29
+ it('extracts target and body correctly', () => {
30
+ const result = parser.parse('->relay:supervisor This is a longer message with multiple words\n');
31
+
32
+ expect(result.commands).toHaveLength(1);
33
+ expect(result.commands[0].to).toBe('supervisor');
34
+ expect(result.commands[0].body).toBe('This is a longer message with multiple words');
35
+ });
36
+
37
+ it('only matches at start of line (after whitespace)', () => {
38
+ const result = parser.parse(' ->relay:agent2 Indented message\n');
39
+
40
+ expect(result.commands).toHaveLength(1);
41
+ expect(result.commands[0].to).toBe('agent2');
42
+ expect(result.commands[0].body).toBe('Indented message');
43
+ });
44
+
45
+ it('handles Gemini sparkle prefix (✦)', () => {
46
+ const result = parser.parse('✦ ->relay:Lead STATUS: Gem is ready\n');
47
+
48
+ expect(result.commands).toHaveLength(1);
49
+ expect(result.commands[0].to).toBe('Lead');
50
+ expect(result.commands[0].body).toBe('STATUS: Gem is ready');
51
+ });
52
+
53
+ it('does not match ->relay: in middle of line', () => {
54
+ const result = parser.parse('This is text ->relay:agent2 should not match\n');
55
+
56
+ expect(result.commands).toHaveLength(0);
57
+ expect(result.output).toBe('This is text ->relay:agent2 should not match\n');
58
+ });
59
+
60
+ it('handles ->thinking: variant', () => {
61
+ const result = parser.parse('->thinking:agent2 Considering the options\n');
62
+
63
+ expect(result.commands).toHaveLength(1);
64
+ expect(result.commands[0]).toMatchObject({
65
+ to: 'agent2',
66
+ kind: 'thinking',
67
+ body: 'Considering the options',
68
+ });
69
+ });
70
+
71
+ it('parses multiple inline commands', () => {
72
+ const result = parser.parse('->relay:agent1 First message\n->relay:agent2 Second message\n');
73
+
74
+ expect(result.commands).toHaveLength(2);
75
+ expect(result.commands[0].to).toBe('agent1');
76
+ expect(result.commands[0].body).toBe('First message');
77
+ expect(result.commands[1].to).toBe('agent2');
78
+ expect(result.commands[1].body).toBe('Second message');
79
+ });
80
+
81
+ it('parses multi-line inline command with indented continuation', () => {
82
+ // TUI wrapping indents continuation lines
83
+ const result = parser.parse('->relay:agent2 First line\n Second line\n');
84
+
85
+ expect(result.commands).toHaveLength(1);
86
+ expect(result.commands[0].body).toBe('First line\n Second line');
87
+ expect(result.output).toBe('');
88
+ });
89
+
90
+ it('does not swallow subsequent inline command after indented continuation', () => {
91
+ const result = parser.parse('->relay:agent1 First line\n Second line\n->relay:agent2 Next\n');
92
+
93
+ expect(result.commands).toHaveLength(2);
94
+ expect(result.commands[0].body).toBe('First line\n Second line');
95
+ expect(result.commands[1].body).toBe('Next');
96
+ expect(result.output).toBe('');
97
+ });
98
+
99
+ it('captures bullet list continuation lines', () => {
100
+ const input = '->relay:agent2 Updates for mcl/2z1:\n- Task A\n- Task B\n\nAfter\n';
101
+ const result = parser.parse(input);
102
+
103
+ expect(result.commands).toHaveLength(1);
104
+ expect(result.commands[0].body).toBe('Updates for mcl/2z1:\n- Task A\n- Task B');
105
+ expect(result.output).toBe('\nAfter\n');
106
+ });
107
+
108
+ it('captures non-indented paragraph continuation until blank line', () => {
109
+ const input = '->relay:lead Signing off. Progress report:\nSummary line one.\nSummary line two.\n\nNext output\n';
110
+ const result = parser.parse(input);
111
+
112
+ expect(result.commands).toHaveLength(1);
113
+ expect(result.commands[0].body).toBe('Signing off. Progress report:\nSummary line one.\nSummary line two.');
114
+ expect(result.output).toBe('\nNext output\n');
115
+ });
116
+
117
+ it('stops continuation at prompt-ish line', () => {
118
+ const input = '->relay:agent2 Message body\n> \nFollow-up\n';
119
+ const result = parser.parse(input);
120
+
121
+ expect(result.commands).toHaveLength(1);
122
+ expect(result.commands[0].body).toBe('Message body');
123
+ expect(result.output).toBe('> \nFollow-up\n');
124
+ });
125
+
126
+ it('does not require spaces in target name', () => {
127
+ const result = parser.parse('->relay:agent-with-dashes Message here\n');
128
+
129
+ expect(result.commands).toHaveLength(1);
130
+ expect(result.commands[0].to).toBe('agent-with-dashes');
131
+ });
132
+ });
133
+
134
+ describe('Block format - [[RELAY]]...[[/RELAY]]', () => {
135
+ it('parses single-line block', () => {
136
+ const result = parser.parse('[[RELAY]]{"to":"agent2","type":"message","body":"Hello"}[[/RELAY]]\n');
137
+
138
+ expect(result.commands).toHaveLength(1);
139
+ expect(result.commands[0]).toMatchObject({
140
+ to: 'agent2',
141
+ kind: 'message',
142
+ body: 'Hello',
143
+ });
144
+ });
145
+
146
+ it('parses multi-line block', () => {
147
+ const input = `[[RELAY]]
148
+ {
149
+ "to": "agent2",
150
+ "type": "message",
151
+ "body": "Multi-line message"
152
+ }
153
+ [[/RELAY]]
154
+ `;
155
+ const result = parser.parse(input);
156
+
157
+ expect(result.commands).toHaveLength(1);
158
+ expect(result.commands[0]).toMatchObject({
159
+ to: 'agent2',
160
+ kind: 'message',
161
+ body: 'Multi-line message',
162
+ });
163
+ });
164
+
165
+ it('extracts JSON fields (to, type, body, data)', () => {
166
+ const input = '[[RELAY]]{"to":"agent2","type":"action","body":"Execute","data":{"cmd":"ls"}}[[/RELAY]]\n';
167
+ const result = parser.parse(input);
168
+
169
+ expect(result.commands).toHaveLength(1);
170
+ expect(result.commands[0]).toMatchObject({
171
+ to: 'agent2',
172
+ kind: 'action',
173
+ body: 'Execute',
174
+ data: { cmd: 'ls' },
175
+ });
176
+ });
177
+
178
+ it('handles invalid JSON gracefully', () => {
179
+ const result = parser.parse('[[RELAY]]{invalid json}[[/RELAY]]\n');
180
+
181
+ expect(result.commands).toHaveLength(0);
182
+ expect(result.output).toBe('\n');
183
+ });
184
+
185
+ it('handles missing required fields', () => {
186
+ const result = parser.parse('[[RELAY]]{"body":"No target"}[[/RELAY]]\n');
187
+
188
+ expect(result.commands).toHaveLength(0);
189
+ });
190
+
191
+ it('handles missing body field with text fallback', () => {
192
+ const result = parser.parse('[[RELAY]]{"to":"agent2","type":"message","text":"Using text field"}[[/RELAY]]\n');
193
+
194
+ expect(result.commands).toHaveLength(1);
195
+ expect(result.commands[0].body).toBe('Using text field');
196
+ });
197
+
198
+ it('does not parse blocks unless [[RELAY]] is at start of line', () => {
199
+ const result = parser.parse(
200
+ 'Some output [[RELAY]]{"to":"agent2","type":"message","body":"Hello"}[[/RELAY]]\n'
201
+ );
202
+
203
+ expect(result.commands).toHaveLength(0);
204
+ expect(result.output).toBe('Some output [[RELAY]]{"to":"agent2","type":"message","body":"Hello"}[[/RELAY]]\n');
205
+ });
206
+
207
+ it('handles block with thinking type', () => {
208
+ const result = parser.parse('[[RELAY]]{"to":"agent2","type":"thinking","body":"Pondering"}[[/RELAY]]\n');
209
+
210
+ expect(result.commands).toHaveLength(1);
211
+ expect(result.commands[0].kind).toBe('thinking');
212
+ });
213
+
214
+ it('handles block with state type', () => {
215
+ const result = parser.parse('[[RELAY]]{"to":"agent2","type":"state","body":"Updated"}[[/RELAY]]\n');
216
+
217
+ expect(result.commands).toHaveLength(1);
218
+ expect(result.commands[0].kind).toBe('state');
219
+ });
220
+ });
221
+
222
+ describe('Fenced inline format - ->relay:Target <<< ... >>>', () => {
223
+ it('parses basic fenced inline message', () => {
224
+ const input = '->relay:agent2 <<<\nHello there\n>>>\n';
225
+ const result = parser.parse(input);
226
+
227
+ expect(result.commands).toHaveLength(1);
228
+ expect(result.commands[0]).toMatchObject({
229
+ to: 'agent2',
230
+ kind: 'message',
231
+ body: 'Hello there',
232
+ });
233
+ expect(result.output).toBe('');
234
+ });
235
+
236
+ it('preserves blank lines within fenced message', () => {
237
+ const input = '->relay:agent2 <<<\nFirst paragraph\n\nSecond paragraph\n>>>\n';
238
+ const result = parser.parse(input);
239
+
240
+ expect(result.commands).toHaveLength(1);
241
+ expect(result.commands[0].body).toBe('First paragraph\n\nSecond paragraph');
242
+ });
243
+
244
+ it('handles multi-line message with complex content', () => {
245
+ const input = `->relay:Lead <<<
246
+ Here's my analysis:
247
+
248
+ 1. First point
249
+ 2. Second point
250
+
251
+ The conclusion is...
252
+ >>>
253
+ `;
254
+ const result = parser.parse(input);
255
+
256
+ expect(result.commands).toHaveLength(1);
257
+ expect(result.commands[0].to).toBe('Lead');
258
+ expect(result.commands[0].body).toContain('First point');
259
+ expect(result.commands[0].body).toContain('Second point');
260
+ expect(result.commands[0].body).toContain('The conclusion is...');
261
+ });
262
+
263
+ it('handles fenced message with code blocks inside', () => {
264
+ const input = `->relay:Dev <<<
265
+ Here's the code:
266
+
267
+ \`\`\`typescript
268
+ function hello() {
269
+ console.log('Hi');
270
+ }
271
+ \`\`\`
272
+
273
+ Let me know if that works.
274
+ >>>
275
+ `;
276
+ const result = parser.parse(input);
277
+
278
+ expect(result.commands).toHaveLength(1);
279
+ expect(result.commands[0].body).toContain('```typescript');
280
+ expect(result.commands[0].body).toContain('function hello()');
281
+ });
282
+
283
+ it('handles fenced thinking variant', () => {
284
+ const input = '->thinking:agent2 <<<\nConsidering options:\n- Option A\n- Option B\n>>>\n';
285
+ const result = parser.parse(input);
286
+
287
+ expect(result.commands).toHaveLength(1);
288
+ expect(result.commands[0]).toMatchObject({
289
+ to: 'agent2',
290
+ kind: 'thinking',
291
+ });
292
+ expect(result.commands[0].body).toContain('Option A');
293
+ });
294
+
295
+ it('handles thread syntax in fenced messages', () => {
296
+ const input = '->relay:agent2 [thread:review-123] <<<\nMulti-line\nreview comments\n>>>\n';
297
+ const result = parser.parse(input);
298
+
299
+ expect(result.commands).toHaveLength(1);
300
+ expect(result.commands[0].thread).toBe('review-123');
301
+ expect(result.commands[0].body).toBe('Multi-line\nreview comments');
302
+ });
303
+
304
+ it('handles await syntax in fenced messages', () => {
305
+ const input = '->relay:agent2 [await:5m] <<<\nPlease confirm\n>>>\n';
306
+ const result = parser.parse(input);
307
+
308
+ expect(result.commands).toHaveLength(1);
309
+ expect(result.commands[0].sync?.blocking).toBe(true);
310
+ expect(result.commands[0].sync?.timeoutMs).toBe(300000);
311
+ expect(result.commands[0].body).toBe('Please confirm');
312
+ });
313
+
314
+ it('handles cross-project thread syntax in fenced messages', () => {
315
+ const input = '->relay:Backend [thread:frontend-app:auth-flow] <<<\nCan you check the session handling?\n>>>\n';
316
+ const result = parser.parse(input);
317
+
318
+ expect(result.commands).toHaveLength(1);
319
+ expect(result.commands[0].to).toBe('Backend');
320
+ expect(result.commands[0].thread).toBe('auth-flow');
321
+ expect(result.commands[0].threadProject).toBe('frontend-app');
322
+ expect(result.commands[0].body).toBe('Can you check the session handling?');
323
+ });
324
+
325
+ it('handles cross-project syntax in fenced messages', () => {
326
+ const input = '->relay:other-project:agent2 <<<\nCross-project message\n>>>\n';
327
+ const result = parser.parse(input);
328
+
329
+ expect(result.commands).toHaveLength(1);
330
+ expect(result.commands[0].to).toBe('agent2');
331
+ expect(result.commands[0].project).toBe('other-project');
332
+ });
333
+
334
+ it('processes content after fenced block closes', () => {
335
+ const input = '->relay:agent1 <<<\nFenced content\n>>>\n->relay:agent2 Regular inline\n';
336
+ const result = parser.parse(input);
337
+
338
+ expect(result.commands).toHaveLength(2);
339
+ expect(result.commands[0].to).toBe('agent1');
340
+ expect(result.commands[0].body).toBe('Fenced content');
341
+ expect(result.commands[1].to).toBe('agent2');
342
+ expect(result.commands[1].body).toBe('Regular inline');
343
+ });
344
+
345
+ it('accumulates across multiple parse calls (streaming)', () => {
346
+ const result1 = parser.parse('->relay:agent2 <<<\nFirst part\n');
347
+ expect(result1.commands).toHaveLength(0);
348
+ expect(result1.output).toBe('');
349
+
350
+ const result2 = parser.parse('Second part\n');
351
+ expect(result2.commands).toHaveLength(0);
352
+
353
+ const result3 = parser.parse('>>>\n');
354
+ expect(result3.commands).toHaveLength(1);
355
+ expect(result3.commands[0].body).toBe('First part\nSecond part');
356
+ });
357
+
358
+ it('handles >>> with leading/trailing whitespace', () => {
359
+ const input = '->relay:agent2 <<<\nContent\n >>> \n';
360
+ const result = parser.parse(input);
361
+
362
+ expect(result.commands).toHaveLength(1);
363
+ expect(result.commands[0].body).toBe('Content');
364
+ });
365
+
366
+ it('trims leading/trailing whitespace from body', () => {
367
+ const input = '->relay:agent2 <<<\n\n Content here \n\n>>>\n';
368
+ const result = parser.parse(input);
369
+
370
+ expect(result.commands).toHaveLength(1);
371
+ // Leading blank lines should be trimmed, content preserved
372
+ expect(result.commands[0].body).toBe('Content here');
373
+ });
374
+
375
+ it('handles fenced message with only blank lines', () => {
376
+ const input = '->relay:agent2 <<<\n\n\n>>>\n';
377
+ const result = parser.parse(input);
378
+
379
+ expect(result.commands).toHaveLength(1);
380
+ expect(result.commands[0].body).toBe('');
381
+ });
382
+
383
+ it('handles prefixes like bullets before fenced start', () => {
384
+ const input = '- ->relay:agent2 <<<\nContent from list\n>>>\n';
385
+ const result = parser.parse(input);
386
+
387
+ expect(result.commands).toHaveLength(1);
388
+ expect(result.commands[0].body).toBe('Content from list');
389
+ });
390
+
391
+ it('handles >>> at end of content line (agent-relay-9igw)', () => {
392
+ // Agents often put >>> at end of message rather than on its own line
393
+ const input = '->relay:agent2 <<<\nMessage content>>>\n';
394
+ const result = parser.parse(input);
395
+
396
+ expect(result.commands).toHaveLength(1);
397
+ expect(result.commands[0].to).toBe('agent2');
398
+ expect(result.commands[0].body).toBe('Message content');
399
+ });
400
+
401
+ it('handles >>> at end of multi-line content', () => {
402
+ const input = '->relay:Lead <<<\nFirst line\nSecond line>>>\n';
403
+ const result = parser.parse(input);
404
+
405
+ expect(result.commands).toHaveLength(1);
406
+ expect(result.commands[0].body).toBe('First line\nSecond line');
407
+ });
408
+
409
+ it('auto-closes and sends incomplete fenced block when new relay starts', () => {
410
+ // Previously this would DISCARD the first message - now it should SEND it
411
+ const input = '->relay:Alice <<<\nImportant content\n->relay:Bob Hello\n';
412
+ const result = parser.parse(input);
413
+
414
+ expect(result.commands).toHaveLength(2);
415
+ expect(result.commands[0].to).toBe('Alice');
416
+ expect(result.commands[0].body).toBe('Important content');
417
+ expect(result.commands[1].to).toBe('Bob');
418
+ expect(result.commands[1].body).toBe('Hello');
419
+ });
420
+
421
+ it('auto-closes fenced block when new fenced block starts', () => {
422
+ const input = '->relay:Agent1 <<<\nFirst message\n->relay:Agent2 <<<\nSecond message\n>>>\n';
423
+ const result = parser.parse(input);
424
+
425
+ expect(result.commands).toHaveLength(2);
426
+ expect(result.commands[0].to).toBe('Agent1');
427
+ expect(result.commands[0].body).toBe('First message');
428
+ expect(result.commands[1].to).toBe('Agent2');
429
+ expect(result.commands[1].body).toBe('Second message');
430
+ });
431
+
432
+ it('does not send empty incomplete fenced block', () => {
433
+ const input = '->relay:Agent1 <<<\n\n->relay:Agent2 Hello\n';
434
+ const result = parser.parse(input);
435
+
436
+ // Only Agent2's message should be sent (Agent1's was empty)
437
+ expect(result.commands).toHaveLength(1);
438
+ expect(result.commands[0].to).toBe('Agent2');
439
+ });
440
+
441
+ it('auto-closes fenced block when MAX_FENCED_LINES exceeded (agent-relay-9igw)', () => {
442
+ // When agent forgets >>> and sends many lines, the parser auto-closes
443
+ // MAX_FENCED_LINES is set to 30 to prevent messages getting stuck forever
444
+ const parser = new OutputParser();
445
+ let lines = '->relay:Lead <<<\n';
446
+ for (let i = 1; i <= 35; i++) {
447
+ lines += `Line ${i} of message content\n`;
448
+ }
449
+ lines += 'Output after message\n';
450
+
451
+ const _result = parser.parse(lines);
452
+
453
+ // Message should be auto-closed and sent (discarded due to exceeding limit)
454
+ // Parser should not be stuck in fenced mode
455
+ expect((parser as unknown as { inFencedInline: boolean }).inFencedInline).toBe(false);
456
+ });
457
+ });
458
+
459
+ describe('Code fence handling', () => {
460
+ it('ignores ->relay: inside code fences', () => {
461
+ const input = '```\n->relay:agent2 This should be ignored\n```\n';
462
+ const result = parser.parse(input);
463
+
464
+ expect(result.commands).toHaveLength(0);
465
+ expect(result.output).toBe('```\n->relay:agent2 This should be ignored\n```\n');
466
+ });
467
+
468
+ it('tracks code fence state correctly', () => {
469
+ const input = 'Before fence\n```\n->relay:agent2 Inside fence\n```\nAfter fence\n->relay:agent3 Outside fence\n';
470
+ const result = parser.parse(input);
471
+
472
+ expect(result.commands).toHaveLength(1);
473
+ expect(result.commands[0].to).toBe('agent3');
474
+ expect(result.output).toContain('->relay:agent2 Inside fence');
475
+ });
476
+
477
+ it('handles multiple code fences', () => {
478
+ const input = '```\n->relay:a1 First fence\n```\nBetween\n```\n->relay:a2 Second fence\n```\n->relay:a3 Outside\n';
479
+ const result = parser.parse(input);
480
+
481
+ expect(result.commands).toHaveLength(1);
482
+ expect(result.commands[0].to).toBe('a3');
483
+ });
484
+
485
+ it('handles code fence with language specifier', () => {
486
+ const input = '```javascript\n->relay:agent2 Code example\n```\n';
487
+ const result = parser.parse(input);
488
+
489
+ expect(result.commands).toHaveLength(0);
490
+ expect(result.output).toContain('->relay:agent2 Code example');
491
+ });
492
+
493
+ it('does not interfere with block format in code fence', () => {
494
+ const input = '```\n[[RELAY]]{"to":"agent2","type":"message","body":"In fence"}[[/RELAY]]\n```\n';
495
+ const result = parser.parse(input);
496
+
497
+ expect(result.commands).toHaveLength(0);
498
+ expect(result.output).toContain('[[RELAY]]');
499
+ });
500
+ });
501
+
502
+ describe('Escaping', () => {
503
+ it('\\->relay: outputs as ->relay: without triggering command', () => {
504
+ const result = parser.parse('\\->relay:agent2 This is escaped\n');
505
+
506
+ expect(result.commands).toHaveLength(0);
507
+ expect(result.output).toBe('->relay:agent2 This is escaped\n');
508
+ });
509
+
510
+ it('\\->thinking: outputs as ->thinking: without triggering command', () => {
511
+ const result = parser.parse('\\->thinking:agent2 This is escaped\n');
512
+
513
+ expect(result.commands).toHaveLength(0);
514
+ expect(result.output).toBe('->thinking:agent2 This is escaped\n');
515
+ });
516
+
517
+ it('escapes work with indentation', () => {
518
+ const result = parser.parse(' \\->relay:agent2 Indented escape\n');
519
+
520
+ expect(result.commands).toHaveLength(0);
521
+ expect(result.output).toBe(' ->relay:agent2 Indented escape\n');
522
+ });
523
+
524
+ it('only escapes at line start', () => {
525
+ const result = parser.parse('Text \\->relay:agent2 Not escaped\n');
526
+
527
+ expect(result.commands).toHaveLength(0);
528
+ expect(result.output).toBe('Text \\->relay:agent2 Not escaped\n');
529
+ });
530
+ });
531
+
532
+ describe('Instructional text filtering', () => {
533
+ it('skips inline commands that look like PROTOCOL instructions', () => {
534
+ const result = parser.parse('->relay:AgentName message. PROTOCOL: (1) Wait for task via relay...\n');
535
+
536
+ expect(result.commands).toHaveLength(0);
537
+ expect(result.output).toBe('->relay:AgentName message. PROTOCOL: (1) Wait for task via relay...\n');
538
+ });
539
+
540
+ it('skips inline commands containing escaped relay prefix in body', () => {
541
+ const result = parser.parse('->relay:docs SEND: \\->relay:AgentName message\n');
542
+
543
+ expect(result.commands).toHaveLength(0);
544
+ expect(result.output).toBe('->relay:docs SEND: \\->relay:AgentName message\n');
545
+ });
546
+
547
+ it('skips inline commands with Agent Relay header', () => {
548
+ const result = parser.parse('->relay:Test [Agent Relay] You are connected\n');
549
+
550
+ expect(result.commands).toHaveLength(0);
551
+ expect(result.output).toBe('->relay:Test [Agent Relay] You are connected\n');
552
+ });
553
+
554
+ it('skips inline commands with Example: marker', () => {
555
+ const result = parser.parse('->relay:docs Example: use this pattern\n');
556
+
557
+ expect(result.commands).toHaveLength(0);
558
+ expect(result.output).toBe('->relay:docs Example: use this pattern\n');
559
+ });
560
+
561
+ it('skips inline commands with MULTI-LINE: instruction', () => {
562
+ const result = parser.parse('->relay:Target MULTI-LINE: use <<< and >>> markers\n');
563
+
564
+ expect(result.commands).toHaveLength(0);
565
+ expect(result.output).toBe('->relay:Target MULTI-LINE: use <<< and >>> markers\n');
566
+ });
567
+
568
+ it('skips inline commands with AgentName placeholder', () => {
569
+ const result = parser.parse('->relay:docs AgentName Hello there\n');
570
+
571
+ expect(result.commands).toHaveLength(0);
572
+ expect(result.output).toBe('->relay:docs AgentName Hello there\n');
573
+ });
574
+
575
+ it('skips thinking commands with PROTOCOL instruction', () => {
576
+ const result = parser.parse('->thinking:docs PROTOCOL: (1) Wait for task\n');
577
+
578
+ expect(result.commands).toHaveLength(0);
579
+ expect(result.output).toBe('->thinking:docs PROTOCOL: (1) Wait for task\n');
580
+ });
581
+
582
+ it('does not skip real commands with normal body', () => {
583
+ const result = parser.parse('->relay:agent2 Hello, can you help me with this task?\n');
584
+
585
+ expect(result.commands).toHaveLength(1);
586
+ expect(result.commands[0].to).toBe('agent2');
587
+ expect(result.commands[0].body).toBe('Hello, can you help me with this task?');
588
+ });
589
+
590
+ it('does not skip commands that happen to contain partial instruction words', () => {
591
+ // "protocol" in lowercase mid-sentence should NOT trigger filter
592
+ const result = parser.parse('->relay:agent2 We need to follow the protocol for this task\n');
593
+
594
+ expect(result.commands).toHaveLength(1);
595
+ expect(result.commands[0].to).toBe('agent2');
596
+ });
597
+ });
598
+
599
+ describe('Spawn and release command filtering', () => {
600
+ it('does not parse spawn command as relay message', () => {
601
+ // Spawn commands should be handled by the wrapper's spawn subsystem, not parsed as messages
602
+ const result = parser.parse('->relay:spawn Developer claude "task description"\n');
603
+
604
+ // Should NOT be parsed as a relay message to target "spawn"
605
+ expect(result.commands).toHaveLength(0);
606
+ // Should be passed through for the wrapper to handle
607
+ expect(result.output).toBe('->relay:spawn Developer claude "task description"\n');
608
+ });
609
+
610
+ it('does not parse spawn command without task as relay message', () => {
611
+ const result = parser.parse('->relay:spawn Worker codex\n');
612
+
613
+ expect(result.commands).toHaveLength(0);
614
+ expect(result.output).toBe('->relay:spawn Worker codex\n');
615
+ });
616
+
617
+ it('does not parse release command as relay message', () => {
618
+ const result = parser.parse('->relay:release Developer\n');
619
+
620
+ expect(result.commands).toHaveLength(0);
621
+ expect(result.output).toBe('->relay:release Developer\n');
622
+ });
623
+
624
+ it('does not parse spawn command with prefixes as relay message', () => {
625
+ // TUI output may have bullet prefixes
626
+ const result = parser.parse('• ->relay:spawn Worker claude "task"\n');
627
+
628
+ expect(result.commands).toHaveLength(0);
629
+ expect(result.output).toBe('• ->relay:spawn Worker claude "task"\n');
630
+ });
631
+
632
+ it('still parses regular relay messages to spawn-like agent names', () => {
633
+ // An agent named "spawner" should still receive messages
634
+ const result = parser.parse('->relay:spawner Please do something\n');
635
+
636
+ expect(result.commands).toHaveLength(1);
637
+ expect(result.commands[0].to).toBe('spawner');
638
+ expect(result.commands[0].body).toBe('Please do something');
639
+ });
640
+
641
+ it('handles spawn command case-insensitively', () => {
642
+ const result = parser.parse('->relay:SPAWN Worker claude\n');
643
+
644
+ expect(result.commands).toHaveLength(0);
645
+ expect(result.output).toBe('->relay:SPAWN Worker claude\n');
646
+ });
647
+
648
+ it('does not parse spawn command with fenced format as relay message (agent-relay-312)', () => {
649
+ // Bug fix: spawn with fenced format should be passed through, not parsed as message to "spawn"
650
+ // Previously this would match the fenced pattern and send to target "spawn"
651
+ const result = parser.parse('->relay:spawn Backend claude <<<\n');
652
+
653
+ // Should NOT create a command to target "spawn"
654
+ expect(result.commands).toHaveLength(0);
655
+ // Should be passed through for wrapper to handle
656
+ expect(result.output).toBe('->relay:spawn Backend claude <<<\n');
657
+ });
658
+
659
+ it('does not enter fenced mode for spawn command with fence markers', () => {
660
+ // Multi-line spawn with fenced format - should all be passed through
661
+ const result1 = parser.parse('->relay:spawn Worker claude <<<\n');
662
+ expect(result1.commands).toHaveLength(0);
663
+ expect(result1.output).toBe('->relay:spawn Worker claude <<<\n');
664
+
665
+ // Parser should NOT be in fenced mode, so this line is independent
666
+ const result2 = parser.parse('Task description here\n');
667
+ expect(result2.commands).toHaveLength(0);
668
+ expect(result2.output).toBe('Task description here\n');
669
+
670
+ // Fence close should also be passed through
671
+ const result3 = parser.parse('>>>\n');
672
+ expect(result3.commands).toHaveLength(0);
673
+ expect(result3.output).toBe('>>>\n');
674
+ });
675
+
676
+ it('passes through spawn with fence marker as CLI (agent-relay-312)', () => {
677
+ // This is corrupted input where CLI became a fence marker
678
+ const result = parser.parse('->relay:spawn Worker <<<\n');
679
+
680
+ expect(result.commands).toHaveLength(0);
681
+ expect(result.output).toBe('->relay:spawn Worker <<<\n');
682
+ });
683
+
684
+ it('passes through release command with fenced format', () => {
685
+ // Release commands should also be passed through
686
+ const result = parser.parse('->relay:release Worker <<<\n');
687
+
688
+ expect(result.commands).toHaveLength(0);
689
+ expect(result.output).toBe('->relay:release Worker <<<\n');
690
+ });
691
+
692
+ it('passes through spawn command embedded in other content', () => {
693
+ // Spawn pattern appearing in documentation/content should be passed through
694
+ const result = parser.parse('->relay:spawn Worker claude\n');
695
+
696
+ expect(result.commands).toHaveLength(0);
697
+ expect(result.output).toBe('->relay:spawn Worker claude\n');
698
+ });
699
+ });
700
+
701
+ describe('Instructional text filtering', () => {
702
+ it('filters out placeholder target names like AgentName', () => {
703
+ const input = '->relay:AgentName This is an example message\n';
704
+ const result = parser.parse(input);
705
+ expect(result.commands).toHaveLength(0);
706
+ expect(result.output).toBe(input);
707
+ });
708
+
709
+ it('filters out placeholder target names case-insensitively', () => {
710
+ const input = '->relay:AGENTNAME Example\n->relay:target Test\n->relay:Recipient Hello\n';
711
+ const result = parser.parse(input);
712
+ expect(result.commands).toHaveLength(0);
713
+ });
714
+
715
+ it('filters out fenced messages to placeholder targets', () => {
716
+ const input = '->relay:AgentName <<<\nMulti-line example message\n>>>\n';
717
+ const result = parser.parse(input);
718
+ expect(result.commands).toHaveLength(0);
719
+ });
720
+
721
+ it('filters out PROTOCOL: instruction patterns in body', () => {
722
+ const input = '->relay:Lead message | PROTOCOL: (1) ACK receipt\n';
723
+ const result = parser.parse(input);
724
+ expect(result.commands).toHaveLength(0);
725
+ });
726
+
727
+ it('allows real agent names that are not placeholders', () => {
728
+ const input = '->relay:Lead Hello\n->relay:Alice Test\n';
729
+ const result = parser.parse(input);
730
+ expect(result.commands).toHaveLength(2);
731
+ expect(result.commands[0].to).toBe('Lead');
732
+ expect(result.commands[1].to).toBe('Alice');
733
+ });
734
+
735
+ it('filters instructional text in fenced inline mode', () => {
736
+ const input = '->relay:Lead <<<\nExample: how to send\n>>>\n';
737
+ const result = parser.parse(input);
738
+ expect(result.commands).toHaveLength(0);
739
+ });
740
+ });
741
+
742
+ describe('Edge cases', () => {
743
+ it('inline commands must be complete in single chunk (no cross-chunk buffering)', () => {
744
+ // Inline relay commands split across chunks are NOT detected
745
+ // This is intentional for minimal terminal interference
746
+ const result1 = parser.parse('->relay:agent2 Partial');
747
+ expect(result1.commands).toHaveLength(0);
748
+ expect(result1.output).toBe('->relay:agent2 Partial'); // Passed through
749
+
750
+ const result2 = parser.parse(' line\n');
751
+ expect(result2.commands).toHaveLength(0); // Not detected
752
+ expect(result2.output).toBe(' line\n'); // Passed through
753
+ });
754
+
755
+ it('buffers partial block correctly', () => {
756
+ const result1 = parser.parse('[[RELAY]]{"to":"agent2"');
757
+ expect(result1.commands).toHaveLength(0);
758
+
759
+ const result2 = parser.parse(',"type":"message","body":"Test"}[[/RELAY]]\n');
760
+ expect(result2.commands).toHaveLength(1);
761
+ expect(result2.commands[0].body).toBe('Test');
762
+ });
763
+
764
+ it('flush() does not detect incomplete inline commands (no buffering)', () => {
765
+ // Incomplete inline commands without newline are passed through, not buffered
766
+ const result1 = parser.parse('->relay:agent2 No newline');
767
+ expect(result1.output).toBe('->relay:agent2 No newline'); // Passed through
768
+
769
+ const result = parser.flush();
770
+ expect(result.commands).toHaveLength(0); // Not detected
771
+ });
772
+
773
+ it('flush() clears all state', () => {
774
+ parser.parse('```\n->relay:agent2 In fence');
775
+ parser.flush();
776
+
777
+ const result = parser.parse('->relay:agent3 After flush\n');
778
+ expect(result.commands).toHaveLength(1);
779
+ expect(result.commands[0].to).toBe('agent3');
780
+ });
781
+
782
+ it('reset() clears state', () => {
783
+ parser.parse('[[RELAY]]{"to":"agent2"');
784
+ parser.reset();
785
+
786
+ const result = parser.parse('Regular output\n');
787
+ expect(result.commands).toHaveLength(0);
788
+ expect(result.output).toBe('Regular output\n');
789
+ });
790
+
791
+ it('reset() clears code fence state', () => {
792
+ parser.parse('```\n->relay:agent2 test');
793
+ parser.reset();
794
+
795
+ const result = parser.parse('->relay:agent3 After reset\n');
796
+ expect(result.commands).toHaveLength(1);
797
+ expect(result.commands[0].to).toBe('agent3');
798
+ });
799
+
800
+ it('handles empty input', () => {
801
+ const result = parser.parse('');
802
+
803
+ expect(result.commands).toHaveLength(0);
804
+ expect(result.output).toBe('');
805
+ });
806
+
807
+ it('handles only newlines', () => {
808
+ const result = parser.parse('\n\n\n');
809
+
810
+ expect(result.commands).toHaveLength(0);
811
+ expect(result.output).toBe('\n\n\n');
812
+ });
813
+
814
+ it('handles block size limit', () => {
815
+ const smallParser = new OutputParser({ maxBlockBytes: 50 });
816
+ const largeBlock = '[[RELAY]]' + 'x'.repeat(100) + '[[/RELAY]]\n';
817
+
818
+ const result = smallParser.parse(largeBlock);
819
+ expect(result.commands).toHaveLength(0);
820
+ });
821
+
822
+ it('preserves regular output', () => {
823
+ const input = 'Regular output line 1\nRegular output line 2\n';
824
+ const result = parser.parse(input);
825
+
826
+ expect(result.commands).toHaveLength(0);
827
+ expect(result.output).toBe(input);
828
+ });
829
+
830
+ it('mixes relay commands with regular output', () => {
831
+ const input = 'Output 1\n->relay:agent2 Message\nOutput 2\n';
832
+ const result = parser.parse(input);
833
+
834
+ expect(result.commands).toHaveLength(1);
835
+ expect(result.output).toBe('Output 1\nOutput 2\n');
836
+ });
837
+
838
+ it('handles incomplete block at flush', () => {
839
+ parser.parse('[[RELAY]]{"to":"agent2","type":"message"');
840
+ const result = parser.flush();
841
+
842
+ expect(result.commands).toHaveLength(0);
843
+ });
844
+
845
+ it('handles target with special characters', () => {
846
+ const result = parser.parse('->relay:agent_2-test.v1 Message\n');
847
+
848
+ expect(result.commands).toHaveLength(1);
849
+ expect(result.commands[0].to).toBe('agent_2-test.v1');
850
+ });
851
+
852
+ it('handles empty body in inline format', () => {
853
+ // Note: The regex requires at least one character for the body (.+)
854
+ // so this actually won't match as a command
855
+ const result = parser.parse('->relay:agent2 Test\n');
856
+
857
+ expect(result.commands).toHaveLength(1);
858
+ expect(result.commands[0].body).toBe('Test');
859
+ });
860
+
861
+ it('handles empty body in block format', () => {
862
+ const result = parser.parse('[[RELAY]]{"to":"agent2","type":"message","body":""}[[/RELAY]]\n');
863
+
864
+ expect(result.commands).toHaveLength(1);
865
+ expect(result.commands[0].body).toBe('');
866
+ });
867
+ });
868
+
869
+ describe('Parser options', () => {
870
+ it('disables inline format when enableInline is false', () => {
871
+ const customParser = new OutputParser({ enableInline: false });
872
+ const result = customParser.parse('->relay:agent2 Message\n');
873
+
874
+ expect(result.commands).toHaveLength(0);
875
+ expect(result.output).toBe('->relay:agent2 Message\n');
876
+ });
877
+
878
+ it('disables block format when enableBlock is false', () => {
879
+ const customParser = new OutputParser({ enableBlock: false });
880
+ const result = customParser.parse('[[RELAY]]{"to":"agent2","type":"message","body":"Test"}[[/RELAY]]\n');
881
+
882
+ expect(result.commands).toHaveLength(0);
883
+ expect(result.output).toBe('[[RELAY]]{"to":"agent2","type":"message","body":"Test"}[[/RELAY]]\n');
884
+ });
885
+
886
+ it('respects custom maxBlockBytes', () => {
887
+ const customParser = new OutputParser({ maxBlockBytes: 30 });
888
+ // Create a block that will exceed 30 bytes after [[RELAY]] is removed
889
+ const largeJson = '{"to":"a","type":"message","body":"' + 'x'.repeat(50) + '"}';
890
+ const input = `[[RELAY]]\n${largeJson}\n[[/RELAY]]\n`;
891
+
892
+ // This should exceed 30 bytes
893
+ const result = customParser.parse(input);
894
+ expect(result.commands).toHaveLength(0);
895
+ });
896
+
897
+ it('preserves text starting with [Letter like [Agent Relay]', () => {
898
+ // Regression test: The orphaned CSI pattern should NOT strip [A from [Agent
899
+ // because [A without digits is not a valid orphaned CSI sequence
900
+ const input = '[Agent Relay] It\'s been 15 minutes. Please output a [[SUMMARY]] block\n';
901
+ const result = parser.parse(input);
902
+
903
+ expect(result.commands).toHaveLength(0);
904
+ expect(result.output).toBe(input);
905
+ // Specifically verify [Agent is preserved, not stripped to 'gent'
906
+ expect(result.output).toContain('[Agent');
907
+ });
908
+ });
909
+
910
+ describe('Complex scenarios', () => {
911
+ it('handles multiple commands in one parse call', () => {
912
+ const input = `->relay:agent1 First
913
+ Regular output
914
+ ->relay:agent2 Second
915
+ [[RELAY]]{"to":"agent3","type":"message","body":"Third"}[[/RELAY]]
916
+ More output
917
+ `;
918
+ const result = parser.parse(input);
919
+
920
+ expect(result.commands).toHaveLength(3);
921
+ expect(result.commands[0].to).toBe('agent1');
922
+ expect(result.commands[1].to).toBe('agent2');
923
+ expect(result.commands[2].to).toBe('agent3');
924
+ expect(result.output).toContain('Regular output');
925
+ expect(result.output).toContain('More output');
926
+ });
927
+
928
+ it('handles incremental parsing with multiple parse calls', () => {
929
+ parser.parse('Line 1\n');
930
+ parser.parse('->relay:agent1 Message 1\n');
931
+ parser.parse('Line 2\n');
932
+ const result = parser.parse('->relay:agent2 Message 2\n');
933
+
934
+ // Only the last parse call returns commands from that call
935
+ expect(result.commands).toHaveLength(1);
936
+ expect(result.commands[0].to).toBe('agent2');
937
+ });
938
+
939
+ it('handles block spanning multiple parse calls', () => {
940
+ const result1 = parser.parse('[[RELAY]]\n');
941
+ expect(result1.commands).toHaveLength(0);
942
+
943
+ const result2 = parser.parse('{"to":"agent2",\n');
944
+ expect(result2.commands).toHaveLength(0);
945
+
946
+ const result3 = parser.parse('"type":"message","body":"Test"}\n');
947
+ expect(result3.commands).toHaveLength(0);
948
+
949
+ const result4 = parser.parse('[[/RELAY]]\n');
950
+ expect(result4.commands).toHaveLength(1);
951
+ expect(result4.commands[0].to).toBe('agent2');
952
+ });
953
+
954
+ it('preserves order of commands and output', () => {
955
+ const input = `Out1
956
+ ->relay:agent1 Msg1
957
+ Out2
958
+ ->relay:agent2 Msg2
959
+ Out3
960
+ `;
961
+ const result = parser.parse(input);
962
+
963
+ const outputLines = result.output.split('\n').filter(l => l.trim());
964
+ expect(outputLines).toEqual(['Out1', 'Out2', 'Out3']);
965
+ expect(result.commands[0].to).toBe('agent1');
966
+ expect(result.commands[1].to).toBe('agent2');
967
+ });
968
+ });
969
+
970
+ describe('Cross-project messaging syntax', () => {
971
+ it('parses project:agent syntax for cross-project messaging', () => {
972
+ const result = parser.parse('->relay:myproject:agent2 Hello from another project\n');
973
+
974
+ expect(result.commands).toHaveLength(1);
975
+ expect(result.commands[0]).toMatchObject({
976
+ to: 'agent2',
977
+ project: 'myproject',
978
+ kind: 'message',
979
+ body: 'Hello from another project',
980
+ });
981
+ });
982
+
983
+ it('parses local agent without project', () => {
984
+ const result = parser.parse('->relay:agent2 Local message\n');
985
+
986
+ expect(result.commands).toHaveLength(1);
987
+ expect(result.commands[0].to).toBe('agent2');
988
+ expect(result.commands[0].project).toBeUndefined();
989
+ });
990
+
991
+ it('handles project names with dashes and underscores', () => {
992
+ const result = parser.parse('->relay:my-project_v2:some-agent Hello\n');
993
+
994
+ expect(result.commands).toHaveLength(1);
995
+ expect(result.commands[0].to).toBe('some-agent');
996
+ expect(result.commands[0].project).toBe('my-project_v2');
997
+ });
998
+
999
+ it('only splits on first colon to allow colons in agent names', () => {
1000
+ const result = parser.parse('->relay:proj:agent:with:colons Message\n');
1001
+
1002
+ expect(result.commands).toHaveLength(1);
1003
+ expect(result.commands[0].to).toBe('agent:with:colons');
1004
+ expect(result.commands[0].project).toBe('proj');
1005
+ });
1006
+
1007
+ it('handles cross-project with ->thinking: variant', () => {
1008
+ const result = parser.parse('->thinking:otherproj:agent2 Thinking about something\n');
1009
+
1010
+ expect(result.commands).toHaveLength(1);
1011
+ expect(result.commands[0]).toMatchObject({
1012
+ to: 'agent2',
1013
+ project: 'otherproj',
1014
+ kind: 'thinking',
1015
+ body: 'Thinking about something',
1016
+ });
1017
+ });
1018
+
1019
+ it('handles cross-project broadcast', () => {
1020
+ const result = parser.parse('->relay:prod-project:* Broadcast to all in prod\n');
1021
+
1022
+ expect(result.commands).toHaveLength(1);
1023
+ expect(result.commands[0].to).toBe('*');
1024
+ expect(result.commands[0].project).toBe('prod-project');
1025
+ });
1026
+
1027
+ it('handles cross-project with thread syntax', () => {
1028
+ const result = parser.parse('->relay:proj:agent [thread:abc123] Threaded cross-project message\n');
1029
+
1030
+ expect(result.commands).toHaveLength(1);
1031
+ expect(result.commands[0]).toMatchObject({
1032
+ to: 'agent',
1033
+ project: 'proj',
1034
+ thread: 'abc123',
1035
+ body: 'Threaded cross-project message',
1036
+ });
1037
+ });
1038
+
1039
+ it('handles cross-project thread syntax in inline messages', () => {
1040
+ const result = parser.parse('->relay:Backend [thread:frontend-app:auth-flow] Can you check session handling?\n');
1041
+
1042
+ expect(result.commands).toHaveLength(1);
1043
+ expect(result.commands[0]).toMatchObject({
1044
+ to: 'Backend',
1045
+ thread: 'auth-flow',
1046
+ threadProject: 'frontend-app',
1047
+ body: 'Can you check session handling?',
1048
+ });
1049
+ });
1050
+
1051
+ it('parses inline await tag with default timeout', () => {
1052
+ const result = parser.parse('->relay:agent [await] Please confirm\n');
1053
+
1054
+ expect(result.commands).toHaveLength(1);
1055
+ expect(result.commands[0]).toMatchObject({
1056
+ to: 'agent',
1057
+ body: 'Please confirm',
1058
+ sync: { blocking: true, timeoutMs: undefined },
1059
+ });
1060
+ });
1061
+
1062
+ it('parses inline await tag with timeout', () => {
1063
+ const result = parser.parse('->relay:agent [await:30s] Please confirm\n');
1064
+
1065
+ expect(result.commands).toHaveLength(1);
1066
+ expect(result.commands[0]?.sync?.timeoutMs).toBe(30000);
1067
+ expect(result.commands[0]?.sync?.blocking).toBe(true);
1068
+ });
1069
+
1070
+ it('handles cross-project thread with cross-project target', () => {
1071
+ // Both target and thread are cross-project
1072
+ const result = parser.parse('->relay:backend-api:AuthService [thread:shared:auth-session] Verify token\n');
1073
+
1074
+ expect(result.commands).toHaveLength(1);
1075
+ expect(result.commands[0]).toMatchObject({
1076
+ to: 'AuthService',
1077
+ project: 'backend-api',
1078
+ thread: 'auth-session',
1079
+ threadProject: 'shared',
1080
+ body: 'Verify token',
1081
+ });
1082
+ });
1083
+
1084
+ it('parses cross-project in block format with explicit project field', () => {
1085
+ const result = parser.parse('[[RELAY]]{"to":"agent2","project":"otherproj","type":"message","body":"Hello"}[[/RELAY]]\n');
1086
+
1087
+ expect(result.commands).toHaveLength(1);
1088
+ expect(result.commands[0]).toMatchObject({
1089
+ to: 'agent2',
1090
+ project: 'otherproj',
1091
+ kind: 'message',
1092
+ body: 'Hello',
1093
+ });
1094
+ });
1095
+
1096
+ it('parses cross-project in block format with colon syntax in to field', () => {
1097
+ const result = parser.parse('[[RELAY]]{"to":"myproj:agent2","type":"message","body":"Hi"}[[/RELAY]]\n');
1098
+
1099
+ expect(result.commands).toHaveLength(1);
1100
+ expect(result.commands[0]).toMatchObject({
1101
+ to: 'agent2',
1102
+ project: 'myproj',
1103
+ kind: 'message',
1104
+ body: 'Hi',
1105
+ });
1106
+ });
1107
+
1108
+ it('explicit project field takes precedence over colon syntax in block format', () => {
1109
+ const result = parser.parse('[[RELAY]]{"to":"ignored:agent2","project":"explicit","type":"message","body":"Test"}[[/RELAY]]\n');
1110
+
1111
+ expect(result.commands).toHaveLength(1);
1112
+ // When explicit project is set, the to field is used as-is
1113
+ expect(result.commands[0].to).toBe('ignored:agent2');
1114
+ expect(result.commands[0].project).toBe('explicit');
1115
+ });
1116
+ });
1117
+
1118
+ describe('Configurable prefix', () => {
1119
+ it('uses default ->relay: prefix', () => {
1120
+ const defaultParser = new OutputParser();
1121
+ expect(defaultParser.prefix).toBe('->relay:');
1122
+
1123
+ const result = defaultParser.parse('->relay:agent2 Hello\n');
1124
+ expect(result.commands).toHaveLength(1);
1125
+ expect(result.commands[0].to).toBe('agent2');
1126
+ });
1127
+
1128
+ it('uses custom prefix >>', () => {
1129
+ const customParser = new OutputParser({ prefix: '>>' });
1130
+ expect(customParser.prefix).toBe('>>');
1131
+
1132
+ const result = customParser.parse('>>agent2 Hello from Gemini\n');
1133
+ expect(result.commands).toHaveLength(1);
1134
+ expect(result.commands[0].to).toBe('agent2');
1135
+ expect(result.commands[0].body).toBe('Hello from Gemini');
1136
+ });
1137
+
1138
+ it('ignores ->relay: when using @msg: prefix', () => {
1139
+ const customParser = new OutputParser({ prefix: '@msg:' });
1140
+
1141
+ const result = customParser.parse('->relay:agent2 Should not match\n');
1142
+ expect(result.commands).toHaveLength(0);
1143
+ expect(result.output).toBe('->relay:agent2 Should not match\n');
1144
+ });
1145
+
1146
+ it('uses custom prefix /relay', () => {
1147
+ const customParser = new OutputParser({ prefix: '/relay' });
1148
+
1149
+ const result = customParser.parse('/relayagent2 Slash style\n');
1150
+ expect(result.commands).toHaveLength(1);
1151
+ expect(result.commands[0].to).toBe('agent2');
1152
+ });
1153
+
1154
+ it('handles prefix with special regex characters', () => {
1155
+ const customParser = new OutputParser({ prefix: '$$msg:' });
1156
+
1157
+ const result = customParser.parse('$$msg:agent2 Dollar prefix\n');
1158
+ expect(result.commands).toHaveLength(1);
1159
+ expect(result.commands[0].to).toBe('agent2');
1160
+ });
1161
+
1162
+ it('supports >> prefix with bullet points', () => {
1163
+ const customParser = new OutputParser({ prefix: '>>' });
1164
+
1165
+ const result = customParser.parse('- >>agent2 Bulleted message\n');
1166
+ expect(result.commands).toHaveLength(1);
1167
+ expect(result.commands[0].to).toBe('agent2');
1168
+ });
1169
+
1170
+ it('supports broadcast with custom prefix', () => {
1171
+ const customParser = new OutputParser({ prefix: '>>' });
1172
+
1173
+ const result = customParser.parse('>>* Broadcast to all\n');
1174
+ expect(result.commands).toHaveLength(1);
1175
+ expect(result.commands[0].to).toBe('*');
1176
+ expect(result.commands[0].body).toBe('Broadcast to all');
1177
+ });
1178
+ });
1179
+ });
1180
+
1181
+ describe('parseSummaryFromOutput', () => {
1182
+ it('parses valid JSON summary block', () => {
1183
+ const output = `Some output
1184
+ [[SUMMARY]]
1185
+ {
1186
+ "currentTask": "Implementing auth",
1187
+ "context": "Working on login flow",
1188
+ "files": ["src/auth.ts"]
1189
+ }
1190
+ [[/SUMMARY]]
1191
+ More output`;
1192
+
1193
+ const summary = parseSummaryFromOutput(output);
1194
+ expect(summary).not.toBeNull();
1195
+ expect(summary).toEqual({
1196
+ currentTask: 'Implementing auth',
1197
+ context: 'Working on login flow',
1198
+ files: ['src/auth.ts'],
1199
+ });
1200
+ });
1201
+
1202
+ it('parses summary with all fields', () => {
1203
+ const output = `[[SUMMARY]]{"currentTask":"Task 1","completedTasks":["T0"],"decisions":["Use JWT"],"context":"Auth work","files":["a.ts","b.ts"]}[[/SUMMARY]]`;
1204
+
1205
+ const summary = parseSummaryFromOutput(output);
1206
+ expect(summary).toEqual({
1207
+ currentTask: 'Task 1',
1208
+ completedTasks: ['T0'],
1209
+ decisions: ['Use JWT'],
1210
+ context: 'Auth work',
1211
+ files: ['a.ts', 'b.ts'],
1212
+ });
1213
+ });
1214
+
1215
+ it('returns null when no summary block exists', () => {
1216
+ const output = 'Just regular output without any summary block';
1217
+
1218
+ const summary = parseSummaryFromOutput(output);
1219
+ expect(summary).toBeNull();
1220
+ });
1221
+
1222
+ it('returns null for invalid JSON', () => {
1223
+ const output = '[[SUMMARY]]not valid json[[/SUMMARY]]';
1224
+
1225
+ const summary = parseSummaryFromOutput(output);
1226
+ expect(summary).toBeNull();
1227
+ });
1228
+
1229
+ it('handles empty summary block', () => {
1230
+ const output = '[[SUMMARY]]{}[[/SUMMARY]]';
1231
+
1232
+ const summary = parseSummaryFromOutput(output);
1233
+ expect(summary).toEqual({});
1234
+ });
1235
+ });
1236
+
1237
+ describe('parseSessionEndFromOutput', () => {
1238
+ it('parses valid JSON session end block', () => {
1239
+ const output = `Some output
1240
+ [[SESSION_END]]
1241
+ {
1242
+ "summary": "Completed auth module",
1243
+ "completedTasks": ["login", "logout"]
1244
+ }
1245
+ [[/SESSION_END]]
1246
+ More output`;
1247
+
1248
+ const result = parseSessionEndFromOutput(output);
1249
+ expect(result).not.toBeNull();
1250
+ expect(result).toEqual({
1251
+ summary: 'Completed auth module',
1252
+ completedTasks: ['login', 'logout'],
1253
+ });
1254
+ });
1255
+
1256
+ it('parses empty session end block', () => {
1257
+ const output = '[[SESSION_END]][[/SESSION_END]]';
1258
+
1259
+ const result = parseSessionEndFromOutput(output);
1260
+ expect(result).toEqual({});
1261
+ });
1262
+
1263
+ it('parses session end with only summary', () => {
1264
+ const output = '[[SESSION_END]]{"summary":"All done!"}[[/SESSION_END]]';
1265
+
1266
+ const result = parseSessionEndFromOutput(output);
1267
+ expect(result).toEqual({ summary: 'All done!' });
1268
+ });
1269
+
1270
+ it('treats non-JSON content as plain summary', () => {
1271
+ const output = '[[SESSION_END]]Work completed successfully[[/SESSION_END]]';
1272
+
1273
+ const result = parseSessionEndFromOutput(output);
1274
+ expect(result).toEqual({ summary: 'Work completed successfully' });
1275
+ });
1276
+
1277
+ it('returns null when no session end block exists', () => {
1278
+ const output = 'Regular output without session end';
1279
+
1280
+ const result = parseSessionEndFromOutput(output);
1281
+ expect(result).toBeNull();
1282
+ });
1283
+
1284
+ it('handles multiline plain text summary', () => {
1285
+ const output = `[[SESSION_END]]
1286
+ Completed the following:
1287
+ - Feature A
1288
+ - Feature B
1289
+ [[/SESSION_END]]`;
1290
+
1291
+ const result = parseSessionEndFromOutput(output);
1292
+ expect(result?.summary).toContain('Completed the following:');
1293
+ expect(result?.summary).toContain('Feature A');
1294
+ });
1295
+ });
1296
+
1297
+ describe('parseRelayMetadataFromOutput', () => {
1298
+ it('parses valid metadata block', () => {
1299
+ const output = `Some output
1300
+ [[RELAY_METADATA]]
1301
+ {
1302
+ "subject": "Task update",
1303
+ "importance": 80,
1304
+ "replyTo": "msg-abc123",
1305
+ "ackRequired": true
1306
+ }
1307
+ [[/RELAY_METADATA]]
1308
+ More output`;
1309
+
1310
+ const result = parseRelayMetadataFromOutput(output);
1311
+ expect(result.found).toBe(true);
1312
+ expect(result.valid).toBe(true);
1313
+ expect(result.metadata).toEqual({
1314
+ subject: 'Task update',
1315
+ importance: 80,
1316
+ replyTo: 'msg-abc123',
1317
+ ackRequired: true,
1318
+ });
1319
+ expect(result.rawContent).toContain('"subject"');
1320
+ });
1321
+
1322
+ it('returns not found when no metadata block exists', () => {
1323
+ const output = 'Regular output without any metadata block';
1324
+
1325
+ const result = parseRelayMetadataFromOutput(output);
1326
+ expect(result.found).toBe(false);
1327
+ expect(result.valid).toBe(false);
1328
+ expect(result.metadata).toBeNull();
1329
+ expect(result.rawContent).toBeNull();
1330
+ });
1331
+
1332
+ it('returns invalid for malformed JSON', () => {
1333
+ const output = '[[RELAY_METADATA]]not valid json[[/RELAY_METADATA]]';
1334
+
1335
+ const result = parseRelayMetadataFromOutput(output);
1336
+ expect(result.found).toBe(true);
1337
+ expect(result.valid).toBe(false);
1338
+ expect(result.metadata).toBeNull();
1339
+ expect(result.rawContent).toBe('not valid json');
1340
+ });
1341
+
1342
+ it('handles empty metadata block', () => {
1343
+ const output = '[[RELAY_METADATA]]{}[[/RELAY_METADATA]]';
1344
+
1345
+ const result = parseRelayMetadataFromOutput(output);
1346
+ expect(result.found).toBe(true);
1347
+ expect(result.valid).toBe(true);
1348
+ expect(result.metadata).toEqual({});
1349
+ });
1350
+
1351
+ it('parses metadata with partial fields', () => {
1352
+ const output = '[[RELAY_METADATA]]{"subject":"Quick note"}[[/RELAY_METADATA]]';
1353
+
1354
+ const result = parseRelayMetadataFromOutput(output);
1355
+ expect(result.found).toBe(true);
1356
+ expect(result.valid).toBe(true);
1357
+ expect(result.metadata).toEqual({ subject: 'Quick note' });
1358
+ });
1359
+ });