argo-search 1.0.1 → 2.9.7

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 (463) hide show
  1. package/LICENSE +201 -21
  2. package/README.en.md +822 -0
  3. package/README.es.md +864 -0
  4. package/README.ja.md +864 -0
  5. package/README.ko.md +864 -0
  6. package/README.md +867 -320
  7. package/SKILL.md +135 -0
  8. package/assets/readme/hero.svg +121 -0
  9. package/assets/readme/made-with-beautify.svg +10 -0
  10. package/assets/readme/proof-routes.svg +73 -0
  11. package/assets/readme/settings-keys.png +0 -0
  12. package/assets/readme/settings-tools.png +0 -0
  13. package/assets/readme/why-better.svg +104 -0
  14. package/assets/readme/workflow.svg +83 -0
  15. package/backends/domain_profiles.json +2308 -96
  16. package/backends/engine_registry.yaml +3314 -503
  17. package/backends/query_synonyms_cn.json +35 -0
  18. package/backends/quota_profiles.json +2409 -161
  19. package/backends/source_types_cn.json +83 -0
  20. package/backends/text_lexicon.json +44 -0
  21. package/bin/argo +703 -0
  22. package/bin/argo.js +71 -39
  23. package/config.yaml +4976 -363
  24. package/cordis.patch.yml +21 -0
  25. package/data/countries.json +40 -0
  26. package/data/lang_matrix/lang_capability.json +842 -0
  27. package/data/lang_matrix/matrix_2026-09-10.json +11921 -0
  28. package/data/regions_cn.json +1 -0
  29. package/docs/ADDING_NEW_ENGINE.md +184 -0
  30. package/docs/ARGO_INTRO.md +124 -0
  31. package/docs/DATA_SOURCE_GAPS.md +81 -0
  32. package/docs/DESIGN_LOCAL_DATA_FUSION.md +90 -0
  33. package/docs/ENGINE_CATALOG.md +564 -0
  34. package/docs/MCP_SETUP.md +106 -0
  35. package/docs/PLATFORM_PATHS.md +111 -0
  36. package/docs/RELEASE_NOTES_v2.8.0.md +71 -0
  37. package/docs/RELEASE_NOTES_v2.8.2.md +63 -0
  38. package/docs/RELEASE_NOTES_v2.8.3.md +55 -0
  39. package/docs/RELEASE_NOTES_v2.8.4.md +79 -0
  40. package/docs/RELEASE_NOTES_v2.8.5.md +102 -0
  41. package/docs/RELEASE_NOTES_v2.8.6.md +66 -0
  42. package/docs/RELEASE_NOTES_v2.8.7.md +65 -0
  43. package/docs/RELEASE_NOTES_v2.8.8.md +67 -0
  44. package/docs/RELEASE_NOTES_v2.8.9.md +67 -0
  45. package/docs/RELEASE_NOTES_v2.9.0.md +64 -0
  46. package/docs/SEARCH_ARCHIVE.md +115 -0
  47. package/docs/assets/architecture.svg +104 -0
  48. package/docs/assets/hero.svg +98 -0
  49. package/docs/engines/bangumi.md +27 -0
  50. package/docs/engines/biorxiv.md +27 -0
  51. package/docs/engines/clawhub.md +27 -0
  52. package/docs/engines/crt_sh.md +27 -0
  53. package/docs/engines/datacite.md +27 -0
  54. package/docs/engines/deps_dev.md +27 -0
  55. package/docs/engines/douban_movie.md +27 -0
  56. package/docs/engines/endoflife.md +27 -0
  57. package/docs/engines/flk_law.md +27 -0
  58. package/docs/engines/fxtwitter.md +27 -0
  59. package/docs/engines/google_news.md +27 -0
  60. package/docs/engines/gov_regulations.md +27 -0
  61. package/docs/engines/guqian.md +27 -0
  62. package/docs/engines/hackernews.md +27 -0
  63. package/docs/engines/iplant.md +27 -0
  64. package/docs/engines/keenable.md +27 -0
  65. package/docs/engines/met_museum.md +27 -0
  66. package/docs/engines/nasa_images.md +27 -0
  67. package/docs/engines/netease_music.md +27 -0
  68. package/docs/engines/npm.md +27 -0
  69. package/docs/engines/nvd.md +27 -0
  70. package/docs/engines/openreview.md +27 -0
  71. package/docs/engines/openstd.md +27 -0
  72. package/docs/engines/parallel_free.md +27 -0
  73. package/docs/engines/people_daily.md +27 -0
  74. package/docs/engines/realtime_index.md +27 -0
  75. package/docs/engines/sec_edgar.md +27 -0
  76. package/docs/engines/seltz.md +27 -0
  77. package/docs/engines/skillsmp.md +27 -0
  78. package/docs/engines/sspai.md +27 -0
  79. package/docs/engines/std_samr.md +27 -0
  80. package/docs/engines/train.md +27 -0
  81. package/docs/engines/un_comtrade.md +27 -0
  82. package/docs/engines/wikisource.md +27 -0
  83. package/docs/engines/zdic.md +27 -0
  84. package/docs/engines/zenodo.md +27 -0
  85. package/docs/engines/zhihu_hot_app.md +27 -0
  86. package/docs//344/270/272/344/273/200/344/271/210/351/200/211/346/213/251argo.md +176 -0
  87. package/engines/README.md +10 -0
  88. package/engines/_template_cli.yaml +39 -0
  89. package/engines/_template_http.yaml +37 -0
  90. package/engines/plugins/_template_plugin.py +50 -0
  91. package/engines/specs/_cisa_kev.yaml +54 -0
  92. package/engines/specs/_osv.yaml +63 -0
  93. package/engines/specs/aljazeera.yaml +43 -0
  94. package/engines/specs/antara.yaml +43 -0
  95. package/engines/specs/artic.yaml +20 -0
  96. package/engines/specs/aviation_weather.yaml +39 -0
  97. package/engines/specs/bangumi.yaml +21 -0
  98. package/engines/specs/biorxiv.yaml +24 -0
  99. package/engines/specs/carbon_intensity.yaml +32 -0
  100. package/engines/specs/cinii.yaml +44 -0
  101. package/engines/specs/clawhub.yaml +49 -0
  102. package/engines/specs/cleveland.yaml +19 -0
  103. package/engines/specs/cn_ai_news.yaml +43 -0
  104. package/engines/specs/core.yaml +35 -0
  105. package/engines/specs/crt_sh.yaml +38 -0
  106. package/engines/specs/ct24.yaml +43 -0
  107. package/engines/specs/datacite.yaml +27 -0
  108. package/engines/specs/datagov_uk.yaml +41 -0
  109. package/engines/specs/ddgs_images.yaml +45 -0
  110. package/engines/specs/deepseek_search.yaml +74 -0
  111. package/engines/specs/deezer.yaml +20 -0
  112. package/engines/specs/deps_dev.yaml +21 -0
  113. package/engines/specs/digup.yaml +36 -0
  114. package/engines/specs/doi.yaml +39 -0
  115. package/engines/specs/douban_movie.yaml +22 -0
  116. package/engines/specs/dw_news.yaml +47 -0
  117. package/engines/specs/egov_law.yaml +21 -0
  118. package/engines/specs/elpais.yaml +43 -0
  119. package/engines/specs/endoflife.yaml +21 -0
  120. package/engines/specs/energy_charts.yaml +21 -0
  121. package/engines/specs/factcheck_org.yaml +52 -0
  122. package/engines/specs/faz.yaml +43 -0
  123. package/engines/specs/federal_register.yaml +57 -0
  124. package/engines/specs/firecrawl.yaml +53 -0
  125. package/engines/specs/flk_law.yaml +24 -0
  126. package/engines/specs/folha.yaml +43 -0
  127. package/engines/specs/france24.yaml +44 -0
  128. package/engines/specs/full_fact.yaml +47 -0
  129. package/engines/specs/fxtwitter.yaml +31 -0
  130. package/engines/specs/gbfs_nyc.yaml +32 -0
  131. package/engines/specs/gdacs.yaml +21 -0
  132. package/engines/specs/gdelt.yaml +64 -0
  133. package/engines/specs/google_news.yaml +22 -0
  134. package/engines/specs/gov_regulations.yaml +38 -0
  135. package/engines/specs/guardian_rss.yaml +59 -0
  136. package/engines/specs/hurriyet.yaml +43 -0
  137. package/engines/specs/iplant.yaml +25 -0
  138. package/engines/specs/jikan.yaml +24 -0
  139. package/engines/specs/jolpica.yaml +20 -0
  140. package/engines/specs/k10plus.yaml +20 -0
  141. package/engines/specs/keenable.yaml +44 -0
  142. package/engines/specs/lefigaro.yaml +43 -0
  143. package/engines/specs/listenbrainz.yaml +19 -0
  144. package/engines/specs/matichon.yaml +43 -0
  145. package/engines/specs/met_museum.yaml +21 -0
  146. package/engines/specs/nasa_images.yaml +45 -0
  147. package/engines/specs/netease_music.yaml +38 -0
  148. package/engines/specs/nhk.yaml +43 -0
  149. package/engines/specs/nhtsa_vpic.yaml +20 -0
  150. package/engines/specs/noaa_swpc.yaml +20 -0
  151. package/engines/specs/nvd.yaml +38 -0
  152. package/engines/specs/obis.yaml +20 -0
  153. package/engines/specs/opencitations.yaml +52 -0
  154. package/engines/specs/openf1.yaml +21 -0
  155. package/engines/specs/openfoodfacts.yaml +45 -0
  156. package/engines/specs/openligadb.yaml +20 -0
  157. package/engines/specs/openreview.yaml +37 -0
  158. package/engines/specs/openstd.yaml +21 -0
  159. package/engines/specs/people_daily.yaml +22 -0
  160. package/engines/specs/realtime_index.yaml +34 -0
  161. package/engines/specs/ror.yaml +20 -0
  162. package/engines/specs/satnogs.yaml +20 -0
  163. package/engines/specs/sec_edgar.yaml +46 -0
  164. package/engines/specs/skillsmp.yaml +50 -0
  165. package/engines/specs/sogou_weixin_browser.yaml +30 -0
  166. package/engines/specs/soilgrids.yaml +25 -0
  167. package/engines/specs/sspai.yaml +36 -0
  168. package/engines/specs/stackexchange.yaml +46 -0
  169. package/engines/specs/std_samr.yaml +21 -0
  170. package/engines/specs/tass.yaml +43 -0
  171. package/engines/specs/tle_mirror.yaml +21 -0
  172. package/engines/specs/train.yaml +37 -0
  173. package/engines/specs/tvmaze.yaml +20 -0
  174. package/engines/specs/twitter_syndication.yaml +31 -0
  175. package/engines/specs/un_comtrade.yaml +23 -0
  176. package/engines/specs/unpaywall.yaml +54 -0
  177. package/engines/specs/vnpress.yaml +43 -0
  178. package/engines/specs/weather.yaml +32 -0
  179. package/engines/specs/who_don.yaml +21 -0
  180. package/engines/specs/who_gho.yaml +20 -0
  181. package/engines/specs/wikimedia_commons.yaml +34 -0
  182. package/engines/specs/wikisource.yaml +22 -0
  183. package/engines/specs/worms.yaml +19 -0
  184. package/engines/specs/x_trending.yaml +34 -0
  185. package/engines/specs/yna.yaml +43 -0
  186. package/engines/specs/ynet.yaml +43 -0
  187. package/engines/specs/zbmath.yaml +40 -0
  188. package/engines/specs/zdic.yaml +22 -0
  189. package/engines/specs/zenodo.yaml +27 -0
  190. package/engines/specs/zhihu_hot_app.yaml +35 -0
  191. package/mcp/clients.yaml +74 -0
  192. package/package.json +51 -12
  193. package/packages/dsh-plugin/README.md +134 -0
  194. package/packages/dsh-plugin/cordis.patch.yml +48 -0
  195. package/packages/dsh-plugin/dsh/client.js +1244 -0
  196. package/packages/dsh-plugin/dsh/index.js +2290 -0
  197. package/packages/dsh-plugin/dsh/native-tools.mjs +743 -0
  198. package/packages/dsh-plugin/package.json +38 -0
  199. package/packages/dsh-plugin/test/client.test.js +232 -0
  200. package/packages/dsh-plugin/test/config.test.js +30 -0
  201. package/packages/dsh-plugin/test/index.test.js +1338 -0
  202. package/references/query-templates.md +96 -0
  203. package/references/query-templates.yaml +118 -0
  204. package/scripts/ab_eval.py +219 -0
  205. package/scripts/ab_eval_p0p1.py +266 -0
  206. package/scripts/adaptive.py +271 -70
  207. package/scripts/answer.py +154 -0
  208. package/scripts/archive_run.py +591 -0
  209. package/scripts/argo_engine_registry.py +172 -0
  210. package/scripts/argo_paths.py +728 -0
  211. package/scripts/article.py +136 -0
  212. package/scripts/arxiv_source.py +201 -0
  213. package/scripts/badcase_ledger.py +252 -0
  214. package/scripts/batch_probe.py +241 -0
  215. package/scripts/benchmark.py +19 -0
  216. package/scripts/bounded_run.py +144 -0
  217. package/scripts/browser_auth.py +323 -0
  218. package/scripts/burstiness_detector.py +147 -0
  219. package/scripts/cache.py +1078 -67
  220. package/scripts/cache_guard.py +271 -0
  221. package/scripts/cache_key_vdom.py +56 -0
  222. package/scripts/cache_similarity.py +170 -0
  223. package/scripts/caliber_isolation.py +195 -0
  224. package/scripts/calibrate_lowquality.py +210 -0
  225. package/scripts/candidate_envelope.py +366 -0
  226. package/scripts/chrome_cdp.py +871 -0
  227. package/scripts/circuit_breaker.py +389 -0
  228. package/scripts/citations.py +334 -0
  229. package/scripts/cite_cli.py +59 -0
  230. package/scripts/cjk_tokens.py +95 -0
  231. package/scripts/clarify.py +47 -24
  232. package/scripts/cli_io.py +240 -0
  233. package/scripts/config.py +683 -45
  234. package/scripts/config_cache.py +402 -0
  235. package/scripts/conn_pool.py +220 -0
  236. package/scripts/content_security.py +685 -0
  237. package/scripts/content_signals.py +730 -0
  238. package/scripts/crawl.py +74 -35
  239. package/scripts/daemon.py +866 -0
  240. package/scripts/digup_bridge.py +214 -0
  241. package/scripts/engine_admission.py +366 -0
  242. package/scripts/engine_dispatch.py +1019 -0
  243. package/scripts/engine_env.py +534 -0
  244. package/scripts/engine_failure.py +411 -0
  245. package/scripts/engine_families.py +732 -0
  246. package/scripts/engine_policy.py +218 -0
  247. package/scripts/engine_requires.py +190 -0
  248. package/scripts/engine_status.py +322 -0
  249. package/scripts/engine_validate.py +638 -0
  250. package/scripts/engines.py +778 -294
  251. package/scripts/engines_base.py +1521 -0
  252. package/scripts/engines_builders.py +298 -0
  253. package/scripts/engines_builders_batch10.py +159 -0
  254. package/scripts/engines_builders_batch11.py +174 -0
  255. package/scripts/engines_builders_batch12.py +103 -0
  256. package/scripts/engines_builders_batch13.py +340 -0
  257. package/scripts/engines_builders_batch9.py +1468 -0
  258. package/scripts/engines_builders_cn.py +2283 -0
  259. package/scripts/engines_builders_data.py +2923 -0
  260. package/scripts/engines_builders_data_macro.py +775 -0
  261. package/scripts/engines_builders_feeds.py +255 -0
  262. package/scripts/engines_builders_intl.py +1239 -0
  263. package/scripts/engines_builders_search.py +440 -0
  264. package/scripts/engines_builders_tech.py +1360 -0
  265. package/scripts/evidence.py +588 -162
  266. package/scripts/evidence_loop.py +592 -0
  267. package/scripts/evidence_tier.py +298 -0
  268. package/scripts/except_sets.py +25 -0
  269. package/scripts/extract.py +55 -16
  270. package/scripts/fact_align.py +207 -0
  271. package/scripts/fact_crosscheck.py +230 -0
  272. package/scripts/fetch.py +25 -12
  273. package/scripts/fetch_browser.py +87 -0
  274. package/scripts/fetch_jsrun.py +122 -0
  275. package/scripts/fetch_quality.py +225 -0
  276. package/scripts/fetch_render_tinyfish.py +117 -0
  277. package/scripts/fetch_v3.py +1779 -0
  278. package/scripts/focus_extract.py +350 -0
  279. package/scripts/fulltext_store.py +169 -0
  280. package/scripts/fxtwitter_api.py +325 -0
  281. package/scripts/gen_engine_catalog.py +364 -0
  282. package/scripts/gen_native_tools.py +91 -0
  283. package/scripts/gen_routing_snapshot.py +184 -0
  284. package/scripts/health_check.py +140 -402
  285. package/scripts/health_probe.py +23 -8
  286. package/scripts/hot_state.py +134 -0
  287. package/scripts/http_client.py +1053 -0
  288. package/scripts/image_ops.py +430 -0
  289. package/scripts/image_vision/vision_probe +0 -0
  290. package/scripts/image_vision/vision_probe.swift +126 -0
  291. package/scripts/install.ps1 +148 -0
  292. package/scripts/install.sh +206 -0
  293. package/scripts/job.py +1186 -0
  294. package/scripts/jump_resolver.py +235 -0
  295. package/scripts/lang_capability.py +150 -0
  296. package/scripts/lang_detect.py +289 -0
  297. package/scripts/lang_matrix_probe.py +329 -0
  298. package/scripts/lang_pref.py +311 -0
  299. package/scripts/link_source.py +340 -0
  300. package/scripts/local_engines.py +321 -0
  301. package/scripts/local_image.py +970 -0
  302. package/scripts/local_seek.py +366 -0
  303. package/scripts/macro_countries.py +82 -0
  304. package/scripts/matrix_search_eval.py +1105 -0
  305. package/scripts/mcp_diag.py +8 -3
  306. package/scripts/mcp_handlers.py +1074 -0
  307. package/scripts/mcp_handlers_surface.py +157 -0
  308. package/scripts/mcp_launch.sh +122 -0
  309. package/scripts/mcp_server.py +118 -417
  310. package/scripts/mcp_setup.py +407 -0
  311. package/scripts/mcp_tools.py +330 -0
  312. package/scripts/mcp_transport.py +191 -0
  313. package/scripts/min_version_scan.py +335 -0
  314. package/scripts/net_proxy.py +258 -0
  315. package/scripts/network_aware.py +152 -0
  316. package/scripts/paper.py +274 -0
  317. package/scripts/pdf_extract.py +518 -0
  318. package/scripts/phrase_filter.py +66 -0
  319. package/scripts/plan.py +422 -0
  320. package/scripts/postprocess_signals.py +150 -0
  321. package/scripts/pre-commit +48 -0
  322. package/scripts/query_enhance.py +157 -0
  323. package/scripts/query_operators.py +196 -0
  324. package/scripts/query_rewriter.py +116 -7
  325. package/scripts/query_signals.py +300 -0
  326. package/scripts/query_templates.py +167 -0
  327. package/scripts/query_understanding.py +411 -0
  328. package/scripts/query_variants.py +298 -0
  329. package/scripts/quota.py +488 -76
  330. package/scripts/rank_signals.py +775 -0
  331. package/scripts/ranking_eval.py +319 -0
  332. package/scripts/readability_extract.py +433 -0
  333. package/scripts/recompute.py +415 -0
  334. package/scripts/recovery.py +616 -0
  335. package/scripts/recovery_l0.py +160 -0
  336. package/scripts/redskill/redskill_engine.py +332 -0
  337. package/scripts/regression_p0p1.py +318 -0
  338. package/scripts/replay_eval.py +640 -0
  339. package/scripts/rerank_tfidf.py +225 -0
  340. package/scripts/research.py +591 -403
  341. package/scripts/research_cli.py +361 -0
  342. package/scripts/research_dossier.py +492 -0
  343. package/scripts/research_expand.py +146 -0
  344. package/scripts/research_gates.py +272 -0
  345. package/scripts/research_report.py +209 -0
  346. package/scripts/research_strategy.py +37 -0
  347. package/scripts/research_work_packages.py +195 -0
  348. package/scripts/result_lang.py +311 -0
  349. package/scripts/robots_guard.py +259 -0
  350. package/scripts/route.py +921 -254
  351. package/scripts/route_bangs.py +40 -0
  352. package/scripts/route_cache.py +225 -0
  353. package/scripts/route_combo.py +910 -0
  354. package/scripts/route_domains.py +328 -0
  355. package/scripts/route_lang.py +605 -0
  356. package/scripts/route_log.py +63 -0
  357. package/scripts/route_policy.py +250 -0
  358. package/scripts/rrf_classic.py +66 -0
  359. package/scripts/screenshot.py +73 -0
  360. package/scripts/search.py +897 -405
  361. package/scripts/search_attribution.py +88 -0
  362. package/scripts/search_benchmark.py +407 -0
  363. package/scripts/search_cli.py +627 -0
  364. package/scripts/search_entry.py +389 -0
  365. package/scripts/search_output.py +606 -0
  366. package/scripts/search_pipeline.py +1077 -0
  367. package/scripts/search_rank.py +991 -0
  368. package/scripts/search_types.py +27 -12
  369. package/scripts/seek_locator.py +68 -0
  370. package/scripts/semantic_evidence.py +215 -0
  371. package/scripts/serp_guard.py +228 -0
  372. package/scripts/serp_kill_log.py +51 -0
  373. package/scripts/single_flight.py +120 -0
  374. package/scripts/social_engines/__init__.py +10 -0
  375. package/scripts/social_engines/bilibili_engine.py +131 -0
  376. package/scripts/social_engines/hackernews_engine.py +113 -0
  377. package/scripts/social_engines/reddit_engine.py +172 -0
  378. package/scripts/social_engines/twitter_engine.py +397 -0
  379. package/scripts/social_engines/v2ex_engine.py +101 -0
  380. package/scripts/social_engines/weibo_engine.py +128 -0
  381. package/scripts/social_engines/xiaohongshu_engine.py +137 -0
  382. package/scripts/social_engines/zhihu_engine.py +139 -0
  383. package/scripts/social_research.py +201 -0
  384. package/scripts/sogou_weixin_browser.py +318 -0
  385. package/scripts/source_ledger.py +249 -0
  386. package/scripts/stage_timing.py +58 -0
  387. package/scripts/stats_cli.py +95 -0
  388. package/scripts/straggler_policy.py +60 -0
  389. package/scripts/stylometry_detector.py +231 -0
  390. package/scripts/sync_backends.py +353 -0
  391. package/scripts/sync_installs.py +29 -0
  392. package/scripts/template_cli.py +127 -0
  393. package/scripts/text_lexicon.py +72 -0
  394. package/scripts/tfidf_router.py +109 -37
  395. package/scripts/time_utils.py +233 -0
  396. package/scripts/topic_research_profiles.py +643 -0
  397. package/scripts/train.py +737 -0
  398. package/scripts/tweet.py +596 -0
  399. package/scripts/url_canon.py +217 -0
  400. package/scripts/url_optimize.py +56 -0
  401. package/scripts/url_safety.py +236 -0
  402. package/scripts/usage_log.py +116 -0
  403. package/scripts/usage_log_clean.py +113 -0
  404. package/scripts/v2ex_nodes.py +311 -0
  405. package/scripts/variant_recall.py +188 -0
  406. package/scripts/watch.py +205 -0
  407. package/scripts/wechat_article.py +258 -0
  408. package/scripts/wide_research.py +402 -0
  409. package/scripts/wx.py +349 -0
  410. package/scripts/x_article_fetch.py +141 -0
  411. package/scripts/x_search.py +234 -0
  412. package/scripts/x_trending.py +189 -0
  413. package/scripts/yaml_load.py +67 -0
  414. package/sub-skills/ego-search/SKILL.md +253 -0
  415. package/sub-skills/ego-search/references/browser-runtime.md +43 -0
  416. package/sub-skills/ego-search/references/install.md +67 -0
  417. package/sub-skills/ego-search/references/original-ego-upgrade.md +51 -0
  418. package/sub-skills/ego-search/scripts/ego_search.py +800 -0
  419. package/sub-skills/ego-search/scripts/merge.py +226 -0
  420. package/sub-skills/ego-search/scripts/quality.py +64 -0
  421. package/sub-skills/ego-search/scripts/runtime.py +172 -0
  422. package/sub-skills/ego-search/scripts/safety.py +109 -0
  423. package/sub-skills/ego-search/scripts/serp_spec.py +109 -0
  424. package/sub-skills/ego-search/scripts/webbridge_adapter.py +374 -0
  425. package/sub-skills/ego-search/tests/test_query_normalize.py +68 -0
  426. package/sub-skills/js-run/SKILL.md +67 -0
  427. package/sub-skills/js-run/scripts/jsrun.py +112 -0
  428. package/sub-skills/js-run/scripts/shim.js +188 -0
  429. package/sub-skills/js-run/tests/fixtures/challenge_like.js +27 -0
  430. package/sub-skills/js-run/tests/test_jsrun.py +165 -0
  431. package/sub-skills/local-search/SKILL.md +50 -53
  432. package/sub-skills/local-search/bm25.py +162 -0
  433. package/sub-skills/local-search/config.yaml +152 -55
  434. package/sub-skills/local-search/engine_registry.py +91 -26
  435. package/sub-skills/local-search/{health_check.py → local_health_check.py} +140 -10
  436. package/sub-skills/local-search/local_search_adapter.py +13 -2
  437. package/sub-skills/local-search/parse_maps.yaml +40 -42
  438. package/sub-skills/local-search/search_v3.py +1182 -65
  439. package/sub-skills/local-search/smart_router.py +47 -7
  440. package/sub-skills/local-seek/SKILL.md +110 -0
  441. package/sub-skills/local-seek/config/domains.yaml +20 -0
  442. package/sub-skills/local-seek/references/auto-behaviors.md +24 -0
  443. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +62 -0
  444. package/sub-skills/local-seek/references/git-integration.md +54 -0
  445. package/sub-skills/local-seek/references/remote-handoff.md +46 -0
  446. package/sub-skills/local-seek/references/rg-recipes.md +96 -0
  447. package/sub-skills/local-seek/references/strategies.md +52 -0
  448. package/sub-skills/local-seek/references/structural-search.md +59 -0
  449. package/sub-skills/local-seek/scripts/eval_rank.py +216 -0
  450. package/sub-skills/local-seek/scripts/eval_seek.py +224 -0
  451. package/sub-skills/local-seek/scripts/seek.py +1465 -0
  452. package/sub-skills/local-seek/scripts/seek_frecency.py +126 -0
  453. package/sub-skills/local-seek/scripts/seek_fts.py +430 -0
  454. package/sub-skills/local-seek/scripts/seek_rank.py +193 -0
  455. package/sub-skills/local-seek/tests/test_content_rank.py +222 -0
  456. package/sub-skills/local-seek/tests/test_determinism_20261006.py +145 -0
  457. package/sub-skills/local-seek/tests/test_noise_tier.py +101 -0
  458. package/sub-skills/local-seek/tests/test_pcre2_probe.py +136 -0
  459. package/sub-skills/local-seek/tests/test_rank_pinyin.py +77 -0
  460. package/sub-skills/local-seek/tests/test_regression_20261003.py +169 -0
  461. package/sub-skills/local-seek/tests/test_regression_20261006.py +146 -0
  462. package/sub-skills/local-seek/tests/test_seek_degradation.py +111 -0
  463. package/sub-skills/local-seek/tests/test_seek_fts.py +313 -0
@@ -0,0 +1,1338 @@
1
+ import { test, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, writeFileSync, readFileSync, existsSync } from 'node:fs'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { normalizeTracks, stageTracks, evaluateGates, normalizeSource, normalizeSourceUrl, mapArgoToWebResult, buildLocalSources, parseJsonArray, parseJsonObject, extractValues, resolveNativeSpawn, resolveArgoEnv, makeNativeTool, buildChildToolFilters, apply, nativeViaMcpPool, disposeSharedMcp, startStructured, providerSupportsStructured, fetchViaArgo, mapArgoToWebFetch, classifyFetchError, runNativeCall, searchViaArgoMCP } from '../dsh/index.js'
7
+ import { NATIVE_TOOLS } from '../dsh/native-tools.mjs'
8
+
9
+ test('mapArgoToWebResult maps compact argo payload to web sources', () => {
10
+ const mapped = mapArgoToWebResult({
11
+ query: 'q',
12
+ engine: 'octen',
13
+ results: [
14
+ { title: '甲', url: 'https://a.com/1', snippet: 's1', source: 'octen', score: 0.9 },
15
+ { title: '', url: 'https://b.com/2', snippet: null },
16
+ { url: 'https://c.com/3' },
17
+ { title: '无url', snippet: 'no' }
18
+ ]
19
+ })
20
+ assert.equal(mapped.sources.length, 3)
21
+ assert.deepEqual(mapped.sources[0], { url: 'https://a.com/1', title: '甲', snippet: 's1' })
22
+ assert.deepEqual(mapped.sources[1], { url: 'https://b.com/2' })
23
+ assert.equal(mapped.truncated, false)
24
+ assert.equal(mapped.engine, 'octen')
25
+ })
26
+
27
+ test('mapArgoToWebResult tolerates empty and malformed payloads', () => {
28
+ const empty = mapArgoToWebResult({}, 'q')
29
+ assert.deepEqual(empty.sources, [])
30
+ const junk = mapArgoToWebResult({ results: 'nope' }, 'q')
31
+ assert.deepEqual(junk.sources, [])
32
+ const nonArray = mapArgoToWebResult({ results: [42, { url: 7 }] }, 'q')
33
+ assert.deepEqual(nonArray.sources, [])
34
+ })
35
+
36
+ test('normalizeTracks keeps depends_on and dedupes ids', () => {
37
+ const tracks = normalizeTracks({
38
+ tracks: [
39
+ { id: 'def', title: '定义', question: '如何分类', rationale: 'r1', depends_on: [] },
40
+ { id: 'risk', title: '风险', question: '瓶颈', rationale: 'r2', depends_on: ['def'] },
41
+ { id: 'def', title: '重复', question: '重复', rationale: 'r3' },
42
+ ],
43
+ }, 9)
44
+ assert.equal(tracks.length, 2)
45
+ assert.deepEqual(tracks[0].depends_on, [])
46
+ assert.deepEqual(tracks[1].depends_on, ['def'])
47
+ })
48
+
49
+ test('normalizeTracks caps at maximum and clips long fields', () => {
50
+ const tracks = normalizeTracks({
51
+ tracks: [1, 2, 3, 4].map((n, i) => ({
52
+ id: `t${i}`, title: 't', question: 'q', rationale: 'r',
53
+ })),
54
+ }, 2)
55
+ assert.equal(tracks.length, 2)
56
+ })
57
+
58
+ test('stageTracks: no deps means a single parallel stage', () => {
59
+ const { stages, warnings } = stageTracks([
60
+ { id: 'a', depends_on: [] },
61
+ { id: 'b', depends_on: [] },
62
+ ])
63
+ assert.equal(warnings.length, 0)
64
+ assert.equal(stages.length, 1)
65
+ assert.deepEqual(stages[0].map(t => t.id).sort(), ['a', 'b'])
66
+ })
67
+
68
+ test('stageTracks: depends_on splits into sequential stages', () => {
69
+ const { stages, warnings } = stageTracks([
70
+ { id: 'risk', depends_on: ['def'] },
71
+ { id: 'def', depends_on: [] },
72
+ { id: 'market', depends_on: ['def'] },
73
+ ])
74
+ assert.equal(warnings.length, 0)
75
+ assert.deepEqual(stages.map(s => s.map(t => t.id)), [['def'], ['market', 'risk']])
76
+ })
77
+
78
+ test('stageTracks: missing dependency is a warning, not a crash', () => {
79
+ const { stages, warnings } = stageTracks([
80
+ { id: 'a', depends_on: ['ghost'] },
81
+ ])
82
+ assert.equal(warnings.length, 1)
83
+ assert.match(warnings[0], /ghost/)
84
+ assert.equal(stages.length, 1)
85
+ })
86
+
87
+ test('stageTracks: cycle merges into last stage with warning', () => {
88
+ const { stages, warnings } = stageTracks([
89
+ { id: 'a', depends_on: ['b'] },
90
+ { id: 'b', depends_on: ['a'] },
91
+ ])
92
+ assert.equal(stages.length, 1)
93
+ assert.equal(stages[0].length, 2)
94
+ assert.ok(warnings.some(w => w.includes('cycle')))
95
+ })
96
+
97
+ test('evaluateGates: no sources fails to low', () => {
98
+ const gates = evaluateGates({
99
+ stats: { completedTracks: 2, failedTracks: 0, sourceCount: 0 },
100
+ sources: [], caveats: [], unansweredQuestions: [],
101
+ })
102
+ assert.equal(gates.passed, false)
103
+ assert.equal(gates.conclusion_cap, 'low')
104
+ assert.ok(gates.failures.some(f => f.id === 'no_sources'))
105
+ })
106
+
107
+ test('evaluateGates: all tracks failed fails to low', () => {
108
+ const gates = evaluateGates({
109
+ stats: { completedTracks: 0, failedTracks: 3, sourceCount: 0 },
110
+ sources: [], caveats: [], unansweredQuestions: [],
111
+ })
112
+ assert.equal(gates.conclusion_cap, 'low')
113
+ assert.ok(gates.failures.some(f => f.id === 'all_tracks_failed'))
114
+ })
115
+
116
+ test('evaluateGates: partial track failure caps at medium', () => {
117
+ const gates = evaluateGates({
118
+ stats: { completedTracks: 2, failedTracks: 1, sourceCount: 5 },
119
+ sources: [
120
+ { confidence: 'high', url: 'https://a.com' },
121
+ { confidence: 'medium', url: 'https://b.com' },
122
+ ],
123
+ caveats: [], unansweredQuestions: [],
124
+ })
125
+ assert.equal(gates.passed, true)
126
+ assert.equal(gates.conclusion_cap, 'medium')
127
+ assert.ok(gates.warnings.some(w => w.id === 'partial_track_failure'))
128
+ })
129
+
130
+ test('evaluateGates: no high-confidence sources warns to medium', () => {
131
+ const gates = evaluateGates({
132
+ stats: { completedTracks: 2, failedTracks: 0, sourceCount: 3 },
133
+ sources: [
134
+ { confidence: 'low', url: 'https://a.com' },
135
+ { confidence: 'low', url: 'https://b.com' },
136
+ ],
137
+ caveats: [], unansweredQuestions: [],
138
+ })
139
+ assert.equal(gates.conclusion_cap, 'medium')
140
+ assert.ok(gates.warnings.some(w => w.id === 'no_high_confidence_sources'))
141
+ })
142
+
143
+ test('evaluateGates: clean run caps at high', () => {
144
+ const gates = evaluateGates({
145
+ stats: { completedTracks: 3, failedTracks: 0, sourceCount: 8 },
146
+ sources: [
147
+ { confidence: 'high', url: 'https://a.com' },
148
+ { confidence: 'high', url: 'https://b.com' },
149
+ { confidence: 'medium', url: 'https://c.com' },
150
+ ],
151
+ caveats: ['one caveat'], unansweredQuestions: [],
152
+ })
153
+ assert.equal(gates.passed, true)
154
+ assert.equal(gates.conclusion_cap, 'high')
155
+ assert.equal(gates.failures.length, 0)
156
+ })
157
+
158
+ test('normalizeSource: only http(s) urls enter the ledger', () => {
159
+ const http = normalizeSource({ title: 't', url: 'https://a.com/x', claim: 'c' })
160
+ assert.equal(http.url, 'https://a.com/x')
161
+ const javascript = normalizeSource({ title: 't', url: 'javascript:alert(1)', claim: 'c' })
162
+ assert.equal(javascript, undefined)
163
+ const ftp = normalizeSource({ title: 't', url: 'ftp://a.com/file', claim: 'c' })
164
+ assert.equal(ftp, undefined)
165
+ const httpNoScheme = normalizeSource({ title: 't', url: 'example.com/x', claim: 'c' })
166
+ assert.equal(httpNoScheme, undefined)
167
+ })
168
+
169
+ test('normalizeSource: confidence defaults to low, limits high to high/medium', () => {
170
+ const low = normalizeSource({ title: 't', url: 'https://a.com', claim: 'c' })
171
+ assert.equal(low.confidence, 'low')
172
+ const high = normalizeSource({ title: 't', url: 'https://a.com', claim: 'c', confidence: 'high' })
173
+ assert.equal(high.confidence, 'high')
174
+ const bogus = normalizeSource({ title: 't', url: 'https://a.com', claim: 'c', confidence: 'certain' })
175
+ assert.equal(bogus.confidence, 'low')
176
+ })
177
+
178
+ test('persistReport writes to <dir>/<ts>-<slug>.md and returns absolute path', async () => {
179
+ const { mkdtemp, readFile } = await import('node:fs/promises')
180
+ const { tmpdir } = await import('node:os')
181
+ const { join } = await import('node:path')
182
+ const { persistReport } = await import('../dsh/index.js')
183
+ const dir = await mkdtemp(join(tmpdir(), 'argo-report-'))
184
+ const file = await persistReport(dir, '测试 报告?问题', '# Report\ncontent')
185
+ assert.ok(file.startsWith(dir))
186
+ assert.ok(file.endsWith('.md'))
187
+ const body = await readFile(file, 'utf8')
188
+ assert.equal(body, '# Report\ncontent')
189
+ // 目录自动创建
190
+ const nested = await persistReport(join(dir, 'a', 'b'), 'q', 'x')
191
+ assert.ok(nested.startsWith(join(dir, 'a', 'b')))
192
+ })
193
+
194
+ test('buildLocalSources registers lineage without content', () => {
195
+ const out = buildLocalSources([
196
+ { path: '~/data/company.xlsx', role: '原始数据', sha256: 'abc', size: 100, mtime: 123 },
197
+ { path: '', role: 'x' },
198
+ { path: '~/notes/a.md' },
199
+ ])
200
+ assert.equal(out.length, 2)
201
+ assert.equal(out[0].ref, '[L1]')
202
+ assert.equal(out[0].type, 'file')
203
+ assert.equal(out[0].role, '原始数据')
204
+ assert.equal(out[0].note.includes('内容未入库'), true)
205
+ assert.equal(out[1].kind, '')
206
+ assert.equal(out[1].role, 'data')
207
+ })
208
+
209
+ test('parseJsonArray tolerates empty & malformed', () => {
210
+ assert.deepEqual(parseJsonArray(undefined, 'fi'), [])
211
+ assert.deepEqual(parseJsonArray('', 'fi'), [])
212
+ const good = parseJsonArray('[{"path":"a"}]', 'fi')
213
+ assert.equal(good.length, 1)
214
+ assert.deepEqual(parseJsonArray('nope', 'fi'), [])
215
+ assert.deepEqual(parseJsonArray([1, 2], 'fi'), [])
216
+ })
217
+
218
+ test('parseJsonObject tolerates empty & malformed', () => {
219
+ assert.deepEqual(parseJsonObject(undefined, 'rc'), {})
220
+ assert.deepEqual(parseJsonObject('', 'rc'), {})
221
+ const good = parseJsonObject('{"script":"1+1","budget":{"timeout_s":30}}', 'rc')
222
+ assert.equal(good.script, '1+1')
223
+ assert.equal(good.budget.timeout_s, 30)
224
+ assert.deepEqual(parseJsonObject('oops', 'rc'), {})
225
+ })
226
+
227
+ test('extractValues pulls numbers and percentages', () => {
228
+ assert.deepEqual(extractValues('营收 1,234.5 万,同比 23%'), [1234.5, 0.23])
229
+ assert.deepEqual(extractValues(''), [])
230
+ assert.deepEqual(extractValues('无数字文本'), [])
231
+ })
232
+
233
+ test('evaluateGates: recompute_skipped caps at medium', () => {
234
+ const gates = evaluateGates({
235
+ stats: { completedTracks: 2, failedTracks: 0, sourceCount: 3 },
236
+ sources: [
237
+ { confidence: 'high', url: 'https://a.com' },
238
+ { confidence: 'high', url: 'https://b.com' },
239
+ ],
240
+ caveats: [], unansweredQuestions: [],
241
+ recomputeExpected: true, recomputedValues: [], localSources: [],
242
+ })
243
+ assert.equal(gates.conclusion_cap, 'medium')
244
+ assert.ok(gates.warnings.some(w => w.id === 'recompute_skipped'))
245
+ })
246
+
247
+ test('evaluateGates: recompute_conflict when no numeric overlap', () => {
248
+ const gates = evaluateGates({
249
+ stats: { completedTracks: 2, failedTracks: 0, sourceCount: 3 },
250
+ sources: [
251
+ { confidence: 'high', url: 'https://a.com', title: '甲', claim: '营收 100 万元' },
252
+ { confidence: 'high', url: 'https://b.com', title: '乙', claim: '利润 5 万元' },
253
+ ],
254
+ caveats: [], unansweredQuestions: [],
255
+ recomputeExpected: true,
256
+ recomputedValues: [{ ok: true, values: [42], stdout_tail: '', stderr_tail: '' }],
257
+ localSources: [],
258
+ })
259
+ assert.ok(gates.warnings.some(w => w.id === 'recompute_conflict'))
260
+ })
261
+
262
+ test('evaluateGates: local first-hand source counts as evidence', () => {
263
+ // 无网络源但含本地一手文件时,不应触发 no_sources 假阴性
264
+ const gates = evaluateGates({
265
+ stats: { completedTracks: 1, failedTracks: 0, sourceCount: 0 },
266
+ sources: [], caveats: [], unansweredQuestions: [],
267
+ localSources: [{ path: '~/company.xlsx', ref: '[L1]' }],
268
+ })
269
+ assert.ok(!gates.failures.some(f => f.id === 'no_sources'), '本地一手存在时不应判 no_sources')
270
+ })
271
+
272
+ // ── 原生一等工具(CLI 单发形态)──────────────────────────────────────────
273
+
274
+ test('resolveNativeSpawn: npx mode appends call subcommand', () => {
275
+ const config = { searchCommand: 'npx', searchArgs: ['-y', 'github:taxueseek/argo'] }
276
+ const spec = resolveNativeSpawn(config, 'argo_search', '{"query":"q"}')
277
+ assert.equal(spec.command, 'npx')
278
+ assert.deepEqual(spec.args.slice(-3), ['call', 'argo_search', '{"query":"q"}'])
279
+ })
280
+
281
+ test('resolveNativeSpawn: local mcp_server.py mode uses --call flag', () => {
282
+ const config = { searchCommand: 'python3', searchArgs: ['/opt/argo/scripts/mcp_server.py'] }
283
+ const spec = resolveNativeSpawn(config, 'argo_fetch', '{"url":"https://x"}')
284
+ assert.equal(spec.command, 'python3')
285
+ assert.deepEqual(spec.args, ['/opt/argo/scripts/mcp_server.py', '--call', 'argo_fetch', '{"url":"https://x"}'])
286
+ })
287
+
288
+ test('resolveNativeSpawn: mcp_server.py mode detected with extra args, not just a single arg', () => {
289
+ // 旧实现按 args.length===1 判定本机模式,searchArgs 一带附加参数就静默走错
290
+ // 分支(把 call 当脚本参数喂给 python)。现按「是否出现 mcp_server.py」判定。
291
+ const config = { searchCommand: 'python3', searchArgs: ['-u', '/opt/argo/scripts/mcp_server.py'] }
292
+ const spec = resolveNativeSpawn(config, 'argo_search', '{}')
293
+ assert.deepEqual(spec.args, ['-u', '/opt/argo/scripts/mcp_server.py', '--call', 'argo_search', '{}'])
294
+ })
295
+
296
+ test('resolveNativeSpawn: empty config falls back to defaults with call tail', () => {
297
+ // DEFAULTS.searchCommand 读环境变量,不硬断言具体命令值;
298
+ // 两种部署路径的 args 尾部统一是 [tool, payload]。
299
+ const spec = resolveNativeSpawn({}, 'argo_search', '{"query":"q"}')
300
+ assert.equal(typeof spec.command, 'string')
301
+ assert.ok(spec.command.length > 0)
302
+ assert.deepEqual(spec.args.slice(-2), ['argo_search', '{"query":"q"}'])
303
+ })
304
+
305
+ test('makeNativeTool: execute forwards picked args and render unwraps MCP payload', async () => {
306
+ const captured = []
307
+ const run = async (spawnSpec) => {
308
+ captured.push(spawnSpec)
309
+ return JSON.stringify({ content: [{ type: 'text', text: 'hello argo' }] })
310
+ }
311
+ const tool = makeNativeTool({ nativeMaxChars: 24_000 }, 'argo_search', { run })
312
+ assert.equal(tool.name, 'argo_search')
313
+ assert.deepEqual(tool.parameters.required, ['query'])
314
+
315
+ const value = await tool.execute({ query: 'q1', evil: 'injected', engine: '' })
316
+ // junk 键被 pickArgs 丢弃、空串过滤;payload 只含合法非空参数
317
+ const payload = JSON.parse(captured[0].args[captured[0].args.length - 1])
318
+ assert.deepEqual(payload, { query: 'q1' })
319
+
320
+ const blocks = tool.output.render({}, value)
321
+ assert.equal(blocks[0].type, 'text')
322
+ assert.equal(blocks[0].text, 'hello argo')
323
+ })
324
+
325
+ test('makeNativeTool: render clips to nativeMaxChars and tolerates raw stdout', () => {
326
+ const run = async () => JSON.stringify({ content: [{ type: 'text', text: 'x'.repeat(100) }] })
327
+ const tool = makeNativeTool({ nativeMaxChars: 10 }, 'argo_fetch', { run })
328
+ const blocks = tool.output.render({}, { stdout: JSON.stringify({ content: [{ type: 'text', text: 'x'.repeat(100) }] }) })
329
+ assert.ok(blocks[0].text.length <= 10)
330
+ // 非 JSON stdout 原样透传(同样受 clip 约束)
331
+ const raw = makeNativeTool({ nativeMaxChars: 5 }, 'argo_fetch', { run: async () => 'plain text!' })
332
+ assert.ok(raw.output.render({}, { stdout: 'plain text!' })[0].text.length <= 5)
333
+ })
334
+
335
+ test('makeNativeTool: prefers persistent MCP connection and passes picked args object', async () => {
336
+ const seen = []
337
+ // mcpCall 收到的是参数对象(不是 JSON 字符串),与 stdio tools/call 的 arguments 对齐
338
+ const mcpCall = async (config, kind, argsObj, timeoutMs) => {
339
+ seen.push({ kind, argsObj, timeoutMs })
340
+ return JSON.stringify({ content: [{ type: 'text', text: 'via-persistent' }] })
341
+ }
342
+ // 常驻连接可用时,单发 runner 不应被调用
343
+ const run = async () => { throw new Error('should not spawn when persistent MCP works') }
344
+ const tool = makeNativeTool(
345
+ { nativeTimeoutMs: 1234 }, 'argo_search', { mcpCall, run })
346
+ const value = await tool.execute({ query: 'q', engine: '', bogus: 1 })
347
+ assert.deepEqual(seen[0].argsObj, { query: 'q' })
348
+ assert.equal(seen[0].kind, 'argo_search')
349
+ assert.equal(seen[0].timeoutMs, 1234)
350
+ assert.equal(tool.output.render({}, value)[0].text, 'via-persistent')
351
+ })
352
+
353
+ test('makeNativeTool: falls back to single-shot spawn when persistent connection fails', async () => {
354
+ const spawned = []
355
+ const mcpCall = async () => { throw new Error('connection closed') }
356
+ const run = async (spawnSpec) => {
357
+ spawned.push(spawnSpec)
358
+ return JSON.stringify({ content: [{ type: 'text', text: 'via-spawn' }] })
359
+ }
360
+ const tool = makeNativeTool(
361
+ { searchCommand: 'python3', searchArgs: ['/opt/argo/scripts/mcp_server.py'] },
362
+ 'argo_fetch', { mcpCall, run })
363
+ const value = await tool.execute({ url: 'https://x' })
364
+ assert.equal(spawned.length, 1, '连接失败后应回退单发一次')
365
+ assert.equal(tool.output.render({}, value)[0].text, 'via-spawn')
366
+ })
367
+
368
+ test('makeNativeTool: unknown tool name fails loudly', () => {
369
+ assert.throws(() => makeNativeTool({}, 'argo_nope'), /unknown native tool/)
370
+ })
371
+
372
+ function makeCtx() {
373
+ const registered = []
374
+ const providers = []
375
+ const fetchProviders = []
376
+ const guards = []
377
+ const ctx = {
378
+ tools: {
379
+ register: (t) => {
380
+ registered.push(t)
381
+ // 与宿主一致:注册返回 disposer,供 nativeTools 热更新时撤销。
382
+ return () => {
383
+ const index = registered.indexOf(t)
384
+ if (index >= 0) registered.splice(index, 1)
385
+ }
386
+ },
387
+ guard: (g) => guards.push(g),
388
+ },
389
+ subagents: { getProvider: () => null, list: () => [] },
390
+ // 与 cordis 一致:effect 回调同步执行并返回 disposer(mock 忽略返回值)。
391
+ effect: (cb) => cb(),
392
+ get: (svc) => svc === 'web' ? {
393
+ registerSearchProvider: (p) => providers.push(p),
394
+ registerFetchProvider: (p) => fetchProviders.push(p),
395
+ } : null,
396
+ }
397
+ return { ctx, registered, providers, fetchProviders, guards }}
398
+
399
+ test('providerSupportsStructured: rc.2 capabilities no longer include toolFilter', () => {
400
+ assert.equal(providerSupportsStructured({
401
+ capabilities: { agentOptions: true, agentPreset: true, outputSchema: true, depthLimit: true }
402
+ }), true, '缺 toolFilter 能力不应再判失败')
403
+ assert.equal(providerSupportsStructured({
404
+ capabilities: { outputSchema: false, depthLimit: true }
405
+ }), false)
406
+ assert.equal(providerSupportsStructured({}), false)
407
+ })
408
+
409
+ function structuredCtx(overrides = {}) {
410
+ const seen = { activation: [], legacy: [] }
411
+ const ctx = {
412
+ subagents: {
413
+ ...(overrides.legacyOnly ? {} : {
414
+ startActivation: async (spec) => {
415
+ seen.activation.push(spec)
416
+ return {
417
+ childId: 'child-1',
418
+ result: Promise.resolve({ stopReason: 'completed', structured: { ok: true } }),
419
+ dispose: async () => { seen.disposed = true }
420
+ }
421
+ }
422
+ }),
423
+ ...(overrides.legacyOnly ? {
424
+ start: async (provider, request) => {
425
+ seen.legacy.push({ provider, request })
426
+ return {
427
+ result: Promise.resolve({ stopReason: 'completed', structured: { ok: true } }),
428
+ dispose: async () => { seen.disposed = true }
429
+ }
430
+ }
431
+ } : {})
432
+ }
433
+ }
434
+ return { ctx, seen }
435
+ }
436
+
437
+ test('startStructured: rc.2 uses startActivation envelope and tracks the child', async () => {
438
+ const { ctx, seen } = structuredCtx()
439
+ const tracked = []
440
+ const untracked = []
441
+ const value = await startStructured(
442
+ ctx, 'spawn', { id: 'parent' }, new AbortController().signal, 'lbl',
443
+ 'prompt', { type: 'object' }, 1234, { allow: ['argo_search'] },
444
+ (id) => { tracked.push(id); return () => untracked.push(id) })
445
+ assert.deepEqual(value, { ok: true })
446
+ assert.equal(seen.activation.length, 1)
447
+ const spec = seen.activation[0]
448
+ assert.equal(spec.provider, 'spawn')
449
+ assert.equal(spec.label, 'lbl')
450
+ assert.equal(spec.delivery, 'caller', '结果只返回给调用方,不重复推给模型')
451
+ assert.equal(spec.request.label, undefined, 'rc.2 的 request 不含 label')
452
+ assert.equal(spec.request.signal, undefined, 'rc.2 的 request 不含 signal')
453
+ assert.equal(spec.request.toolFilter, undefined,
454
+ 'rc.2 的 request 不再接受 toolFilter(工具范围由 preset/guard 承担)')
455
+ assert.equal(spec.request.agentOptions.maxTokens, 1234)
456
+ assert.deepEqual(tracked, ['child-1'])
457
+ assert.deepEqual(untracked, ['child-1'], 'dispose 后应解除追踪')
458
+ })
459
+
460
+ test('startStructured: falls back to legacy start(provider, request)', async () => {
461
+ const { ctx, seen } = structuredCtx({ legacyOnly: true })
462
+ const value = await startStructured(
463
+ ctx, 'spawn', { id: 'parent' }, new AbortController().signal, 'lbl',
464
+ 'prompt', { type: 'object' }, 999, { allow: ['argo_search'] }, () => () => {})
465
+ assert.deepEqual(value, { ok: true })
466
+ assert.equal(seen.legacy.length, 1)
467
+ assert.equal(seen.legacy[0].provider, 'spawn')
468
+ assert.equal(seen.legacy[0].request.label, 'lbl')
469
+ assert.equal(seen.legacy[0].request.signal instanceof AbortSignal, true)
470
+ assert.deepEqual(seen.legacy[0].request.toolFilter, { allow: ['argo_search'] },
471
+ '旧宿主仍按 alpha.x 契约收 toolFilter')
472
+ })
473
+
474
+ test('apply: registers a guard that blocks wide_research inside a tracked worker', () => {
475
+ const { ctx, guards } = makeCtx()
476
+ const handle = apply(ctx, {})
477
+ assert.equal(guards.length, 1)
478
+ assert.equal(guards[0]({ name: 'wide_research', agent: { id: 'outside' } }), undefined)
479
+ handle.childIds.add('worker-1')
480
+ assert.match(guards[0]({ name: 'wide_research', agent: { id: 'worker-1' } }),
481
+ /not available inside a research worker/)
482
+ assert.equal(guards[0]({ name: 'argo_search', agent: { id: 'worker-1' } }), undefined)
483
+ })
484
+
485
+ test('apply: default config registers native tools and an available web provider', () => {
486
+ const { ctx, registered, providers } = makeCtx()
487
+ apply(ctx, {})
488
+ const names = registered.map(t => t.name)
489
+ assert.ok(names.includes('argo_search'), 'argo_search 应注册')
490
+ assert.ok(names.includes('argo_fetch'), 'argo_fetch 应注册')
491
+ assert.ok(names.includes('wide_research'))
492
+ assert.equal(providers.length, 1)
493
+ assert.equal(providers[0].id, 'argo')
494
+ assert.equal(providers[0].available(), true)
495
+ })
496
+
497
+ test('apply: default config also registers an available web fetch provider', () => {
498
+ const { ctx, fetchProviders } = makeCtx()
499
+ apply(ctx, {})
500
+ assert.equal(fetchProviders.length, 1, '宿主有 registerFetchProvider 时应注册 fetch provider')
501
+ assert.equal(fetchProviders[0].id, 'argo')
502
+ assert.equal(fetchProviders[0].available(), true)
503
+ })
504
+
505
+ test('apply: fetchProviderEnabled=false keeps the fetch provider registered but unavailable', () => {
506
+ const { ctx, fetchProviders } = makeCtx()
507
+ apply(ctx, { fetchProviderEnabled: false })
508
+ assert.equal(fetchProviders.length, 1, '与 search seam 同 pattern:注册但不可选')
509
+ assert.equal(fetchProviders[0].available(), false)
510
+ })
511
+
512
+ test('apply: old host without registerFetchProvider does not break search registration', () => {
513
+ const registered = []
514
+ const providers = []
515
+ const ctx = {
516
+ tools: { register: (t) => registered.push(t), guard: () => {} },
517
+ subagents: { getProvider: () => null, list: () => [] },
518
+ effect() {},
519
+ get: (svc) => svc === 'web' ? { registerSearchProvider: (p) => providers.push(p) } : null,
520
+ }
521
+ apply(ctx, {})
522
+ assert.equal(providers.length, 1, 'search seam 不受宿主缺 fetch seam 影响')
523
+ assert.equal(registered.filter(t => t.name === 'argo_fetch').length, 1, '原生 argo_fetch 工具仍在')
524
+ })
525
+
526
+ test('apply: old host without ctx.effect does not block activation', () => {
527
+ // 7022fa2 修过同一类病(可选宿主服务缺席被当成必需,整个 entry 激活失败,
528
+ // argo 工具与设置页一起消失),并在设置块留了「旧宿主无 ctx.effect」的
529
+ // try/catch。但 apply 里另有两处**无条件** ctx.effect 先炸,那句容忍从未
530
+ // 兑现——实测裸 ctx 下 apply 直接抛 "ctx.effect is not a function"。
531
+ // 这条钉住:缺 effect 只丢卸载清理,不丢功能、不激活失败。
532
+ const registered = []
533
+ const ctx = {
534
+ tools: { register: (t) => registered.push(t.name), guard: () => {} },
535
+ subagents: { getProvider: () => null, list: () => [] },
536
+ get: (svc) => svc === 'web' ? { registerSearchProvider: () => {}, registerFetchProvider: () => {} } : null,
537
+ // 注意:没有 effect、没有 on、没有 fiber
538
+ }
539
+ let threw = null
540
+ try { apply(ctx, { nativeTools: ['argo_search'] }) } catch (e) { threw = e }
541
+ assert.equal(threw, null, `旧宿主缺 ctx.effect 不该阻断激活:${threw && threw.message}`)
542
+ assert.ok(registered.includes('argo_search'), '原生工具仍要注册')
543
+ assert.ok(registered.includes('wide_research'), '研究工具仍要注册')
544
+ })
545
+
546
+ test('apply: fetchProviderId override changes the registered id', () => {
547
+ const { ctx, fetchProviders } = makeCtx()
548
+ apply(ctx, { fetchProviderId: 'argo-fetch' })
549
+ assert.equal(fetchProviders[0].id, 'argo-fetch')
550
+ })
551
+
552
+ test('apply: nativeTools=[] and searchProviderEnabled=false disables both seams', () => {
553
+ const { ctx, registered, providers } = makeCtx()
554
+ apply(ctx, { nativeTools: [], searchProviderEnabled: false })
555
+ const names = registered.map(t => t.name)
556
+ assert.ok(!names.includes('argo_search'))
557
+ assert.ok(!names.includes('argo_fetch'))
558
+ assert.ok(names.includes('wide_research'), 'wide_research 不受搜索开关影响')
559
+ assert.equal(providers.length, 1, 'provider 仍注册(headless 一致性)')
560
+ assert.equal(providers[0].available(), false)
561
+ })
562
+
563
+ test('apply: unknown native tool name fails loudly', () => {
564
+ const { ctx } = makeCtx()
565
+ assert.throws(() => apply(ctx, { nativeTools: ['argo_nope'] }), /unknown native tool/)
566
+ })
567
+
568
+ test('apply: disables the auto-form lazily and never reads ctx.settings directly', () => {
569
+ // cordis 的 ctx 是 Proxy:未 inject 就访问 ctx.settings 会抛
570
+ // `cannot get property "settings" without inject`(可选链救不了 getter
571
+ // 抛错)。回归 2026-10-20 的启动崩溃:上一轮用 ctx.inject(['settings'],
572
+ // cb) 取服务,子 fiber 在本插件 apply 期对 settings 仍 inactive,cb 里
573
+ // child.settings 一样抛,整个 entry 激活失败。现在改为 effect 内惰性
574
+ // ctx.get('settings')(get 对未提供/未激活服务返回 undefined,不抛)。
575
+ const throwing = makeCtx().ctx
576
+ Object.defineProperty(throwing, 'settings', {
577
+ get() { throw new Error('cannot get property "settings" without inject') },
578
+ })
579
+ assert.doesNotThrow(() => apply(throwing, {}), 'apply 不得因 settings 不可用而抛')
580
+
581
+ // settings 服务可用时应经 ctx.get 拿到并 configure({auto:false})
582
+ const { ctx } = makeCtx()
583
+ let configured = 0
584
+ ctx.get = (name) => (name === 'settings' ? { configure: () => { configured += 1 } } : undefined)
585
+ apply(ctx, {})
586
+ assert.equal(configured, 1, '应经 ctx.get 取到 settings 并关闭自动表单')
587
+
588
+ // configure 抛错(旧宿主 API 不兼容)时静默跳过,不阻断插件
589
+ const { ctx: ctx2 } = makeCtx()
590
+ ctx2.get = (name) => (name === 'settings' ? { configure: () => { throw new Error('nope') } } : undefined)
591
+ assert.doesNotThrow(() => apply(ctx2, {}))
592
+ })
593
+
594
+ test('apply: nativeTools follows a live settings update without remounting', () => {
595
+ const { ctx, registered } = makeCtx()
596
+ const config = { nativeTools: ['argo_search'] }
597
+ const handle = apply(ctx, config)
598
+ assert.deepEqual(registered.map(t => t.name).sort(), ['argo_search', 'wide_research'])
599
+
600
+ config.nativeTools = ['argo_fetch']
601
+ handle.syncNativeTools()
602
+ assert.deepEqual(registered.map(t => t.name).sort(), ['argo_fetch', 'wide_research'],
603
+ '旧工具应撤销、新工具应注册、wide_research 保留')
604
+ })
605
+
606
+ test('apply: volatile refs are unwrapped and stay live after apply', () => {
607
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
608
+ let enabled = true
609
+ const ref = () => ({
610
+ get: () => enabled,
611
+ [VOLATILE_WRITE]: (next) => { enabled = next },
612
+ })
613
+ const { ctx, providers, fetchProviders } = makeCtx()
614
+ apply(ctx, { searchProviderEnabled: ref(), fetchProviderEnabled: ref() })
615
+ assert.equal(providers[0].available(), true, 'volatile 引用必须解成当前值')
616
+ assert.equal(fetchProviders[0].available(), true)
617
+
618
+ enabled = false
619
+ assert.equal(providers[0].available(), false, '引用更新后 provider 立即可见新值')
620
+ assert.equal(fetchProviders[0].available(), false)
621
+ })
622
+ test('resolveArgoEnv: resolves host credential refs into the child env', async () => {
623
+ const fake = {
624
+ resolve: async (ref) => (ref === 'DEEPSEEK_API_KEY' ? { value: 'sk-host', source: 'file' } : undefined),
625
+ }
626
+ const env = await resolveArgoEnv({ keyRefs: ['DEEPSEEK_API_KEY'] }, { credentials: fake })
627
+ assert.equal(env.DEEPSEEK_API_KEY, 'sk-host')
628
+ assert.equal(env.PATH, process.env.PATH, '整份 env 继承,不是只带密钥的裸对象')
629
+ })
630
+
631
+ test('resolveArgoEnv: the credential service wins over the process env (file layer)', async () => {
632
+ // 凭证服务契约:存储文件层 > 启动环境 > .env。用户经面板/Models 页轮换 key
633
+ // 后,argo 子进程必须拿到新值,不能抱着启动时的旧值不放。
634
+ const saved = process.env.DEEPSEEK_API_KEY
635
+ process.env.DEEPSEEK_API_KEY = 'sk-env'
636
+ try {
637
+ const fake = { resolve: async () => ({ value: 'sk-file', source: 'file' }) }
638
+ const env = await resolveArgoEnv({ keyRefs: ['DEEPSEEK_API_KEY'] }, { credentials: fake })
639
+ assert.equal(env.DEEPSEEK_API_KEY, 'sk-file')
640
+ } finally {
641
+ if (saved === undefined) delete process.env.DEEPSEEK_API_KEY
642
+ else process.env.DEEPSEEK_API_KEY = saved
643
+ }
644
+ })
645
+
646
+ test('resolveArgoEnv: an unconfigured ref falls through to the inherited process env', async () => {
647
+ // 服务在场但该引用哪层都没有值:跳过它,spawn 继承 process.env(旧宿主/
648
+ // 未配置场景的保底路径)。
649
+ const saved = process.env.DEEPSEEK_API_KEY
650
+ process.env.DEEPSEEK_API_KEY = 'sk-env'
651
+ try {
652
+ const fake = { resolve: async () => undefined }
653
+ assert.equal(await resolveArgoEnv({ keyRefs: ['DEEPSEEK_API_KEY'] }, { credentials: fake }), undefined)
654
+ } finally {
655
+ if (saved === undefined) delete process.env.DEEPSEEK_API_KEY
656
+ else process.env.DEEPSEEK_API_KEY = saved
657
+ }
658
+ })
659
+
660
+ test('resolveArgoEnv: empty keyRefs, missing service and failed resolve all degrade to undefined', async () => {
661
+ assert.equal(await resolveArgoEnv({ keyRefs: [] }, { credentials: { resolve: async () => ({ value: 'x' }) } }), undefined)
662
+ assert.equal(await resolveArgoEnv({ keyRefs: ['DEEPSEEK_API_KEY'] }, {}), undefined)
663
+ const boom = { resolve: async () => { throw new Error('locked keychain') } }
664
+ assert.equal(await resolveArgoEnv({ keyRefs: ['DEEPSEEK_API_KEY'] }, { credentials: boom }), undefined)
665
+ })
666
+
667
+ test('apply: captures the host credentials service for child env resolution', async () => {
668
+ const fake = {
669
+ resolve: async (ref) => (ref === 'DEEPSEEK_API_KEY' ? { value: 'sk-host', source: 'file' } : undefined),
670
+ }
671
+ const { ctx } = makeCtx()
672
+ ctx.get = (svc) => (svc === 'credentials' ? fake : null)
673
+ apply(ctx, {})
674
+ const env = await resolveArgoEnv()
675
+ assert.equal(env.DEEPSEEK_API_KEY, 'sk-host')
676
+ // 收尾复位模块状态,不污染后续用例
677
+ apply(makeCtx().ctx, { nativeTools: [], searchProviderEnabled: false })
678
+ })
679
+
680
+ test('apply: a keyRefs entry that is not an identifier fails loudly', () => {
681
+ const { ctx } = makeCtx()
682
+ assert.throws(() => apply(ctx, { keyRefs: ['has space'] }), /POSIX-style identifier/)
683
+ })
684
+
685
+ test('makeNativeTool: single-spawn spec carries the host-resolved key env', async () => {
686
+ const saved = process.env.DEEPSEEK_API_KEY
687
+ delete process.env.DEEPSEEK_API_KEY
688
+ const captured = []
689
+ try {
690
+ const fake = {
691
+ resolve: async (ref) => (ref === 'DEEPSEEK_API_KEY' ? { value: 'sk-host', source: 'file' } : undefined),
692
+ }
693
+ const tool = makeNativeTool(
694
+ {
695
+ searchCommand: 'python3',
696
+ searchArgs: ['mcp_server.py'],
697
+ keyRefs: ['DEEPSEEK_API_KEY'],
698
+ credentials: fake,
699
+ },
700
+ 'argo_search',
701
+ { run: async (spec) => { captured.push(spec); return '{"ok":true}' } },
702
+ )
703
+ await tool.execute({ query: 'q' })
704
+ assert.equal(captured.length, 1)
705
+ assert.equal(captured[0].env.DEEPSEEK_API_KEY, 'sk-host')
706
+ } finally {
707
+ if (saved !== undefined) process.env.DEEPSEEK_API_KEY = saved
708
+ }
709
+ })
710
+
711
+ test('makeNativeTool: spawn spec shape is unchanged when no key resolves', async () => {
712
+ const captured = []
713
+ const tool = makeNativeTool(
714
+ { searchCommand: 'python3', searchArgs: ['mcp_server.py'], keyRefs: [] },
715
+ 'argo_search',
716
+ { run: async (spec) => { captured.push(spec); return '{"ok":true}' } },
717
+ )
718
+ await tool.execute({ query: 'q' })
719
+ assert.equal(captured.length, 1)
720
+ assert.equal(captured[0].env, undefined, '无增量时不改 spec 形状')
721
+ })
722
+
723
+ test('buildChildToolFilters: default allow list dual-writes MCP and native tools', () => {
724
+ const { allow, deny } = buildChildToolFilters({}, 'wide_research')
725
+ for (const tool of ['argo_search', 'argo_fetch', 'web_search', 'web_fetch', 'mcp__argo__argo_search']) {
726
+ assert.ok(allow.includes(tool), `默认白名单应含 ${tool}(MCP/原生两态自洽)`)
727
+ }
728
+ assert.ok(!allow.includes('wide_research'), '自身不入白名单')
729
+ assert.ok(!allow.includes('mcp__argo__argo_research'), '研究不套研究')
730
+ assert.ok(deny.includes('wide_research'))
731
+ })
732
+
733
+ test('buildChildToolFilters: explicit empty allow falls back to deny path', () => {
734
+ const { allow, deny } = buildChildToolFilters({ childToolAllow: [] }, 'wide_research')
735
+ assert.deepEqual(allow, [])
736
+ assert.ok(deny.includes('wide_research'))
737
+ })
738
+
739
+ test('buildChildToolFilters: explicit list filters blocked names and dedupes', () => {
740
+ const { allow } = buildChildToolFilters({
741
+ childToolAllow: ['argo_search', 'argo_search', 'mcp__argo__argo_research', 'web_search'],
742
+ }, 'wide_research')
743
+ assert.deepEqual(allow, ['argo_search', 'web_search'])
744
+ })
745
+
746
+ test('buildChildToolFilters: default MCP allow list covers the full tool face (minus argo_research)', () => {
747
+ // 门禁:worker 白名单的 MCP 侧必须等于原生规格表。native-tools.mjs 是
748
+ // mcp_tools.py 的生成产物(= 全量面去掉 argo_research,由
749
+ // tests/test_native_tools_sync.py 把关),因此这条断言把插件白名单与
750
+ // MCP 真源串成一条链——此前手写列表在工具面涨到 19 后漏掉了 5 个。
751
+ const { allow } = buildChildToolFilters({}, 'wide_research')
752
+ const mcpInAllow = allow.filter(tool => tool.startsWith('mcp__argo__'))
753
+ assert.deepEqual(
754
+ mcpInAllow,
755
+ Object.keys(NATIVE_TOOLS).map(name => `mcp__argo__${name}`),
756
+ 'worker 白名单的 MCP 侧必须等于原生规格表(= mcp_tools.py 全量面 − argo_research)',
757
+ )
758
+ assert.ok(!allow.includes('mcp__argo__argo_research'), '研究不套研究')
759
+ })
760
+
761
+ test('buildChildToolFilters: deny entries are subtracted from the allow list', () => {
762
+ // allow 非空时调用方只把 allow 交给宿主 toolFilter(单一键),deny 必须
763
+ // 在这里就扣除,否则用户同时设 allow + deny 时 deny 被静默忽略。
764
+ const { allow, deny } = buildChildToolFilters(
765
+ { childToolDeny: ['argo_search', 'mcp__argo__argo_pdf'] }, 'wide_research')
766
+ assert.ok(!allow.includes('argo_search'))
767
+ assert.ok(!allow.includes('mcp__argo__argo_pdf'))
768
+ assert.ok(deny.includes('argo_search'))
769
+ assert.ok(allow.includes('argo_fetch'), '未被 deny 的工具应保留')
770
+ })
771
+
772
+ // ── 连接池(真实 stdio 桩)─────────────────────────────────────────────────
773
+ // 上面注入的 mcpCall 是替身,测不到池本身。这里起一个真进程讲 NDJSON:每次
774
+ // 启动往计数文件追加一行,「开了几条连接」于是成为可断言的观测值——复用、
775
+ // 按需增长、并发是否真并行、dispose 是否收干净,都能被证明而不是被假设。
776
+
777
+ const STUB_DIR = mkdtempSync(join(tmpdir(), 'argo-dsh-stub-'))
778
+ const STUB_SERVER = join(STUB_DIR, 'stub-mcp.js')
779
+
780
+ writeFileSync(STUB_SERVER, `
781
+ const fs = require('node:fs')
782
+ if (process.env.ARGO_STUB_COUNTER) fs.appendFileSync(process.env.ARGO_STUB_COUNTER, 'spawn\\n')
783
+ const delayMs = Number(process.env.ARGO_STUB_DELAY_MS || 0)
784
+ const toolError = process.env.ARGO_STUB_TOOL_ERROR === '1'
785
+ const fetchError = process.env.ARGO_STUB_FETCH_ERROR === '1'
786
+ let buf = ''
787
+ process.stdin.setEncoding('utf8')
788
+ const write = (obj) => process.stdout.write(JSON.stringify(obj) + '\\n')
789
+ process.stdin.on('data', (chunk) => {
790
+ buf += chunk
791
+ let nl
792
+ while ((nl = buf.indexOf('\\n')) >= 0) {
793
+ const line = buf.slice(0, nl).trim()
794
+ buf = buf.slice(nl + 1)
795
+ if (line === '') continue
796
+ let msg
797
+ try { msg = JSON.parse(line) } catch { continue }
798
+ if (msg.id === undefined) continue
799
+ if (msg.method === 'initialize') {
800
+ write({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 'stub', version: '0' } } })
801
+ continue
802
+ }
803
+ setTimeout(() => {
804
+ const isFetch = msg.params?.name === 'argo_fetch'
805
+ if (isFetch && fetchError) {
806
+ write({
807
+ jsonrpc: '2.0',
808
+ id: msg.id,
809
+ result: { content: [{ type: 'text', text: JSON.stringify({ error: 'blocked by robots' }) }], isError: true },
810
+ })
811
+ return
812
+ }
813
+ write({
814
+ jsonrpc: '2.0',
815
+ id: msg.id,
816
+ result: toolError
817
+ ? { content: [{ type: 'text', text: 'stub tool failed' }], isError: true }
818
+ : isFetch
819
+ ? { content: [{ type: 'text', text: JSON.stringify({
820
+ success: true,
821
+ url: 'https://stub.example/final',
822
+ title: 'Stub page',
823
+ content: 'stub fetched body (max_chars=' + (msg.params && msg.params.arguments ? msg.params.arguments.max_chars : 'none') + ')',
824
+ length: 17,
825
+ truncated: false,
826
+ }) }] }
827
+ : { content: [{ type: 'text', text: 'pooled-ok' }] },
828
+ })
829
+ }, delayMs)
830
+ }
831
+ })
832
+ `, 'utf8')
833
+
834
+ let spawnLog = ''
835
+ let envRestore = {}
836
+
837
+ function stubEnv(vars = {}) {
838
+ spawnLog = join(mkdtempSync(join(tmpdir(), 'argo-dsh-count-')), 'spawns.log')
839
+ envRestore = {}
840
+ for (const [key, value] of Object.entries({ ARGO_STUB_COUNTER: spawnLog, ...vars })) {
841
+ envRestore[key] = process.env[key]
842
+ process.env[key] = value
843
+ }
844
+ }
845
+
846
+ /** 桩进程启动次数 === 池开出的连接数。 */
847
+ function spawnCount() {
848
+ if (!existsSync(spawnLog)) return 0
849
+ return readFileSync(spawnLog, 'utf8').split('\n').filter(Boolean).length
850
+ }
851
+
852
+ function stubTool(config = {}) {
853
+ return makeNativeTool(
854
+ { searchCommand: process.execPath, searchArgs: [STUB_SERVER], ...config },
855
+ 'argo_search',
856
+ )
857
+ }
858
+
859
+ afterEach(() => {
860
+ disposeSharedMcp()
861
+ for (const [key, value] of Object.entries(envRestore)) {
862
+ if (value === undefined) delete process.env[key]
863
+ else process.env[key] = value
864
+ }
865
+ envRestore = {}
866
+ })
867
+
868
+ test('pool: sequential calls reuse one connection (no extra resident process)', async () => {
869
+ stubEnv()
870
+ const tool = stubTool({ searchConcurrency: 3 })
871
+ await tool.execute({ query: 'a' })
872
+ await tool.execute({ query: 'b' })
873
+ assert.equal(spawnCount(), 1, '顺序调用只该有一条常驻连接,池化不该带来固定开销')
874
+ })
875
+
876
+ test('pool: concurrent calls grow the pool without exceeding the cap', async () => {
877
+ stubEnv({ ARGO_STUB_DELAY_MS: '150' })
878
+ const tool = stubTool({ searchConcurrency: 3 })
879
+ await Promise.all([1, 2, 3].map(i => tool.execute({ query: `q${i}` })))
880
+ const spawned = spawnCount()
881
+ assert.ok(spawned > 1, `并发调用应补开连接,实际只有 ${spawned} 条`)
882
+ assert.ok(spawned <= 3, `不得越过 searchConcurrency,实际开了 ${spawned} 条`)
883
+ })
884
+
885
+ test('pool: concurrent calls run in parallel instead of queueing on one connection', async () => {
886
+ stubEnv({ ARGO_STUB_DELAY_MS: '300' })
887
+ const tool = stubTool({ searchConcurrency: 3 })
888
+ const started = Date.now()
889
+ await Promise.all([1, 2, 3].map(i => tool.execute({ query: `q${i}` })))
890
+ const elapsed = Date.now() - started
891
+ // 单连接串行会累加到 ~900ms;池化后应贴近单次 300ms。阈值放宽到 700ms 抗抖动。
892
+ assert.ok(elapsed < 700, `3 个 300ms 调用耗时 ${elapsed}ms,说明仍在同一连接上串行`)
893
+ })
894
+
895
+ test('pool: tool-level error is NOT retried through single-shot spawn', async () => {
896
+ stubEnv({ ARGO_STUB_TOOL_ERROR: '1' })
897
+ const spawned = []
898
+ const tool = makeNativeTool(
899
+ { searchCommand: process.execPath, searchArgs: [STUB_SERVER] },
900
+ 'argo_search',
901
+ { mcpCall: nativeViaMcpPool, run: async (spec) => { spawned.push(spec); return 'unreachable' } },
902
+ )
903
+ await assert.rejects(() => tool.execute({ query: 'q' }), /stub tool failed/)
904
+ assert.equal(spawned.length, 0, '工具级错误不得回退单发(那是双倍引擎调用与配额)')
905
+ assert.equal(spawnCount(), 1, '工具级错误不得新起进程重跑')
906
+ })
907
+
908
+ test('pool: transport failure still falls back to single-shot spawn', async () => {
909
+ const spawned = []
910
+ const tool = makeNativeTool(
911
+ { searchCommand: process.execPath, searchArgs: [join(STUB_DIR, 'does-not-exist.js')] },
912
+ 'argo_search',
913
+ {
914
+ mcpCall: nativeViaMcpPool,
915
+ run: async (spec) => {
916
+ spawned.push(spec)
917
+ return JSON.stringify({ content: [{ type: 'text', text: 'via-spawn' }] })
918
+ },
919
+ },
920
+ )
921
+ const value = await tool.execute({ query: 'q' })
922
+ assert.equal(spawned.length, 1, '连接建立失败应回退单发一次,功能不丢')
923
+ assert.equal(tool.output.render({}, value)[0].text, 'via-spawn')
924
+ })
925
+
926
+ test('pool: disposeSharedMcp closes every connection; next call rebuilds', async () => {
927
+ stubEnv()
928
+ const tool = stubTool({ searchConcurrency: 2 })
929
+ await Promise.all([tool.execute({ query: 'a' }), tool.execute({ query: 'b' })])
930
+ const before = spawnCount()
931
+ assert.ok(before >= 1)
932
+ disposeSharedMcp()
933
+ await tool.execute({ query: 'c' })
934
+ assert.equal(spawnCount(), before + 1, 'dispose 后池应为空,下次调用重新建立连接')
935
+ })
936
+
937
+ // --- web_fetch seam:fetchViaArgo / mapArgoToWebFetch / classifyFetchError ---
938
+
939
+ test('mapArgoToWebFetch: maps payload, prefers the final url, and carries the truncated flag', () => {
940
+ const mapped = mapArgoToWebFetch({
941
+ success: true,
942
+ url: 'https://final.example/doc',
943
+ title: 'T',
944
+ content: 'body text',
945
+ truncated: true,
946
+ }, 'https://request.example/doc', 100_000)
947
+ assert.equal(mapped.url, 'https://final.example/doc', '重定向后的最终 URL 优先')
948
+ assert.equal(mapped.statusCode, 200)
949
+ assert.equal(mapped.body.kind, 'text', 'argo 产出已是 markdown 正文,归 text 不再二次转换')
950
+ assert.equal(mapped.body.content, 'body text')
951
+ assert.equal(mapped.truncated, true, '上游截断标记必须透传,模型据此知道内容被裁过')
952
+ })
953
+
954
+ test('mapArgoToWebFetch: falls back to the request url and tolerates missing content', () => {
955
+ const mapped = mapArgoToWebFetch({ success: true }, 'https://request.example/doc', 100_000)
956
+ assert.equal(mapped.url, 'https://request.example/doc')
957
+ assert.equal(mapped.body.content, '')
958
+ assert.equal(mapped.truncated, false)
959
+ })
960
+
961
+ test('mapArgoToWebFetch: clips to maxChars and flags truncation', () => {
962
+ const mapped = mapArgoToWebFetch(
963
+ { success: true, url: 'https://a.test', content: 'x'.repeat(50) },
964
+ 'https://a.test',
965
+ 10,
966
+ )
967
+ assert.equal(mapped.body.content.length, 10)
968
+ assert.equal(mapped.truncated, true)
969
+ })
970
+
971
+ test('classifyFetchError: every failure class carries an actionable category and the url', () => {
972
+ const cases = [
973
+ [{ code: 'ARGO_TIMEOUT', message: 'argo MCP request timed out after 25000ms' }, /argo fetch timeout/],
974
+ [{ code: 'ARGO_TOOL_ERROR', message: '403 blocked by robots.txt' }, /argo fetch blocked/],
975
+ [{ code: 'ARGO_TOOL_ERROR', message: 'no readable content extracted' }, /argo fetch empty/],
976
+ [{ code: 'ARGO_TOOL_ERROR', message: 'weird upstream failure' }, /argo fetch failed/],
977
+ [{ code: 'WEB_ABORTED', message: 'argo search aborted' }, /argo fetch aborted/],
978
+ ]
979
+ for (const [err, pattern] of cases) {
980
+ const classified = classifyFetchError(err, 'https://x.test/page')
981
+ assert.match(classified.message, pattern)
982
+ assert.match(classified.message, /url=https:\/\/x\.test\/page/, '失败必须带 URL,模型才知道是哪一页')
983
+ assert.ok(typeof classified.code === 'string' && classified.code.startsWith('ARGO_FETCH_'))
984
+ }
985
+ })
986
+
987
+ test('fetch: pool path maps the stub argo_fetch payload to the seam result shape', async () => {
988
+ stubEnv()
989
+ const result = await fetchViaArgo('https://stub.example/page', undefined, {
990
+ command: process.execPath,
991
+ args: [STUB_SERVER],
992
+ timeoutMs: 5000,
993
+ })
994
+ assert.equal(result.url, 'https://stub.example/final')
995
+ assert.equal(result.statusCode, 200)
996
+ assert.equal(result.body.kind, 'text')
997
+ assert.match(result.body.content, /^stub fetched body \(max_chars=50000\)$/)
998
+ assert.equal(result.truncated, false)
999
+ })
1000
+
1001
+ test('fetch: maxChars above the tool schema maximum clamps to 50000', async () => {
1002
+ stubEnv()
1003
+ const result = await fetchViaArgo('https://stub.example/page', undefined, {
1004
+ command: process.execPath,
1005
+ args: [STUB_SERVER],
1006
+ timeoutMs: 5000,
1007
+ maxChars: 100_000,
1008
+ })
1009
+ assert.match(result.body.content, /max_chars=50000/,
1010
+ '超出 argo_fetch schema 上限(50000)必须 clamp 回 50000:声明与实效一致,不静默顶掉')
1011
+ })
1012
+
1013
+ test('fetch: tool-level error is classified and NOT retried through single-shot spawn', async () => {
1014
+ stubEnv({ ARGO_STUB_FETCH_ERROR: '1' })
1015
+ const spawned = []
1016
+ await assert.rejects(() => fetchViaArgo('https://stub.example/x', undefined, {
1017
+ command: process.execPath,
1018
+ args: [STUB_SERVER],
1019
+ timeoutMs: 5000,
1020
+ }, { run: async (spec) => { spawned.push(spec); return '{}' } }), /argo fetch blocked/)
1021
+ assert.equal(spawned.length, 0, '工具级错误不得回退单发(双倍引擎调用与配额)')
1022
+ assert.equal(spawnCount(), 1, '工具级错误不得新起进程重跑')
1023
+ })
1024
+
1025
+ test('fetch: timeout is classified and NOT retried through single-shot spawn', async () => {
1026
+ stubEnv({ ARGO_STUB_DELAY_MS: '2000' })
1027
+ const spawned = []
1028
+ await assert.rejects(() => fetchViaArgo('https://stub.example/slow', undefined, {
1029
+ command: process.execPath,
1030
+ args: [STUB_SERVER],
1031
+ timeoutMs: 300,
1032
+ }, { run: async (spec) => { spawned.push(spec); return '{}' } }), /argo fetch timeout/)
1033
+ assert.equal(spawned.length, 0, '超时不是传输故障,重发只会把同一次慢调用再跑一遍')
1034
+ })
1035
+
1036
+ test('fetch: transport failure falls back to single-shot spawn once', async () => {
1037
+ const spawned = []
1038
+ const result = await fetchViaArgo('https://stub.example/page', undefined, {
1039
+ command: process.execPath,
1040
+ args: [join(STUB_DIR, 'does-not-exist.js')],
1041
+ timeoutMs: 5000,
1042
+ }, {
1043
+ run: async (spec) => {
1044
+ spawned.push(spec)
1045
+ return JSON.stringify({ content: [{ type: 'text', text: JSON.stringify({
1046
+ success: true, url: 'https://spawn.example/final', content: 'spawned body', truncated: true,
1047
+ }) }] })
1048
+ },
1049
+ })
1050
+ assert.equal(spawned.length, 1, '连接建立失败应回退单发一次,功能不丢')
1051
+ assert.equal(result.url, 'https://spawn.example/final')
1052
+ assert.equal(result.body.content, 'spawned body')
1053
+ assert.equal(result.truncated, true)
1054
+ })
1055
+
1056
+ test('fetch: an already-aborted signal sends no request and releases the claim', async () => {
1057
+ stubEnv()
1058
+ const controller = new AbortController()
1059
+ controller.abort()
1060
+ await assert.rejects(() => fetchViaArgo('https://stub.example/page', controller.signal, {
1061
+ command: process.execPath,
1062
+ args: [STUB_SERVER],
1063
+ timeoutMs: 5000,
1064
+ searchIdleMs: 0,
1065
+ }), /argo fetch aborted/)
1066
+ // 认领已归还:紧接着的顺序调用仍应复用同一条连接(不会因泄漏退化成串行)
1067
+ const result = await fetchViaArgo('https://stub.example/page', undefined, {
1068
+ command: process.execPath,
1069
+ args: [STUB_SERVER],
1070
+ timeoutMs: 5000,
1071
+ searchIdleMs: 0,
1072
+ })
1073
+ assert.match(result.body.content, /^stub fetched body \(max_chars=50000\)$/)
1074
+ assert.equal(spawnCount(), 1, 'abort 后认领应已归还,不该为此多开连接')
1075
+ })
1076
+
1077
+ // ── 2026-10-20 审查轮次补测 ───────────────────────────────────────────────
1078
+ // 六处改动各钉一条不变式。放在文件末尾,与上面的池/兜底测试共用 stubEnv /
1079
+ // spawnCount / afterEach 收尾。
1080
+
1081
+ test('pool: pooled reuse does not re-resolve host credentials', async () => {
1082
+ // env 只被 createMcpConnection 消费。解析放在 acquireMcp 开头时,每次池化
1083
+ // 复用都白付一次 credentials.resolve + 一次无用 await;凭证服务在场
1084
+ // (desktop profile)这是每次搜索都要付的。
1085
+ //
1086
+ // 增量断言而非绝对次数:acquireMcp 走 resolveArgoEnv(activeConfig, options),
1087
+ // keyRefs 读的是**模块级** activeConfig(由 apply() 设置),不是这里传给
1088
+ // makeNativeTool 的 config——那条对解析次数完全无效,只有 credentials 经
1089
+ // nativeViaMcpPool 传下去有效。生产里两者是同一个对象(apply 内
1090
+ // syncNativeTools 闭包共享 config),直接构造工具时才分叉。所以这里只钉
1091
+ // 「池化复用不新增解析」这个方向,条数交给 activeConfig 的当前状态。
1092
+ let resolveCalls = 0
1093
+ const counting = { resolve: async () => { resolveCalls += 1; return { value: 'sk-host', source: 'file' } } }
1094
+ stubEnv()
1095
+ const tool = makeNativeTool(
1096
+ {
1097
+ searchCommand: process.execPath,
1098
+ searchArgs: [STUB_SERVER],
1099
+ credentials: counting,
1100
+ searchConcurrency: 3,
1101
+ },
1102
+ 'argo_search',
1103
+ { mcpCall: nativeViaMcpPool },
1104
+ )
1105
+ await tool.execute({ query: 'a' })
1106
+ const baseline = resolveCalls
1107
+ assert.ok(baseline >= 1, '第一次调用至少该解析一次(新连接要 env)')
1108
+ await tool.execute({ query: 'b' })
1109
+ await tool.execute({ query: 'c' })
1110
+ assert.equal(resolveCalls, baseline, `池化复用不得再解析凭证,实际从 ${baseline} 涨到 ${resolveCalls}`)
1111
+ assert.equal(spawnCount(), 1, '三次顺序调用仍只该有一条常驻连接')
1112
+ })
1113
+
1114
+ test('pool: a new connection after idle recycle re-resolves credentials', async () => {
1115
+ // 反方向的不变式:连接被回收后新建时必须重新解析,轮换 key 才对下一跳生效。
1116
+ // 少这一条,「不重复解析」会被误做成「只解析一次」。同样是增量口径,理由
1117
+ // 见上一条对 activeConfig 的说明。
1118
+ let resolveCalls = 0
1119
+ const counting = { resolve: async () => { resolveCalls += 1; return { value: 'sk-host', source: 'file' } } }
1120
+ stubEnv()
1121
+ const tool = makeNativeTool(
1122
+ {
1123
+ searchCommand: process.execPath,
1124
+ searchArgs: [STUB_SERVER],
1125
+ credentials: counting,
1126
+ searchConcurrency: 1,
1127
+ },
1128
+ 'argo_search',
1129
+ { mcpCall: nativeViaMcpPool },
1130
+ )
1131
+ await tool.execute({ query: 'a' })
1132
+ const baseline = resolveCalls
1133
+ disposeSharedMcp()
1134
+ await tool.execute({ query: 'b' })
1135
+ assert.equal(resolveCalls, baseline + 1, `回收后新建连接应再解析一次,实际从 ${baseline} 到 ${resolveCalls}`)
1136
+ assert.equal(spawnCount(), 2)
1137
+ })
1138
+
1139
+ test('normalizeSourceUrl: case-distinct paths and queries stay distinct', () => {
1140
+ // 整串 toLowerCase 曾把 /About?doc=Ab12 与 /about?doc=ab12 归成一个键,
1141
+ // 第二个来源在 evidence ledger 里被 seenUrls 静默丢掉。
1142
+ assert.notEqual(
1143
+ normalizeSourceUrl('https://ex.com/About?doc=Ab12'),
1144
+ normalizeSourceUrl('https://ex.com/about?doc=ab12'),
1145
+ 'path/query 大小写不同是两个页面,不能并成一个去重键',
1146
+ )
1147
+ })
1148
+
1149
+ test('normalizeSourceUrl: host is still normalized and tracking params still stripped', () => {
1150
+ // 反向不变量:去掉整串小写不能顺手丢掉原有的归一能力。URL.prototype.toString()
1151
+ // 本身就把 scheme/host 转小写并抹掉默认端口。
1152
+ assert.equal(normalizeSourceUrl('https://EX.com:443/A'), normalizeSourceUrl('https://ex.com/A'))
1153
+ assert.equal(
1154
+ normalizeSourceUrl('https://ex.com/a?utm_source=x&fbclid=y&id=1'),
1155
+ normalizeSourceUrl('https://ex.com/a?id=1'),
1156
+ )
1157
+ assert.equal(normalizeSourceUrl('https://ex.com/a#frag'), normalizeSourceUrl('https://ex.com/a'))
1158
+ assert.equal(normalizeSourceUrl('https://ex.com/a/'), normalizeSourceUrl('https://ex.com/a'))
1159
+ // ?id=1 与 ?id=2 仍是不同页面(原有口径不能被削弱)
1160
+ assert.notEqual(normalizeSourceUrl('https://ex.com/a?id=1'), normalizeSourceUrl('https://ex.com/a?id=2'))
1161
+ // 百分号编码的 hex 大小写要归一(RFC 3986:%2F 与 %2f 同一个八位组),
1162
+ // 否则同一页面按不同编码各记一笔、虚增 sourceCount
1163
+ assert.equal(normalizeSourceUrl('https://ex.com/a%2Fb'), normalizeSourceUrl('https://ex.com/a%2fb'))
1164
+ // 但编码里的大写hex不能反过来污染字面量大小写
1165
+ assert.notEqual(normalizeSourceUrl('https://ex.com/Ab'), normalizeSourceUrl('https://ex.com/aB'))
1166
+ })
1167
+
1168
+ test('makeNativeTool: a non-positive nativeMaxChars falls back to the default cap', () => {
1169
+ // clip(body, 0) 会返回一个孤零零的「…」,整个工具输出变成省略号。
1170
+ // schema 没有 min,profile patch 可以直接写 0。
1171
+ const tool = makeNativeTool(
1172
+ { searchCommand: process.execPath, searchArgs: [STUB_SERVER], nativeMaxChars: 0 },
1173
+ 'argo_search',
1174
+ { run: async () => JSON.stringify({ content: [{ type: 'text', text: 'x'.repeat(30_000) }] }) },
1175
+ )
1176
+ const rendered = tool.output.render({}, { stdout: JSON.stringify({ content: [{ type: 'text', text: 'x'.repeat(30_000) }] }) })
1177
+ const text0 = rendered[0].text
1178
+ assert.equal(text0.length, 24_000, `非正数应回退默认 24000,实际 ${text0.length}`)
1179
+ assert.ok(text0.endsWith('…'), '回退后仍应按默认上限截断')
1180
+
1181
+ const negative = makeNativeTool(
1182
+ { searchCommand: process.execPath, searchArgs: [STUB_SERVER], nativeMaxChars: -5 },
1183
+ 'argo_search',
1184
+ { run: async () => '{}' },
1185
+ )
1186
+ const rendered2 = negative.output.render({}, { stdout: JSON.stringify({ content: [{ type: 'text', text: 'y'.repeat(30_000) }] }) })
1187
+ assert.equal(rendered2[0].text.length, 24_000, '负数同样回退默认值')
1188
+ })
1189
+
1190
+ test('runNativeCall: an abort during the single-shot path kills the child and rejects', async () => {
1191
+ // 兜底路径此前没有 signal 形参:abort 之后这一发仍跑满 timeoutMs,且最终
1192
+ // resolve 出结果。用一个真睡 3s 的命令证明「取消即收摊」。
1193
+ const controller = new AbortController()
1194
+ const started = Date.now()
1195
+ setTimeout(() => controller.abort(), 60)
1196
+ await assert.rejects(
1197
+ () => runNativeCall({ command: '/bin/sleep', args: ['3'] }, 25_000, controller.signal),
1198
+ (err) => err.code === 'WEB_ABORTED',
1199
+ )
1200
+ const elapsed = Date.now() - started
1201
+ assert.ok(elapsed < 1500, `abort 后应立即收摊,实际等了 ${elapsed}ms`)
1202
+ })
1203
+
1204
+ test('runNativeCall: an already-aborted signal spawns nothing', async () => {
1205
+ stubEnv()
1206
+ const controller = new AbortController()
1207
+ controller.abort()
1208
+ await assert.rejects(
1209
+ () => runNativeCall({ command: process.execPath, args: [STUB_SERVER] }, 5000, controller.signal),
1210
+ (err) => err.code === 'WEB_ABORTED',
1211
+ )
1212
+ assert.equal(spawnCount(), 0, '已取消就不该起进程')
1213
+ })
1214
+
1215
+ test('fetch: the single-shot fallback receives the caller signal', async () => {
1216
+ // 透传证明:兜底分支必须把 signal 交给 runner,否则 abort 在兜底路径上失效。
1217
+ const seen = []
1218
+ await fetchViaArgo('https://stub.example/page', undefined, {
1219
+ command: process.execPath,
1220
+ args: [join(STUB_DIR, 'does-not-exist.js')],
1221
+ timeoutMs: 5000,
1222
+ idleMs: 0,
1223
+ max: 1,
1224
+ }, {
1225
+ run: async (spec, timeoutMs, signal) => {
1226
+ seen.push({ spec, timeoutMs, signal })
1227
+ return JSON.stringify({ content: [{ type: 'text', text: JSON.stringify({
1228
+ success: true, url: 'https://spawn.example/final', content: 'body', truncated: false,
1229
+ }) }] })
1230
+ },
1231
+ })
1232
+ assert.equal(seen.length, 1)
1233
+ assert.equal(seen[0].signal, undefined, '调用方没给 signal 时透传 undefined,不改变 spec 形状')
1234
+
1235
+ const controller = new AbortController()
1236
+ await fetchViaArgo('https://stub.example/page', controller.signal, {
1237
+ command: process.execPath,
1238
+ args: [join(STUB_DIR, 'does-not-exist.js')],
1239
+ timeoutMs: 5000,
1240
+ idleMs: 0,
1241
+ max: 1,
1242
+ }, {
1243
+ run: async (spec, timeoutMs, signal) => {
1244
+ assert.equal(signal, controller.signal, '调用方给了 signal 时必须原样透传')
1245
+ return JSON.stringify({ content: [{ type: 'text', text: JSON.stringify({
1246
+ success: true, url: 'https://spawn.example/final', content: 'body', truncated: false,
1247
+ }) }] })
1248
+ },
1249
+ })
1250
+ })
1251
+
1252
+ test('fetch: abort during the single-shot fallback is classified as aborted, not failed', async () => {
1253
+ // 池路用一个起不来的入口快速失败(node 对不存在的脚本立刻退 1),兜底委托
1254
+ // **真实** runNativeCall 跑一个真睡 3s 的进程。abort 在 60ms 时到达:修好
1255
+ // 后 runNativeCall 看到 signal.aborted 直接拒绝、不起进程;改前兜底不收
1256
+ // signal,这一发要跑满 3s 且最终 resolve 出结果。
1257
+ //
1258
+ // fallbackCalls 是必需的:池路自身失败只要 34-38ms,abort 设在 60ms,余量
1259
+ // 只有 20ms 出头。机器一慢,池路的 onAbort 先 reject,同样得到
1260
+ // ARGO_FETCH_ABORTED、同样 elapsed < 1500——测试全绿而兜底一次都没进。
1261
+ // 没有这条计数,这个失败是静默的。
1262
+ let fallbackCalls = 0
1263
+ const controller = new AbortController()
1264
+ setTimeout(() => controller.abort(), 60)
1265
+ const started = Date.now()
1266
+ await assert.rejects(
1267
+ () => fetchViaArgo('https://stub.example/page', controller.signal, {
1268
+ command: process.execPath,
1269
+ args: [join(STUB_DIR, 'does-not-exist.js')],
1270
+ timeoutMs: 25_000,
1271
+ idleMs: 0,
1272
+ max: 1,
1273
+ }, {
1274
+ run: (spec, timeoutMs, signal) => {
1275
+ fallbackCalls += 1
1276
+ return runNativeCall({ command: '/bin/sleep', args: ['3'] }, timeoutMs, signal)
1277
+ },
1278
+ }),
1279
+ (err) => {
1280
+ assert.equal(err.code, 'ARGO_FETCH_ABORTED', '取消要归到 aborted 类,不能变成失败类')
1281
+ return true
1282
+ },
1283
+ )
1284
+ assert.equal(fallbackCalls, 1, '这条测试必须真的走到单发兜底,否则测的是池路')
1285
+ const elapsed = Date.now() - started
1286
+ assert.ok(elapsed < 1500, `取消后应立即返回,实际 ${elapsed}ms`)
1287
+ })
1288
+
1289
+ test('pool: abort during connection establishment kills the child promptly', async () => {
1290
+ // acquireMcp 此前不收 signal:取消发生在连接建立阶段时,调用方只能干等
1291
+ // initMs 或子进程退出(/bin/sleep 场景实测 3009ms 全花在池路)。initialize
1292
+ // 握手对 sleep 永不回包,abort 必须让 initializeWithSignal 关掉连接并
1293
+ // 以 WEB_ABORTED 收摊,而不是等 30s 握手预算。
1294
+ stubEnv()
1295
+ const controller = new AbortController()
1296
+ const started = Date.now()
1297
+ const p = nativeViaMcpPool(
1298
+ { searchCommand: '/bin/sleep', searchArgs: ['30'], searchIdleMs: 0, searchConcurrency: 1, searchTimeoutMs: 30_000 },
1299
+ 'argo_search',
1300
+ {},
1301
+ 30_000,
1302
+ controller.signal,
1303
+ )
1304
+ await new Promise((r) => setTimeout(r, 80))
1305
+ controller.abort()
1306
+ await assert.rejects(p, (err) => {
1307
+ assert.equal(err.code, 'WEB_ABORTED', '连接建立阶段取消要归到 aborted 类')
1308
+ return true
1309
+ })
1310
+ const elapsed = Date.now() - started
1311
+ assert.ok(elapsed < 2000, `abort 后应立即返回,实际 ${elapsed}ms`)
1312
+ })
1313
+
1314
+ test('pool: already-aborted signal never spawns a connection', async () => {
1315
+ // 已取消就不起进程:spawn 之后再拒绝只是白起一个 python(与 runNativeCall
1316
+ // 的已取消分支同一口径)。用 marker 文件断言子进程确实没被创建——spawnCount
1317
+ // 只数 STUB_SERVER 桩,数不到这里的任意 command。
1318
+ stubEnv()
1319
+ const dir = mkdtempSync(join(tmpdir(), 'argo-dsh-nospawn-'))
1320
+ const marker = join(dir, 'spawned')
1321
+ const script = `require('fs').writeFileSync(${JSON.stringify(marker)}, '1'); setInterval(() => {}, 1000)`
1322
+ const controller = new AbortController()
1323
+ controller.abort()
1324
+ await assert.rejects(
1325
+ () => nativeViaMcpPool(
1326
+ { searchCommand: process.execPath, searchArgs: ['-e', script], searchIdleMs: 0, searchConcurrency: 1, searchTimeoutMs: 30_000 },
1327
+ 'argo_search',
1328
+ {},
1329
+ 30_000,
1330
+ controller.signal,
1331
+ ),
1332
+ (err) => {
1333
+ assert.equal(err.code, 'WEB_ABORTED')
1334
+ return true
1335
+ },
1336
+ )
1337
+ assert.equal(existsSync(marker), false, '已取消的调用不得 spawn 子进程')
1338
+ })