switchroom 0.13.47 → 0.13.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Tests for telegram-plugin/hooks/repo-context-pretool.mjs.
3
+ *
4
+ * Two layers:
5
+ * - Pure helpers exported from the hook (resolveTargetDir,
6
+ * findNearestMarker, isUnderAgentWorkspace) — fast unit tests.
7
+ * - End-to-end spawn of the hook with a faked PreToolUse envelope
8
+ * on stdin — pins the JSON-output contract + the session-scoped
9
+ * dedup behaviour.
10
+ *
11
+ * Mirrors the spawn-the-hook integration pattern from
12
+ * secret-guard-pretool.test.ts.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
16
+ import { spawnSync } from 'node:child_process'
17
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
18
+ import { tmpdir } from 'node:os'
19
+ import { join, resolve } from 'node:path'
20
+
21
+ import {
22
+ resolveTargetDir,
23
+ findNearestMarker,
24
+ isUnderAgentWorkspace,
25
+ } from '../hooks/repo-context-pretool.mjs'
26
+
27
+ const HOOK_PATH = resolve(__dirname, '..', 'hooks', 'repo-context-pretool.mjs')
28
+
29
+ // ─── Pure helpers ─────────────────────────────────────────────────────────
30
+
31
+ describe('resolveTargetDir', () => {
32
+ it('Read → dirname(file_path)', () => {
33
+ expect(
34
+ resolveTargetDir('Read', { file_path: '/abs/path/to/file.ts' }, '/cwd'),
35
+ ).toBe('/abs/path/to')
36
+ })
37
+
38
+ it('Edit / Write / MultiEdit / NotebookEdit — same shape', () => {
39
+ for (const tool of ['Edit', 'Write', 'MultiEdit']) {
40
+ expect(
41
+ resolveTargetDir(tool, { file_path: '/repo/src/main.ts' }, '/cwd'),
42
+ ).toBe('/repo/src')
43
+ }
44
+ // NotebookEdit takes file_path OR notebook_path
45
+ expect(
46
+ resolveTargetDir('NotebookEdit', { notebook_path: '/r/n.ipynb' }, '/cwd'),
47
+ ).toBe('/r')
48
+ })
49
+
50
+ it('Bash → envelope cwd, not anything inside the command', () => {
51
+ expect(
52
+ resolveTargetDir('Bash', { command: 'ls /elsewhere' }, '/agent/cwd'),
53
+ ).toBe('/agent/cwd')
54
+ })
55
+
56
+ it('returns null for relative file paths (ambiguous)', () => {
57
+ expect(
58
+ resolveTargetDir('Read', { file_path: 'rel/path.ts' }, '/cwd'),
59
+ ).toBe(null)
60
+ })
61
+
62
+ it('returns null for non-target tools', () => {
63
+ expect(resolveTargetDir('Glob', { pattern: '*' }, '/cwd')).toBe(null)
64
+ expect(resolveTargetDir('Grep', { pattern: 'x' }, '/cwd')).toBe(null)
65
+ expect(resolveTargetDir('WebFetch', { url: 'u' }, '/cwd')).toBe(null)
66
+ })
67
+
68
+ it('returns null for missing / empty tool_input fields', () => {
69
+ expect(resolveTargetDir('Read', {}, '/cwd')).toBe(null)
70
+ expect(resolveTargetDir('Read', { file_path: '' }, '/cwd')).toBe(null)
71
+ expect(resolveTargetDir('Bash', { command: 'x' }, '')).toBe(null)
72
+ expect(resolveTargetDir('Bash', { command: 'x' }, 'rel')).toBe(null)
73
+ })
74
+ })
75
+
76
+ describe('findNearestMarker', () => {
77
+ let tmpRoot: string
78
+
79
+ beforeEach(() => {
80
+ tmpRoot = mkdtempSync(join(tmpdir(), 'repo-context-marker-'))
81
+ })
82
+
83
+ afterEach(() => {
84
+ rmSync(tmpRoot, { recursive: true, force: true })
85
+ })
86
+
87
+ it('finds CLAUDE.md in the target dir itself', () => {
88
+ writeFileSync(join(tmpRoot, 'CLAUDE.md'), 'root')
89
+ expect(findNearestMarker(tmpRoot)).toBe(join(tmpRoot, 'CLAUDE.md'))
90
+ })
91
+
92
+ it('walks up to find CLAUDE.md in an ancestor', () => {
93
+ mkdirSync(join(tmpRoot, 'a', 'b', 'c'), { recursive: true })
94
+ writeFileSync(join(tmpRoot, 'a', 'CLAUDE.md'), 'a-level')
95
+ expect(findNearestMarker(join(tmpRoot, 'a', 'b', 'c'))).toBe(
96
+ join(tmpRoot, 'a', 'CLAUDE.md'),
97
+ )
98
+ })
99
+
100
+ it('prefers CLAUDE.md over AGENTS.md over AGENT.md at the same level', () => {
101
+ writeFileSync(join(tmpRoot, 'AGENT.md'), 'agent')
102
+ writeFileSync(join(tmpRoot, 'AGENTS.md'), 'agents')
103
+ writeFileSync(join(tmpRoot, 'CLAUDE.md'), 'claude')
104
+ expect(findNearestMarker(tmpRoot)).toBe(join(tmpRoot, 'CLAUDE.md'))
105
+ })
106
+
107
+ it('falls back to AGENTS.md when CLAUDE.md is absent', () => {
108
+ writeFileSync(join(tmpRoot, 'AGENTS.md'), 'agents')
109
+ expect(findNearestMarker(tmpRoot)).toBe(join(tmpRoot, 'AGENTS.md'))
110
+ })
111
+
112
+ it('returns the NEAREST marker, not the furthest, when both exist', () => {
113
+ mkdirSync(join(tmpRoot, 'inner'), { recursive: true })
114
+ writeFileSync(join(tmpRoot, 'CLAUDE.md'), 'outer')
115
+ writeFileSync(join(tmpRoot, 'inner', 'CLAUDE.md'), 'inner')
116
+ expect(findNearestMarker(join(tmpRoot, 'inner'))).toBe(
117
+ join(tmpRoot, 'inner', 'CLAUDE.md'),
118
+ )
119
+ })
120
+
121
+ it('returns null when no marker exists in the walk path', () => {
122
+ mkdirSync(join(tmpRoot, 'a', 'b'), { recursive: true })
123
+ expect(findNearestMarker(join(tmpRoot, 'a', 'b'))).toBe(null)
124
+ })
125
+
126
+ it('returns null for empty / non-string input', () => {
127
+ expect(findNearestMarker('')).toBe(null)
128
+ // @ts-expect-error testing runtime tolerance
129
+ expect(findNearestMarker(null)).toBe(null)
130
+ })
131
+ })
132
+
133
+ describe('isUnderAgentWorkspace', () => {
134
+ it('returns true when target equals the workspace root', () => {
135
+ const home = '/home/op'
136
+ const ws = join(home, '.switchroom', 'agents', 'clerk', 'workspace')
137
+ expect(isUnderAgentWorkspace(ws, 'clerk', home)).toBe(true)
138
+ })
139
+
140
+ it('returns true when target is nested under the workspace', () => {
141
+ const home = '/home/op'
142
+ expect(
143
+ isUnderAgentWorkspace(
144
+ join(home, '.switchroom/agents/clerk/workspace/memory'),
145
+ 'clerk',
146
+ home,
147
+ ),
148
+ ).toBe(true)
149
+ })
150
+
151
+ it('returns false for paths outside the workspace', () => {
152
+ const home = '/home/op'
153
+ expect(isUnderAgentWorkspace('/tmp/foo', 'clerk', home)).toBe(false)
154
+ expect(
155
+ isUnderAgentWorkspace(
156
+ join(home, '.switchroom/agents/clerk'),
157
+ 'clerk',
158
+ home,
159
+ ),
160
+ ).toBe(false)
161
+ })
162
+
163
+ it('returns false when agentName is empty (cannot derive workspace)', () => {
164
+ expect(isUnderAgentWorkspace('/home/op/x', '', '/home/op')).toBe(false)
165
+ })
166
+
167
+ it('does not false-positive on prefix-collision dirs', () => {
168
+ // /workspace-other should NOT match /workspace (no trailing sep)
169
+ const home = '/home/op'
170
+ expect(
171
+ isUnderAgentWorkspace(
172
+ join(home, '.switchroom/agents/clerk/workspace-other'),
173
+ 'clerk',
174
+ home,
175
+ ),
176
+ ).toBe(false)
177
+ })
178
+ })
179
+
180
+ // ─── End-to-end spawn ─────────────────────────────────────────────────────
181
+
182
+ interface Sandbox {
183
+ root: string
184
+ cleanup: () => void
185
+ }
186
+
187
+ function newSandbox(): Sandbox {
188
+ const root = mkdtempSync(join(tmpdir(), 'repo-context-e2e-'))
189
+ return {
190
+ root,
191
+ cleanup: () => rmSync(root, { recursive: true, force: true }),
192
+ }
193
+ }
194
+
195
+ function runHook(
196
+ envelope: object,
197
+ opts: { home?: string; agent?: string; disabled?: boolean; env?: Record<string, string> } = {},
198
+ ): { stdout: string; stderr: string; code: number } {
199
+ const env: Record<string, string> = {
200
+ PATH: process.env.PATH ?? '',
201
+ HOME: opts.home ?? '/tmp/non-existent-home',
202
+ SWITCHROOM_AGENT_NAME: opts.agent ?? '',
203
+ ...(opts.disabled ? { SWITCHROOM_DISABLE_REPO_CONTEXT_HOOK: '1' } : {}),
204
+ ...(opts.env ?? {}),
205
+ }
206
+ const res = spawnSync('node', [HOOK_PATH], {
207
+ input: JSON.stringify(envelope),
208
+ env,
209
+ encoding: 'utf8',
210
+ timeout: 5000,
211
+ })
212
+ return {
213
+ stdout: res.stdout ?? '',
214
+ stderr: res.stderr ?? '',
215
+ code: res.status ?? -1,
216
+ }
217
+ }
218
+
219
+ describe('repo-context-pretool e2e', () => {
220
+ let sb: Sandbox
221
+ let sessionStateDirs: string[] = []
222
+
223
+ beforeEach(() => {
224
+ sb = newSandbox()
225
+ sessionStateDirs = []
226
+ })
227
+
228
+ afterEach(() => {
229
+ sb.cleanup()
230
+ for (const d of sessionStateDirs) rmSync(d, { recursive: true, force: true })
231
+ })
232
+
233
+ function repoStateDir(sessionId: string): string {
234
+ const d = join(tmpdir(), `switchroom-repo-context-${sessionId}`)
235
+ sessionStateDirs.push(d)
236
+ return d
237
+ }
238
+
239
+ it('injects CLAUDE.md on first Read into a new repo', () => {
240
+ const repo = join(sb.root, 'foo')
241
+ mkdirSync(join(repo, 'src'), { recursive: true })
242
+ writeFileSync(join(repo, 'CLAUDE.md'), '# foo project\nuse pnpm.')
243
+ writeFileSync(join(repo, 'src', 'main.ts'), 'x')
244
+
245
+ const sid = `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
246
+ repoStateDir(sid)
247
+ const res = runHook({
248
+ session_id: sid,
249
+ cwd: sb.root,
250
+ hook_event_name: 'PreToolUse',
251
+ tool_name: 'Read',
252
+ tool_input: { file_path: join(repo, 'src', 'main.ts') },
253
+ })
254
+
255
+ expect(res.code).toBe(0)
256
+ expect(res.stdout.length).toBeGreaterThan(0)
257
+ const parsed = JSON.parse(res.stdout)
258
+ expect(parsed.hookSpecificOutput.hookEventName).toBe('PreToolUse')
259
+ const ctx = parsed.hookSpecificOutput.additionalContext
260
+ expect(ctx).toContain('# foo project')
261
+ expect(ctx).toContain('use pnpm')
262
+ expect(ctx).toContain(join(repo, 'CLAUDE.md'))
263
+ })
264
+
265
+ it('dedups — second call in the same session is a no-op', () => {
266
+ const repo = join(sb.root, 'foo')
267
+ mkdirSync(repo, { recursive: true })
268
+ writeFileSync(join(repo, 'CLAUDE.md'), '# foo')
269
+ writeFileSync(join(repo, 'main.ts'), 'x')
270
+
271
+ const sid = `e2e-dedup-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
272
+ repoStateDir(sid)
273
+ const envelope = {
274
+ session_id: sid,
275
+ cwd: sb.root,
276
+ hook_event_name: 'PreToolUse',
277
+ tool_name: 'Read',
278
+ tool_input: { file_path: join(repo, 'main.ts') },
279
+ }
280
+
281
+ const first = runHook(envelope)
282
+ expect(first.code).toBe(0)
283
+ expect(first.stdout.length).toBeGreaterThan(0)
284
+
285
+ const second = runHook(envelope)
286
+ expect(second.code).toBe(0)
287
+ expect(second.stdout).toBe('')
288
+ })
289
+
290
+ it('different sessions get independent dedup tracking', () => {
291
+ const repo = join(sb.root, 'foo')
292
+ mkdirSync(repo, { recursive: true })
293
+ writeFileSync(join(repo, 'CLAUDE.md'), '# foo')
294
+ writeFileSync(join(repo, 'main.ts'), 'x')
295
+
296
+ const sid1 = `e2e-sess1-${Date.now()}`
297
+ const sid2 = `e2e-sess2-${Date.now()}`
298
+ repoStateDir(sid1)
299
+ repoStateDir(sid2)
300
+ const base = {
301
+ cwd: sb.root,
302
+ hook_event_name: 'PreToolUse',
303
+ tool_name: 'Read',
304
+ tool_input: { file_path: join(repo, 'main.ts') },
305
+ }
306
+ const a = runHook({ ...base, session_id: sid1 })
307
+ const b = runHook({ ...base, session_id: sid2 })
308
+ expect(a.stdout.length).toBeGreaterThan(0)
309
+ expect(b.stdout.length).toBeGreaterThan(0)
310
+ })
311
+
312
+ it('opt-out via SWITCHROOM_DISABLE_REPO_CONTEXT_HOOK=1 → no-op', () => {
313
+ const repo = join(sb.root, 'foo')
314
+ mkdirSync(repo, { recursive: true })
315
+ writeFileSync(join(repo, 'CLAUDE.md'), '# foo')
316
+ writeFileSync(join(repo, 'main.ts'), 'x')
317
+
318
+ const res = runHook(
319
+ {
320
+ session_id: `e2e-disabled-${Date.now()}`,
321
+ cwd: sb.root,
322
+ hook_event_name: 'PreToolUse',
323
+ tool_name: 'Read',
324
+ tool_input: { file_path: join(repo, 'main.ts') },
325
+ },
326
+ { disabled: true },
327
+ )
328
+ expect(res.code).toBe(0)
329
+ expect(res.stdout).toBe('')
330
+ })
331
+
332
+ it('skips the agent workspace (already auto-loaded by Claude Code)', () => {
333
+ const home = sb.root
334
+ const wsRoot = join(home, '.switchroom', 'agents', 'clerk', 'workspace')
335
+ mkdirSync(wsRoot, { recursive: true })
336
+ writeFileSync(join(wsRoot, 'CLAUDE.md'), '# workspace')
337
+ writeFileSync(join(wsRoot, 'note.md'), 'x')
338
+
339
+ const sid = `e2e-ws-skip-${Date.now()}`
340
+ repoStateDir(sid)
341
+ const res = runHook(
342
+ {
343
+ session_id: sid,
344
+ cwd: wsRoot,
345
+ hook_event_name: 'PreToolUse',
346
+ tool_name: 'Read',
347
+ tool_input: { file_path: join(wsRoot, 'note.md') },
348
+ },
349
+ { home, agent: 'clerk' },
350
+ )
351
+ expect(res.code).toBe(0)
352
+ expect(res.stdout).toBe('')
353
+ })
354
+
355
+ it('large CLAUDE.md emits a pointer rather than the body', () => {
356
+ const repo = join(sb.root, 'huge')
357
+ mkdirSync(repo, { recursive: true })
358
+ // 60 KB — exceeds the default 30 KB per-file budget
359
+ writeFileSync(join(repo, 'CLAUDE.md'), 'x'.repeat(60_000))
360
+ writeFileSync(join(repo, 'main.ts'), 'y')
361
+
362
+ const sid = `e2e-huge-${Date.now()}`
363
+ repoStateDir(sid)
364
+ const res = runHook({
365
+ session_id: sid,
366
+ cwd: sb.root,
367
+ hook_event_name: 'PreToolUse',
368
+ tool_name: 'Read',
369
+ tool_input: { file_path: join(repo, 'main.ts') },
370
+ })
371
+ expect(res.code).toBe(0)
372
+ expect(res.stdout.length).toBeGreaterThan(0)
373
+ const parsed = JSON.parse(res.stdout)
374
+ const ctx = parsed.hookSpecificOutput.additionalContext
375
+ // Should NOT contain the body; should contain the pointer phrasing
376
+ expect(ctx).not.toContain('xxxxxxxxxxxx')
377
+ expect(ctx).toContain('larger than the per-file injection budget')
378
+ expect(ctx).toContain(join(repo, 'CLAUDE.md'))
379
+ })
380
+
381
+ it('Bash tool uses the envelope cwd', () => {
382
+ const repo = join(sb.root, 'bash-repo')
383
+ mkdirSync(repo, { recursive: true })
384
+ writeFileSync(join(repo, 'CLAUDE.md'), '# bash repo')
385
+
386
+ const sid = `e2e-bash-${Date.now()}`
387
+ repoStateDir(sid)
388
+ const res = runHook({
389
+ session_id: sid,
390
+ cwd: repo,
391
+ hook_event_name: 'PreToolUse',
392
+ tool_name: 'Bash',
393
+ tool_input: { command: 'ls' },
394
+ })
395
+ expect(res.code).toBe(0)
396
+ expect(res.stdout.length).toBeGreaterThan(0)
397
+ const parsed = JSON.parse(res.stdout)
398
+ expect(parsed.hookSpecificOutput.additionalContext).toContain('# bash repo')
399
+ })
400
+
401
+ it('fails open on malformed stdin', () => {
402
+ const res = spawnSync('node', [HOOK_PATH], {
403
+ input: 'not json',
404
+ env: { PATH: process.env.PATH ?? '', HOME: '/tmp' },
405
+ encoding: 'utf8',
406
+ timeout: 5000,
407
+ })
408
+ expect(res.status).toBe(0)
409
+ expect(res.stdout ?? '').toBe('')
410
+ })
411
+
412
+ it('fails open on empty stdin', () => {
413
+ const res = spawnSync('node', [HOOK_PATH], {
414
+ input: '',
415
+ env: { PATH: process.env.PATH ?? '', HOME: '/tmp' },
416
+ encoding: 'utf8',
417
+ timeout: 5000,
418
+ })
419
+ expect(res.status).toBe(0)
420
+ expect(res.stdout ?? '').toBe('')
421
+ })
422
+
423
+ it('no marker → no output', () => {
424
+ // tmpdir, no CLAUDE.md anywhere up the chain
425
+ const file = join(sb.root, 'nothing.ts')
426
+ writeFileSync(file, 'x')
427
+ const sid = `e2e-no-marker-${Date.now()}`
428
+ repoStateDir(sid)
429
+ const res = runHook({
430
+ session_id: sid,
431
+ cwd: sb.root,
432
+ hook_event_name: 'PreToolUse',
433
+ tool_name: 'Read',
434
+ tool_input: { file_path: file },
435
+ })
436
+ expect(res.code).toBe(0)
437
+ expect(res.stdout).toBe('')
438
+ })
439
+
440
+ it('per-session file-count cap → stops injecting after N repos', () => {
441
+ // Create 6 separate repos each with a marker; first 2 should inject
442
+ // (override MAX_FILES to 2 via env)
443
+ const sid = `e2e-cap-${Date.now()}`
444
+ repoStateDir(sid)
445
+
446
+ const outputs: string[] = []
447
+ for (let i = 0; i < 4; i++) {
448
+ const repo = join(sb.root, `r${i}`)
449
+ mkdirSync(repo, { recursive: true })
450
+ writeFileSync(join(repo, 'CLAUDE.md'), `# repo ${i}`)
451
+ writeFileSync(join(repo, 'f.ts'), 'x')
452
+ const res = runHook(
453
+ {
454
+ session_id: sid,
455
+ cwd: sb.root,
456
+ hook_event_name: 'PreToolUse',
457
+ tool_name: 'Read',
458
+ tool_input: { file_path: join(repo, 'f.ts') },
459
+ },
460
+ {
461
+ env: { SWITCHROOM_REPO_CONTEXT_PER_SESSION_MAX_FILES: '2' },
462
+ },
463
+ )
464
+ outputs.push(res.stdout)
465
+ }
466
+ // First 2 inject, last 2 are caps-blocked → empty
467
+ expect(outputs[0].length).toBeGreaterThan(0)
468
+ expect(outputs[1].length).toBeGreaterThan(0)
469
+ expect(outputs[2]).toBe('')
470
+ expect(outputs[3]).toBe('')
471
+ })
472
+ })