@thaut/claude-ide 0.0.0-stage → 1.0.0

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 (559) hide show
  1. package/bin/claude-ide.js +23 -0
  2. package/package.json +75 -4
  3. package/server/add-dirs.ts +126 -0
  4. package/server/auth.test.ts +148 -0
  5. package/server/auth.ts +235 -0
  6. package/server/bang.ts +54 -0
  7. package/server/btw-instructions.ts +19 -0
  8. package/server/btw-policy.ts +17 -0
  9. package/server/btw.ts +143 -0
  10. package/server/child-env.ts +29 -0
  11. package/server/cli-control.ts +255 -0
  12. package/server/cli-files.ts +55 -0
  13. package/server/cli-info.ts +73 -0
  14. package/server/config/account.ts +65 -0
  15. package/server/config/agents.ts +186 -0
  16. package/server/config/mcp.ts +86 -0
  17. package/server/config/memory.ts +193 -0
  18. package/server/config/paths.ts +156 -0
  19. package/server/config/plugins.ts +204 -0
  20. package/server/config/routes.ts +95 -0
  21. package/server/config/settings.ts +246 -0
  22. package/server/config.ts +46 -0
  23. package/server/db.ts +121 -0
  24. package/server/directories.test.ts +180 -0
  25. package/server/directories.ts +87 -0
  26. package/server/dist-version.ts +69 -0
  27. package/server/durable-tasks.ts +61 -0
  28. package/server/file-transfers.ts +79 -0
  29. package/server/files.test.ts +127 -0
  30. package/server/files.ts +386 -0
  31. package/server/fs-queue.ts +14 -0
  32. package/server/fsutil.ts +144 -0
  33. package/server/git.ts +86 -0
  34. package/server/goal-state.test.ts +64 -0
  35. package/server/goal-state.ts +78 -0
  36. package/server/history-pages.test.ts +619 -0
  37. package/server/history-pages.ts +513 -0
  38. package/server/http-channel-manager.ts +468 -0
  39. package/server/http-channel-pull.test.ts +314 -0
  40. package/server/http-channel-push.test.ts +364 -0
  41. package/server/http-channel-registration.test.ts +291 -0
  42. package/server/http-channel-routes.ts +73 -0
  43. package/server/http-channel-store.ts +142 -0
  44. package/server/http-channel-test-fixture.ts +103 -0
  45. package/server/ide-messages-mcp.test.ts +82 -0
  46. package/server/ide-messages-mcp.ts +47 -0
  47. package/server/idecron-mcp.ts +65 -0
  48. package/server/idecron.test.ts +79 -0
  49. package/server/idecron.ts +271 -0
  50. package/server/index.ts +336 -0
  51. package/server/live-session.test.ts +1126 -0
  52. package/server/live-session.ts +1165 -0
  53. package/server/project-routes.ts +46 -0
  54. package/server/projects.test.ts +28 -0
  55. package/server/push.ts +315 -0
  56. package/server/report-cache.ts +28 -0
  57. package/server/session-aliases.test.ts +57 -0
  58. package/server/session-aliases.ts +50 -0
  59. package/server/session-branch.ts +71 -0
  60. package/server/session-dirs.ts +189 -0
  61. package/server/session-file.test.ts +134 -0
  62. package/server/session-file.ts +113 -0
  63. package/server/session-messaging.test.ts +297 -0
  64. package/server/session-messaging.ts +165 -0
  65. package/server/sessions.ts +638 -0
  66. package/server/subagents.test.ts +43 -0
  67. package/server/subagents.ts +58 -0
  68. package/server/tool-images.test.ts +76 -0
  69. package/server/tool-images.ts +45 -0
  70. package/server/turn-changes.ts +308 -0
  71. package/server/worktree-git.ts +87 -0
  72. package/server/worktrees.ts +138 -0
  73. package/server/ws.test.ts +195 -0
  74. package/server/ws.ts +197 -0
  75. package/shared/agent-model.ts +15 -0
  76. package/shared/agent-tasks.ts +12 -0
  77. package/shared/config.ts +175 -0
  78. package/shared/directories.ts +12 -0
  79. package/shared/files.ts +51 -0
  80. package/shared/http-channels.ts +63 -0
  81. package/shared/message-window.ts +189 -0
  82. package/shared/project.ts +77 -0
  83. package/shared/protocol.ts +203 -0
  84. package/shared/task-types.ts +4 -0
  85. package/shared/todos.ts +40 -0
  86. package/shared/write-policy.ts +9 -0
  87. package/web/dist/assets/AgentView-wrTKgcJ3.js +1 -0
  88. package/web/dist/assets/AnnotationSelection-BZmNmwVW.js +7 -0
  89. package/web/dist/assets/ChangesView-G1WTzh4q.js +2 -0
  90. package/web/dist/assets/FilesView-DPgAbYt_.js +6 -0
  91. package/web/dist/assets/Form-BKTwiYyw.js +1 -0
  92. package/web/dist/assets/HomePage-Dq2apjBe.js +1 -0
  93. package/web/dist/assets/Markdown-IwP-xF29.js +67 -0
  94. package/web/dist/assets/MessageList-DD9GypSy.js +15 -0
  95. package/web/dist/assets/Modal-g2rv8I_n.js +1 -0
  96. package/web/dist/assets/NotificationSettings-DpuLO_Bv.js +1 -0
  97. package/web/dist/assets/OverviewPage-Dx6miabE.js +1 -0
  98. package/web/dist/assets/ProjectHome-CousyTLL.js +1 -0
  99. package/web/dist/assets/SessionView-CPVL7_HO.js +2 -0
  100. package/web/dist/assets/SettingsPage-D_oYSnW2.js +7 -0
  101. package/web/dist/assets/WorktreesView-C7e0HV__.js +1 -0
  102. package/web/dist/assets/abap-CLvhMVsD.js +1 -0
  103. package/web/dist/assets/actionscript-3-Cs6JJXT5.js +1 -0
  104. package/web/dist/assets/ada-X3Y2mlq2.js +1 -0
  105. package/web/dist/assets/ahk-DwADkxyd.js +1 -0
  106. package/web/dist/assets/ahk2-_6rzW9BS.js +1 -0
  107. package/web/dist/assets/andromeeda-vGVdxbeo.js +1 -0
  108. package/web/dist/assets/angular-html-Cw29lun8.js +1 -0
  109. package/web/dist/assets/angular-ts-BXz-O3SJ.js +1 -0
  110. package/web/dist/assets/apache-U0d_L8uA.js +1 -0
  111. package/web/dist/assets/apex-KFGFyKwG.js +1 -0
  112. package/web/dist/assets/apl-xBchdRhy.js +1 -0
  113. package/web/dist/assets/applescript-CCn79oCD.js +1 -0
  114. package/web/dist/assets/ara-4CJ0cIlV.js +1 -0
  115. package/web/dist/assets/asciidoc-SCjQUq34.js +1 -0
  116. package/web/dist/assets/asm-Cmm7eHzH.js +1 -0
  117. package/web/dist/assets/astro-1q6DGuN3.js +1 -0
  118. package/web/dist/assets/aurora-x-CDeNXAV0.js +1 -0
  119. package/web/dist/assets/awk-BWXHIvNe.js +1 -0
  120. package/web/dist/assets/ayu-dark-DluEY0Gj.js +1 -0
  121. package/web/dist/assets/ayu-light-C3h-C4tm.js +1 -0
  122. package/web/dist/assets/ayu-mirage-Bqwy1Gya.js +1 -0
  123. package/web/dist/assets/ballerina-B7ZEbQpA.js +1 -0
  124. package/web/dist/assets/bat-qdxAdBWQ.js +1 -0
  125. package/web/dist/assets/beancount-C2IN4Vk8.js +1 -0
  126. package/web/dist/assets/berry-DKpUyyne.js +1 -0
  127. package/web/dist/assets/bibtex-Ci_nEsc7.js +1 -0
  128. package/web/dist/assets/bicep-CUHmPFLl.js +1 -0
  129. package/web/dist/assets/bird2-CYIUgYqB.js +1 -0
  130. package/web/dist/assets/blade-Cqdv4oZN.js +1 -0
  131. package/web/dist/assets/bsl-CVED6lJ1.js +1 -0
  132. package/web/dist/assets/c-D9gIxSfx.js +1 -0
  133. package/web/dist/assets/c3-D0apG41V.js +1 -0
  134. package/web/dist/assets/cadence-CQ2zXKGN.js +1 -0
  135. package/web/dist/assets/cairo-DRAZz75c.js +1 -0
  136. package/web/dist/assets/catppuccin-frappe-C9Q5zVZY.js +1 -0
  137. package/web/dist/assets/catppuccin-latte-CtsyrtPq.js +1 -0
  138. package/web/dist/assets/catppuccin-macchiato-R8nAf1vR.js +1 -0
  139. package/web/dist/assets/catppuccin-mocha-DjuaAJKi.js +1 -0
  140. package/web/dist/assets/chapel-CuseGurG.js +1 -0
  141. package/web/dist/assets/clarity-CtfBBspg.js +1 -0
  142. package/web/dist/assets/clojure-DqKBuwfJ.js +1 -0
  143. package/web/dist/assets/cmake-Bj61d0ZC.js +1 -0
  144. package/web/dist/assets/cobol-Bdpj3Qoh.js +1 -0
  145. package/web/dist/assets/codeowners-C8r90Shi.js +1 -0
  146. package/web/dist/assets/codeql-oeQT6MSM.js +1 -0
  147. package/web/dist/assets/coffee-Sk8pHFjs.js +1 -0
  148. package/web/dist/assets/common-lisp-Cv5bFMCO.js +1 -0
  149. package/web/dist/assets/coq-CGaSfhwR.js +1 -0
  150. package/web/dist/assets/core-BxI4V4-T.js +12 -0
  151. package/web/dist/assets/cpp-BQfBYKx3.js +1 -0
  152. package/web/dist/assets/crystal-B3j7DoOZ.js +1 -0
  153. package/web/dist/assets/csharp-dmMxLVLW.js +1 -0
  154. package/web/dist/assets/css-DpFbYmRh.js +1 -0
  155. package/web/dist/assets/csv-Dx-8-gkx.js +1 -0
  156. package/web/dist/assets/cue-CE9AQfxI.js +1 -0
  157. package/web/dist/assets/cypher-ClKdZ_lG.js +1 -0
  158. package/web/dist/assets/d-qD-0Kul2.js +1 -0
  159. package/web/dist/assets/dark-plus-Cs2F2srj.js +1 -0
  160. package/web/dist/assets/dart-CHZY9Xhz.js +1 -0
  161. package/web/dist/assets/dax-BkyTk9wS.js +1 -0
  162. package/web/dist/assets/desktop-Dlh5hvp9.js +1 -0
  163. package/web/dist/assets/diff-woXpYk--.js +1 -0
  164. package/web/dist/assets/docker-IyjqRm3v.js +1 -0
  165. package/web/dist/assets/dotenv-CuH5my32.js +1 -0
  166. package/web/dist/assets/dracula-BHWKrbxM.js +1 -0
  167. package/web/dist/assets/dracula-soft-5eyTD99u.js +1 -0
  168. package/web/dist/assets/dream-maker-DW3nJb8Q.js +1 -0
  169. package/web/dist/assets/edge-DXQWmXTB.js +1 -0
  170. package/web/dist/assets/elixir-CrDIY2Vo.js +1 -0
  171. package/web/dist/assets/elm-BslFEg8K.js +1 -0
  172. package/web/dist/assets/emacs-lisp-DR2vtWaj.js +1 -0
  173. package/web/dist/assets/engine-javascript-DJg3-Vu2.js +141 -0
  174. package/web/dist/assets/erb-CUYhg0Ws.js +1 -0
  175. package/web/dist/assets/erlang-BXdgHVfi.js +1 -0
  176. package/web/dist/assets/everforest-dark-sB-x3p7T.js +1 -0
  177. package/web/dist/assets/everforest-light-Df2xbC6M.js +1 -0
  178. package/web/dist/assets/fennel-DQxkIbk2.js +1 -0
  179. package/web/dist/assets/fish-BJitypiv.js +1 -0
  180. package/web/dist/assets/fluent-C03EYrpw.js +1 -0
  181. package/web/dist/assets/format-DrBC7w0A.js +1 -0
  182. package/web/dist/assets/fortran-fixed-form-D6mN0kjy.js +1 -0
  183. package/web/dist/assets/fortran-free-form-CYNrtFtB.js +1 -0
  184. package/web/dist/assets/fsharp-CwoEF39o.js +1 -0
  185. package/web/dist/assets/gdresource-CZkFGo3B.js +1 -0
  186. package/web/dist/assets/gdscript-DYvRD3hg.js +1 -0
  187. package/web/dist/assets/gdshader-CBce3t8t.js +1 -0
  188. package/web/dist/assets/genie-CV2tkWYe.js +1 -0
  189. package/web/dist/assets/gherkin-DExj1W_8.js +1 -0
  190. package/web/dist/assets/git-commit-dUGrvm6Q.js +1 -0
  191. package/web/dist/assets/git-rebase-4zCRqF28.js +1 -0
  192. package/web/dist/assets/github-dark-C-LZuMrd.js +1 -0
  193. package/web/dist/assets/github-dark-default-DXG-b-1a.js +1 -0
  194. package/web/dist/assets/github-dark-dimmed-Bx1FflLF.js +1 -0
  195. package/web/dist/assets/github-dark-high-contrast-B_tTalzw.js +1 -0
  196. package/web/dist/assets/github-light-EUqPIrTm.js +1 -0
  197. package/web/dist/assets/github-light-default-BXViO-2h.js +1 -0
  198. package/web/dist/assets/github-light-high-contrast-B68TUdTA.js +1 -0
  199. package/web/dist/assets/gleam-CSRkHgEL.js +1 -0
  200. package/web/dist/assets/glimmer-js-7adTRvH4.js +1 -0
  201. package/web/dist/assets/glimmer-ts-IdSskDYw.js +1 -0
  202. package/web/dist/assets/glsl-Qzy53KDt.js +1 -0
  203. package/web/dist/assets/gn-CAXYAgV-.js +1 -0
  204. package/web/dist/assets/gnuplot-7GGW24-e.js +1 -0
  205. package/web/dist/assets/go-rLFTqkRN.js +1 -0
  206. package/web/dist/assets/graphql-CV_puxbB.js +1 -0
  207. package/web/dist/assets/groovy-CacY0gHj.js +1 -0
  208. package/web/dist/assets/gruvbox-dark-hard-C820rvS2.js +1 -0
  209. package/web/dist/assets/gruvbox-dark-medium-BPjhmG05.js +1 -0
  210. package/web/dist/assets/gruvbox-dark-soft-MrdJrrXF.js +1 -0
  211. package/web/dist/assets/gruvbox-light-hard-BC_s9l72.js +1 -0
  212. package/web/dist/assets/gruvbox-light-medium-BAWPOn9u.js +1 -0
  213. package/web/dist/assets/gruvbox-light-soft-BSMLrYjP.js +1 -0
  214. package/web/dist/assets/hack-C5GPjiLV.js +1 -0
  215. package/web/dist/assets/haml-BQ5YR1DQ.js +1 -0
  216. package/web/dist/assets/handlebars-DnhRK9A1.js +1 -0
  217. package/web/dist/assets/haskell-D8IpX4py.js +1 -0
  218. package/web/dist/assets/haxe-C8oCnwJJ.js +1 -0
  219. package/web/dist/assets/hcl-Dh228itO.js +1 -0
  220. package/web/dist/assets/highlight.worker-CY-ZeDAl.js +1 -0
  221. package/web/dist/assets/hjson-CxZEssPk.js +1 -0
  222. package/web/dist/assets/hlsl-Cvrh5tZx.js +1 -0
  223. package/web/dist/assets/horizon-CE9ld1lL.js +1 -0
  224. package/web/dist/assets/horizon-bright-DSNQnXHK.js +1 -0
  225. package/web/dist/assets/houston-CsvMBhTu.js +1 -0
  226. package/web/dist/assets/html-CmHiEKV9.js +1 -0
  227. package/web/dist/assets/html-derivative-BXE-rYIK.js +1 -0
  228. package/web/dist/assets/http-DPjjqtcL.js +1 -0
  229. package/web/dist/assets/hurl-AMBpSloO.js +1 -0
  230. package/web/dist/assets/hxml-D7Y24wKF.js +1 -0
  231. package/web/dist/assets/hy-CZbG8q4J.js +1 -0
  232. package/web/dist/assets/imba-DsUTQ-LC.js +1 -0
  233. package/web/dist/assets/index-DNPnl4sh.css +2 -0
  234. package/web/dist/assets/index-DQh72lEx.js +14 -0
  235. package/web/dist/assets/ini-B5eOa1yu.js +1 -0
  236. package/web/dist/assets/java-CEM7Tdw_.js +1 -0
  237. package/web/dist/assets/javascript-CU3PLowd.js +1 -0
  238. package/web/dist/assets/jinja-fR0x_9Xy.js +1 -0
  239. package/web/dist/assets/jison-BoKsxs5M.js +1 -0
  240. package/web/dist/assets/json-BM2FWsRB.js +1 -0
  241. package/web/dist/assets/json5-BR5RXkoi.js +1 -0
  242. package/web/dist/assets/jsonc-CYpm1nAK.js +1 -0
  243. package/web/dist/assets/jsonl-CmCQp5Yx.js +1 -0
  244. package/web/dist/assets/jsonnet-CJTPZ8u_.js +1 -0
  245. package/web/dist/assets/jssm-DXw9l8Rf.js +1 -0
  246. package/web/dist/assets/jsx-B5fTecQP.js +1 -0
  247. package/web/dist/assets/julia-DGyDG39_.js +1 -0
  248. package/web/dist/assets/just-B-RyLcSR.js +1 -0
  249. package/web/dist/assets/kanagawa-dragon-Bozd7klA.js +1 -0
  250. package/web/dist/assets/kanagawa-lotus-BmuOG9ZW.js +1 -0
  251. package/web/dist/assets/kanagawa-wave-BML5mF2J.js +1 -0
  252. package/web/dist/assets/kdl-CsD5j6eV.js +1 -0
  253. package/web/dist/assets/kotlin-DhhofPvG.js +1 -0
  254. package/web/dist/assets/kusto-C7mF5XQf.js +1 -0
  255. package/web/dist/assets/langs-CciPKqaF.js +1 -0
  256. package/web/dist/assets/laserwave-C_8bwKvT.js +1 -0
  257. package/web/dist/assets/latex-CV1pQelm.js +1 -0
  258. package/web/dist/assets/lean-CewbzKMR.js +1 -0
  259. package/web/dist/assets/less-DVTAwKKz.js +1 -0
  260. package/web/dist/assets/lib-DlcVMUqT.js +1 -0
  261. package/web/dist/assets/light-plus-DVQuIRkW.js +1 -0
  262. package/web/dist/assets/liquid-9p05RSzQ.js +1 -0
  263. package/web/dist/assets/llvm-DqHOMS-s.js +1 -0
  264. package/web/dist/assets/log-BNLmms1o.js +1 -0
  265. package/web/dist/assets/logo-Cluzi2Zq.js +1 -0
  266. package/web/dist/assets/lua-CIPVh0S-.js +1 -0
  267. package/web/dist/assets/luau-VGWVLoqX.js +1 -0
  268. package/web/dist/assets/make-Dixweg8N.js +1 -0
  269. package/web/dist/assets/markdown-BYOwaDjH.js +1 -0
  270. package/web/dist/assets/marko-h2h6YUMS.js +1 -0
  271. package/web/dist/assets/material-theme-Bm3Qr25_.js +1 -0
  272. package/web/dist/assets/material-theme-darker-2IIEA8gg.js +1 -0
  273. package/web/dist/assets/material-theme-lighter-uhdI0v04.js +1 -0
  274. package/web/dist/assets/material-theme-ocean-CHQ94UKr.js +1 -0
  275. package/web/dist/assets/material-theme-palenight-B5W6OYN7.js +1 -0
  276. package/web/dist/assets/matlab-D7qyCx1q.js +1 -0
  277. package/web/dist/assets/mdc-yAyZMdoC.js +1 -0
  278. package/web/dist/assets/mdx-DQZ5AkYe.js +1 -0
  279. package/web/dist/assets/mermaid-BxpfTIzt.js +1 -0
  280. package/web/dist/assets/message-font-size-DoaZfwkT.js +1 -0
  281. package/web/dist/assets/min-dark-BSWPekZh.js +1 -0
  282. package/web/dist/assets/min-light-DDpmG2fV.js +1 -0
  283. package/web/dist/assets/mipsasm-BMqwQI7S.js +1 -0
  284. package/web/dist/assets/mojo-fg40_ntQ.js +1 -0
  285. package/web/dist/assets/monokai-BscAjiQZ.js +1 -0
  286. package/web/dist/assets/moonbit-D9W784E_.js +1 -0
  287. package/web/dist/assets/move-BYnGhZoM.js +1 -0
  288. package/web/dist/assets/narrat-_X_XdTYD.js +1 -0
  289. package/web/dist/assets/nextflow-BoTnSBQ_.js +1 -0
  290. package/web/dist/assets/nextflow-groovy-D7IRt6Pp.js +1 -0
  291. package/web/dist/assets/nginx-jWINucNT.js +1 -0
  292. package/web/dist/assets/night-owl-DhmEMT88.js +1 -0
  293. package/web/dist/assets/night-owl-light-eJ-hLW7d.js +1 -0
  294. package/web/dist/assets/nim-Dtt9DZ0t.js +1 -0
  295. package/web/dist/assets/nix-Cl_7024t.js +1 -0
  296. package/web/dist/assets/nord-Cb4Vim4T.js +1 -0
  297. package/web/dist/assets/nsis-CjNLYCLg.js +1 -0
  298. package/web/dist/assets/nushell--0fT3PI1.js +1 -0
  299. package/web/dist/assets/objective-c-D1A_Heim.js +1 -0
  300. package/web/dist/assets/objective-cpp-BsSzOQcm.js +1 -0
  301. package/web/dist/assets/ocaml-O90oeIOV.js +1 -0
  302. package/web/dist/assets/odin-B1RWQWA5.js +1 -0
  303. package/web/dist/assets/one-dark-pro-C6YyR8OD.js +1 -0
  304. package/web/dist/assets/one-light-D7Lr4KcI.js +1 -0
  305. package/web/dist/assets/openscad-BUDT5pXO.js +1 -0
  306. package/web/dist/assets/org-ClD5huOa.js +1 -0
  307. package/web/dist/assets/pascal-4ZHwLPI5.js +1 -0
  308. package/web/dist/assets/perl-BFegxyeJ.js +1 -0
  309. package/web/dist/assets/php-B-qOA3Y4.js +1 -0
  310. package/web/dist/assets/pkl-ot-7Btpt.js +1 -0
  311. package/web/dist/assets/plastic-DQwYfKfQ.js +1 -0
  312. package/web/dist/assets/plsql-DGHpHOYJ.js +1 -0
  313. package/web/dist/assets/po-BiJDBrnU.js +1 -0
  314. package/web/dist/assets/poimandres-DRFjx7u4.js +1 -0
  315. package/web/dist/assets/polar-C7UOKdEL.js +1 -0
  316. package/web/dist/assets/postcss-BXeXVLqQ.js +1 -0
  317. package/web/dist/assets/powerquery-DNMTfnFr.js +1 -0
  318. package/web/dist/assets/powershell-QtSnmXcu.js +1 -0
  319. package/web/dist/assets/prisma-DMgNhnYS.js +1 -0
  320. package/web/dist/assets/prolog-iXnhIJG7.js +1 -0
  321. package/web/dist/assets/proto-DB4EqR-F.js +1 -0
  322. package/web/dist/assets/pug-Cq0NBPu-.js +1 -0
  323. package/web/dist/assets/puppet-CDv2pdJW.js +1 -0
  324. package/web/dist/assets/purescript-9MfHhQsQ.js +1 -0
  325. package/web/dist/assets/python-gzcpVVnB.js +1 -0
  326. package/web/dist/assets/qml-CYUsa87k.js +1 -0
  327. package/web/dist/assets/qmldir-DCQb3MpD.js +1 -0
  328. package/web/dist/assets/qss-Fe1Jh2GI.js +1 -0
  329. package/web/dist/assets/r-4xJhgbOf.js +1 -0
  330. package/web/dist/assets/racket-DcIDlBhZ.js +1 -0
  331. package/web/dist/assets/raku-B3gFvitq.js +1 -0
  332. package/web/dist/assets/razor-BlHaOHx8.js +1 -0
  333. package/web/dist/assets/rbs-C0MpOrj4.js +1 -0
  334. package/web/dist/assets/react-B4UGpcWC.js +1 -0
  335. package/web/dist/assets/red-CYlAq2b0.js +1 -0
  336. package/web/dist/assets/reg-CRGYupPL.js +1 -0
  337. package/web/dist/assets/regexp-BUSxjv2o.js +1 -0
  338. package/web/dist/assets/rel-BtDbiS_P.js +1 -0
  339. package/web/dist/assets/riscv-Ckw8ddFX.js +1 -0
  340. package/web/dist/assets/ron-VUp2lXgN.js +1 -0
  341. package/web/dist/assets/rose-pine-aMsaMD1D.js +1 -0
  342. package/web/dist/assets/rose-pine-dawn-8PwUyIlP.js +1 -0
  343. package/web/dist/assets/rose-pine-moon-DO-qXg-I.js +1 -0
  344. package/web/dist/assets/rosmsg-CAekHB0j.js +1 -0
  345. package/web/dist/assets/rst-zjfguEh7.js +1 -0
  346. package/web/dist/assets/ruby-4oR9waBK.js +1 -0
  347. package/web/dist/assets/rust-DpiIf5JO.js +1 -0
  348. package/web/dist/assets/sas-Do4V_2i9.js +1 -0
  349. package/web/dist/assets/sass-DXrisJhu.js +1 -0
  350. package/web/dist/assets/scala-BiFt_7mu.js +1 -0
  351. package/web/dist/assets/scheme-DQCgrYNe.js +1 -0
  352. package/web/dist/assets/scss-CW6JPtag.js +1 -0
  353. package/web/dist/assets/sdbl-bTVj8UrX.js +1 -0
  354. package/web/dist/assets/shaderlab-C4rnI7x0.js +1 -0
  355. package/web/dist/assets/shellscript-zBmOMFJh.js +1 -0
  356. package/web/dist/assets/shellsession-DxGUTWaf.js +1 -0
  357. package/web/dist/assets/slack-dark-DnToyrRv.js +1 -0
  358. package/web/dist/assets/slack-ochin-B2OO5cIa.js +1 -0
  359. package/web/dist/assets/smalltalk-BHpQb5nM.js +1 -0
  360. package/web/dist/assets/smithy-Dgaz7aur.js +1 -0
  361. package/web/dist/assets/snazzy-light-4G7pJPwS.js +1 -0
  362. package/web/dist/assets/solarized-dark-b97czJOk.js +1 -0
  363. package/web/dist/assets/solarized-light-DFPxpIYQ.js +1 -0
  364. package/web/dist/assets/solidity-Bj7E1mRn.js +1 -0
  365. package/web/dist/assets/soy-DVtw20Yn.js +1 -0
  366. package/web/dist/assets/sparql-C0ia5K60.js +1 -0
  367. package/web/dist/assets/splunk-BC2Px7Mm.js +1 -0
  368. package/web/dist/assets/sql-DGCM-jag.js +1 -0
  369. package/web/dist/assets/ssh-config-BgfXC-Er.js +1 -0
  370. package/web/dist/assets/stata-DfuMOXna.js +1 -0
  371. package/web/dist/assets/stylus-B6D30XZt.js +1 -0
  372. package/web/dist/assets/surrealql-CBoRWxfR.js +1 -0
  373. package/web/dist/assets/svelte-g_uCUem5.js +1 -0
  374. package/web/dist/assets/swift-CyEgAFGc.js +1 -0
  375. package/web/dist/assets/synthwave-84-nFMaYfgc.js +1 -0
  376. package/web/dist/assets/system-verilog-Dh5ihWbf.js +1 -0
  377. package/web/dist/assets/systemd-BxMlprV5.js +1 -0
  378. package/web/dist/assets/talonscript-CohzipZa.js +1 -0
  379. package/web/dist/assets/tasl-DMoTqEGO.js +1 -0
  380. package/web/dist/assets/tcl-CZd0xW_V.js +1 -0
  381. package/web/dist/assets/templ-N5C1hJ4Y.js +1 -0
  382. package/web/dist/assets/terraform-DswuEJGm.js +1 -0
  383. package/web/dist/assets/tex-mx6xDwuK.js +1 -0
  384. package/web/dist/assets/themes-CNElGaXw.js +1 -0
  385. package/web/dist/assets/tokyo-night-oM2G3aXe.js +1 -0
  386. package/web/dist/assets/toml-CcmNWLt0.js +1 -0
  387. package/web/dist/assets/ts-tags-C-j71FRb.js +1 -0
  388. package/web/dist/assets/tsv-sltzmVWM.js +1 -0
  389. package/web/dist/assets/tsx-z04y7qR9.js +1 -0
  390. package/web/dist/assets/turtle-ByJddavk.js +1 -0
  391. package/web/dist/assets/twig-Bz57G_nl.js +1 -0
  392. package/web/dist/assets/typescript-RMqVTmzR.js +1 -0
  393. package/web/dist/assets/typespec-BGhR2SqG.js +1 -0
  394. package/web/dist/assets/typst-BgkqA2Pg.js +1 -0
  395. package/web/dist/assets/v-DBj4K9d_.js +1 -0
  396. package/web/dist/assets/vala-zf12oZj6.js +1 -0
  397. package/web/dist/assets/vb-DOwzaHZ0.js +1 -0
  398. package/web/dist/assets/verilog-B2gwz-sJ.js +1 -0
  399. package/web/dist/assets/vesper-DdrHHSXu.js +1 -0
  400. package/web/dist/assets/vhdl-BroJfC0k.js +1 -0
  401. package/web/dist/assets/viml-DvXPmvsu.js +1 -0
  402. package/web/dist/assets/vitesse-black-fwtXNY1n.js +1 -0
  403. package/web/dist/assets/vitesse-dark-BZCL-v6S.js +1 -0
  404. package/web/dist/assets/vitesse-light-VbXTXTou.js +1 -0
  405. package/web/dist/assets/vue-CQgvQytb.js +1 -0
  406. package/web/dist/assets/vue-html-Dhl6fh9l.js +1 -0
  407. package/web/dist/assets/vue-vine-Dw_uU59K.js +1 -0
  408. package/web/dist/assets/vyper-CgoNMtux.js +1 -0
  409. package/web/dist/assets/wasm-ByWQv1Qj.js +1 -0
  410. package/web/dist/assets/wenyan-C8pVoKbM.js +1 -0
  411. package/web/dist/assets/wgsl-BsKzXJz4.js +1 -0
  412. package/web/dist/assets/wikitext-ClFFjSW2.js +1 -0
  413. package/web/dist/assets/wit-DdvCle-K.js +1 -0
  414. package/web/dist/assets/wolfram-DLL8P-h_.js +1 -0
  415. package/web/dist/assets/xml-B49klYQ3.js +1 -0
  416. package/web/dist/assets/xsl-D7tnD3qq.js +1 -0
  417. package/web/dist/assets/yaml-DNQ5_xkL.js +1 -0
  418. package/web/dist/assets/zenscript-BnlCZFoB.js +1 -0
  419. package/web/dist/assets/zig-CMLA9XwU.js +1 -0
  420. package/web/dist/icons/apple-touch-icon.png +0 -0
  421. package/web/dist/icons/badge-96.png +0 -0
  422. package/web/dist/icons/icon-192.png +0 -0
  423. package/web/dist/icons/icon-512.png +0 -0
  424. package/web/dist/icons/icon.svg +4 -0
  425. package/web/dist/icons/maskable-192.png +0 -0
  426. package/web/dist/icons/maskable-512.png +0 -0
  427. package/web/dist/index.html +30 -0
  428. package/web/dist/manifest.webmanifest +20 -0
  429. package/web/dist/sw.js +176 -0
  430. package/web/dist/version.json +1 -0
  431. package/web/src/App.tsx +97 -0
  432. package/web/src/agent-history-flow.test.ts +191 -0
  433. package/web/src/agent-history-flow.ts +58 -0
  434. package/web/src/agent-owners.ts +30 -0
  435. package/web/src/annotation-selection.test.ts +36 -0
  436. package/web/src/annotation-selection.ts +123 -0
  437. package/web/src/annotations.test.ts +279 -0
  438. package/web/src/annotations.ts +191 -0
  439. package/web/src/api.ts +27 -0
  440. package/web/src/auth-events.ts +11 -0
  441. package/web/src/auth-lifecycle.test.ts +105 -0
  442. package/web/src/clipboard.ts +32 -0
  443. package/web/src/components/ActivityNotifier.tsx +22 -0
  444. package/web/src/components/AnnotationEditor.tsx +135 -0
  445. package/web/src/components/AnnotationSelection.tsx +122 -0
  446. package/web/src/components/Automation.tsx +390 -0
  447. package/web/src/components/CodeView.tsx +121 -0
  448. package/web/src/components/Composer.tsx +552 -0
  449. package/web/src/components/ComposerAnnotations.tsx +45 -0
  450. package/web/src/components/Confirmation.tsx +91 -0
  451. package/web/src/components/CopyButton.tsx +23 -0
  452. package/web/src/components/DiffView.tsx +94 -0
  453. package/web/src/components/DirectoryPicker.tsx +230 -0
  454. package/web/src/components/FileCopyMenu.tsx +58 -0
  455. package/web/src/components/FileEntryActions.tsx +95 -0
  456. package/web/src/components/ImagePreview.tsx +68 -0
  457. package/web/src/components/Items.tsx +608 -0
  458. package/web/src/components/Markdown.tsx +73 -0
  459. package/web/src/components/MessageList.tsx +458 -0
  460. package/web/src/components/Modal.tsx +72 -0
  461. package/web/src/components/NotificationEntry.tsx +31 -0
  462. package/web/src/components/NotificationSettings.tsx +226 -0
  463. package/web/src/components/NotificationToasts.tsx +59 -0
  464. package/web/src/components/OpenTabs.tsx +112 -0
  465. package/web/src/components/Pending.tsx +175 -0
  466. package/web/src/components/ProjectExtras.tsx +299 -0
  467. package/web/src/components/ProjectNav.tsx +47 -0
  468. package/web/src/components/SessionActions.tsx +271 -0
  469. package/web/src/components/SessionChannels.tsx +288 -0
  470. package/web/src/components/SessionExtras.tsx +263 -0
  471. package/web/src/components/SettingsLink.tsx +21 -0
  472. package/web/src/components/Shell.tsx +320 -0
  473. package/web/src/components/SvgPreview.tsx +18 -0
  474. package/web/src/components/TodoPanel.tsx +37 -0
  475. package/web/src/components/VersionNotice.tsx +40 -0
  476. package/web/src/components/icons.tsx +43 -0
  477. package/web/src/diff.ts +62 -0
  478. package/web/src/drafts.test.ts +80 -0
  479. package/web/src/drafts.ts +64 -0
  480. package/web/src/format.ts +5 -0
  481. package/web/src/highlight.worker.ts +49 -0
  482. package/web/src/hooks.ts +59 -0
  483. package/web/src/item-view-state.test.ts +51 -0
  484. package/web/src/item-view-state.ts +43 -0
  485. package/web/src/live-history-flow.test.ts +77 -0
  486. package/web/src/live-merge.test.ts +64 -0
  487. package/web/src/live-merge.ts +50 -0
  488. package/web/src/live-message-hydration.test.ts +253 -0
  489. package/web/src/live.ts +404 -0
  490. package/web/src/main.tsx +27 -0
  491. package/web/src/markdown-assets.test.ts +70 -0
  492. package/web/src/markdown-assets.ts +78 -0
  493. package/web/src/markdown-html.test.ts +84 -0
  494. package/web/src/markdown-html.ts +60 -0
  495. package/web/src/markdown-svg.test.ts +37 -0
  496. package/web/src/markdown-svg.ts +42 -0
  497. package/web/src/markdown-tables.test.ts +34 -0
  498. package/web/src/markdown-tables.ts +17 -0
  499. package/web/src/message-bodies.ts +117 -0
  500. package/web/src/message-cache.test.ts +208 -0
  501. package/web/src/message-cache.ts +158 -0
  502. package/web/src/message-font-size.test.ts +41 -0
  503. package/web/src/message-font-size.ts +57 -0
  504. package/web/src/message-time.test.ts +32 -0
  505. package/web/src/message-time.ts +26 -0
  506. package/web/src/notify.ts +132 -0
  507. package/web/src/open-tabs.test.ts +69 -0
  508. package/web/src/open-tabs.ts +110 -0
  509. package/web/src/pages/AgentView.tsx +138 -0
  510. package/web/src/pages/ChangesView.tsx +193 -0
  511. package/web/src/pages/FilesView.tsx +406 -0
  512. package/web/src/pages/HomePage.tsx +96 -0
  513. package/web/src/pages/LoginPage.tsx +56 -0
  514. package/web/src/pages/OverviewPage.tsx +124 -0
  515. package/web/src/pages/ProjectHome.tsx +136 -0
  516. package/web/src/pages/SessionView.tsx +238 -0
  517. package/web/src/pages/WorktreesView.tsx +197 -0
  518. package/web/src/pages/settings/AccountPage.tsx +149 -0
  519. package/web/src/pages/settings/AgentsPage.tsx +182 -0
  520. package/web/src/pages/settings/ConfigPage.tsx +181 -0
  521. package/web/src/pages/settings/GeneralPage.tsx +27 -0
  522. package/web/src/pages/settings/HooksPage.tsx +244 -0
  523. package/web/src/pages/settings/McpPage.tsx +310 -0
  524. package/web/src/pages/settings/MemoryPage.tsx +309 -0
  525. package/web/src/pages/settings/PermissionsPage.tsx +160 -0
  526. package/web/src/pages/settings/PluginsPage.tsx +251 -0
  527. package/web/src/pages/settings/SettingsPage.tsx +141 -0
  528. package/web/src/pages/settings/SkillsPage.tsx +142 -0
  529. package/web/src/pages/settings/ui.tsx +165 -0
  530. package/web/src/preference-document.ts +133 -0
  531. package/web/src/project-actions.ts +18 -0
  532. package/web/src/project-files-state.test.ts +49 -0
  533. package/web/src/project-files-state.ts +64 -0
  534. package/web/src/project-images.ts +13 -0
  535. package/web/src/pwa.ts +33 -0
  536. package/web/src/queryClient.ts +5 -0
  537. package/web/src/reasoning-preference.test.ts +39 -0
  538. package/web/src/reasoning-preference.ts +42 -0
  539. package/web/src/session-confirmations.ts +36 -0
  540. package/web/src/stream-replay.test.ts +163 -0
  541. package/web/src/styles.css +231 -0
  542. package/web/src/tool-result-body.test.ts +115 -0
  543. package/web/src/transcript.test.ts +186 -0
  544. package/web/src/transcript.ts +583 -0
  545. package/web/src/turns.test.ts +338 -0
  546. package/web/src/turns.ts +178 -0
  547. package/web/src/ui/Button.tsx +60 -0
  548. package/web/src/ui/Controls.tsx +227 -0
  549. package/web/src/ui/Form.tsx +203 -0
  550. package/web/src/ui/MenuPopover.tsx +144 -0
  551. package/web/src/ui/Popover.tsx +147 -0
  552. package/web/src/ui/ResizablePane.tsx +110 -0
  553. package/web/src/ui/Select.tsx +150 -0
  554. package/web/src/ui/icons.tsx +270 -0
  555. package/web/src/unsaved.tsx +99 -0
  556. package/web/src/user-text.test.ts +60 -0
  557. package/web/src/user-text.ts +35 -0
  558. package/web/src/uuid.ts +12 -0
  559. package/README.md +0 -3
@@ -0,0 +1,364 @@
1
+ import { test, after, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { channelFixture, until } from "./http-channel-test-fixture.ts";
4
+
5
+ const fixture = await channelFixture();
6
+ afterEach(() => fixture.reset());
7
+ after(() => fixture.close());
8
+
9
+ test("complete text blocks push once with IDs, even when API message IDs match; deltas/thinking/subagents do not", async () => {
10
+ const posts: Record<string, any>[] = [];
11
+ const endpoint = await fixture.http(async (request, response) => {
12
+ assert.equal(request.method, "POST");
13
+ let body = "";
14
+ for await (const chunk of request) body += chunk;
15
+ posts.push(JSON.parse(body));
16
+ response.end("ok");
17
+ });
18
+ const manager = fixture.httpChannels;
19
+ manager.start();
20
+ const session = fixture.newSession();
21
+ const source = fixture.store.saveSource({ name: "push", url: endpoint.url });
22
+ const binding = fixture.store.createBinding(session, source.id, "push");
23
+ manager.activate(binding.id);
24
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
25
+ const query = fixture.queryFor(session)!;
26
+ const first = fixture.text(fixture.id(), "first");
27
+ query.push({ type: "stream_event", parent_tool_use_id: null, event: { type: "message_start", message: { id: "api-message" } } });
28
+ query.push({ type: "stream_event", parent_tool_use_id: null, event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "delta must not push" } } });
29
+ query.push(first);
30
+ query.push(first);
31
+ query.push(fixture.text(fixture.id(), "second"));
32
+ query.push({ ...fixture.text(fixture.id(), "subagent"), parent_tool_use_id: "agent-tool" });
33
+ query.push({ ...fixture.text(fixture.id(), "thinking"), message: { id: "thinking", model: "mock", content: [{ type: "thinking", thinking: "hidden" }] } });
34
+ await until(() => posts.length === 2 && manager.info(session).bindings[0]!.stats!.delivered === 2);
35
+ assert.deepEqual(posts.map((post) => post.text), ["first", "second"]);
36
+ assert.ok(posts.every((post) => post.kind === "text" && post.sessionId === session && typeof post.id === "string"));
37
+ assert.notEqual(posts[0]!.id, posts[1]!.id);
38
+ assert.equal(manager.info(session).bindings[0]!.stats!.duplicates, 1);
39
+ });
40
+
41
+ test("a stalled receiver does not block SDK output, and each registration preserves order", async () => {
42
+ const texts: string[] = [];
43
+ let release: (() => void) | undefined;
44
+ const endpoint = await fixture.http(async (request, response) => {
45
+ let body = "";
46
+ for await (const chunk of request) body += chunk;
47
+ texts.push(JSON.parse(body).text);
48
+ if (texts.length === 1) release = () => response.end();
49
+ else response.end();
50
+ });
51
+ const manager = fixture.httpChannels;
52
+ manager.start();
53
+ const session = fixture.newSession();
54
+ const source = fixture.store.saveSource({ name: "slow", url: endpoint.url });
55
+ manager.activate(fixture.store.createBinding(session, source.id, "push").id);
56
+ const live = await fixture.sessions.getOrCreate(session, fixture.tmp);
57
+ await live.wake();
58
+ const query = fixture.queryFor(session)!;
59
+ query.push(fixture.text(fixture.id(), "one"));
60
+ await until(() => !!release);
61
+ query.push(fixture.text(fixture.id(), "two"));
62
+ await until(() => live.events.filter((event) => event.ev.k === "sdk" && event.ev.m.type === "assistant").length === 2);
63
+ assert.deepEqual(texts, ["one"]);
64
+ release!();
65
+ await until(() => texts.length === 2);
66
+ assert.deepEqual(texts, ["one", "two"]);
67
+ });
68
+
69
+ test("push failures are counted without retries", async () => {
70
+ let posts = 0;
71
+ const endpoint = await fixture.http((_request, response) => { posts++; response.statusCode = 500; response.end(); });
72
+ const manager = fixture.httpChannels;
73
+ manager.start();
74
+ const session = fixture.newSession();
75
+ const source = fixture.store.saveSource({ name: "failed", url: endpoint.url });
76
+ manager.activate(fixture.store.createBinding(session, source.id, "push").id);
77
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
78
+ fixture.queryFor(session)!.push(fixture.text(fixture.id(), "try once"));
79
+ await until(() => manager.info(session).bindings[0]!.stats!.dropped === 1);
80
+ await new Promise((resolve) => setTimeout(resolve, 50));
81
+ assert.equal(posts, 1);
82
+ assert.equal(manager.info(session).bindings[0]!.stats!.lastError, "HTTP 500");
83
+ });
84
+
85
+ test("source rename retains queued pushes and frame-ID deduplication", async () => {
86
+ const posts: string[] = [];
87
+ let release: (() => void) | undefined;
88
+ const endpoint = await fixture.http(async (request, response) => {
89
+ let body = "";
90
+ for await (const chunk of request) body += chunk;
91
+ posts.push(JSON.parse(body).text);
92
+ if (posts.length === 1) release = () => response.end();
93
+ else response.end();
94
+ });
95
+ const manager = fixture.httpChannels;
96
+ manager.start();
97
+ const session = fixture.newSession();
98
+ const source = fixture.store.saveSource({ name: "before", url: endpoint.url });
99
+ manager.activate(fixture.store.createBinding(session, source.id, "push").id);
100
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
101
+ const query = fixture.queryFor(session)!;
102
+ const first = fixture.text(fixture.id(), "one");
103
+ query.push(first);
104
+ await until(() => !!release);
105
+ query.push(fixture.text(fixture.id(), "two"));
106
+ await until(() => manager.info(session).bindings[0]!.stats!.pending === 2);
107
+ fixture.store.saveSource({ name: "after", url: endpoint.url }, source.id);
108
+ manager.sourceChanged(source.id);
109
+ assert.equal(manager.info(session).bindings[0]!.stats!.pending, 2);
110
+ query.push(first);
111
+ await until(() => manager.info(session).bindings[0]!.stats!.duplicates === 1);
112
+ release!();
113
+ await until(() => manager.info(session).bindings[0]!.stats!.delivered === 2);
114
+ assert.deepEqual(posts, ["one", "two"]);
115
+ });
116
+
117
+ test("push Content-Type is always a single application/json, including legacy configured headers", async () => {
118
+ let contentType: string | undefined;
119
+ const endpoint = await fixture.http((request, response) => {
120
+ contentType = request.headers["content-type"];
121
+ response.end();
122
+ });
123
+ const source = fixture.store.saveSource({ name: "JSON", url: endpoint.url, headers: { "cOnTeNt-TyPe": "text/plain", Authorization: "Bearer test" } });
124
+ assert.equal(new Headers(JSON.parse(source.headers_json)).has("content-type"), false);
125
+ // Old persisted configurations must not bypass the fixed protocol header either.
126
+ fixture.db.prepare("UPDATE http_sources SET headers_json=? WHERE id=?").run(JSON.stringify({ "Content-Type": "text/plain", Authorization: "Bearer test" }), source.id);
127
+ const manager = fixture.httpChannels;
128
+ manager.start();
129
+ const session = fixture.newSession();
130
+ manager.activate(fixture.store.createBinding(session, source.id, "push").id);
131
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
132
+ fixture.queryFor(session)!.push(fixture.text(fixture.id(), "JSON body"));
133
+ await until(() => contentType !== undefined);
134
+ assert.equal(contentType, "application/json");
135
+ });
136
+
137
+ test("per-source extra body merges KOOK fields and substitutes escaped text without changing protocol fields", async () => {
138
+ const posts: Record<string, any>[] = [];
139
+ const endpoint = await fixture.http(async (request, response) => {
140
+ let body = "";
141
+ for await (const chunk of request) body += chunk;
142
+ assert.equal(request.headers["content-type"], "application/json");
143
+ posts.push(JSON.parse(body));
144
+ response.end();
145
+ });
146
+ const manager = fixture.httpChannels;
147
+ manager.start();
148
+ const session = fixture.newSession();
149
+ const plain = fixture.store.saveSource({ name: "plain", url: endpoint.url });
150
+ const custom = fixture.store.saveSource({ name: "KOOK", url: endpoint.url, extraBody: {
151
+ type: 9, target_id: "channel-id", content: "{{text}}", nonce: "{{id}}",
152
+ nested: { session: "{{sessionId}}", items: [true, 42, null, "prefix: {{text}}"] },
153
+ } });
154
+ for (const source of [plain, custom]) manager.activate(fixture.store.createBinding(session, source.id, "push").id);
155
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
156
+ const text = 'quotes " and newline\nbackslash \\ literal {{id}} 中文';
157
+ fixture.queryFor(session)!.push(fixture.text(fixture.id(), text));
158
+ await until(() => posts.length === 2 && manager.info(session).bindings.every((binding) => binding.stats!.delivered === 1));
159
+ const plainPost = posts.find((post) => !post.target_id)!;
160
+ const kook = posts.find((post) => post.target_id)!;
161
+ assert.equal(kook.type, 9);
162
+ assert.equal(kook.target_id, "channel-id");
163
+ assert.equal(kook.content, text);
164
+ assert.equal(kook.nonce, plainPost.id);
165
+ assert.deepEqual(kook.nested, { session, items: [true, 42, null, `prefix: ${text}`] });
166
+ assert.deepEqual({ kind: kook.kind, id: kook.id, text: kook.text, sessionId: kook.sessionId }, plainPost);
167
+ });
168
+
169
+ test("editing extra body applies to new messages and bounds template expansion", async () => {
170
+ const posts: Record<string, any>[] = [];
171
+ const endpoint = await fixture.http(async (request, response) => {
172
+ let body = "";
173
+ for await (const chunk of request) body += chunk;
174
+ posts.push(JSON.parse(body));
175
+ response.end();
176
+ });
177
+ const manager = fixture.httpChannels;
178
+ manager.start();
179
+ const session = fixture.newSession();
180
+ const source = fixture.store.saveSource({ name: "extra", url: endpoint.url, extraBody: { target_id: "before", content: "{{text}}" } });
181
+ const binding = fixture.store.createBinding(session, source.id, "push");
182
+ manager.activate(binding.id);
183
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
184
+ const query = fixture.queryFor(session)!;
185
+ query.push(fixture.text(fixture.id(), "first"));
186
+ await until(() => manager.info(session).bindings[0]!.stats!.delivered === 1);
187
+ fixture.store.saveSource({ name: source.name, url: source.url, extraBody: { target_id: "after", content: "{{text}}" } }, source.id);
188
+ manager.sourceChanged(source.id);
189
+ query.push(fixture.text(fixture.id(), "second"));
190
+ await until(() => manager.info(session).bindings[0]!.stats!.delivered === 2);
191
+ assert.deepEqual(posts.map((post) => post.target_id), ["before", "after"]);
192
+ fixture.store.saveSource({ name: source.name, url: source.url, extraBody: { content: "{{text}}".repeat(100) } }, source.id);
193
+ manager.sourceChanged(source.id);
194
+ query.push(fixture.text(fixture.id(), "x".repeat(64 * 1024)));
195
+ await until(() => manager.info(session).bindings[0]!.stats!.dropped === 1);
196
+ assert.match(manager.info(session).bindings[0]!.stats!.lastError!, /256KB/);
197
+ assert.equal(posts.length, 2);
198
+ });
199
+
200
+ test("queued brain-api pushes keep matching destinations, credentials, brain IDs and voices across config edits", async () => {
201
+ const posts: { server: string; authorization?: string; voice: string | string[] | undefined; body: unknown }[] = [];
202
+ let release: (() => void) | undefined;
203
+ const receiver = (server: string) => async (request: import("node:http").IncomingMessage, response: import("node:http").ServerResponse) => {
204
+ let raw = "";
205
+ for await (const chunk of request) raw += chunk;
206
+ if (request.url === "/agent-to-brain") {
207
+ posts.push({ server, authorization: request.headers.authorization, voice: request.headers["x-brain-voice"], body: JSON.parse(raw) });
208
+ if (posts.length === 1) { release = () => response.end('{"kind":"empty"}'); return; }
209
+ }
210
+ response.end('{"kind":"empty"}');
211
+ };
212
+ const before = await fixture.http(receiver("before"));
213
+ const after = await fixture.http(receiver("after"));
214
+ const manager = fixture.httpChannels;
215
+ manager.start();
216
+ const session = fixture.newSession();
217
+ const source = fixture.store.saveSource({ kind: "brain-api", brainId: "old-brain", brainVoice: "old-voice", url: before.url, headers: { Authorization: "Bearer old" } });
218
+ manager.activate(fixture.store.createBinding(session, source.id, "subscribe").id);
219
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
220
+ const query = fixture.queryFor(session)!;
221
+ query.push(fixture.text(fixture.id(), "in flight"));
222
+ await until(() => !!release);
223
+ query.push(fixture.text(fixture.id(), "queued"));
224
+ await until(() => manager.info(session).bindings[0]!.stats!.pending === 2);
225
+ fixture.store.saveSource({ name: source.name, kind: "brain-api", brainId: "new-brain", brainVoice: "new-voice", url: after.url, headers: { Authorization: "Bearer new" } }, source.id);
226
+ manager.sourceChanged(source.id);
227
+ query.push(fixture.text(fixture.id(), "new config"));
228
+ await until(() => manager.info(session).bindings[0]!.stats!.pending === 3);
229
+ release!();
230
+ await until(() => manager.info(session).bindings[0]!.stats!.pushed === 3);
231
+ assert.deepEqual(posts, [
232
+ { server: "before", authorization: "Bearer old", voice: "old-voice", body: { brain_id: "old-brain", text: "in flight" } },
233
+ { server: "before", authorization: "Bearer old", voice: "old-voice", body: { brain_id: "old-brain", text: "queued" } },
234
+ { server: "after", authorization: "Bearer new", voice: "new-voice", body: { brain_id: "new-brain", text: "new config" } },
235
+ ]);
236
+ });
237
+
238
+ test("voice-only edits preserve the held poll and queued push headers, with generic headers as fallback", async () => {
239
+ const posts: { text: string; voice: string | string[] | undefined }[] = [];
240
+ let pollHeld = false;
241
+ let pollCancelled = false;
242
+ let release: (() => void) | undefined;
243
+ const endpoint = await fixture.http(async (request, response) => {
244
+ assert.equal(request.headers["content-type"], "application/json");
245
+ assert.equal(request.headers.authorization, "Bearer unchanged");
246
+ let raw = "";
247
+ for await (const chunk of request) raw += chunk;
248
+ const body = JSON.parse(raw);
249
+ if (request.url === "/brain-to-agent") {
250
+ assert.deepEqual(body, { brain_id: "test-brain" });
251
+ assert.equal(request.headers["x-brain-voice"], "generic-voice");
252
+ pollHeld = true;
253
+ response.on("close", () => { pollCancelled = true; });
254
+ return;
255
+ }
256
+ assert.equal(request.url, "/agent-to-brain");
257
+ assert.deepEqual(Object.keys(body).sort(), ["brain_id", "text"]);
258
+ posts.push({ text: body.text, voice: request.headers["x-brain-voice"] });
259
+ if (posts.length === 1) release = () => response.end();
260
+ else response.end();
261
+ });
262
+ const manager = fixture.httpChannels;
263
+ manager.start();
264
+ const session = fixture.newSession();
265
+ const source = fixture.store.saveSource({ kind: "brain-api", brainId: "test-brain", brainVoice: "voice-before", url: endpoint.url, headers: { Authorization: "Bearer unchanged", "X-Brain-Voice": "generic-voice" } });
266
+ manager.activate(fixture.store.createBinding(session, source.id, "subscribe").id);
267
+ await (await fixture.sessions.getOrCreate(session, fixture.tmp)).wake();
268
+ const query = fixture.queryFor(session)!;
269
+ query.push(fixture.text(fixture.id(), "in flight"));
270
+ await until(() => pollHeld && !!release);
271
+ query.push(fixture.text(fixture.id(), "queued before edit"));
272
+ await until(() => manager.info(session).bindings[0]!.stats!.pending === 2);
273
+ fixture.store.saveSource({ name: source.name, url: source.url, brainVoice: "voice-after" }, source.id);
274
+ manager.sourceChanged(source.id);
275
+ query.push(fixture.text(fixture.id(), "queued after edit"));
276
+ await until(() => manager.info(session).bindings[0]!.stats!.pending === 3);
277
+ fixture.store.saveSource({ name: source.name, url: source.url, brainVoice: null }, source.id);
278
+ manager.sourceChanged(source.id);
279
+ query.push(fixture.text(fixture.id(), "dedicated voice cleared"));
280
+ await until(() => manager.info(session).bindings[0]!.stats!.pending === 4);
281
+ release!();
282
+ await until(() => manager.info(session).bindings[0]!.stats!.pushed === 4);
283
+ assert.equal(pollCancelled, false);
284
+ assert.deepEqual(posts, [
285
+ { text: "in flight", voice: "voice-before" },
286
+ { text: "queued before edit", voice: "voice-before" },
287
+ { text: "queued after edit", voice: "voice-after" },
288
+ { text: "dedicated voice cleared", voice: "generic-voice" },
289
+ ]);
290
+ });
291
+
292
+ test("shared brain source keeps per-session pull IDs and push overrides isolated, including queued edits and restart", async () => {
293
+ const polls: { id: string; closed: boolean }[] = [];
294
+ const posts: { brain_id: string; text: string; voice?: string | string[] }[] = [];
295
+ let release: (() => void) | undefined;
296
+ const endpoint = await fixture.http(async (request, response) => {
297
+ let raw = "";
298
+ for await (const chunk of request) raw += chunk;
299
+ const body = JSON.parse(raw);
300
+ if (request.url === "/brain-to-agent") {
301
+ assert.equal(request.headers["x-brain-voice"], undefined);
302
+ const poll = { id: body.brain_id, closed: false };
303
+ polls.push(poll);
304
+ response.on("close", () => { poll.closed = true; });
305
+ return;
306
+ }
307
+ assert.equal(request.url, "/agent-to-brain");
308
+ posts.push({ ...body, voice: request.headers["x-brain-voice"] });
309
+ if (body.text === "held") release = () => response.end();
310
+ else response.end();
311
+ });
312
+ const manager = fixture.httpChannels;
313
+ const source = fixture.store.saveSource({ kind: "brain-api", brainId: "default", brainVoice: "default-voice", url: endpoint.url });
314
+ const first = fixture.newSession();
315
+ const second = fixture.newSession();
316
+ const inherited = fixture.store.createBinding(first, source.id, "subscribe");
317
+ const own = fixture.store.createBinding(second, source.id, "subscribe", { brainId: "own", brainVoice: "own-voice" });
318
+ manager.start();
319
+ manager.activate(inherited.id);
320
+ manager.activate(own.id);
321
+ await until(() => polls.length === 2);
322
+ assert.deepEqual(polls.map((poll) => poll.id).sort(), ["default", "own"]);
323
+ await (await fixture.sessions.getOrCreate(first, fixture.tmp)).wake();
324
+ await (await fixture.sessions.getOrCreate(second, fixture.tmp)).wake();
325
+ const firstQuery = fixture.queryFor(first)!;
326
+ const secondQuery = fixture.queryFor(second)!;
327
+ firstQuery.push(fixture.text(fixture.id(), "inherited"));
328
+ secondQuery.push(fixture.text(fixture.id(), "held"));
329
+ await until(() => !!release && posts.length === 2);
330
+ secondQuery.push(fixture.text(fixture.id(), "queued"));
331
+ await until(() => manager.info(second).bindings[0]!.stats!.pending === 2);
332
+ manager.updateBinding(own.id, { brainId: "new-own", brainVoice: "" });
333
+ await until(() => polls.some((poll) => poll.id === "new-own"));
334
+ assert.equal(polls.find((poll) => poll.id === "own")!.closed, true);
335
+ assert.equal(polls.find((poll) => poll.id === "default")!.closed, false);
336
+ secondQuery.push(fixture.text(fixture.id(), "after edit"));
337
+ await until(() => manager.info(second).bindings[0]!.stats!.pending === 3);
338
+ release!();
339
+ await until(() => posts.length === 4);
340
+ assert.deepEqual(posts.find((post) => post.text === "inherited"), { brain_id: "default", text: "inherited", voice: "default-voice" });
341
+ assert.deepEqual(posts.filter((post) => post.text !== "inherited"), [
342
+ { brain_id: "own", text: "held", voice: "own-voice" },
343
+ { brain_id: "own", text: "queued", voice: "own-voice" },
344
+ { brain_id: "new-own", text: "after edit", voice: undefined },
345
+ ]);
346
+ // Source edits do not restart an overridden poll. Inheritors move to the new ID.
347
+ const ownPoll = polls.find((poll) => poll.id === "new-own")!;
348
+ fixture.store.saveSource({ url: source.url, brainId: "next-default", brainVoice: "next-voice" }, source.id);
349
+ manager.sourceChanged(source.id);
350
+ await until(() => polls.some((poll) => poll.id === "next-default"));
351
+ assert.equal(ownPoll.closed, false);
352
+ manager.updateBinding(own.id, { brainId: null, brainVoice: null });
353
+ await until(() => polls.filter((poll) => poll.id === "next-default").length === 2);
354
+ secondQuery.push(fixture.text(fixture.id(), "inherit again"));
355
+ await until(() => posts.length === 5);
356
+ assert.deepEqual(posts[4], { brain_id: "next-default", text: "inherit again", voice: "next-voice" });
357
+ manager.updateBinding(own.id, { brainId: "restored", brainVoice: "restored-voice" });
358
+ manager.stop();
359
+ manager.start();
360
+ await until(() => polls.filter((poll) => poll.id === "restored").length >= 1 && polls.filter((poll) => poll.id === "next-default").length >= 3);
361
+ secondQuery.push(fixture.text(fixture.id(), "after restart"));
362
+ await until(() => posts.length === 6);
363
+ assert.deepEqual(posts[5], { brain_id: "restored", text: "after restart", voice: "restored-voice" });
364
+ });
@@ -0,0 +1,291 @@
1
+ import { test, after, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { channelFixture, until } from "./http-channel-test-fixture.ts";
4
+
5
+ const fixture = await channelFixture();
6
+ afterEach(() => fixture.reset());
7
+ after(() => fixture.close());
8
+
9
+ test("source and registration CRUD persist configuration without returning auth headers", async () => {
10
+ const { app, project, newSession, httpChannels } = fixture;
11
+ httpChannels.start();
12
+ const response = await app.request("/api/http-channels/sources", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "local", url: "http://127.0.0.1:1", headers: { Authorization: "Bearer private-test-token" } }) });
13
+ assert.equal(response.status, 201);
14
+ const source = await response.json();
15
+ assert.equal(source.hasHeaders, true);
16
+ const session = newSession();
17
+ const body = { sourceId: source.id, direction: "push" };
18
+ const path = `/api/projects/${project.id}/sessions/${session}/http-bindings`;
19
+ const bindingResponse = await app.request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
20
+ assert.equal(bindingResponse.status, 201);
21
+ const binding = await bindingResponse.json();
22
+ assert.equal((await app.request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })).status, 409);
23
+ const listing = await (await app.request("/api/http-channels")).json();
24
+ assert.equal(listing.bindings[0].active, false);
25
+ assert.ok(!JSON.stringify(listing).includes("private-test-token"));
26
+ const edit = await app.request(`/api/http-channels/sources/${source.id}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "updated", url: source.url }) });
27
+ assert.equal(edit.status, 200);
28
+ assert.ok(fixture.store.sourceRow(source.id)!.headers_json.includes("private-test-token"));
29
+ const detailsResponse = await app.request(`/api/http-channels/sources/${source.id}`);
30
+ assert.equal(detailsResponse.status, 200);
31
+ assert.equal(detailsResponse.headers.get("cache-control"), "no-store");
32
+ assert.deepEqual((await detailsResponse.json()).headers, { authorization: "Bearer private-test-token" });
33
+ assert.equal((await app.request(`/api/http-channels/bindings/${binding.id}/activation`, { method: "POST", headers: { "content-type": "application/json" }, body: '{"active":true}' })).status, 200);
34
+ assert.equal(httpChannels.info().activeCount, 1);
35
+ assert.equal((await app.request(`/api/http-channels/sources/${source.id}`, { method: "DELETE" })).status, 200);
36
+ assert.equal(httpChannels.info().activeCount, 0);
37
+ assert.equal(httpChannels.info().bindings.length, 0);
38
+ });
39
+
40
+ test("editing headers reads back replacements and clears them without affecting the fixed JSON content type", async () => {
41
+ const { app } = fixture;
42
+ const source = fixture.store.saveSource({ name: "headers", url: "http://127.0.0.1:1", headers: { Authorization: "Bearer before" } });
43
+ const path = `/api/http-channels/sources/${source.id}`;
44
+ const replaced = await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: source.name, url: source.url, headers: { Authorization: "Bearer after", "X-API-Key": "example", "Content-Type": "text/plain" } }) });
45
+ assert.equal(replaced.status, 200);
46
+ const readback = await (await app.request(path)).json();
47
+ assert.deepEqual(readback.headers, { authorization: "Bearer after", "x-api-key": "example" });
48
+ const cleared = await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: source.name, url: source.url, headers: {} }) });
49
+ assert.equal(cleared.status, 200);
50
+ const empty = await (await app.request(path)).json();
51
+ assert.equal(empty.hasHeaders, false);
52
+ assert.deepEqual(empty.headers, {});
53
+ });
54
+
55
+ test("extra body persists, reads back, preserves omitted updates, clears and rejects invalid objects", async () => {
56
+ const { app } = fixture;
57
+ const input = { name: "extra", url: "http://127.0.0.1:1", extraBody: { target_id: "channel", content: "{{text}}", nested: { enabled: true, values: [1, null] } } };
58
+ const created = await app.request("/api/http-channels/sources", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input) });
59
+ assert.equal(created.status, 201);
60
+ const source = await created.json();
61
+ assert.equal(source.hasExtraBody, true);
62
+ const path = `/api/http-channels/sources/${source.id}`;
63
+ assert.deepEqual((await (await app.request(path)).json()).extraBody, input.extraBody);
64
+ const renamed = await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "renamed", url: source.url }) });
65
+ assert.equal(renamed.status, 200);
66
+ assert.deepEqual((await (await app.request(path)).json()).extraBody, input.extraBody);
67
+ for (const extraBody of [null, [], "text", { id: "overwrite" }, { text: "overwrite" }, { sessionId: "overwrite" }, { kind: "overwrite" }, { big: "x".repeat(16_384) }]) {
68
+ const invalid = await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: source.name, url: source.url, extraBody }) });
69
+ assert.equal(invalid.status, 400);
70
+ }
71
+ const cleared = await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: source.name, url: source.url, extraBody: {} }) });
72
+ assert.equal(cleared.status, 200);
73
+ const details = await (await app.request(path)).json();
74
+ assert.equal(details.hasExtraBody, false);
75
+ assert.deepEqual(details.extraBody, {});
76
+ });
77
+
78
+ test("subscription method defaults to GET, persists POST across omitted updates and validates input", async () => {
79
+ const { app } = fixture;
80
+ const source = fixture.store.saveSource({ name: "method", url: "http://127.0.0.1:1" });
81
+ assert.equal(source.subscribe_method, "GET");
82
+ const path = `/api/http-channels/sources/${source.id}`;
83
+ const update = (body: object) => app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: source.name, url: source.url, ...body }) });
84
+ assert.equal((await update({ subscribeMethod: "POST", extraBody: { brain_id: "genkigirl" } })).status, 200);
85
+ assert.equal((await (await app.request(path)).json()).subscribeMethod, "POST");
86
+ assert.equal((await update({ name: "renamed" })).status, 200);
87
+ assert.equal((await (await app.request(path)).json()).subscribeMethod, "POST");
88
+ for (const subscribeMethod of ["PUT", "post", "", null, 0]) assert.equal((await update({ subscribeMethod })).status, 400);
89
+ });
90
+
91
+ test("brain-api accepts optional source defaults, persists its type and registers both directions as one subscription", async () => {
92
+ const { app } = fixture;
93
+ const create = (body: object) => app.request("/api/http-channels/sources", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
94
+ for (const brainId of [42, "x".repeat(257)]) {
95
+ assert.equal((await create({ kind: "brain-api", brainId, brainVoice: "optional-voice", url: "http://127.0.0.1:1" })).status, 400);
96
+ }
97
+ for (const brainId of [undefined, null, "", " "]) {
98
+ const response = await create({ kind: "brain-api", brainId, url: "http://127.0.0.1:1" });
99
+ assert.equal(response.status, 201);
100
+ const source = await response.json();
101
+ assert.equal(source.brainId, null);
102
+ assert.equal(source.name, "brain-api");
103
+ }
104
+ const created = await create({ kind: "brain-api", brainId: " genkigirl ", url: "http://127.0.0.1:1" });
105
+ assert.equal(created.status, 201);
106
+ const source = await created.json();
107
+ assert.equal(source.kind, "brain-api");
108
+ assert.equal(source.brainId, "genkigirl");
109
+ assert.equal(source.brainVoice, null);
110
+ assert.equal(source.subscribeMethod, "POST");
111
+ assert.ok(source.name.includes("genkigirl"));
112
+ const path = `/api/http-channels/sources/${source.id}`;
113
+ assert.equal((await (await app.request(path)).json()).brainId, "genkigirl");
114
+ const renamed = await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "renamed", url: source.url }) });
115
+ assert.equal(renamed.status, 200);
116
+ assert.equal((await (await app.request(path)).json()).kind, "brain-api");
117
+ assert.equal((await (await app.request(path)).json()).brainId, "genkigirl");
118
+ assert.equal((await app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind: "http", name: "changed", url: source.url }) })).status, 400);
119
+ const session = fixture.newSession();
120
+ const registrationPath = `/api/projects/${fixture.project.id}/sessions/${session}/http-bindings`;
121
+ const register = (direction: string) => app.request(registrationPath, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceId: source.id, direction }) });
122
+ assert.equal((await register("push")).status, 400);
123
+ assert.equal((await register("subscribe")).status, 201);
124
+ assert.equal((await register("subscribe")).status, 409);
125
+ assert.equal(fixture.httpChannels.info().bindings.length, 1);
126
+ });
127
+
128
+ test("brain binding overrides round-trip, preserve omitted updates and explicitly clear or inherit voice", async () => {
129
+ const source = fixture.store.saveSource({ kind: "brain-api", url: "http://127.0.0.1:1", brainId: "default", brainVoice: "default-voice" });
130
+ const session = fixture.newSession();
131
+ const path = `/api/projects/${fixture.project.id}/sessions/${session}/http-bindings`;
132
+ const created = await fixture.app.request(path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sourceId: source.id, direction: "subscribe", brainId: " own ", brainVoice: " own-voice " }) });
133
+ assert.equal(created.status, 201);
134
+ const { id } = await created.json();
135
+ const row = () => fixture.store.bindingRow(id)!;
136
+ const edit = (body: object) => fixture.app.request(`/api/http-channels/bindings/${id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
137
+ assert.equal(row().brain_id, "own");
138
+ assert.equal(row().brain_voice, "own-voice");
139
+ assert.equal((await edit({ brainVoice: "" })).status, 200);
140
+ assert.equal(row().brain_id, "own");
141
+ assert.equal(row().brain_voice, "");
142
+ const listing = fixture.httpChannels.info(session).bindings[0]!;
143
+ assert.equal(listing.brainId, "own");
144
+ assert.equal(listing.brainVoice, "");
145
+ for (const invalid of [{ brainId: 42 }, { brainId: "x".repeat(257) }, { brainVoice: 42 }, { brainVoice: "voice\r\ninjected" }, { brainVoice: "x".repeat(1025) }]) {
146
+ assert.equal((await edit(invalid)).status, 400);
147
+ assert.equal(row().brain_id, "own");
148
+ assert.equal(row().brain_voice, "");
149
+ }
150
+ assert.equal((await edit({ brainId: null, brainVoice: null })).status, 200);
151
+ assert.deepEqual(fixture.store.effectiveBrain(source, row()), { brainId: "default", brainVoice: "default-voice" });
152
+ assert.equal(fixture.store.sourceRow(source.id)!.brain_id, "default");
153
+ const plain = fixture.store.saveSource({ name: "plain", url: source.url });
154
+ assert.throws(() => fixture.store.createBinding(fixture.newSession(), plain.id, "push", { brainId: "wrong-kind" }), /只有 brain-api/);
155
+ });
156
+
157
+ test("missing effective brain_id cannot activate or evict another binding; clearing a source only deactivates inheritors", () => {
158
+ const manager = fixture.httpChannels;
159
+ manager.start();
160
+ const source = fixture.store.saveSource({ kind: "brain-api", url: "http://127.0.0.1:1" });
161
+ const inherited = fixture.store.createBinding(fixture.newSession(), source.id, "subscribe");
162
+ const explicit = fixture.store.createBinding(fixture.newSession(), source.id, "subscribe", { brainId: "own" });
163
+ assert.throws(() => manager.activate(inherited.id), /填写 brain_id/);
164
+ assert.equal(fixture.store.bindingRow(inherited.id)!.active, 0);
165
+ fixture.store.saveSource({ url: source.url, brainId: "default" }, source.id);
166
+ manager.sourceChanged(source.id);
167
+ manager.activate(inherited.id);
168
+ manager.activate(explicit.id);
169
+ fixture.store.saveSource({ url: source.url, brainId: null }, source.id);
170
+ manager.sourceChanged(source.id);
171
+ assert.equal(fixture.store.bindingRow(inherited.id)!.active, 0);
172
+ assert.equal(fixture.store.bindingRow(explicit.id)!.active, 1);
173
+ assert.throws(() => manager.updateBinding(explicit.id, { brainId: null }), /填写 brain_id/);
174
+ assert.equal(fixture.store.bindingRow(explicit.id)!.brain_id, "own");
175
+ const plain = fixture.store.saveSource({ name: "push", url: source.url });
176
+ for (let i = 0; i < 31; i++) manager.activate(fixture.store.createBinding(fixture.newSession(), plain.id, "push").id);
177
+ assert.equal(manager.info().activeCount, 32);
178
+ assert.throws(() => manager.activate(inherited.id), /填写 brain_id/);
179
+ assert.equal(manager.info().activeCount, 32);
180
+ assert.equal(fixture.store.bindingRow(explicit.id)!.active, 1);
181
+ // Restart restoration also uses the effective configuration, preserving overrides.
182
+ manager.stop();
183
+ fixture.store.setBindingActive(inherited.id, true);
184
+ manager.start();
185
+ assert.equal(fixture.store.bindingRow(inherited.id)!.active, 0);
186
+ assert.match(fixture.store.bindingRow(inherited.id)!.inactive_reason!, /填写 brain_id/);
187
+ assert.equal(fixture.store.bindingRow(explicit.id)!.brain_id, "own");
188
+ });
189
+
190
+ test("brain-api voice round-trips, preserves omitted updates, clears and validates header values", async () => {
191
+ const { app } = fixture;
192
+ const created = await app.request("/api/http-channels/sources", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ kind: "brain-api", brainId: "test-brain", brainVoice: " voice-before ", url: "http://127.0.0.1:1" }) });
193
+ assert.equal(created.status, 201);
194
+ const source = await created.json();
195
+ const path = `/api/http-channels/sources/${source.id}`;
196
+ const read = async () => (await app.request(path)).json();
197
+ const update = (body: object) => app.request(path, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: source.name, url: source.url, ...body }) });
198
+ assert.equal(source.brainVoice, "voice-before");
199
+ assert.equal(fixture.store.sourceRow(source.id)!.brain_voice, "voice-before");
200
+ assert.equal((await read()).brainVoice, "voice-before");
201
+ assert.equal((await (await app.request("/api/http-channels")).json()).sources[0].brainVoice, "voice-before");
202
+ assert.equal((await update({ name: "renamed" })).status, 200);
203
+ assert.equal((await read()).brainVoice, "voice-before");
204
+ const longest = "v".repeat(1024);
205
+ assert.equal((await update({ brainVoice: longest })).status, 200);
206
+ assert.equal((await read()).brainVoice, longest);
207
+ for (const brainVoice of [42, false, {}, [], "v".repeat(1025), "voice\r\ninjected", "voice\n", "\r\n", "voice\u0000", "voice\u007f", "中文"]) {
208
+ assert.equal((await update({ brainVoice })).status, 400, `must reject ${JSON.stringify(brainVoice)}`);
209
+ assert.equal(fixture.store.sourceRow(source.id)!.brain_voice, longest);
210
+ }
211
+ assert.equal((await update({ brainVoice: " \t " })).status, 200);
212
+ assert.equal((await read()).brainVoice, null);
213
+ assert.equal((await update({ brainVoice: "voice-after" })).status, 200);
214
+ assert.equal((await read()).brainVoice, "voice-after");
215
+ assert.equal((await update({ brainVoice: null })).status, 200);
216
+ assert.equal((await read()).brainVoice, null);
217
+ assert.equal(fixture.store.sourceRow(source.id)!.brain_voice, null);
218
+ const plain = fixture.store.saveSource({ name: "plain", url: source.url, brainVoice: "brain-only" });
219
+ assert.equal(plain.brain_voice, null);
220
+ assert.equal(fixture.store.sourceInfo(plain).brainVoice, null);
221
+ });
222
+
223
+ test("a brain-api subscription occupies one FIFO slot and eviction cancels its long poll", async () => {
224
+ let held = false;
225
+ let cancelled = false;
226
+ const endpoint = await fixture.http((_request, response) => { held = true; response.on("close", () => { cancelled = true; }); });
227
+ const manager = fixture.httpChannels;
228
+ manager.start();
229
+ const brain = fixture.store.saveSource({ kind: "brain-api", brainId: "test", url: endpoint.url });
230
+ const oldest = fixture.store.createBinding(fixture.newSession(), brain.id, "subscribe");
231
+ manager.activate(oldest.id);
232
+ await until(() => held);
233
+ const plain = fixture.store.saveSource({ name: "plain", url: "http://127.0.0.1:1" });
234
+ for (let i = 0; i < 31; i++) manager.activate(fixture.store.createBinding(fixture.newSession(), plain.id, "push").id);
235
+ assert.equal(manager.info().activeCount, 32);
236
+ assert.equal(manager.activate(fixture.store.createBinding(fixture.newSession(), plain.id, "push").id), oldest.id);
237
+ await until(() => cancelled);
238
+ assert.equal(manager.info().activeCount, 32);
239
+ assert.equal(manager.info().bindings.find((binding) => binding.id === oldest.id)!.active, false);
240
+ });
241
+
242
+ test("activating a 33rd registration evicts the oldest subscription, cancelling its long poll", async () => {
243
+ let open = false;
244
+ let cancelled = false;
245
+ const endpoint = await fixture.http((_request, response) => {
246
+ open = true;
247
+ response.on("close", () => { cancelled = true; });
248
+ });
249
+ const manager = fixture.httpChannels;
250
+ manager.start();
251
+ const source = fixture.store.saveSource({ name: "long-poll", url: endpoint.url });
252
+ const oldest = fixture.store.createBinding(fixture.newSession(), source.id, "subscribe");
253
+ manager.activate(oldest.id);
254
+ await until(() => open);
255
+ for (let index = 0; index < 31; index++) manager.activate(fixture.store.createBinding(fixture.newSession(), source.id, "push").id);
256
+ const newest = fixture.store.createBinding(fixture.newSession(), source.id, "push");
257
+ assert.equal(manager.activate(newest.id), oldest.id);
258
+ assert.equal(manager.info().activeCount, 32);
259
+ assert.equal(fixture.store.bindingRow(oldest.id)!.active, 0);
260
+ assert.equal(manager.info().bindings.find((binding) => binding.id === oldest.id)!.stats, null);
261
+ await until(() => cancelled);
262
+ assert.equal(fixture.queries.length, 0);
263
+ });
264
+
265
+ test("the oldest push can also be evicted and active registrations restore after restart", () => {
266
+ const source = fixture.store.saveSource({ name: "push", url: "http://127.0.0.1:1" });
267
+ const manager = fixture.httpChannels;
268
+ manager.start();
269
+ const ids = Array.from({ length: 33 }, () => fixture.store.createBinding(fixture.newSession(), source.id, "push").id);
270
+ for (const id of ids.slice(0, 32)) manager.activate(id);
271
+ assert.equal(manager.activate(ids[32]!), ids[0]);
272
+ manager.stop();
273
+ assert.equal(manager.info().activeCount, 0);
274
+ manager.start();
275
+ assert.equal(manager.info().activeCount, 32);
276
+ assert.equal(manager.info().bindings.find((binding) => binding.id === ids[0])!.active, false);
277
+ assert.equal(fixture.queries.length, 0);
278
+ });
279
+
280
+ test("deleting a session removes both active and inactive registrations", async () => {
281
+ const manager = fixture.httpChannels;
282
+ manager.start();
283
+ const session = fixture.newSession();
284
+ const source = fixture.store.saveSource({ name: "push", url: "http://127.0.0.1:1" });
285
+ const active = fixture.store.createBinding(session, source.id, "push");
286
+ fixture.store.createBinding(session, source.id, "subscribe");
287
+ manager.activate(active.id);
288
+ await fixture.sessions.drop(session);
289
+ assert.equal(manager.info(session).bindings.length, 0);
290
+ assert.equal(manager.info().activeCount, 0);
291
+ });