@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,540 @@
1
+ /**
2
+ * Tests for BaseWrapper abstract class
3
+ *
4
+ * These tests verify the shared functionality extracted
5
+ * from PtyWrapper and TmuxWrapper into a common base class.
6
+ */
7
+
8
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
9
+ import { BaseWrapper } from './base-wrapper.js';
10
+ import type { QueuedMessage } from './shared.js';
11
+ import type { ParsedSummary } from './parser.js';
12
+ import type { SendPayload, SendMeta } from '@agent-relay/protocol/types';
13
+
14
+ // Mock the client module
15
+ vi.mock('./client.js', () => ({
16
+ RelayClient: vi.fn().mockImplementation((name: string, _options?: any) => ({
17
+ name,
18
+ state: 'READY' as string,
19
+ sentMessages: [] as Array<{ to: string; body: string; kind: string; meta?: unknown }>,
20
+ onMessage: null as ((from: string, payload: any, messageId: string, meta?: any, originalTo?: string) => void) | null,
21
+ sendMessage: vi.fn().mockImplementation(function(this: any, to: string, body: string, kind: string, meta?: unknown) {
22
+ this.sentMessages.push({ to, body, kind, meta });
23
+ return true;
24
+ }),
25
+ destroy: vi.fn(),
26
+ })),
27
+ }));
28
+
29
+ // Mock the continuity module
30
+ vi.mock('@agent-relay/continuity', () => ({
31
+ getContinuityManager: vi.fn(() => mockContinuityManager),
32
+ parseContinuityCommand: vi.fn(),
33
+ hasContinuityCommand: vi.fn(() => false),
34
+ }));
35
+
36
+ // Mock continuity manager instance
37
+ const mockContinuityManager = {
38
+ ledgers: new Map<string, any>(),
39
+ savedSummaries: [] as Array<{ agentName: string; updates: any }>,
40
+
41
+ async getOrCreateLedger(agentName: string, cli: string) {
42
+ if (!this.ledgers.has(agentName)) {
43
+ this.ledgers.set(agentName, {
44
+ agentName,
45
+ agentId: `test-agent-id-${agentName}`,
46
+ cli,
47
+ });
48
+ }
49
+ return this.ledgers.get(agentName);
50
+ },
51
+
52
+ async findLedgerByAgentId(agentId: string) {
53
+ for (const ledger of this.ledgers.values()) {
54
+ if (ledger.agentId === agentId) return ledger;
55
+ }
56
+ return null;
57
+ },
58
+
59
+ async saveLedger(agentName: string, updates: any) {
60
+ this.savedSummaries.push({ agentName, updates });
61
+ },
62
+
63
+ async handleCommand() {
64
+ return null;
65
+ },
66
+
67
+ // Reset for tests
68
+ reset() {
69
+ this.ledgers.clear();
70
+ this.savedSummaries = [];
71
+ },
72
+ };
73
+
74
+ /**
75
+ * Concrete test implementation of BaseWrapper
76
+ */
77
+ class TestWrapper extends BaseWrapper {
78
+ // Track calls for testing
79
+ spawnCalls: Array<{ name: string; cli: string; task: string }> = [];
80
+ releaseCalls: string[] = [];
81
+ injectedMessages: string[] = [];
82
+
83
+ // Expose protected members for testing
84
+ get testMessageQueue(): QueuedMessage[] {
85
+ return this.messageQueue;
86
+ }
87
+
88
+ get testReceivedMessageIds(): Set<string> {
89
+ return this.receivedMessageIds;
90
+ }
91
+
92
+ get testSentMessageHashes(): Set<string> {
93
+ return this.sentMessageHashes;
94
+ }
95
+
96
+ get testProcessedSpawnCommands(): Set<string> {
97
+ return this.processedSpawnCommands;
98
+ }
99
+
100
+ get testProcessedReleaseCommands(): Set<string> {
101
+ return this.processedReleaseCommands;
102
+ }
103
+
104
+ get testClient() {
105
+ return this.client;
106
+ }
107
+
108
+ get testSessionEndProcessed(): boolean {
109
+ return this.sessionEndProcessed;
110
+ }
111
+
112
+ set testSessionEndProcessed(value: boolean) {
113
+ this.sessionEndProcessed = value;
114
+ }
115
+
116
+ get testLastSummaryRawContent(): string {
117
+ return this.lastSummaryRawContent;
118
+ }
119
+
120
+ set testLastSummaryRawContent(value: string) {
121
+ this.lastSummaryRawContent = value;
122
+ }
123
+
124
+ get testSessionEndData() {
125
+ return this.sessionEndData;
126
+ }
127
+
128
+ set testSessionEndData(value: any) {
129
+ this.sessionEndData = value;
130
+ }
131
+
132
+ get testContinuity() {
133
+ return this.continuity;
134
+ }
135
+
136
+ get testConfig() {
137
+ return this.config;
138
+ }
139
+
140
+ get testAgentId() {
141
+ return this.agentId;
142
+ }
143
+
144
+ // Abstract method implementations
145
+ async start(): Promise<void> {
146
+ this.running = true;
147
+ await this.initializeAgentId();
148
+ }
149
+
150
+ stop(): void {
151
+ this.running = false;
152
+ }
153
+
154
+ protected async performInjection(content: string): Promise<void> {
155
+ this.injectedMessages.push(content);
156
+ }
157
+
158
+ protected getCleanOutput(): string {
159
+ return '';
160
+ }
161
+
162
+ // Expose protected methods for testing
163
+ testHandleIncomingMessage(
164
+ from: string,
165
+ payload: SendPayload,
166
+ messageId: string,
167
+ meta?: SendMeta,
168
+ originalTo?: string
169
+ ): void {
170
+ this.handleIncomingMessage(from, payload, messageId, meta, originalTo);
171
+ }
172
+
173
+ testSendRelayCommand(cmd: { to: string; body: string; thread?: string }): void {
174
+ this.sendRelayCommand(cmd);
175
+ }
176
+
177
+ testParseSpawnReleaseCommands(content: string): void {
178
+ this.parseSpawnReleaseCommands(content);
179
+ }
180
+
181
+ async testSaveSummaryToLedger(summary: ParsedSummary): Promise<void> {
182
+ await this.saveSummaryToLedger(summary);
183
+ }
184
+
185
+ testJoinContinuationLines(content: string): string {
186
+ return this.joinContinuationLines(content);
187
+ }
188
+
189
+ // Override executeSpawn/Release to track calls instead of making HTTP requests
190
+ protected async executeSpawn(name: string, cli: string, task: string): Promise<void> {
191
+ this.spawnCalls.push({ name, cli, task });
192
+ if (this.config.onSpawn) {
193
+ await this.config.onSpawn(name, cli, task);
194
+ }
195
+ }
196
+
197
+ protected async executeRelease(name: string): Promise<void> {
198
+ this.releaseCalls.push(name);
199
+ if (this.config.onRelease) {
200
+ await this.config.onRelease(name);
201
+ }
202
+ }
203
+ }
204
+
205
+ // ============================================================================
206
+ // TESTS
207
+ // ============================================================================
208
+
209
+ describe('BaseWrapper', () => {
210
+ let wrapper: TestWrapper;
211
+
212
+ beforeEach(() => {
213
+ // Reset mock continuity manager state
214
+ mockContinuityManager.reset();
215
+
216
+ wrapper = new TestWrapper({
217
+ name: 'TestAgent',
218
+ command: 'claude',
219
+ });
220
+ });
221
+
222
+ afterEach(() => {
223
+ wrapper.stop();
224
+ });
225
+
226
+ describe('message queue management', () => {
227
+ it('queues incoming messages', () => {
228
+ wrapper.testHandleIncomingMessage(
229
+ 'Sender',
230
+ { body: 'Hello', kind: 'message' },
231
+ 'msg-1'
232
+ );
233
+
234
+ expect(wrapper.testMessageQueue).toHaveLength(1);
235
+ expect(wrapper.testMessageQueue[0].from).toBe('Sender');
236
+ expect(wrapper.testMessageQueue[0].body).toBe('Hello');
237
+ });
238
+
239
+ it('deduplicates messages by ID', () => {
240
+ wrapper.testHandleIncomingMessage(
241
+ 'Sender',
242
+ { body: 'Hello', kind: 'message' },
243
+ 'msg-1'
244
+ );
245
+ wrapper.testHandleIncomingMessage(
246
+ 'Sender',
247
+ { body: 'Hello again', kind: 'message' },
248
+ 'msg-1' // Same ID
249
+ );
250
+
251
+ expect(wrapper.testMessageQueue).toHaveLength(1);
252
+ });
253
+
254
+ it('allows different messages with different IDs', () => {
255
+ wrapper.testHandleIncomingMessage(
256
+ 'Sender',
257
+ { body: 'Hello', kind: 'message' },
258
+ 'msg-1'
259
+ );
260
+ wrapper.testHandleIncomingMessage(
261
+ 'Sender',
262
+ { body: 'World', kind: 'message' },
263
+ 'msg-2'
264
+ );
265
+
266
+ expect(wrapper.testMessageQueue).toHaveLength(2);
267
+ });
268
+
269
+ it('preserves message metadata', () => {
270
+ wrapper.testHandleIncomingMessage(
271
+ 'Sender',
272
+ { body: 'Hello', kind: 'message', thread: 'thread-1' },
273
+ 'msg-1',
274
+ { importance: 80 }
275
+ );
276
+
277
+ expect(wrapper.testMessageQueue[0].thread).toBe('thread-1');
278
+ expect(wrapper.testMessageQueue[0].importance).toBe(80);
279
+ });
280
+
281
+ it('limits dedup set size', () => {
282
+ // Add 1001 messages to trigger cleanup
283
+ for (let i = 0; i < 1001; i++) {
284
+ wrapper.testHandleIncomingMessage(
285
+ 'Sender',
286
+ { body: `Message ${i}`, kind: 'message' },
287
+ `msg-${i}`
288
+ );
289
+ }
290
+
291
+ // Set should not grow unbounded
292
+ expect(wrapper.testReceivedMessageIds.size).toBeLessThanOrEqual(1001);
293
+ });
294
+ });
295
+
296
+ describe('spawn/release handling', () => {
297
+ it('parses single-line spawn commands', () => {
298
+ wrapper.testParseSpawnReleaseCommands(
299
+ '->relay:spawn Worker claude "implement auth"'
300
+ );
301
+
302
+ expect(wrapper.spawnCalls).toHaveLength(1);
303
+ expect(wrapper.spawnCalls[0]).toEqual({
304
+ name: 'Worker',
305
+ cli: 'claude',
306
+ task: 'implement auth',
307
+ });
308
+ });
309
+
310
+ it('parses fenced spawn commands', () => {
311
+ wrapper.testParseSpawnReleaseCommands(
312
+ '->relay:spawn Worker claude <<<\nImplement authentication\nwith JWT tokens\n>>>'
313
+ );
314
+
315
+ expect(wrapper.spawnCalls).toHaveLength(1);
316
+ expect(wrapper.spawnCalls[0].name).toBe('Worker');
317
+ expect(wrapper.spawnCalls[0].task).toContain('Implement authentication');
318
+ });
319
+
320
+ it('deduplicates spawn commands', () => {
321
+ wrapper.testParseSpawnReleaseCommands(
322
+ '->relay:spawn Worker claude "task"'
323
+ );
324
+ wrapper.testParseSpawnReleaseCommands(
325
+ '->relay:spawn Worker claude "task"' // Same command
326
+ );
327
+
328
+ expect(wrapper.spawnCalls).toHaveLength(1);
329
+ });
330
+
331
+ it('calls onSpawn callback', async () => {
332
+ const onSpawn = vi.fn();
333
+ wrapper.testConfig.onSpawn = onSpawn;
334
+
335
+ wrapper.testParseSpawnReleaseCommands(
336
+ '->relay:spawn Worker claude "task"'
337
+ );
338
+
339
+ // Wait for async callback
340
+ await new Promise(resolve => setTimeout(resolve, 10));
341
+
342
+ expect(onSpawn).toHaveBeenCalledWith('Worker', 'claude', 'task');
343
+ });
344
+
345
+ it('parses release commands', () => {
346
+ wrapper.testParseSpawnReleaseCommands('->relay:release Worker');
347
+
348
+ expect(wrapper.releaseCalls).toHaveLength(1);
349
+ expect(wrapper.releaseCalls[0]).toBe('Worker');
350
+ });
351
+
352
+ it('deduplicates release commands', () => {
353
+ wrapper.testParseSpawnReleaseCommands('->relay:release Worker');
354
+ wrapper.testParseSpawnReleaseCommands('->relay:release Worker');
355
+
356
+ expect(wrapper.releaseCalls).toHaveLength(1);
357
+ });
358
+
359
+ it('calls onRelease callback', async () => {
360
+ const onRelease = vi.fn();
361
+ wrapper.testConfig.onRelease = onRelease;
362
+
363
+ wrapper.testParseSpawnReleaseCommands('->relay:release Worker');
364
+
365
+ await new Promise(resolve => setTimeout(resolve, 10));
366
+
367
+ expect(onRelease).toHaveBeenCalledWith('Worker');
368
+ });
369
+ });
370
+
371
+ describe('continuity integration', () => {
372
+ it('initializes agent ID on start', async () => {
373
+ await wrapper.start();
374
+
375
+ expect(wrapper.testAgentId).toBeDefined();
376
+ expect(wrapper.testAgentId).toContain('test-agent-id');
377
+ });
378
+
379
+ it('resumes from previous agent ID if provided', async () => {
380
+ // Pre-populate a ledger
381
+ mockContinuityManager.ledgers.set('OldAgent', {
382
+ agentName: 'OldAgent',
383
+ agentId: 'resume-agent-id',
384
+ cli: 'claude',
385
+ });
386
+
387
+ wrapper.testConfig.resumeAgentId = 'resume-agent-id';
388
+ await wrapper.start();
389
+
390
+ expect(wrapper.testAgentId).toBe('resume-agent-id');
391
+ });
392
+
393
+ it('saves summary to ledger', async () => {
394
+ await wrapper.testSaveSummaryToLedger({
395
+ currentTask: 'Implementing auth',
396
+ completedTasks: ['Login', 'Logout'],
397
+ context: 'Working on session handling',
398
+ files: ['src/auth.ts'],
399
+ });
400
+
401
+ expect(mockContinuityManager.savedSummaries).toHaveLength(1);
402
+ expect(mockContinuityManager.savedSummaries[0].updates.currentTask).toBe('Implementing auth');
403
+ expect(mockContinuityManager.savedSummaries[0].updates.completed).toEqual(['Login', 'Logout']);
404
+ });
405
+
406
+ it('does not save empty summary', async () => {
407
+ await wrapper.testSaveSummaryToLedger({});
408
+
409
+ expect(mockContinuityManager.savedSummaries).toHaveLength(0);
410
+ });
411
+
412
+ it('resets session state', () => {
413
+ wrapper.testSessionEndProcessed = true;
414
+ wrapper.testLastSummaryRawContent = 'some content';
415
+ wrapper.testSessionEndData = { summary: 'test' };
416
+
417
+ wrapper.resetSessionState();
418
+
419
+ expect(wrapper.testSessionEndProcessed).toBe(false);
420
+ expect(wrapper.testLastSummaryRawContent).toBe('');
421
+ expect(wrapper.testSessionEndData).toBeUndefined();
422
+ });
423
+
424
+ it('returns agent ID via getter', async () => {
425
+ await wrapper.start();
426
+
427
+ expect(wrapper.getAgentId()).toBe(wrapper.testAgentId);
428
+ });
429
+ });
430
+
431
+ describe('relay command handling', () => {
432
+ it('sends relay commands to client', () => {
433
+ wrapper.testSendRelayCommand({
434
+ to: 'ReceiverAgent',
435
+ body: 'Hello',
436
+ thread: 'thread-1',
437
+ });
438
+
439
+ expect(wrapper.testClient.sentMessages).toHaveLength(1);
440
+ expect(wrapper.testClient.sentMessages[0].to).toBe('ReceiverAgent');
441
+ expect(wrapper.testClient.sentMessages[0].body).toBe('Hello');
442
+ });
443
+
444
+ it('deduplicates sent messages by hash', () => {
445
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'Hello' });
446
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'Hello' }); // Same
447
+
448
+ expect(wrapper.testClient.sentMessages).toHaveLength(1);
449
+ });
450
+
451
+ it('allows different messages to same target', () => {
452
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'Hello' });
453
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'World' });
454
+
455
+ expect(wrapper.testClient.sentMessages).toHaveLength(2);
456
+ });
457
+
458
+ it('does not send when client not ready', () => {
459
+ wrapper.testClient.state = 'CONNECTING';
460
+
461
+ wrapper.testSendRelayCommand({ to: 'ReceiverAgent', body: 'Hello' });
462
+
463
+ expect(wrapper.testClient.sentMessages).toHaveLength(0);
464
+ });
465
+ });
466
+
467
+ describe('joinContinuationLines', () => {
468
+ it('joins indented continuation lines for relay commands', () => {
469
+ const content = `->relay:Target <<<
470
+ Line 1
471
+ Line 2
472
+ >>>`;
473
+ const result = wrapper.testJoinContinuationLines(content);
474
+
475
+ expect(result).toContain('->relay:Target');
476
+ expect(result).toContain('Line 1');
477
+ });
478
+
479
+ it('joins continuation lines for continuity commands', () => {
480
+ const content = `->continuity:save <<<
481
+ Current task: Auth
482
+ Completed: Login
483
+ >>>`;
484
+ const result = wrapper.testJoinContinuationLines(content);
485
+
486
+ expect(result).toContain('->continuity:save');
487
+ expect(result).toContain('Current task: Auth');
488
+ });
489
+
490
+ it('stops joining on empty line', () => {
491
+ const content = `->relay:Target <<<
492
+ Line 1
493
+
494
+ Line 2
495
+ >>>`;
496
+ const lines = wrapper.testJoinContinuationLines(content).split('\n');
497
+
498
+ // Should have separate entries for lines after empty line
499
+ expect(lines.length).toBeGreaterThan(2);
500
+ });
501
+
502
+ it('stops joining on new block/bullet', () => {
503
+ const content = `->relay:Target <<<content>>>
504
+ - Next bullet point`;
505
+ const result = wrapper.testJoinContinuationLines(content);
506
+
507
+ // Bullet should be separate
508
+ expect(result).toContain('- Next bullet point');
509
+ });
510
+
511
+ it('handles content without commands unchanged', () => {
512
+ const content = 'Just regular text\nOn multiple lines';
513
+ const result = wrapper.testJoinContinuationLines(content);
514
+
515
+ expect(result).toBe(content);
516
+ });
517
+ });
518
+
519
+ describe('state management', () => {
520
+ it('tracks running state', async () => {
521
+ expect(wrapper.isRunning).toBe(false);
522
+
523
+ await wrapper.start();
524
+ expect(wrapper.isRunning).toBe(true);
525
+
526
+ wrapper.stop();
527
+ expect(wrapper.isRunning).toBe(false);
528
+ });
529
+
530
+ it('maintains separate dedup sets', () => {
531
+ wrapper.testHandleIncomingMessage('A', { body: 'msg', kind: 'message' }, 'id1');
532
+ wrapper.testSendRelayCommand({ to: 'B', body: 'msg' });
533
+ wrapper.testParseSpawnReleaseCommands('->relay:spawn W claude "t"');
534
+
535
+ expect(wrapper.testReceivedMessageIds.size).toBe(1);
536
+ expect(wrapper.testSentMessageHashes.size).toBe(1);
537
+ expect(wrapper.testProcessedSpawnCommands.size).toBe(1);
538
+ });
539
+ });
540
+ });