@wenathlan/devthink 2.0.13 → 2.0.15

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 (444) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/accounts.ts +2087 -0
  3. package/agent.ts +2331 -0
  4. package/antigravity.ts +2139 -0
  5. package/apifreeze.ts +4146 -0
  6. package/auth.ts +642 -0
  7. package/background.ts +16837 -0
  8. package/bridge.ts +2080 -0
  9. package/build.ts +54 -0
  10. package/bun.ts +26 -0
  11. package/capture.ts +1614 -0
  12. package/checksecrets.ts +72 -0
  13. package/cjs.ts +9 -0
  14. package/cli.ts +3500 -0
  15. package/commands.ts +1101 -0
  16. package/companion.ts +450 -0
  17. package/compatibility.ts +37 -0
  18. package/config.ts +3137 -0
  19. package/constants.ts +943 -0
  20. package/core.ts +1473 -0
  21. package/crossbrowser.ts +1359 -0
  22. package/dashdone.ts +605 -0
  23. package/data.ts +943 -0
  24. package/database.ts +133 -0
  25. package/debug.ts +850 -0
  26. package/deno.ts +27 -0
  27. package/devthink.ts +707 -0
  28. package/dist/accounts.d.ts +409 -0
  29. package/dist/agent.d.ts +837 -0
  30. package/dist/antigravity.d.ts +380 -0
  31. package/dist/apifreeze.d.ts +113 -0
  32. package/dist/auth.d.ts +224 -0
  33. package/{bridge.d.ts → dist/bridge.d.ts} +1 -1
  34. package/dist/capture.d.ts +595 -0
  35. package/dist/cjs.d.ts +6 -0
  36. package/{cli.js → dist/cli.js} +2 -2
  37. package/dist/commands.d.ts +310 -0
  38. package/dist/compatibility.d.ts +17 -0
  39. package/dist/config.d.ts +1853 -0
  40. package/dist/constants.d.ts +637 -0
  41. package/dist/core.d.ts +361 -0
  42. package/dist/data.d.ts +330 -0
  43. package/dist/database.d.ts +24 -0
  44. package/dist/debug.d.ts +270 -0
  45. package/dist/engine.d.ts +74 -0
  46. package/dist/environments.d.ts +244 -0
  47. package/dist/evidence.d.ts +277 -0
  48. package/dist/export.d.ts +311 -0
  49. package/dist/fingerprint.d.ts +594 -0
  50. package/dist/flow.d.ts +300 -0
  51. package/dist/gates.d.ts +103 -0
  52. package/dist/identity.d.ts +51 -0
  53. package/dist/ids.d.ts +7 -0
  54. package/dist/llm.d.ts +652 -0
  55. package/dist/models.d.ts +1807 -0
  56. package/dist/net.d.ts +325 -0
  57. package/dist/neutral.d.ts +41 -0
  58. package/dist/oauth.d.ts +703 -0
  59. package/dist/page.d.ts +1166 -0
  60. package/dist/perf.d.ts +481 -0
  61. package/dist/plan.d.ts +134 -0
  62. package/dist/project.d.ts +395 -0
  63. package/dist/providers.d.ts +37 -0
  64. package/dist/quota.d.ts +453 -0
  65. package/dist/recovery.d.ts +621 -0
  66. package/dist/request.d.ts +1492 -0
  67. package/dist/run.d.ts +779 -0
  68. package/dist/runtime.d.ts +212 -0
  69. package/dist/search.d.ts +375 -0
  70. package/dist/security.d.ts +534 -0
  71. package/dist/serve.d.ts +136 -0
  72. package/dist/session.d.ts +296 -0
  73. package/dist/storage.d.ts +53 -0
  74. package/dist/streaming.d.ts +1119 -0
  75. package/dist/swarm.d.ts +328 -0
  76. package/dist/tools.d.ts +170 -0
  77. package/dist/types.d.ts +8357 -0
  78. package/dist/utils.d.ts +82 -0
  79. package/dist/version.d.ts +7 -0
  80. package/dist/versionregistry.d.ts +406 -0
  81. package/dist/views.d.ts +579 -0
  82. package/dist/workbenchsession.d.ts +69 -0
  83. package/dist/workflow.d.ts +649 -0
  84. package/docs/readme.md +92 -0
  85. package/docs/schemas/antigravity.json +192 -0
  86. package/engine.ts +1524 -0
  87. package/environments.ts +1005 -0
  88. package/evidence.ts +643 -0
  89. package/export.ts +889 -0
  90. package/fingerprint.ts +1458 -0
  91. package/flow.ts +1190 -0
  92. package/gates.ts +307 -0
  93. package/gateway.ts +34 -0
  94. package/hardening.ts +191 -0
  95. package/headless.ts +369 -0
  96. package/http.ts +2110 -0
  97. package/identity.ts +246 -0
  98. package/ids.ts +26 -0
  99. package/index.ts +369 -0
  100. package/llm.ts +2682 -0
  101. package/mcp.ts +1835 -0
  102. package/{memory.js → memory.ts} +3834 -2310
  103. package/models.ts +3805 -0
  104. package/modes.ts +43 -0
  105. package/net.ts +1214 -0
  106. package/neutral.ts +362 -0
  107. package/node.ts +26 -0
  108. package/oauth.ts +2216 -0
  109. package/offscreen.ts +170 -0
  110. package/pack.ts +956 -0
  111. package/package.json +175 -82
  112. package/page.ts +7824 -0
  113. package/pagebridge.ts +1116 -0
  114. package/perf.ts +957 -0
  115. package/plan.ts +672 -0
  116. package/plugin.ts +76 -0
  117. package/{policy.js → policy.ts} +5073 -4679
  118. package/prisma.config.ts +27 -0
  119. package/progress.ts +1308 -0
  120. package/project.ts +1291 -0
  121. package/protocol.ts +4187 -0
  122. package/providers.ts +246 -0
  123. package/quota.ts +1887 -0
  124. package/recovery.ts +2433 -0
  125. package/request.ts +4482 -0
  126. package/run.ts +2135 -0
  127. package/runtime.ts +526 -0
  128. package/search.ts +1760 -0
  129. package/security.ts +1673 -0
  130. package/serve.ts +419 -0
  131. package/server.ts +5538 -0
  132. package/session.ts +1162 -0
  133. package/storage.ts +182 -0
  134. package/streaming.ts +4101 -0
  135. package/swarm.ts +884 -0
  136. package/sync.ts +56 -0
  137. package/tests/agentcert.test.ts +128 -0
  138. package/tests/agentfleet.test.ts +214 -0
  139. package/tests/agentreview.test.ts +241 -0
  140. package/tests/agentstream.test.ts +335 -0
  141. package/tests/agentwork.test.ts +356 -0
  142. package/tests/apifreeze.test.ts +290 -0
  143. package/tests/apimap.test.ts +112 -0
  144. package/tests/approvalgate.test.ts +175 -0
  145. package/tests/artifactmanifest.test.ts +204 -0
  146. package/tests/attentionfeed.test.ts +163 -0
  147. package/tests/backgroundruns.test.ts +100 -0
  148. package/tests/batchscheduling.test.ts +131 -0
  149. package/tests/blackboard.test.ts +238 -0
  150. package/tests/bridge.test.ts +1088 -0
  151. package/tests/browsercoverage.test.ts +134 -0
  152. package/tests/browserpolyfills.test.ts +124 -0
  153. package/tests/build.mjs +1260 -0
  154. package/tests/clientauth.test.ts +223 -0
  155. package/tests/clitools.test.ts +578 -0
  156. package/tests/confirmgates.test.ts +190 -0
  157. package/tests/containerpack.test.ts +143 -0
  158. package/tests/controlflow.test.ts +2120 -0
  159. package/tests/coordination.test.ts +345 -0
  160. package/tests/copyscreen.test.ts +139 -0
  161. package/tests/core.test.ts +417 -0
  162. package/tests/costcert.test.ts +95 -0
  163. package/tests/crossbrowser.test.ts +115 -0
  164. package/tests/cspaudit.test.ts +79 -0
  165. package/tests/dashdone.test.ts +615 -0
  166. package/tests/datagrid.test.ts +137 -0
  167. package/tests/doccheck.test.ts +107 -0
  168. package/tests/dombatch.test.ts +109 -0
  169. package/tests/emulation.test.ts +282 -0
  170. package/tests/environments.test.ts +259 -0
  171. package/tests/evidenceviews.test.ts +88 -0
  172. package/tests/exporttools.test.ts +185 -0
  173. package/tests/firefoxprep.test.ts +134 -0
  174. package/tests/flowlibrary.test.ts +242 -0
  175. package/tests/flowrun.test.ts +303 -0
  176. package/tests/forensics.test.ts +439 -0
  177. package/tests/gateway.test.ts +939 -0
  178. package/tests/headlesslib.test.ts +171 -0
  179. package/tests/httpstream.test.ts +277 -0
  180. package/tests/immutablelog.test.ts +157 -0
  181. package/tests/inboundguard.test.ts +177 -0
  182. package/tests/lazyload.test.ts +72 -0
  183. package/tests/librarymodes.test.ts +623 -0
  184. package/tests/llm.test.ts +696 -0
  185. package/tests/manifest.test.ts +226 -0
  186. package/tests/maskinputs.test.ts +150 -0
  187. package/tests/matrixverify.test.ts +111 -0
  188. package/tests/mavenpack.test.ts +75 -0
  189. package/tests/mcpserver.test.ts +704 -0
  190. package/tests/memory.test.ts +4155 -0
  191. package/tests/memorycare.test.ts +230 -0
  192. package/tests/migrateplan.test.ts +349 -0
  193. package/tests/minimization.test.ts +414 -0
  194. package/tests/modelroute.test.ts +112 -0
  195. package/tests/multiagent.test.ts +440 -0
  196. package/tests/nativebridge.test.ts +807 -0
  197. package/tests/navigation.test.ts +284 -0
  198. package/tests/nugetpack.test.ts +83 -0
  199. package/tests/orchestration.test.ts +449 -0
  200. package/tests/originpolicy.test.ts +405 -0
  201. package/tests/outputcompare.test.ts +97 -0
  202. package/tests/package.test.ts +91 -0
  203. package/tests/pagebridge.test.ts +5909 -0
  204. package/tests/pentest.test.ts +75 -0
  205. package/tests/perfrecords.test.ts +257 -0
  206. package/tests/permdiff.test.ts +85 -0
  207. package/tests/phishguard.test.ts +128 -0
  208. package/tests/pickerviews.test.ts +155 -0
  209. package/tests/pipelines.test.ts +381 -0
  210. package/tests/planlint.test.ts +240 -0
  211. package/tests/planreview.test.ts +383 -0
  212. package/tests/platformtargets.test.ts +56 -0
  213. package/tests/policy.test.ts +8284 -0
  214. package/tests/portability.test.ts +96 -0
  215. package/tests/progress.test.ts +1036 -0
  216. package/tests/promptlibrary.test.ts +116 -0
  217. package/tests/protocol.test.ts +4793 -0
  218. package/tests/publishingpipeline.test.ts +314 -0
  219. package/tests/quickactions.test.ts +164 -0
  220. package/tests/readiness.test.ts +78 -0
  221. package/tests/recipes.test.ts +163 -0
  222. package/tests/redactshots.test.ts +216 -0
  223. package/tests/registernvidiakeys.ts +51 -0
  224. package/tests/relayserve.test.ts +304 -0
  225. package/tests/releasenotes.test.ts +276 -0
  226. package/tests/resilience.test.ts +467 -0
  227. package/tests/resourceaware.test.ts +187 -0
  228. package/tests/runbudget.test.ts +125 -0
  229. package/tests/runhistory.test.ts +299 -0
  230. package/tests/runreplay.test.ts +113 -0
  231. package/tests/runresume.test.ts +184 -0
  232. package/tests/runstate.test.ts +288 -0
  233. package/tests/runtimeadapters.test.ts +251 -0
  234. package/tests/safariskeleton.test.ts +115 -0
  235. package/tests/sandboxframe.test.ts +196 -0
  236. package/tests/sbom.test.ts +105 -0
  237. package/tests/secretvault.test.ts +188 -0
  238. package/tests/selcache.test.ts +111 -0
  239. package/tests/sessioninterface.test.ts +791 -0
  240. package/tests/sessions.test.ts +591 -0
  241. package/tests/siteprefs.test.ts +228 -0
  242. package/tests/snapshotdelta.test.ts +165 -0
  243. package/tests/statusviews.test.ts +237 -0
  244. package/tests/stepmeter.test.ts +200 -0
  245. package/tests/streamparse.test.ts +163 -0
  246. package/tests/stylepruning.test.ts +90 -0
  247. package/tests/surfaces.test.ts +282 -0
  248. package/tests/sweep.test.ts +78 -0
  249. package/tests/syncbridge.test.ts +164 -0
  250. package/tests/taskqueue.test.ts +206 -0
  251. package/tests/toolcalls.test.ts +336 -0
  252. package/tests/toolcatalog.test.ts +201 -0
  253. package/tests/torture-agent.test.ts +706 -0
  254. package/tests/torture-auth.test.ts +461 -0
  255. package/tests/torture-commands.test.ts +895 -0
  256. package/tests/torture-data.test.ts +923 -0
  257. package/tests/torture-environments.test.ts +285 -0
  258. package/tests/torture-export.test.ts +263 -0
  259. package/tests/torture-http.test.ts +1379 -0
  260. package/tests/torture-llm.test.ts +808 -0
  261. package/tests/torture-memory.test.ts +1084 -0
  262. package/tests/torture-plan.test.ts +829 -0
  263. package/tests/torture-run.test.ts +626 -0
  264. package/tests/torture-session.test.ts +928 -0
  265. package/tests/torture-swarm.test.ts +1245 -0
  266. package/tests/torture-tools.test.ts +964 -0
  267. package/tests/torture-workflow.test.ts +2354 -0
  268. package/tests/tourviews.test.ts +52 -0
  269. package/tests/transparency.test.ts +147 -0
  270. package/tests/trigger.test.ts +941 -0
  271. package/tests/v1sunset.test.ts +428 -0
  272. package/tests/virtlist.test.ts +94 -0
  273. package/tests/vision.test.ts +540 -0
  274. package/tests/vsixpack.test.ts +145 -0
  275. package/tests/webapi.test.ts +523 -0
  276. package/tests/workflow.test.ts +1785 -0
  277. package/tests/workfloweditor.test.ts +759 -0
  278. package/tests/xpipack.test.ts +128 -0
  279. package/tools.ts +734 -0
  280. package/types.ts +9908 -0
  281. package/umd.ts +9 -0
  282. package/utils.ts +249 -0
  283. package/version.ts +8 -0
  284. package/versionregistry.ts +1310 -0
  285. package/views.ts +2153 -0
  286. package/vitest.config.ts +12 -0
  287. package/web/App.tsx +78 -0
  288. package/web/README.md +47 -0
  289. package/web/capacitor.config.ts +46 -0
  290. package/web/const.ts +19 -0
  291. package/web/control.shell.tsx +66 -0
  292. package/web/dashboardpage.ts +554 -0
  293. package/web/db.ts +493 -0
  294. package/web/design.html +541 -0
  295. package/web/gatewayview/Gateway.tsx +345 -0
  296. package/web/gatewayview/config.ts +1042 -0
  297. package/web/gatewayview/definition.ts +174 -0
  298. package/web/icons.ts +15 -0
  299. package/{manifest.json → web/manifest.json} +1 -1
  300. package/web/optionspage.ts +691 -0
  301. package/web/popup.ts +1651 -0
  302. package/web/schema.prisma +176 -0
  303. package/web/sidepanel.ts +7941 -0
  304. package/web/sitemanifest.json +13 -0
  305. package/web/terminal-ui.tsx +622 -0
  306. package/web/transparencypage.ts +541 -0
  307. package/web/vite.config.ts +39 -0
  308. package/web/workspace.ts +23 -0
  309. package/workbenchmemory.ts +104 -0
  310. package/workbenchsession.ts +304 -0
  311. package/workflow.ts +4495 -0
  312. package/bridge.d.ts.map +0 -1
  313. package/bridge.js +0 -1877
  314. package/bun.d.ts.map +0 -1
  315. package/bun.js +0 -52329
  316. package/caps/background.json +0 -712
  317. package/caps/cli.json +0 -360
  318. package/caps/library.json +0 -3611
  319. package/caps/mcp.json +0 -126
  320. package/caps/pagebridge.json +0 -377
  321. package/caps/popup.json +0 -51
  322. package/caps/sidepanel.json +0 -522
  323. package/checksums.txt +0 -142
  324. package/cli.d.ts.map +0 -1
  325. package/companion.d.ts.map +0 -1
  326. package/companion.js +0 -389
  327. package/crossbrowser.d.ts.map +0 -1
  328. package/crossbrowser.js +0 -1191
  329. package/dashdone.d.ts.map +0 -1
  330. package/dashdone.js +0 -368
  331. package/deno.d.ts.map +0 -1
  332. package/deno.js +0 -52325
  333. package/devthink.umd.js +0 -52346
  334. package/fixtures/bun.ts +0 -7
  335. package/fixtures/cjs.js +0 -6
  336. package/fixtures/deno.ts +0 -6
  337. package/fixtures/example-org-pagestate.json +0 -22
  338. package/fixtures/importers/automa-workflow.json +0 -18
  339. package/fixtures/importers/selenium-side.json +0 -20
  340. package/fixtures/importers/tabular-plan.csv +0 -7
  341. package/fixtures/importers/uivision-macro.json +0 -10
  342. package/fixtures/importers/v1-plan.json +0 -13
  343. package/fixtures/mcp-client.mjs +0 -14
  344. package/fixtures/pages/checkout.html +0 -192
  345. package/fixtures/pages/dashboard.html +0 -110
  346. package/fixtures/pages/feed.html +0 -113
  347. package/fixtures/pages/product-grid.html +0 -150
  348. package/fixtures/plans/form-inventory-plan.json +0 -11
  349. package/fixtures/plans/release-notes-plan.json +0 -11
  350. package/fixtures/recipes/agentscompete.json +0 -11
  351. package/fixtures/recipes/agentsforms.json +0 -11
  352. package/fixtures/recipes/agentsmonitor.json +0 -11
  353. package/fixtures/recipes/agentsreview.json +0 -29
  354. package/fixtures/recipes/agentswarm.json +0 -11
  355. package/fixtures/recipes/fillcalendar.json +0 -12
  356. package/fixtures/recipes/fillcheckout.json +0 -13
  357. package/fixtures/recipes/filldependentdropdowns.json +0 -12
  358. package/fixtures/recipes/fillfromcsv.json +0 -13
  359. package/fixtures/recipes/fillloginform.json +0 -12
  360. package/fixtures/recipes/fillmultistepwizard.json +0 -13
  361. package/fixtures/recipes/fillregistration.json +0 -12
  362. package/fixtures/recipes/fillsearchfilters.json +0 -12
  363. package/fixtures/recipes/filltypeahead.json +0 -11
  364. package/fixtures/recipes/monitoralerts.json +0 -12
  365. package/fixtures/recipes/monitoravailability.json +0 -11
  366. package/fixtures/recipes/monitornetworkquiet.json +0 -12
  367. package/fixtures/recipes/monitorpagechanges.json +0 -11
  368. package/fixtures/recipes/monitorpricechange.json +0 -11
  369. package/fixtures/recipes/monitorschedule.json +0 -12
  370. package/fixtures/recipes/scrapedynamicprices.json +0 -13
  371. package/fixtures/recipes/scrapeimagegallery.json +0 -12
  372. package/fixtures/recipes/scrapeinfinitefeed.json +0 -13
  373. package/fixtures/recipes/scrapenestedlists.json +0 -12
  374. package/fixtures/recipes/scrapeproductgrid.json +0 -12
  375. package/fixtures/recipes/scrapereviews.json +0 -12
  376. package/fixtures/recipes/scrapesearchresults.json +0 -12
  377. package/fixtures/recipes/scrapetablepagination.json +0 -12
  378. package/fixtures/recipes/testaccessibility.json +0 -34
  379. package/fixtures/recipes/testconsoleerrors.json +0 -12
  380. package/fixtures/recipes/testdeeplinks.json +0 -12
  381. package/fixtures/recipes/testformvalidation.json +0 -13
  382. package/fixtures/recipes/testlayoutshift.json +0 -12
  383. package/fixtures/recipes/testlinkcheck.json +0 -12
  384. package/fixtures/recipes/testloadtiming.json +0 -12
  385. package/fixtures/recipes/testvisualdiff.json +0 -12
  386. package/fixtures/workflows/changelog-digest-workflow.json +0 -13
  387. package/fixtures/workflows/form-survey-workflow.json +0 -13
  388. package/gallery.json +0 -706
  389. package/hardening.d.ts.map +0 -1
  390. package/hardening.js +0 -188
  391. package/headless.d.ts.map +0 -1
  392. package/headless.js +0 -11522
  393. package/http.d.ts.map +0 -1
  394. package/http.js +0 -2165
  395. package/index.cjs +0 -54682
  396. package/index.d.ts.map +0 -1
  397. package/index.js +0 -52310
  398. package/index.neutral.js +0 -46343
  399. package/mcp.d.ts.map +0 -1
  400. package/mcp.js +0 -20515
  401. package/memory.d.ts.map +0 -1
  402. package/nativehost.template.json +0 -10
  403. package/node.cjs +0 -54686
  404. package/node.d.ts.map +0 -1
  405. package/pack.d.ts.map +0 -1
  406. package/pack.js +0 -805
  407. package/policy.d.ts.map +0 -1
  408. package/progress.d.ts.map +0 -1
  409. package/progress.js +0 -645
  410. package/protocol.d.ts.map +0 -1
  411. package/protocol.js +0 -8334
  412. package/schemas/audit.schema.json +0 -226
  413. package/schemas/capability.schema.json +0 -113
  414. package/schemas/envelope.schema.json +0 -162
  415. package/schemas/memory.schema.json +0 -51
  416. package/schemas/observation.schema.json +0 -113
  417. package/schemas/plan.schema.json +0 -208
  418. package/schemas/progress.schema.json +0 -126
  419. package/schemas/proposal.schema.json +0 -68
  420. package/schemas/session.schema.json +0 -82
  421. package/schemas/tool.schema.json +0 -94
  422. package/server.d.ts.map +0 -1
  423. package/server.js +0 -28877
  424. package/umd-example.html +0 -18
  425. package/umd.d.ts.map +0 -1
  426. /package/{bun.d.ts → dist/bun.d.ts} +0 -0
  427. /package/{cli.d.ts → dist/cli.d.ts} +0 -0
  428. /package/{companion.d.ts → dist/companion.d.ts} +0 -0
  429. /package/{crossbrowser.d.ts → dist/crossbrowser.d.ts} +0 -0
  430. /package/{dashdone.d.ts → dist/dashdone.d.ts} +0 -0
  431. /package/{deno.d.ts → dist/deno.d.ts} +0 -0
  432. /package/{hardening.d.ts → dist/hardening.d.ts} +0 -0
  433. /package/{headless.d.ts → dist/headless.d.ts} +0 -0
  434. /package/{http.d.ts → dist/http.d.ts} +0 -0
  435. /package/{index.d.ts → dist/index.d.ts} +0 -0
  436. /package/{mcp.d.ts → dist/mcp.d.ts} +0 -0
  437. /package/{memory.d.ts → dist/memory.d.ts} +0 -0
  438. /package/{node.d.ts → dist/node.d.ts} +0 -0
  439. /package/{pack.d.ts → dist/pack.d.ts} +0 -0
  440. /package/{policy.d.ts → dist/policy.d.ts} +0 -0
  441. /package/{progress.d.ts → dist/progress.d.ts} +0 -0
  442. /package/{protocol.d.ts → dist/protocol.d.ts} +0 -0
  443. /package/{server.d.ts → dist/server.d.ts} +0 -0
  444. /package/{umd.d.ts → dist/umd.d.ts} +0 -0
@@ -0,0 +1,4793 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { existsSync } from "node:fs";
3
+ import {
4
+ allowlistreport,
5
+ approvalframes,
6
+ batchreport,
7
+ calllogreport,
8
+ cancelframes,
9
+ dryrunreport,
10
+ eventnotification,
11
+ heartbeatreport,
12
+ httpstreamreport,
13
+ idempotencyreplayframe,
14
+ idempotencyreport,
15
+ inflightreport,
16
+ mockreport,
17
+ pairingframes,
18
+ progressnoticeframe,
19
+ promptcallframe,
20
+ promptreport,
21
+ ratelimitreport,
22
+ resourcedeltareport,
23
+ samplingframes,
24
+ streamchunkframe,
25
+ structurederrorreport,
26
+ subscriptionframes,
27
+ tlsreport,
28
+ tokenreport,
29
+ toolcallframe,
30
+ toolresultframe,
31
+ ecosystemviews,
32
+ } from "../protocol.js";
33
+ import {
34
+ authreport,
35
+ callsreport,
36
+ capturereport,
37
+ cdpreport,
38
+ consolediffreport,
39
+ controlreport,
40
+ datasetresponse,
41
+ diffresponse,
42
+ downloadreport,
43
+ editorstate,
44
+ errorreportresponse,
45
+ eventresponse,
46
+ exchangesreport,
47
+ extractionreport,
48
+ formreportresponse,
49
+ heldkeysreport,
50
+ layoutreport,
51
+ mapresponse,
52
+ mediareport,
53
+ navstateresponse,
54
+ netlogreport,
55
+ observationresponse,
56
+ emulationreport,
57
+ outcomeresponse,
58
+ parseproposal,
59
+ profilereport,
60
+ provenancereport,
61
+ quarantinereport,
62
+ requestbody,
63
+ runhistoryquery,
64
+ runhistoryreport,
65
+ safetyresponse,
66
+ selectorresponse,
67
+ signalsreport,
68
+ tabreportresponse,
69
+ timelinereport,
70
+ trailreport,
71
+ transformgrammar,
72
+ wizardreport,
73
+ workflowfileversion,
74
+ } from "../protocol.js";
75
+ import { modeloutcome, modelproposal, environmentgrammar, environmentreport } from "../protocol.js";
76
+ import { consentmodel, logchainreport, securityreport, surfacesnapshot, interfaceviews } from "../protocol.js";
77
+ import { agenteventframe, boardstatesnapshot, handoffframe, reviewframe, swarmstatereport } from "../protocol.js";
78
+ import {
79
+ protocolversion,
80
+ type a11ynode,
81
+ type modeloutput,
82
+ type plandraft,
83
+ type agentplan,
84
+ type apimapentry,
85
+ type breakpointspec,
86
+ type callrecord,
87
+ type cdpcommand,
88
+ type cdpeventrule,
89
+ type cdpsession,
90
+ type cpuprofile,
91
+ type debuggergrant,
92
+ type flowmetric,
93
+ type growsample,
94
+ type heaprecord,
95
+ type memorytrend,
96
+ type shiftentry,
97
+ type sourcemapconsent,
98
+ type sourcemapref,
99
+ type tracerecord,
100
+ type pausestate,
101
+ type scriptoverride,
102
+ type watchexpression,
103
+ type channelrecord,
104
+ type columnspec,
105
+ type dataset,
106
+ type eventsubscription,
107
+ type exchangerecord,
108
+ type downloadrecord,
109
+ type extractsession,
110
+ type imagebatch,
111
+ type mediarecord,
112
+ type mutationevent,
113
+ type focusevent,
114
+ type bannerreport,
115
+ type errorreport,
116
+ type formreport,
117
+ type navstate,
118
+ type netlogrecord,
119
+ type observation,
120
+ type provenancerecord,
121
+ type quarantineentry,
122
+ type redirectchain,
123
+ type safetyverdict,
124
+ type selectorcandidate,
125
+ type shotpair,
126
+ type shotrecord,
127
+ type snapshotdiff,
128
+ type stepoutcome,
129
+ type tablayout,
130
+ type tabreport,
131
+ type transformrule,
132
+ type typeaheadpick,
133
+ type wizardstate,
134
+ type swarmstate,
135
+ } from "../types.js";
136
+
137
+ describe("protocol", () => {
138
+ it("turns a valid proposal into a pending local review", () => {
139
+ const proposal = parseproposal(
140
+ {
141
+ version: protocolversion,
142
+ plan: {
143
+ objective: "Read the page",
144
+ steps: [{ kind: "observe", summary: "Capture an approved page snapshot" }],
145
+ },
146
+ },
147
+ "https://example.com",
148
+ );
149
+ expect(proposal.plan.state).toBe("pending");
150
+ expect(proposal.plan.steps[0]?.risk).toBe("read");
151
+ });
152
+
153
+ it("rejects unsupported versions and cross-origin routes", () => {
154
+ expect(() =>
155
+ parseproposal({ version: "1.0.0", plan: { objective: "x", steps: [] } }, "https://example.com"),
156
+ ).toThrow("protocol");
157
+ expect(() =>
158
+ parseproposal(
159
+ {
160
+ version: protocolversion,
161
+ plan: {
162
+ objective: "x",
163
+ steps: [{ kind: "navigate", value: "https://elsewhere.example", summary: "Leave site" }],
164
+ },
165
+ },
166
+ "https://example.com",
167
+ ),
168
+ ).toThrow("approved origin");
169
+ });
170
+
171
+ it("accepts the expanded action vocabulary with derived risk levels", () => {
172
+ const proposal = parseproposal(
173
+ {
174
+ version: protocolversion,
175
+ plan: {
176
+ objective: "Review a richer flow",
177
+ steps: [
178
+ { kind: "scroll", target: "#menu", summary: "Scroll the reviewed menu into view." },
179
+ { kind: "hover", target: "#menu", summary: "Hover the reviewed menu." },
180
+ { kind: "select", target: "#country", value: "br", summary: "Select the reviewed country." },
181
+ { kind: "extract", summary: "Extract bounded page links." },
182
+ { kind: "wait", value: "500", summary: "Wait half a second between steps." },
183
+ { kind: "presskey", value: "Enter", options: '{"modifiers":["shift"]}', summary: "Press shift enter." },
184
+ { kind: "readtable", target: "#prices", summary: "Read the reviewed price table." },
185
+ { kind: "drag", target: "#card", value: "#board", summary: "Drag the reviewed card to the board." },
186
+ { kind: "tablist", summary: "List the open tabs." },
187
+ { kind: "evaluate", value: "document.title", summary: "Read the title through a reviewed expression." },
188
+ ],
189
+ },
190
+ },
191
+ "https://example.com",
192
+ );
193
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual([
194
+ "interaction",
195
+ "interaction",
196
+ "sensitive",
197
+ "read",
198
+ "read",
199
+ "sensitive",
200
+ "read",
201
+ "sensitive",
202
+ "read",
203
+ "sensitive",
204
+ ]);
205
+ expect(proposal.plan.steps[5]?.options).toBe('{"modifiers":["shift"]}');
206
+ });
207
+
208
+ it("accepts plans with an unlimited number of reviewed steps", () => {
209
+ const steps = Array.from({ length: 150 }, (_, index) => ({
210
+ kind: "wait",
211
+ value: "1",
212
+ summary: `Reviewed pause ${index + 1}.`,
213
+ }));
214
+ const proposal = parseproposal(
215
+ { version: protocolversion, plan: { objective: "Long reviewed flow", steps } },
216
+ "https://example.com",
217
+ );
218
+ expect(proposal.plan.steps).toHaveLength(150);
219
+ expect(() =>
220
+ parseproposal({ version: protocolversion, plan: { objective: "Empty flow", steps: [] } }, "https://example.com"),
221
+ ).toThrow("at least one step");
222
+ });
223
+
224
+ it("honors a user configured plan expiry without a fixed ceiling", () => {
225
+ const createdat = Date.now();
226
+ const twohours = createdat + 2 * 60 * 60 * 1000;
227
+ const proposal = parseproposal(
228
+ {
229
+ version: protocolversion,
230
+ plan: {
231
+ objective: "Long lived review",
232
+ expiresat: twohours,
233
+ steps: [{ kind: "observe", summary: "Observe once." }],
234
+ },
235
+ },
236
+ "https://example.com",
237
+ );
238
+ expect(proposal.plan.expiresat).toBe(twohours);
239
+ });
240
+
241
+ it("shapes the proposal request with the capability report and wraps step outcomes", () => {
242
+ const body = JSON.parse(
243
+ requestbody({
244
+ objective: "Read prices",
245
+ session: { id: "session", tabid: 4, origin: "https://example.com", startedat: 1, expiresat: 2 },
246
+ observation: {
247
+ schemaversion: 2,
248
+ url: "https://example.com",
249
+ title: "Example",
250
+ textpreview: "",
251
+ textlength: 0,
252
+ forms: [],
253
+ interactive: [],
254
+ capturedat: 1,
255
+ },
256
+ capabilities: { tabs: true, downloads: false, clipboardread: false, clipboardwrite: false, reportedat: 1 },
257
+ }),
258
+ );
259
+ expect(body.capabilities.tabs).toBe(true);
260
+ expect(body.version).toBe(protocolversion);
261
+ const envelope = JSON.parse(
262
+ outcomeresponse({
263
+ outcome: { stepid: "step", ok: true, summary: "Done.", details: { count: 3 }, at: 5 },
264
+ plan: {
265
+ id: "plan",
266
+ objective: "o",
267
+ origin: "https://example.com",
268
+ steps: [],
269
+ createdat: 1,
270
+ expiresat: 2,
271
+ state: "approved",
272
+ },
273
+ }),
274
+ );
275
+ expect(envelope.outcome.details.count).toBe(3);
276
+ expect(envelope.planstate).toBe("approved");
277
+ expect(envelope.resolvedtarget).toBeUndefined();
278
+ });
279
+
280
+ it("attaches the resolvedtarget summary to the outcome envelope for review", () => {
281
+ const plan = {
282
+ id: "plan",
283
+ objective: "o",
284
+ origin: "https://example.com",
285
+ steps: [],
286
+ createdat: 1,
287
+ expiresat: 2,
288
+ state: "approved" as const,
289
+ };
290
+ const resolvedtarget = {
291
+ mode: "text" as const,
292
+ selector: "#signin",
293
+ tag: "button",
294
+ label: "Sign in",
295
+ geometry: { x: 12, y: 30, width: 90, height: 32 },
296
+ };
297
+ const envelope = JSON.parse(
298
+ outcomeresponse({
299
+ outcome: {
300
+ stepid: "t1",
301
+ ok: true,
302
+ summary: "Clicked Sign in.",
303
+ details: { mode: "text", resolvedtarget },
304
+ at: 9,
305
+ },
306
+ plan,
307
+ resolvedtarget,
308
+ }),
309
+ );
310
+ expect(envelope.version).toBe(protocolversion);
311
+ expect(envelope.resolvedtarget).toEqual(resolvedtarget);
312
+ expect(envelope.planid).toBe("plan");
313
+ });
314
+
315
+ it("wraps clickablemap payloads with numbered entries in the versioned envelope", () => {
316
+ const plan = {
317
+ id: "plan",
318
+ objective: "o",
319
+ origin: "https://example.com",
320
+ steps: [],
321
+ createdat: 1,
322
+ expiresat: 2,
323
+ state: "approved" as const,
324
+ };
325
+ const map = {
326
+ version: 4,
327
+ builtat: 77,
328
+ entries: [
329
+ { number: 1, selector: "a:nth-of-type(1)", role: "link", label: "Home", mode: "selector" as const },
330
+ { number: 2, selector: "#search", role: "button", label: "Search", mode: "selector" as const },
331
+ ],
332
+ };
333
+ const envelope = JSON.parse(mapresponse({ map, plan }));
334
+ expect(envelope.version).toBe(protocolversion);
335
+ expect(envelope.planid).toBe("plan");
336
+ expect(envelope.map.version).toBe(4);
337
+ expect((envelope.map.entries as Array<{ number: number }>).map((entry) => entry.number)).toEqual([1, 2]);
338
+ expect(envelope.map.entries[1]).toMatchObject({ number: 2, selector: "#search", role: "button", label: "Search" });
339
+ });
340
+
341
+ it("reports the keys currently held on one tab in the context envelope", () => {
342
+ const report = heldkeysreport({
343
+ tabid: 4,
344
+ holds: [
345
+ { holdid: "shift1", key: "Shift", modifiers: ["shift"], tabid: 4, stepid: "s1", pressedat: 10 },
346
+ { holdid: "ctrl1", key: "Control", tabid: 4, stepid: "s2", pressedat: 20, releasedat: 30 },
347
+ ],
348
+ });
349
+ expect(report.version).toBe(protocolversion);
350
+ expect(report.tabid).toBe(4);
351
+ expect(report.heldkeys).toHaveLength(2);
352
+ expect(report.heldkeys[0]).toMatchObject({ holdid: "shift1", key: "Shift" });
353
+ expect(report.heldkeys[1]?.releasedat).toBe(30);
354
+ });
355
+
356
+ it("accepts the interaction universe vocabulary with its reviewed options grammar", () => {
357
+ const proposal = parseproposal(
358
+ {
359
+ version: protocolversion,
360
+ plan: {
361
+ objective: "Complete the login flow",
362
+ steps: [
363
+ { id: "map", kind: "mapclicks", summary: "Number every clickable element." },
364
+ {
365
+ kind: "movepointer",
366
+ summary: "Travel the reviewed path.",
367
+ options:
368
+ '{"pointpath":{"start":{"x":0,"y":0},"end":{"x":120,"y":60},"duration":400},"speedprofile":{"easing":"easeinout","peak":800}}',
369
+ },
370
+ {
371
+ kind: "clicktext",
372
+ summary: "Click the sign in control by text.",
373
+ options: '{"targetref":{"mode":"text","text":"Sign in"}}',
374
+ },
375
+ {
376
+ kind: "clickaria",
377
+ summary: "Click the submit control by aria pair.",
378
+ options: '{"targetref":{"mode":"aria","role":"button","name":"Submit"}}',
379
+ },
380
+ {
381
+ kind: "typetime",
382
+ target: "#email",
383
+ value: "user@example.com",
384
+ summary: "Type the reviewed email slowly.",
385
+ options: '{"delay":90}',
386
+ },
387
+ { kind: "keyhold", value: "Shift", summary: "Hold shift for the range.", options: '{"holdid":"shift1"}' },
388
+ { kind: "keyrelease", value: "shift1", summary: "Release the held shift key." },
389
+ { kind: "setslider", target: "#volume", value: "40", summary: "Drag the volume slider." },
390
+ { kind: "verifyvisible", target: "#results", summary: "Verify the results render." },
391
+ { kind: "dismissdialog", summary: "Accept the next dialog.", options: '{"accept":true}' },
392
+ {
393
+ kind: "retryaction",
394
+ summary: "Retry the moving banner click.",
395
+ options: '{"stepid":"banner","retryrule":{"attempts":3,"settle":200,"tolerance":4}}',
396
+ },
397
+ { id: "banner", kind: "click", target: "#banner", summary: "Click the moving banner." },
398
+ ],
399
+ },
400
+ },
401
+ "https://example.com",
402
+ );
403
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual([
404
+ "read",
405
+ "interaction",
406
+ "interaction",
407
+ "interaction",
408
+ "sensitive",
409
+ "sensitive",
410
+ "sensitive",
411
+ "sensitive",
412
+ "read",
413
+ "sensitive",
414
+ "interaction",
415
+ "sensitive",
416
+ ]);
417
+ expect(proposal.plan.steps[1]?.options).toContain("pointpath");
418
+ });
419
+
420
+ it("rejects wrapper steps that reference unknown sibling step ids", () => {
421
+ expect(() =>
422
+ parseproposal(
423
+ {
424
+ version: protocolversion,
425
+ plan: {
426
+ objective: "Retry a ghost",
427
+ steps: [
428
+ {
429
+ id: "wrapper",
430
+ kind: "retryaction",
431
+ summary: "Retry the ghost click.",
432
+ options: '{"stepid":"ghost","retryrule":{"attempts":2}}',
433
+ },
434
+ ],
435
+ },
436
+ },
437
+ "https://example.com",
438
+ ),
439
+ ).toThrow("unknown step id");
440
+ expect(() =>
441
+ parseproposal(
442
+ {
443
+ version: protocolversion,
444
+ plan: {
445
+ objective: "Frame a ghost",
446
+ steps: [
447
+ {
448
+ id: "frame",
449
+ kind: "enterframe",
450
+ summary: "Route inside the frame.",
451
+ options: '{"framepath":[0],"stepid":"ghost"}',
452
+ },
453
+ ],
454
+ },
455
+ },
456
+ "https://example.com",
457
+ ),
458
+ ).toThrow("unknown step id");
459
+ });
460
+
461
+ it("accepts the observation depth vocabulary with its reviewed options grammar", () => {
462
+ const proposal = parseproposal(
463
+ {
464
+ version: protocolversion,
465
+ plan: {
466
+ objective: "See the whole page",
467
+ steps: [
468
+ { kind: "a11ytree", summary: "Capture the accessibility tree." },
469
+ { kind: "readertree", summary: "Extract the reader view." },
470
+ { kind: "readvisible", target: "#main", summary: "Read the visible text." },
471
+ { kind: "detectlists", summary: "Detect repeated lists." },
472
+ { kind: "detecttables", summary: "Detect data tables." },
473
+ { kind: "readjson", summary: "Read the embedded json state." },
474
+ { kind: "watchmutate", summary: "Watch the feed.", options: '{"lifetime":2000,"scopes":["#feed"]}' },
475
+ {
476
+ kind: "waitquiet",
477
+ summary: "Wait for network quiet.",
478
+ options: '{"quietrule":{"idle":400,"timeout":6000}}',
479
+ },
480
+ { kind: "watchbanner", summary: "Watch consent banners.", options: '{"lifetime":1500}' },
481
+ { kind: "detectinfinitescroll", summary: "Detect infinite scroll." },
482
+ { kind: "detectvirtual", summary: "Detect virtualized lists." },
483
+ { kind: "detectlazy", summary: "Detect lazy images." },
484
+ { kind: "readscrollpos", summary: "Read the scroll position." },
485
+ { kind: "readlang", summary: "Read the page language." },
486
+ { kind: "readoutline", summary: "Read the heading outline." },
487
+ { kind: "countpages", summary: "Count the pagination." },
488
+ { kind: "listshadow", summary: "List open shadow roots." },
489
+ { kind: "listframes", summary: "List the iframes." },
490
+ { kind: "classifypage", summary: "Classify the page template." },
491
+ { kind: "fingerprintsection", target: "#prices", summary: "Fingerprint the section." },
492
+ { kind: "diffsnapshots", summary: "Diff two observation versions.", options: '{"versions":[1,2]}' },
493
+ { kind: "readselection", summary: "Read the current selection." },
494
+ { kind: "watchfocus", summary: "Watch focus changes.", options: '{"lifetime":900}' },
495
+ { kind: "detectsticky", summary: "Detect sticky overlays." },
496
+ { kind: "detectscrolllock", summary: "Detect scroll locks." },
497
+ { kind: "readopengraph", summary: "Read the open graph fields." },
498
+ { kind: "detectlanguage", summary: "Detect the text language." },
499
+ { kind: "deriveselector", target: "#checkout", summary: "Derive a stable selector." },
500
+ ],
501
+ },
502
+ },
503
+ "https://example.com",
504
+ );
505
+ expect(proposal.plan.steps).toHaveLength(28);
506
+ expect(proposal.plan.steps.every((step) => step.risk === "read")).toBe(true);
507
+ expect(() =>
508
+ parseproposal(
509
+ {
510
+ version: protocolversion,
511
+ plan: { objective: "Watch without a lifetime", steps: [{ kind: "watchmutate", summary: "Watch the feed." }] },
512
+ },
513
+ "https://example.com",
514
+ ),
515
+ ).toThrow("watch lifetime");
516
+ });
517
+
518
+ it("wraps the observation envelope with its a11y, reader, listpattern and tableshape sections", () => {
519
+ const plan = {
520
+ id: "plan",
521
+ objective: "o",
522
+ origin: "https://example.com",
523
+ steps: [],
524
+ createdat: 1,
525
+ expiresat: 2,
526
+ state: "approved" as const,
527
+ };
528
+ const tree: a11ynode = { role: "button", name: "Submit", states: ["disabled"], childcount: 0, children: [] };
529
+ const observationcapture: observation = {
530
+ schemaversion: 3,
531
+ url: "https://example.com",
532
+ title: "Example",
533
+ textpreview: "text",
534
+ textlength: 4,
535
+ forms: [],
536
+ interactive: [],
537
+ capturedat: 9,
538
+ mode: "passive",
539
+ a11y: tree,
540
+ reader: {
541
+ title: "Example",
542
+ byline: "",
543
+ blocks: [{ kind: "p", text: "text", words: 1 }],
544
+ words: 1,
545
+ characters: 4,
546
+ },
547
+ listpattern: [{ container: "ul", itemselector: "li.item", repeat: 3, samples: ["a", "b", "c"] }],
548
+ tableshape: [
549
+ { selector: "table", headers: ["Name"], columns: [{ label: "Name", cells: 3 }], rows: 3, caption: "" },
550
+ ],
551
+ };
552
+ const envelope = JSON.parse(observationresponse({ observation: observationcapture, plan }));
553
+ expect(envelope.version).toBe(protocolversion);
554
+ expect(envelope.planstate).toBe("approved");
555
+ expect(envelope.observation.a11y).toEqual(tree);
556
+ expect(envelope.observation.reader.blocks).toHaveLength(1);
557
+ expect(envelope.observation.listpattern[0]).toMatchObject({ itemselector: "li.item", repeat: 3 });
558
+ expect(envelope.observation.tableshape[0]).toMatchObject({ headers: ["Name"], rows: 3 });
559
+ expect(envelope.observation.mode).toBe("passive");
560
+ });
561
+
562
+ it("wraps mutation, focus and banner event records in the versioned envelope", () => {
563
+ const plan = {
564
+ id: "plan",
565
+ objective: "o",
566
+ origin: "https://example.com",
567
+ steps: [],
568
+ createdat: 1,
569
+ expiresat: 2,
570
+ state: "approved" as const,
571
+ };
572
+ const mutation: mutationevent = {
573
+ watchid: "w1",
574
+ event: "childList",
575
+ targetpath: "#feed > li",
576
+ sessionid: "session",
577
+ at: 100,
578
+ };
579
+ const focus: focusevent = { watchid: "w2", kind: "focus", targetpath: "#search", sessionid: "session", at: 150 };
580
+ const banner: bannerreport = {
581
+ kind: "cookie",
582
+ selector: "#cookiebar",
583
+ text: "We use cookies.",
584
+ controls: ["Accept"],
585
+ sessionid: "session",
586
+ at: 200,
587
+ };
588
+ const envelope = JSON.parse(eventresponse({ events: [mutation, focus, banner], plan }));
589
+ expect(envelope.version).toBe(protocolversion);
590
+ expect(envelope.events).toHaveLength(3);
591
+ expect(envelope.events[0]).toMatchObject({ watchid: "w1", event: "childList", targetpath: "#feed > li", at: 100 });
592
+ expect(envelope.events[1]).toMatchObject({ kind: "focus", targetpath: "#search", at: 150 });
593
+ expect(envelope.events[2]).toMatchObject({ kind: "cookie", controls: ["Accept"], at: 200 });
594
+ });
595
+
596
+ it("wraps snapshot diffs with their version pair and added, removed and changed nodes", () => {
597
+ const plan = {
598
+ id: "plan",
599
+ objective: "o",
600
+ origin: "https://example.com",
601
+ steps: [],
602
+ createdat: 1,
603
+ expiresat: 2,
604
+ state: "approved" as const,
605
+ };
606
+ const diff: snapshotdiff = {
607
+ baseversion: 2,
608
+ targetversion: 5,
609
+ added: [{ kind: "added", selector: "#new", summary: "New" }],
610
+ removed: [{ kind: "removed", selector: "#old", summary: "Old" }],
611
+ changed: [{ kind: "changed", selector: "#go", summary: "Go became Stop" }],
612
+ at: 42,
613
+ };
614
+ const envelope = JSON.parse(diffresponse({ diff, plan }));
615
+ expect(envelope.version).toBe(protocolversion);
616
+ expect(envelope.diff.baseversion).toBe(2);
617
+ expect(envelope.diff.targetversion).toBe(5);
618
+ expect(envelope.diff.added).toEqual([{ kind: "added", selector: "#new", summary: "New" }]);
619
+ expect(envelope.diff.removed).toHaveLength(1);
620
+ expect(envelope.diff.changed).toHaveLength(1);
621
+ });
622
+
623
+ it("reports page language, template, scroll lock and banner state in the context payload", () => {
624
+ expect(
625
+ signalsreport({
626
+ signals: { language: "pt-BR", template: "article", scrolllocked: true, banner: "cookie", refreshedat: 7 },
627
+ }),
628
+ ).toMatchObject({
629
+ version: protocolversion,
630
+ language: "pt-BR",
631
+ template: "article",
632
+ scrolllocked: true,
633
+ banner: "cookie",
634
+ });
635
+ expect(signalsreport({ signals: { language: "en", refreshedat: 8 } })).toEqual({
636
+ version: protocolversion,
637
+ language: "en",
638
+ });
639
+ expect(signalsreport({})).toEqual({ version: protocolversion });
640
+ expect(signalsreport({ signals: { refreshedat: 9 } })).toEqual({ version: protocolversion });
641
+ });
642
+
643
+ it("wraps derived selector candidates with their stability scores", () => {
644
+ const plan = {
645
+ id: "plan",
646
+ objective: "o",
647
+ origin: "https://example.com",
648
+ steps: [],
649
+ createdat: 1,
650
+ expiresat: 2,
651
+ state: "approved" as const,
652
+ };
653
+ const candidates: selectorcandidate[] = [
654
+ { selector: "#checkout", strategy: "id", score: 100 },
655
+ { selector: 'button[data-testid="checkout"]', strategy: "attribute", score: 80 },
656
+ ];
657
+ const envelope = JSON.parse(selectorresponse({ candidates, plan }));
658
+ expect(envelope.version).toBe(protocolversion);
659
+ expect(envelope.candidates).toEqual(candidates);
660
+ expect(envelope.candidates[0]).toMatchObject({ selector: "#checkout", strategy: "id", score: 100 });
661
+ });
662
+
663
+ it("accepts the navigation mastery vocabulary with its reviewed options grammar", () => {
664
+ const proposal = parseproposal(
665
+ {
666
+ version: protocolversion,
667
+ plan: {
668
+ objective: "Move anywhere with review",
669
+ steps: [
670
+ {
671
+ kind: "openlink",
672
+ summary: "Open the reviewed url in a new tab.",
673
+ options: '{"navtarget":{"url":"https://example.com/report","container":"tab","position":"adjacent"}}',
674
+ },
675
+ {
676
+ kind: "openprivate",
677
+ summary: "Open the reviewed url in a private window.",
678
+ options: '{"navtarget":{"url":"https://example.com/report","container":"private"}}',
679
+ },
680
+ { kind: "reloadcache", summary: "Reload bypassing the cache." },
681
+ { kind: "stopnav", summary: "Stop the pending navigation." },
682
+ { kind: "waitload", summary: "Wait for the load event.", options: '{"timeout":3000}' },
683
+ {
684
+ kind: "waiturl",
685
+ summary: "Wait for the reviewed pattern.",
686
+ options:
687
+ '{"urlpattern":{"mode":"prefix","url":"https://example.com/results","query":{"page":"*"}},"timeout":4000}',
688
+ },
689
+ { kind: "followlink", value: "Read the docs", summary: "Follow the reviewed link." },
690
+ {
691
+ kind: "spanav",
692
+ value: "Open settings",
693
+ summary: "Route the single page app.",
694
+ options: '{"routepattern":{"mode":"prefix","url":"https://example.com/settings"},"timeout":2500}',
695
+ },
696
+ { kind: "spawait", summary: "Wait for the spa url change.", options: '{"timeout":2000,"poll":50}' },
697
+ {
698
+ kind: "rewritequery",
699
+ summary: "Rewrite the query parameters.",
700
+ options: '{"set":{"page":"3"},"remove":["session"]}',
701
+ },
702
+ { kind: "setfragment", value: "pricing", summary: "Set the reviewed fragment." },
703
+ {
704
+ kind: "navlist",
705
+ summary: "Navigate the reviewed urls.",
706
+ options: '{"urls":["https://example.com/a","https://example.com/b"]}',
707
+ },
708
+ {
709
+ kind: "navprofile",
710
+ summary: "Apply the per site wait profile.",
711
+ options:
712
+ '{"waitprofile":{"signals":["load","networkidle"],"idle":500,"overrides":[{"origin":"https://example.com","idle":1500}]}}',
713
+ },
714
+ { kind: "detecthttp", summary: "Detect http errors and offline states." },
715
+ { kind: "readredirects", summary: "Read the redirect chain." },
716
+ { kind: "readfinalurl", summary: "Read the final url." },
717
+ { kind: "handleauth", value: "https://example.com", summary: "Answer the basic auth prompt." },
718
+ { kind: "printpdf", summary: "Print the page to pdf.", options: '{"name":"report.pdf"}' },
719
+ {
720
+ kind: "prefetch",
721
+ summary: "Prefetch the predicted pages.",
722
+ options: '{"urls":["https://example.com/next"]}',
723
+ },
724
+ {
725
+ kind: "preconnect",
726
+ summary: "Preconnect to the expected origins.",
727
+ options: '{"origins":["https://cdn.example"]}',
728
+ },
729
+ {
730
+ kind: "deeplink",
731
+ summary: "Build the deep link.",
732
+ options:
733
+ '{"app":"github","params":{"owner":"wenathlan","repo":"extension"},"navtarget":{"url":"https://example.com","container":"tab"}}',
734
+ },
735
+ { kind: "reopentab", value: "https://example.com/closed", summary: "Reopen the closed tab." },
736
+ { kind: "trailaudit", summary: "Restore the navigation trail." },
737
+ {
738
+ kind: "pausenav",
739
+ summary: "Pause navigation for the consent prompt.",
740
+ options: '{"reason":"a consent banner is open"}',
741
+ },
742
+ { kind: "navintent", value: "open the pricing page", summary: "Record the navigation intent." },
743
+ {
744
+ kind: "navrate",
745
+ summary: "Apply the per domain rate limit.",
746
+ options: '{"ratelimit":{"domain":"example.com","window":600000,"ceiling":3}}',
747
+ },
748
+ { kind: "openclipboard", value: "clipboard url", summary: "Open the clipboard url on consent." },
749
+ { kind: "checksafe", value: "https://example.com/report", summary: "Verify the url safety." },
750
+ {
751
+ kind: "batchopen",
752
+ summary: "Open the curated list.",
753
+ options: '{"urls":["https://example.com/a","https://example.com/b"]}',
754
+ },
755
+ ],
756
+ },
757
+ },
758
+ "https://example.com",
759
+ );
760
+ expect(proposal.plan.steps).toHaveLength(29);
761
+ expect(proposal.plan.steps.filter((step) => step.risk === "sensitive")).toHaveLength(20);
762
+ expect(proposal.plan.steps.filter((step) => step.risk === "read")).toHaveLength(9);
763
+ expect(() =>
764
+ parseproposal(
765
+ {
766
+ version: protocolversion,
767
+ plan: {
768
+ objective: "Open without a target",
769
+ steps: [{ kind: "openlink", summary: "Open without a navtarget." }],
770
+ },
771
+ },
772
+ "https://example.com",
773
+ ),
774
+ ).toThrow("navtarget");
775
+ expect(() =>
776
+ parseproposal(
777
+ {
778
+ version: protocolversion,
779
+ plan: { objective: "Rate without a limit", steps: [{ kind: "navrate", summary: "Apply without a limit." }] },
780
+ },
781
+ "https://example.com",
782
+ ),
783
+ ).toThrow("ratelimit");
784
+ });
785
+
786
+ it("wraps the navstate envelope with its load phase, final url and redirect chain", () => {
787
+ const plan = {
788
+ id: "plan",
789
+ objective: "o",
790
+ origin: "https://example.com",
791
+ steps: [],
792
+ createdat: 1,
793
+ expiresat: 2,
794
+ state: "approved" as const,
795
+ };
796
+ const chain: redirectchain = {
797
+ hops: [
798
+ { url: "https://example.com/start", status: 302, at: 10 },
799
+ { url: "https://example.com/mid", status: 302, at: 20 },
800
+ { url: "https://example.com/final", status: 200, at: 35 },
801
+ ],
802
+ startedat: 10,
803
+ endedat: 35,
804
+ };
805
+ const state: navstate = { phase: "complete", finalurl: "https://example.com/final", redirects: chain };
806
+ const envelope = JSON.parse(navstateresponse({ navstate: state, plan }));
807
+ expect(envelope.version).toBe(protocolversion);
808
+ expect(envelope.planstate).toBe("approved");
809
+ expect(envelope.navstate.phase).toBe("complete");
810
+ expect(envelope.navstate.finalurl).toBe("https://example.com/final");
811
+ expect(envelope.navstate.redirects.hops).toHaveLength(3);
812
+ expect(envelope.navstate.redirects.hops[1]).toMatchObject({ url: "https://example.com/mid", status: 302, at: 20 });
813
+ expect(envelope.navstate.redirects.endedat - envelope.navstate.redirects.startedat).toBe(25);
814
+ });
815
+
816
+ it("carries the navigation trail of a session in the context envelope", () => {
817
+ const report = trailreport({
818
+ sessionid: "session",
819
+ trail: [
820
+ { url: "https://example.com/", title: "Home", stepid: "s1", at: 10 },
821
+ { url: "https://example.com/pricing", title: "Pricing", stepid: "s2", at: 20 },
822
+ { url: "https://example.com/pricing#faq", title: "", at: 30 },
823
+ ],
824
+ });
825
+ expect(report.version).toBe(protocolversion);
826
+ expect(report.sessionid).toBe("session");
827
+ expect(report.trail).toHaveLength(3);
828
+ expect(report.trail[1]).toMatchObject({
829
+ url: "https://example.com/pricing",
830
+ title: "Pricing",
831
+ stepid: "s2",
832
+ at: 20,
833
+ });
834
+ expect(report.trail[2]?.stepid).toBeUndefined();
835
+ expect(trailreport({ trail: [] })).toEqual({ version: protocolversion, trail: [] });
836
+ });
837
+
838
+ it("wraps url safety verdicts with their reasons in the response envelope", () => {
839
+ const plan = {
840
+ id: "plan",
841
+ objective: "o",
842
+ origin: "https://example.com",
843
+ steps: [],
844
+ createdat: 1,
845
+ expiresat: 2,
846
+ state: "approved" as const,
847
+ };
848
+ const verdicts: safetyverdict[] = [
849
+ { url: "https://partner.example/report", safe: true, reasons: [], at: 5 },
850
+ { url: "http://tracker.example/pixel", safe: false, reasons: ["the url must use HTTPS"], at: 6 },
851
+ ];
852
+ const envelope = JSON.parse(safetyresponse({ verdicts, plan }));
853
+ expect(envelope.version).toBe(protocolversion);
854
+ expect(envelope.verdicts).toEqual(verdicts);
855
+ expect(envelope.verdicts[1]).toMatchObject({ safe: false, reasons: ["the url must use HTTPS"] });
856
+ });
857
+ });
858
+
859
+ describe("protocol tabs and windows command", () => {
860
+ const plan = {
861
+ id: "plan",
862
+ objective: "o",
863
+ origin: "https://example.com",
864
+ steps: [],
865
+ createdat: 1,
866
+ expiresat: 2,
867
+ state: "approved" as const,
868
+ };
869
+
870
+ it("wraps tab reports with matches, groups and badges in the response envelope", () => {
871
+ const report: tabreport = {
872
+ matches: [
873
+ {
874
+ tabid: 4,
875
+ url: "https://example.com/a",
876
+ title: "Example A",
877
+ index: 0,
878
+ windowid: 10,
879
+ active: true,
880
+ pinned: false,
881
+ audible: false,
882
+ muted: true,
883
+ discarded: false,
884
+ meta: { tabid: 4, taskrefs: ["plan"], provenance: "plan step", labels: ["research"], at: 5 },
885
+ },
886
+ ],
887
+ groups: [{ name: "research", color: "blue", tabids: [4, 5], collapsed: false }],
888
+ badges: [{ tabid: 4, taskid: "plan", label: "2/5", setat: 6 }],
889
+ };
890
+ const envelope = JSON.parse(tabreportresponse({ report, plan }));
891
+ expect(envelope.version).toBe(protocolversion);
892
+ expect(envelope.planid).toBe("plan");
893
+ expect(envelope.planstate).toBe("approved");
894
+ expect(envelope.report.matches[0]).toMatchObject({ tabid: 4, muted: true, meta: { labels: ["research"] } });
895
+ expect(envelope.report.groups[0]).toMatchObject({ name: "research", color: "blue", tabids: [4, 5] });
896
+ expect(envelope.report.badges[0]).toMatchObject({ label: "2/5", taskid: "plan" });
897
+ });
898
+
899
+ it("carries layout snapshots with their window bounds and group states in the session context", () => {
900
+ const layout: tablayout = {
901
+ name: "work",
902
+ tabs: [{ url: "https://example.com/a", title: "A", pinned: true, index: 0, windowid: 10 }],
903
+ groups: [{ name: "research", color: "blue", tabids: [4], collapsed: true }],
904
+ windows: [
905
+ {
906
+ windowid: 10,
907
+ state: { bounds: { left: 0, top: 0, width: 1280, height: 800 }, maximized: false, profile: "normal" },
908
+ },
909
+ ],
910
+ savedat: 42,
911
+ };
912
+ const report = layoutreport({ layouts: [layout] });
913
+ expect(report.version).toBe(protocolversion);
914
+ expect(report.layouts).toHaveLength(1);
915
+ expect(report.layouts[0]?.windows[0]?.state.bounds).toEqual({ left: 0, top: 0, width: 1280, height: 800 });
916
+ expect(report.layouts[0]?.groups[0]).toMatchObject({ collapsed: true });
917
+ expect(layoutreport({ layouts: [] })).toEqual({ version: protocolversion, layouts: [] });
918
+ });
919
+
920
+ it("wraps form reports, error reports and wizard states in versioned envelopes", () => {
921
+ const plan: agentplan = {
922
+ id: "p1",
923
+ objective: "Fill the checkout form",
924
+ origin: "https://example.com",
925
+ steps: [{ id: "s1", kind: "detectfields", summary: "Detect the fields.", risk: "read" }],
926
+ createdat: 1,
927
+ expiresat: 2,
928
+ state: "approved",
929
+ };
930
+ const report: formreport = {
931
+ form: "#checkout",
932
+ fields: [
933
+ { selector: "#name", label: "Full name", kind: "text", matched: true },
934
+ { selector: "#card", label: "Card number", kind: "card", matched: false },
935
+ ],
936
+ };
937
+ const formenvelope = JSON.parse(formreportresponse({ report, plan })) as {
938
+ version: string;
939
+ planid: string;
940
+ planstate: string;
941
+ report: formreport;
942
+ };
943
+ expect(formenvelope).toMatchObject({ version: protocolversion, planid: "p1", planstate: "approved" });
944
+ expect(formenvelope.report.fields[1]).toMatchObject({ kind: "card", matched: false });
945
+ const error: errorreport = {
946
+ form: "#checkout",
947
+ errors: [{ field: "#email", message: "Enter a valid email address." }],
948
+ at: 5,
949
+ };
950
+ const errorenvelope = JSON.parse(errorreportresponse({ report: error, plan })) as {
951
+ version: string;
952
+ report: errorreport;
953
+ };
954
+ expect(errorenvelope.version).toBe(protocolversion);
955
+ expect(errorenvelope.report.errors[0]).toEqual({ field: "#email", message: "Enter a valid email address." });
956
+ const wizards: wizardstate[] = [{ index: 2, steps: 3, completed: [true, true, false], at: 6 }];
957
+ const picks: typeaheadpick[] = [{ field: "#city", query: "sao", pick: "São Paulo", at: 7 }];
958
+ expect(wizardreport({ sessionid: "session", wizards, picks })).toEqual({
959
+ version: protocolversion,
960
+ sessionid: "session",
961
+ wizards,
962
+ picks,
963
+ });
964
+ expect(wizardreport({ wizards: [], picks: [] })).toEqual({ version: protocolversion, wizards: [], picks: [] });
965
+ });
966
+
967
+ it("wraps dataset payloads with column specs and sampled rows in the dataset envelope", () => {
968
+ const columns: columnspec[] = [{ key: "sku", label: "Sku", kind: "text", normalized: "sku" }];
969
+ const rows = Array.from({ length: 12 }, (_, index) => ({ sku: `sku-${index}` }));
970
+ const datasetvalue: dataset = {
971
+ id: "d1",
972
+ name: "catalog",
973
+ columns,
974
+ rows,
975
+ sources: [{ row: 0, url: "https://example.com/list", at: 5, stepid: "s1" }],
976
+ at: 6,
977
+ };
978
+ const payload = JSON.parse(
979
+ datasetresponse({ dataset: datasetvalue, plan: { ...plan, state: "approved" }, sample: 3 }),
980
+ ) as { version: string; planstate: string; dataset: dataset & { totalrows: number } };
981
+ expect(payload).toMatchObject({ version: protocolversion, planstate: "approved" });
982
+ expect(payload.dataset.columns).toEqual(columns);
983
+ expect(payload.dataset.rows).toHaveLength(3);
984
+ expect(payload.dataset.totalrows).toBe(12);
985
+ expect(payload.dataset.sources[0]).toMatchObject({ row: 0, stepid: "s1" });
986
+ });
987
+
988
+ it("carries extraction progress and provenance records per exported artifact in the context envelopes", () => {
989
+ const sessionvalue: extractsession = {
990
+ id: "e1",
991
+ datasetid: "d1",
992
+ name: "catalog",
993
+ target: "table",
994
+ next: "a.next",
995
+ planned: 3,
996
+ pages: ["https://example.com/1"],
997
+ rows: 10,
998
+ cursor: 1,
999
+ startedat: 1,
1000
+ updatedat: 2,
1001
+ };
1002
+ expect(extractionreport({ sessions: [sessionvalue] })).toEqual({
1003
+ version: protocolversion,
1004
+ sessions: [sessionvalue],
1005
+ });
1006
+ const record: provenancerecord = {
1007
+ artifact: "a1",
1008
+ name: "catalog.csv",
1009
+ url: "https://example.com/list",
1010
+ stepid: "s2",
1011
+ rowstart: 1,
1012
+ rowend: 10,
1013
+ checksum: "fnv1a-1",
1014
+ at: 3,
1015
+ };
1016
+ expect(provenancereport({ records: [record] })).toEqual({ version: protocolversion, records: [record] });
1017
+ const rules: transformrule[] = [{ expression: "trim", sources: ["name"], target: "name" }];
1018
+ expect(JSON.parse(transformgrammar(rules))).toEqual({ rules });
1019
+ });
1020
+
1021
+ it("accepts the extraction vocabulary and grades exports sensitive inside a proposal", () => {
1022
+ const proposal = parseproposal(
1023
+ {
1024
+ version: protocolversion,
1025
+ plan: {
1026
+ objective: "Scrape the catalog",
1027
+ steps: [
1028
+ {
1029
+ kind: "scrapetable",
1030
+ target: "table.prices",
1031
+ options: '{"name":"catalog","rowlimit":200}',
1032
+ summary: "Scrape the price table.",
1033
+ },
1034
+ {
1035
+ kind: "transformvalues",
1036
+ options: '{"dataset":"d1","rules":[{"expression":"upper","sources":["name"],"target":"name"}]}',
1037
+ summary: "Uppercase the names.",
1038
+ },
1039
+ ],
1040
+ },
1041
+ },
1042
+ "https://example.com",
1043
+ );
1044
+ expect(proposal.plan.steps[0]?.risk).toBe("read");
1045
+ expect(() =>
1046
+ parseproposal(
1047
+ {
1048
+ version: protocolversion,
1049
+ plan: {
1050
+ objective: "Bad transform",
1051
+ steps: [
1052
+ {
1053
+ kind: "transformvalues",
1054
+ options: '{"dataset":"d1","rules":{"expression":"upper"}}',
1055
+ summary: "Rules must be a list.",
1056
+ },
1057
+ ],
1058
+ },
1059
+ },
1060
+ "https://example.com",
1061
+ ),
1062
+ ).toThrow("transform rules");
1063
+ });
1064
+
1065
+ it("accepts the forms and data vocabulary and refuses submissions without an asksubmit review", () => {
1066
+ const proposal = parseproposal(
1067
+ {
1068
+ version: protocolversion,
1069
+ plan: {
1070
+ objective: "Fill and submit the checkout",
1071
+ steps: [
1072
+ { kind: "detectfields", summary: "Detect the form fields." },
1073
+ {
1074
+ kind: "fillform",
1075
+ options:
1076
+ '{"formrecord":{"form":"#checkout","entries":[{"match":{"mode":"label","label":"Full name"},"kind":"text","value":"Ana Alves"}]}}',
1077
+ summary: "Fill from the structured record.",
1078
+ },
1079
+ { kind: "asksubmit", value: "#checkout", summary: "Ask before submitting." },
1080
+ {
1081
+ kind: "submitform",
1082
+ target: "#checkout",
1083
+ options: '{"consentref":"ask"}',
1084
+ summary: "Submit through the owning form.",
1085
+ },
1086
+ { kind: "readerrors", target: "#checkout", summary: "Read the validation errors." },
1087
+ ],
1088
+ },
1089
+ },
1090
+ "https://example.com",
1091
+ );
1092
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual(["read", "sensitive", "read", "sensitive", "read"]);
1093
+ expect(() =>
1094
+ parseproposal(
1095
+ {
1096
+ version: protocolversion,
1097
+ plan: {
1098
+ objective: "Submit without review",
1099
+ steps: [
1100
+ {
1101
+ kind: "submitform",
1102
+ target: "#checkout",
1103
+ options: '{"consentref":"missing"}',
1104
+ summary: "Submit without asksubmit.",
1105
+ },
1106
+ ],
1107
+ },
1108
+ },
1109
+ "https://example.com",
1110
+ ),
1111
+ ).toThrow("asksubmit");
1112
+ });
1113
+ });
1114
+
1115
+ describe("files, clipboard and downloads protocol", () => {
1116
+ const plan = {
1117
+ id: "p1",
1118
+ objective: "Move files",
1119
+ origin: "https://example.com",
1120
+ steps: [],
1121
+ createdat: 1,
1122
+ expiresat: 2,
1123
+ state: "approved" as const,
1124
+ };
1125
+
1126
+ it("wraps download, netlog and quarantine payloads in the versioned envelopes", () => {
1127
+ const downloads: downloadrecord[] = [
1128
+ {
1129
+ id: "d1",
1130
+ url: "https://example.com/report.pdf",
1131
+ filename: "report.pdf",
1132
+ state: "complete",
1133
+ downloadid: 9,
1134
+ path: "/downloads/report.pdf",
1135
+ bytes: 2048,
1136
+ checksum: "fnv1a-abc",
1137
+ at: 4,
1138
+ updatedat: 5,
1139
+ },
1140
+ { id: "d2", url: "https://example.com/data.zip", filename: "data.zip", state: "paused", at: 6, updatedat: 7 },
1141
+ ];
1142
+ const envelope = JSON.parse(downloadreport({ downloads, plan: { ...plan, state: "approved" } })) as {
1143
+ version: string;
1144
+ planid: string;
1145
+ planstate: string;
1146
+ downloads: downloadrecord[];
1147
+ };
1148
+ expect(envelope).toMatchObject({ version: protocolversion, planid: "p1", planstate: "approved" });
1149
+ expect(envelope.downloads[0]).toMatchObject({
1150
+ state: "complete",
1151
+ path: "/downloads/report.pdf",
1152
+ checksum: "fnv1a-abc",
1153
+ bytes: 2048,
1154
+ });
1155
+ expect(envelope.downloads[1]).toMatchObject({ state: "paused" });
1156
+ const records: netlogrecord[] = [
1157
+ {
1158
+ url: "https://example.com/list",
1159
+ method: "GET",
1160
+ status: 200,
1161
+ timing: 120,
1162
+ requestid: "nav-1-1",
1163
+ stepid: "nav-1",
1164
+ at: 8,
1165
+ },
1166
+ ];
1167
+ expect(netlogreport({ records })).toEqual({ version: protocolversion, records });
1168
+ const entries: quarantineentry[] = [
1169
+ {
1170
+ id: "q1",
1171
+ path: "devthink-quarantine/invoice.pdf",
1172
+ reason: "mime application/pdf matched",
1173
+ scan: "clean",
1174
+ release: "user-9",
1175
+ at: 9,
1176
+ updatedat: 10,
1177
+ },
1178
+ ];
1179
+ expect(quarantinereport({ entries })).toEqual({ version: protocolversion, entries });
1180
+ });
1181
+
1182
+ it("accepts the files vocabulary and grades the sensitive batch and clipboard kinds", () => {
1183
+ const proposal = parseproposal(
1184
+ {
1185
+ version: protocolversion,
1186
+ plan: {
1187
+ objective: "Move the files safely",
1188
+ steps: [
1189
+ {
1190
+ kind: "batchdownload",
1191
+ options:
1192
+ '{"downloadspec":{"urls":["https://example.com/report.pdf","https://example.com/data.zip"],"filename":"report","complete":"size"},"concurrent":3}',
1193
+ summary: "Download the reviewed batch.",
1194
+ },
1195
+ { kind: "pausedownload", value: "report.pdf", summary: "Pause the first download." },
1196
+ { kind: "verifydownload", value: "report.pdf", options: '{"bytes":2048}', summary: "Verify the size." },
1197
+ {
1198
+ kind: "interceptmime",
1199
+ options: '{"mimefilter":{"include":["application/pdf*"],"exclude":[],"default":"deny"}}',
1200
+ summary: "Intercept pdf downloads.",
1201
+ },
1202
+ {
1203
+ kind: "readclipboard",
1204
+ options: '{"consentref":"clip-1","prompt":"Read the tracking number."}',
1205
+ summary: "Read the clipboard once.",
1206
+ },
1207
+ { kind: "writeclipboard", value: "Reviewed summary.", summary: "Write the reviewed text." },
1208
+ { kind: "copyscreen", summary: "Copy the visible tab." },
1209
+ { kind: "exportnetlog", summary: "Export the network log." },
1210
+ { kind: "namecaptures", options: '{"task":"task-1","extension":"png"}', summary: "Stamp capture names." },
1211
+ {
1212
+ kind: "cleanupartifacts",
1213
+ options: '{"rules":[{"age":60000,"kind":"any","keep":"latest"}]}',
1214
+ summary: "Sweep old artifacts.",
1215
+ },
1216
+ ],
1217
+ },
1218
+ },
1219
+ "https://example.com",
1220
+ );
1221
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual([
1222
+ "sensitive",
1223
+ "sensitive",
1224
+ "read",
1225
+ "sensitive",
1226
+ "sensitive",
1227
+ "sensitive",
1228
+ "sensitive",
1229
+ "read",
1230
+ "read",
1231
+ "sensitive",
1232
+ ]);
1233
+ expect(() =>
1234
+ parseproposal(
1235
+ {
1236
+ version: protocolversion,
1237
+ plan: {
1238
+ objective: "Read without consent",
1239
+ steps: [{ kind: "readclipboard", summary: "Read the clipboard without a consent ref." }],
1240
+ },
1241
+ },
1242
+ "https://example.com",
1243
+ ),
1244
+ ).toThrow("consent ref");
1245
+ expect(() =>
1246
+ parseproposal(
1247
+ {
1248
+ version: protocolversion,
1249
+ plan: {
1250
+ objective: "Bad filter",
1251
+ steps: [
1252
+ {
1253
+ kind: "interceptmime",
1254
+ options: '{"mimefilter":{"include":[],"exclude":[],"default":"deny"}}',
1255
+ summary: "Empty include list.",
1256
+ },
1257
+ ],
1258
+ },
1259
+ },
1260
+ "https://example.com",
1261
+ ),
1262
+ ).toThrow("include");
1263
+ });
1264
+ });
1265
+
1266
+ describe("media capture protocol", () => {
1267
+ const plan = {
1268
+ id: "p1",
1269
+ objective: "See the page",
1270
+ origin: "https://example.com",
1271
+ steps: [],
1272
+ createdat: 1,
1273
+ expiresat: 2,
1274
+ state: "approved" as const,
1275
+ };
1276
+
1277
+ it("wraps the capture block into the outcome envelope and carries the capture report", () => {
1278
+ const outcome: stepoutcome = {
1279
+ stepid: "shot-1",
1280
+ ok: true,
1281
+ summary: "Captured the visible viewport.",
1282
+ details: { capture: { id: "cap-1", kind: "shotview", format: "png", width: 1280, height: 800, bytes: 5200 } },
1283
+ at: 3,
1284
+ };
1285
+ const envelope = JSON.parse(
1286
+ outcomeresponse({ outcome, plan, capture: { id: "cap-1", format: "png", bytes: 5200 } }),
1287
+ ) as { version: string; capture: { id: string; format: string; bytes: number } };
1288
+ expect(envelope).toMatchObject({ version: protocolversion, planid: "p1", planstate: "approved" });
1289
+ expect(envelope.capture).toEqual({ id: "cap-1", format: "png", bytes: 5200 });
1290
+ const plain = JSON.parse(outcomeresponse({ outcome, plan })) as { capture?: unknown };
1291
+ expect(plain.capture).toBeUndefined();
1292
+ const records: shotrecord[] = [
1293
+ {
1294
+ id: "cap-1",
1295
+ runid: "p1",
1296
+ stepid: "shot-1",
1297
+ kind: "shotview",
1298
+ format: "png",
1299
+ width: 1280,
1300
+ height: 800,
1301
+ capturedat: 3,
1302
+ bytes: "data:image/png;base64,xyz",
1303
+ },
1304
+ {
1305
+ id: "cap-2",
1306
+ runid: "p1",
1307
+ stepid: "shot-2",
1308
+ kind: "contactsheet",
1309
+ format: "webp",
1310
+ width: 960,
1311
+ height: 536,
1312
+ capturedat: 4,
1313
+ annotated: true,
1314
+ },
1315
+ ];
1316
+ const pairs: shotpair[] = [
1317
+ {
1318
+ id: "pair-1",
1319
+ beforeid: "cap-1",
1320
+ afterid: "cap-2",
1321
+ actionkind: "click",
1322
+ target: "#submit",
1323
+ domsnapshotid: "7",
1324
+ at: 5,
1325
+ },
1326
+ ];
1327
+ expect(capturereport({ records, pairs })).toEqual({ version: protocolversion, records, pairs });
1328
+ });
1329
+
1330
+ it("accepts the capture vocabulary, refuses unknown formats and negative region coordinates", () => {
1331
+ const proposal = parseproposal(
1332
+ {
1333
+ version: protocolversion,
1334
+ plan: {
1335
+ objective: "See the page with review",
1336
+ steps: [
1337
+ {
1338
+ kind: "shotview",
1339
+ options: '{"capture":{"format":"png","pixelratio":2}}',
1340
+ summary: "Shoot the viewport.",
1341
+ },
1342
+ {
1343
+ kind: "shotfullpage",
1344
+ options: '{"settle":150,"overlap":40,"wait":60000}',
1345
+ summary: "Shoot the full page.",
1346
+ },
1347
+ {
1348
+ kind: "shotelement",
1349
+ target: "#card",
1350
+ options: '{"capture":{"format":"webp","exporttarget":"clipboard"}}',
1351
+ summary: "Shoot the card.",
1352
+ },
1353
+ {
1354
+ kind: "shotregion",
1355
+ options: '{"regionrect":{"x":10,"y":20,"width":400,"height":300},"reviewed":true}',
1356
+ summary: "Shoot the reviewed region.",
1357
+ },
1358
+ {
1359
+ kind: "contactsheet",
1360
+ options: '{"elements":["#a","#b"],"sheet":{"cellsize":240,"columns":3,"label":"both"}}',
1361
+ summary: "Tile the cards.",
1362
+ },
1363
+ ],
1364
+ },
1365
+ },
1366
+ "https://example.com",
1367
+ );
1368
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual(["read", "read", "read", "read", "read"]);
1369
+ expect(proposal.plan.steps[2]?.target).toBe("#card");
1370
+ expect(() =>
1371
+ parseproposal(
1372
+ {
1373
+ version: protocolversion,
1374
+ plan: {
1375
+ objective: "Bad format",
1376
+ steps: [{ kind: "shotview", options: '{"capture":{"format":"gif"}}', summary: "Shoot with a bad format." }],
1377
+ },
1378
+ },
1379
+ "https://example.com",
1380
+ ),
1381
+ ).toThrow("png, jpeg or webp");
1382
+ expect(() =>
1383
+ parseproposal(
1384
+ {
1385
+ version: protocolversion,
1386
+ plan: {
1387
+ objective: "Negative region",
1388
+ steps: [
1389
+ {
1390
+ kind: "shotregion",
1391
+ options: '{"regionrect":{"x":-1,"y":0,"width":40,"height":30},"reviewed":true}',
1392
+ summary: "Shoot a negative region.",
1393
+ },
1394
+ ],
1395
+ },
1396
+ },
1397
+ "https://example.com",
1398
+ ),
1399
+ ).toThrow("negative coordinates");
1400
+ expect(() =>
1401
+ parseproposal(
1402
+ {
1403
+ version: protocolversion,
1404
+ plan: {
1405
+ objective: "Unreviewed region",
1406
+ steps: [
1407
+ {
1408
+ kind: "shotregion",
1409
+ options: '{"regionrect":{"x":0,"y":0,"width":40,"height":30}}',
1410
+ summary: "Shoot an unreviewed region.",
1411
+ },
1412
+ ],
1413
+ },
1414
+ },
1415
+ "https://example.com",
1416
+ ),
1417
+ ).toThrow("reviewed flag");
1418
+ });
1419
+ });
1420
+
1421
+ describe("media capture part two protocol", () => {
1422
+ const plan = {
1423
+ id: "p1",
1424
+ objective: "See and hear the page",
1425
+ origin: "https://example.com",
1426
+ steps: [],
1427
+ createdat: 1,
1428
+ expiresat: 2,
1429
+ state: "approved" as const,
1430
+ };
1431
+
1432
+ it("wraps the media block into the outcome envelope and carries the media report", () => {
1433
+ const outcome: stepoutcome = {
1434
+ stepid: "pdf-1",
1435
+ ok: true,
1436
+ summary: "Composed a derived pdf report.",
1437
+ details: { media: { id: "pdf-1", kind: "pdf", bytes: 4200 } },
1438
+ at: 3,
1439
+ };
1440
+ const envelope = JSON.parse(
1441
+ outcomeresponse({ outcome, plan, media: { id: "pdf-1", kind: "pdf", bytes: 4200 } }),
1442
+ ) as { version: string; media: { id: string; kind: string; bytes: number } };
1443
+ expect(envelope).toMatchObject({ version: protocolversion, planid: "p1", planstate: "approved" });
1444
+ expect(envelope.media).toEqual({ id: "pdf-1", kind: "pdf", bytes: 4200 });
1445
+ const plain = JSON.parse(outcomeresponse({ outcome, plan })) as { media?: unknown };
1446
+ expect(plain.media).toBeUndefined();
1447
+ const records: mediarecord[] = [
1448
+ {
1449
+ id: "pdf-1",
1450
+ runid: "p1",
1451
+ stepid: "pdf-1",
1452
+ pages: 3,
1453
+ pagewidth: 612,
1454
+ pageheight: 792,
1455
+ margins: { top: 28.8, right: 28.8, bottom: 28.8, left: 28.8 },
1456
+ landscape: false,
1457
+ scale: 1,
1458
+ bytes: 4200,
1459
+ at: 3,
1460
+ },
1461
+ {
1462
+ id: "rec-1",
1463
+ runid: "p1",
1464
+ stepid: "rec-1",
1465
+ tabid: 4,
1466
+ kind: "screen",
1467
+ scope: "run",
1468
+ format: "frames",
1469
+ startedat: 4,
1470
+ endedat: 9,
1471
+ duration: 5,
1472
+ fps: 2,
1473
+ frames: ["f1", "f2"],
1474
+ at: 4,
1475
+ },
1476
+ ];
1477
+ const images: imagebatch[] = [
1478
+ {
1479
+ id: "batch-1",
1480
+ runid: "p1",
1481
+ stepid: "img-1",
1482
+ images: [
1483
+ { url: "https://example.com/a.png", alt: "Chart", width: 400, height: 300, bytes: 1200, mime: "image/png" },
1484
+ ],
1485
+ matched: 1,
1486
+ downloaded: 1,
1487
+ at: 5,
1488
+ },
1489
+ ];
1490
+ expect(mediareport({ records, images })).toEqual({ version: protocolversion, records, images });
1491
+ });
1492
+
1493
+ it("accepts the media vocabulary and rejects negative margins, bad scopes and negative intervals", () => {
1494
+ const proposal = parseproposal(
1495
+ {
1496
+ version: protocolversion,
1497
+ plan: {
1498
+ objective: "Capture media with review",
1499
+ steps: [
1500
+ {
1501
+ kind: "capturepdf",
1502
+ options:
1503
+ '{"pdf":{"paperwidth":8.5,"paperheight":11,"margins":{"top":0.4,"right":0.4,"bottom":0.4,"left":0.4},"scale":1,"paginate":true},"breakpoints":["#section-2"],"exporttarget":"memory"}',
1504
+ summary: "Print the report.",
1505
+ },
1506
+ {
1507
+ kind: "recordscreen",
1508
+ options: '{"recording":{"scope":"run","fps":2},"duration":1000,"consentref":"c1"}',
1509
+ summary: "Record the run.",
1510
+ },
1511
+ {
1512
+ kind: "captureaudio",
1513
+ options: '{"recording":{"audio":true},"duration":1000,"consentref":"c2"}',
1514
+ summary: "Record the audio.",
1515
+ },
1516
+ {
1517
+ kind: "captureframe",
1518
+ target: "video.tutorial",
1519
+ options: '{"timestamp":5,"poster":true}',
1520
+ summary: "Grab the frame.",
1521
+ },
1522
+ {
1523
+ kind: "downloadimages",
1524
+ options: '{"imagefilter":{"selector":"main img","minwidth":200}}',
1525
+ summary: "Download the images.",
1526
+ },
1527
+ {
1528
+ kind: "shotcanvas",
1529
+ target: "canvas.chart",
1530
+ options: '{"capture":{"format":"webp"}}',
1531
+ summary: "Shoot the canvas.",
1532
+ },
1533
+ { kind: "probestream", summary: "Probe the streams." },
1534
+ { kind: "readmedia", summary: "Read the sources." },
1535
+ { kind: "readassets", summary: "Read the assets." },
1536
+ {
1537
+ kind: "timelapse",
1538
+ options: '{"lapse":{"interval":500,"duration":5000},"wait":6000}',
1539
+ summary: "Shoot the lapse.",
1540
+ },
1541
+ {
1542
+ kind: "convertimage",
1543
+ options: '{"capture":"cap-1","convert":{"target":"webp","quality":85}}',
1544
+ summary: "Convert the capture.",
1545
+ },
1546
+ {
1547
+ kind: "makethumbs",
1548
+ options: '{"captures":["cap-1"],"thumb":{"size":240,"fit":"cover","suffix":"thumb"}}',
1549
+ summary: "Thumb the captures.",
1550
+ },
1551
+ ],
1552
+ },
1553
+ },
1554
+ "https://example.com",
1555
+ );
1556
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual([
1557
+ "read",
1558
+ "sensitive",
1559
+ "sensitive",
1560
+ "read",
1561
+ "sensitive",
1562
+ "read",
1563
+ "read",
1564
+ "read",
1565
+ "read",
1566
+ "read",
1567
+ "read",
1568
+ "read",
1569
+ ]);
1570
+ expect(proposal.plan.steps[3]?.target).toBe("video.tutorial");
1571
+ expect(() =>
1572
+ parseproposal(
1573
+ {
1574
+ version: protocolversion,
1575
+ plan: {
1576
+ objective: "Negative margins",
1577
+ steps: [
1578
+ {
1579
+ kind: "capturepdf",
1580
+ options: '{"pdf":{"margins":{"left":-0.2}}}',
1581
+ summary: "Print with negative margins.",
1582
+ },
1583
+ ],
1584
+ },
1585
+ },
1586
+ "https://example.com",
1587
+ ),
1588
+ ).toThrow("negative margins are refused");
1589
+ expect(() =>
1590
+ parseproposal(
1591
+ {
1592
+ version: protocolversion,
1593
+ plan: {
1594
+ objective: "Bad scope",
1595
+ steps: [
1596
+ {
1597
+ kind: "recordscreen",
1598
+ options: '{"recording":{"scope":"window"},"consentref":"c1"}',
1599
+ summary: "Record with a bad scope.",
1600
+ },
1601
+ ],
1602
+ },
1603
+ },
1604
+ "https://example.com",
1605
+ ),
1606
+ ).toThrow("scope must be tab or run");
1607
+ expect(() =>
1608
+ parseproposal(
1609
+ {
1610
+ version: protocolversion,
1611
+ plan: {
1612
+ objective: "Negative interval",
1613
+ steps: [
1614
+ {
1615
+ kind: "timelapse",
1616
+ options: '{"lapse":{"interval":-500,"duration":5000}}',
1617
+ summary: "Shoot a negative interval.",
1618
+ },
1619
+ ],
1620
+ },
1621
+ },
1622
+ "https://example.com",
1623
+ ),
1624
+ ).toThrow("interval");
1625
+ expect(() =>
1626
+ parseproposal(
1627
+ {
1628
+ version: protocolversion,
1629
+ plan: {
1630
+ objective: "Recording without consent",
1631
+ steps: [
1632
+ { kind: "captureaudio", options: '{"recording":{"audio":true}}', summary: "Record without consent." },
1633
+ ],
1634
+ },
1635
+ },
1636
+ "https://example.com",
1637
+ ),
1638
+ ).toThrow("consent ref");
1639
+ expect(() =>
1640
+ parseproposal(
1641
+ {
1642
+ version: protocolversion,
1643
+ plan: {
1644
+ objective: "Lapse over budget",
1645
+ steps: [
1646
+ {
1647
+ kind: "timelapse",
1648
+ options: '{"lapse":{"interval":500,"duration":9000},"wait":5000}',
1649
+ summary: "Shoot past the budget.",
1650
+ },
1651
+ ],
1652
+ },
1653
+ },
1654
+ "https://example.com",
1655
+ ),
1656
+ ).toThrow("exceeds the reviewed wait budget");
1657
+ });
1658
+ });
1659
+
1660
+ describe("network observation part one protocol", () => {
1661
+ const plan = {
1662
+ id: "p1",
1663
+ objective: "Speak to the network under review",
1664
+ origin: "https://example.com",
1665
+ steps: [],
1666
+ createdat: 1,
1667
+ expiresat: 2,
1668
+ state: "approved" as const,
1669
+ };
1670
+
1671
+ it("wraps the transport block into the outcome envelope and carries the calls report without bodies", () => {
1672
+ const outcome: stepoutcome = {
1673
+ stepid: "f1",
1674
+ ok: true,
1675
+ summary: "Fetched the reviewed url.",
1676
+ details: { transport: { status: 200, headers: ["content-type"], bytes: 512, duration: 340 }, retries: 1 },
1677
+ at: 3,
1678
+ };
1679
+ const envelope = JSON.parse(
1680
+ outcomeresponse({
1681
+ outcome,
1682
+ plan,
1683
+ transport: { status: 200, headers: ["content-type"], bytes: 512, duration: 340 },
1684
+ }),
1685
+ ) as { version: string; transport: { status: number; headers: string[]; bytes: number; duration: number } };
1686
+ expect(envelope).toMatchObject({ version: protocolversion, planid: "p1", planstate: "approved" });
1687
+ expect(envelope.transport).toEqual({ status: 200, headers: ["content-type"], bytes: 512, duration: 340 });
1688
+ const plain = JSON.parse(outcomeresponse({ outcome, plan })) as { transport?: unknown };
1689
+ expect(plain.transport).toBeUndefined();
1690
+ const calls: callrecord[] = [
1691
+ {
1692
+ id: "c1",
1693
+ runid: "p1",
1694
+ stepid: "f1",
1695
+ kind: "fetch",
1696
+ url: "https://api.example/data",
1697
+ origin: "https://api.example",
1698
+ method: "GET",
1699
+ status: 200,
1700
+ statusclass: "success",
1701
+ duration: 120,
1702
+ retries: 1,
1703
+ bytes: 512,
1704
+ headernames: ["x-request-id"],
1705
+ body: '{"secret":"no"}',
1706
+ at: 4,
1707
+ },
1708
+ {
1709
+ id: "c2",
1710
+ runid: "p1",
1711
+ stepid: "g1",
1712
+ kind: "graphql",
1713
+ url: "https://api.example/graphql",
1714
+ origin: "https://api.example",
1715
+ method: "POST",
1716
+ status: 200,
1717
+ statusclass: "success",
1718
+ duration: 80,
1719
+ retries: 0,
1720
+ bytes: 64,
1721
+ headernames: [],
1722
+ errors: ["auth required"],
1723
+ at: 5,
1724
+ },
1725
+ ];
1726
+ const report = callsreport({ calls });
1727
+ expect(report.version).toBe(protocolversion);
1728
+ expect(report.calls).toHaveLength(2);
1729
+ expect(report.calls[0]).toMatchObject({ id: "c1", statusclass: "success", retries: 1 });
1730
+ expect(report.calls[0]?.streambytes).toBeUndefined();
1731
+ expect(report.calls[0]?.body).toBeUndefined();
1732
+ expect(report.calls[1]).toMatchObject({ kind: "graphql", errors: ["auth required"] });
1733
+ });
1734
+
1735
+ it("rejects fetch requests to ungranted origins and header allowlists with empty names", () => {
1736
+ const proposal = parseproposal(
1737
+ {
1738
+ version: protocolversion,
1739
+ plan: {
1740
+ objective: "Fetch under review",
1741
+ steps: [
1742
+ {
1743
+ kind: "fetchurl",
1744
+ options: '{"fetch":{"url":"https://example.com/data"}}',
1745
+ summary: "Fetch the granted origin.",
1746
+ },
1747
+ { kind: "callrest", options: '{"endpoint":"issues"}', summary: "Call the typed endpoint." },
1748
+ ],
1749
+ },
1750
+ },
1751
+ "https://example.com",
1752
+ );
1753
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual(["read", "sensitive"]);
1754
+ expect(() =>
1755
+ parseproposal(
1756
+ {
1757
+ version: protocolversion,
1758
+ plan: {
1759
+ objective: "Fetch outside",
1760
+ steps: [
1761
+ {
1762
+ kind: "fetchurl",
1763
+ options: '{"fetch":{"url":"https://api.example/data"}}',
1764
+ summary: "Fetch an ungranted origin.",
1765
+ },
1766
+ ],
1767
+ },
1768
+ },
1769
+ "https://example.com",
1770
+ ),
1771
+ ).toThrow("outside the grants");
1772
+ const granted = parseproposal(
1773
+ {
1774
+ version: protocolversion,
1775
+ plan: {
1776
+ objective: "Fetch the granted api",
1777
+ steps: [
1778
+ {
1779
+ kind: "fetchurl",
1780
+ options: '{"fetch":{"url":"https://api.example/data"}}',
1781
+ summary: "Fetch the granted api origin.",
1782
+ },
1783
+ ],
1784
+ },
1785
+ },
1786
+ "https://example.com",
1787
+ ["https://example.com", "https://api.example"],
1788
+ );
1789
+ expect(granted.plan.steps).toHaveLength(1);
1790
+ expect(() =>
1791
+ parseproposal(
1792
+ {
1793
+ version: protocolversion,
1794
+ plan: {
1795
+ objective: "Empty header name",
1796
+ steps: [
1797
+ {
1798
+ kind: "fetchurl",
1799
+ options: '{"fetch":{"url":"https://example.com/data","headers":{"":"v"}}}',
1800
+ summary: "Fetch with an empty header name.",
1801
+ },
1802
+ ],
1803
+ },
1804
+ },
1805
+ "https://example.com",
1806
+ ),
1807
+ ).toThrow("empty names");
1808
+ });
1809
+
1810
+ it("accepts the http options grammar with stream budgets, path rules, queries and graphql operations", () => {
1811
+ const proposal = parseproposal(
1812
+ {
1813
+ version: protocolversion,
1814
+ plan: {
1815
+ objective: "Observe the network",
1816
+ steps: [
1817
+ {
1818
+ kind: "fetchurl",
1819
+ options:
1820
+ '{"fetch":{"url":"https://example.com/data","method":"POST","headers":{"x-request-id":"42"},"body":"{}","mode":"cors"},"fetchoptions":{"timeout":1000,"retries":2,"backoff":100,"follow":3},"stream":{"budget":4096},"wait":5000,"consentref":"c1"}',
1821
+ summary: "Fetch a reviewed url.",
1822
+ },
1823
+ {
1824
+ kind: "parsejson",
1825
+ options: '{"call":"c1","fields":[{"name":"title","path":"data.title","kind":"text"}]}',
1826
+ summary: "Parse the fetched body.",
1827
+ },
1828
+ {
1829
+ kind: "parsehtml",
1830
+ options: '{"call":"c1","queries":[{"selector":"a.link","attribute":"href","multi":true}]}',
1831
+ summary: "Query the fetched markup.",
1832
+ },
1833
+ {
1834
+ kind: "callgraphql",
1835
+ options: '{"endpoint":"graph","graphql":{"query":"query{hero}","operationkind":"query"}}',
1836
+ summary: "Run the reviewed query.",
1837
+ },
1838
+ ],
1839
+ },
1840
+ },
1841
+ "https://example.com",
1842
+ );
1843
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual(["read", "read", "read", "sensitive"]);
1844
+ expect(() =>
1845
+ parseproposal(
1846
+ {
1847
+ version: protocolversion,
1848
+ plan: {
1849
+ objective: "Bad path",
1850
+ steps: [
1851
+ {
1852
+ kind: "parsejson",
1853
+ options: '{"call":"c1","fields":[{"name":"a","path":"bad..path"}]}',
1854
+ summary: "Parse a bad path.",
1855
+ },
1856
+ ],
1857
+ },
1858
+ },
1859
+ "https://example.com",
1860
+ ),
1861
+ ).toThrow("dotted path");
1862
+ });
1863
+ });
1864
+
1865
+ describe("network observation part two protocol", () => {
1866
+ const now = 1_800_000_000_000;
1867
+ const origin = "https://example.com";
1868
+ const version = protocolversion;
1869
+
1870
+ it("wraps step outcomes with the network block of exchange counts and channel state", () => {
1871
+ const plan: agentplan = {
1872
+ id: "plan",
1873
+ objective: "Observe",
1874
+ origin,
1875
+ steps: [{ id: "s1", kind: "watchrequests", summary: "Watch.", risk: "read" }],
1876
+ createdat: now,
1877
+ expiresat: now + 1000,
1878
+ state: "approved",
1879
+ };
1880
+ const outcome: stepoutcome = {
1881
+ stepid: "s1",
1882
+ ok: true,
1883
+ summary: "Observed 3 requests.",
1884
+ details: { network: { exchanges: 3, channelstate: "none", messages: 0 } },
1885
+ at: now,
1886
+ };
1887
+ const parsed = JSON.parse(
1888
+ outcomeresponse({ outcome, plan, network: { exchanges: 3, channelstate: "none", messages: 0 } }),
1889
+ ) as { network?: { exchanges: number; channelstate: string; messages: number } };
1890
+ expect(parsed.network).toEqual({ exchanges: 3, channelstate: "none", messages: 0 });
1891
+ const bare = JSON.parse(outcomeresponse({ outcome, plan })) as { network?: unknown };
1892
+ expect(bare.network).toBeUndefined();
1893
+ });
1894
+
1895
+ it("carries every exchange, channel, subscription and api map entry in the exchanges report", () => {
1896
+ const exchange: exchangerecord = {
1897
+ id: "e1",
1898
+ runid: "run",
1899
+ stepid: "s1",
1900
+ correlationid: "run-1",
1901
+ url: "https://api.example/items",
1902
+ origin: "https://api.example",
1903
+ method: "GET",
1904
+ status: 200,
1905
+ statusclass: "success",
1906
+ source: "extension",
1907
+ timing: 42,
1908
+ bytes: 128,
1909
+ mime: "application/json",
1910
+ bodyref: "body1",
1911
+ at: now,
1912
+ };
1913
+ const channel: channelrecord = {
1914
+ id: "ch1",
1915
+ runid: "run",
1916
+ stepid: "s2",
1917
+ kind: "websocket",
1918
+ url: "wss://api.example/live",
1919
+ origin: "https://api.example",
1920
+ state: "open",
1921
+ openedat: now,
1922
+ sent: 2,
1923
+ received: 5,
1924
+ reconnects: 1,
1925
+ };
1926
+ const subscription: eventsubscription = {
1927
+ id: "sub1",
1928
+ runid: "run",
1929
+ stepid: "s3",
1930
+ url: "https://api.example/stream",
1931
+ origin: "https://api.example",
1932
+ state: "open",
1933
+ events: 9,
1934
+ names: ["userjoin"],
1935
+ lasteventid: "41",
1936
+ cancel: { kind: "stop", value: "done" },
1937
+ openedat: now,
1938
+ };
1939
+ const apimap: apimapentry = {
1940
+ endpoint: "https://api.example/items",
1941
+ method: "GET",
1942
+ mime: "application/json",
1943
+ frequency: 3,
1944
+ payloadshape: ["id"],
1945
+ jsonshare: 1,
1946
+ stability: 1,
1947
+ origin: "https://api.example",
1948
+ correlationids: ["run-1"],
1949
+ };
1950
+ const report = exchangesreport({
1951
+ exchanges: [exchange],
1952
+ channels: [channel],
1953
+ subscriptions: [subscription],
1954
+ apimap: [apimap],
1955
+ });
1956
+ expect(report.version).toBe(version);
1957
+ expect(report.exchanges[0]?.correlationid).toBe("run-1");
1958
+ expect(report.channels[0]).toMatchObject({ state: "open", sent: 2, received: 5 });
1959
+ expect(report.subscriptions[0]).toMatchObject({ events: 9, lasteventid: "41" });
1960
+ expect(report.apimap[0]).toMatchObject({ frequency: 3 });
1961
+ });
1962
+
1963
+ it("rejects channel urls outside the grants, subscriptions without cancellation and lifetimes beyond the plan window", () => {
1964
+ const planof = (steps: Array<Record<string, unknown>>, expiresat?: number): unknown => ({
1965
+ version,
1966
+ plan: { objective: "Observe", origin, steps, ...(expiresat !== undefined ? { expiresat } : {}) },
1967
+ });
1968
+ const stepinput = (kind: string, options: Record<string, unknown>): Record<string, unknown> => ({
1969
+ id: "s1",
1970
+ kind,
1971
+ summary: "Observe.",
1972
+ options: JSON.stringify(options),
1973
+ });
1974
+ const socket = parseproposal(
1975
+ planof([stepinput("opensocket", { socket: { url: "wss://api.example/live" } })]) as Record<string, unknown>,
1976
+ origin,
1977
+ [origin, "https://api.example"],
1978
+ );
1979
+ expect(socket.plan.steps[0]?.risk).toBe("read");
1980
+ expect(() =>
1981
+ parseproposal(
1982
+ planof([stepinput("opensocket", { socket: { url: "wss://other.example/live" } })]) as Record<string, unknown>,
1983
+ origin,
1984
+ [origin],
1985
+ ),
1986
+ ).toThrow("outside the grants");
1987
+ expect(() =>
1988
+ parseproposal(
1989
+ planof([
1990
+ stepinput("subscribesse", {
1991
+ subscription: { url: "https://other.example/stream", cancel: { kind: "stop", value: "done" } },
1992
+ }),
1993
+ ]) as Record<string, unknown>,
1994
+ origin,
1995
+ [origin],
1996
+ ),
1997
+ ).toThrow("outside the grants");
1998
+ expect(() =>
1999
+ parseproposal(
2000
+ planof([stepinput("subscribesse", { subscription: { url: "https://api.example/stream" } })]) as Record<
2001
+ string,
2002
+ unknown
2003
+ >,
2004
+ origin,
2005
+ ["https://api.example"],
2006
+ ),
2007
+ ).toThrow("cancellation path");
2008
+ expect(() =>
2009
+ parseproposal(
2010
+ planof([
2011
+ stepinput("longpoll", { poll: { url: "https://api.example/poll", cursorfield: "c", interval: 50 } }),
2012
+ ]) as Record<string, unknown>,
2013
+ origin,
2014
+ ["https://api.example"],
2015
+ ),
2016
+ ).toThrow("stop condition");
2017
+ expect(() =>
2018
+ parseproposal(
2019
+ planof(
2020
+ [stepinput("opensocket", { socket: { url: "wss://api.example/live", lifetime: 60_000 } })],
2021
+ Date.now() + 5000,
2022
+ ),
2023
+ origin,
2024
+ ["https://api.example"],
2025
+ ),
2026
+ ).toThrow("exceeds the reviewed plan window");
2027
+ expect(() =>
2028
+ parseproposal(
2029
+ planof(
2030
+ [
2031
+ stepinput("subscribesse", {
2032
+ subscription: {
2033
+ url: "https://api.example/stream",
2034
+ lifetime: 60_000,
2035
+ cancel: { kind: "lifetime", value: 60_000 },
2036
+ },
2037
+ }),
2038
+ ],
2039
+ Date.now() + 5000,
2040
+ ),
2041
+ origin,
2042
+ ["https://api.example"],
2043
+ ),
2044
+ ).toThrow("exceeds the reviewed plan window");
2045
+ expect(() =>
2046
+ parseproposal(
2047
+ planof(
2048
+ [stepinput("opensocket", { socket: { url: "wss://api.example/live", lifetime: 4000 } })],
2049
+ Date.now() + 5000,
2050
+ ),
2051
+ origin,
2052
+ ["https://api.example"],
2053
+ ),
2054
+ ).not.toThrow();
2055
+ });
2056
+
2057
+ it("grades conditional capturebodies and extractapi steps through the resolved risk", () => {
2058
+ const stepinput = (kind: string, options: Record<string, unknown>): Record<string, unknown> => ({
2059
+ id: "s1",
2060
+ kind,
2061
+ summary: "Observe.",
2062
+ options: JSON.stringify(options),
2063
+ });
2064
+ const privatebodies = parseproposal(
2065
+ {
2066
+ version,
2067
+ plan: {
2068
+ objective: "Capture",
2069
+ origin,
2070
+ steps: [stepinput("capturebodies", { body: { mimes: ["application/json"], ceiling: 100 } })],
2071
+ },
2072
+ },
2073
+ origin,
2074
+ [origin],
2075
+ );
2076
+ expect(privatebodies.plan.steps[0]?.risk).toBe("sensitive");
2077
+ const publicbodies = parseproposal(
2078
+ {
2079
+ version,
2080
+ plan: {
2081
+ objective: "Capture",
2082
+ origin,
2083
+ steps: [stepinput("capturebodies", { body: { mimes: ["image/png"], ceiling: 100 } })],
2084
+ },
2085
+ },
2086
+ origin,
2087
+ [origin],
2088
+ );
2089
+ expect(publicbodies.plan.steps[0]?.risk).toBe("interaction");
2090
+ const readreplay = parseproposal(
2091
+ {
2092
+ version,
2093
+ plan: {
2094
+ objective: "Replay",
2095
+ origin,
2096
+ steps: [stepinput("extractapi", { replay: { endpoint: "https://api.example/items", verb: "GET" } })],
2097
+ },
2098
+ },
2099
+ origin,
2100
+ [origin, "https://api.example"],
2101
+ );
2102
+ expect(readreplay.plan.steps[0]?.risk).toBe("read");
2103
+ const mutreplay = parseproposal(
2104
+ {
2105
+ version,
2106
+ plan: {
2107
+ objective: "Replay",
2108
+ origin,
2109
+ steps: [stepinput("extractapi", { replay: { endpoint: "https://api.example/items", verb: "POST" } })],
2110
+ },
2111
+ },
2112
+ origin,
2113
+ [origin, "https://api.example"],
2114
+ );
2115
+ expect(mutreplay.plan.steps[0]?.risk).toBe("sensitive");
2116
+ const send = parseproposal(
2117
+ {
2118
+ version,
2119
+ plan: {
2120
+ objective: "Send",
2121
+ origin,
2122
+ steps: [stepinput("sendmessage", { message: { channel: "ch1", payload: "x" } })],
2123
+ },
2124
+ },
2125
+ origin,
2126
+ [origin],
2127
+ );
2128
+ expect(send.plan.steps[0]?.risk).toBe("sensitive");
2129
+ });
2130
+ });
2131
+
2132
+ describe("network control protocol", () => {
2133
+ const plan: agentplan = {
2134
+ id: "plan",
2135
+ objective: "Control traffic",
2136
+ origin: "https://example.com",
2137
+ steps: [],
2138
+ createdat: 1,
2139
+ expiresat: 2,
2140
+ state: "approved",
2141
+ };
2142
+ const outcome: stepoutcome = { stepid: "s1", ok: true, summary: "Applied the reviewed rules.", at: 3 };
2143
+
2144
+ it("attaches the control block with applied rule counts to the response envelope", () => {
2145
+ const envelope = JSON.parse(outcomeresponse({ outcome, plan, control: { applied: 3, blocked: 2, mocked: 1 } }));
2146
+ expect(envelope.control).toEqual({ applied: 3, blocked: 2, mocked: 1 });
2147
+ const bare = JSON.parse(outcomeresponse({ outcome, plan }));
2148
+ expect(bare.control).toBeUndefined();
2149
+ });
2150
+
2151
+ it("carries token scopes without token values in the auth report", () => {
2152
+ const report = authreport({
2153
+ tokens: [
2154
+ {
2155
+ id: "t1",
2156
+ provider: "providerco",
2157
+ origin: "https://api.example",
2158
+ scopes: ["read"],
2159
+ accessstorageid: "token-t1-access",
2160
+ expiresat: 99,
2161
+ at: 1,
2162
+ },
2163
+ ],
2164
+ });
2165
+ expect(report.tokens[0]).toMatchObject({ id: "t1", provider: "providerco", scopes: ["read"] });
2166
+ expect(JSON.stringify(report)).not.toContain("token-t1-access");
2167
+ });
2168
+
2169
+ it("carries the traffic control state with mock bodies held back", () => {
2170
+ const report = controlreport({
2171
+ blocks: [
2172
+ { id: "b1", runid: "run1", stepid: "s1", urlpattern: "https://ads.example/*", hits: 2, registeredat: 1 },
2173
+ ],
2174
+ mocks: [
2175
+ {
2176
+ id: "m1",
2177
+ runid: "run1",
2178
+ stepid: "s1",
2179
+ urlpattern: "https://api.example/status",
2180
+ status: 204,
2181
+ body: "fixture-body",
2182
+ reviewed: true,
2183
+ hits: 1,
2184
+ registeredat: 1,
2185
+ },
2186
+ ],
2187
+ rewrites: [
2188
+ {
2189
+ id: "h1",
2190
+ runid: "run1",
2191
+ stepid: "s1",
2192
+ urlpattern: "https://api.example/*",
2193
+ name: "accept",
2194
+ operation: "set",
2195
+ value: "json",
2196
+ hits: 4,
2197
+ registeredat: 1,
2198
+ },
2199
+ ],
2200
+ cookies: [
2201
+ { id: "c1", runid: "run1", stepid: "s1", kind: "write", domain: "example.com", names: ["session"], at: 1 },
2202
+ ],
2203
+ proxies: [
2204
+ {
2205
+ id: "p1",
2206
+ runid: "run1",
2207
+ stepid: "s1",
2208
+ scheme: "socks5",
2209
+ host: "proxy.example",
2210
+ port: 1080,
2211
+ bypass: ["https://api.example"],
2212
+ appliedat: 1,
2213
+ },
2214
+ ],
2215
+ ratelimits: [{ origin: "https://api.example", remaining: 3, limit: 10, resetat: 50, at: 1 }],
2216
+ });
2217
+ expect(report.blocks[0]).toMatchObject({ hits: 2 });
2218
+ expect(report.mocks[0]).toMatchObject({ status: 204, hits: 1 });
2219
+ expect(report.mocks[0]?.body).toBeUndefined();
2220
+ expect(JSON.stringify(report)).not.toContain("fixture-body");
2221
+ expect(report.cookies[0]).toMatchObject({ kind: "write" });
2222
+ expect(report.ratelimits[0]).toMatchObject({ remaining: 3 });
2223
+ });
2224
+
2225
+ it("rejects block rules without an origin pattern and proxy routes without a bypass list", () => {
2226
+ expect(() =>
2227
+ parseproposal(
2228
+ {
2229
+ version: protocolversion,
2230
+ plan: {
2231
+ objective: "Block",
2232
+ steps: [
2233
+ {
2234
+ kind: "blockrequest",
2235
+ summary: "Block",
2236
+ options: JSON.stringify({ block: { urlpattern: "ads.example/*", reviewed: true } }),
2237
+ },
2238
+ ],
2239
+ },
2240
+ },
2241
+ "https://example.com",
2242
+ ),
2243
+ ).toThrow("Block rules without a named origin pattern are refused.");
2244
+ expect(() =>
2245
+ parseproposal(
2246
+ {
2247
+ version: protocolversion,
2248
+ plan: {
2249
+ objective: "Proxy",
2250
+ steps: [
2251
+ {
2252
+ kind: "routeproxy",
2253
+ summary: "Route",
2254
+ options: JSON.stringify({
2255
+ consentref: "c",
2256
+ proxy: { scheme: "socks5", host: "proxy.example", port: 1080, bypass: [] },
2257
+ }),
2258
+ },
2259
+ ],
2260
+ },
2261
+ },
2262
+ "https://example.com",
2263
+ ),
2264
+ ).toThrow("Proxy routes without a bypass list are refused.");
2265
+ });
2266
+ });
2267
+
2268
+ describe("debugging protocol", () => {
2269
+ const plan = {
2270
+ id: "plan",
2271
+ objective: "Debug the page",
2272
+ origin: "https://example.com",
2273
+ steps: [],
2274
+ createdat: 1,
2275
+ expiresat: 2,
2276
+ state: "approved" as const,
2277
+ };
2278
+
2279
+ it("attaches the timeline block to the outcome envelope and carries the timeline and console diff reports", () => {
2280
+ const outcome: stepoutcome = {
2281
+ stepid: "w1",
2282
+ ok: true,
2283
+ summary: "Captured 4 console calls.",
2284
+ details: { errorids: ["e1"], rejectionids: [] },
2285
+ at: 5,
2286
+ };
2287
+ const envelope = JSON.parse(
2288
+ outcomeresponse({
2289
+ outcome,
2290
+ plan,
2291
+ timeline: { entries: 4, levels: { error: 1, warn: 1, info: 2, log: 0, debug: 0, trace: 0 }, collapsed: 2 },
2292
+ }),
2293
+ ) as { version: string; timeline: { entries: number; levels: Record<string, number>; collapsed: number } };
2294
+ expect(envelope.version).toBe(protocolversion);
2295
+ expect(envelope.timeline).toEqual({
2296
+ entries: 4,
2297
+ levels: { error: 1, warn: 1, info: 2, log: 0, debug: 0, trace: 0 },
2298
+ collapsed: 2,
2299
+ });
2300
+ const plain = JSON.parse(outcomeresponse({ outcome, plan })) as { timeline?: unknown };
2301
+ expect(plain.timeline).toBeUndefined();
2302
+ const timeline = timelinereport({
2303
+ entries: [{ id: "t1", runid: "plan", stepid: "w1", time: 3, level: "error", source: "error", message: "boom" }],
2304
+ errors: [
2305
+ {
2306
+ id: "e1",
2307
+ runid: "plan",
2308
+ stepid: "w1",
2309
+ message: "boom",
2310
+ frames: [{ url: "https://example.com/app.js", line: 42 }],
2311
+ sourceurl: "https://example.com/app.js",
2312
+ line: 42,
2313
+ at: 3,
2314
+ },
2315
+ ],
2316
+ rejections: [],
2317
+ longtasks: [
2318
+ { id: "l1", runid: "plan", stepid: "w1", duration: 90, starttime: 10, attributions: ["same-origin"], at: 3 },
2319
+ ],
2320
+ levelcounts: { error: 1 },
2321
+ });
2322
+ expect(timeline.version).toBe(protocolversion);
2323
+ expect(timeline.entries[0]?.message).toBe("boom");
2324
+ expect(timeline.errors[0]?.frames).toHaveLength(1);
2325
+ expect(timeline.longtasks[0]?.attributions).toEqual(["same-origin"]);
2326
+ const diff = consolediffreport({
2327
+ diff: {
2328
+ base: "run1",
2329
+ target: "run2",
2330
+ lines: [
2331
+ { kind: "added", text: "fresh" },
2332
+ { kind: "removed", text: "gone" },
2333
+ { kind: "repeated", text: "boot", count: 1 },
2334
+ ],
2335
+ added: 1,
2336
+ removed: 1,
2337
+ repeated: 1,
2338
+ at: 8,
2339
+ },
2340
+ });
2341
+ expect(diff.diff.base).toBe("run1");
2342
+ expect(diff.diff.lines).toHaveLength(3);
2343
+ });
2344
+
2345
+ it("accepts the watch options grammar and rejects level floors outside the level set", () => {
2346
+ const proposal = parseproposal(
2347
+ {
2348
+ version: protocolversion,
2349
+ plan: {
2350
+ objective: "Debug",
2351
+ steps: [
2352
+ {
2353
+ kind: "watchconsole",
2354
+ options: JSON.stringify({
2355
+ watch: { window: 250 },
2356
+ level: "warn",
2357
+ depth: 3,
2358
+ redact: ["secret"],
2359
+ spam: { pattern: "retry", windowsize: 500, collapse: 2 },
2360
+ rotation: { maxentries: 40, overflowtarget: "overflow1" },
2361
+ }),
2362
+ summary: "Capture console output at every level.",
2363
+ },
2364
+ {
2365
+ kind: "watcherrors",
2366
+ options: JSON.stringify({ watch: { window: 250 }, level: "error" }),
2367
+ summary: "Capture errors with stacks.",
2368
+ },
2369
+ {
2370
+ kind: "watchtasks",
2371
+ options: JSON.stringify({ watch: { window: 250 }, threshold: 50 }),
2372
+ summary: "Capture long task timings.",
2373
+ },
2374
+ ],
2375
+ },
2376
+ },
2377
+ "https://example.com",
2378
+ );
2379
+ expect(proposal.plan.steps.map((step) => step.risk)).toEqual(["read", "read", "read"]);
2380
+ expect(() =>
2381
+ parseproposal(
2382
+ {
2383
+ version: protocolversion,
2384
+ plan: {
2385
+ objective: "Debug",
2386
+ steps: [
2387
+ {
2388
+ kind: "watchconsole",
2389
+ options: JSON.stringify({ watch: { window: 250 }, redact: ["s"], level: "verbose" }),
2390
+ summary: "Bad level floor.",
2391
+ },
2392
+ ],
2393
+ },
2394
+ },
2395
+ "https://example.com",
2396
+ ),
2397
+ ).toThrow("level floor");
2398
+ expect(() =>
2399
+ parseproposal(
2400
+ {
2401
+ version: protocolversion,
2402
+ plan: {
2403
+ objective: "Debug",
2404
+ steps: [
2405
+ {
2406
+ kind: "watchconsole",
2407
+ options: JSON.stringify({ watch: { window: 250 } }),
2408
+ summary: "Missing redaction.",
2409
+ },
2410
+ ],
2411
+ },
2412
+ },
2413
+ "https://example.com",
2414
+ ),
2415
+ ).toThrow("redaction");
2416
+ });
2417
+
2418
+ it("rejects watch steps for ungranted origins", () => {
2419
+ expect(() =>
2420
+ parseproposal(
2421
+ {
2422
+ version: protocolversion,
2423
+ plan: {
2424
+ objective: "Debug",
2425
+ steps: [
2426
+ {
2427
+ kind: "watchconsole",
2428
+ options: JSON.stringify({ watch: { window: 250 }, redact: ["secret"] }),
2429
+ summary: "Watch the console.",
2430
+ },
2431
+ ],
2432
+ },
2433
+ },
2434
+ "https://example.com",
2435
+ ["https://other.example"],
2436
+ ),
2437
+ ).toThrow("outside the grants");
2438
+ const granted = parseproposal(
2439
+ {
2440
+ version: protocolversion,
2441
+ plan: {
2442
+ objective: "Debug",
2443
+ steps: [
2444
+ {
2445
+ kind: "watchconsole",
2446
+ options: JSON.stringify({ watch: { window: 250 }, redact: ["secret"] }),
2447
+ summary: "Watch the console.",
2448
+ },
2449
+ ],
2450
+ },
2451
+ },
2452
+ "https://example.com",
2453
+ ["https://example.com"],
2454
+ );
2455
+ expect(granted.plan.steps).toHaveLength(1);
2456
+ });
2457
+ });
2458
+
2459
+ describe("debugging part two protocol", () => {
2460
+ const attachoptions = {
2461
+ domains: ["Runtime", "Debugger"],
2462
+ teardown: { revertsteps: ["revert breakpoints", "revert overrides"], resumepolicy: "pause" },
2463
+ };
2464
+
2465
+ it("attaches the cdp block to the outcome envelope and carries the cdp report with sources held back", () => {
2466
+ const outcome: stepoutcome = { stepid: "c1", ok: true, summary: "The command returned.", at: 4 };
2467
+ const plan: agentplan = {
2468
+ id: "plan",
2469
+ objective: "Debug",
2470
+ origin: "https://example.com",
2471
+ steps: [
2472
+ {
2473
+ id: "c1",
2474
+ kind: "cdpcmd",
2475
+ summary: "Evaluate",
2476
+ risk: "sensitive",
2477
+ options: JSON.stringify({ command: { method: "Runtime.evaluate" } }),
2478
+ },
2479
+ ],
2480
+ createdat: 1,
2481
+ expiresat: 9000,
2482
+ state: "approved",
2483
+ };
2484
+ const envelope = JSON.parse(
2485
+ outcomeresponse({ outcome, plan, cdp: { sessionid: "cdp1", state: "attached", commandids: ["cmd1", "cmd2"] } }),
2486
+ ) as { cdp: { sessionid: string; state: string; commandids: string[] } };
2487
+ expect(envelope.cdp).toEqual({ sessionid: "cdp1", state: "attached", commandids: ["cmd1", "cmd2"] });
2488
+ const plain = JSON.parse(outcomeresponse({ outcome, plan })) as { cdp?: unknown };
2489
+ expect(plain.cdp).toBeUndefined();
2490
+ const session: cdpsession = {
2491
+ id: "cdp1",
2492
+ runid: "plan",
2493
+ stepid: "a1",
2494
+ tabid: 4,
2495
+ origin: "https://example.com",
2496
+ attachedat: 2,
2497
+ domains: ["Runtime"],
2498
+ debuggerversion: "devthink instrumented harness 1.1.46 (no chrome.debugger permission)",
2499
+ };
2500
+ const command: cdpcommand = {
2501
+ id: "cmd1",
2502
+ sessionid: "cdp1",
2503
+ runid: "plan",
2504
+ stepid: "c1",
2505
+ method: "Runtime.evaluate",
2506
+ domain: "Runtime",
2507
+ duration: 8,
2508
+ at: 3,
2509
+ };
2510
+ const rule: cdpeventrule = {
2511
+ id: "r1",
2512
+ sessionid: "cdp1",
2513
+ runid: "plan",
2514
+ stepid: "w1",
2515
+ domain: "Log",
2516
+ event: "entryAdded",
2517
+ events: 2,
2518
+ registeredat: 2,
2519
+ };
2520
+ const breakpoint: breakpointspec = {
2521
+ id: "bp1",
2522
+ runid: "plan",
2523
+ stepid: "b1",
2524
+ url: "https://example.com/app.js",
2525
+ line: 5,
2526
+ condition: "items.length > 0",
2527
+ hits: 3,
2528
+ registeredat: 2,
2529
+ };
2530
+ const pause: pausestate = {
2531
+ id: "p1",
2532
+ runid: "plan",
2533
+ stepid: "b1",
2534
+ reason: "breakpoint",
2535
+ callframes: [{ url: "https://example.com/app.js", line: 5 }],
2536
+ hitbreakpoint: "bp1",
2537
+ domsnapshotid: "dom-3",
2538
+ at: 4,
2539
+ };
2540
+ const watch: watchexpression = {
2541
+ id: "we1",
2542
+ runid: "plan",
2543
+ stepid: "w1",
2544
+ expression: "items.length",
2545
+ scope: "topframe",
2546
+ reviewed: true,
2547
+ values: [{ pauseid: "p1", value: "3", at: 4 }],
2548
+ at: 2,
2549
+ };
2550
+ const override: scriptoverride = {
2551
+ id: "ov1",
2552
+ runid: "plan",
2553
+ stepid: "o1",
2554
+ urlpattern: "https://cdn.example/vendor.js",
2555
+ source: "window.fixture = true;",
2556
+ reviewed: true,
2557
+ hits: 1,
2558
+ appliedat: 2,
2559
+ };
2560
+ const grant: debuggergrant = {
2561
+ id: "g1",
2562
+ prompt: "Debugger attach prompt",
2563
+ origin: "https://example.com",
2564
+ domains: ["Runtime"],
2565
+ approved: true,
2566
+ consentedat: 1,
2567
+ };
2568
+ const report = cdpreport({
2569
+ sessions: [session],
2570
+ commands: [command],
2571
+ events: [rule],
2572
+ breakpoints: [breakpoint],
2573
+ pauses: [pause],
2574
+ watches: [watch],
2575
+ overrides: [override],
2576
+ grants: [grant],
2577
+ });
2578
+ expect(report.version).toBe(protocolversion);
2579
+ expect(report.sessions[0]?.domains).toEqual(["Runtime"]);
2580
+ expect(report.commands[0]).toMatchObject({ method: "Runtime.evaluate", duration: 8 });
2581
+ expect(report.breakpoints[0]).toMatchObject({ hits: 3, condition: "items.length > 0" });
2582
+ expect(report.pauses[0]).toMatchObject({ domsnapshotid: "dom-3" });
2583
+ expect(report.watches[0]?.values).toHaveLength(1);
2584
+ expect(report.overrides[0]?.source).toBeUndefined();
2585
+ expect(report.overrides[0]?.urlpattern).toBe("https://cdn.example/vendor.js");
2586
+ expect(report.grants[0]?.prompt).toBeUndefined();
2587
+ expect(report.grants[0]?.domains).toEqual(["Runtime"]);
2588
+ });
2589
+
2590
+ it("rejects cdp proposals outside the attach contract and accepts the reviewed chain", () => {
2591
+ const attach = {
2592
+ kind: "attachcdp",
2593
+ options: JSON.stringify(attachoptions),
2594
+ summary: "Attach the devtools session.",
2595
+ };
2596
+ const command = {
2597
+ kind: "cdpcmd",
2598
+ options: JSON.stringify({ command: { method: "Runtime.evaluate" } }),
2599
+ summary: "Evaluate an expression.",
2600
+ };
2601
+ const accepted = parseproposal(
2602
+ { version: protocolversion, plan: { objective: "Debug", steps: [attach, command] } },
2603
+ "https://example.com",
2604
+ );
2605
+ expect(accepted.plan.steps).toHaveLength(2);
2606
+ expect(accepted.plan.steps[1]?.risk).toBe("sensitive");
2607
+ expect(() =>
2608
+ parseproposal(
2609
+ {
2610
+ version: protocolversion,
2611
+ plan: {
2612
+ objective: "Debug",
2613
+ steps: [
2614
+ {
2615
+ kind: "attachcdp",
2616
+ options: JSON.stringify({ domains: ["Runtime"] }),
2617
+ summary: "Attach without a teardown plan.",
2618
+ },
2619
+ ],
2620
+ },
2621
+ },
2622
+ "https://example.com",
2623
+ ),
2624
+ ).toThrow("teardown plan");
2625
+ expect(() =>
2626
+ parseproposal(
2627
+ {
2628
+ version: protocolversion,
2629
+ plan: {
2630
+ objective: "Debug",
2631
+ steps: [
2632
+ {
2633
+ kind: "attachcdp",
2634
+ options: JSON.stringify({ domains: [], teardown: { revertsteps: ["x"] } }),
2635
+ summary: "Attach without domains.",
2636
+ },
2637
+ ],
2638
+ },
2639
+ },
2640
+ "https://example.com",
2641
+ ),
2642
+ ).toThrow("domain");
2643
+ expect(() =>
2644
+ parseproposal(
2645
+ { version: protocolversion, plan: { objective: "Debug", steps: [command] } },
2646
+ "https://example.com",
2647
+ ),
2648
+ ).toThrow("attachcdp step");
2649
+ expect(() =>
2650
+ parseproposal(
2651
+ {
2652
+ version: protocolversion,
2653
+ plan: {
2654
+ objective: "Debug",
2655
+ steps: [
2656
+ attach,
2657
+ {
2658
+ kind: "cdpcmd",
2659
+ options: JSON.stringify({ command: { method: "DOM.getSnapshot" } }),
2660
+ summary: "Command outside the allowlist.",
2661
+ },
2662
+ ],
2663
+ },
2664
+ },
2665
+ "https://example.com",
2666
+ ),
2667
+ ).toThrow("allowlist");
2668
+ expect(() =>
2669
+ parseproposal(
2670
+ {
2671
+ version: protocolversion,
2672
+ plan: {
2673
+ objective: "Debug",
2674
+ steps: [
2675
+ {
2676
+ kind: "watchcdp",
2677
+ options: JSON.stringify({ events: [{ domain: "Log", event: "entryAdded" }], watch: { window: 100 } }),
2678
+ summary: "Watch events on an ungranted origin.",
2679
+ },
2680
+ ],
2681
+ },
2682
+ },
2683
+ "https://example.com",
2684
+ ["https://other.example"],
2685
+ ),
2686
+ ).toThrow("outside the grants");
2687
+ const gated = {
2688
+ kind: "attachcdp",
2689
+ options: JSON.stringify({ ...attachoptions, allowlist: { domains: ["Runtime"], methods: ["Runtime.evaluate"] } }),
2690
+ summary: "Attach with method gates.",
2691
+ };
2692
+ expect(
2693
+ parseproposal(
2694
+ { version: protocolversion, plan: { objective: "Debug", steps: [gated, command] } },
2695
+ "https://example.com",
2696
+ ).plan.steps,
2697
+ ).toHaveLength(2);
2698
+ expect(() =>
2699
+ parseproposal(
2700
+ {
2701
+ version: protocolversion,
2702
+ plan: {
2703
+ objective: "Debug",
2704
+ steps: [
2705
+ gated,
2706
+ {
2707
+ kind: "cdpcmd",
2708
+ options: JSON.stringify({ command: { method: "Runtime.callFunctionOn" } }),
2709
+ summary: "Gated out.",
2710
+ },
2711
+ ],
2712
+ },
2713
+ },
2714
+ "https://example.com",
2715
+ ),
2716
+ ).toThrow("allowlist");
2717
+ });
2718
+ });
2719
+
2720
+ describe("debugging part three protocol", () => {
2721
+ it("attaches the profile block to the outcome envelope and carries the profile report with consent prompts held back", () => {
2722
+ const outcome: stepoutcome = { stepid: "m1", ok: true, summary: "The flow measurement ran.", at: 4 };
2723
+ const plan: agentplan = {
2724
+ id: "plan",
2725
+ objective: "Profile",
2726
+ origin: "https://example.com",
2727
+ steps: [
2728
+ {
2729
+ id: "m1",
2730
+ kind: "measureflow",
2731
+ summary: "Measure",
2732
+ risk: "read",
2733
+ options: JSON.stringify({
2734
+ flow: { prefix: "flow", steps: ["s1"], metrics: ["navigation"] },
2735
+ watch: { window: 100 },
2736
+ }),
2737
+ },
2738
+ ],
2739
+ createdat: 1,
2740
+ expiresat: 9000,
2741
+ state: "approved",
2742
+ };
2743
+ const envelope = JSON.parse(outcomeresponse({ outcome, plan, profile: { metrics: 3, samples: 5 } })) as {
2744
+ profile: { metrics: number; samples: number };
2745
+ };
2746
+ expect(envelope.profile).toEqual({ metrics: 3, samples: 5 });
2747
+ const plain = JSON.parse(outcomeresponse({ outcome, plan })) as { profile?: unknown };
2748
+ expect(plain.profile).toBeUndefined();
2749
+ const flow: flowmetric = {
2750
+ id: "f1",
2751
+ runid: "plan",
2752
+ stepid: "m1",
2753
+ name: "navigation",
2754
+ start: 0,
2755
+ end: 800,
2756
+ duration: 800,
2757
+ steps: ["s1"],
2758
+ at: 2,
2759
+ };
2760
+ const heap: heaprecord = {
2761
+ id: "h1",
2762
+ runid: "plan",
2763
+ stepid: "hs",
2764
+ origin: "https://example.com",
2765
+ bytesize: 12_000_000,
2766
+ nodecount: 1450,
2767
+ capturedat: 3,
2768
+ };
2769
+ const sample: growsample = {
2770
+ id: "g1",
2771
+ runid: "plan",
2772
+ stepid: "s1",
2773
+ usedbytes: 1_000_000,
2774
+ limitbytes: 4_000_000,
2775
+ at: 3,
2776
+ };
2777
+ const trend: memorytrend = { runid: "plan", slope: 2000, samples: 2, flaggedsteps: ["s2"], at: 4 };
2778
+ const cpu: cpuprofile = {
2779
+ id: "c1",
2780
+ runid: "plan",
2781
+ stepid: "cpu",
2782
+ origin: "https://example.com",
2783
+ duration: 500,
2784
+ samplecount: 3,
2785
+ hotfunctions: ["render"],
2786
+ at: 4,
2787
+ };
2788
+ const shift: shiftentry = {
2789
+ id: "sh1",
2790
+ runid: "plan",
2791
+ stepid: "ws",
2792
+ score: 0.12,
2793
+ starttime: 40,
2794
+ selectors: ["img#hero"],
2795
+ at: 4,
2796
+ };
2797
+ const trace: tracerecord = {
2798
+ id: "t1",
2799
+ runid: "plan",
2800
+ stepid: "tl",
2801
+ origin: "https://example.com",
2802
+ categories: ["scripting"],
2803
+ bytesize: 400,
2804
+ events: 2,
2805
+ annotations: [{ stepid: "s1", label: "open", offset: 100 }],
2806
+ startedat: 1,
2807
+ endedat: 4,
2808
+ };
2809
+ const map: sourcemapref = {
2810
+ id: "sm1",
2811
+ runid: "plan",
2812
+ stepid: "sm",
2813
+ origin: "https://example.com",
2814
+ scripturl: "https://example.com/app.js",
2815
+ mapurl: "https://example.com/app.js.map",
2816
+ parsed: true,
2817
+ at: 4,
2818
+ };
2819
+ const consent: sourcemapconsent = {
2820
+ id: "c1",
2821
+ prompt: "Source map capture prompt",
2822
+ origin: "https://example.com",
2823
+ approved: true,
2824
+ consentedat: 1,
2825
+ };
2826
+ const report = profilereport({
2827
+ flows: [flow],
2828
+ heaps: [heap],
2829
+ samples: [sample],
2830
+ trends: [trend],
2831
+ profiles: [cpu],
2832
+ shifts: [shift],
2833
+ traces: [trace],
2834
+ sourcemaps: [map],
2835
+ consents: [consent],
2836
+ });
2837
+ expect(report.version).toBe(protocolversion);
2838
+ expect(report.flows[0]).toMatchObject({ name: "navigation", duration: 800, steps: ["s1"] });
2839
+ expect(report.heaps[0]).toMatchObject({ bytesize: 12_000_000, nodecount: 1450 });
2840
+ expect(report.trends[0]?.flaggedsteps).toEqual(["s2"]);
2841
+ expect(report.profiles[0]?.hotfunctions).toEqual(["render"]);
2842
+ expect(report.shifts[0]?.selectors).toEqual(["img#hero"]);
2843
+ expect(report.traces[0]?.annotations).toHaveLength(1);
2844
+ expect(report.sourcemaps[0]).toMatchObject({ parsed: true });
2845
+ expect(report.consents[0]?.prompt).toBeUndefined();
2846
+ expect(report.consents[0]?.origin).toBe("https://example.com");
2847
+ });
2848
+
2849
+ it("rejects profiling proposals outside the grants, the reviewed categories and the annotation contract", () => {
2850
+ const flow = {
2851
+ kind: "measureflow",
2852
+ options: JSON.stringify({
2853
+ flow: { prefix: "flow", steps: ["s1"], metrics: ["navigation", "blocking"] },
2854
+ watch: { window: 100 },
2855
+ }),
2856
+ summary: "Measure the flow.",
2857
+ };
2858
+ expect(
2859
+ parseproposal({ version: protocolversion, plan: { objective: "Profile", steps: [flow] } }, "https://example.com")
2860
+ .plan.steps[0]?.risk,
2861
+ ).toBe("read");
2862
+ expect(() =>
2863
+ parseproposal(
2864
+ { version: protocolversion, plan: { objective: "Profile", steps: [flow] } },
2865
+ "https://example.com",
2866
+ ["https://other.example"],
2867
+ ),
2868
+ ).toThrow("outside the grants");
2869
+ expect(() =>
2870
+ parseproposal(
2871
+ {
2872
+ version: protocolversion,
2873
+ plan: {
2874
+ objective: "Profile",
2875
+ steps: [
2876
+ {
2877
+ kind: "traceload",
2878
+ options: JSON.stringify({ trace: { categories: ["secret"], window: 100 } }),
2879
+ summary: "Trace with an unreviewed category.",
2880
+ },
2881
+ ],
2882
+ },
2883
+ },
2884
+ "https://example.com",
2885
+ ),
2886
+ ).toThrow("reviewed list");
2887
+ expect(() =>
2888
+ parseproposal(
2889
+ {
2890
+ version: protocolversion,
2891
+ plan: {
2892
+ objective: "Profile",
2893
+ steps: [
2894
+ {
2895
+ kind: "heapshot",
2896
+ options: JSON.stringify({ target: { kind: "iframe", url: "https://elsewhere.example/frame" } }),
2897
+ summary: "Snapshot an ungranted frame.",
2898
+ },
2899
+ ],
2900
+ },
2901
+ },
2902
+ "https://example.com",
2903
+ ),
2904
+ ).toThrow("outside the granted origins");
2905
+ const framed = {
2906
+ kind: "heapshot",
2907
+ options: JSON.stringify({ target: { kind: "worker", url: "https://cdn.example/worker.js" } }),
2908
+ summary: "Snapshot a granted worker.",
2909
+ };
2910
+ expect(
2911
+ parseproposal(
2912
+ { version: protocolversion, plan: { objective: "Profile", steps: [framed] } },
2913
+ "https://example.com",
2914
+ ["https://example.com", "https://cdn.example"],
2915
+ ).plan.steps,
2916
+ ).toHaveLength(1);
2917
+ expect(() =>
2918
+ parseproposal(
2919
+ {
2920
+ version: protocolversion,
2921
+ plan: {
2922
+ objective: "Profile",
2923
+ steps: [
2924
+ {
2925
+ kind: "capturesourcemaps",
2926
+ options: JSON.stringify({ scripts: ["https://elsewhere.example/app.js"] }),
2927
+ summary: "Capture maps of an ungranted origin.",
2928
+ },
2929
+ ],
2930
+ },
2931
+ },
2932
+ "https://example.com",
2933
+ ),
2934
+ ).toThrow("outside the grants");
2935
+ expect(() =>
2936
+ parseproposal(
2937
+ {
2938
+ version: protocolversion,
2939
+ plan: {
2940
+ objective: "Profile",
2941
+ steps: [
2942
+ {
2943
+ kind: "annotatetrace",
2944
+ options: JSON.stringify({ trace: { traceid: "t1" } }),
2945
+ summary: "Annotate without annotations.",
2946
+ },
2947
+ ],
2948
+ },
2949
+ },
2950
+ "https://example.com",
2951
+ ),
2952
+ ).toThrow("step annotations");
2953
+ const annotated = {
2954
+ kind: "annotatetrace",
2955
+ options: JSON.stringify({ trace: { traceid: "t1" }, annotations: [{ stepid: "s1", label: "open" }] }),
2956
+ summary: "Annotate the trace.",
2957
+ };
2958
+ expect(
2959
+ parseproposal(
2960
+ { version: protocolversion, plan: { objective: "Profile", steps: [annotated] } },
2961
+ "https://example.com",
2962
+ ).plan.steps,
2963
+ ).toHaveLength(1);
2964
+ });
2965
+ });
2966
+
2967
+ describe("emulation protocol", () => {
2968
+ const outcome: stepoutcome = { stepid: "e1", ok: true, summary: "Applied the device layer phone.", at: 1 };
2969
+ const plan: agentplan = {
2970
+ id: "plan",
2971
+ objective: "Emulate",
2972
+ origin: "https://example.com",
2973
+ steps: [],
2974
+ createdat: 1,
2975
+ expiresat: 2,
2976
+ state: "approved",
2977
+ };
2978
+ const device = { name: "phone", width: 390, height: 844, pixelratio: 3, mobile: true };
2979
+ const network = { name: "slow3g", latency: 400, download: 400, upload: 400, offline: true };
2980
+ const location = { name: "lisbon", latitude: 38.7223, longitude: -9.1393, accuracy: 100 };
2981
+ const agent = {
2982
+ name: "desktopmask",
2983
+ useragent: "Mozilla/5.0 (X11; Linux x86_64) Chrome/120.0.0.0 Safari/537.36",
2984
+ platform: "Linux x86_64",
2985
+ brands: ["Chromium"],
2986
+ };
2987
+ const revertplan = ["restore the prior page state"];
2988
+
2989
+ it("carries the applied and reverted layer names in the emulation block of the response envelope", () => {
2990
+ const envelope = JSON.parse(
2991
+ outcomeresponse({ outcome, plan, emulation: { applied: ["phone"], reverted: ["slow3g"] } }),
2992
+ ) as { emulation?: { applied: string[]; reverted: string[] } };
2993
+ expect(envelope.emulation).toEqual({ applied: ["phone"], reverted: ["slow3g"] });
2994
+ const bare = JSON.parse(outcomeresponse({ outcome, plan })) as { emulation?: unknown };
2995
+ expect(bare.emulation).toBeUndefined();
2996
+ });
2997
+
2998
+ it("builds the emulation report envelope with the layer history, preset libraries, blackbox rules and permission overrides while the consent prompt text stays out", () => {
2999
+ const report = emulationreport({
3000
+ state: {
3001
+ runid: "run",
3002
+ tabid: 4,
3003
+ origin: "https://example.com",
3004
+ layers: [
3005
+ {
3006
+ id: "l1",
3007
+ runid: "run",
3008
+ stepid: "e1",
3009
+ family: "device",
3010
+ name: "phone",
3011
+ originscope: "https://example.com",
3012
+ appliedat: 1,
3013
+ revertplan,
3014
+ },
3015
+ ],
3016
+ updatedat: 2,
3017
+ },
3018
+ devices: [device],
3019
+ networks: [network],
3020
+ locations: [location],
3021
+ agents: [agent],
3022
+ blackbox: [
3023
+ { origin: "https://example.com", rules: [{ urlpatterns: ["https://cdn.example/**"], tracescope: "both" }] },
3024
+ ],
3025
+ permissions: [
3026
+ {
3027
+ id: "p1",
3028
+ runid: "run",
3029
+ stepid: "e1",
3030
+ origin: "https://example.com",
3031
+ name: "geolocation",
3032
+ state: "granted",
3033
+ priorstate: "prompt",
3034
+ appliedat: 1,
3035
+ },
3036
+ ],
3037
+ consents: [
3038
+ {
3039
+ id: "c1",
3040
+ prompt: "Where?",
3041
+ origin: "https://example.com",
3042
+ latitude: 38.7223,
3043
+ longitude: -9.1393,
3044
+ approved: true,
3045
+ consentedat: 1,
3046
+ },
3047
+ ],
3048
+ });
3049
+ expect(report.layers[0]?.name).toBe("phone");
3050
+ expect(report.layers[0]?.revertplan).toEqual(revertplan);
3051
+ expect(report.devices[0]?.name).toBe("phone");
3052
+ expect(report.networks[0]?.offline).toBe(true);
3053
+ expect(report.blackbox[0]?.rules[0]?.tracescope).toBe("both");
3054
+ expect(report.permissions[0]?.priorstate).toBe("prompt");
3055
+ expect(report.consents[0]?.latitude).toBe(38.7223);
3056
+ expect(report.consents[0]).not.toHaveProperty("prompt");
3057
+ const empty = emulationreport({
3058
+ devices: [],
3059
+ networks: [],
3060
+ locations: [],
3061
+ agents: [],
3062
+ blackbox: [],
3063
+ permissions: [],
3064
+ consents: [],
3065
+ });
3066
+ expect(empty.layers).toEqual([]);
3067
+ expect(empty).not.toHaveProperty("state");
3068
+ });
3069
+
3070
+ it("rejects emulation steps without a revert plan, location values out of range and unknown permission names", () => {
3071
+ expect(() =>
3072
+ parseproposal(
3073
+ {
3074
+ version: protocolversion,
3075
+ plan: {
3076
+ objective: "Emulate",
3077
+ steps: [
3078
+ {
3079
+ kind: "emulatedevice",
3080
+ options: JSON.stringify({ reviewed: true, device }),
3081
+ summary: "Mask without a revert plan.",
3082
+ },
3083
+ ],
3084
+ },
3085
+ },
3086
+ "https://example.com",
3087
+ ),
3088
+ ).toThrow("revert plan");
3089
+ expect(() =>
3090
+ parseproposal(
3091
+ {
3092
+ version: protocolversion,
3093
+ plan: {
3094
+ objective: "Emulate",
3095
+ steps: [
3096
+ {
3097
+ kind: "emulatelocate",
3098
+ options: JSON.stringify({ reviewed: true, location: { ...location, latitude: 91 }, revertplan }),
3099
+ summary: "Mask a coordinate out of range.",
3100
+ },
3101
+ ],
3102
+ },
3103
+ },
3104
+ "https://example.com",
3105
+ ),
3106
+ ).toThrow("latitude and longitude ranges");
3107
+ expect(() =>
3108
+ parseproposal(
3109
+ {
3110
+ version: protocolversion,
3111
+ plan: {
3112
+ objective: "Emulate",
3113
+ steps: [
3114
+ {
3115
+ kind: "overridepermission",
3116
+ options: JSON.stringify({
3117
+ reviewed: true,
3118
+ permission: { name: "screen-capture", state: "granted" },
3119
+ revertplan,
3120
+ }),
3121
+ summary: "Override an unknown permission.",
3122
+ },
3123
+ ],
3124
+ },
3125
+ },
3126
+ "https://example.com",
3127
+ ),
3128
+ ).toThrow("unknown permission names");
3129
+ expect(() =>
3130
+ parseproposal(
3131
+ {
3132
+ version: protocolversion,
3133
+ plan: {
3134
+ objective: "Emulate",
3135
+ steps: [
3136
+ {
3137
+ kind: "setuseragent",
3138
+ options: JSON.stringify({ reviewed: true, agent, revertplan }),
3139
+ summary: "Mask the agent.",
3140
+ },
3141
+ ],
3142
+ },
3143
+ },
3144
+ "https://example.com",
3145
+ ["https://other.example"],
3146
+ ),
3147
+ ).toThrow("outside the grants");
3148
+ });
3149
+
3150
+ it("accepts reviewed emulation steps with their revert plans into a pending plan", () => {
3151
+ const proposal = parseproposal(
3152
+ {
3153
+ version: protocolversion,
3154
+ plan: {
3155
+ objective: "Emulate",
3156
+ steps: [
3157
+ {
3158
+ kind: "emulatedevice",
3159
+ options: JSON.stringify({ reviewed: true, device, revertplan, reload: true }),
3160
+ summary: "Wear the reviewed phone mask.",
3161
+ },
3162
+ {
3163
+ kind: "blackboxscripts",
3164
+ options: JSON.stringify({
3165
+ reviewed: true,
3166
+ rules: [{ urlpatterns: ["https://cdn.example/**"], tracescope: "both" }],
3167
+ revertplan,
3168
+ }),
3169
+ summary: "Blackbox the vendor scripts.",
3170
+ },
3171
+ ],
3172
+ },
3173
+ },
3174
+ "https://example.com",
3175
+ );
3176
+ expect(proposal.plan.steps[0]?.risk).toBe("sensitive");
3177
+ expect(proposal.plan.steps[1]?.risk).toBe("read");
3178
+ });
3179
+ });
3180
+
3181
+ describe("workflow editor protocol", () => {
3182
+ it("builds the editor state envelope for panel synchronization", () => {
3183
+ const envelope = editorstate({
3184
+ versions: [{ workflowid: "wf1", version: 2, createdat: 5, note: "tuned the delay", steps: 3, risk: "read" }],
3185
+ diffs: [{ workflowid: "wf1", from: 1, to: 2, added: [], removed: [], changed: [], at: 6 }],
3186
+ history: [
3187
+ {
3188
+ runid: "r1",
3189
+ workflowid: "wf1",
3190
+ outcome: "done",
3191
+ steps: 3,
3192
+ total: 3,
3193
+ duration: 42,
3194
+ cause: "manual",
3195
+ startedat: 7,
3196
+ endedat: 49,
3197
+ },
3198
+ ],
3199
+ breakpoints: ["s2"],
3200
+ overrides: [
3201
+ { id: "o1", workflowid: "wf1", pattern: "https://example.com", deltas: { waitms: 500 }, createdat: 8 },
3202
+ ],
3203
+ imports: [
3204
+ { id: "imp1", workflowid: "wf1", name: "imported", version: 1, steps: 2, risk: "sensitive", importedat: 9 },
3205
+ ],
3206
+ backgroundruns: { wf1: true },
3207
+ watchdog: {
3208
+ config: { enabled: true, stallthreshold: 30_000, action: "pause" },
3209
+ events: [
3210
+ { id: "w1", runid: "r1", verdict: "stalled", action: "pause", outcome: "Paused at the checkpoint.", at: 10 },
3211
+ ],
3212
+ },
3213
+ });
3214
+ expect(envelope.version).toBe(protocolversion);
3215
+ expect(envelope.editor.versions[0]?.note).toBe("tuned the delay");
3216
+ expect(envelope.editor.history[0]?.cause).toBe("manual");
3217
+ expect(envelope.editor.breakpoints).toEqual(["s2"]);
3218
+ expect(envelope.editor.imports[0]?.workflowid).toBe("wf1");
3219
+ expect(envelope.editor.backgroundruns).toEqual({ wf1: true });
3220
+ expect(envelope.editor.watchdog.config?.action).toBe("pause");
3221
+ expect(envelope.editor.watchdog.events[0]?.verdict).toBe("stalled");
3222
+ expect("model" in envelope).toBe(false);
3223
+ const withmodel = editorstate({
3224
+ versions: [],
3225
+ history: [],
3226
+ overrides: [],
3227
+ imports: [],
3228
+ backgroundruns: {},
3229
+ watchdog: { events: [] },
3230
+ model: {
3231
+ workflowid: "wf1",
3232
+ name: "canvas",
3233
+ version: 1,
3234
+ origins: ["https://example.com"],
3235
+ nodes: [],
3236
+ edges: [],
3237
+ blocks: [],
3238
+ layout: { width: 640, height: 480, viewportx: 0, viewporty: 0, zoom: 1 },
3239
+ minimap: { width: 160, height: 100, scale: 1, zoom: 1, viewport: { x: 0, y: 0, width: 160, height: 100 } },
3240
+ dirty: false,
3241
+ },
3242
+ });
3243
+ expect(withmodel.model?.name).toBe("canvas");
3244
+ expect(withmodel.editor.backgroundruns).toEqual({});
3245
+ expect(workflowfileversion).toBe(1);
3246
+ });
3247
+
3248
+ it("parses run history queries with filters and echoes them in the report", () => {
3249
+ expect(runhistoryquery(undefined)).toEqual({});
3250
+ expect(runhistoryquery({ workflowid: "wf1", outcome: "done", since: 100, limit: 25 })).toEqual({
3251
+ workflowid: "wf1",
3252
+ outcome: "done",
3253
+ since: 100,
3254
+ limit: 25,
3255
+ });
3256
+ expect(() => runhistoryquery({ workflowid: " " })).toThrow("non-empty string");
3257
+ expect(() => runhistoryquery({ outcome: "" })).toThrow("non-empty string");
3258
+ expect(() => runhistoryquery({ since: Number.NaN })).toThrow("finite");
3259
+ expect(() => runhistoryquery({ limit: 0 })).toThrow("positive integer");
3260
+ expect(() => runhistoryquery({ limit: 1.5 })).toThrow("positive integer");
3261
+ const report = runhistoryreport({
3262
+ entries: [
3263
+ {
3264
+ runid: "r1",
3265
+ workflowid: "wf1",
3266
+ outcome: "failed",
3267
+ steps: 1,
3268
+ total: 3,
3269
+ duration: 5,
3270
+ cause: "cron",
3271
+ startedat: 1,
3272
+ endedat: 6,
3273
+ },
3274
+ ],
3275
+ query: { outcome: "failed" },
3276
+ });
3277
+ expect(report.version).toBe(protocolversion);
3278
+ expect(report.entries[0]?.cause).toBe("cron");
3279
+ expect(report.query).toEqual({ outcome: "failed" });
3280
+ expect(runhistoryreport({ entries: [] }).query).toEqual({});
3281
+ });
3282
+ });
3283
+
3284
+ describe("agent protocol protocol", () => {
3285
+ it("builds toolcall and toolresult frames with the json rpc grammar", () => {
3286
+ const call = toolcallframe({ id: 7, name: "browser.click", params: { stepid: "s1" } });
3287
+ expect(call).toMatchObject({ jsonrpc: "2.0", id: 7, method: "tools/call" });
3288
+ expect(call.params).toEqual({ stepid: "s1", name: "browser.click" });
3289
+ expect(toolcallframe({ id: "alpha", name: "browser.snapshot" }).params).toEqual({ name: "browser.snapshot" });
3290
+ const result = toolresultframe({ id: 7, result: { content: "ran click", iserror: false } });
3291
+ expect(result.result).toEqual({ content: "ran click", iserror: false });
3292
+ expect(result.error).toBeUndefined();
3293
+ const refusal = toolresultframe({
3294
+ id: null,
3295
+ error: { code: "consentrefused", message: "The consent gates refused the tool call." },
3296
+ });
3297
+ expect(refusal.id).toBeNull();
3298
+ expect(refusal.error?.code).toBe("consentrefused");
3299
+ expect(refusal.result).toBeUndefined();
3300
+ });
3301
+
3302
+ it("carries tool errors with the json rpc error codes in the response envelope", () => {
3303
+ const plan: agentplan = {
3304
+ id: "plan",
3305
+ objective: "Run the tool calls",
3306
+ origin: "https://example.com",
3307
+ steps: [],
3308
+ createdat: 1,
3309
+ expiresat: 2,
3310
+ state: "approved",
3311
+ };
3312
+ const outcome: stepoutcome = { stepid: "s1", ok: false, summary: "The tool call was refused.", at: 5 };
3313
+ const refused = JSON.parse(
3314
+ outcomeresponse({
3315
+ outcome,
3316
+ plan,
3317
+ tool: {
3318
+ clientid: "client1",
3319
+ tool: "browser.click",
3320
+ origin: "https://example.com",
3321
+ ok: false,
3322
+ code: "consentrefused",
3323
+ },
3324
+ }),
3325
+ ) as { tool?: { clientid: string; tool: string; ok: boolean; code?: string } };
3326
+ expect(refused.tool).toEqual({
3327
+ clientid: "client1",
3328
+ tool: "browser.click",
3329
+ origin: "https://example.com",
3330
+ ok: false,
3331
+ code: "consentrefused",
3332
+ });
3333
+ const ran = JSON.parse(
3334
+ outcomeresponse({
3335
+ outcome: { ...outcome, ok: true },
3336
+ plan,
3337
+ tool: { clientid: "client1", tool: "browser.readtext", origin: "https://example.com", ok: true },
3338
+ }),
3339
+ ) as { tool?: { ok: boolean; code?: string } };
3340
+ expect(ran.tool?.ok).toBe(true);
3341
+ expect(ran.tool?.code).toBeUndefined();
3342
+ const without = JSON.parse(outcomeresponse({ outcome, plan })) as { tool?: unknown };
3343
+ expect(without.tool).toBeUndefined();
3344
+ });
3345
+ });
3346
+
3347
+ describe("agent protocol part two envelopes", () => {
3348
+ const now = 1_800_000_000_000;
3349
+
3350
+ it("builds the http stream transport report with its event channels", () => {
3351
+ const stream = {
3352
+ endpoint: "/mcp",
3353
+ streampath: "/mcp/stream",
3354
+ tls: { mode: "on" as const },
3355
+ heartbeatms: 15_000,
3356
+ idlewindowms: 45_000,
3357
+ };
3358
+ const report = httpstreamreport({
3359
+ stream,
3360
+ channels: [
3361
+ { id: "c1", clientid: "client1", openedat: now - 2000, lastbeatat: now - 1000 },
3362
+ { id: "c2", clientid: "client2", openedat: now - 2000, lastbeatat: now - 50_000 },
3363
+ ],
3364
+ now,
3365
+ });
3366
+ expect(report.endpoint).toBe("/mcp");
3367
+ expect(report.streampath).toBe("/mcp/stream");
3368
+ expect(report.heartbeatms).toBe(15_000);
3369
+ expect(report.idlewindowms).toBe(45_000);
3370
+ expect(report.channelsopen).toBe(1);
3371
+ expect(report.channelsdead).toBe(1);
3372
+ expect(
3373
+ httpstreamreport({
3374
+ stream: { endpoint: "/mcp", streampath: "/mcp/stream", tls: { mode: "off" } },
3375
+ channels: [],
3376
+ now,
3377
+ }).heartbeatms,
3378
+ ).toBe(30_000);
3379
+ });
3380
+
3381
+ it("builds the pairing and auth handshake frames for issued, verified and refused outcomes", () => {
3382
+ const challenge = { nonce: "nonce-1", method: "pairingcode" as const, issuedat: now, expiresat: now + 120_000 };
3383
+ const issued = pairingframes({ id: 1, challenge, outcome: "issued" });
3384
+ expect(issued.request.method).toBe("pairing");
3385
+ expect(issued.request.params?.nonce).toBe("nonce-1");
3386
+ expect((issued.response.result as { challenge?: string }).challenge).toBe("nonce-1");
3387
+ const verified = pairingframes({ id: 2, challenge, answer: "DT-CODE", outcome: "verified" });
3388
+ expect(verified.request.params?.answer).toBe("DT-CODE");
3389
+ expect((verified.response.result as { paired?: boolean }).paired).toBe(true);
3390
+ const refused = pairingframes({ id: 3, challenge, outcome: "refused" });
3391
+ expect(refused.response.error?.code).toBe("consentrefused");
3392
+ expect(refused.response.error?.message).toBe("The remote frame failed its authentication handshake.");
3393
+ });
3394
+
3395
+ it("builds the token report without raw tokens or digests", () => {
3396
+ const tokens = [
3397
+ {
3398
+ id: "t1",
3399
+ clientid: "client1",
3400
+ hash: "sha256:abc",
3401
+ scopes: ["browser" as const],
3402
+ issuedat: now - 1000,
3403
+ expiresat: now + 500_000,
3404
+ },
3405
+ {
3406
+ id: "t2",
3407
+ clientid: "client2",
3408
+ hash: "sha256:def",
3409
+ scopes: ["memory" as const],
3410
+ issuedat: now - 1000,
3411
+ expiresat: now + 500_000,
3412
+ revokedat: now - 500,
3413
+ },
3414
+ ];
3415
+ const report = tokenreport(tokens, now);
3416
+ expect(report.tokens).toHaveLength(2);
3417
+ expect(report.tokens[0]?.msremaining).toBe(500_000);
3418
+ expect(report.tokens[1]?.revokedat).toBe(now - 500);
3419
+ expect(JSON.stringify(report)).not.toContain("sha256:abc");
3420
+ expect(JSON.stringify(report)).not.toContain("raw");
3421
+ });
3422
+
3423
+ it("builds the approval gate request and response frames with redacted arguments", () => {
3424
+ const gate = {
3425
+ id: "gate-1",
3426
+ clientid: "client1",
3427
+ tool: "browser.type",
3428
+ reason: "Typing.",
3429
+ params: { stepid: "s1", value: "hunter2" },
3430
+ secretfields: ["value"],
3431
+ state: "pending" as const,
3432
+ raisedat: now,
3433
+ timeoutat: now + 120_000,
3434
+ };
3435
+ const pending = approvalframes({ id: 9, request: gate });
3436
+ expect(pending.raise.method).toBe("approval");
3437
+ expect(pending.raise.params?.approvalid).toBe("gate-1");
3438
+ expect(pending.raise.params?.arguments).toContain("[redacted]");
3439
+ expect(pending.raise.params?.arguments).not.toContain("hunter2");
3440
+ expect((pending.decision.result as { state?: string }).state).toBe("pending");
3441
+ const approved = approvalframes({
3442
+ id: 9,
3443
+ request: { ...gate, state: "approved" },
3444
+ identity: { fingerprint: "fp-1", displayname: "Laptop agent" },
3445
+ });
3446
+ expect((approved.decision.result as { client?: string }).client).toBe("Laptop agent");
3447
+ const refused = approvalframes({ id: 9, request: { ...gate, state: "refused" } });
3448
+ expect(refused.decision.error?.code).toBe("consentrefused");
3449
+ expect(refused.decision.error?.message).toMatch(/was refused/i);
3450
+ const expired = approvalframes({ id: 9, request: { ...gate, state: "expired" } });
3451
+ expect(expired.decision.error?.message).toMatch(/expired/i);
3452
+ });
3453
+
3454
+ it("builds the tls, allowlist and heartbeat reports", () => {
3455
+ expect(tlsreport({ mode: "required", certificatefingerprint: "sha256:aa", verifiedat: now })).toEqual({
3456
+ version: protocolversion,
3457
+ mode: "required",
3458
+ certificaterequired: true,
3459
+ verified: true,
3460
+ });
3461
+ expect(tlsreport({ mode: "off" })).toEqual({
3462
+ version: protocolversion,
3463
+ mode: "off",
3464
+ certificaterequired: false,
3465
+ verified: false,
3466
+ });
3467
+ const entries = [
3468
+ {
3469
+ fingerprint: "aa11",
3470
+ displayname: "Laptop agent",
3471
+ namespaces: ["browser" as const, "memory" as const],
3472
+ grantedat: now,
3473
+ history: [
3474
+ { at: now, actor: "user", change: "Granted." },
3475
+ { at: now + 1, actor: "user", change: "Rescoped." },
3476
+ ],
3477
+ },
3478
+ ];
3479
+ const allow = allowlistreport(entries);
3480
+ expect(allow.entries[0]?.namespaces).toEqual(["browser", "memory"]);
3481
+ expect(allow.entries[0]?.grants).toBe(2);
3482
+ const beats = heartbeatreport({
3483
+ channels: [
3484
+ { id: "c1", clientid: "client1", openedat: now, lastbeatat: now },
3485
+ { id: "c2", clientid: "client2", openedat: now - 1000, lastbeatat: now - 500 },
3486
+ ],
3487
+ now,
3488
+ idlewindow: 1000,
3489
+ });
3490
+ expect(beats.beats).toBe(1);
3491
+ expect(beats.open).toBe(2);
3492
+ expect(beats.dead).toBe(0);
3493
+ expect(heartbeatreport({ channels: [], now }).open).toBe(0);
3494
+ });
3495
+ });
3496
+
3497
+ describe("agent protocol part three envelopes", () => {
3498
+ const now = 1_800_000_000_000;
3499
+
3500
+ it("builds the event subscription frames and the event notification frames", () => {
3501
+ const subscription = {
3502
+ id: "sub1",
3503
+ clientid: "client1",
3504
+ kinds: ["callresult" as const, "progress" as const],
3505
+ origin: "https://example.com",
3506
+ createdat: now,
3507
+ };
3508
+ const frames = subscriptionframes({ id: 9, subscription });
3509
+ expect(frames.subscribe.method).toBe("events/subscribe");
3510
+ expect(frames.subscribe.params?.kinds).toEqual(["callresult", "progress"]);
3511
+ expect(frames.subscribe.params?.origin).toBe("https://example.com");
3512
+ expect(frames.unsubscribe.method).toBe("events/unsubscribe");
3513
+ expect(frames.unsubscribe.params?.subscriptionid).toBe("sub1");
3514
+ const notification = eventnotification({
3515
+ subscriptionid: "sub1",
3516
+ kind: "callresult",
3517
+ origin: "https://example.com",
3518
+ tool: "browser.readtext",
3519
+ payload: { ok: true },
3520
+ now,
3521
+ });
3522
+ expect(notification.method).toBe("events/notify");
3523
+ expect(notification.params?.payload).toEqual({ ok: true });
3524
+ expect(notification.id).toBeUndefined();
3525
+ });
3526
+
3527
+ it("builds the page state delta reports of the resource watchers", () => {
3528
+ const report = resourcedeltareport({
3529
+ watchid: "watch1",
3530
+ clientid: "client1",
3531
+ resource: "page",
3532
+ delta: { url: "https://example.com/next" },
3533
+ now,
3534
+ });
3535
+ expect(report).toMatchObject({
3536
+ watchid: "watch1",
3537
+ clientid: "client1",
3538
+ resource: "page",
3539
+ delta: { url: "https://example.com/next" },
3540
+ });
3541
+ expect(report.version).toBe(protocolversion);
3542
+ });
3543
+
3544
+ it("builds the sampling callback frames with the exact prompt payload", () => {
3545
+ const pending = {
3546
+ id: "sample1",
3547
+ clientid: "client1",
3548
+ prompt: "Summarize the run.",
3549
+ pagecontent: "the page text",
3550
+ state: "pending" as const,
3551
+ requestedat: now,
3552
+ };
3553
+ const pendingframes = samplingframes({ id: 4, request: pending });
3554
+ expect(pendingframes.request.method).toBe("sampling/request");
3555
+ expect(pendingframes.request.params?.prompt).toBe("Summarize the run.");
3556
+ expect(pendingframes.request.params?.pagecontent).toBe("the page text");
3557
+ expect(pendingframes.answer.params?.state).toBe("pending");
3558
+ const answered = samplingframes({
3559
+ id: 5,
3560
+ request: { ...pending, state: "answered", answeredat: now + 1, answer: "the model answer" },
3561
+ });
3562
+ expect(answered.answer.params?.answer).toBe("the model answer");
3563
+ const refused = samplingframes({ id: 6, request: { ...pending, state: "refused", answeredat: now + 1 } });
3564
+ expect(refused.answer.params?.refused).toBe(true);
3565
+ });
3566
+
3567
+ it("builds the prompt tool reports and the prompt call frames", () => {
3568
+ const report = promptreport({
3569
+ prompts: [
3570
+ {
3571
+ name: "runreview",
3572
+ description: "Review the run.",
3573
+ arguments: [{ name: "objective", description: "The plan objective.", required: true }],
3574
+ template: "Review {{objective}}.",
3575
+ },
3576
+ ],
3577
+ });
3578
+ expect(report.prompts[0]?.name).toBe("runreview");
3579
+ expect(report.prompts[0]?.template).toBe("Review {{objective}}.");
3580
+ const call = promptcallframe({ id: 7, name: "runreview", args: { objective: "fill the form" } });
3581
+ expect(call.method).toBe("prompts/call");
3582
+ expect(call.params?.arguments).toEqual({ objective: "fill the form" });
3583
+ });
3584
+
3585
+ it("builds the stream chunk and progress notice frames", () => {
3586
+ const chunk = streamchunkframe({ callid: "call1", seq: 2, content: "more text", done: false, at: now });
3587
+ expect(chunk.method).toBe("calls/stream");
3588
+ expect(chunk.params).toMatchObject({ callid: "call1", seq: 2, content: "more text", done: false });
3589
+ const notice = progressnoticeframe({
3590
+ callid: "call1",
3591
+ percent: 60,
3592
+ message: "Typing the reviewed field.",
3593
+ cancellable: true,
3594
+ at: now,
3595
+ });
3596
+ expect(notice.method).toBe("calls/progress");
3597
+ expect(notice.params).toMatchObject({ percent: 60, cancellable: true });
3598
+ });
3599
+
3600
+ it("builds the cancellation frames with the preserved partial result", () => {
3601
+ const frames = cancelframes({
3602
+ id: 8,
3603
+ frame: { callid: "call1", reason: "the user asked", at: now },
3604
+ partial: { content: "the first chunks", iserror: false },
3605
+ });
3606
+ expect(frames.cancel.method).toBe("calls/cancel");
3607
+ expect(frames.cancel.params?.reason).toBe("the user asked");
3608
+ expect(frames.cancelled.result).toMatchObject({
3609
+ cancelled: true,
3610
+ callid: "call1",
3611
+ partial: { content: "the first chunks" },
3612
+ });
3613
+ });
3614
+
3615
+ it("builds the structured error, rate limit, idempotency, batch, call log, in flight, dry run and mock reports", () => {
3616
+ const error = structurederrorreport({
3617
+ error: { code: "ratelimited", message: "The budget is spent.", retryhint: "wait", retryafter: 4000 },
3618
+ usage: { clientid: "client1", used: 5, budget: 5 },
3619
+ });
3620
+ expect(error).toMatchObject({ code: "ratelimited", retryhint: "wait", retryafter: 4000 });
3621
+ expect(error.usage).toEqual({ clientid: "client1", used: 5, budget: 5 });
3622
+ const limits = ratelimitreport({
3623
+ limits: [
3624
+ { clientid: "client1", windowms: 60_000, budget: 5, windowstartedat: now, used: 3 },
3625
+ { clientid: "client2", windowms: 30_000, windowstartedat: now, used: 0 },
3626
+ ],
3627
+ now,
3628
+ });
3629
+ expect(limits.limits[0]).toMatchObject({ used: 3, unbounded: false });
3630
+ expect(limits.limits[1]?.unbounded).toBe(true);
3631
+ const replay = idempotencyreplayframe({
3632
+ id: 11,
3633
+ key: "key-1",
3634
+ result: { content: "stored", iserror: false },
3635
+ originalat: now,
3636
+ });
3637
+ expect(replay.result).toMatchObject({ replayed: true, idempotencykey: "key-1" });
3638
+ const idempotency = idempotencyreport(
3639
+ [
3640
+ {
3641
+ key: "key-1",
3642
+ clientid: "client1",
3643
+ tool: "browser.readtext",
3644
+ result: { content: "stored", iserror: false },
3645
+ createdat: now,
3646
+ expiresat: now + 5000,
3647
+ },
3648
+ ],
3649
+ now + 6000,
3650
+ );
3651
+ expect(idempotency.records[0]?.live).toBe(false);
3652
+ const batch = batchreport({
3653
+ batch: {
3654
+ id: "batch1",
3655
+ clientid: "client1",
3656
+ calls: [
3657
+ { id: "m1", name: "browser.readtext", params: {} },
3658
+ { id: "m2", name: "memory.list", params: {} },
3659
+ ],
3660
+ stoponerror: true,
3661
+ state: "stopped",
3662
+ createdat: now,
3663
+ finishedat: now + 100,
3664
+ outcomes: [
3665
+ { callid: "m1", tool: "browser.readtext", ok: true, at: now + 1 },
3666
+ { callid: "m2", tool: "memory.list", ok: false, at: now + 2 },
3667
+ ],
3668
+ },
3669
+ });
3670
+ expect(batch).toMatchObject({ batchid: "batch1", state: "stopped", done: 2, total: 2 });
3671
+ const log = calllogreport({
3672
+ calls: [
3673
+ {
3674
+ id: "call1",
3675
+ clientid: "client1",
3676
+ tool: "browser.readtext",
3677
+ origin: "https://example.com",
3678
+ ok: true,
3679
+ at: now,
3680
+ idempotencykey: "key-1",
3681
+ replayed: true,
3682
+ },
3683
+ ],
3684
+ filters: { clientid: "client1" },
3685
+ });
3686
+ expect(log.calls[0]?.replayed).toBe(true);
3687
+ expect(log.filters.clientid).toBe("client1");
3688
+ const inflight = inflightreport({
3689
+ contexts: [
3690
+ {
3691
+ callid: "call1",
3692
+ clientid: "client1",
3693
+ tool: "browser.readtext",
3694
+ state: "inflight",
3695
+ startedat: now,
3696
+ chunks: 3,
3697
+ dryrun: true,
3698
+ },
3699
+ ],
3700
+ now: now + 5000,
3701
+ });
3702
+ expect(inflight.inflight[0]).toMatchObject({ chunks: 3, msopen: 5000, dryrun: true });
3703
+ const dry = dryrunreport({
3704
+ callid: "call1",
3705
+ tool: "browser.click",
3706
+ argsvalid: false,
3707
+ consentok: true,
3708
+ findings: ["The required argument stepid stays empty."],
3709
+ executed: false,
3710
+ mutations: [],
3711
+ at: now,
3712
+ });
3713
+ expect(dry.findings[0]).toMatch(/stepid stays empty/);
3714
+ const mocks = mockreport([
3715
+ { tool: "browser.readtext", result: { content: "canned", iserror: false }, testcontext: true, createdat: now },
3716
+ ]);
3717
+ expect(mocks.mocks[0]).toMatchObject({ tool: "browser.readtext", testcontext: true });
3718
+ });
3719
+ });
3720
+
3721
+ describe("protocol llm envelopes", () => {
3722
+ const now = 1_800_000_000_000;
3723
+
3724
+ it("carries a model drafted plan in the model proposal envelope for review", () => {
3725
+ const draft: plandraft = {
3726
+ id: "draft1",
3727
+ goal: "Read the docs page",
3728
+ steps: [
3729
+ { id: "s1", kind: "navigate", value: "https://docs.example", summary: "Open the docs page." },
3730
+ { id: "t1", kind: "reload", summary: "Reload.", freshreview: true },
3731
+ ],
3732
+ openquestions: ["which section?"],
3733
+ providerid: "prov1",
3734
+ model: "model-a",
3735
+ state: "draft",
3736
+ lintfindings: ["The drafted step s9 violates the action grammar."],
3737
+ createdat: now,
3738
+ };
3739
+ const envelope = modelproposal({ draft });
3740
+ expect(envelope.version).toBe(protocolversion);
3741
+ expect(envelope.modelproposal.draftid).toBe("draft1");
3742
+ expect(envelope.modelproposal.steps[1]?.freshreview).toBe(true);
3743
+ expect(envelope.modelproposal.openquestions).toEqual(["which section?"]);
3744
+ expect(envelope.modelproposal.lintfindings).toHaveLength(1);
3745
+ expect(envelope.modelproposal.state).toBe("draft");
3746
+ });
3747
+
3748
+ it("reports the usage totals and guard verdicts in the model outcome envelope", () => {
3749
+ const outputs: modeloutput[] = [
3750
+ { raw: "{}", parsed: { intent: "navigate" }, verdict: "valid", attempts: 1 },
3751
+ { raw: "nope", verdict: "invalid", reason: "The model answer is not json.", attempts: 3 },
3752
+ ];
3753
+ const envelope = modeloutcome({
3754
+ runid: "run1",
3755
+ outputs,
3756
+ totals: { prompttokens: 10, completiontokens: 5, totaltokens: 15, cost: 0.02, calls: 2 },
3757
+ });
3758
+ expect(envelope.version).toBe(protocolversion);
3759
+ expect(envelope.modeloutcome.runid).toBe("run1");
3760
+ expect(envelope.modeloutcome.usage.totaltokens).toBe(15);
3761
+ expect(envelope.modeloutcome.guards[1]).toMatchObject({ verdict: "invalid", attempts: 3 });
3762
+ });
3763
+ });
3764
+
3765
+ describe("protocol swarm envelopes", () => {
3766
+ const now = 1_800_000_000_000;
3767
+
3768
+ it("carries the agents, the task queue and the mailboxes in the swarm state report", () => {
3769
+ const state: swarmstate = {
3770
+ agents: [
3771
+ {
3772
+ id: "a1",
3773
+ name: "Scout",
3774
+ role: "worker",
3775
+ depth: 0,
3776
+ state: "active",
3777
+ tabid: 7,
3778
+ registeredat: now,
3779
+ heartbeatat: now,
3780
+ },
3781
+ {
3782
+ id: "a2",
3783
+ name: "Scout sub 1",
3784
+ role: "planner",
3785
+ depth: 1,
3786
+ state: "paused",
3787
+ parentid: "a1",
3788
+ registeredat: now,
3789
+ },
3790
+ ],
3791
+ queue: {
3792
+ lanes: ["extraction"],
3793
+ priorities: [1, 2],
3794
+ completionpolicy: "all",
3795
+ items: [
3796
+ {
3797
+ id: "t1",
3798
+ lane: "extraction",
3799
+ priority: 2,
3800
+ payload: "Read the pricing table",
3801
+ state: "claimed",
3802
+ enqueuedat: now,
3803
+ },
3804
+ ],
3805
+ claims: [{ agentid: "a1", taskid: "t1", claimedat: now, heartbeatat: now }],
3806
+ },
3807
+ mailboxes: [
3808
+ {
3809
+ agentid: "a1",
3810
+ inbox: [{ id: "m1", senderid: "a2", recipient: "a1", routing: "direct", payload: "Ready.", sentat: now }],
3811
+ outbox: [],
3812
+ unread: 1,
3813
+ },
3814
+ ],
3815
+ killswitch: { engaged: true, engagedat: now, reason: "The user halted the swarm." },
3816
+ };
3817
+ const report = swarmstatereport(state);
3818
+ expect(report.version).toBe(protocolversion);
3819
+ expect(report.swarm.agents[0]).toMatchObject({ id: "a1", name: "Scout", role: "worker", tabid: 7 });
3820
+ expect(report.swarm.agents[1]?.parentid).toBe("a1");
3821
+ expect(report.swarm.queue.lanes).toEqual(["extraction"]);
3822
+ expect(report.swarm.queue.items[0]).toMatchObject({ payload: "Read the pricing table", state: "claimed" });
3823
+ expect(report.swarm.queue.claims[0]).toMatchObject({ agentid: "a1", taskid: "t1" });
3824
+ expect(report.swarm.mailboxes[0]).toMatchObject({ agentid: "a1", unread: 1, inbox: 1, outbox: 0 });
3825
+ expect(report.swarm.killswitch).toMatchObject({ engaged: true, reason: "The user halted the swarm." });
3826
+ });
3827
+
3828
+ it("builds the agent event notification frame of one lifecycle change", () => {
3829
+ const frame = agenteventframe({
3830
+ id: "e1",
3831
+ kind: "claimed",
3832
+ agentid: "a1",
3833
+ taskid: "t1",
3834
+ summary: "The agent claimed the task.",
3835
+ at: now,
3836
+ });
3837
+ expect(frame.method).toBe("agents/notify");
3838
+ expect(frame.params).toMatchObject({
3839
+ eventid: "e1",
3840
+ kind: "claimed",
3841
+ agentid: "a1",
3842
+ taskid: "t1",
3843
+ summary: "The agent claimed the task.",
3844
+ at: now,
3845
+ });
3846
+ const bare = agenteventframe({ id: "e2", kind: "killall", summary: "The user halted the swarm.", at: now });
3847
+ expect(bare.params).not.toHaveProperty("agentid");
3848
+ expect(bare.params).not.toHaveProperty("taskid");
3849
+ });
3850
+ });
3851
+
3852
+ describe("protocol multi agent part two envelopes", () => {
3853
+ const now = 1_800_000_000_000;
3854
+
3855
+ it("builds the boardstate snapshot for dashboards with every lane and milestone", () => {
3856
+ const snapshot = boardstatesnapshot({
3857
+ id: "board1",
3858
+ builtat: now,
3859
+ lanes: [
3860
+ {
3861
+ agentid: "a1",
3862
+ name: "Scout",
3863
+ role: "worker",
3864
+ state: "active",
3865
+ lane: "extraction",
3866
+ currenttask: "Read the table",
3867
+ milestones: [{ label: "Rows read", done: true, at: now }],
3868
+ },
3869
+ { agentid: "a2", name: "Scribe", role: "critic", state: "active", lane: "critic", milestones: [] },
3870
+ ],
3871
+ });
3872
+ expect(snapshot.version).toBe(protocolversion);
3873
+ expect(snapshot.board.lanes).toHaveLength(2);
3874
+ expect(snapshot.board.lanes[0]).toMatchObject({ agentid: "a1", lane: "extraction", currenttask: "Read the table" });
3875
+ expect(snapshot.board.lanes[0]?.milestones[0]).toMatchObject({ label: "Rows read", done: true, at: now });
3876
+ expect(snapshot.board.lanes[1]?.currenttask).toBeUndefined();
3877
+ });
3878
+
3879
+ it("builds the handoff frame that pushes one tab handoff state", () => {
3880
+ const frame = handoffframe({
3881
+ id: "h1",
3882
+ fromagentid: "a1",
3883
+ toagentid: "a2",
3884
+ tabid: 7,
3885
+ taskstate: "Halfway through the footer.",
3886
+ state: "transferred",
3887
+ createdat: now,
3888
+ transferredat: now + 1,
3889
+ });
3890
+ expect(frame.method).toBe("agents/handoff");
3891
+ expect(frame.params).toMatchObject({
3892
+ id: "h1",
3893
+ from: "a1",
3894
+ to: "a2",
3895
+ tabid: 7,
3896
+ taskstate: "Halfway through the footer.",
3897
+ state: "transferred",
3898
+ transferredat: now + 1,
3899
+ });
3900
+ const resumed = handoffframe({
3901
+ id: "h1",
3902
+ fromagentid: "a1",
3903
+ toagentid: "a2",
3904
+ taskstate: "Halfway.",
3905
+ state: "resumed",
3906
+ createdat: now,
3907
+ transferredat: now + 1,
3908
+ resumedat: now + 2,
3909
+ });
3910
+ expect(resumed.params).toMatchObject({ state: "resumed", resumedat: now + 2 });
3911
+ const bare = handoffframe({
3912
+ id: "h2",
3913
+ fromagentid: "a1",
3914
+ toagentid: "a2",
3915
+ taskstate: "State.",
3916
+ state: "prepared",
3917
+ createdat: now,
3918
+ });
3919
+ expect(bare.params).not.toHaveProperty("transferredat");
3920
+ expect(bare.params).not.toHaveProperty("resumedat");
3921
+ });
3922
+
3923
+ it("builds the review frame with the routed request and its optional critic verdict", () => {
3924
+ const frame = reviewframe({
3925
+ request: {
3926
+ id: "r1",
3927
+ fromagentid: "a1",
3928
+ toagentid: "a2",
3929
+ subject: "The extraction output",
3930
+ payload: "The rows",
3931
+ state: "answered",
3932
+ requestedat: now,
3933
+ ackedat: now + 1,
3934
+ answeredat: now + 2,
3935
+ },
3936
+ review: {
3937
+ id: "r1",
3938
+ reviewerid: "a2",
3939
+ subjectagentid: "a1",
3940
+ verdict: "changes",
3941
+ issues: ["One stale row."],
3942
+ requiredchanges: ["Re-read the footer."],
3943
+ reviewedat: now + 2,
3944
+ },
3945
+ });
3946
+ expect(frame.method).toBe("agents/review");
3947
+ expect(frame.params).toMatchObject({
3948
+ id: "r1",
3949
+ from: "a1",
3950
+ to: "a2",
3951
+ subject: "The extraction output",
3952
+ state: "answered",
3953
+ verdict: "changes",
3954
+ });
3955
+ expect(frame.params).toMatchObject({ issues: ["One stale row."], requiredchanges: ["Re-read the footer."] });
3956
+ const bare = reviewframe({
3957
+ request: {
3958
+ id: "r2",
3959
+ fromagentid: "a1",
3960
+ toagentid: "a2",
3961
+ subject: "s",
3962
+ payload: "p",
3963
+ state: "open",
3964
+ requestedat: now,
3965
+ },
3966
+ });
3967
+ expect(bare.params).not.toHaveProperty("verdict");
3968
+ expect(bare.params).not.toHaveProperty("ackedat");
3969
+ });
3970
+ });
3971
+
3972
+ describe("execution environment envelopes", () => {
3973
+ it("documents the environment grammar of every reviewed action kind", () => {
3974
+ const grammar = environmentgrammar();
3975
+ expect(grammar.version).toBe(protocolversion);
3976
+ expect(grammar.kinds.length).toBeGreaterThan(330);
3977
+ expect(grammar.kinds.find((entry) => entry.kind === "evaluate")?.environments).toEqual(["isolatedworld"]);
3978
+ expect(grammar.kinds.find((entry) => entry.kind === "readhtml")?.environments).toEqual([
3979
+ "pagecontext",
3980
+ "offscreenworker",
3981
+ ]);
3982
+ expect(grammar.notes.join(" ")).toMatch(/isolated world/);
3983
+ expect(grammar.notes.join(" ")).toMatch(/sandboxframe/);
3984
+ expect(grammar.notes.join(" ")).toMatch(/bypasses the human review/);
3985
+ });
3986
+
3987
+ it("wraps the environment view with the step environments, turnarounds, offscreen registry, workers and keepalive", () => {
3988
+ const report = environmentreport({
3989
+ environments: { s1: "pagecontext", s2: "offscreenworker" },
3990
+ turnarounds: { s2: 42 },
3991
+ offscreen: [
3992
+ {
3993
+ document: "offscreen.html",
3994
+ runid: "run1",
3995
+ reasons: ["DOM_PARSER", "WORKERS"],
3996
+ justification: "Heavy parsing of reviewed snapshots.",
3997
+ createdat: 1,
3998
+ },
3999
+ ],
4000
+ workers: 3,
4001
+ keepalive: { runid: "run1", state: "active", beats: 5, lastbeatat: 99, portopen: true },
4002
+ });
4003
+ expect(report.version).toBe(protocolversion);
4004
+ expect(report.environments).toEqual([
4005
+ { stepid: "s1", environment: "pagecontext" },
4006
+ { stepid: "s2", environment: "offscreenworker" },
4007
+ ]);
4008
+ expect(report.turnarounds).toEqual([{ stepid: "s2", milliseconds: 42 }]);
4009
+ expect(report.offscreen[0]?.reasons).toEqual(["DOM_PARSER", "WORKERS"]);
4010
+ expect(report.workers).toBe(3);
4011
+ expect(report.keepalive).toMatchObject({ runid: "run1", beats: 5, portopen: true });
4012
+ const empty = environmentreport({ environments: {} });
4013
+ expect(empty.environments).toEqual([]);
4014
+ expect(empty.turnarounds).toEqual([]);
4015
+ expect(empty.offscreen).toEqual([]);
4016
+ expect(empty.workers).toBe(0);
4017
+ expect(empty.keepalive).toBeUndefined();
4018
+ });
4019
+
4020
+ it("returns the execution environment beside the step outcome in the response envelope", () => {
4021
+ const plan = {
4022
+ id: "p1",
4023
+ objective: "o",
4024
+ origin: "https://example.com",
4025
+ steps: [],
4026
+ createdat: 1,
4027
+ expiresat: 2,
4028
+ state: "approved" as const,
4029
+ };
4030
+ const envelope = JSON.parse(
4031
+ outcomeresponse({
4032
+ outcome: { stepid: "s1", ok: true, summary: "Done.", environment: "offscreenworker", at: 1 },
4033
+ plan,
4034
+ }),
4035
+ ) as { outcome: { environment?: string } };
4036
+ expect(envelope.outcome.environment).toBe("offscreenworker");
4037
+ });
4038
+ });
4039
+
4040
+ describe("security envelopes", () => {
4041
+ it("documents the consent model grammar with the denydefault posture and the four sensitive classes", () => {
4042
+ const model = consentmodel();
4043
+ expect(model.version).toBe(protocolversion);
4044
+ expect(model.posture).toBe("denydefault");
4045
+ expect(model.sensitiveclasses).toEqual(["payment", "credential", "delete", "publish"]);
4046
+ expect(model.maskshapes).toEqual(["password", "token", "card", "secret"]);
4047
+ expect(model.notes.join(" ")).toMatch(/no wildcard expansion/i);
4048
+ expect(model.notes.join(" ")).toMatch(/defaults to unlimited/i);
4049
+ expect(model.notes.join(" ")).toMatch(/revokerun is a terminal session event/i);
4050
+ expect(model.notes.join(" ")).toMatch(/loghash/i);
4051
+ });
4052
+
4053
+ it("wraps the security view in the versioned response envelope", () => {
4054
+ const envelope = securityreport({
4055
+ allowlist: [{ origin: "https://example.com", profileid: "default", grantedat: 1 }],
4056
+ profiles: [],
4057
+ windows: [
4058
+ {
4059
+ id: "w1",
4060
+ sessionid: "s1",
4061
+ origin: "https://example.com",
4062
+ startedat: 1,
4063
+ duration: 60_000,
4064
+ expiresat: 61_000,
4065
+ boundary: "60000 milliseconds the user chose",
4066
+ kinds: [],
4067
+ state: "active",
4068
+ },
4069
+ ],
4070
+ consents: [{ id: "c1", origin: "https://example.com", sensitiveclass: "payment", grantedat: 1 }],
4071
+ revocations: [],
4072
+ maskrules: [],
4073
+ chain: [{ runid: "run1", valid: true, entries: 2, reason: "The hash chain verifies." }],
4074
+ });
4075
+ expect(envelope.version).toBe(protocolversion);
4076
+ expect(envelope.posture).toBe("denydefault");
4077
+ expect(envelope.allowlist).toHaveLength(1);
4078
+ expect(envelope.windows[0]?.state).toBe("active");
4079
+ expect(envelope.chain[0]?.valid).toBe(true);
4080
+ });
4081
+
4082
+ it("wraps one run log chain verification with the seal hash when the log sealed", () => {
4083
+ const openchain = logchainreport({ runid: "run1", valid: true, entries: 3, reason: "The hash chain verifies." });
4084
+ expect(openchain.version).toBe(protocolversion);
4085
+ expect(openchain.sealhash).toBeUndefined();
4086
+ const sealed = logchainreport({
4087
+ runid: "run2",
4088
+ valid: false,
4089
+ entries: 3,
4090
+ brokenat: 1,
4091
+ reason: "The chain reports tamper evidence.",
4092
+ });
4093
+ expect(sealed.brokenat).toBe(1);
4094
+ expect(sealed.valid).toBe(false);
4095
+ });
4096
+ });
4097
+
4098
+ import { transparencyreport } from "../protocol.js";
4099
+
4100
+ describe("protocol transparency", () => {
4101
+ it("serves the transparencypage data in one versioned envelope with the denydefault posture", () => {
4102
+ const report = transparencyreport({
4103
+ grants: [
4104
+ {
4105
+ origin: "https://example.com",
4106
+ scope: "automation allowlist",
4107
+ boundary: "the user revokes the entry",
4108
+ grantedat: 1,
4109
+ },
4110
+ ],
4111
+ windows: [
4112
+ {
4113
+ id: "w",
4114
+ origin: "https://example.com",
4115
+ state: "closed",
4116
+ boundary: "1000 milliseconds the user chose",
4117
+ startedat: 1,
4118
+ expiresat: 2,
4119
+ },
4120
+ ],
4121
+ connectallow: [{ senderid: "sender", displayname: "Bridge", addedat: 1 }],
4122
+ permdiffs: [
4123
+ { fromversion: "1.1.61", toversion: "1.1.62", added: ["optional:downloads"], removed: [], computedat: 1 },
4124
+ ],
4125
+ safedefaults: [{ origin: "https://first.example", firstseenat: 1 }],
4126
+ vault: [{ vaultid: "v", label: "Bank login", scope: "https://bank.example", provenance: "user", createdat: 1 }],
4127
+ });
4128
+ expect(report.version).toBe(consentmodel().version);
4129
+ expect(report.posture).toBe("denydefault");
4130
+ expect(report.grants[0]?.origin).toBe("https://example.com");
4131
+ expect(report.permdiffs[0]?.added).toEqual(["optional:downloads"]);
4132
+ expect(report.vault[0]?.label).toBe("Bank login");
4133
+ });
4134
+
4135
+ it("wraps one interface surface snapshot in the versioned envelope", () => {
4136
+ const report = surfacesnapshot({
4137
+ surface: "sidepanel",
4138
+ palette: [
4139
+ {
4140
+ entry: {
4141
+ id: "starttask",
4142
+ label: "Start task",
4143
+ keywords: ["task"],
4144
+ action: { command: "starttask", surface: "popup" },
4145
+ },
4146
+ score: 100,
4147
+ reason: "The query matches the starttask command exactly.",
4148
+ },
4149
+ ],
4150
+ timeline: [{ stepid: "s1", kind: "readtable", status: "running", active: true, anchor: "#step-s1" }],
4151
+ logstream: {
4152
+ events: [
4153
+ {
4154
+ id: "e1",
4155
+ level: "info",
4156
+ source: "background",
4157
+ origin: "https://example.com",
4158
+ summary: "The session started.",
4159
+ masked: false,
4160
+ maskverdict: "none",
4161
+ at: 1,
4162
+ },
4163
+ ],
4164
+ chainvalid: true,
4165
+ reason: "The logstream chain of 1 event verifies link by link.",
4166
+ },
4167
+ plancards: [
4168
+ {
4169
+ risk: "sensitive",
4170
+ cards: [
4171
+ {
4172
+ stepid: "s3",
4173
+ kind: "type",
4174
+ risk: "sensitive",
4175
+ environment: "pagecontext",
4176
+ options: "",
4177
+ summary: "Type the card.",
4178
+ corrections: [],
4179
+ editable: true,
4180
+ },
4181
+ ],
4182
+ expanded: true,
4183
+ },
4184
+ ],
4185
+ onboarding: { stepscompleted: ["origingrants"], done: false },
4186
+ });
4187
+ expect(report.version).toBe(consentmodel().version);
4188
+ expect(report.surface).toBe("sidepanel");
4189
+ expect(report.palette[0]?.entry.id).toBe("starttask");
4190
+ expect(report.timeline[0]?.status).toBe("running");
4191
+ expect(report.logstream.chainvalid).toBe(true);
4192
+ expect(report.plancards[0]?.risk).toBe("sensitive");
4193
+ expect(report.onboarding?.done).toBe(false);
4194
+ });
4195
+
4196
+ it("wraps the finishing interface snapshot in the versioned envelope", () => {
4197
+ const report = interfaceviews({
4198
+ exportmenu: [{ format: "csv", scope: "run", destination: "download" }],
4199
+ badge: { state: "attention", waitingcount: 2, runid: "run-1" },
4200
+ recenttray: [
4201
+ {
4202
+ runid: "run-1",
4203
+ origin: "https://example.com",
4204
+ outcome: "halted",
4205
+ title: "Pricing pass",
4206
+ at: 1,
4207
+ resumable: true,
4208
+ reopenable: false,
4209
+ },
4210
+ ],
4211
+ toasts: { live: [{ id: "t-1", stepid: "s-1", kind: "click", durationms: 40, at: 1 }], total: 3 },
4212
+ appearance: { mode: "dark", tokens: { surface: "#1f1f1f" }, source: "os" },
4213
+ });
4214
+ expect(report.version).toBe(protocolversion);
4215
+ expect(report.badge.state).toBe("attention");
4216
+ expect(report.recenttray[0]?.resumable).toBe(true);
4217
+ expect(report.toasts.total).toBe(3);
4218
+ expect(report.appearance.source).toBe("os");
4219
+ expect(report.datagrid).toBeUndefined();
4220
+ });
4221
+
4222
+ it("wraps the ecosystem snapshot in the versioned envelope", () => {
4223
+ const report = ecosystemviews({
4224
+ library: [
4225
+ {
4226
+ id: "invoice@1.0.0",
4227
+ title: "Invoice digest",
4228
+ publisher: "example publisher",
4229
+ version: "1.0.0",
4230
+ grants: ["https://example.com"],
4231
+ sensitive: false,
4232
+ state: "available",
4233
+ registry: "https://registry.example.test",
4234
+ },
4235
+ ],
4236
+ installed: [{ id: "invoice@1.0.0", title: "Invoice digest", version: "1.0.0", forkable: true }],
4237
+ syncbridge: {
4238
+ hooks: [
4239
+ {
4240
+ id: "sync:file:1",
4241
+ provider: "file",
4242
+ direction: "both",
4243
+ optin: false,
4244
+ endpoint: "manifests.json",
4245
+ state: "idle",
4246
+ },
4247
+ ],
4248
+ conflicts: [{ id: "conflict-1", manifestid: "invoice", localversion: "1.0.0", remoteversion: "2.0.0" }],
4249
+ },
4250
+ attention: [
4251
+ {
4252
+ id: "attention:gatewait:run1:g1",
4253
+ cause: "gatewait",
4254
+ severity: "critical",
4255
+ runid: "run1",
4256
+ summary: "A gate waits for one human action.",
4257
+ deeplink: "devthink://gate/g1?run=run1",
4258
+ at: 1,
4259
+ },
4260
+ ],
4261
+ backgroundruns: [
4262
+ {
4263
+ id: "background:wf:1",
4264
+ workflowid: "wf",
4265
+ state: "running",
4266
+ keepaliveheld: true,
4267
+ progress: "Running with the keepalive signal held",
4268
+ },
4269
+ ],
4270
+ replay: {
4271
+ runid: "run1",
4272
+ cursor: 1,
4273
+ playing: false,
4274
+ steps: [
4275
+ { stepid: "s1", index: 0, summary: "completed" },
4276
+ { stepid: "s2", index: 1, summary: "waited at a gate" },
4277
+ ],
4278
+ restored: { stepid: "s2", summary: "The gate resolved after a human action.", captureid: "c-99" },
4279
+ },
4280
+ compare: {
4281
+ runids: ["runa", "runb"],
4282
+ metrics: ["agreement", "divergence", "durationdelta"],
4283
+ firstdivergence: 1,
4284
+ steps: [
4285
+ {
4286
+ stepid: "s2",
4287
+ agreement: "diverge",
4288
+ summarya: "clicked",
4289
+ summaryb: "clicked twice",
4290
+ durationdelta: -100,
4291
+ highlighted: true,
4292
+ },
4293
+ ],
4294
+ },
4295
+ });
4296
+ expect(report.version).toBe(protocolversion);
4297
+ expect(report.library[0]?.publisher).toBe("example publisher");
4298
+ expect(report.installed[0]?.forkable).toBe(true);
4299
+ expect(report.syncbridge.hooks[0]?.optin).toBe(false);
4300
+ expect(report.syncbridge.conflicts[0]?.remoteversion).toBe("2.0.0");
4301
+ expect(report.attention[0]?.deeplink).toBe("devthink://gate/g1?run=run1");
4302
+ expect(report.backgroundruns[0]?.keepaliveheld).toBe(true);
4303
+ expect(report.replay?.restored.captureid).toBe("c-99");
4304
+ expect(report.compare?.steps[0]?.highlighted).toBe(true);
4305
+ });
4306
+ });
4307
+
4308
+ /* ── The protocolv2 api freeze of the 1.1.91 release. ── */
4309
+ import { readFile, readdir } from "node:fs/promises";
4310
+ import {
4311
+ errorcodetable,
4312
+ framingrules,
4313
+ frozenmessagecatalog,
4314
+ frozenmessagecarriers,
4315
+ memoryitemframe,
4316
+ responseenvelopeoutcomes,
4317
+ stabilityrules,
4318
+ } from "../protocol.js";
4319
+ import { initialize, negotiate, handleframe, servercapabilities } from "../mcp.js";
4320
+ import { buildtoolcatalog } from "../tools.js";
4321
+ import { defaultmcpconfig } from "../mcp.js";
4322
+ import { deprecationwindow, protocolsupported } from "../apifreeze.js";
4323
+ import type { agentsession, clientrecord, jsonrpcframe, toolcatalog } from "../types.js";
4324
+
4325
+ const freezenow = 1_800_000_000_000;
4326
+ const freezeconfig = { ...defaultmcpconfig(), enabled: true };
4327
+ const freezecatalog: toolcatalog = buildtoolcatalog();
4328
+ const freezesession: agentsession = {
4329
+ id: "sess",
4330
+ tabid: 7,
4331
+ origin: "https://example.com",
4332
+ startedat: freezenow - 5000,
4333
+ expiresat: freezenow + 600_000,
4334
+ grants: ["https://example.com"],
4335
+ };
4336
+ const freezeplan: agentplan = {
4337
+ id: "plan1",
4338
+ objective: "Validate the frozen contracts",
4339
+ origin: "https://example.com",
4340
+ steps: [{ id: "s1", kind: "observe", summary: "Snapshot the tab", risk: "read" }],
4341
+ createdat: freezenow - 4000,
4342
+ expiresat: freezenow + 600_000,
4343
+ state: "approved",
4344
+ };
4345
+ const v2client: clientrecord = {
4346
+ id: "v2client",
4347
+ transport: "stdio",
4348
+ paired: true,
4349
+ connectedat: freezenow - 1000,
4350
+ pairedat: freezenow - 500,
4351
+ capabilities: {
4352
+ protocolversion: "2.0.0",
4353
+ name: "v2client",
4354
+ version: "2",
4355
+ toolversion: freezecatalog.version,
4356
+ tools: 0,
4357
+ namespaces: ["browser"],
4358
+ transports: ["stdio"],
4359
+ },
4360
+ };
4361
+ const v1client: clientrecord = {
4362
+ ...v2client,
4363
+ id: "v1client",
4364
+ capabilities: {
4365
+ protocolversion: "1.1.54",
4366
+ name: "v1client",
4367
+ version: "1",
4368
+ toolversion: freezecatalog.version,
4369
+ tools: 0,
4370
+ namespaces: ["browser"],
4371
+ transports: ["stdio"],
4372
+ },
4373
+ };
4374
+
4375
+ /** Builds one json rpc request frame for the freeze tests. */
4376
+ function freezeframe(id: number | string, method: string, params?: Record<string, unknown>): jsonrpcframe {
4377
+ return { jsonrpc: "2.0", id, method, ...(params !== undefined ? { params } : {}) };
4378
+ }
4379
+
4380
+ describe("the protocolv2 frozen contract of 1.1.91", () => {
4381
+ it("validates every frozen message against its schema file", async () => {
4382
+ if (!existsSync("dist/schemas"))
4383
+ return; /* the schema artifacts ride the build: the pass runs after pnpm build in the validate chain and the ci lanes */
4384
+ const schemafiles = (await readdir("dist/schemas")).filter((file) => file.endsWith(".json")).sort();
4385
+ expect(schemafiles).toHaveLength(10);
4386
+ const catalogschemas = new Set(frozenmessagecatalog.map((entry) => entry.schema));
4387
+ for (const file of schemafiles) expect(catalogschemas.has(`schemas/${file}`)).toBe(true);
4388
+ for (const entry of frozenmessagecatalog) {
4389
+ const schema = JSON.parse(await readFile(`dist/${entry.schema}`, "utf8")) as {
4390
+ $id?: string;
4391
+ version?: string;
4392
+ title?: string;
4393
+ description?: string;
4394
+ };
4395
+ expect(schema.$id).toContain("protocolv2");
4396
+ expect(schema.version).toMatch(/^\d+\.\d+\.\d+$/);
4397
+ expect(schema.description?.toLowerCase()).toContain(entry.family);
4398
+ expect(frozenmessagecarriers(entry).length).toBeGreaterThan(0);
4399
+ expect(frozenmessagecarriers(entry).join(",")).not.toContain("nowhere");
4400
+ }
4401
+ });
4402
+
4403
+ it("wraps concrete messages in the versioned envelope their schema freezes", async () => {
4404
+ if (!existsSync("dist/schemas"))
4405
+ return; /* the schema artifacts ride the build: the pass runs after pnpm build in the validate chain and the ci lanes, and a lane that skipped the build asserts the frozen catalog above and leaves the envelope walk to the chain that builds first */
4406
+ const planschema = JSON.parse(await readFile("dist/schemas/plan.schema.json", "utf8")) as {
4407
+ plan?: { properties?: Record<string, { type?: string }> };
4408
+ };
4409
+ const proposal = parseproposal(
4410
+ {
4411
+ version: protocolversion,
4412
+ plan: {
4413
+ objective: "Read the page",
4414
+ steps: [{ kind: "observe", summary: "Capture an approved page snapshot" }],
4415
+ },
4416
+ },
4417
+ "https://example.com",
4418
+ );
4419
+ for (const field of ["id", "objective", "origin", "steps", "createdat", "expiresat", "state"])
4420
+ expect(planschema.plan?.properties?.[field]).toBeDefined();
4421
+ expect(planschema.plan?.properties?.createdat?.type).toBe("number");
4422
+ expect(proposal.version).toBe(protocolversion);
4423
+ expect(proposal.plan.state).toBe("pending");
4424
+ const memoryschema = JSON.parse(await readFile("dist/schemas/memory.schema.json", "utf8")) as {
4425
+ provenancefields?: { fields?: string[] };
4426
+ };
4427
+ const provenance = { origin: "https://example.com", runid: "run1", stepid: "step1", capturedat: 1 };
4428
+ for (const field of memoryschema.provenancefields?.fields ?? []) expect(provenance).toHaveProperty(field);
4429
+ const item = memoryitemframe({ version: protocolversion, key: "k", value: 1, provenance });
4430
+ expect(item.provenance.runid).toBe("run1");
4431
+ const envelopeschema = JSON.parse(await readFile("dist/schemas/envelope.schema.json", "utf8")) as {
4432
+ outcomes?: { values?: string[] };
4433
+ errorcodetable?: { items?: Array<{ code?: string; retry?: string }> };
4434
+ };
4435
+ expect(envelopeschema.outcomes?.values).toEqual(["success", "error", "cancel"]);
4436
+ expect(responseenvelopeoutcomes).toEqual(["success", "error", "cancel"]);
4437
+ const envelope = JSON.parse(
4438
+ outcomeresponse({ outcome: { stepid: "step", ok: true, summary: "Done.", at: 5 }, plan: freezeplan }),
4439
+ );
4440
+ expect(envelope.version).toBe(protocolversion);
4441
+ for (const entry of errorcodetable)
4442
+ expect(
4443
+ envelopeschema.errorcodetable?.items?.some((item) => item.code === entry.code && item.retry === entry.retry),
4444
+ ).toBe(true);
4445
+ for (const entry of errorcodetable) expect(["never", "immediate", "afterbackoff"]).toContain(entry.retry);
4446
+ expect(framingrules.stdio).toContain("newline");
4447
+ expect(framingrules.http).toContain("http post");
4448
+ expect(stabilityrules.additive).toContain("additive");
4449
+ expect(stabilityrules.breaking).toContain("new major protocol version");
4450
+ });
4451
+
4452
+ it("covers version negotiation for protocol one and two clients", () => {
4453
+ const server = servercapabilities({ config: freezeconfig, catalog: freezecatalog });
4454
+ const handshake = initialize({ config: freezeconfig, catalog: freezecatalog });
4455
+ expect(handshake.protocolmajor).toBe(2);
4456
+ expect(handshake.protocolversion).toBe(protocolversion);
4457
+ const undeclared = negotiate({ server });
4458
+ expect(undeclared.agreed).toBe(true);
4459
+ expect(undeclared.protocolmajor).toBe(2);
4460
+ expect(undeclared.deprecation).toBeUndefined();
4461
+ /* the 2.0.0 sunset closed the window: the deprecated full version string the version one clients exchanged parses nowhere, so a client that still sends it answers the frozen default of two without a deprecation notice */
4462
+ const stringform = negotiate({ client: { protocolversion: "1.1.54" }, server });
4463
+ expect(stringform.agreed).toBe(true);
4464
+ expect(stringform.protocolmajor).toBe(2);
4465
+ expect(stringform.deprecation).toBeUndefined();
4466
+ /* the numeric declaration is the frozen form: a major one declaration refuses below the supported floor with the conversion path inside the mismatch */
4467
+ const legacy = negotiate({ client: { protocolmajor: 1 }, server });
4468
+ expect(legacy.agreed).toBe(false);
4469
+ expect(legacy.mismatch).toContain("below the supported floor");
4470
+ expect(legacy.mismatch).toContain("migrateplan");
4471
+ expect(legacy.mismatch).toContain("docs/migrationguide.md");
4472
+ const frozen = negotiate({ client: { protocolmajor: 2 }, server });
4473
+ expect(frozen.agreed).toBe(true);
4474
+ expect(frozen.protocolmajor).toBe(2);
4475
+ expect(frozen.deprecation).toBeUndefined();
4476
+ const future = negotiate({ client: { protocolmajor: 3 }, server });
4477
+ expect(future.agreed).toBe(false);
4478
+ expect(future.mismatch).toContain("the supported protocol versions are 2 through 2");
4479
+ expect(protocolsupported).toEqual({ minimum: 2, maximum: 2 });
4480
+ expect(deprecationwindow).toEqual({ opens: "1.1.91", closes: "2.0.0" });
4481
+ });
4482
+
4483
+ it("answers the strict unknown field refusal for every client after the sunset", async () => {
4484
+ const strictframe = { ...freezeframe(21, "ping"), mysteryfield: true };
4485
+ const strict = await handleframe({
4486
+ frame: strictframe as jsonrpcframe,
4487
+ client: v2client,
4488
+ catalog: freezecatalog,
4489
+ config: freezeconfig,
4490
+ session: freezesession,
4491
+ plan: freezeplan,
4492
+ origin: freezesession.origin,
4493
+ tabid: freezesession.tabid,
4494
+ now: freezenow,
4495
+ execute: async (step) => ({ content: `ran ${step.kind}`, iserror: false }),
4496
+ });
4497
+ expect(strict.error?.code).toBe("params");
4498
+ expect(strict.error?.message).toContain("Strict schema validation");
4499
+ /* the version one client that still sends the deprecated protocolversion string parses to the frozen default of two, so its unknown fields meet the same strict refusal the 2.0.0 sunset answers every major with */
4500
+ const sunset = await handleframe({
4501
+ frame: strictframe as jsonrpcframe,
4502
+ client: v1client,
4503
+ catalog: freezecatalog,
4504
+ config: freezeconfig,
4505
+ session: freezesession,
4506
+ plan: freezeplan,
4507
+ origin: freezesession.origin,
4508
+ tabid: freezesession.tabid,
4509
+ now: freezenow,
4510
+ execute: async (step) => ({ content: `ran ${step.kind}`, iserror: false }),
4511
+ });
4512
+ expect(sunset.error?.code).toBe("params");
4513
+ expect(sunset.error?.message).toContain("Strict schema validation");
4514
+ });
4515
+ });
4516
+
4517
+ /* ── The 1.1.95 quarantine review flow messages of the security hardening release. ── */
4518
+
4519
+ import { quarantineverdictreport } from "../protocol.js";
4520
+ import { enforcequarantine, exportall, inventoryentry, purgeonrequest } from "../export.js";
4521
+
4522
+ describe("the quarantine review flow messages", () => {
4523
+ it("wraps the verdict report of a held quarantine entry in the versioned envelope", () => {
4524
+ const held = enforcequarantine({
4525
+ path: "/sandbox/invoice.pdf",
4526
+ reason: "download moved out of the downloads folder",
4527
+ digest: `${"a".repeat(64)}`,
4528
+ now: 1000,
4529
+ });
4530
+ expect(held.action).toBe("hold");
4531
+ expect(held.entry.digest).toBe("a".repeat(64));
4532
+ const report = quarantineverdictreport({
4533
+ entry: {
4534
+ id: held.entry.id,
4535
+ path: held.entry.path,
4536
+ reason: held.entry.reason,
4537
+ scan: held.entry.scan,
4538
+ status: held.entry.status ?? "held",
4539
+ at: held.entry.at,
4540
+ updatedat: held.entry.updatedat,
4541
+ },
4542
+ });
4543
+ expect(report.version).toBe(protocolversion);
4544
+ expect(report.scan).toBe("pending");
4545
+ expect(report.status).toBe("held");
4546
+ expect(report.released).toBe(false);
4547
+ });
4548
+
4549
+ it("releases only a clean verdict and never reports a held or flagged file as released", () => {
4550
+ const clean = enforcequarantine({ path: "/sandbox/invoice.pdf", reason: "clean scan", scan: "clean", now: 2000 });
4551
+ const cleanreport = quarantineverdictreport({
4552
+ entry: {
4553
+ id: clean.entry.id,
4554
+ path: clean.entry.path,
4555
+ reason: clean.entry.reason,
4556
+ scan: clean.entry.scan,
4557
+ status: clean.entry.status ?? "held",
4558
+ at: clean.entry.at,
4559
+ updatedat: clean.entry.updatedat,
4560
+ },
4561
+ });
4562
+ expect(cleanreport.released).toBe(true);
4563
+ expect(cleanreport.scan).toBe("clean");
4564
+ const flagged = enforcequarantine({
4565
+ path: "/sandbox/invoice.pdf",
4566
+ reason: "flagged scan",
4567
+ scan: "flagged",
4568
+ now: 3000,
4569
+ });
4570
+ expect(flagged.action).toBe("delete");
4571
+ expect(() =>
4572
+ quarantineverdictreport({
4573
+ entry: {
4574
+ id: flagged.entry.id,
4575
+ path: flagged.entry.path,
4576
+ reason: flagged.entry.reason,
4577
+ scan: "flagged",
4578
+ status: "released",
4579
+ at: flagged.entry.at,
4580
+ updatedat: flagged.entry.updatedat,
4581
+ },
4582
+ }),
4583
+ ).toThrow("Only a clean scanner verdict releases a quarantined file");
4584
+ });
4585
+
4586
+ it("carries the quarantine entries with their digests through the session context report", () => {
4587
+ const held = enforcequarantine({
4588
+ path: "/sandbox/report.pdf",
4589
+ reason: "quarantine step",
4590
+ digest: `${"b".repeat(64)}`,
4591
+ now: 1000,
4592
+ });
4593
+ const context = quarantinereport({ entries: [held.entry] });
4594
+ expect(context.version).toBe(protocolversion);
4595
+ expect(context.entries[0]?.digest).toBe("b".repeat(64));
4596
+ expect(context.entries[0]?.status).toBe("held");
4597
+ });
4598
+ });
4599
+
4600
+ /* ── The 1.1.95 purge and export completeness messages of the minimization family. ── */
4601
+
4602
+ describe("the purge and export completeness messages", () => {
4603
+ it("reports the full inventory scope of a purge with the audit hashes preserved", () => {
4604
+ const now = 4000;
4605
+ const inventory = ["runs", "memoryitems", "captures", "provlog"].map((key) =>
4606
+ inventoryentry({
4607
+ key,
4608
+ dataclass: key === "memoryitems" ? "memory" : key === "provlog" ? "provenance" : key,
4609
+ value: [{}],
4610
+ now,
4611
+ }),
4612
+ );
4613
+ const purge = purgeonrequest({
4614
+ policy: { scope: ["all"], confirmation: "delete everything", at: now },
4615
+ typed: "delete everything",
4616
+ inventory,
4617
+ now,
4618
+ });
4619
+ expect(purge.deleted.sort()).toEqual(["captures", "memoryitems", "provlog", "runs"].sort());
4620
+ expect(purge.audithashespreserved).toBe(true);
4621
+ });
4622
+
4623
+ it("returns every stored record inside the one exportall archive bundle", () => {
4624
+ const bundle = exportall({
4625
+ runs: ["run-one", "run-two"],
4626
+ memory: ["memory-one"],
4627
+ captures: ["capture-one"],
4628
+ settings: true,
4629
+ provenance: ["prov-one"],
4630
+ now: 5000,
4631
+ });
4632
+ expect(bundle.records).toBe(6);
4633
+ expect(bundle.runs).toEqual(["run-one", "run-two"]);
4634
+ expect(bundle.memory).toEqual(["memory-one"]);
4635
+ expect(bundle.captures).toEqual(["capture-one"]);
4636
+ expect(bundle.settings).toBe(true);
4637
+ expect(bundle.provenance).toEqual(["prov-one"]);
4638
+ });
4639
+ });
4640
+
4641
+ /* ── The 1.1.96 multi agent certification: the coordination messages against the frozen schemas. ── */
4642
+ import {
4643
+ aggregationreport,
4644
+ consensusreport,
4645
+ costledgerreport,
4646
+ lessonreport,
4647
+ parsespawnrequest,
4648
+ spawnreply,
4649
+ } from "../protocol.js";
4650
+ import type { agentrecord, consensusrecord, aggregaterecord, costentry, lessonrecord, spawnrecord } from "../types.js";
4651
+
4652
+ describe("protocol coordination messages", () => {
4653
+ const now = 1_800_000_000_000;
4654
+
4655
+ it("wraps the consensus round with every vote, the tally, the quorum and the outcome", () => {
4656
+ const record: consensusrecord = {
4657
+ id: "con1",
4658
+ proposal: "Merge the two extractions of the pricing table.",
4659
+ votes: [
4660
+ { agentid: "w1", vote: "yes", castat: now },
4661
+ { agentid: "w2", vote: "yes", castat: now + 1 },
4662
+ { agentid: "c1", vote: "no", reason: "One stale row.", castat: now + 2 },
4663
+ { agentid: "v1", vote: "abstain", castat: now + 3 },
4664
+ ],
4665
+ tally: { yes: 2, no: 1, abstain: 1 },
4666
+ quorum: 3,
4667
+ outcome: "failed",
4668
+ closedat: now + 4,
4669
+ };
4670
+ const report = consensusreport({ record });
4671
+ expect(report.version).toBe(protocolversion);
4672
+ expect(report.proposal).toBe("Merge the two extractions of the pricing table.");
4673
+ expect(report.votes).toHaveLength(4);
4674
+ expect(report.votes[2]).toMatchObject({ agentid: "c1", vote: "no", reason: "One stale row." });
4675
+ expect(report.tally).toEqual({ yes: 2, no: 1, abstain: 1 });
4676
+ expect(report.quorum).toBe(3);
4677
+ expect(report.outcome).toBe("failed");
4678
+ });
4679
+
4680
+ it("wraps the aggregation report with the per agent sections, the conflicts and the open state", () => {
4681
+ const record: aggregaterecord = {
4682
+ id: "agg1",
4683
+ subject: "The pricing table read",
4684
+ cells: [
4685
+ { agentid: "w1", runid: "run-1", section: "rowcount", output: "14" },
4686
+ { agentid: "w2", runid: "run-2", section: "rowcount", output: "15" },
4687
+ ],
4688
+ conflicts: [{ key: "rowcount" }],
4689
+ state: "open",
4690
+ createdat: now,
4691
+ };
4692
+ const report = aggregationreport({ record });
4693
+ expect(report.version).toBe(protocolversion);
4694
+ expect(report.subject).toBe("The pricing table read");
4695
+ expect(report.sections).toHaveLength(2);
4696
+ expect(report.sections[0]).toMatchObject({ agentid: "w1", runid: "run-1", section: "rowcount" });
4697
+ expect(report.conflicts).toEqual([{ key: "rowcount" }]);
4698
+ expect(report.state).toBe("open");
4699
+ });
4700
+
4701
+ it("wraps the lessonshare matches with the agent, the finding, the origin and the reuse count", () => {
4702
+ const lessons: lessonrecord[] = [
4703
+ {
4704
+ id: "l1",
4705
+ agentid: "w1",
4706
+ finding: "The pricing table loads only after the hero settles.",
4707
+ origin: "https://example.com",
4708
+ reusecount: 2,
4709
+ recordedat: now,
4710
+ lastusedat: now + 1,
4711
+ },
4712
+ {
4713
+ id: "l2",
4714
+ agentid: "w2",
4715
+ finding: "The login form refuses pasted passwords.",
4716
+ origin: "https://shop.example",
4717
+ reusecount: 0,
4718
+ recordedat: now,
4719
+ },
4720
+ ];
4721
+ const report = lessonreport({ lessons });
4722
+ expect(report.version).toBe(protocolversion);
4723
+ expect(report.lessons).toHaveLength(2);
4724
+ expect(report.lessons[0]).toMatchObject({ id: "l1", agentid: "w1", origin: "https://example.com", reusecount: 2 });
4725
+ expect(report.lessons[0]?.lastusedat).toBe(now + 1);
4726
+ expect(report.lessons[1]).not.toHaveProperty("lastusedat");
4727
+ });
4728
+
4729
+ it("wraps the shared cost ledger with every entry attributed to its agent and the split", () => {
4730
+ const entries: costentry[] = [
4731
+ { agentid: "w1", runid: "run-1", units: 4, description: "The pricing table read.", at: now },
4732
+ { agentid: "w2", runid: "run-2", units: 2, description: "The footer read.", at: now + 1 },
4733
+ ];
4734
+ const split = [
4735
+ { agentid: "w1", units: 4, share: 2, sharedwith: ["w2"] },
4736
+ { agentid: "w2", units: 2, share: 2 },
4737
+ ];
4738
+ const report = costledgerreport({ entries, split });
4739
+ expect(report.version).toBe(protocolversion);
4740
+ expect(report.entries[0]).toMatchObject({
4741
+ agentid: "w1",
4742
+ runid: "run-1",
4743
+ units: 4,
4744
+ description: "The pricing table read.",
4745
+ });
4746
+ expect(report.split[0]).toMatchObject({ agentid: "w1", units: 4, share: 2, sharedwith: ["w2"] });
4747
+ expect(report.split[1]).not.toHaveProperty("sharedwith");
4748
+ });
4749
+
4750
+ it("parses the spawn request against the fleet registry and answers the spawn reply with the lineage", () => {
4751
+ const agents: agentrecord[] = [
4752
+ { id: "a1", name: "Scout", role: "worker", origin: "https://example.com", state: "active", registeredat: now },
4753
+ { id: "w1", name: "Scribe", role: "worker", origin: "https://example.com", state: "active", registeredat: now },
4754
+ ];
4755
+ const spawns: spawnrecord[] = [];
4756
+ const parsed = parsespawnrequest(
4757
+ {
4758
+ parentid: "a1",
4759
+ objective: "Index the footer links.",
4760
+ role: "worker",
4761
+ depth: 1,
4762
+ narrowscope: { origins: ["https://example.com"] },
4763
+ },
4764
+ { maxdepth: 2 },
4765
+ agents,
4766
+ spawns,
4767
+ );
4768
+ expect(parsed.spec.parentid).toBe("a1");
4769
+ expect(parsed.spec.depth).toBe(1);
4770
+ expect(parsed.spec.narrowscope).toEqual({ origins: ["https://example.com"] });
4771
+ expect(parsed.parent.id).toBe("a1");
4772
+ const reply = spawnreply({
4773
+ child: {
4774
+ id: "g1",
4775
+ name: "Probe",
4776
+ role: "worker",
4777
+ origin: "https://example.com",
4778
+ state: "active",
4779
+ registeredat: now + 1,
4780
+ },
4781
+ parentid: "a1",
4782
+ depth: 1,
4783
+ });
4784
+ expect(reply.version).toBe(protocolversion);
4785
+ expect(reply).toMatchObject({ agentid: "g1", name: "Probe", role: "worker", parentid: "a1", depth: 1 });
4786
+ expect(() =>
4787
+ parsespawnrequest({ parentid: "a1", objective: "Too deep.", depth: 9 }, { maxdepth: 2 }, agents, spawns),
4788
+ ).toThrow(/depth limit/);
4789
+ expect(() =>
4790
+ parsespawnrequest({ parentid: "ghost", objective: "Unknown.", depth: 1 }, { maxdepth: 2 }, agents, spawns),
4791
+ ).toThrow(/does not carry/);
4792
+ });
4793
+ });