@cuongtran001/kanna 0.39.2

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 (473) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +355 -0
  3. package/bin/kanna +9 -0
  4. package/dist/client/apple-touch-icon.png +0 -0
  5. package/dist/client/assets/abap-BdImnpbu.js +1 -0
  6. package/dist/client/assets/actionscript-3-CoDkCxhg.js +1 -0
  7. package/dist/client/assets/ada-bCR0ucgS.js +1 -0
  8. package/dist/client/assets/andromeeda-C4gqWexZ.js +1 -0
  9. package/dist/client/assets/angular-html-CU67Zn6k.js +1 -0
  10. package/dist/client/assets/angular-ts-BwZT4LLn.js +1 -0
  11. package/dist/client/assets/apache-Pmp26Uib.js +1 -0
  12. package/dist/client/assets/apex-D8_7TLub.js +1 -0
  13. package/dist/client/assets/apl-dKokRX4l.js +1 -0
  14. package/dist/client/assets/applescript-Co6uUVPk.js +1 -0
  15. package/dist/client/assets/ara-BRHolxvo.js +1 -0
  16. package/dist/client/assets/asciidoc-Ve4PFQV2.js +1 -0
  17. package/dist/client/assets/asm-D_Q5rh1f.js +1 -0
  18. package/dist/client/assets/astro-CbQHKStN.js +1 -0
  19. package/dist/client/assets/aurora-x-D-2ljcwZ.js +1 -0
  20. package/dist/client/assets/awk-DMzUqQB5.js +1 -0
  21. package/dist/client/assets/ayu-dark-DYE7WIF3.js +1 -0
  22. package/dist/client/assets/ayu-light-BA47KaF1.js +1 -0
  23. package/dist/client/assets/ayu-mirage-32ctXXKs.js +1 -0
  24. package/dist/client/assets/ballerina-BFfxhgS-.js +1 -0
  25. package/dist/client/assets/bat-BkioyH1T.js +1 -0
  26. package/dist/client/assets/beancount-k_qm7-4y.js +1 -0
  27. package/dist/client/assets/berry-uYugtg8r.js +1 -0
  28. package/dist/client/assets/bibtex-CHM0blh-.js +1 -0
  29. package/dist/client/assets/bicep-Bmn6On1c.js +1 -0
  30. package/dist/client/assets/bird2-DPOp833l.js +1 -0
  31. package/dist/client/assets/blade-D4QpJJKB.js +1 -0
  32. package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  33. package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  34. package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  35. package/dist/client/assets/bsl-BO_Y6i37.js +1 -0
  36. package/dist/client/assets/c-BIGW1oBm.js +1 -0
  37. package/dist/client/assets/c3-eo99z4R2.js +1 -0
  38. package/dist/client/assets/cadence-Bv_4Rxtq.js +1 -0
  39. package/dist/client/assets/cairo-KRGpt6FW.js +1 -0
  40. package/dist/client/assets/catppuccin-frappe-DFWUc33u.js +1 -0
  41. package/dist/client/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
  42. package/dist/client/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
  43. package/dist/client/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
  44. package/dist/client/assets/clarity-D53aC0YG.js +1 -0
  45. package/dist/client/assets/clojure-P80f7IUj.js +1 -0
  46. package/dist/client/assets/cmake-D1j8_8rp.js +1 -0
  47. package/dist/client/assets/cobol-nwyudZeR.js +1 -0
  48. package/dist/client/assets/codeowners-Bp6g37R7.js +1 -0
  49. package/dist/client/assets/codeql-DsOJ9woJ.js +1 -0
  50. package/dist/client/assets/coffee-Ch7k5sss.js +1 -0
  51. package/dist/client/assets/common-lisp-Cg-RD9OK.js +1 -0
  52. package/dist/client/assets/coq-DkFqJrB1.js +1 -0
  53. package/dist/client/assets/cpp-CofmeUqb.js +1 -0
  54. package/dist/client/assets/crystal-tKQVLTB8.js +1 -0
  55. package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
  56. package/dist/client/assets/css-DPfMkruS.js +1 -0
  57. package/dist/client/assets/csv-fuZLfV_i.js +1 -0
  58. package/dist/client/assets/cue-D82EKSYY.js +1 -0
  59. package/dist/client/assets/cypher-COkxafJQ.js +1 -0
  60. package/dist/client/assets/d-85-TOEBH.js +1 -0
  61. package/dist/client/assets/dark-plus-C3mMm8J8.js +1 -0
  62. package/dist/client/assets/dart-CF10PKvl.js +1 -0
  63. package/dist/client/assets/dax-CEL-wOlO.js +1 -0
  64. package/dist/client/assets/desktop-BmXAJ9_W.js +1 -0
  65. package/dist/client/assets/diff-D97Zzqfu.js +1 -0
  66. package/dist/client/assets/docker-BcOcwvcX.js +1 -0
  67. package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
  68. package/dist/client/assets/dracula-BzJJZx-M.js +1 -0
  69. package/dist/client/assets/dracula-soft-BXkSAIEj.js +1 -0
  70. package/dist/client/assets/dream-maker-BtqSS_iP.js +1 -0
  71. package/dist/client/assets/edge-BkV0erSs.js +1 -0
  72. package/dist/client/assets/elixir-CDX3lj18.js +1 -0
  73. package/dist/client/assets/elm-DbKCFpqz.js +1 -0
  74. package/dist/client/assets/emacs-lisp-C9XAeP06.js +1 -0
  75. package/dist/client/assets/erb-B12qg9BL.js +1 -0
  76. package/dist/client/assets/erlang-DsQrWhSR.js +1 -0
  77. package/dist/client/assets/everforest-dark-BgDCqdQA.js +1 -0
  78. package/dist/client/assets/everforest-light-C8M2exoo.js +1 -0
  79. package/dist/client/assets/fennel-BYunw83y.js +1 -0
  80. package/dist/client/assets/fish-BvzEVeQv.js +1 -0
  81. package/dist/client/assets/fluent-C4IJs8-o.js +1 -0
  82. package/dist/client/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
  83. package/dist/client/assets/fortran-free-form-BxgE0vQu.js +1 -0
  84. package/dist/client/assets/fsharp-CXgrBDvD.js +1 -0
  85. package/dist/client/assets/gdresource-BOOCDP_w.js +1 -0
  86. package/dist/client/assets/gdscript-C5YyOfLZ.js +1 -0
  87. package/dist/client/assets/gdshader-DkwncUOv.js +1 -0
  88. package/dist/client/assets/genie-D0YGMca9.js +1 -0
  89. package/dist/client/assets/gherkin-DyxjwDmM.js +1 -0
  90. package/dist/client/assets/git-commit-F4YmCXRG.js +1 -0
  91. package/dist/client/assets/git-rebase-r7XF79zn.js +1 -0
  92. package/dist/client/assets/github-dark-DHJKELXO.js +1 -0
  93. package/dist/client/assets/github-dark-default-Cuk6v7N8.js +1 -0
  94. package/dist/client/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  95. package/dist/client/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  96. package/dist/client/assets/github-light-DAi9KRSo.js +1 -0
  97. package/dist/client/assets/github-light-default-D7oLnXFd.js +1 -0
  98. package/dist/client/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  99. package/dist/client/assets/gleam-BspZqrRM.js +1 -0
  100. package/dist/client/assets/glimmer-js-Rg0-pVw9.js +1 -0
  101. package/dist/client/assets/glimmer-ts-U6CK756n.js +1 -0
  102. package/dist/client/assets/glsl-DplSGwfg.js +1 -0
  103. package/dist/client/assets/gn-n2N0HUVH.js +1 -0
  104. package/dist/client/assets/gnuplot-DdkO51Og.js +1 -0
  105. package/dist/client/assets/go-CxLEBnE3.js +1 -0
  106. package/dist/client/assets/graphql-ChdNCCLP.js +1 -0
  107. package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
  108. package/dist/client/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
  109. package/dist/client/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
  110. package/dist/client/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
  111. package/dist/client/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
  112. package/dist/client/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
  113. package/dist/client/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
  114. package/dist/client/assets/hack-CaT9iCJl.js +1 -0
  115. package/dist/client/assets/haml-B8DHNrY2.js +1 -0
  116. package/dist/client/assets/handlebars-BL8al0AC.js +1 -0
  117. package/dist/client/assets/haskell-Df6bDoY_.js +1 -0
  118. package/dist/client/assets/haxe-CzTSHFRz.js +1 -0
  119. package/dist/client/assets/hcl-BWvSN4gD.js +1 -0
  120. package/dist/client/assets/hjson-D5-asLiD.js +1 -0
  121. package/dist/client/assets/hlsl-D3lLCCz7.js +1 -0
  122. package/dist/client/assets/horizon-BUw7H-hv.js +1 -0
  123. package/dist/client/assets/horizon-bright-Cn-bp-IR.js +1 -0
  124. package/dist/client/assets/houston-DnULxvSX.js +1 -0
  125. package/dist/client/assets/html-GMplVEZG.js +1 -0
  126. package/dist/client/assets/html-derivative-BFtXZ54Q.js +1 -0
  127. package/dist/client/assets/http-jrhK8wxY.js +1 -0
  128. package/dist/client/assets/hurl-irOxFIW8.js +1 -0
  129. package/dist/client/assets/hxml-Bvhsp5Yf.js +1 -0
  130. package/dist/client/assets/hy-DFXneXwc.js +1 -0
  131. package/dist/client/assets/imba-DGztddWO.js +1 -0
  132. package/dist/client/assets/index-Do7324M0.css +32 -0
  133. package/dist/client/assets/index-ktE9DLCD.js +2620 -0
  134. package/dist/client/assets/ini-BEwlwnbL.js +1 -0
  135. package/dist/client/assets/java-CylS5w8V.js +1 -0
  136. package/dist/client/assets/javascript-wDzz0qaB.js +1 -0
  137. package/dist/client/assets/jinja-4LBKfQ-Z.js +1 -0
  138. package/dist/client/assets/jison-wvAkD_A8.js +1 -0
  139. package/dist/client/assets/json-Cp-IABpG.js +1 -0
  140. package/dist/client/assets/json5-C9tS-k6U.js +1 -0
  141. package/dist/client/assets/jsonc-Des-eS-w.js +1 -0
  142. package/dist/client/assets/jsonl-DcaNXYhu.js +1 -0
  143. package/dist/client/assets/jsonnet-DFQXde-d.js +1 -0
  144. package/dist/client/assets/jssm-C2t-YnRu.js +1 -0
  145. package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
  146. package/dist/client/assets/julia-CxzCAyBv.js +1 -0
  147. package/dist/client/assets/just-Cw27pwNe.js +1 -0
  148. package/dist/client/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  149. package/dist/client/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  150. package/dist/client/assets/kanagawa-wave-DWedfzmr.js +1 -0
  151. package/dist/client/assets/kdl-DV7GczEv.js +1 -0
  152. package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
  153. package/dist/client/assets/kusto-DZf3V79B.js +1 -0
  154. package/dist/client/assets/laserwave-DUszq2jm.js +1 -0
  155. package/dist/client/assets/latex-CWtU0Tv5.js +1 -0
  156. package/dist/client/assets/lean-BZvkOJ9d.js +1 -0
  157. package/dist/client/assets/less-B1dDrJ26.js +1 -0
  158. package/dist/client/assets/light-plus-B7mTdjB0.js +1 -0
  159. package/dist/client/assets/liquid-DYVedYrR.js +1 -0
  160. package/dist/client/assets/llvm-DjAJT7YJ.js +1 -0
  161. package/dist/client/assets/log-2UxHyX5q.js +1 -0
  162. package/dist/client/assets/logo-BtOb2qkB.js +1 -0
  163. package/dist/client/assets/lua-BaeVxFsk.js +1 -0
  164. package/dist/client/assets/luau-C-HG3fhB.js +1 -0
  165. package/dist/client/assets/make-CHLpvVh8.js +1 -0
  166. package/dist/client/assets/markdown-Cvjx9yec.js +1 -0
  167. package/dist/client/assets/marko-CnJfTvn9.js +1 -0
  168. package/dist/client/assets/material-theme-D5KoaKCx.js +1 -0
  169. package/dist/client/assets/material-theme-darker-BfHTSMKl.js +1 -0
  170. package/dist/client/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  171. package/dist/client/assets/material-theme-ocean-CyktbL80.js +1 -0
  172. package/dist/client/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  173. package/dist/client/assets/matlab-D7o27uSR.js +1 -0
  174. package/dist/client/assets/mdc-BMNejdWA.js +1 -0
  175. package/dist/client/assets/mdx-Cmh6b_Ma.js +1 -0
  176. package/dist/client/assets/mermaid-mWjccvbQ.js +1 -0
  177. package/dist/client/assets/min-dark-CafNBF8u.js +1 -0
  178. package/dist/client/assets/min-light-CTRr51gU.js +1 -0
  179. package/dist/client/assets/mipsasm-CKIfxQSi.js +1 -0
  180. package/dist/client/assets/mojo-rZm6bMo-.js +1 -0
  181. package/dist/client/assets/monokai-D4h5O-jR.js +1 -0
  182. package/dist/client/assets/moonbit-_H4v1dQx.js +1 -0
  183. package/dist/client/assets/move-IF9eRakj.js +1 -0
  184. package/dist/client/assets/narrat-DRg8JJMk.js +1 -0
  185. package/dist/client/assets/nextflow-Zz6hmt5N.js +1 -0
  186. package/dist/client/assets/nextflow-groovy-BeH2EWoN.js +1 -0
  187. package/dist/client/assets/nginx-BpAMiNFr.js +1 -0
  188. package/dist/client/assets/night-owl-C39BiMTA.js +1 -0
  189. package/dist/client/assets/night-owl-light-CMTm3GFP.js +1 -0
  190. package/dist/client/assets/nim-CVrawwO9.js +1 -0
  191. package/dist/client/assets/nix-CwoSXNpI.js +1 -0
  192. package/dist/client/assets/nord-Ddv68eIx.js +1 -0
  193. package/dist/client/assets/nushell-Cz2AlsmD.js +1 -0
  194. package/dist/client/assets/objective-c-DXmwc3jG.js +1 -0
  195. package/dist/client/assets/objective-cpp-CLxacb5B.js +1 -0
  196. package/dist/client/assets/ocaml-C0hk2d4L.js +1 -0
  197. package/dist/client/assets/odin-BBf5iR-q.js +1 -0
  198. package/dist/client/assets/one-dark-pro-DVMEJ2y_.js +1 -0
  199. package/dist/client/assets/one-light-C3Wv6jpd.js +1 -0
  200. package/dist/client/assets/openscad-C4EeE6gA.js +1 -0
  201. package/dist/client/assets/pascal-D93ZcfNL.js +1 -0
  202. package/dist/client/assets/perl-C0TMdlhV.js +1 -0
  203. package/dist/client/assets/php-Dhbhpdrm.js +1 -0
  204. package/dist/client/assets/pierre-dark-DF2SEV7i.js +1 -0
  205. package/dist/client/assets/pierre-light-DOlZxES8.js +1 -0
  206. package/dist/client/assets/pkl-u5AG7uiY.js +1 -0
  207. package/dist/client/assets/plastic-3e1v2bzS.js +1 -0
  208. package/dist/client/assets/plsql-ChMvpjG-.js +1 -0
  209. package/dist/client/assets/po-BTJTHyun.js +1 -0
  210. package/dist/client/assets/poimandres-CS3Unz2-.js +1 -0
  211. package/dist/client/assets/polar-C0HS_06l.js +1 -0
  212. package/dist/client/assets/postcss-CXtECtnM.js +1 -0
  213. package/dist/client/assets/powerquery-CEu0bR-o.js +1 -0
  214. package/dist/client/assets/powershell-Dpen1YoG.js +1 -0
  215. package/dist/client/assets/prisma-Dd19v3D-.js +1 -0
  216. package/dist/client/assets/prolog-CbFg5uaA.js +1 -0
  217. package/dist/client/assets/proto-C7zT0LnQ.js +1 -0
  218. package/dist/client/assets/pug-CGlum2m_.js +1 -0
  219. package/dist/client/assets/puppet-BMWR74SV.js +1 -0
  220. package/dist/client/assets/purescript-CklMAg4u.js +1 -0
  221. package/dist/client/assets/python-B6aJPvgy.js +1 -0
  222. package/dist/client/assets/qml-3beO22l8.js +1 -0
  223. package/dist/client/assets/qmldir-C8lEn-DE.js +1 -0
  224. package/dist/client/assets/qss-IeuSbFQv.js +1 -0
  225. package/dist/client/assets/r-Dspwwk_N.js +1 -0
  226. package/dist/client/assets/racket-BqYA7rlc.js +1 -0
  227. package/dist/client/assets/raku-DXvB9xmW.js +1 -0
  228. package/dist/client/assets/razor-Uh8Bk_45.js +1 -0
  229. package/dist/client/assets/red-bN70gL4F.js +1 -0
  230. package/dist/client/assets/reg-C-SQnVFl.js +1 -0
  231. package/dist/client/assets/regexp-CDVJQ6XC.js +1 -0
  232. package/dist/client/assets/rel-C3B-1QV4.js +1 -0
  233. package/dist/client/assets/riscv-BM1_JUlF.js +1 -0
  234. package/dist/client/assets/ron-D8l8udqQ.js +1 -0
  235. package/dist/client/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
  236. package/dist/client/assets/rose-pine-moon-D4_iv3hh.js +1 -0
  237. package/dist/client/assets/rose-pine-qdsjHGoJ.js +1 -0
  238. package/dist/client/assets/rosmsg-BJDFO7_C.js +1 -0
  239. package/dist/client/assets/rst-BrH8l1NY.js +1 -0
  240. package/dist/client/assets/ruby-Dw2BHqvy.js +1 -0
  241. package/dist/client/assets/rust-B1yitclQ.js +1 -0
  242. package/dist/client/assets/sas-cz2c8ADy.js +1 -0
  243. package/dist/client/assets/sass-Cj5Yp3dK.js +1 -0
  244. package/dist/client/assets/scala-C151Ov-r.js +1 -0
  245. package/dist/client/assets/scheme-C98Dy4si.js +1 -0
  246. package/dist/client/assets/scss-OYdSNvt2.js +1 -0
  247. package/dist/client/assets/sdbl-DVxCFoDh.js +1 -0
  248. package/dist/client/assets/shaderlab-Dg9Lc6iA.js +1 -0
  249. package/dist/client/assets/shellscript-Yzrsuije.js +1 -0
  250. package/dist/client/assets/shellsession-BADoaaVG.js +1 -0
  251. package/dist/client/assets/slack-dark-BthQWCQV.js +1 -0
  252. package/dist/client/assets/slack-ochin-DqwNpetd.js +1 -0
  253. package/dist/client/assets/smalltalk-BERRCDM3.js +1 -0
  254. package/dist/client/assets/snazzy-light-Bw305WKR.js +1 -0
  255. package/dist/client/assets/solarized-dark-DXbdFlpD.js +1 -0
  256. package/dist/client/assets/solarized-light-L9t79GZl.js +1 -0
  257. package/dist/client/assets/solidity-rGO070M0.js +1 -0
  258. package/dist/client/assets/soy-Brmx7dQM.js +1 -0
  259. package/dist/client/assets/sparql-rVzFXLq3.js +1 -0
  260. package/dist/client/assets/splunk-BtCnVYZw.js +1 -0
  261. package/dist/client/assets/sql-BLtJtn59.js +1 -0
  262. package/dist/client/assets/ssh-config-_ykCGR6B.js +1 -0
  263. package/dist/client/assets/stata-BH5u7GGu.js +1 -0
  264. package/dist/client/assets/stylus-BEDo0Tqx.js +1 -0
  265. package/dist/client/assets/surrealql-Bq5Q-fJD.js +1 -0
  266. package/dist/client/assets/svelte-C_ipcX3V.js +1 -0
  267. package/dist/client/assets/swift-D82vCrfD.js +1 -0
  268. package/dist/client/assets/synthwave-84-CbfX1IO0.js +1 -0
  269. package/dist/client/assets/system-verilog-CnnmHF94.js +1 -0
  270. package/dist/client/assets/systemd-4A_iFExJ.js +1 -0
  271. package/dist/client/assets/talonscript-CkByrt1z.js +1 -0
  272. package/dist/client/assets/tasl-QIJgUcNo.js +1 -0
  273. package/dist/client/assets/tcl-dwOrl1Do.js +1 -0
  274. package/dist/client/assets/templ-P3uqSqPl.js +1 -0
  275. package/dist/client/assets/terraform-BETggiCN.js +1 -0
  276. package/dist/client/assets/tex-idrVyKtj.js +1 -0
  277. package/dist/client/assets/tokyo-night-hegEt444.js +1 -0
  278. package/dist/client/assets/toml-vGWfd6FD.js +1 -0
  279. package/dist/client/assets/ts-tags-zn1MmPIZ.js +1 -0
  280. package/dist/client/assets/tsv-B_m7g4N7.js +1 -0
  281. package/dist/client/assets/tsx-COt5Ahok.js +1 -0
  282. package/dist/client/assets/turtle-BsS91CYL.js +1 -0
  283. package/dist/client/assets/twig-DNn4PbVi.js +1 -0
  284. package/dist/client/assets/typescript-BPQ3VLAy.js +1 -0
  285. package/dist/client/assets/typespec-BGHnOYBU.js +1 -0
  286. package/dist/client/assets/typst-DHCkPAjA.js +1 -0
  287. package/dist/client/assets/v-BcVCzyr7.js +1 -0
  288. package/dist/client/assets/vala-CsfeWuGM.js +1 -0
  289. package/dist/client/assets/vb-D17OF-Vu.js +1 -0
  290. package/dist/client/assets/verilog-BQ8w6xss.js +1 -0
  291. package/dist/client/assets/vesper-DU1UobuO.js +1 -0
  292. package/dist/client/assets/vhdl-CeAyd5Ju.js +1 -0
  293. package/dist/client/assets/viml-CJc9bBzg.js +1 -0
  294. package/dist/client/assets/vitesse-black-Bkuqu6BP.js +1 -0
  295. package/dist/client/assets/vitesse-dark-D0r3Knsf.js +1 -0
  296. package/dist/client/assets/vitesse-light-CVO1_9PV.js +1 -0
  297. package/dist/client/assets/vue-DN_0RTcg.js +1 -0
  298. package/dist/client/assets/vue-html-AaS7Mt5G.js +1 -0
  299. package/dist/client/assets/vue-vine-CQOfvN7w.js +1 -0
  300. package/dist/client/assets/vyper-CDx5xZoG.js +1 -0
  301. package/dist/client/assets/wasm-CG6Dc4jp.js +1 -0
  302. package/dist/client/assets/wasm-MzD3tlZU.js +1 -0
  303. package/dist/client/assets/wenyan-BV7otONQ.js +1 -0
  304. package/dist/client/assets/wgsl-Dx-B1_4e.js +1 -0
  305. package/dist/client/assets/wikitext-BhOHFoWU.js +1 -0
  306. package/dist/client/assets/wit-5i3qLPDT.js +1 -0
  307. package/dist/client/assets/wolfram-lXgVvXCa.js +1 -0
  308. package/dist/client/assets/xml-sdJ4AIDG.js +1 -0
  309. package/dist/client/assets/xsl-CtQFsRM5.js +1 -0
  310. package/dist/client/assets/yaml-Buea-lGh.js +1 -0
  311. package/dist/client/assets/zenscript-DVFEvuxE.js +1 -0
  312. package/dist/client/assets/zig-VOosw3JB.js +1 -0
  313. package/dist/client/chat-sounds/Blow.mp3 +0 -0
  314. package/dist/client/chat-sounds/Bottle.mp3 +0 -0
  315. package/dist/client/chat-sounds/Frog.mp3 +0 -0
  316. package/dist/client/chat-sounds/Funk.mp3 +0 -0
  317. package/dist/client/chat-sounds/Glass.mp3 +0 -0
  318. package/dist/client/chat-sounds/Ping.mp3 +0 -0
  319. package/dist/client/chat-sounds/Pop.mp3 +0 -0
  320. package/dist/client/chat-sounds/Purr.mp3 +0 -0
  321. package/dist/client/chat-sounds/Tink.mp3 +0 -0
  322. package/dist/client/editor-icons/cursor.png +0 -0
  323. package/dist/client/editor-icons/custom.png +0 -0
  324. package/dist/client/editor-icons/default-app.png +0 -0
  325. package/dist/client/editor-icons/finder.png +0 -0
  326. package/dist/client/editor-icons/preview.png +0 -0
  327. package/dist/client/editor-icons/terminal.png +0 -0
  328. package/dist/client/editor-icons/windsurf.png +0 -0
  329. package/dist/client/editor-icons/xcode.png +0 -0
  330. package/dist/client/favicon.png +0 -0
  331. package/dist/client/fonts/body-medium.woff2 +0 -0
  332. package/dist/client/fonts/body-regular-italic.woff2 +0 -0
  333. package/dist/client/fonts/body-regular.woff2 +0 -0
  334. package/dist/client/fonts/body-semibold.woff2 +0 -0
  335. package/dist/client/icon-192.png +0 -0
  336. package/dist/client/icon-512.png +0 -0
  337. package/dist/client/icon-maskable-512.png +0 -0
  338. package/dist/client/icon.svg +4 -0
  339. package/dist/client/index.html +34 -0
  340. package/dist/client/manifest.webmanifest +46 -0
  341. package/dist/client/screenshot-light.png +0 -0
  342. package/dist/client/screenshot.png +0 -0
  343. package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
  344. package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
  345. package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
  346. package/dist/export-viewer/assets/index-D1qUumZR.js +410 -0
  347. package/dist/export-viewer/assets/index-gG2nMW51.css +1 -0
  348. package/dist/export-viewer/editor-icons/cursor.png +0 -0
  349. package/dist/export-viewer/editor-icons/custom.png +0 -0
  350. package/dist/export-viewer/editor-icons/default-app.png +0 -0
  351. package/dist/export-viewer/editor-icons/finder.png +0 -0
  352. package/dist/export-viewer/editor-icons/preview.png +0 -0
  353. package/dist/export-viewer/editor-icons/terminal.png +0 -0
  354. package/dist/export-viewer/editor-icons/windsurf.png +0 -0
  355. package/dist/export-viewer/editor-icons/xcode.png +0 -0
  356. package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
  357. package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
  358. package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
  359. package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
  360. package/dist/export-viewer/index.html +14 -0
  361. package/package.json +99 -0
  362. package/src/server/__fixtures__/claude-session-empty.jsonl +0 -0
  363. package/src/server/__fixtures__/claude-session-malformed.jsonl +3 -0
  364. package/src/server/__fixtures__/claude-session-valid.jsonl +6 -0
  365. package/src/server/agent.test.ts +2369 -0
  366. package/src/server/agent.ts +1927 -0
  367. package/src/server/analytics.test.ts +313 -0
  368. package/src/server/analytics.ts +131 -0
  369. package/src/server/app-settings.test.ts +233 -0
  370. package/src/server/app-settings.ts +548 -0
  371. package/src/server/auth.test.ts +329 -0
  372. package/src/server/auth.ts +204 -0
  373. package/src/server/auto-continue/e2e.test.ts +215 -0
  374. package/src/server/auto-continue/events.test.ts +30 -0
  375. package/src/server/auto-continue/events.ts +35 -0
  376. package/src/server/auto-continue/limit-detector.test.ts +153 -0
  377. package/src/server/auto-continue/limit-detector.ts +159 -0
  378. package/src/server/auto-continue/read-model.test.ts +109 -0
  379. package/src/server/auto-continue/read-model.ts +83 -0
  380. package/src/server/auto-continue/schedule-manager.test.ts +155 -0
  381. package/src/server/auto-continue/schedule-manager.ts +116 -0
  382. package/src/server/claude-session-importer.test.ts +214 -0
  383. package/src/server/claude-session-importer.ts +187 -0
  384. package/src/server/claude-session-mapper.test.ts +88 -0
  385. package/src/server/claude-session-mapper.ts +106 -0
  386. package/src/server/claude-session-parser.test.ts +38 -0
  387. package/src/server/claude-session-parser.ts +67 -0
  388. package/src/server/claude-session-scanner.test.ts +49 -0
  389. package/src/server/claude-session-scanner.ts +24 -0
  390. package/src/server/claude-session-types.ts +61 -0
  391. package/src/server/cli-runtime.test.ts +523 -0
  392. package/src/server/cli-runtime.ts +405 -0
  393. package/src/server/cli-supervisor.ts +102 -0
  394. package/src/server/cli.ts +64 -0
  395. package/src/server/cloudflare-tunnel/agent-integration.test.ts +76 -0
  396. package/src/server/cloudflare-tunnel/agent-integration.ts +55 -0
  397. package/src/server/cloudflare-tunnel/detector.test.ts +72 -0
  398. package/src/server/cloudflare-tunnel/detector.ts +44 -0
  399. package/src/server/cloudflare-tunnel/e2e.test.ts +194 -0
  400. package/src/server/cloudflare-tunnel/events.test.ts +43 -0
  401. package/src/server/cloudflare-tunnel/events.ts +31 -0
  402. package/src/server/cloudflare-tunnel/gateway.ts +143 -0
  403. package/src/server/cloudflare-tunnel/lifecycle.test.ts +48 -0
  404. package/src/server/cloudflare-tunnel/lifecycle.ts +62 -0
  405. package/src/server/cloudflare-tunnel/read-model.test.ts +69 -0
  406. package/src/server/cloudflare-tunnel/read-model.ts +80 -0
  407. package/src/server/cloudflare-tunnel/tunnel-manager.test.ts +116 -0
  408. package/src/server/cloudflare-tunnel/tunnel-manager.ts +165 -0
  409. package/src/server/codex-app-server-protocol.ts +487 -0
  410. package/src/server/codex-app-server.test.ts +1816 -0
  411. package/src/server/codex-app-server.ts +1475 -0
  412. package/src/server/diff-store.test.ts +737 -0
  413. package/src/server/diff-store.ts +2199 -0
  414. package/src/server/discovery.test.ts +211 -0
  415. package/src/server/discovery.ts +301 -0
  416. package/src/server/event-store.test.ts +797 -0
  417. package/src/server/event-store.ts +1421 -0
  418. package/src/server/events.ts +217 -0
  419. package/src/server/external-open.test.ts +112 -0
  420. package/src/server/external-open.ts +345 -0
  421. package/src/server/generate-commit-message.test.ts +79 -0
  422. package/src/server/generate-commit-message.ts +126 -0
  423. package/src/server/generate-title.ts +76 -0
  424. package/src/server/harness-types.ts +19 -0
  425. package/src/server/keybindings.test.ts +144 -0
  426. package/src/server/keybindings.ts +178 -0
  427. package/src/server/llm-provider.test.ts +134 -0
  428. package/src/server/llm-provider.ts +207 -0
  429. package/src/server/machine-name.ts +22 -0
  430. package/src/server/paths-route.test.ts +64 -0
  431. package/src/server/paths.ts +35 -0
  432. package/src/server/process-utils.test.ts +12 -0
  433. package/src/server/process-utils.ts +47 -0
  434. package/src/server/project-paths.test.ts +95 -0
  435. package/src/server/project-paths.ts +191 -0
  436. package/src/server/provider-catalog.test.ts +69 -0
  437. package/src/server/provider-catalog.ts +87 -0
  438. package/src/server/quick-response.test.ts +440 -0
  439. package/src/server/quick-response.ts +300 -0
  440. package/src/server/read-models.test.ts +509 -0
  441. package/src/server/read-models.ts +230 -0
  442. package/src/server/restart.test.ts +27 -0
  443. package/src/server/restart.ts +30 -0
  444. package/src/server/server.ts +616 -0
  445. package/src/server/share.test.ts +180 -0
  446. package/src/server/share.ts +150 -0
  447. package/src/server/standalone-export.test.ts +224 -0
  448. package/src/server/standalone-export.ts +419 -0
  449. package/src/server/terminal-manager.test.ts +315 -0
  450. package/src/server/terminal-manager.ts +350 -0
  451. package/src/server/test-helpers/async-event-queue.ts +52 -0
  452. package/src/server/test-helpers/wait-for.ts +14 -0
  453. package/src/server/title-generation.live.test.ts +44 -0
  454. package/src/server/update-manager.test.ts +158 -0
  455. package/src/server/update-manager.ts +222 -0
  456. package/src/server/update-strategy.test.ts +237 -0
  457. package/src/server/update-strategy.ts +241 -0
  458. package/src/server/uploads.test.ts +292 -0
  459. package/src/server/uploads.ts +131 -0
  460. package/src/server/ws-router.test.ts +2292 -0
  461. package/src/server/ws-router.ts +1465 -0
  462. package/src/shared/analytics.ts +30 -0
  463. package/src/shared/branding.test.ts +31 -0
  464. package/src/shared/branding.ts +77 -0
  465. package/src/shared/dev-ports.test.ts +113 -0
  466. package/src/shared/dev-ports.ts +134 -0
  467. package/src/shared/ports.ts +2 -0
  468. package/src/shared/protocol.ts +257 -0
  469. package/src/shared/share.ts +27 -0
  470. package/src/shared/tools.test.ts +164 -0
  471. package/src/shared/tools.ts +327 -0
  472. package/src/shared/types.test.ts +25 -0
  473. package/src/shared/types.ts +1088 -0
@@ -0,0 +1,2369 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ AgentCoordinator,
4
+ buildAttachmentHintText,
5
+ buildPromptText,
6
+ maxClaudeContextWindowFromModelUsage,
7
+ normalizeClaudeStreamMessage,
8
+ normalizeClaudeUsageSnapshot,
9
+ } from "./agent"
10
+ import type { HarnessTurn } from "./harness-types"
11
+ import type { ChatAttachment, SlashCommand, TranscriptEntry } from "../shared/types"
12
+ import type { AutoContinueEvent } from "./auto-continue/events"
13
+ import { AsyncEventQueue } from "./test-helpers/async-event-queue"
14
+ import { waitFor } from "./test-helpers/wait-for"
15
+
16
+ function timestamped<T extends Omit<TranscriptEntry, "_id" | "createdAt">>(entry: T): TranscriptEntry {
17
+ return {
18
+ _id: crypto.randomUUID(),
19
+ createdAt: Date.now(),
20
+ ...entry,
21
+ } as TranscriptEntry
22
+ }
23
+
24
+ describe("normalizeClaudeStreamMessage", () => {
25
+ test("normalizes assistant tool calls", () => {
26
+ const entries = normalizeClaudeStreamMessage({
27
+ type: "assistant",
28
+ uuid: "msg-1",
29
+ message: {
30
+ content: [
31
+ {
32
+ type: "tool_use",
33
+ id: "tool-1",
34
+ name: "Bash",
35
+ input: {
36
+ command: "pwd",
37
+ timeout: 1000,
38
+ },
39
+ },
40
+ ],
41
+ },
42
+ })
43
+
44
+ expect(entries).toHaveLength(1)
45
+ expect(entries[0]?.kind).toBe("tool_call")
46
+ if (entries[0]?.kind !== "tool_call") throw new Error("unexpected entry")
47
+ expect(entries[0].tool.toolKind).toBe("bash")
48
+ })
49
+
50
+ test("normalizes result messages", () => {
51
+ const entries = normalizeClaudeStreamMessage({
52
+ type: "result",
53
+ subtype: "success",
54
+ is_error: false,
55
+ duration_ms: 3210,
56
+ result: "done",
57
+ })
58
+
59
+ expect(entries).toHaveLength(1)
60
+ expect(entries[0]?.kind).toBe("result")
61
+ if (entries[0]?.kind !== "result") throw new Error("unexpected entry")
62
+ expect(entries[0].durationMs).toBe(3210)
63
+ })
64
+
65
+ test("normalizes Claude usage snapshots from SDK usage payloads", () => {
66
+ const snapshot = normalizeClaudeUsageSnapshot({
67
+ input_tokens: 4,
68
+ cache_creation_input_tokens: 2715,
69
+ cache_read_input_tokens: 21144,
70
+ output_tokens: 679,
71
+ tool_uses: 2,
72
+ duration_ms: 654,
73
+ }, 200_000)
74
+
75
+ expect(snapshot).toEqual({
76
+ usedTokens: 24_542,
77
+ inputTokens: 23_863,
78
+ cachedInputTokens: 21_144,
79
+ outputTokens: 679,
80
+ lastUsedTokens: 24_542,
81
+ lastInputTokens: 23_863,
82
+ lastCachedInputTokens: 21_144,
83
+ lastOutputTokens: 679,
84
+ toolUses: 2,
85
+ durationMs: 654,
86
+ maxTokens: 200_000,
87
+ compactsAutomatically: false,
88
+ })
89
+ })
90
+
91
+ test("reads the max Claude context window from modelUsage", () => {
92
+ expect(maxClaudeContextWindowFromModelUsage({
93
+ "claude-opus-4-6": {
94
+ contextWindow: 200_000,
95
+ },
96
+ "claude-opus-4-6[1m]": {
97
+ contextWindow: 1_000_000,
98
+ },
99
+ })).toBe(1_000_000)
100
+ })
101
+ })
102
+
103
+ describe("attachment prompt helpers", () => {
104
+ test("appends a structured attachment hint block for all attachment kinds", () => {
105
+ const attachments: ChatAttachment[] = [
106
+ {
107
+ id: "image-1",
108
+ kind: "image",
109
+ displayName: "shot.png",
110
+ absolutePath: "/tmp/project/.kanna/uploads/shot.png",
111
+ relativePath: "./.kanna/uploads/shot.png",
112
+ contentUrl: "/api/projects/project-1/uploads/shot.png/content",
113
+ mimeType: "image/png",
114
+ size: 512,
115
+ },
116
+ {
117
+ id: "file-1",
118
+ kind: "file",
119
+ displayName: "spec.pdf",
120
+ absolutePath: "/tmp/project/.kanna/uploads/spec.pdf",
121
+ relativePath: "./.kanna/uploads/spec.pdf",
122
+ contentUrl: "/api/projects/project-1/uploads/spec.pdf/content",
123
+ mimeType: "application/pdf",
124
+ size: 1234,
125
+ },
126
+ ]
127
+
128
+ const prompt = buildPromptText("Review these", attachments)
129
+ expect(prompt).toContain("<kanna-attachments>")
130
+ expect(prompt).toContain('path="/tmp/project/.kanna/uploads/shot.png"')
131
+ expect(prompt).toContain('project_path="./.kanna/uploads/spec.pdf"')
132
+ })
133
+
134
+ test("supports attachment-only prompts", () => {
135
+ const attachments: ChatAttachment[] = [{
136
+ id: "file-1",
137
+ kind: "file",
138
+ displayName: "todo.txt",
139
+ absolutePath: "/tmp/project/.kanna/uploads/todo.txt",
140
+ relativePath: "./.kanna/uploads/todo.txt",
141
+ contentUrl: "/api/projects/project-1/uploads/todo.txt/content",
142
+ mimeType: "text/plain",
143
+ size: 32,
144
+ }]
145
+
146
+ expect(buildPromptText("", attachments)).toContain("Please inspect the attached files.")
147
+ })
148
+
149
+ test("escapes xml attribute values for attachment hint markup", () => {
150
+ const hint = buildAttachmentHintText([{
151
+ id: "file-1",
152
+ kind: "file",
153
+ displayName: "\"report\" <draft>.txt",
154
+ absolutePath: "/tmp/project/.kanna/uploads/report.txt",
155
+ relativePath: "./.kanna/uploads/report.txt",
156
+ contentUrl: "/api/projects/project-1/uploads/report.txt/content",
157
+ mimeType: "text/plain",
158
+ size: 64,
159
+ }])
160
+
161
+ expect(hint).toContain("&quot;report&quot; &lt;draft&gt;.txt")
162
+ })
163
+
164
+ test("renders kind=\"mention\" attachments", () => {
165
+ const hint = buildAttachmentHintText([{
166
+ id: "m1",
167
+ kind: "mention",
168
+ displayName: "src/agent.ts",
169
+ absolutePath: "/tmp/project/src/agent.ts",
170
+ relativePath: "./src/agent.ts",
171
+ contentUrl: "",
172
+ mimeType: "",
173
+ size: 0,
174
+ }])
175
+ expect(hint).toContain("kind=\"mention\"")
176
+ expect(hint).toContain("path=\"/tmp/project/src/agent.ts\"")
177
+ expect(hint).toContain("project_path=\"./src/agent.ts\"")
178
+ })
179
+ })
180
+
181
+ describe("AgentCoordinator codex integration", () => {
182
+ test("generates a chat title in the background on the first user message", async () => {
183
+ let releaseTitle!: () => void
184
+ const titleGate = new Promise<void>((resolve) => {
185
+ releaseTitle = resolve
186
+ })
187
+ const fakeCodexManager = {
188
+ async startSession() {},
189
+ async startTurn(): Promise<HarnessTurn> {
190
+ async function* stream() {
191
+ yield {
192
+ type: "transcript" as const,
193
+ entry: timestamped({
194
+ kind: "system_init",
195
+ provider: "codex",
196
+ model: "gpt-5.4",
197
+ tools: [],
198
+ agents: [],
199
+ slashCommands: [],
200
+ mcpServers: [],
201
+ }),
202
+ }
203
+ yield {
204
+ type: "transcript" as const,
205
+ entry: timestamped({
206
+ kind: "result",
207
+ subtype: "success",
208
+ isError: false,
209
+ durationMs: 0,
210
+ result: "",
211
+ }),
212
+ }
213
+ }
214
+
215
+ return {
216
+ provider: "codex",
217
+ stream: stream(),
218
+ interrupt: async () => {},
219
+ close: () => {},
220
+ }
221
+ },
222
+ }
223
+
224
+ const store = createFakeStore()
225
+ const coordinator = new AgentCoordinator({
226
+ store: store as never,
227
+ onStateChange: () => {},
228
+ codexManager: fakeCodexManager as never,
229
+ generateTitle: async () => {
230
+ await titleGate
231
+ return {
232
+ title: "Generated title",
233
+ usedFallback: false,
234
+ failureMessage: null,
235
+ }
236
+ },
237
+ })
238
+
239
+ await coordinator.send({
240
+ type: "chat.send",
241
+ chatId: "chat-1",
242
+ provider: "codex",
243
+ content: "first message",
244
+ model: "gpt-5.4",
245
+ })
246
+
247
+ expect(store.chat.title).toBe("first message")
248
+ releaseTitle()
249
+ await waitFor(() => store.chat.title === "Generated title")
250
+ expect(store.messages[0]?.kind).toBe("user_prompt")
251
+ })
252
+
253
+ test("does not overwrite a manual rename when background title generation finishes later", async () => {
254
+ let releaseTitle!: () => void
255
+ const titleGate = new Promise<void>((resolve) => {
256
+ releaseTitle = resolve
257
+ })
258
+ const fakeCodexManager = {
259
+ async startSession() {},
260
+ async startTurn(): Promise<HarnessTurn> {
261
+ async function* stream() {
262
+ yield {
263
+ type: "transcript" as const,
264
+ entry: timestamped({
265
+ kind: "system_init",
266
+ provider: "codex",
267
+ model: "gpt-5.4",
268
+ tools: [],
269
+ agents: [],
270
+ slashCommands: [],
271
+ mcpServers: [],
272
+ }),
273
+ }
274
+ yield {
275
+ type: "transcript" as const,
276
+ entry: timestamped({
277
+ kind: "result",
278
+ subtype: "success",
279
+ isError: false,
280
+ durationMs: 0,
281
+ result: "",
282
+ }),
283
+ }
284
+ }
285
+
286
+ return {
287
+ provider: "codex",
288
+ stream: stream(),
289
+ interrupt: async () => {},
290
+ close: () => {},
291
+ }
292
+ },
293
+ }
294
+
295
+ const store = createFakeStore()
296
+ const coordinator = new AgentCoordinator({
297
+ store: store as never,
298
+ onStateChange: () => {},
299
+ codexManager: fakeCodexManager as never,
300
+ generateTitle: async () => {
301
+ await titleGate
302
+ return {
303
+ title: "Generated title",
304
+ usedFallback: false,
305
+ failureMessage: null,
306
+ }
307
+ },
308
+ })
309
+
310
+ await coordinator.send({
311
+ type: "chat.send",
312
+ chatId: "chat-1",
313
+ provider: "codex",
314
+ content: "first message",
315
+ model: "gpt-5.4",
316
+ })
317
+
318
+ await store.renameChat("chat-1", "Manual title")
319
+ releaseTitle()
320
+ await waitFor(() => store.turnFinishedCount === 1)
321
+
322
+ expect(store.chat.title).toBe("Manual title")
323
+ })
324
+
325
+ test("reports provider failure without a second rename after the optimistic title", async () => {
326
+ const fakeCodexManager = {
327
+ async startSession() {},
328
+ async startTurn(): Promise<HarnessTurn> {
329
+ async function* stream() {
330
+ yield {
331
+ type: "transcript" as const,
332
+ entry: timestamped({
333
+ kind: "system_init",
334
+ provider: "codex",
335
+ model: "gpt-5.4",
336
+ tools: [],
337
+ agents: [],
338
+ slashCommands: [],
339
+ mcpServers: [],
340
+ }),
341
+ }
342
+ yield {
343
+ type: "transcript" as const,
344
+ entry: timestamped({
345
+ kind: "result",
346
+ subtype: "success",
347
+ isError: false,
348
+ durationMs: 0,
349
+ result: "",
350
+ }),
351
+ }
352
+ }
353
+
354
+ return {
355
+ provider: "codex",
356
+ stream: stream(),
357
+ interrupt: async () => {},
358
+ close: () => {},
359
+ }
360
+ },
361
+ }
362
+
363
+ const store = createFakeStore()
364
+ const backgroundErrors: string[] = []
365
+ const coordinator = new AgentCoordinator({
366
+ store: store as never,
367
+ onStateChange: () => {},
368
+ codexManager: fakeCodexManager as never,
369
+ generateTitle: async () => ({
370
+ title: "first message",
371
+ usedFallback: true,
372
+ failureMessage: "claude failed conversation title generation: Not authenticated",
373
+ }),
374
+ })
375
+ coordinator.setBackgroundErrorReporter((message) => {
376
+ backgroundErrors.push(message)
377
+ })
378
+
379
+ await coordinator.send({
380
+ type: "chat.send",
381
+ chatId: "chat-1",
382
+ provider: "codex",
383
+ content: "first message",
384
+ model: "gpt-5.4",
385
+ })
386
+
387
+ expect(store.chat.title).toBe("first message")
388
+ await waitFor(() => store.turnFinishedCount === 1)
389
+ expect(store.chat.title).toBe("first message")
390
+ expect(backgroundErrors).toEqual([
391
+ "[title-generation] chat chat-1 failed provider title generation: claude failed conversation title generation: Not authenticated",
392
+ ])
393
+ })
394
+
395
+ test("binds codex provider and reuses the session token on later turns", async () => {
396
+ const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
397
+ const fakeCodexManager = {
398
+ async startSession(args: { chatId: string; sessionToken: string | null }) {
399
+ sessionCalls.push({ chatId: args.chatId, sessionToken: args.sessionToken })
400
+ },
401
+ async startTurn(): Promise<HarnessTurn> {
402
+ async function* stream() {
403
+ yield { type: "session_token" as const, sessionToken: "thread-1" }
404
+ yield {
405
+ type: "transcript" as const,
406
+ entry: timestamped({
407
+ kind: "system_init",
408
+ provider: "codex",
409
+ model: "gpt-5.4",
410
+ tools: [],
411
+ agents: [],
412
+ slashCommands: [],
413
+ mcpServers: [],
414
+ }),
415
+ }
416
+ yield {
417
+ type: "transcript" as const,
418
+ entry: timestamped({
419
+ kind: "result",
420
+ subtype: "success",
421
+ isError: false,
422
+ durationMs: 0,
423
+ result: "",
424
+ }),
425
+ }
426
+ }
427
+
428
+ return {
429
+ provider: "codex",
430
+ stream: stream(),
431
+ interrupt: async () => {},
432
+ close: () => {},
433
+ }
434
+ },
435
+ }
436
+
437
+ const store = createFakeStore()
438
+ const coordinator = new AgentCoordinator({
439
+ store: store as never,
440
+ onStateChange: () => {},
441
+ codexManager: fakeCodexManager as never,
442
+ })
443
+
444
+ await coordinator.send({
445
+ type: "chat.send",
446
+ chatId: "chat-1",
447
+ provider: "codex",
448
+ content: "first",
449
+ })
450
+
451
+ await waitFor(() => store.turnFinishedCount === 1)
452
+ expect(store.chat.provider).toBe("codex")
453
+ expect(store.chat.sessionToken).toBe("thread-1")
454
+ expect(sessionCalls).toEqual([{ chatId: "chat-1", sessionToken: null }])
455
+
456
+ await coordinator.send({
457
+ type: "chat.send",
458
+ chatId: "chat-1",
459
+ content: "second",
460
+ })
461
+
462
+ await waitFor(() => store.turnFinishedCount === 2)
463
+ expect(sessionCalls).toEqual([
464
+ { chatId: "chat-1", sessionToken: null },
465
+ { chatId: "chat-1", sessionToken: "thread-1" },
466
+ ])
467
+ })
468
+
469
+ test("maps codex model options into session and turn settings", async () => {
470
+ const sessionCalls: Array<{ chatId: string; sessionToken: string | null; serviceTier?: string }> = []
471
+ const turnCalls: Array<{ effort?: string; serviceTier?: string }> = []
472
+
473
+ const fakeCodexManager = {
474
+ async startSession(args: { chatId: string; sessionToken: string | null; serviceTier?: string }) {
475
+ sessionCalls.push({
476
+ chatId: args.chatId,
477
+ sessionToken: args.sessionToken,
478
+ serviceTier: args.serviceTier,
479
+ })
480
+ },
481
+ async startTurn(args: { effort?: string; serviceTier?: string }): Promise<HarnessTurn> {
482
+ turnCalls.push({
483
+ effort: args.effort,
484
+ serviceTier: args.serviceTier,
485
+ })
486
+
487
+ async function* stream() {
488
+ yield { type: "session_token" as const, sessionToken: "thread-1" }
489
+ yield {
490
+ type: "transcript" as const,
491
+ entry: timestamped({
492
+ kind: "system_init",
493
+ provider: "codex",
494
+ model: "gpt-5.4",
495
+ tools: [],
496
+ agents: [],
497
+ slashCommands: [],
498
+ mcpServers: [],
499
+ }),
500
+ }
501
+ yield {
502
+ type: "transcript" as const,
503
+ entry: timestamped({
504
+ kind: "result",
505
+ subtype: "success",
506
+ isError: false,
507
+ durationMs: 0,
508
+ result: "",
509
+ }),
510
+ }
511
+ }
512
+
513
+ return {
514
+ provider: "codex",
515
+ stream: stream(),
516
+ interrupt: async () => {},
517
+ close: () => {},
518
+ }
519
+ },
520
+ }
521
+
522
+ const store = createFakeStore()
523
+ const coordinator = new AgentCoordinator({
524
+ store: store as never,
525
+ onStateChange: () => {},
526
+ codexManager: fakeCodexManager as never,
527
+ })
528
+
529
+ await coordinator.send({
530
+ type: "chat.send",
531
+ chatId: "chat-1",
532
+ provider: "codex",
533
+ content: "opt in",
534
+ modelOptions: {
535
+ codex: {
536
+ reasoningEffort: "xhigh",
537
+ fastMode: true,
538
+ },
539
+ },
540
+ })
541
+
542
+ await waitFor(() => store.turnFinishedCount === 1)
543
+
544
+ expect(sessionCalls).toEqual([{ chatId: "chat-1", sessionToken: null, serviceTier: "fast" }])
545
+ expect(turnCalls).toEqual([{ effort: "xhigh", serviceTier: "fast" }])
546
+ })
547
+
548
+ test("approving synthetic codex ExitPlanMode starts a hidden follow-up turn and can clear context", async () => {
549
+ const sessionCalls: Array<{ chatId: string; sessionToken: string | null }> = []
550
+ const startTurnCalls: Array<{ content: string; planMode: boolean }> = []
551
+ let turnCount = 0
552
+
553
+ const fakeCodexManager = {
554
+ async startSession(args: { chatId: string; sessionToken: string | null }) {
555
+ sessionCalls.push({ chatId: args.chatId, sessionToken: args.sessionToken })
556
+ },
557
+ async startTurn(args: {
558
+ content: string
559
+ planMode: boolean
560
+ onToolRequest: (request: any) => Promise<unknown>
561
+ }): Promise<HarnessTurn> {
562
+ startTurnCalls.push({ content: args.content, planMode: args.planMode })
563
+ turnCount += 1
564
+
565
+ async function* firstStream() {
566
+ yield { type: "session_token" as const, sessionToken: "thread-1" }
567
+ yield {
568
+ type: "transcript" as const,
569
+ entry: timestamped({
570
+ kind: "system_init",
571
+ provider: "codex",
572
+ model: "gpt-5.4",
573
+ tools: [],
574
+ agents: [],
575
+ slashCommands: [],
576
+ mcpServers: [],
577
+ }),
578
+ }
579
+ yield {
580
+ type: "transcript" as const,
581
+ entry: timestamped({
582
+ kind: "tool_call",
583
+ tool: {
584
+ kind: "tool",
585
+ toolKind: "exit_plan_mode",
586
+ toolName: "ExitPlanMode",
587
+ toolId: "exit-1",
588
+ input: {
589
+ plan: "## Plan\n\n- [ ] Ship it",
590
+ summary: "Plan summary",
591
+ },
592
+ },
593
+ }),
594
+ }
595
+ await args.onToolRequest({
596
+ tool: {
597
+ kind: "tool",
598
+ toolKind: "exit_plan_mode",
599
+ toolName: "ExitPlanMode",
600
+ toolId: "exit-1",
601
+ input: {
602
+ plan: "## Plan\n\n- [ ] Ship it",
603
+ summary: "Plan summary",
604
+ },
605
+ },
606
+ })
607
+ }
608
+
609
+ async function* secondStream() {
610
+ yield { type: "session_token" as const, sessionToken: "thread-2" }
611
+ yield {
612
+ type: "transcript" as const,
613
+ entry: timestamped({
614
+ kind: "system_init",
615
+ provider: "codex",
616
+ model: "gpt-5.4",
617
+ tools: [],
618
+ agents: [],
619
+ slashCommands: [],
620
+ mcpServers: [],
621
+ }),
622
+ }
623
+ yield {
624
+ type: "transcript" as const,
625
+ entry: timestamped({
626
+ kind: "result",
627
+ subtype: "success",
628
+ isError: false,
629
+ durationMs: 0,
630
+ result: "",
631
+ }),
632
+ }
633
+ }
634
+
635
+ return {
636
+ provider: "codex",
637
+ stream: turnCount === 1 ? firstStream() : secondStream(),
638
+ interrupt: async () => {},
639
+ close: () => {},
640
+ }
641
+ },
642
+ }
643
+
644
+ const store = createFakeStore()
645
+ const coordinator = new AgentCoordinator({
646
+ store: store as never,
647
+ onStateChange: () => {},
648
+ codexManager: fakeCodexManager as never,
649
+ })
650
+
651
+ await coordinator.send({
652
+ type: "chat.send",
653
+ chatId: "chat-1",
654
+ provider: "codex",
655
+ content: "plan this",
656
+ planMode: true,
657
+ })
658
+
659
+ await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "exit_plan_mode")
660
+
661
+ await coordinator.respondTool({
662
+ type: "chat.respondTool",
663
+ chatId: "chat-1",
664
+ toolUseId: "exit-1",
665
+ result: {
666
+ confirmed: true,
667
+ clearContext: true,
668
+ message: "Use the fast path",
669
+ },
670
+ })
671
+
672
+ await waitFor(() => store.turnFinishedCount === 1)
673
+
674
+ expect(startTurnCalls).toEqual([
675
+ { content: "plan this", planMode: true },
676
+ { content: "Proceed with the approved plan. Additional guidance: Use the fast path", planMode: false },
677
+ ])
678
+ expect(sessionCalls).toEqual([
679
+ { chatId: "chat-1", sessionToken: null },
680
+ { chatId: "chat-1", sessionToken: null },
681
+ ])
682
+ expect(store.messages.filter((entry) => entry.kind === "user_prompt")).toHaveLength(1)
683
+ expect(store.messages.some((entry) => entry.kind === "context_cleared")).toBe(true)
684
+ expect(store.chat.sessionToken).toBe("thread-2")
685
+ })
686
+
687
+ test("cancelling a waiting ask-user-question records a discarded tool result", async () => {
688
+ let releaseInterrupt!: () => void
689
+ const interrupted = new Promise<void>((resolve) => {
690
+ releaseInterrupt = resolve
691
+ })
692
+
693
+ const fakeCodexManager = {
694
+ async startSession() {},
695
+ async startTurn(args: {
696
+ onToolRequest: (request: any) => Promise<unknown>
697
+ }): Promise<HarnessTurn> {
698
+ async function* stream() {
699
+ yield {
700
+ type: "transcript" as const,
701
+ entry: timestamped({
702
+ kind: "system_init",
703
+ provider: "codex",
704
+ model: "gpt-5.4",
705
+ tools: [],
706
+ agents: [],
707
+ slashCommands: [],
708
+ mcpServers: [],
709
+ }),
710
+ }
711
+ void args.onToolRequest({
712
+ tool: {
713
+ kind: "tool",
714
+ toolKind: "ask_user_question",
715
+ toolName: "AskUserQuestion",
716
+ toolId: "question-1",
717
+ input: {
718
+ questions: [{ question: "Provider?" }],
719
+ },
720
+ },
721
+ })
722
+ await interrupted
723
+ }
724
+
725
+ return {
726
+ provider: "codex",
727
+ stream: stream(),
728
+ interrupt: async () => {
729
+ releaseInterrupt()
730
+ },
731
+ close: () => {},
732
+ }
733
+ },
734
+ }
735
+
736
+ const store = createFakeStore()
737
+ const coordinator = new AgentCoordinator({
738
+ store: store as never,
739
+ onStateChange: () => {},
740
+ codexManager: fakeCodexManager as never,
741
+ })
742
+
743
+ await coordinator.send({
744
+ type: "chat.send",
745
+ chatId: "chat-1",
746
+ provider: "codex",
747
+ content: "ask me something",
748
+ })
749
+
750
+ await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "ask_user_question")
751
+ await coordinator.cancel("chat-1")
752
+
753
+ const discardedResult = store.messages.find((entry) => entry.kind === "tool_result" && entry.toolId === "question-1")
754
+ expect(discardedResult).toBeDefined()
755
+ if (!discardedResult || discardedResult.kind !== "tool_result") {
756
+ throw new Error("missing discarded ask-user-question result")
757
+ }
758
+ expect(discardedResult.content).toEqual({ discarded: true, answers: {} })
759
+ expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
760
+ })
761
+
762
+ test("UI unblocks immediately when result arrives even if stream stays open", async () => {
763
+ let resolveStream!: () => void
764
+
765
+ const fakeCodexManager = {
766
+ async startSession() {},
767
+ async startTurn(): Promise<HarnessTurn> {
768
+ async function* stream() {
769
+ yield {
770
+ type: "transcript" as const,
771
+ entry: timestamped({
772
+ kind: "system_init",
773
+ provider: "codex",
774
+ model: "gpt-5.4",
775
+ tools: [],
776
+ agents: [],
777
+ slashCommands: [],
778
+ mcpServers: [],
779
+ }),
780
+ }
781
+ // Produce the result event
782
+ yield {
783
+ type: "transcript" as const,
784
+ entry: timestamped({
785
+ kind: "result",
786
+ subtype: "success",
787
+ isError: false,
788
+ durationMs: 120_000,
789
+ result: "done",
790
+ }),
791
+ }
792
+ // Stream stays open (simulates background tasks still running)
793
+ await new Promise<void>((resolve) => {
794
+ resolveStream = resolve
795
+ })
796
+ }
797
+
798
+ return {
799
+ provider: "codex",
800
+ stream: stream(),
801
+ interrupt: async () => {},
802
+ close: () => {
803
+ resolveStream?.()
804
+ },
805
+ }
806
+ },
807
+ }
808
+
809
+ const store = createFakeStore()
810
+ const coordinator = new AgentCoordinator({
811
+ store: store as never,
812
+ onStateChange: () => {},
813
+ codexManager: fakeCodexManager as never,
814
+ })
815
+
816
+ await coordinator.send({
817
+ type: "chat.send",
818
+ chatId: "chat-1",
819
+ provider: "codex",
820
+ content: "run something with a background task",
821
+ })
822
+
823
+ // Wait for the result message to be persisted
824
+ await waitFor(() => store.messages.some((entry) => entry.kind === "result"))
825
+
826
+ // The active turn should be removed even though the stream is still open.
827
+ // This is the key assertion: the UI should show idle (not "Running...")
828
+ // so the user can send new messages without hitting stop.
829
+ expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
830
+ expect(store.turnFinishedCount).toBe(1)
831
+
832
+ // The stream is still open, so it should be draining
833
+ expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(true)
834
+
835
+ // Clean up the hanging stream
836
+ resolveStream()
837
+
838
+ // After the stream closes, draining should stop
839
+ await waitFor(() => !coordinator.getDrainingChatIds().has("chat-1"))
840
+ })
841
+
842
+ test("stopDraining closes the stream and removes from draining set", async () => {
843
+ let resolveStream!: () => void
844
+ let streamClosed = false
845
+
846
+ const fakeCodexManager = {
847
+ async startSession() {},
848
+ async startTurn(): Promise<HarnessTurn> {
849
+ async function* stream() {
850
+ yield {
851
+ type: "transcript" as const,
852
+ entry: timestamped({
853
+ kind: "system_init",
854
+ provider: "codex",
855
+ model: "gpt-5.4",
856
+ tools: [],
857
+ agents: [],
858
+ slashCommands: [],
859
+ mcpServers: [],
860
+ }),
861
+ }
862
+ yield {
863
+ type: "transcript" as const,
864
+ entry: timestamped({
865
+ kind: "result",
866
+ subtype: "success",
867
+ isError: false,
868
+ durationMs: 0,
869
+ result: "done",
870
+ }),
871
+ }
872
+ await new Promise<void>((resolve) => {
873
+ resolveStream = resolve
874
+ })
875
+ }
876
+
877
+ return {
878
+ provider: "codex",
879
+ stream: stream(),
880
+ interrupt: async () => {},
881
+ close: () => {
882
+ streamClosed = true
883
+ resolveStream?.()
884
+ },
885
+ }
886
+ },
887
+ }
888
+
889
+ const store = createFakeStore()
890
+ const coordinator = new AgentCoordinator({
891
+ store: store as never,
892
+ onStateChange: () => {},
893
+ codexManager: fakeCodexManager as never,
894
+ })
895
+
896
+ await coordinator.send({
897
+ type: "chat.send",
898
+ chatId: "chat-1",
899
+ provider: "codex",
900
+ content: "work",
901
+ })
902
+
903
+ await waitFor(() => coordinator.getDrainingChatIds().has("chat-1"))
904
+
905
+ await coordinator.stopDraining("chat-1")
906
+
907
+ expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(false)
908
+ expect(streamClosed).toBe(true)
909
+ })
910
+
911
+ test("cancel immediately removes active turn so UI shows idle", async () => {
912
+ let resolveInterrupt!: () => void
913
+ const interruptCalled = new Promise<void>((resolve) => {
914
+ resolveInterrupt = resolve
915
+ })
916
+ // interrupt() that hangs until we resolve it — simulating a slow SDK
917
+ let interruptDone = false
918
+
919
+ const fakeCodexManager = {
920
+ async startSession() {},
921
+ async startTurn(): Promise<HarnessTurn> {
922
+ async function* stream() {
923
+ yield {
924
+ type: "transcript" as const,
925
+ entry: timestamped({
926
+ kind: "system_init",
927
+ provider: "codex",
928
+ model: "gpt-5.4",
929
+ tools: [],
930
+ agents: [],
931
+ slashCommands: [],
932
+ mcpServers: [],
933
+ }),
934
+ }
935
+ // Stream that never ends (simulates the SDK hanging)
936
+ await new Promise(() => {})
937
+ }
938
+
939
+ return {
940
+ provider: "codex",
941
+ stream: stream(),
942
+ interrupt: async () => {
943
+ resolveInterrupt()
944
+ // Hang to simulate a slow interrupt
945
+ await new Promise<void>((resolve) => {
946
+ setTimeout(() => {
947
+ interruptDone = true
948
+ resolve()
949
+ }, 100)
950
+ })
951
+ },
952
+ close: () => {},
953
+ }
954
+ },
955
+ }
956
+
957
+ const stateChanges: number[] = []
958
+ const store = createFakeStore()
959
+ const coordinator = new AgentCoordinator({
960
+ store: store as never,
961
+ onStateChange: () => {
962
+ stateChanges.push(Date.now())
963
+ },
964
+ codexManager: fakeCodexManager as never,
965
+ })
966
+
967
+ await coordinator.send({
968
+ type: "chat.send",
969
+ chatId: "chat-1",
970
+ provider: "codex",
971
+ content: "do something",
972
+ })
973
+
974
+ // Wait for the turn to be running
975
+ await waitFor(() => coordinator.getActiveStatuses().get("chat-1") === "running")
976
+
977
+ // Cancel — this should immediately remove from active turns
978
+ const cancelPromise = coordinator.cancel("chat-1")
979
+
980
+ // The turn should be removed from activeTurns immediately,
981
+ // BEFORE interrupt() resolves
982
+ await interruptCalled
983
+ expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
984
+ expect(interruptDone).toBe(false) // interrupt is still in progress
985
+
986
+ await cancelPromise
987
+
988
+ // Verify only one "interrupted" message was appended
989
+ const interruptedMessages = store.messages.filter((entry) => entry.kind === "interrupted")
990
+ expect(interruptedMessages).toHaveLength(1)
991
+ })
992
+
993
+ test("concurrent cancel calls only produce a single interrupted message", async () => {
994
+ let resolveStream!: () => void
995
+
996
+ const fakeCodexManager = {
997
+ async startSession() {},
998
+ async startTurn(): Promise<HarnessTurn> {
999
+ async function* stream() {
1000
+ yield {
1001
+ type: "transcript" as const,
1002
+ entry: timestamped({
1003
+ kind: "system_init",
1004
+ provider: "codex",
1005
+ model: "gpt-5.4",
1006
+ tools: [],
1007
+ agents: [],
1008
+ slashCommands: [],
1009
+ mcpServers: [],
1010
+ }),
1011
+ }
1012
+ await new Promise<void>((resolve) => {
1013
+ resolveStream = resolve
1014
+ })
1015
+ }
1016
+
1017
+ return {
1018
+ provider: "codex",
1019
+ stream: stream(),
1020
+ interrupt: async () => {
1021
+ resolveStream()
1022
+ },
1023
+ close: () => {},
1024
+ }
1025
+ },
1026
+ }
1027
+
1028
+ const store = createFakeStore()
1029
+ const coordinator = new AgentCoordinator({
1030
+ store: store as never,
1031
+ onStateChange: () => {},
1032
+ codexManager: fakeCodexManager as never,
1033
+ })
1034
+
1035
+ await coordinator.send({
1036
+ type: "chat.send",
1037
+ chatId: "chat-1",
1038
+ provider: "codex",
1039
+ content: "work",
1040
+ })
1041
+
1042
+ await waitFor(() => coordinator.getActiveStatuses().get("chat-1") === "running")
1043
+
1044
+ // Fire multiple cancel calls concurrently (simulating repeated stop button clicks)
1045
+ await Promise.all([
1046
+ coordinator.cancel("chat-1"),
1047
+ coordinator.cancel("chat-1"),
1048
+ coordinator.cancel("chat-1"),
1049
+ ])
1050
+
1051
+ // Only one "interrupted" message should exist
1052
+ const interruptedMessages = store.messages.filter((entry) => entry.kind === "interrupted")
1053
+ expect(interruptedMessages).toHaveLength(1)
1054
+ })
1055
+
1056
+ test("runTurn stops processing events after cancel", async () => {
1057
+ let resolveStream!: () => void
1058
+
1059
+ const fakeCodexManager = {
1060
+ async startSession() {},
1061
+ async startTurn(): Promise<HarnessTurn> {
1062
+ async function* stream() {
1063
+ yield {
1064
+ type: "transcript" as const,
1065
+ entry: timestamped({
1066
+ kind: "system_init",
1067
+ provider: "codex",
1068
+ model: "gpt-5.4",
1069
+ tools: [],
1070
+ agents: [],
1071
+ slashCommands: [],
1072
+ mcpServers: [],
1073
+ }),
1074
+ }
1075
+ // Wait for cancel, then yield another event that should be ignored
1076
+ await new Promise<void>((resolve) => {
1077
+ resolveStream = resolve
1078
+ })
1079
+ // This event arrives after cancel — should not be processed
1080
+ yield {
1081
+ type: "transcript" as const,
1082
+ entry: timestamped({
1083
+ kind: "assistant_text",
1084
+ text: "this should be ignored after cancel",
1085
+ }),
1086
+ }
1087
+ }
1088
+
1089
+ return {
1090
+ provider: "codex",
1091
+ stream: stream(),
1092
+ interrupt: async () => {
1093
+ resolveStream()
1094
+ },
1095
+ close: () => {},
1096
+ }
1097
+ },
1098
+ }
1099
+
1100
+ const store = createFakeStore()
1101
+ const coordinator = new AgentCoordinator({
1102
+ store: store as never,
1103
+ onStateChange: () => {},
1104
+ codexManager: fakeCodexManager as never,
1105
+ })
1106
+
1107
+ await coordinator.send({
1108
+ type: "chat.send",
1109
+ chatId: "chat-1",
1110
+ provider: "codex",
1111
+ content: "work",
1112
+ })
1113
+
1114
+ await waitFor(() => coordinator.getActiveStatuses().get("chat-1") === "running")
1115
+
1116
+ const messageCountBefore = store.messages.filter((entry) => entry.kind === "assistant_text").length
1117
+ await coordinator.cancel("chat-1")
1118
+
1119
+ // Give the stream time to yield the extra event
1120
+ await new Promise((resolve) => setTimeout(resolve, 50))
1121
+
1122
+ const postCancelTextMessages = store.messages.filter((entry) => entry.kind === "assistant_text")
1123
+ expect(postCancelTextMessages.length).toBe(messageCountBefore)
1124
+ })
1125
+
1126
+ test("cancelling a waiting codex exit-plan prompt discards it without starting a follow-up turn", async () => {
1127
+ let releaseInterrupt!: () => void
1128
+ const interrupted = new Promise<void>((resolve) => {
1129
+ releaseInterrupt = resolve
1130
+ })
1131
+ const startTurnCalls: string[] = []
1132
+
1133
+ const fakeCodexManager = {
1134
+ async startSession() {},
1135
+ async startTurn(args: {
1136
+ content: string
1137
+ onToolRequest: (request: any) => Promise<unknown>
1138
+ }): Promise<HarnessTurn> {
1139
+ startTurnCalls.push(args.content)
1140
+
1141
+ async function* stream() {
1142
+ yield {
1143
+ type: "transcript" as const,
1144
+ entry: timestamped({
1145
+ kind: "system_init",
1146
+ provider: "codex",
1147
+ model: "gpt-5.4",
1148
+ tools: [],
1149
+ agents: [],
1150
+ slashCommands: [],
1151
+ mcpServers: [],
1152
+ }),
1153
+ }
1154
+ yield {
1155
+ type: "transcript" as const,
1156
+ entry: timestamped({
1157
+ kind: "tool_call",
1158
+ tool: {
1159
+ kind: "tool",
1160
+ toolKind: "exit_plan_mode",
1161
+ toolName: "ExitPlanMode",
1162
+ toolId: "exit-1",
1163
+ input: {
1164
+ plan: "## Plan",
1165
+ },
1166
+ },
1167
+ }),
1168
+ }
1169
+ await args.onToolRequest({
1170
+ tool: {
1171
+ kind: "tool",
1172
+ toolKind: "exit_plan_mode",
1173
+ toolName: "ExitPlanMode",
1174
+ toolId: "exit-1",
1175
+ input: {
1176
+ plan: "## Plan",
1177
+ },
1178
+ },
1179
+ })
1180
+ await interrupted
1181
+ }
1182
+
1183
+ return {
1184
+ provider: "codex",
1185
+ stream: stream(),
1186
+ interrupt: async () => {
1187
+ releaseInterrupt()
1188
+ },
1189
+ close: () => {},
1190
+ }
1191
+ },
1192
+ }
1193
+
1194
+ const store = createFakeStore()
1195
+ const coordinator = new AgentCoordinator({
1196
+ store: store as never,
1197
+ onStateChange: () => {},
1198
+ codexManager: fakeCodexManager as never,
1199
+ })
1200
+
1201
+ await coordinator.send({
1202
+ type: "chat.send",
1203
+ chatId: "chat-1",
1204
+ provider: "codex",
1205
+ content: "plan this",
1206
+ planMode: true,
1207
+ })
1208
+
1209
+ await waitFor(() => coordinator.getPendingTool("chat-1")?.toolKind === "exit_plan_mode")
1210
+ await coordinator.cancel("chat-1")
1211
+
1212
+ const discardedResult = store.messages.find((entry) => entry.kind === "tool_result" && entry.toolId === "exit-1")
1213
+ expect(discardedResult).toBeDefined()
1214
+ if (!discardedResult || discardedResult.kind !== "tool_result") {
1215
+ throw new Error("missing discarded exit-plan result")
1216
+ }
1217
+ expect(discardedResult.content).toEqual({ discarded: true })
1218
+ expect(startTurnCalls).toEqual(["plan this"])
1219
+ })
1220
+ })
1221
+
1222
+ describe("AgentCoordinator claude integration", () => {
1223
+ test("tracks analytics for new chats, queued messages, and forks", async () => {
1224
+ const events = new AsyncEventQueue<any>()
1225
+ const analyticsEvents: string[] = []
1226
+ const store = createFakeStore()
1227
+ store.chat.provider = "claude"
1228
+ store.chat.sessionToken = "session-1"
1229
+
1230
+ const coordinator = new AgentCoordinator({
1231
+ store: store as never,
1232
+ analytics: {
1233
+ track: (eventName: string) => {
1234
+ analyticsEvents.push(eventName)
1235
+ },
1236
+ trackLaunch: () => {},
1237
+ },
1238
+ onStateChange: () => {},
1239
+ startClaudeSession: async () => ({
1240
+ provider: "claude",
1241
+ stream: events,
1242
+ getAccountInfo: async () => null,
1243
+ interrupt: async () => {},
1244
+ close: () => {},
1245
+ setModel: async () => {},
1246
+ setPermissionMode: async () => {},
1247
+ getSupportedCommands: async () => [],
1248
+ sendPrompt: async () => {
1249
+ events.push({
1250
+ type: "transcript" as const,
1251
+ entry: timestamped({
1252
+ kind: "result",
1253
+ subtype: "success",
1254
+ isError: false,
1255
+ durationMs: 0,
1256
+ result: "done",
1257
+ }),
1258
+ })
1259
+ },
1260
+ }),
1261
+ })
1262
+
1263
+ await coordinator.send({
1264
+ type: "chat.send",
1265
+ projectId: "project-1",
1266
+ provider: "claude",
1267
+ content: "first message",
1268
+ })
1269
+ await waitFor(() => store.turnFinishedCount === 1)
1270
+
1271
+ await coordinator.enqueue({
1272
+ type: "message.enqueue",
1273
+ chatId: "chat-1",
1274
+ content: "queued message",
1275
+ })
1276
+
1277
+ await coordinator.forkChat("chat-1")
1278
+
1279
+ expect(analyticsEvents).toEqual([
1280
+ "chat_created",
1281
+ "message_sent",
1282
+ "message_sent",
1283
+ "chat_created",
1284
+ ])
1285
+
1286
+ events.close()
1287
+ })
1288
+
1289
+ test("reuses a persistent Claude session across turns", async () => {
1290
+ const events = new AsyncEventQueue<any>()
1291
+ const startSessionCalls: Array<{ model: string; planMode: boolean; sessionToken: string | null }> = []
1292
+ const prompts: string[] = []
1293
+
1294
+ const store = createFakeStore()
1295
+ const coordinator = new AgentCoordinator({
1296
+ store: store as never,
1297
+ onStateChange: () => {},
1298
+ startClaudeSession: async (args) => {
1299
+ startSessionCalls.push({
1300
+ model: args.model,
1301
+ planMode: args.planMode,
1302
+ sessionToken: args.sessionToken,
1303
+ })
1304
+
1305
+ return {
1306
+ provider: "claude",
1307
+ stream: events,
1308
+ getAccountInfo: async () => null,
1309
+ interrupt: async () => {},
1310
+ close: () => {},
1311
+ setModel: async () => {},
1312
+ setPermissionMode: async () => {},
1313
+ getSupportedCommands: async () => [],
1314
+ sendPrompt: async (content: string) => {
1315
+ prompts.push(content)
1316
+ if (prompts.length === 1) {
1317
+ events.push({ type: "session_token" as const, sessionToken: "claude-session-1" })
1318
+ events.push({
1319
+ type: "transcript" as const,
1320
+ entry: timestamped({
1321
+ kind: "system_init",
1322
+ provider: "claude",
1323
+ model: "claude-opus-4-1",
1324
+ tools: [],
1325
+ agents: [],
1326
+ slashCommands: [],
1327
+ mcpServers: [],
1328
+ }),
1329
+ })
1330
+ }
1331
+ events.push({
1332
+ type: "transcript" as const,
1333
+ entry: timestamped({
1334
+ kind: "result",
1335
+ subtype: "success",
1336
+ isError: false,
1337
+ durationMs: 0,
1338
+ result: "done",
1339
+ }),
1340
+ })
1341
+ },
1342
+ }
1343
+ },
1344
+ })
1345
+
1346
+ await coordinator.send({
1347
+ type: "chat.send",
1348
+ chatId: "chat-1",
1349
+ provider: "claude",
1350
+ content: "start background task",
1351
+ model: "claude-opus-4-1",
1352
+ })
1353
+ await waitFor(() => store.turnFinishedCount === 1)
1354
+
1355
+ await coordinator.send({
1356
+ type: "chat.send",
1357
+ chatId: "chat-1",
1358
+ provider: "claude",
1359
+ content: "check task output",
1360
+ model: "claude-opus-4-1",
1361
+ })
1362
+ await waitFor(() => store.turnFinishedCount === 2)
1363
+
1364
+ expect(startSessionCalls).toHaveLength(1)
1365
+ expect(startSessionCalls[0]?.planMode).toBe(false)
1366
+ expect(startSessionCalls[0]?.sessionToken).toBeNull()
1367
+ expect(prompts).toEqual(["start background task", "check task output"])
1368
+ expect(store.chat.sessionToken).toBe("claude-session-1")
1369
+
1370
+ events.close()
1371
+ })
1372
+
1373
+ test("loads supported commands when a fresh Claude session starts", async () => {
1374
+ const events = new AsyncEventQueue<any>()
1375
+ const commandsFromSDK: SlashCommand[] = [
1376
+ { name: "review", description: "Review PR", argumentHint: "<pr>" },
1377
+ { name: "help", description: "Show help", argumentHint: "" },
1378
+ ]
1379
+
1380
+ const store = createFakeStore()
1381
+ const stateChanges: Array<string | undefined> = []
1382
+ let releaseCommands: (value: SlashCommand[]) => void
1383
+ const commandsReady = new Promise<SlashCommand[]>((resolve) => {
1384
+ releaseCommands = resolve
1385
+ })
1386
+ const coordinator = new AgentCoordinator({
1387
+ store: store as never,
1388
+ onStateChange: (chatId) => { stateChanges.push(chatId) },
1389
+ startClaudeSession: async () => ({
1390
+ provider: "claude",
1391
+ stream: events,
1392
+ getAccountInfo: async () => null,
1393
+ interrupt: async () => {},
1394
+ close: () => {},
1395
+ setModel: async () => {},
1396
+ setPermissionMode: async () => {},
1397
+ getSupportedCommands: () => commandsReady,
1398
+ sendPrompt: async () => {
1399
+ events.push({
1400
+ type: "transcript" as const,
1401
+ entry: timestamped({
1402
+ kind: "result",
1403
+ subtype: "success",
1404
+ isError: false,
1405
+ durationMs: 0,
1406
+ result: "done",
1407
+ }),
1408
+ })
1409
+ },
1410
+ }),
1411
+ })
1412
+
1413
+ await coordinator.send({
1414
+ type: "chat.send",
1415
+ chatId: "chat-1",
1416
+ provider: "claude",
1417
+ content: "hello",
1418
+ model: "claude-opus-4-1",
1419
+ })
1420
+ await waitFor(() => store.turnFinishedCount === 1)
1421
+ // Let any pending coordinator state emits flush before we capture the
1422
+ // baseline so the post-release growth strictly reflects the commands-
1423
+ // loaded emit.
1424
+ await new Promise((r) => setTimeout(r, 50))
1425
+
1426
+ const stateChangesBeforeLoad = stateChanges.length
1427
+ releaseCommands!(commandsFromSDK)
1428
+
1429
+ await waitFor(() => store.commandsLoaded.length === 1)
1430
+
1431
+ expect(store.commandsLoaded[0].chatId).toBe("chat-1")
1432
+ expect(store.commandsLoaded[0].commands).toEqual(commandsFromSDK)
1433
+ // Coordinator must nudge subscribers after persisting commands so freshly
1434
+ // loaded slash commands reach the client.
1435
+ await waitFor(() => stateChanges.length > stateChangesBeforeLoad)
1436
+
1437
+ events.close()
1438
+ })
1439
+
1440
+ test("Claude final results clear running state without using draining mode", async () => {
1441
+ const events = new AsyncEventQueue<any>()
1442
+
1443
+ const store = createFakeStore()
1444
+ const coordinator = new AgentCoordinator({
1445
+ store: store as never,
1446
+ onStateChange: () => {},
1447
+ startClaudeSession: async () => ({
1448
+ provider: "claude",
1449
+ stream: events,
1450
+ getAccountInfo: async () => null,
1451
+ interrupt: async () => {},
1452
+ close: () => {},
1453
+ setModel: async () => {},
1454
+ setPermissionMode: async () => {},
1455
+ getSupportedCommands: async () => [],
1456
+ sendPrompt: async () => {
1457
+ events.push({
1458
+ type: "transcript" as const,
1459
+ entry: timestamped({
1460
+ kind: "system_init",
1461
+ provider: "claude",
1462
+ model: "claude-opus-4-1",
1463
+ tools: [],
1464
+ agents: [],
1465
+ slashCommands: [],
1466
+ mcpServers: [],
1467
+ }),
1468
+ })
1469
+ events.push({
1470
+ type: "transcript" as const,
1471
+ entry: timestamped({
1472
+ kind: "result",
1473
+ subtype: "success",
1474
+ isError: false,
1475
+ durationMs: 0,
1476
+ result: "done",
1477
+ }),
1478
+ })
1479
+ },
1480
+ }),
1481
+ })
1482
+
1483
+ await coordinator.send({
1484
+ type: "chat.send",
1485
+ chatId: "chat-1",
1486
+ provider: "claude",
1487
+ content: "run something",
1488
+ model: "claude-opus-4-1",
1489
+ })
1490
+
1491
+ await waitFor(() => store.turnFinishedCount === 1)
1492
+ expect(coordinator.getActiveStatuses().has("chat-1")).toBe(false)
1493
+ expect(coordinator.getDrainingChatIds().has("chat-1")).toBe(false)
1494
+
1495
+ events.close()
1496
+ })
1497
+
1498
+ test("Claude steer interrupts the active run and immediately sends the steered message", async () => {
1499
+ const events = new AsyncEventQueue<any>()
1500
+ const prompts: string[] = []
1501
+
1502
+ const store = createFakeStore()
1503
+ await store.enqueueMessage("chat-1", {
1504
+ id: "queued-1",
1505
+ content: "queued follow up",
1506
+ attachments: [],
1507
+ provider: "claude",
1508
+ model: "claude-opus-4-1",
1509
+ planMode: false,
1510
+ })
1511
+
1512
+ const coordinator = new AgentCoordinator({
1513
+ store: store as never,
1514
+ onStateChange: () => {},
1515
+ startClaudeSession: async () => ({
1516
+ provider: "claude",
1517
+ stream: events,
1518
+ getAccountInfo: async () => null,
1519
+ interrupt: async () => {},
1520
+ close: () => {},
1521
+ setModel: async () => {},
1522
+ setPermissionMode: async () => {},
1523
+ getSupportedCommands: async () => [],
1524
+ sendPrompt: async (content: string) => {
1525
+ prompts.push(content)
1526
+ },
1527
+ }),
1528
+ })
1529
+
1530
+ await coordinator.send({
1531
+ type: "chat.send",
1532
+ chatId: "chat-1",
1533
+ provider: "claude",
1534
+ content: "first prompt",
1535
+ model: "claude-opus-4-1",
1536
+ })
1537
+
1538
+ expect(prompts).toEqual(["first prompt"])
1539
+ await coordinator.steer({
1540
+ type: "message.steer",
1541
+ chatId: "chat-1",
1542
+ queuedMessageId: "queued-1",
1543
+ })
1544
+
1545
+ expect(prompts).toHaveLength(2)
1546
+ expect(prompts[0]).toEqual("first prompt")
1547
+ expect(prompts[1]).toContain("queued follow up")
1548
+ expect(prompts[1]).toContain("<system-message>")
1549
+ expect(prompts[1]).toContain("</system-message>")
1550
+ expect(store.messages.some((entry) => entry.kind === "interrupted")).toBe(true)
1551
+
1552
+ events.push({
1553
+ type: "transcript" as const,
1554
+ entry: timestamped({
1555
+ kind: "interrupted",
1556
+ }),
1557
+ })
1558
+ expect(coordinator.getActiveStatuses().get("chat-1")).toBe("running")
1559
+
1560
+ events.close()
1561
+ })
1562
+
1563
+ test("uses Claude forkSession when starting a forked chat", async () => {
1564
+ const startSessionCalls: Array<{ sessionToken: string | null; forkSession: boolean }> = []
1565
+ const events = new AsyncEventQueue<any>()
1566
+ const store = createFakeStore()
1567
+ store.chat.provider = "claude"
1568
+ store.chat.pendingForkSessionToken = "claude-parent-1"
1569
+
1570
+ const coordinator = new AgentCoordinator({
1571
+ store: store as never,
1572
+ onStateChange: () => {},
1573
+ startClaudeSession: async (args) => {
1574
+ startSessionCalls.push({
1575
+ sessionToken: args.sessionToken,
1576
+ forkSession: args.forkSession,
1577
+ })
1578
+
1579
+ return {
1580
+ provider: "claude",
1581
+ stream: events,
1582
+ getAccountInfo: async () => null,
1583
+ interrupt: async () => {},
1584
+ close: () => {},
1585
+ setModel: async () => {},
1586
+ setPermissionMode: async () => {},
1587
+ getSupportedCommands: async () => [],
1588
+ sendPrompt: async () => {
1589
+ events.push({ type: "session_token" as const, sessionToken: "claude-fork-1" })
1590
+ events.push({
1591
+ type: "transcript" as const,
1592
+ entry: timestamped({
1593
+ kind: "system_init",
1594
+ provider: "claude",
1595
+ model: "claude-opus-4-1",
1596
+ tools: [],
1597
+ agents: [],
1598
+ slashCommands: [],
1599
+ mcpServers: [],
1600
+ }),
1601
+ })
1602
+ events.push({
1603
+ type: "transcript" as const,
1604
+ entry: timestamped({
1605
+ kind: "result",
1606
+ subtype: "success",
1607
+ isError: false,
1608
+ durationMs: 0,
1609
+ result: "done",
1610
+ }),
1611
+ })
1612
+ },
1613
+ }
1614
+ },
1615
+ })
1616
+
1617
+ await coordinator.send({
1618
+ type: "chat.send",
1619
+ chatId: "chat-1",
1620
+ provider: "claude",
1621
+ content: "branch this",
1622
+ model: "claude-opus-4-1",
1623
+ })
1624
+
1625
+ await waitFor(() => store.turnFinishedCount === 1)
1626
+
1627
+ expect(startSessionCalls).toEqual([{
1628
+ sessionToken: "claude-parent-1",
1629
+ forkSession: true,
1630
+ }])
1631
+ expect(store.chat.pendingForkSessionToken).toBeNull()
1632
+ events.close()
1633
+ })
1634
+ })
1635
+
1636
+ describe("AgentCoordinator.ensureSlashCommandsLoaded", () => {
1637
+ test("starts an ephemeral Claude session to load commands for a chat without a turn", async () => {
1638
+ const store = createFakeStore()
1639
+ const stateChanges: Array<string | undefined> = []
1640
+ const commands: SlashCommand[] = [
1641
+ { name: "review", description: "Review PR", argumentHint: "<pr>" },
1642
+ ]
1643
+ let startCount = 0
1644
+ let closeCount = 0
1645
+ const coordinator = new AgentCoordinator({
1646
+ store: store as never,
1647
+ onStateChange: (chatId) => { stateChanges.push(chatId) },
1648
+ startClaudeSession: async () => {
1649
+ startCount += 1
1650
+ return {
1651
+ provider: "claude",
1652
+ stream: new AsyncEventQueue<any>(),
1653
+ getAccountInfo: async () => null,
1654
+ interrupt: async () => {},
1655
+ close: () => { closeCount += 1 },
1656
+ setModel: async () => {},
1657
+ setPermissionMode: async () => {},
1658
+ getSupportedCommands: async () => commands,
1659
+ sendPrompt: async () => {},
1660
+ }
1661
+ },
1662
+ })
1663
+
1664
+ await coordinator.ensureSlashCommandsLoaded("chat-1")
1665
+
1666
+ expect(startCount).toBe(1)
1667
+ expect(closeCount).toBe(1)
1668
+ expect(store.commandsLoaded).toHaveLength(1)
1669
+ expect(store.commandsLoaded[0].commands).toEqual(commands)
1670
+ expect(stateChanges).toContain("chat-1")
1671
+ })
1672
+
1673
+ test("skips when commands already loaded for the chat", async () => {
1674
+ const store = createFakeStore()
1675
+ store.chat.slashCommands = [
1676
+ { name: "help", description: "", argumentHint: "" },
1677
+ ]
1678
+ let startCount = 0
1679
+ const coordinator = new AgentCoordinator({
1680
+ store: store as never,
1681
+ onStateChange: () => {},
1682
+ startClaudeSession: async () => {
1683
+ startCount += 1
1684
+ return {
1685
+ provider: "claude",
1686
+ stream: new AsyncEventQueue<any>(),
1687
+ getAccountInfo: async () => null,
1688
+ interrupt: async () => {},
1689
+ close: () => {},
1690
+ setModel: async () => {},
1691
+ setPermissionMode: async () => {},
1692
+ getSupportedCommands: async () => [],
1693
+ sendPrompt: async () => {},
1694
+ }
1695
+ },
1696
+ })
1697
+
1698
+ await coordinator.ensureSlashCommandsLoaded("chat-1")
1699
+
1700
+ expect(startCount).toBe(0)
1701
+ expect(store.commandsLoaded).toHaveLength(0)
1702
+ })
1703
+
1704
+ test("skips chats whose provider is codex", async () => {
1705
+ const store = createFakeStore()
1706
+ store.chat.provider = "codex"
1707
+ let startCount = 0
1708
+ const coordinator = new AgentCoordinator({
1709
+ store: store as never,
1710
+ onStateChange: () => {},
1711
+ startClaudeSession: async () => {
1712
+ startCount += 1
1713
+ return {
1714
+ provider: "claude",
1715
+ stream: new AsyncEventQueue<any>(),
1716
+ getAccountInfo: async () => null,
1717
+ interrupt: async () => {},
1718
+ close: () => {},
1719
+ setModel: async () => {},
1720
+ setPermissionMode: async () => {},
1721
+ getSupportedCommands: async () => [],
1722
+ sendPrompt: async () => {},
1723
+ }
1724
+ },
1725
+ })
1726
+
1727
+ await coordinator.ensureSlashCommandsLoaded("chat-1")
1728
+
1729
+ expect(startCount).toBe(0)
1730
+ expect(store.commandsLoaded).toHaveLength(0)
1731
+ })
1732
+
1733
+ test("dedupes concurrent calls via in-flight guard", async () => {
1734
+ const store = createFakeStore()
1735
+ let releaseCommands: (value: SlashCommand[]) => void
1736
+ const commandsReady = new Promise<SlashCommand[]>((resolve) => {
1737
+ releaseCommands = resolve
1738
+ })
1739
+ let startCount = 0
1740
+ const coordinator = new AgentCoordinator({
1741
+ store: store as never,
1742
+ onStateChange: () => {},
1743
+ startClaudeSession: async () => {
1744
+ startCount += 1
1745
+ return {
1746
+ provider: "claude",
1747
+ stream: new AsyncEventQueue<any>(),
1748
+ getAccountInfo: async () => null,
1749
+ interrupt: async () => {},
1750
+ close: () => {},
1751
+ setModel: async () => {},
1752
+ setPermissionMode: async () => {},
1753
+ getSupportedCommands: () => commandsReady,
1754
+ sendPrompt: async () => {},
1755
+ }
1756
+ },
1757
+ })
1758
+
1759
+ const p1 = coordinator.ensureSlashCommandsLoaded("chat-1")
1760
+ const p2 = coordinator.ensureSlashCommandsLoaded("chat-1")
1761
+
1762
+ await new Promise((r) => setTimeout(r, 20))
1763
+ releaseCommands!([{ name: "plan", description: "", argumentHint: "" }])
1764
+
1765
+ await Promise.all([p1, p2])
1766
+
1767
+ expect(startCount).toBe(1)
1768
+ expect(store.commandsLoaded).toHaveLength(1)
1769
+ })
1770
+ })
1771
+
1772
+ function createFakeStore() {
1773
+ const chat = {
1774
+ id: "chat-1",
1775
+ projectId: "project-1",
1776
+ title: "New Chat",
1777
+ provider: null as "claude" | "codex" | null,
1778
+ planMode: false,
1779
+ sessionToken: null as string | null,
1780
+ slashCommands: undefined as SlashCommand[] | undefined,
1781
+ pendingForkSessionToken: null as string | null,
1782
+ }
1783
+ const project = {
1784
+ id: "project-1",
1785
+ localPath: "/tmp/project",
1786
+ }
1787
+ return {
1788
+ chat,
1789
+ turnFinishedCount: 0,
1790
+ messages: [] as TranscriptEntry[],
1791
+ queuedMessages: [] as any[],
1792
+ commandsLoaded: [] as Array<{ chatId: string; commands: SlashCommand[] }>,
1793
+ async recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]) {
1794
+ this.commandsLoaded.push({ chatId, commands })
1795
+ chat.slashCommands = commands
1796
+ },
1797
+ requireChat(chatId: string) {
1798
+ expect(chatId).toBe("chat-1")
1799
+ return chat
1800
+ },
1801
+ getChat(chatId: string) {
1802
+ if (chatId !== "chat-1") return null
1803
+ return chat
1804
+ },
1805
+ getProject(projectId: string) {
1806
+ expect(projectId).toBe("project-1")
1807
+ return project
1808
+ },
1809
+ getMessages() {
1810
+ return this.messages
1811
+ },
1812
+ async setChatProvider(_chatId: string, provider: "claude" | "codex") {
1813
+ chat.provider = provider
1814
+ },
1815
+ async setPlanMode(_chatId: string, planMode: boolean) {
1816
+ chat.planMode = planMode
1817
+ },
1818
+ async renameChat(_chatId: string, title: string) {
1819
+ chat.title = title
1820
+ },
1821
+ async appendMessage(_chatId: string, entry: TranscriptEntry) {
1822
+ this.messages.push(entry)
1823
+ },
1824
+ async recordTurnStarted() {},
1825
+ async recordTurnFinished() {
1826
+ this.turnFinishedCount += 1
1827
+ },
1828
+ turnFailedCount: 0,
1829
+ turnFailures: [] as Array<{ chatId: string; reason: string }>,
1830
+ async recordTurnFailed(chatId: string, reason: string) {
1831
+ this.turnFailedCount += 1
1832
+ this.turnFailures.push({ chatId, reason })
1833
+ },
1834
+ async recordTurnCancelled() {},
1835
+ autoContinueEvents: [] as AutoContinueEvent[],
1836
+ async appendAutoContinueEvent(event: AutoContinueEvent) {
1837
+ this.autoContinueEvents.push(event)
1838
+ },
1839
+ getAutoContinueEvents(chatId: string) {
1840
+ return this.autoContinueEvents.filter((e) => e.chatId === chatId)
1841
+ },
1842
+ listAutoContinueChats() {
1843
+ return [...new Set(this.autoContinueEvents.map((e) => e.chatId))]
1844
+ },
1845
+ async setSessionToken(_chatId: string, sessionToken: string | null) {
1846
+ chat.sessionToken = sessionToken
1847
+ },
1848
+ async setPendingForkSessionToken(_chatId: string, pendingForkSessionToken: string | null) {
1849
+ chat.pendingForkSessionToken = pendingForkSessionToken
1850
+ },
1851
+ async createChat() {
1852
+ return chat
1853
+ },
1854
+ async forkChat() {
1855
+ return {
1856
+ ...chat,
1857
+ id: "chat-fork-1",
1858
+ title: "Fork: New Chat",
1859
+ sessionToken: null,
1860
+ pendingForkSessionToken: chat.sessionToken ?? chat.pendingForkSessionToken,
1861
+ }
1862
+ },
1863
+ async enqueueMessage(_chatId: string, message: any) {
1864
+ const queuedMessage = {
1865
+ id: message.id ?? crypto.randomUUID(),
1866
+ content: message.content,
1867
+ attachments: message.attachments ?? [],
1868
+ createdAt: message.createdAt ?? Date.now(),
1869
+ provider: message.provider,
1870
+ model: message.model,
1871
+ modelOptions: message.modelOptions,
1872
+ planMode: message.planMode,
1873
+ autoContinue: message.autoContinue,
1874
+ }
1875
+ this.queuedMessages.push(queuedMessage)
1876
+ return queuedMessage
1877
+ },
1878
+ getQueuedMessages() {
1879
+ return [...this.queuedMessages]
1880
+ },
1881
+ getQueuedMessage(_chatId: string, queuedMessageId: string) {
1882
+ return this.queuedMessages.find((entry) => entry.id === queuedMessageId) ?? null
1883
+ },
1884
+ async removeQueuedMessage(_chatId: string, queuedMessageId: string) {
1885
+ this.queuedMessages = this.queuedMessages.filter((entry) => entry.id !== queuedMessageId)
1886
+ },
1887
+ }
1888
+ }
1889
+
1890
+ function makeLimitError() {
1891
+ const err = new Error(
1892
+ JSON.stringify({
1893
+ type: "error",
1894
+ error: { type: "rate_limit_error" },
1895
+ })
1896
+ ) as Error & { status?: number; headers?: Record<string, string> }
1897
+ err.status = 429
1898
+ err.headers = {
1899
+ "anthropic-ratelimit-unified-reset": new Date(5_000).toISOString(),
1900
+ "x-anthropic-timezone": "Asia/Saigon",
1901
+ }
1902
+ return err
1903
+ }
1904
+
1905
+ describe("AgentCoordinator rate-limit detection (manual mode)", () => {
1906
+ test("emits auto_continue_proposed when Claude throws a rate-limit error and autoResumeOnRateLimit is false", async () => {
1907
+ const store = createFakeStore()
1908
+ const limitErr = makeLimitError()
1909
+ const events = new AsyncEventQueue<any>()
1910
+
1911
+ const coordinator = new AgentCoordinator({
1912
+ store: store as never,
1913
+ onStateChange: () => {},
1914
+ getAutoResumePreference: () => false,
1915
+ startClaudeSession: async () => ({
1916
+ provider: "claude",
1917
+ stream: events,
1918
+ getAccountInfo: async () => null,
1919
+ interrupt: async () => {},
1920
+ close: () => {},
1921
+ setModel: async () => {},
1922
+ setPermissionMode: async () => {},
1923
+ getSupportedCommands: async () => [],
1924
+ sendPrompt: async () => {
1925
+ // Throw after sendPrompt is called — activeTurns is already set by this point
1926
+ events.throw(limitErr)
1927
+ },
1928
+ }),
1929
+ })
1930
+
1931
+ await coordinator.send({
1932
+ type: "chat.send",
1933
+ chatId: "chat-1",
1934
+ provider: "claude",
1935
+ content: "hello",
1936
+ model: "claude-opus-4-5",
1937
+ autoResumeOnRateLimit: false,
1938
+ })
1939
+
1940
+ await waitFor(() => store.getAutoContinueEvents("chat-1").length >= 1 && store.turnFailedCount >= 1)
1941
+
1942
+ const acEvents = store.getAutoContinueEvents("chat-1")
1943
+ expect(acEvents).toHaveLength(1)
1944
+ expect(acEvents[0].kind).toBe("auto_continue_proposed")
1945
+ if (acEvents[0].kind === "auto_continue_proposed") {
1946
+ expect(acEvents[0].tz).toBe("Asia/Saigon")
1947
+ }
1948
+ expect(store.turnFailures.some((f) => f.reason === "rate_limit")).toBe(true)
1949
+ })
1950
+
1951
+ test("auto-resume on: emits auto_continue_accepted directly with source=auto_setting", async () => {
1952
+ const store = createFakeStore()
1953
+ const limitErr = makeLimitError()
1954
+ const events = new AsyncEventQueue<any>()
1955
+
1956
+ const coordinator = new AgentCoordinator({
1957
+ store: store as never,
1958
+ onStateChange: () => {},
1959
+ getAutoResumePreference: () => true,
1960
+ startClaudeSession: async () => ({
1961
+ provider: "claude",
1962
+ stream: events,
1963
+ getAccountInfo: async () => null,
1964
+ interrupt: async () => {},
1965
+ close: () => {},
1966
+ setModel: async () => {},
1967
+ setPermissionMode: async () => {},
1968
+ getSupportedCommands: async () => [],
1969
+ sendPrompt: async () => {
1970
+ events.throw(limitErr)
1971
+ },
1972
+ }),
1973
+ })
1974
+
1975
+ await coordinator.send({
1976
+ type: "chat.send",
1977
+ chatId: "chat-1",
1978
+ provider: "claude",
1979
+ content: "hello",
1980
+ model: "claude-opus-4-5",
1981
+ autoResumeOnRateLimit: true,
1982
+ })
1983
+
1984
+ await waitFor(() => store.getAutoContinueEvents("chat-1").length >= 1 && store.turnFailedCount >= 1)
1985
+
1986
+ const acEvents = store.getAutoContinueEvents("chat-1")
1987
+ expect(acEvents).toHaveLength(1)
1988
+ expect(acEvents[0].kind).toBe("auto_continue_accepted")
1989
+ if (acEvents[0].kind === "auto_continue_accepted") {
1990
+ expect(acEvents[0].source).toBe("auto_setting")
1991
+ }
1992
+ expect(store.turnFailures.some((f) => f.reason === "rate_limit")).toBe(true)
1993
+ })
1994
+ })
1995
+
1996
+ describe("AgentCoordinator auto-continue firing", () => {
1997
+ test("firing enqueues a 'continue' user message carrying autoContinue metadata", async () => {
1998
+ const store = createFakeStore()
1999
+ const limitErr = makeLimitError()
2000
+ const events = new AsyncEventQueue<any>()
2001
+
2002
+ // FakeClock lets us manually advance time to trigger armed schedules.
2003
+ class FakeClock {
2004
+ private currentTime = 0
2005
+ private readonly timers = new Map<number, { fn: () => void; fireAt: number }>()
2006
+ private nextId = 1
2007
+
2008
+ now() { return this.currentTime }
2009
+
2010
+ setTimeout(fn: () => void, delayMs: number): number {
2011
+ const id = this.nextId++
2012
+ this.timers.set(id, { fn, fireAt: this.currentTime + delayMs })
2013
+ return id
2014
+ }
2015
+
2016
+ clearTimeout(id: number) { this.timers.delete(id) }
2017
+
2018
+ advance(ms: number) {
2019
+ this.currentTime += ms
2020
+ for (const [id, timer] of [...this.timers.entries()]) {
2021
+ if (timer.fireAt <= this.currentTime) {
2022
+ this.timers.delete(id)
2023
+ timer.fn()
2024
+ }
2025
+ }
2026
+ }
2027
+ }
2028
+
2029
+ const clock = new FakeClock()
2030
+
2031
+ let coordinator!: AgentCoordinator
2032
+ const { ScheduleManager: SM } = await import("./auto-continue/schedule-manager")
2033
+ const scheduleManager = new SM({
2034
+ clock,
2035
+ fire: async (chatId, scheduleId) => {
2036
+ await coordinator.fireAutoContinue(chatId, scheduleId)
2037
+ },
2038
+ })
2039
+
2040
+ coordinator = new AgentCoordinator({
2041
+ store: store as never,
2042
+ onStateChange: () => {},
2043
+ getAutoResumePreference: () => true,
2044
+ scheduleManager,
2045
+ startClaudeSession: async () => ({
2046
+ provider: "claude",
2047
+ stream: events,
2048
+ getAccountInfo: async () => null,
2049
+ interrupt: async () => {},
2050
+ close: () => {},
2051
+ setModel: async () => {},
2052
+ setPermissionMode: async () => {},
2053
+ getSupportedCommands: async () => [],
2054
+ sendPrompt: async () => {
2055
+ events.throw(limitErr)
2056
+ },
2057
+ }),
2058
+ })
2059
+
2060
+ await coordinator.send({
2061
+ type: "chat.send",
2062
+ chatId: "chat-1",
2063
+ provider: "claude",
2064
+ content: "hello",
2065
+ model: "claude-opus-4-5",
2066
+ autoResumeOnRateLimit: true,
2067
+ })
2068
+
2069
+ // Wait for auto_continue_accepted to be stored (Task 12 already handles this).
2070
+ await waitFor(() => store.getAutoContinueEvents("chat-1").length >= 1 && store.turnFailedCount >= 1)
2071
+
2072
+ const acceptedEvent = store.getAutoContinueEvents("chat-1")[0]
2073
+ expect(acceptedEvent.kind).toBe("auto_continue_accepted")
2074
+
2075
+ // The limit error header sets resetAt = new Date(5_000).toISOString() → 5000 ms from epoch.
2076
+ // Advancing the clock past that fires the schedule.
2077
+ clock.advance(10_000)
2078
+
2079
+ // Wait for the fired event AND the "continue" user_prompt to both appear.
2080
+ await waitFor(
2081
+ () =>
2082
+ store.getAutoContinueEvents("chat-1").some((e) => e.kind === "auto_continue_fired") &&
2083
+ store.messages.some((m) => m.kind === "user_prompt" && m.content === "continue")
2084
+ )
2085
+
2086
+ const acEvents = store.getAutoContinueEvents("chat-1")
2087
+ const firedEvent = acEvents.find((e) => e.kind === "auto_continue_fired")
2088
+ expect(firedEvent).toBeDefined()
2089
+ if (firedEvent?.kind === "auto_continue_fired") {
2090
+ expect(firedEvent.scheduleId).toBe(acceptedEvent.scheduleId)
2091
+ }
2092
+
2093
+ // Exactly one "continue" user_prompt with autoContinue metadata.
2094
+ const userPrompts = store.messages.filter(
2095
+ (m) => m.kind === "user_prompt" && m.content === "continue"
2096
+ )
2097
+ expect(userPrompts).toHaveLength(1)
2098
+ if (userPrompts[0].kind === "user_prompt") {
2099
+ expect(userPrompts[0].autoContinue?.scheduleId).toBe(acceptedEvent.scheduleId)
2100
+ }
2101
+ })
2102
+ })
2103
+
2104
+ // ── AgentCoordinator: acceptAutoContinue / rescheduleAutoContinue / cancelAutoContinue / listLiveSchedules ──
2105
+
2106
+ // Minimal coordinator factory for Task 14 auto-continue tests; intentionally omits
2107
+ // codexManager and generateTitle — do not use for tests that need provider flows.
2108
+ function makeCoordinatorWithStore(extraStoreFields: Partial<ReturnType<typeof createFakeStore>> = {}) {
2109
+ const store = { ...createFakeStore(), ...extraStoreFields }
2110
+ const coordinator = new AgentCoordinator({
2111
+ store: store as never,
2112
+ onStateChange: () => {},
2113
+ getAutoResumePreference: () => false,
2114
+ startClaudeSession: async () => { throw new Error("not needed") },
2115
+ })
2116
+ return { store, coordinator }
2117
+ }
2118
+
2119
+ describe("AgentCoordinator.acceptAutoContinue", () => {
2120
+ test("happy path: appends auto_continue_accepted with source 'user' for a proposed schedule", async () => {
2121
+ const { store, coordinator } = makeCoordinatorWithStore()
2122
+ // Seed a proposed event
2123
+ const scheduleId = "sched-1"
2124
+ const proposedEvent: AutoContinueEvent = {
2125
+ v: 3,
2126
+ kind: "auto_continue_proposed",
2127
+ timestamp: Date.now(),
2128
+ chatId: "chat-1",
2129
+ scheduleId,
2130
+ detectedAt: Date.now(),
2131
+ resetAt: Date.now() + 10_000,
2132
+ tz: "UTC",
2133
+
2134
+ }
2135
+ store.autoContinueEvents.push(proposedEvent)
2136
+
2137
+ const future = Date.now() + 60_000
2138
+ await coordinator.acceptAutoContinue("chat-1", scheduleId, future)
2139
+
2140
+ const appended = store.autoContinueEvents.filter((e) => e.kind === "auto_continue_accepted")
2141
+ expect(appended).toHaveLength(1)
2142
+ expect(appended[0]!.kind).toBe("auto_continue_accepted")
2143
+ if (appended[0]!.kind === "auto_continue_accepted") {
2144
+ expect(appended[0]!.source).toBe("user")
2145
+ expect(appended[0]!.scheduledAt).toBe(future)
2146
+ }
2147
+ })
2148
+
2149
+ test("guard: rejects when schedule state is not 'proposed'", async () => {
2150
+ const { store, coordinator } = makeCoordinatorWithStore()
2151
+ const scheduleId = "sched-cancel"
2152
+ // Seed a proposed + cancelled event so state = "cancelled"
2153
+ store.autoContinueEvents.push({
2154
+ v: 3,
2155
+ kind: "auto_continue_proposed",
2156
+ timestamp: Date.now(),
2157
+ chatId: "chat-1",
2158
+ scheduleId,
2159
+ detectedAt: Date.now(),
2160
+ resetAt: Date.now() + 10_000,
2161
+ tz: "UTC",
2162
+
2163
+ })
2164
+ store.autoContinueEvents.push({
2165
+ v: 3,
2166
+ kind: "auto_continue_cancelled",
2167
+ timestamp: Date.now(),
2168
+ chatId: "chat-1",
2169
+ scheduleId,
2170
+ reason: "user",
2171
+ })
2172
+
2173
+ await expect(
2174
+ coordinator.acceptAutoContinue("chat-1", scheduleId, Date.now() + 60_000)
2175
+ ).rejects.toThrow("Schedule not pending")
2176
+ })
2177
+
2178
+ test("guard: rejects when scheduledAt is in the past (time guard)", async () => {
2179
+ const { store, coordinator } = makeCoordinatorWithStore()
2180
+ const scheduleId = "sched-past"
2181
+ store.autoContinueEvents.push({
2182
+ v: 3,
2183
+ kind: "auto_continue_proposed",
2184
+ timestamp: Date.now(),
2185
+ chatId: "chat-1",
2186
+ scheduleId,
2187
+ detectedAt: Date.now(),
2188
+ resetAt: Date.now() + 10_000,
2189
+ tz: "UTC",
2190
+
2191
+ })
2192
+
2193
+ await expect(
2194
+ coordinator.acceptAutoContinue("chat-1", scheduleId, Date.now() - 1)
2195
+ ).rejects.toThrow("scheduledAt must be in the future")
2196
+ })
2197
+ })
2198
+
2199
+ describe("AgentCoordinator.rescheduleAutoContinue", () => {
2200
+ test("happy path: appends auto_continue_rescheduled for a scheduled schedule", async () => {
2201
+ const { store, coordinator } = makeCoordinatorWithStore()
2202
+ const scheduleId = "sched-sched"
2203
+ // Seed proposed + accepted = state "scheduled"
2204
+ store.autoContinueEvents.push({
2205
+ v: 3,
2206
+ kind: "auto_continue_proposed",
2207
+ timestamp: Date.now(),
2208
+ chatId: "chat-1",
2209
+ scheduleId,
2210
+ detectedAt: Date.now(),
2211
+ resetAt: Date.now() + 10_000,
2212
+ tz: "UTC",
2213
+
2214
+ })
2215
+ store.autoContinueEvents.push({
2216
+ v: 3,
2217
+ kind: "auto_continue_accepted",
2218
+ timestamp: Date.now(),
2219
+ chatId: "chat-1",
2220
+ scheduleId,
2221
+ scheduledAt: Date.now() + 30_000,
2222
+ tz: "UTC",
2223
+ source: "user",
2224
+ resetAt: Date.now() + 10_000,
2225
+ detectedAt: Date.now(),
2226
+ })
2227
+
2228
+ const newTime = Date.now() + 120_000
2229
+ await coordinator.rescheduleAutoContinue("chat-1", scheduleId, newTime)
2230
+
2231
+ const appended = store.autoContinueEvents.filter((e) => e.kind === "auto_continue_rescheduled")
2232
+ expect(appended).toHaveLength(1)
2233
+ if (appended[0]!.kind === "auto_continue_rescheduled") {
2234
+ expect(appended[0]!.scheduledAt).toBe(newTime)
2235
+ }
2236
+ })
2237
+
2238
+ test("guard: rejects when schedule state is not 'scheduled'", async () => {
2239
+ const { store, coordinator } = makeCoordinatorWithStore()
2240
+ const scheduleId = "sched-prop"
2241
+ // Proposed only = state "proposed"
2242
+ store.autoContinueEvents.push({
2243
+ v: 3,
2244
+ kind: "auto_continue_proposed",
2245
+ timestamp: Date.now(),
2246
+ chatId: "chat-1",
2247
+ scheduleId,
2248
+ detectedAt: Date.now(),
2249
+ resetAt: Date.now() + 10_000,
2250
+ tz: "UTC",
2251
+
2252
+ })
2253
+
2254
+ await expect(
2255
+ coordinator.rescheduleAutoContinue("chat-1", scheduleId, Date.now() + 60_000)
2256
+ ).rejects.toThrow("Schedule not active")
2257
+ })
2258
+
2259
+ test("guard: rejects when scheduledAt is in the past (time guard)", async () => {
2260
+ const { store, coordinator } = makeCoordinatorWithStore()
2261
+ const scheduleId = "sched-ts"
2262
+ store.autoContinueEvents.push({
2263
+ v: 3,
2264
+ kind: "auto_continue_proposed",
2265
+ timestamp: Date.now(),
2266
+ chatId: "chat-1",
2267
+ scheduleId,
2268
+ detectedAt: Date.now(),
2269
+ resetAt: Date.now() + 10_000,
2270
+ tz: "UTC",
2271
+
2272
+ })
2273
+ store.autoContinueEvents.push({
2274
+ v: 3,
2275
+ kind: "auto_continue_accepted",
2276
+ timestamp: Date.now(),
2277
+ chatId: "chat-1",
2278
+ scheduleId,
2279
+ scheduledAt: Date.now() + 30_000,
2280
+ tz: "UTC",
2281
+ source: "user",
2282
+ resetAt: Date.now() + 10_000,
2283
+ detectedAt: Date.now(),
2284
+ })
2285
+
2286
+ await expect(
2287
+ coordinator.rescheduleAutoContinue("chat-1", scheduleId, Date.now() - 1)
2288
+ ).rejects.toThrow("scheduledAt must be in the future")
2289
+ })
2290
+ })
2291
+
2292
+ describe("AgentCoordinator.cancelAutoContinue", () => {
2293
+ test("happy path: appends auto_continue_cancelled with given reason for a live schedule", async () => {
2294
+ const { store, coordinator } = makeCoordinatorWithStore()
2295
+ const scheduleId = "sched-live"
2296
+ store.autoContinueEvents.push({
2297
+ v: 3,
2298
+ kind: "auto_continue_proposed",
2299
+ timestamp: Date.now(),
2300
+ chatId: "chat-1",
2301
+ scheduleId,
2302
+ detectedAt: Date.now(),
2303
+ resetAt: Date.now() + 10_000,
2304
+ tz: "UTC",
2305
+
2306
+ })
2307
+
2308
+ await coordinator.cancelAutoContinue("chat-1", scheduleId, "user")
2309
+
2310
+ const appended = store.autoContinueEvents.filter((e) => e.kind === "auto_continue_cancelled")
2311
+ expect(appended).toHaveLength(1)
2312
+ if (appended[0]!.kind === "auto_continue_cancelled") {
2313
+ expect(appended[0]!.reason).toBe("user")
2314
+ }
2315
+ })
2316
+
2317
+ test("guard: silently no-ops when schedule state is outside proposed|scheduled (does not throw, no event appended)", async () => {
2318
+ const { store, coordinator } = makeCoordinatorWithStore()
2319
+ const scheduleId = "sched-fired"
2320
+ // Seed a fired schedule
2321
+ store.autoContinueEvents.push({
2322
+ v: 3,
2323
+ kind: "auto_continue_fired",
2324
+ timestamp: Date.now(),
2325
+ chatId: "chat-1",
2326
+ scheduleId,
2327
+
2328
+ })
2329
+
2330
+ // Should not throw
2331
+ await coordinator.cancelAutoContinue("chat-1", scheduleId, "user")
2332
+
2333
+ // No cancelled event appended
2334
+ const cancelled = store.autoContinueEvents.filter((e) => e.kind === "auto_continue_cancelled")
2335
+ expect(cancelled).toHaveLength(0)
2336
+ })
2337
+ })
2338
+
2339
+ describe("AgentCoordinator.listLiveSchedules", () => {
2340
+ test("returns scheduleIds for proposed and scheduled states only", async () => {
2341
+ const { store, coordinator } = makeCoordinatorWithStore()
2342
+ // proposed
2343
+ store.autoContinueEvents.push({
2344
+ v: 3, kind: "auto_continue_proposed", timestamp: Date.now(),
2345
+ chatId: "chat-1", scheduleId: "sched-proposed",
2346
+ detectedAt: Date.now(), resetAt: Date.now() + 10_000, tz: "UTC",
2347
+ })
2348
+ // scheduled
2349
+ store.autoContinueEvents.push({
2350
+ v: 3, kind: "auto_continue_proposed", timestamp: Date.now(),
2351
+ chatId: "chat-1", scheduleId: "sched-scheduled",
2352
+ detectedAt: Date.now(), resetAt: Date.now() + 10_000, tz: "UTC",
2353
+ })
2354
+ store.autoContinueEvents.push({
2355
+ v: 3, kind: "auto_continue_accepted", timestamp: Date.now(),
2356
+ chatId: "chat-1", scheduleId: "sched-scheduled",
2357
+ scheduledAt: Date.now() + 30_000, tz: "UTC", source: "user",
2358
+ resetAt: Date.now() + 10_000, detectedAt: Date.now(),
2359
+ })
2360
+ // fired (should not appear)
2361
+ store.autoContinueEvents.push({
2362
+ v: 3, kind: "auto_continue_fired", timestamp: Date.now(),
2363
+ chatId: "chat-1", scheduleId: "sched-fired",
2364
+ })
2365
+
2366
+ const live = coordinator.listLiveSchedules("chat-1")
2367
+ expect(live.sort()).toEqual(["sched-proposed", "sched-scheduled"].sort())
2368
+ })
2369
+ })