frontload-hook.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. /**
  2. * Front-load hook project resolution (#964).
  3. *
  4. * The Claude `UserPromptSubmit` front-load hook must inject CodeGraph context
  5. * for the RIGHT project — including the monorepo case where the agent's cwd is
  6. * an un-indexed workspace root and the index lives in a sub-project. These test
  7. * `planFrontload` / `findIndexedSubprojectRoots` directly (the hook's decision
  8. * logic), since the end-to-end hook is validated by a live agent run, not a
  9. * unit test.
  10. */
  11. import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
  12. import * as fs from 'fs';
  13. import * as os from 'os';
  14. import * as path from 'path';
  15. import { planFrontload, findIndexedSubprojectRoots, unsafeIndexRootReason, isStructuralPrompt, hasStructuralKeyword, extractCodeTokens, PROMPT_HOOK_INJECTION_MAX, CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT, capPromptHookInjection } from '../src/directory';
  16. // Make the built-in exports configurable so HOME can point at a real temp
  17. // fixture without changing the process environment or the user's home files.
  18. vi.mock('os', async (importOriginal) => ({ ...await importOriginal<typeof import('os')>() }));
  19. /** Make `dir` look indexed (isInitialized needs `.codegraph/codegraph.db`). */
  20. function mkIndexed(dir: string): string {
  21. fs.mkdirSync(path.join(dir, '.codegraph'), { recursive: true });
  22. fs.writeFileSync(path.join(dir, '.codegraph', 'codegraph.db'), '');
  23. return dir;
  24. }
  25. /** A workspace-root manifest so the down-scan gate (looksLikeProjectRoot) passes. */
  26. function mkWorkspaceRoot(dir: string): string {
  27. fs.mkdirSync(dir, { recursive: true });
  28. fs.writeFileSync(path.join(dir, 'package.json'), '{"private":true,"workspaces":["packages/*"]}');
  29. return dir;
  30. }
  31. describe('planFrontload — front-load hook project resolution (#964)', () => {
  32. let tmp: string;
  33. beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-frontload-'))); });
  34. afterEach(() => {
  35. vi.restoreAllMocks();
  36. fs.rmSync(tmp, { recursive: true, force: true });
  37. });
  38. it('cwd is itself indexed → front-load cwd (the common single-project case)', () => {
  39. mkIndexed(tmp);
  40. const plan = planFrontload(tmp, 'how does login work');
  41. expect(plan.exploreRoot).toBe(tmp);
  42. expect(plan.viaSubScan).toBe(false);
  43. expect(plan.nudgeProjects).toEqual([]);
  44. });
  45. it('a nested file under an indexed project resolves up to that project', () => {
  46. mkIndexed(tmp);
  47. const nested = path.join(tmp, 'src', 'deep');
  48. fs.mkdirSync(nested, { recursive: true });
  49. expect(planFrontload(nested, 'trace the flow').exploreRoot).toBe(tmp);
  50. });
  51. it('un-indexed workspace root with ONE indexed sub-project → front-load it (the #964 case)', () => {
  52. mkWorkspaceRoot(tmp);
  53. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  54. const plan = planFrontload(tmp, 'how does the request get handled');
  55. expect(plan.exploreRoot).toBe(api);
  56. expect(plan.viaSubScan).toBe(true);
  57. expect(plan.nudgeProjects).toEqual([]);
  58. });
  59. it('multiple indexed sub-projects, prompt names one by path → front-load it, nudge the rest', () => {
  60. mkWorkspaceRoot(tmp);
  61. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  62. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  63. const plan = planFrontload(tmp, 'in packages/api, how does the handler validate the token?');
  64. expect(plan.exploreRoot).toBe(api);
  65. expect(plan.viaSubScan).toBe(true);
  66. expect(plan.nudgeProjects).toEqual([web]);
  67. });
  68. it('multiple indexed sub-projects, prompt names one by package name → front-load it', () => {
  69. mkWorkspaceRoot(tmp);
  70. mkIndexed(path.join(tmp, 'packages', 'api'));
  71. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  72. const plan = planFrontload(tmp, 'how does the web frontend render the dashboard?');
  73. expect(plan.exploreRoot).toBe(web);
  74. });
  75. it('multiple indexed sub-projects, NO clear match → nudge the full list, do not guess', () => {
  76. mkWorkspaceRoot(tmp);
  77. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  78. const web = mkIndexed(path.join(tmp, 'packages', 'web'));
  79. const plan = planFrontload(tmp, 'how does authentication work end to end?');
  80. expect(plan.exploreRoot).toBeNull();
  81. expect(plan.viaSubScan).toBe(true);
  82. expect(plan.nudgeProjects.sort()).toEqual([api, web].sort());
  83. });
  84. it('un-indexed dir that is NOT a workspace root → no-op (guards $HOME-style crawls)', () => {
  85. // Indexed project exists below, but cwd has no manifest, so the down-scan is skipped.
  86. mkIndexed(path.join(tmp, 'some', 'project'));
  87. const plan = planFrontload(tmp, 'how does it work');
  88. expect(plan.exploreRoot).toBeNull();
  89. expect(plan.nudgeProjects).toEqual([]);
  90. });
  91. it.each([
  92. { root: 'home', manifest: 'package.json', children: 1 },
  93. { root: 'home', manifest: 'package.json', children: 2 },
  94. { root: 'home', manifest: 'WORKSPACE', children: 1 },
  95. { root: 'parent of home', manifest: 'package.json', children: 1 },
  96. ])('$root with stray $manifest and $children indexed children → no-op (#1454)', ({ root, manifest, children }) => {
  97. const homeDir = root === 'home' ? tmp : path.join(tmp, 'user');
  98. fs.mkdirSync(homeDir, { recursive: true });
  99. vi.spyOn(os, 'homedir').mockReturnValue(homeDir);
  100. if (manifest === 'package.json') mkWorkspaceRoot(tmp);
  101. else fs.mkdirSync(path.join(tmp, manifest)); // Even a WORKSPACE directory opens the manifest gate.
  102. mkIndexed(path.join(tmp, 'packages', 'api'));
  103. if (children === 2) mkIndexed(path.join(tmp, 'packages', 'web'));
  104. expect(unsafeIndexRootReason(tmp)).toBe(root === 'home' ? 'your home directory' : 'a parent of your home directory');
  105. expect(planFrontload(tmp, 'how does authentication work end to end?')).toEqual({
  106. exploreRoot: null,
  107. nudgeProjects: [],
  108. viaSubScan: false,
  109. });
  110. expect(findIndexedSubprojectRoots(tmp)).toEqual([]);
  111. });
  112. it('nothing indexed anywhere → no-op', () => {
  113. mkWorkspaceRoot(tmp);
  114. fs.mkdirSync(path.join(tmp, 'packages', 'api'), { recursive: true });
  115. const plan = planFrontload(tmp, 'how does it work');
  116. expect(plan.exploreRoot).toBeNull();
  117. expect(plan.nudgeProjects).toEqual([]);
  118. });
  119. });
  120. describe('findIndexedSubprojectRoots', () => {
  121. let tmp: string;
  122. beforeEach(() => { tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'cg-subscan-'))); });
  123. afterEach(() => { fs.rmSync(tmp, { recursive: true, force: true }); });
  124. it('finds indexed projects a couple levels down and skips node_modules/.git', () => {
  125. mkIndexed(path.join(tmp, 'packages', 'api'));
  126. mkIndexed(path.join(tmp, 'services', 'auth'));
  127. // Decoys that must NOT be scanned into.
  128. mkIndexed(path.join(tmp, 'node_modules', 'dep'));
  129. mkIndexed(path.join(tmp, '.git', 'x'));
  130. const found = findIndexedSubprojectRoots(tmp).map((p) => path.relative(tmp, p)).sort();
  131. expect(found).toEqual([path.join('packages', 'api'), path.join('services', 'auth')].sort());
  132. });
  133. it('does not descend INTO an indexed project (a project\'s sub-dirs are not separate projects)', () => {
  134. const api = mkIndexed(path.join(tmp, 'packages', 'api'));
  135. mkIndexed(path.join(api, 'submodule')); // nested index under an already-indexed project
  136. const found = findIndexedSubprojectRoots(tmp);
  137. expect(found).toEqual([api]);
  138. });
  139. it('respects the depth bound', () => {
  140. mkIndexed(path.join(tmp, 'a', 'b', 'c', 'd', 'e', 'deep'));
  141. expect(findIndexedSubprojectRoots(tmp, { maxDepth: 2 })).toEqual([]);
  142. });
  143. });
  144. describe('hasStructuralKeyword — keyword signal fires the hook directly (#994)', () => {
  145. it('English keywords match with word boundaries so "flow" ≠ "flower"', () => {
  146. expect(hasStructuralKeyword('how does article publish work')).toBe(true);
  147. expect(hasStructuralKeyword('where is the token validated')).toBe(true);
  148. expect(hasStructuralKeyword('trace the request flow')).toBe(true);
  149. expect(hasStructuralKeyword('what calls parseToken')).toBe(true);
  150. expect(hasStructuralKeyword('water the flower')).toBe(false); // "flow" in "flower"
  151. });
  152. it('Chinese keywords match WITHOUT `\\b` — the #994 fix (were silently dropped)', () => {
  153. expect(hasStructuralKeyword('介绍文章发布流程')).toBe(true); // introduce / flow
  154. expect(hasStructuralKeyword('登录是如何实现的')).toBe(true); // how / implement
  155. expect(hasStructuralKeyword('这个函数的调用链')).toBe(true); // call (chain)
  156. expect(hasStructuralKeyword('支付模块依赖哪些服务')).toBe(true); // depend
  157. expect(hasStructuralKeyword('修复这个拼写错误')).toBe(false); // "fix this typo"
  158. });
  159. it('a bare code-token is NOT a keyword — it needs graph verification', () => {
  160. expect(hasStructuralKeyword('看看 get_user 这段逻辑')).toBe(false);
  161. expect(hasStructuralKeyword('I really love JavaScript')).toBe(false);
  162. });
  163. });
  164. describe('hasStructuralKeyword — Latin-script languages, Cyrillic, JA/KO (#1126)', () => {
  165. it('French structural prompts fire — including the prompts from the report', () => {
  166. expect(hasStructuralKeyword('comment marche la state machine des commandes ?')).toBe(true);
  167. expect(hasStructuralKeyword("explique l'architecture du module de stock")).toBe(true);
  168. expect(hasStructuralKeyword('qui appelle cette fonction de parsing ?')).toBe(true);
  169. expect(hasStructuralKeyword('de quoi dépend le module de paiement ?')).toBe(true);
  170. });
  171. it('accented keyword edges match — ASCII `\\b` could never bound "où"', () => {
  172. expect(hasStructuralKeyword('où est validé le token ?')).toBe(true);
  173. expect(hasStructuralKeyword("d'où vient cette valeur ?")).toBe(true);
  174. });
  175. it('Spanish / Portuguese / German / Italian fire', () => {
  176. expect(hasStructuralKeyword('¿cómo funciona la máquina de estados de pedidos?')).toBe(true);
  177. expect(hasStructuralKeyword('¿qué rompe este cambio?')).toBe(true);
  178. expect(hasStructuralKeyword('como funciona a máquina de estados dos pedidos?')).toBe(true);
  179. expect(hasStructuralKeyword('qual é a arquitetura do módulo de estoque?')).toBe(true);
  180. expect(hasStructuralKeyword('wie funktioniert die Zustandsmaschine für Bestellungen?')).toBe(true);
  181. expect(hasStructuralKeyword('wovon hängt das Zahlungsmodul ab?')).toBe(true);
  182. expect(hasStructuralKeyword('come funziona la macchina a stati degli ordini?')).toBe(true);
  183. expect(hasStructuralKeyword('spiegami la struttura del modulo ordini')).toBe(true);
  184. });
  185. it('Russian / Japanese / Korean / traditional Chinese fire', () => {
  186. expect(hasStructuralKeyword('как работает конечный автомат заказов?')).toBe(true);
  187. expect(hasStructuralKeyword('от чего зависит модуль оплаты?')).toBe(true);
  188. expect(hasStructuralKeyword('注文のステートマシンの仕組みを説明して')).toBe(true);
  189. expect(hasStructuralKeyword('この関数の呼び出しの流れは?')).toBe(true);
  190. expect(hasStructuralKeyword('주문 상태 머신은 어떻게 작동하나요?')).toBe(true);
  191. expect(hasStructuralKeyword('訂單狀態機的架構是怎麼實現的?')).toBe(true);
  192. });
  193. it('English derived forms fire — "architecture"/"dependencies" failed the old exact-word list', () => {
  194. expect(hasStructuralKeyword('explain the architecture of the stock module')).toBe(true);
  195. expect(hasStructuralKeyword('what are the dependencies of the parser?')).toBe(true);
  196. });
  197. it('second-tier languages fire — VI/TR/ID/PL/UA/NL/CS/RO/HU/EL/SV/NO/FI/HI', () => {
  198. expect(hasStructuralKeyword('state machine của đơn hàng hoạt động thế nào?')).toBe(true); // Vietnamese
  199. expect(hasStructuralKeyword('sipariş durum makinesi nasıl çalışıyor?')).toBe(true); // Turkish
  200. expect(hasStructuralKeyword('bu fonksiyonun bağımlılıkları neler?')).toBe(true); // Turkish (stem)
  201. expect(hasStructuralKeyword('bagaimana cara kerja mesin status pesanan?')).toBe(true); // Indonesian
  202. expect(hasStructuralKeyword('jak działa maszyna stanów zamówień?')).toBe(true); // Polish
  203. expect(hasStructuralKeyword('co wywołuje tę funkcję?')).toBe(true); // Polish (stem)
  204. expect(hasStructuralKeyword('як працює кінцевий автомат замовлень?')).toBe(true); // Ukrainian
  205. expect(hasStructuralKeyword('від чого залежить модуль оплати?')).toBe(true); // Ukrainian (stem)
  206. expect(hasStructuralKeyword('hoe werkt de state machine van bestellingen?')).toBe(true); // Dutch
  207. expect(hasStructuralKeyword('jak funguje stavový automat objednávek?')).toBe(true); // Czech
  208. expect(hasStructuralKeyword('cum funcționează mașina de stări a comenzilor?')).toBe(true); // Romanian
  209. expect(hasStructuralKeyword('hogyan működik a rendelések állapotgépe?')).toBe(true); // Hungarian
  210. expect(hasStructuralKeyword('πώς λειτουργεί η μηχανή καταστάσεων παραγγελιών;')).toBe(true); // Greek
  211. expect(hasStructuralKeyword('hur fungerar orderns tillståndsmaskin?')).toBe(true); // Swedish
  212. expect(hasStructuralKeyword('hvordan fungerer ordrenes tilstandsmaskin?')).toBe(true); // Norwegian/Danish
  213. expect(hasStructuralKeyword('miten tilausten tilakone toimii?')).toBe(true); // Finnish
  214. expect(hasStructuralKeyword('ऑर्डर स्टेट मशीन कैसे काम करती है?')).toBe(true); // Hindi
  215. });
  216. it('RTL scripts and Thai fire — proclitics/unsegmented text uses substring matching', () => {
  217. expect(hasStructuralKeyword('كيف تعمل آلة حالات الطلبات؟')).toBe(true); // Arabic
  218. expect(hasStructuralKeyword('وكيف يعتمد هذا على قاعدة البيانات؟')).toBe(true); // Arabic, proclitic و attached
  219. expect(hasStructuralKeyword('ماشین وضعیت سفارش‌ها چگونه کار می‌کند؟')).toBe(true); // Farsi
  220. expect(hasStructuralKeyword('איך עובדת מכונת המצבים של ההזמנות?')).toBe(true); // Hebrew
  221. expect(hasStructuralKeyword('สถาปัตยกรรมของระบบทำงานอย่างไร')).toBe(true); // Thai
  222. });
  223. it('terms that collide with English or code words are deliberately excluded', () => {
  224. expect(hasStructuralKeyword('pad the buffer with zeros')).toBe(false); // NL pad=path skipped
  225. expect(hasStructuralKeyword('declare a var for the count')).toBe(false); // SV var=where skipped
  226. expect(hasStructuralKeyword('refresh the token')).toBe(false); // CS tok=flow skipped
  227. expect(hasStructuralKeyword('run the llama model locally')).toBe(false); // ES bare llama skipped
  228. expect(hasStructuralKeyword('come back to this later')).toBe(false); // IT bare come skipped
  229. });
  230. it('stems match only at word start — no mid-word false positives', () => {
  231. expect(hasStructuralKeyword('restructure this paragraph')).toBe(false); // "structur" mid-word
  232. expect(hasStructuralKeyword('an independent module')).toBe(false); // "depend" mid-word
  233. expect(hasStructuralKeyword('water the flower')).toBe(false); // unchanged guarantee
  234. });
  235. it('bounded stems reject ordinary-English completions (#1138)', () => {
  236. expect(hasStructuralKeyword('he has a callus on his palm')).toBe(false);
  237. expect(hasStructuralKeyword('a lovely calligraphy font')).toBe(false);
  238. expect(hasStructuralKeyword('Connecticut is a state')).toBe(false);
  239. expect(hasStructuralKeyword('connective tissue damage')).toBe(false);
  240. expect(hasStructuralKeyword('she is very affectionate')).toBe(false);
  241. expect(hasStructuralKeyword('Tracey went home early')).toBe(false);
  242. });
  243. it('bounded stems keep every structural derived form (#1138)', () => {
  244. // call
  245. expect(hasStructuralKeyword('list the callers of parseToken')).toBe(true);
  246. expect(hasStructuralKeyword('what callbacks fire on save')).toBe(true);
  247. expect(hasStructuralKeyword('is submitOrder callable from the worker')).toBe(true);
  248. expect(hasStructuralKeyword('find every call site of dispose')).toBe(true);
  249. expect(hasStructuralKeyword('who called setupRouter')).toBe(true);
  250. // trace ("tracing" is covered by the exact-word list — the e drops)
  251. expect(hasStructuralKeyword('trace the request')).toBe(true);
  252. expect(hasStructuralKeyword('we traced it to the cache layer')).toBe(true);
  253. expect(hasStructuralKeyword('add tracing to the pipeline')).toBe(true);
  254. // affect / connect
  255. expect(hasStructuralKeyword('which modules are affected by this change')).toBe(true);
  256. expect(hasStructuralKeyword('how do the connections get pooled')).toBe(true);
  257. expect(hasStructuralKeyword('the connector registers itself at boot')).toBe(true);
  258. });
  259. it('non-structural prose stays a no-op in every covered language', () => {
  260. expect(hasStructuralKeyword('corrige cette faute de frappe')).toBe(false); // FR "fix this typo"
  261. expect(hasStructuralKeyword('arregla este error tipográfico')).toBe(false); // ES
  262. expect(hasStructuralKeyword('behebe diesen Tippfehler')).toBe(false); // DE
  263. expect(hasStructuralKeyword('исправь эту опечатку')).toBe(false); // RU
  264. expect(hasStructuralKeyword('このタイプミスを直して')).toBe(false); // JA
  265. expect(hasStructuralKeyword('이 오타를 수정해줘')).toBe(false); // KO
  266. expect(hasStructuralKeyword('sửa lỗi chính tả này')).toBe(false); // VI
  267. expect(hasStructuralKeyword('bu yazım hatasını düzelt')).toBe(false); // TR
  268. expect(hasStructuralKeyword('popraw tę literówkę')).toBe(false); // PL
  269. expect(hasStructuralKeyword('صحح هذا الخطأ الإملائي')).toBe(false); // AR
  270. });
  271. });
  272. describe('extractCodeTokens — candidate symbols the hook verifies against the graph', () => {
  273. it('pulls camelCase / PascalCase / snake_case / call / member tokens', () => {
  274. expect(extractCodeTokens('prepareArticlePublish 的调用链')).toContain('prepareArticlePublish');
  275. expect(extractCodeTokens('看看 get_user 这段逻辑')).toContain('get_user'); // snake_case
  276. expect(extractCodeTokens('render() 在哪触发')).toContain('render'); // call form
  277. expect(extractCodeTokens('user.login 做了什么').sort()).toEqual(['login', 'user']); // member access
  278. expect(extractCodeTokens('看看 UserService')).toContain('UserService'); // PascalCase class kept
  279. });
  280. it('a tech brand is extracted as a CANDIDATE — the hook’s graph check is what rejects it', () => {
  281. // This is the #994 follow-up: "JavaScript" is identifier-shaped, so it surfaces
  282. // here as a candidate; the hook only fires if it's a real symbol in the index.
  283. expect(extractCodeTokens('I really love JavaScript')).toEqual(['JavaScript']);
  284. expect(extractCodeTokens('thoughts on GitHub vs GitLab').sort()).toEqual(['GitHub', 'GitLab']);
  285. });
  286. it('ordinary prose and doc/data filenames yield no tokens', () => {
  287. expect(extractCodeTokens('fix typo in readme')).toEqual([]);
  288. expect(extractCodeTokens('fix the typo in README.md')).toEqual([]); // doc filename excluded
  289. expect(extractCodeTokens('bump the version in package.json')).toEqual([]);
  290. expect(extractCodeTokens('water the flower')).toEqual([]);
  291. });
  292. });
  293. describe('isStructuralPrompt — cheap candidate gate (keyword OR code-token)', () => {
  294. it('fires on a keyword prompt in any language', () => {
  295. expect(isStructuralPrompt('how does article publish work')).toBe(true);
  296. expect(isStructuralPrompt('介绍文章发布流程')).toBe(true);
  297. });
  298. it('fires on a code-token prompt with no keyword', () => {
  299. expect(isStructuralPrompt('看看 get_user 这段逻辑')).toBe(true);
  300. expect(isStructuralPrompt('where is prepareArticlePublish 定义')).toBe(true);
  301. expect(isStructuralPrompt('user.login 做了什么')).toBe(true);
  302. });
  303. it('a tech brand passes the CHEAP gate as a candidate — the hook then graph-verifies it', () => {
  304. // Layering, not a bug: isStructuralPrompt is shape-only, so a token-shaped brand
  305. // is a candidate here; the hook rejects it as a non-symbol (proven by the CLI e2e).
  306. expect(isStructuralPrompt('I really love JavaScript')).toBe(true);
  307. });
  308. it('non-structural prose stays a no-op — in either language', () => {
  309. expect(isStructuralPrompt('fix typo in readme')).toBe(false);
  310. expect(isStructuralPrompt('修复这个拼写错误')).toBe(false);
  311. expect(isStructuralPrompt('water the flower')).toBe(false);
  312. expect(isStructuralPrompt('')).toBe(false);
  313. });
  314. });
  315. describe('prompt-hook injection cap (#1694)', () => {
  316. it('PROMPT_HOOK_INJECTION_MAX stays under Claude Code\'s 10k inline hook-output limit', () => {
  317. expect(PROMPT_HOOK_INJECTION_MAX).toBe(9000);
  318. expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT).toBe(10_000);
  319. expect(PROMPT_HOOK_INJECTION_MAX).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
  320. // Leave headroom for the <codegraph_context> wrapper + projectPath nudge lines.
  321. expect(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT - PROMPT_HOOK_INJECTION_MAX).toBeGreaterThanOrEqual(500);
  322. });
  323. it('capPromptHookInjection leaves short payloads intact', () => {
  324. expect(capPromptHookInjection('hello')).toBe('hello');
  325. expect(capPromptHookInjection('x'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe('x'.repeat(PROMPT_HOOK_INJECTION_MAX));
  326. });
  327. it('capPromptHookInjection truncates oversize payloads with the explore notice', () => {
  328. const over = 'a'.repeat(PROMPT_HOOK_INJECTION_MAX + 500);
  329. const out = capPromptHookInjection(over);
  330. expect(out.length).toBeLessThan(over.length);
  331. expect(out.startsWith('a'.repeat(PROMPT_HOOK_INJECTION_MAX))).toBe(true);
  332. expect(out).toContain('…(truncated; call codegraph_explore for the rest)');
  333. // Capped body alone must still fit under the host inline limit.
  334. expect(out.length).toBeLessThan(CLAUDE_CODE_INLINE_HOOK_OUTPUT_LIMIT);
  335. });
  336. });