deepflow 0.1.107 → 0.1.109

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.
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * Tests for df-explore-protocol.js — PreToolUse hook
3
3
  *
4
- * Verifies that the hook injects the explore-protocol.md search protocol
5
- * into Explore agent prompts via updatedInput.
4
+ * Two-phase behavior coverage:
5
+ * Phase 1: inline regex extraction from source files in cwd
6
+ * Phase 2: inject symbol locations + static template into prompt
7
+ *
8
+ * All tests control Phase 1 by writing fixture source files to a tmpDir,
9
+ * then passing that tmpDir as cwd. No subprocess, no fake `claude` binary.
6
10
  */
7
11
 
8
12
  'use strict';
@@ -15,6 +19,43 @@ const path = require('node:path');
15
19
  const os = require('node:os');
16
20
 
17
21
  const HOOK_PATH = path.resolve(__dirname, 'df-explore-protocol.js');
22
+ const PROTOCOL_CONTENT =
23
+ '# Explore Agent Pattern\n\nReturn ONLY:\n- filepath:startLine-endLine -- why relevant';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ /**
30
+ * Create a temp project directory with an optional explore-protocol.md template
31
+ * and optional .deepflow/config.yaml.
32
+ */
33
+ function createTempProject({ withTemplate = true, configYaml = null } = {}) {
34
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-test-'));
35
+ if (withTemplate) {
36
+ const templatesDir = path.join(tmpDir, 'templates');
37
+ fs.mkdirSync(templatesDir, { recursive: true });
38
+ fs.writeFileSync(path.join(templatesDir, 'explore-protocol.md'), PROTOCOL_CONTENT);
39
+ }
40
+ if (configYaml) {
41
+ const dfDir = path.join(tmpDir, '.deepflow');
42
+ fs.mkdirSync(dfDir, { recursive: true });
43
+ fs.writeFileSync(path.join(dfDir, 'config.yaml'), configYaml);
44
+ }
45
+ return tmpDir;
46
+ }
47
+
48
+ /**
49
+ * Write a fixture source file into tmpDir/src/ with the given content.
50
+ * Returns the absolute path of the written file.
51
+ */
52
+ function writeFixtureFile(tmpDir, filename, content) {
53
+ const srcDir = path.join(tmpDir, 'src');
54
+ fs.mkdirSync(srcDir, { recursive: true });
55
+ const filepath = path.join(srcDir, filename);
56
+ fs.writeFileSync(filepath, content);
57
+ return filepath;
58
+ }
18
59
 
19
60
  /**
20
61
  * Run the hook as a child process with JSON piped to stdin.
@@ -23,19 +64,14 @@ const HOOK_PATH = path.resolve(__dirname, 'df-explore-protocol.js');
23
64
  function runHook(input, { cwd, home } = {}) {
24
65
  const json = typeof input === 'string' ? input : JSON.stringify(input);
25
66
  const env = { ...process.env };
26
- if (cwd) env.CWD_OVERRIDE = cwd;
27
67
  if (home) env.HOME = home;
28
68
  try {
29
- const stdout = execFileSync(
30
- process.execPath,
31
- [HOOK_PATH],
32
- {
33
- input: json,
34
- encoding: 'utf8',
35
- timeout: 5000,
36
- env,
37
- }
38
- );
69
+ const stdout = execFileSync(process.execPath, [HOOK_PATH], {
70
+ input: json,
71
+ encoding: 'utf8',
72
+ timeout: 10000,
73
+ env,
74
+ });
39
75
  return { stdout, stderr: '', code: 0 };
40
76
  } catch (err) {
41
77
  return {
@@ -46,19 +82,9 @@ function runHook(input, { cwd, home } = {}) {
46
82
  }
47
83
  }
48
84
 
49
- /**
50
- * Create a temp directory with a mock explore-protocol.md template.
51
- */
52
- function createTempProject(protocolContent) {
53
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-test-'));
54
- const templatesDir = path.join(tmpDir, 'templates');
55
- fs.mkdirSync(templatesDir, { recursive: true });
56
- fs.writeFileSync(
57
- path.join(templatesDir, 'explore-protocol.md'),
58
- protocolContent || '# Explore Agent Pattern\n\nReturn ONLY:\n- filepath:startLine-endLine -- why relevant'
59
- );
60
- return tmpDir;
61
- }
85
+ // ---------------------------------------------------------------------------
86
+ // Test suite
87
+ // ---------------------------------------------------------------------------
62
88
 
63
89
  describe('df-explore-protocol hook', () => {
64
90
  let tmpDir;
@@ -71,65 +97,103 @@ describe('df-explore-protocol hook', () => {
71
97
  fs.rmSync(tmpDir, { recursive: true, force: true });
72
98
  });
73
99
 
74
- test('injects protocol into Explore agent prompt', () => {
100
+ // -------------------------------------------------------------------------
101
+ // AC-1: Phase 1 uses inline regex extraction — no subprocess required
102
+ // -------------------------------------------------------------------------
103
+ test('AC-1: Phase 1 uses inline regex extraction without requiring a claude binary', () => {
104
+ // Write a fixture file with a function whose name matches the query substring
105
+ writeFixtureFile(tmpDir, 'index.js', 'function databaseConfig() { return {}; }\n');
106
+
107
+ // Query is just the symbol name so it substring-matches the symbol "databaseConfig"
75
108
  const input = {
76
109
  tool_name: 'Agent',
77
- tool_input: {
78
- subagent_type: 'Explore',
79
- prompt: 'Find: config files related to database',
80
- model: 'haiku',
81
- },
110
+ tool_input: { subagent_type: 'Explore', prompt: 'databaseConfig' },
82
111
  cwd: tmpDir,
83
112
  };
84
113
 
85
- const { stdout, code } = runHook(input);
114
+ // Run without any PATH manipulation — no fake claude binary needed
115
+ const { code, stdout } = runHook(input);
86
116
  assert.equal(code, 0);
87
117
 
118
+ // Phase 1 should have found the symbol; LSP block injected
88
119
  const result = JSON.parse(stdout);
89
- const updated = result.hookSpecificOutput.updatedInput;
90
-
91
- assert.ok(updated.prompt.includes('Find: config files related to database'));
92
- assert.ok(updated.prompt.includes('filepath:startLine-endLine'));
93
- assert.ok(updated.prompt.includes('Search Protocol (auto-injected'));
94
- assert.equal(updated.subagent_type, 'Explore');
95
- assert.equal(updated.model, 'haiku');
96
- assert.equal(result.hookSpecificOutput.permissionDecision, 'allow');
120
+ const prompt = result.hookSpecificOutput.updatedInput.prompt;
121
+ assert.ok(
122
+ prompt.includes('[LSP Phase -- locations found]'),
123
+ 'Expected [LSP Phase -- locations found] section from regex extraction'
124
+ );
97
125
  });
98
126
 
99
- test('ignores non-Agent tool calls', () => {
127
+ // -------------------------------------------------------------------------
128
+ // AC-2: Phase 1 hit injects [LSP Phase -- locations found] section
129
+ // -------------------------------------------------------------------------
130
+ test('AC-2: Phase 1 hit injects [LSP Phase -- locations found] section into prompt', () => {
131
+ // Write fixture files whose symbol names contain the query substring "connect"
132
+ writeFixtureFile(tmpDir, 'config.ts', 'export function dbConnect() { return {}; }\n');
133
+ writeFixtureFile(tmpDir, 'db.ts', 'export async function connectDB() {}\n');
134
+
135
+ // Query "connect" matches both symbols by substring
100
136
  const input = {
101
- tool_name: 'Read',
102
- tool_input: { file_path: '/some/file.ts' },
137
+ tool_name: 'Agent',
138
+ tool_input: { subagent_type: 'Explore', prompt: 'connect' },
103
139
  cwd: tmpDir,
104
140
  };
105
141
 
106
142
  const { stdout, code } = runHook(input);
107
143
  assert.equal(code, 0);
108
- assert.equal(stdout, '');
144
+
145
+ const result = JSON.parse(stdout);
146
+ const prompt = result.hookSpecificOutput.updatedInput.prompt;
147
+
148
+ assert.ok(
149
+ prompt.includes('[LSP Phase -- locations found]'),
150
+ 'Expected [LSP Phase -- locations found] section'
151
+ );
152
+ assert.ok(
153
+ prompt.includes('Search Protocol (auto-injected'),
154
+ 'Expected Search Protocol section'
155
+ );
156
+ assert.ok(prompt.includes(PROTOCOL_CONTENT.slice(0, 30)), 'Expected protocol content');
109
157
  });
110
158
 
111
- test('ignores non-Explore agent calls', () => {
159
+ // -------------------------------------------------------------------------
160
+ // AC-3: Reader-phase entries in filepath:line format
161
+ // -------------------------------------------------------------------------
162
+ test('AC-3: LSP locations are formatted as filepath:line -- symbolName (symbolKind)', () => {
163
+ // Write a fixture file whose symbol name contains "myFunc" (matches query substring)
164
+ const srcDir = path.join(tmpDir, 'src');
165
+ fs.mkdirSync(srcDir, { recursive: true });
166
+ const filepath = path.join(srcDir, 'utils.ts');
167
+ // Place the function at line 1
168
+ fs.writeFileSync(filepath, 'export function myFunc() {}\n');
169
+
170
+ // Query "myFunc" matches the symbol name by substring
112
171
  const input = {
113
172
  tool_name: 'Agent',
114
- tool_input: {
115
- subagent_type: 'reasoner',
116
- prompt: 'Analyze this code',
117
- },
173
+ tool_input: { subagent_type: 'Explore', prompt: 'myFunc' },
118
174
  cwd: tmpDir,
119
175
  };
120
176
 
121
177
  const { stdout, code } = runHook(input);
122
178
  assert.equal(code, 0);
123
- assert.equal(stdout, '');
179
+
180
+ const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
181
+ assert.ok(
182
+ prompt.includes(`${filepath}:1 -- myFunc (function)`),
183
+ `Expected filepath:line format, prompt: ${prompt}`
184
+ );
124
185
  });
125
186
 
126
- test('handles case-insensitive subagent_type', () => {
187
+ // -------------------------------------------------------------------------
188
+ // AC-5: No matching symbols → fallback to static template injection
189
+ // -------------------------------------------------------------------------
190
+ test('AC-5: no matching symbols falls back to static template injection', () => {
191
+ // Write a fixture file whose symbol/path do NOT match the query "xyzRouteHandler"
192
+ writeFixtureFile(tmpDir, 'unrelated.js', 'function completelyDifferent() {}\n');
193
+
127
194
  const input = {
128
195
  tool_name: 'Agent',
129
- tool_input: {
130
- subagent_type: 'explore',
131
- prompt: 'Find: test utilities',
132
- },
196
+ tool_input: { subagent_type: 'Explore', prompt: 'xyzRouteHandler' },
133
197
  cwd: tmpDir,
134
198
  };
135
199
 
@@ -137,45 +201,245 @@ describe('df-explore-protocol hook', () => {
137
201
  assert.equal(code, 0);
138
202
 
139
203
  const result = JSON.parse(stdout);
140
- assert.ok(result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol'));
204
+ const prompt = result.hookSpecificOutput.updatedInput.prompt;
205
+
206
+ // Should inject protocol but NOT the LSP phase block
207
+ assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Expected protocol injection');
208
+ assert.ok(
209
+ !prompt.includes('[LSP Phase -- locations found]'),
210
+ 'Must NOT include LSP phase block when no symbols match'
211
+ );
212
+ assert.ok(prompt.includes('xyzRouteHandler'), 'Original prompt preserved');
141
213
  });
142
214
 
143
- test('exits cleanly when no template found', () => {
144
- const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-empty-'));
215
+ // -------------------------------------------------------------------------
216
+ // AC-6: No template + no matching symbols → exit 0 with no output
217
+ // -------------------------------------------------------------------------
218
+ test('AC-6: no template and no matching symbols exits 0 with no output', () => {
219
+ const emptyDir = createTempProject({ withTemplate: false });
145
220
  const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'df-explore-home-'));
221
+
146
222
  try {
147
223
  const input = {
148
224
  tool_name: 'Agent',
149
- tool_input: {
150
- subagent_type: 'Explore',
151
- prompt: 'Find: something',
152
- },
225
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
153
226
  cwd: emptyDir,
154
227
  };
155
228
 
156
229
  const { stdout, code } = runHook(input, { home: fakeHome });
157
230
  assert.equal(code, 0);
158
- assert.equal(stdout, '');
231
+ assert.equal(stdout, '', 'Expected empty stdout when no template and no symbols match');
159
232
  } finally {
160
233
  fs.rmSync(emptyDir, { recursive: true, force: true });
161
234
  fs.rmSync(fakeHome, { recursive: true, force: true });
162
235
  }
163
236
  });
164
237
 
165
- test('exits cleanly on malformed JSON input', () => {
166
- const { code } = runHook('not valid json');
238
+ // -------------------------------------------------------------------------
239
+ // AC-7: Path filtering removes node_modules, .claude/worktrees, dist paths
240
+ // -------------------------------------------------------------------------
241
+ test('AC-7: noise paths filtered out — node_modules, .claude/worktrees, dist, .git', () => {
242
+ // Write a good source file with a symbol name that IS the query
243
+ const srcDir = path.join(tmpDir, 'src');
244
+ fs.mkdirSync(srcDir, { recursive: true });
245
+ const goodFile = path.join(srcDir, 'main.ts');
246
+ fs.writeFileSync(goodFile, 'export function targetSymbol() {}\n');
247
+
248
+ // Write noise files that also declare targetSymbol but live in noise paths
249
+ const nodeModDir = path.join(tmpDir, 'node_modules', 'lib');
250
+ fs.mkdirSync(nodeModDir, { recursive: true });
251
+ fs.writeFileSync(path.join(nodeModDir, 'index.js'), 'function targetSymbol() {}\n');
252
+
253
+ const worktreeDir = path.join(tmpDir, '.claude', 'worktrees', 'branch');
254
+ fs.mkdirSync(worktreeDir, { recursive: true });
255
+ fs.writeFileSync(path.join(worktreeDir, 'file.ts'), 'function targetSymbol() {}\n');
256
+
257
+ const distDir = path.join(tmpDir, 'dist');
258
+ fs.mkdirSync(distDir, { recursive: true });
259
+ fs.writeFileSync(path.join(distDir, 'bundle.js'), 'function targetSymbol() {}\n');
260
+
261
+ const gitDir = path.join(tmpDir, '.git', 'hooks');
262
+ fs.mkdirSync(gitDir, { recursive: true });
263
+ fs.writeFileSync(path.join(gitDir, 'pre-commit'), 'function targetSymbol() {}\n');
264
+
265
+ const input = {
266
+ // Query "targetSymbol" substring-matches the symbol name in all files,
267
+ // but only the good file should survive the noise-path filter
268
+ tool_input: { subagent_type: 'Explore', prompt: 'targetSymbol' },
269
+ tool_name: 'Agent',
270
+ cwd: tmpDir,
271
+ };
272
+
273
+ const { stdout, code } = runHook(input);
274
+ assert.equal(code, 0);
275
+
276
+ const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
277
+ assert.ok(prompt.includes(goodFile), 'Good path should be present');
278
+ assert.ok(!prompt.includes('node_modules'), 'node_modules should be filtered');
279
+ assert.ok(!prompt.includes('.claude/worktrees'), '.claude/worktrees should be filtered');
280
+ assert.ok(!prompt.includes('/dist/'), 'dist should be filtered');
281
+ assert.ok(!prompt.includes('.git/hooks'), '.git should be filtered');
282
+ });
283
+
284
+ // -------------------------------------------------------------------------
285
+ // AC-7 edge: all symbols in noise paths → falls back to static template (no LSP block)
286
+ // -------------------------------------------------------------------------
287
+ test('AC-7: all symbols filtered → falls back to static template (no LSP block)', () => {
288
+ // Write only a noise file that would match the query but lives in node_modules
289
+ const nodeModDir = path.join(tmpDir, 'node_modules', 'lib');
290
+ fs.mkdirSync(nodeModDir, { recursive: true });
291
+ fs.writeFileSync(path.join(nodeModDir, 'index.js'), 'function badNodeModules() {}\n');
292
+
293
+ const input = {
294
+ tool_name: 'Agent',
295
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: functions' },
296
+ cwd: tmpDir,
297
+ };
298
+
299
+ const { stdout, code } = runHook(input);
167
300
  assert.equal(code, 0);
301
+
302
+ const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
303
+ assert.ok(
304
+ !prompt.includes('[LSP Phase -- locations found]'),
305
+ 'Should not inject LSP block when all paths filtered'
306
+ );
307
+ assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Should still inject protocol');
168
308
  });
169
309
 
170
- test('preserves all original tool_input fields', () => {
310
+ // -------------------------------------------------------------------------
311
+ // AC-8: Deduplication guard prevents double-injection
312
+ // -------------------------------------------------------------------------
313
+ test('AC-8: dedup guard — skips injection if Search Protocol already present', () => {
171
314
  const input = {
172
315
  tool_name: 'Agent',
173
316
  tool_input: {
174
317
  subagent_type: 'Explore',
175
- prompt: 'Find: API routes',
318
+ prompt:
319
+ 'Find: config\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\nalready here',
320
+ },
321
+ cwd: tmpDir,
322
+ };
323
+
324
+ const { stdout, code } = runHook(input);
325
+ assert.equal(code, 0);
326
+ // Dedup guard should fire — no output (hook returns without modification)
327
+ assert.equal(stdout, '', 'Expected no output when dedup guard fires');
328
+ });
329
+
330
+ test('AC-8: dedup guard — skips injection if LSP Phase already present', () => {
331
+ const input = {
332
+ tool_name: 'Agent',
333
+ tool_input: {
334
+ subagent_type: 'Explore',
335
+ prompt: 'Find: config\n\n## [LSP Phase -- locations found]\n\n/some/file.ts:10 -- foo (Fn)',
336
+ },
337
+ cwd: tmpDir,
338
+ };
339
+
340
+ const { stdout, code } = runHook(input);
341
+ assert.equal(code, 0);
342
+ assert.equal(stdout, '', 'Expected no output when LSP Phase marker already present');
343
+ });
344
+
345
+ // -------------------------------------------------------------------------
346
+ // AC-9: No subprocess means timeout config has no effect — static fallback still works
347
+ // -------------------------------------------------------------------------
348
+ test('AC-9: config with explore_lsp_timeout_ms is ignored — regex extraction always runs inline', () => {
349
+ // Create project with a very short timeout config (no longer relevant, but must not crash)
350
+ const projectWithConfig = createTempProject({
351
+ configYaml: 'explore_lsp_timeout_ms: 100\n',
352
+ });
353
+
354
+ // No matching symbols in the project directory
355
+ const input = {
356
+ tool_name: 'Agent',
357
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: symbols' },
358
+ cwd: projectWithConfig,
359
+ };
360
+
361
+ const { stdout, code } = runHook(input);
362
+ assert.equal(code, 0);
363
+
364
+ // Should fall back to static template since no matching symbols
365
+ const result = JSON.parse(stdout);
366
+ const prompt = result.hookSpecificOutput.updatedInput.prompt;
367
+ assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Should inject static protocol');
368
+ assert.ok(
369
+ !prompt.includes('[LSP Phase -- locations found]'),
370
+ 'Should NOT have LSP block (no matching symbols)'
371
+ );
372
+
373
+ fs.rmSync(projectWithConfig, { recursive: true, force: true });
374
+ });
375
+
376
+ // -------------------------------------------------------------------------
377
+ // AC-10: Exit 0 on malformed JSON input
378
+ // -------------------------------------------------------------------------
379
+ test('AC-10: exits 0 on malformed JSON stdin', () => {
380
+ const { code, stdout } = runHook('not valid json {{ }}');
381
+ assert.equal(code, 0);
382
+ assert.equal(stdout, '');
383
+ });
384
+
385
+ // AC-10: missing tool_input field
386
+ test('AC-10: exits 0 when tool_input is missing', () => {
387
+ const input = {
388
+ tool_name: 'Agent',
389
+ // tool_input deliberately omitted
390
+ cwd: tmpDir,
391
+ };
392
+
393
+ const { code } = runHook(input);
394
+ assert.equal(code, 0);
395
+ });
396
+
397
+ // AC-10: filesystem error (cwd that does not exist)
398
+ test('AC-10: exits 0 when cwd does not exist (filesystem error)', () => {
399
+ const input = {
400
+ tool_name: 'Agent',
401
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
402
+ cwd: '/nonexistent/path/that/does/not/exist',
403
+ };
404
+
405
+ const { code } = runHook(input);
406
+ assert.equal(code, 0);
407
+ });
408
+
409
+ // AC-10: no source files in cwd → fallback to static protocol
410
+ test('AC-10: exits 0 and falls back to static protocol when cwd has no matching source files', () => {
411
+ const input = {
412
+ tool_name: 'Agent',
413
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
414
+ cwd: tmpDir,
415
+ };
416
+
417
+ const { code, stdout } = runHook(input);
418
+ assert.equal(code, 0);
419
+ // Should fall back to static protocol
420
+ const result = JSON.parse(stdout);
421
+ assert.ok(
422
+ result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol (auto-injected')
423
+ );
424
+ });
425
+
426
+ // -------------------------------------------------------------------------
427
+ // AC-12: All original tool_input fields preserved in updatedInput
428
+ // -------------------------------------------------------------------------
429
+ test('AC-12: all original tool_input fields preserved after Phase 1 hit', () => {
430
+ // Write a fixture whose symbol name contains "ApiRoutes" — matches query by substring
431
+ writeFixtureFile(tmpDir, 'api.ts', 'export class ApiRoutes {}\n');
432
+
433
+ const input = {
434
+ tool_name: 'Agent',
435
+ tool_input: {
436
+ subagent_type: 'Explore',
437
+ // Query "ApiRoutes" substring-matches the class name
438
+ prompt: 'ApiRoutes',
176
439
  model: 'haiku',
177
- description: 'search for routes',
440
+ description: 'search for API routes',
178
441
  run_in_background: false,
442
+ custom_field: 'custom_value',
179
443
  },
180
444
  cwd: tmpDir,
181
445
  };
@@ -184,18 +448,24 @@ describe('df-explore-protocol hook', () => {
184
448
  assert.equal(code, 0);
185
449
 
186
450
  const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
451
+ assert.equal(updated.subagent_type, 'Explore');
187
452
  assert.equal(updated.model, 'haiku');
188
- assert.equal(updated.description, 'search for routes');
453
+ assert.equal(updated.description, 'search for API routes');
189
454
  assert.equal(updated.run_in_background, false);
190
- assert.equal(updated.subagent_type, 'Explore');
455
+ assert.equal(updated.custom_field, 'custom_value');
456
+ // Prompt is modified (has injection) but still contains original text
457
+ assert.ok(updated.prompt.includes('ApiRoutes'));
191
458
  });
192
459
 
193
- test('does not double-inject if protocol already present', () => {
460
+ test('AC-12: all original tool_input fields preserved after Phase 1 fallback', () => {
461
+ // No matching files → fallback path
194
462
  const input = {
195
463
  tool_name: 'Agent',
196
464
  tool_input: {
197
465
  subagent_type: 'Explore',
198
- prompt: 'Find: config\n\n---\n## Search Protocol (auto-injected — MUST follow)\n\nalready here',
466
+ prompt: 'Find: something',
467
+ model: 'sonnet',
468
+ extra: 'value',
199
469
  },
200
470
  cwd: tmpDir,
201
471
  };
@@ -204,18 +474,127 @@ describe('df-explore-protocol hook', () => {
204
474
  assert.equal(code, 0);
205
475
 
206
476
  const updated = JSON.parse(stdout).hookSpecificOutput.updatedInput;
207
- const matches = updated.prompt.match(/Search Protocol \(auto-injected/g);
208
- // Currently will double-inject — documenting current behavior
209
- // If this becomes a problem, add dedup logic
210
- assert.ok(matches.length >= 1);
477
+ assert.equal(updated.model, 'sonnet');
478
+ assert.equal(updated.extra, 'value');
479
+ assert.equal(updated.subagent_type, 'Explore');
211
480
  });
212
481
 
213
- test('handles missing prompt gracefully', () => {
482
+ // -------------------------------------------------------------------------
483
+ // Existing behavior: non-Explore/non-Agent pass-through
484
+ // -------------------------------------------------------------------------
485
+ test('ignores non-Agent tool calls', () => {
486
+ const input = {
487
+ tool_name: 'Read',
488
+ tool_input: { file_path: '/some/file.ts' },
489
+ cwd: tmpDir,
490
+ };
491
+
492
+ const { stdout, code } = runHook(input);
493
+ assert.equal(code, 0);
494
+ assert.equal(stdout, '');
495
+ });
496
+
497
+ test('ignores non-Explore agent calls', () => {
214
498
  const input = {
215
499
  tool_name: 'Agent',
216
- tool_input: {
217
- subagent_type: 'Explore',
218
- },
500
+ tool_input: { subagent_type: 'reasoner', prompt: 'Analyze this code' },
501
+ cwd: tmpDir,
502
+ };
503
+
504
+ const { stdout, code } = runHook(input);
505
+ assert.equal(code, 0);
506
+ assert.equal(stdout, '');
507
+ });
508
+
509
+ test('handles case-insensitive subagent_type (EXPLORE)', () => {
510
+ const input = {
511
+ tool_name: 'Agent',
512
+ tool_input: { subagent_type: 'EXPLORE', prompt: 'Find: test utilities' },
513
+ cwd: tmpDir,
514
+ };
515
+
516
+ const { stdout, code } = runHook(input);
517
+ assert.equal(code, 0);
518
+
519
+ const result = JSON.parse(stdout);
520
+ assert.ok(result.hookSpecificOutput.updatedInput.prompt.includes('Search Protocol'));
521
+ });
522
+
523
+ // -------------------------------------------------------------------------
524
+ // AC-15: Phase 1 filters symbols by substring match on symbol name OR file path
525
+ // -------------------------------------------------------------------------
526
+ test('AC-15: Phase 1 includes symbols matching query in name or filepath', () => {
527
+ // File path contains "database" — all functions in it should be included
528
+ writeFixtureFile(tmpDir, 'database.ts', 'export function connect() {}\nexport function close() {}\n');
529
+ // File name does not match, but symbol name "databaseHelper" contains "database"
530
+ writeFixtureFile(tmpDir, 'utils.ts', 'export function databaseHelper() {}\nexport function unrelated() {}\n');
531
+
532
+ // Query "database" matches filepath of database.ts and symbol name databaseHelper
533
+ const input = {
534
+ tool_name: 'Agent',
535
+ tool_input: { subagent_type: 'Explore', prompt: 'database' },
536
+ cwd: tmpDir,
537
+ };
538
+
539
+ const { stdout, code } = runHook(input);
540
+ assert.equal(code, 0);
541
+
542
+ const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
543
+ assert.ok(prompt.includes('[LSP Phase -- locations found]'), 'Should have LSP block');
544
+ // databaseHelper matches by symbol name substring
545
+ assert.ok(prompt.includes('databaseHelper'), 'Symbol matching query by name should be included');
546
+ // connect/close match because their file path includes "database"
547
+ assert.ok(prompt.includes('connect'), 'Symbol in matching filepath should be included');
548
+ // unrelated in utils.ts should not be included (path has "utils", not "database"; name "unrelated" doesn't match)
549
+ assert.ok(!prompt.includes('unrelated'), 'Unrelated symbol in non-matching file excluded');
550
+ });
551
+
552
+ // -------------------------------------------------------------------------
553
+ // Phase 1 no matching symbols → static fallback
554
+ // -------------------------------------------------------------------------
555
+ test('Phase 1 no matching symbols falls back to static template injection', () => {
556
+ // Write a file with a symbol that does NOT match the query
557
+ writeFixtureFile(tmpDir, 'unrelated.js', 'function completelyDifferent() {}\n');
558
+
559
+ const input = {
560
+ tool_name: 'Agent',
561
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: nothing here' },
562
+ cwd: tmpDir,
563
+ };
564
+
565
+ const { stdout, code } = runHook(input);
566
+ assert.equal(code, 0);
567
+
568
+ const prompt = JSON.parse(stdout).hookSpecificOutput.updatedInput.prompt;
569
+ assert.ok(prompt.includes('Search Protocol (auto-injected'), 'Should inject static protocol');
570
+ assert.ok(!prompt.includes('[LSP Phase -- locations found]'), 'No LSP block for empty results');
571
+ });
572
+
573
+ // -------------------------------------------------------------------------
574
+ // permissionDecision is always 'allow'
575
+ // -------------------------------------------------------------------------
576
+ test('hookSpecificOutput has permissionDecision allow', () => {
577
+ const input = {
578
+ tool_name: 'Agent',
579
+ tool_input: { subagent_type: 'Explore', prompt: 'Find: something' },
580
+ cwd: tmpDir,
581
+ };
582
+
583
+ const { stdout, code } = runHook(input);
584
+ assert.equal(code, 0);
585
+
586
+ const out = JSON.parse(stdout).hookSpecificOutput;
587
+ assert.equal(out.permissionDecision, 'allow');
588
+ assert.equal(out.hookEventName, 'PreToolUse');
589
+ });
590
+
591
+ // -------------------------------------------------------------------------
592
+ // Missing prompt field — should inject into empty string base
593
+ // -------------------------------------------------------------------------
594
+ test('handles missing prompt field gracefully', () => {
595
+ const input = {
596
+ tool_name: 'Agent',
597
+ tool_input: { subagent_type: 'Explore' },
219
598
  cwd: tmpDir,
220
599
  };
221
600