@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,207 @@
1
+ /**
2
+ * Tmux Binary Resolver
3
+ *
4
+ * Locates tmux binary with fallback to bundled version.
5
+ * Priority:
6
+ * 1. System tmux (in PATH)
7
+ * 2. Bundled tmux within the agent-relay package (bin/tmux)
8
+ */
9
+
10
+ import { execSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import os from 'node:os';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __filename = fileURLToPath(import.meta.url);
17
+ const __dirname = path.dirname(__filename);
18
+
19
+ /**
20
+ * Get the package root directory (where agent-relay is installed)
21
+ * This works whether we're in dist/utils/ or src/utils/
22
+ */
23
+ function getPackageRoot(): string {
24
+ // Navigate up from dist/utils or src/utils to package root
25
+ return path.resolve(__dirname, '..', '..');
26
+ }
27
+
28
+ /** Path where bundled tmux binary is installed (within the package) */
29
+ export function getBundledTmuxDir(): string {
30
+ return path.join(getPackageRoot(), 'bin');
31
+ }
32
+
33
+ export function getBundledTmuxPath(): string {
34
+ return path.join(getBundledTmuxDir(), 'tmux');
35
+ }
36
+
37
+ // Legacy exports for backwards compatibility
38
+ export const BUNDLED_TMUX_DIR = getBundledTmuxDir();
39
+ export const BUNDLED_TMUX_PATH = getBundledTmuxPath();
40
+
41
+ /** Minimum supported tmux version */
42
+ export const MIN_TMUX_VERSION = '3.0';
43
+
44
+ export interface TmuxInfo {
45
+ /** Full path to tmux binary */
46
+ path: string;
47
+ /** Version string (e.g., "3.6a") */
48
+ version: string;
49
+ /** Whether this is the bundled version */
50
+ isBundled: boolean;
51
+ }
52
+
53
+ /**
54
+ * Check if tmux exists at a given path and get its version
55
+ */
56
+ function getTmuxVersion(tmuxPath: string): string | null {
57
+ try {
58
+ const output = execSync(`"${tmuxPath}" -V`, {
59
+ encoding: 'utf-8',
60
+ stdio: ['pipe', 'pipe', 'pipe'],
61
+ });
62
+ // Output format: "tmux 3.6a" or similar
63
+ const match = output.trim().match(/tmux\s+(\d+\.\d+\w?)/i);
64
+ return match ? match[1] : null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Find tmux in system PATH
72
+ */
73
+ function findSystemTmux(): string | null {
74
+ try {
75
+ const output = execSync('which tmux', {
76
+ encoding: 'utf-8',
77
+ stdio: ['pipe', 'pipe', 'pipe'],
78
+ });
79
+ return output.trim() || null;
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Resolve tmux binary path with fallback to bundled version.
87
+ * Returns null if tmux is not available.
88
+ */
89
+ export function resolveTmux(): TmuxInfo | null {
90
+ // 1. Check system tmux first
91
+ const systemPath = findSystemTmux();
92
+ if (systemPath) {
93
+ const version = getTmuxVersion(systemPath);
94
+ if (version) {
95
+ return {
96
+ path: systemPath,
97
+ version,
98
+ isBundled: false,
99
+ };
100
+ }
101
+ }
102
+
103
+ // 2. Check bundled tmux (within the package)
104
+ const bundledPath = getBundledTmuxPath();
105
+ if (fs.existsSync(bundledPath)) {
106
+ const version = getTmuxVersion(bundledPath);
107
+ if (version) {
108
+ return {
109
+ path: bundledPath,
110
+ version,
111
+ isBundled: true,
112
+ };
113
+ }
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Get the tmux command to use. Throws if tmux is not available.
121
+ */
122
+ export function getTmuxPath(): string {
123
+ const info = resolveTmux();
124
+ if (!info) {
125
+ throw new TmuxNotFoundError();
126
+ }
127
+ return info.path;
128
+ }
129
+
130
+ /**
131
+ * Check if tmux is available (either system or bundled)
132
+ */
133
+ export function isTmuxAvailable(): boolean {
134
+ return resolveTmux() !== null;
135
+ }
136
+
137
+ /**
138
+ * Get platform identifier for downloading binaries
139
+ */
140
+ export function getPlatformIdentifier(): string | null {
141
+ const platform = os.platform();
142
+ const arch = os.arch();
143
+
144
+ if (platform === 'darwin') {
145
+ return arch === 'arm64' ? 'macos-arm64' : 'macos-x86_64';
146
+ } else if (platform === 'linux') {
147
+ return arch === 'arm64' ? 'linux-arm64' : 'linux-x86_64';
148
+ }
149
+
150
+ // Unsupported platform
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * Error thrown when tmux is not available
156
+ */
157
+ export class TmuxNotFoundError extends Error {
158
+ constructor() {
159
+ const platformInstructions = (() => {
160
+ switch (os.platform()) {
161
+ case 'darwin':
162
+ return ' macOS: brew install tmux';
163
+ case 'linux':
164
+ return ' Ubuntu/Debian: sudo apt install tmux\n Fedora: sudo dnf install tmux\n Arch: sudo pacman -S tmux';
165
+ case 'win32':
166
+ return ' Windows: tmux requires WSL (Windows Subsystem for Linux)\n Install WSL, then: sudo apt install tmux';
167
+ default:
168
+ return ' See: https://github.com/tmux/tmux/wiki/Installing';
169
+ }
170
+ })();
171
+
172
+ super(
173
+ `tmux is required but not found.\n\nInstall tmux:\n${platformInstructions}\n\nThen reinstall agent-relay: npm install agent-relay`
174
+ );
175
+ this.name = 'TmuxNotFoundError';
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Parse version string to compare versions
181
+ */
182
+ function parseVersion(version: string): { major: number; minor: number } {
183
+ const match = version.match(/(\d+)\.(\d+)/);
184
+ if (!match) {
185
+ return { major: 0, minor: 0 };
186
+ }
187
+ return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
188
+ }
189
+
190
+ /**
191
+ * Check if installed tmux version meets minimum requirements
192
+ */
193
+ export function checkTmuxVersion(): { ok: boolean; version: string | null; minimum: string } {
194
+ const info = resolveTmux();
195
+ if (!info) {
196
+ return { ok: false, version: null, minimum: MIN_TMUX_VERSION };
197
+ }
198
+
199
+ const installed = parseVersion(info.version);
200
+ const required = parseVersion(MIN_TMUX_VERSION);
201
+
202
+ const ok =
203
+ installed.major > required.major ||
204
+ (installed.major === required.major && installed.minor >= required.minor);
205
+
206
+ return { ok, version: info.version, minimum: MIN_TMUX_VERSION };
207
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Unit tests for TmuxWrapper constants and utilities
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import { getDefaultPrefix } from './tmux-wrapper.js';
7
+ import {
8
+ type InjectionResult,
9
+ INJECTION_CONSTANTS,
10
+ createInjectionMetrics,
11
+ } from './shared.js';
12
+
13
+ describe('TmuxWrapper constants', () => {
14
+ // Unified prefix across all CLI types
15
+ describe('getDefaultPrefix', () => {
16
+ it('returns ->relay: for gemini CLI type', () => {
17
+ expect(getDefaultPrefix('gemini')).toBe('->relay:');
18
+ });
19
+
20
+ it('returns ->relay: for claude CLI type', () => {
21
+ expect(getDefaultPrefix('claude')).toBe('->relay:');
22
+ });
23
+
24
+ it('returns ->relay: for codex CLI type', () => {
25
+ expect(getDefaultPrefix('codex')).toBe('->relay:');
26
+ });
27
+
28
+ it('returns ->relay: for other CLI type', () => {
29
+ expect(getDefaultPrefix('other')).toBe('->relay:');
30
+ });
31
+ });
32
+ });
33
+
34
+ describe('String truncation safety', () => {
35
+ // Test the truncation pattern used throughout tmux-wrapper
36
+ // Pattern: str.substring(0, Math.min(LIMIT, str.length))
37
+
38
+ const safeSubstring = (str: string, maxLen: number): string => {
39
+ return str.substring(0, Math.min(maxLen, str.length));
40
+ };
41
+
42
+ describe('safeSubstring helper pattern', () => {
43
+ it('truncates long strings', () => {
44
+ const longString = 'a'.repeat(100);
45
+ expect(safeSubstring(longString, 40)).toBe('a'.repeat(40));
46
+ expect(safeSubstring(longString, 40)).toHaveLength(40);
47
+ });
48
+
49
+ it('preserves short strings', () => {
50
+ const shortString = 'hello';
51
+ expect(safeSubstring(shortString, 40)).toBe('hello');
52
+ expect(safeSubstring(shortString, 40)).toHaveLength(5);
53
+ });
54
+
55
+ it('handles exact length strings', () => {
56
+ const exactString = 'a'.repeat(40);
57
+ expect(safeSubstring(exactString, 40)).toBe(exactString);
58
+ expect(safeSubstring(exactString, 40)).toHaveLength(40);
59
+ });
60
+
61
+ it('handles empty strings', () => {
62
+ expect(safeSubstring('', 40)).toBe('');
63
+ expect(safeSubstring('', 40)).toHaveLength(0);
64
+ });
65
+
66
+ it('handles strings shorter than limit', () => {
67
+ expect(safeSubstring('ab', 40)).toBe('ab');
68
+ });
69
+
70
+ it('handles limit of 0', () => {
71
+ expect(safeSubstring('hello', 0)).toBe('');
72
+ });
73
+
74
+ it('handles unicode characters', () => {
75
+ const unicodeStr = ''.repeat(100);
76
+ expect(safeSubstring(unicodeStr, 10)).toBe(''.repeat(10));
77
+ });
78
+ });
79
+
80
+ describe('DEBUG_LOG_TRUNCATE_LENGTH constant (40)', () => {
81
+ const DEBUG_LOG_TRUNCATE_LENGTH = 40;
82
+
83
+ it('truncates debug log content appropriately', () => {
84
+ const longMessage = 'This is a very long debug message that exceeds the limit';
85
+ const truncated = safeSubstring(longMessage, DEBUG_LOG_TRUNCATE_LENGTH);
86
+ expect(truncated).toBe('This is a very long debug message that e');
87
+ expect(truncated).toHaveLength(40);
88
+ });
89
+ });
90
+
91
+ describe('RELAY_LOG_TRUNCATE_LENGTH constant (50)', () => {
92
+ const RELAY_LOG_TRUNCATE_LENGTH = 50;
93
+
94
+ it('truncates relay command log content appropriately', () => {
95
+ const longMessage = 'This is a very long relay message that definitely exceeds the fifty character limit';
96
+ const truncated = safeSubstring(longMessage, RELAY_LOG_TRUNCATE_LENGTH);
97
+ expect(truncated).toBe('This is a very long relay message that definitely ');
98
+ expect(truncated).toHaveLength(50);
99
+ });
100
+ });
101
+ });
102
+
103
+ describe('Cursor stability constants', () => {
104
+ // These test the logic that uses STABLE_CURSOR_THRESHOLD and MAX_PROMPT_CURSOR_POSITION
105
+
106
+ const STABLE_CURSOR_THRESHOLD = 3;
107
+ const MAX_PROMPT_CURSOR_POSITION = 4;
108
+
109
+ describe('STABLE_CURSOR_THRESHOLD', () => {
110
+ it('requires 3 or more stable polls to consider input clear', () => {
111
+ // Simulate cursor stability counting
112
+ let stableCursorCount = 0;
113
+ const cursorX = 2;
114
+
115
+ // First poll - not stable yet
116
+ stableCursorCount++;
117
+ expect(stableCursorCount >= STABLE_CURSOR_THRESHOLD).toBe(false);
118
+
119
+ // Second poll - still not stable
120
+ stableCursorCount++;
121
+ expect(stableCursorCount >= STABLE_CURSOR_THRESHOLD).toBe(false);
122
+
123
+ // Third poll - now stable
124
+ stableCursorCount++;
125
+ expect(stableCursorCount >= STABLE_CURSOR_THRESHOLD).toBe(true);
126
+ expect(cursorX <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
127
+ });
128
+
129
+ it('resets count when cursor moves', () => {
130
+ let stableCursorCount = 2;
131
+ let lastCursorX = 2;
132
+ const newCursorX = 5; // Cursor moved
133
+
134
+ if (newCursorX !== lastCursorX) {
135
+ stableCursorCount = 0;
136
+ lastCursorX = newCursorX;
137
+ }
138
+
139
+ expect(stableCursorCount).toBe(0);
140
+ });
141
+ });
142
+
143
+ describe('MAX_PROMPT_CURSOR_POSITION', () => {
144
+ it('considers positions 0-4 as typical prompt positions', () => {
145
+ expect(0 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
146
+ expect(1 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
147
+ expect(2 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
148
+ expect(3 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
149
+ expect(4 <= MAX_PROMPT_CURSOR_POSITION).toBe(true);
150
+ });
151
+
152
+ it('considers positions > 4 as likely having user input', () => {
153
+ expect(5 <= MAX_PROMPT_CURSOR_POSITION).toBe(false);
154
+ expect(10 <= MAX_PROMPT_CURSOR_POSITION).toBe(false);
155
+ });
156
+
157
+ it('works with combined stability check', () => {
158
+ const stableCursorCount = 3;
159
+ const cursorAtPrompt = 2;
160
+ const cursorWithInput = 10;
161
+
162
+ // At prompt position - should be considered clear
163
+ const isClearAtPrompt =
164
+ stableCursorCount >= STABLE_CURSOR_THRESHOLD &&
165
+ cursorAtPrompt <= MAX_PROMPT_CURSOR_POSITION;
166
+ expect(isClearAtPrompt).toBe(true);
167
+
168
+ // With input - should not be considered clear
169
+ const isClearWithInput =
170
+ stableCursorCount >= STABLE_CURSOR_THRESHOLD &&
171
+ cursorWithInput <= MAX_PROMPT_CURSOR_POSITION;
172
+ expect(isClearWithInput).toBe(false);
173
+ });
174
+ });
175
+ });
176
+
177
+ describe('Injection retry logic', () => {
178
+ // Test the retry logic pattern used by injectWithRetry
179
+
180
+ describe('INJECTION_CONSTANTS', () => {
181
+ it('has correct MAX_RETRIES', () => {
182
+ expect(INJECTION_CONSTANTS.MAX_RETRIES).toBe(3);
183
+ });
184
+
185
+ it('has correct VERIFICATION_TIMEOUT_MS', () => {
186
+ expect(INJECTION_CONSTANTS.VERIFICATION_TIMEOUT_MS).toBe(2000);
187
+ });
188
+
189
+ it('has correct RETRY_BACKOFF_MS', () => {
190
+ expect(INJECTION_CONSTANTS.RETRY_BACKOFF_MS).toBe(200);
191
+ });
192
+ });
193
+
194
+ describe('InjectionMetrics tracking', () => {
195
+ it('initializes with zero counts', () => {
196
+ const metrics = createInjectionMetrics();
197
+ expect(metrics.total).toBe(0);
198
+ expect(metrics.successFirstTry).toBe(0);
199
+ expect(metrics.successWithRetry).toBe(0);
200
+ expect(metrics.failed).toBe(0);
201
+ });
202
+
203
+ it('tracks successful first-try injection', () => {
204
+ const metrics = createInjectionMetrics();
205
+
206
+ // Simulate successful first-try injection
207
+ metrics.total++;
208
+ const verified = true;
209
+ const attempt = 0;
210
+ if (verified && attempt === 0) {
211
+ metrics.successFirstTry++;
212
+ }
213
+
214
+ expect(metrics.total).toBe(1);
215
+ expect(metrics.successFirstTry).toBe(1);
216
+ expect(metrics.successWithRetry).toBe(0);
217
+ expect(metrics.failed).toBe(0);
218
+ });
219
+
220
+ it('tracks successful retry injection', () => {
221
+ const metrics = createInjectionMetrics();
222
+
223
+ // Simulate successful injection on retry
224
+ metrics.total++;
225
+ const verified = true;
226
+ const attempt = 2; // Third attempt
227
+ if (verified && attempt > 0) {
228
+ metrics.successWithRetry++;
229
+ }
230
+
231
+ expect(metrics.total).toBe(1);
232
+ expect(metrics.successFirstTry).toBe(0);
233
+ expect(metrics.successWithRetry).toBe(1);
234
+ expect(metrics.failed).toBe(0);
235
+ });
236
+
237
+ it('tracks failed injection', () => {
238
+ const metrics = createInjectionMetrics();
239
+
240
+ // Simulate failed injection after all retries
241
+ metrics.total++;
242
+ metrics.failed++;
243
+
244
+ expect(metrics.total).toBe(1);
245
+ expect(metrics.successFirstTry).toBe(0);
246
+ expect(metrics.successWithRetry).toBe(0);
247
+ expect(metrics.failed).toBe(1);
248
+ });
249
+ });
250
+
251
+ describe('InjectionResult structure', () => {
252
+ it('returns success result on first try', () => {
253
+ const result: InjectionResult = { success: true, attempts: 1 };
254
+ expect(result.success).toBe(true);
255
+ expect(result.attempts).toBe(1);
256
+ });
257
+
258
+ it('returns success result after retries', () => {
259
+ const result: InjectionResult = { success: true, attempts: 3 };
260
+ expect(result.success).toBe(true);
261
+ expect(result.attempts).toBe(3);
262
+ });
263
+
264
+ it('returns failure result after max retries', () => {
265
+ const result: InjectionResult = {
266
+ success: false,
267
+ attempts: INJECTION_CONSTANTS.MAX_RETRIES,
268
+ };
269
+ expect(result.success).toBe(false);
270
+ expect(result.attempts).toBe(3);
271
+ });
272
+
273
+ it('can include fallback flag', () => {
274
+ const result: InjectionResult = {
275
+ success: false,
276
+ attempts: 3,
277
+ fallbackUsed: true,
278
+ };
279
+ expect(result.fallbackUsed).toBe(true);
280
+ });
281
+ });
282
+
283
+ describe('Backoff calculation', () => {
284
+ it('increases backoff with each attempt', () => {
285
+ const backoffMs = INJECTION_CONSTANTS.RETRY_BACKOFF_MS;
286
+
287
+ // Backoff for attempt 0 (first retry)
288
+ const backoff0 = backoffMs * 1;
289
+ expect(backoff0).toBe(200);
290
+
291
+ // Backoff for attempt 1 (second retry)
292
+ const backoff1 = backoffMs * 2;
293
+ expect(backoff1).toBe(400);
294
+
295
+ // Backoff for attempt 2 (third retry - but no backoff needed after last attempt)
296
+ const backoff2 = backoffMs * 3;
297
+ expect(backoff2).toBe(600);
298
+ });
299
+ });
300
+
301
+ describe('Verification pattern matching', () => {
302
+ it('generates correct expected pattern', () => {
303
+ const shortId = 'abc12345';
304
+ const from = 'TestAgent';
305
+ const expectedPattern = `Relay message from ${from} [${shortId}]`;
306
+ expect(expectedPattern).toBe('Relay message from TestAgent [abc12345]');
307
+ });
308
+
309
+ it('handles different agent names', () => {
310
+ const shortId = 'def67890';
311
+ const from = 'Backend';
312
+ const expectedPattern = `Relay message from ${from} [${shortId}]`;
313
+ expect(expectedPattern).toBe('Relay message from Backend [def67890]');
314
+ });
315
+ });
316
+ });