@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.
- package/LICENSE +22 -0
- package/README.md +355 -0
- package/bin/kanna +9 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/abap-BdImnpbu.js +1 -0
- package/dist/client/assets/actionscript-3-CoDkCxhg.js +1 -0
- package/dist/client/assets/ada-bCR0ucgS.js +1 -0
- package/dist/client/assets/andromeeda-C4gqWexZ.js +1 -0
- package/dist/client/assets/angular-html-CU67Zn6k.js +1 -0
- package/dist/client/assets/angular-ts-BwZT4LLn.js +1 -0
- package/dist/client/assets/apache-Pmp26Uib.js +1 -0
- package/dist/client/assets/apex-D8_7TLub.js +1 -0
- package/dist/client/assets/apl-dKokRX4l.js +1 -0
- package/dist/client/assets/applescript-Co6uUVPk.js +1 -0
- package/dist/client/assets/ara-BRHolxvo.js +1 -0
- package/dist/client/assets/asciidoc-Ve4PFQV2.js +1 -0
- package/dist/client/assets/asm-D_Q5rh1f.js +1 -0
- package/dist/client/assets/astro-CbQHKStN.js +1 -0
- package/dist/client/assets/aurora-x-D-2ljcwZ.js +1 -0
- package/dist/client/assets/awk-DMzUqQB5.js +1 -0
- package/dist/client/assets/ayu-dark-DYE7WIF3.js +1 -0
- package/dist/client/assets/ayu-light-BA47KaF1.js +1 -0
- package/dist/client/assets/ayu-mirage-32ctXXKs.js +1 -0
- package/dist/client/assets/ballerina-BFfxhgS-.js +1 -0
- package/dist/client/assets/bat-BkioyH1T.js +1 -0
- package/dist/client/assets/beancount-k_qm7-4y.js +1 -0
- package/dist/client/assets/berry-uYugtg8r.js +1 -0
- package/dist/client/assets/bibtex-CHM0blh-.js +1 -0
- package/dist/client/assets/bicep-Bmn6On1c.js +1 -0
- package/dist/client/assets/bird2-DPOp833l.js +1 -0
- package/dist/client/assets/blade-D4QpJJKB.js +1 -0
- package/dist/client/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- package/dist/client/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- package/dist/client/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- package/dist/client/assets/bsl-BO_Y6i37.js +1 -0
- package/dist/client/assets/c-BIGW1oBm.js +1 -0
- package/dist/client/assets/c3-eo99z4R2.js +1 -0
- package/dist/client/assets/cadence-Bv_4Rxtq.js +1 -0
- package/dist/client/assets/cairo-KRGpt6FW.js +1 -0
- package/dist/client/assets/catppuccin-frappe-DFWUc33u.js +1 -0
- package/dist/client/assets/catppuccin-latte-C9dUb6Cb.js +1 -0
- package/dist/client/assets/catppuccin-macchiato-DQyhUUbL.js +1 -0
- package/dist/client/assets/catppuccin-mocha-D87Tk5Gz.js +1 -0
- package/dist/client/assets/clarity-D53aC0YG.js +1 -0
- package/dist/client/assets/clojure-P80f7IUj.js +1 -0
- package/dist/client/assets/cmake-D1j8_8rp.js +1 -0
- package/dist/client/assets/cobol-nwyudZeR.js +1 -0
- package/dist/client/assets/codeowners-Bp6g37R7.js +1 -0
- package/dist/client/assets/codeql-DsOJ9woJ.js +1 -0
- package/dist/client/assets/coffee-Ch7k5sss.js +1 -0
- package/dist/client/assets/common-lisp-Cg-RD9OK.js +1 -0
- package/dist/client/assets/coq-DkFqJrB1.js +1 -0
- package/dist/client/assets/cpp-CofmeUqb.js +1 -0
- package/dist/client/assets/crystal-tKQVLTB8.js +1 -0
- package/dist/client/assets/csharp-COcwbKMJ.js +1 -0
- package/dist/client/assets/css-DPfMkruS.js +1 -0
- package/dist/client/assets/csv-fuZLfV_i.js +1 -0
- package/dist/client/assets/cue-D82EKSYY.js +1 -0
- package/dist/client/assets/cypher-COkxafJQ.js +1 -0
- package/dist/client/assets/d-85-TOEBH.js +1 -0
- package/dist/client/assets/dark-plus-C3mMm8J8.js +1 -0
- package/dist/client/assets/dart-CF10PKvl.js +1 -0
- package/dist/client/assets/dax-CEL-wOlO.js +1 -0
- package/dist/client/assets/desktop-BmXAJ9_W.js +1 -0
- package/dist/client/assets/diff-D97Zzqfu.js +1 -0
- package/dist/client/assets/docker-BcOcwvcX.js +1 -0
- package/dist/client/assets/dotenv-Da5cRb03.js +1 -0
- package/dist/client/assets/dracula-BzJJZx-M.js +1 -0
- package/dist/client/assets/dracula-soft-BXkSAIEj.js +1 -0
- package/dist/client/assets/dream-maker-BtqSS_iP.js +1 -0
- package/dist/client/assets/edge-BkV0erSs.js +1 -0
- package/dist/client/assets/elixir-CDX3lj18.js +1 -0
- package/dist/client/assets/elm-DbKCFpqz.js +1 -0
- package/dist/client/assets/emacs-lisp-C9XAeP06.js +1 -0
- package/dist/client/assets/erb-B12qg9BL.js +1 -0
- package/dist/client/assets/erlang-DsQrWhSR.js +1 -0
- package/dist/client/assets/everforest-dark-BgDCqdQA.js +1 -0
- package/dist/client/assets/everforest-light-C8M2exoo.js +1 -0
- package/dist/client/assets/fennel-BYunw83y.js +1 -0
- package/dist/client/assets/fish-BvzEVeQv.js +1 -0
- package/dist/client/assets/fluent-C4IJs8-o.js +1 -0
- package/dist/client/assets/fortran-fixed-form-CkoXwp7k.js +1 -0
- package/dist/client/assets/fortran-free-form-BxgE0vQu.js +1 -0
- package/dist/client/assets/fsharp-CXgrBDvD.js +1 -0
- package/dist/client/assets/gdresource-BOOCDP_w.js +1 -0
- package/dist/client/assets/gdscript-C5YyOfLZ.js +1 -0
- package/dist/client/assets/gdshader-DkwncUOv.js +1 -0
- package/dist/client/assets/genie-D0YGMca9.js +1 -0
- package/dist/client/assets/gherkin-DyxjwDmM.js +1 -0
- package/dist/client/assets/git-commit-F4YmCXRG.js +1 -0
- package/dist/client/assets/git-rebase-r7XF79zn.js +1 -0
- package/dist/client/assets/github-dark-DHJKELXO.js +1 -0
- package/dist/client/assets/github-dark-default-Cuk6v7N8.js +1 -0
- package/dist/client/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
- package/dist/client/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
- package/dist/client/assets/github-light-DAi9KRSo.js +1 -0
- package/dist/client/assets/github-light-default-D7oLnXFd.js +1 -0
- package/dist/client/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
- package/dist/client/assets/gleam-BspZqrRM.js +1 -0
- package/dist/client/assets/glimmer-js-Rg0-pVw9.js +1 -0
- package/dist/client/assets/glimmer-ts-U6CK756n.js +1 -0
- package/dist/client/assets/glsl-DplSGwfg.js +1 -0
- package/dist/client/assets/gn-n2N0HUVH.js +1 -0
- package/dist/client/assets/gnuplot-DdkO51Og.js +1 -0
- package/dist/client/assets/go-CxLEBnE3.js +1 -0
- package/dist/client/assets/graphql-ChdNCCLP.js +1 -0
- package/dist/client/assets/groovy-gcz8RCvz.js +1 -0
- package/dist/client/assets/gruvbox-dark-hard-CFHQjOhq.js +1 -0
- package/dist/client/assets/gruvbox-dark-medium-GsRaNv29.js +1 -0
- package/dist/client/assets/gruvbox-dark-soft-CVdnzihN.js +1 -0
- package/dist/client/assets/gruvbox-light-hard-CH1njM8p.js +1 -0
- package/dist/client/assets/gruvbox-light-medium-DRw_LuNl.js +1 -0
- package/dist/client/assets/gruvbox-light-soft-hJgmCMqR.js +1 -0
- package/dist/client/assets/hack-CaT9iCJl.js +1 -0
- package/dist/client/assets/haml-B8DHNrY2.js +1 -0
- package/dist/client/assets/handlebars-BL8al0AC.js +1 -0
- package/dist/client/assets/haskell-Df6bDoY_.js +1 -0
- package/dist/client/assets/haxe-CzTSHFRz.js +1 -0
- package/dist/client/assets/hcl-BWvSN4gD.js +1 -0
- package/dist/client/assets/hjson-D5-asLiD.js +1 -0
- package/dist/client/assets/hlsl-D3lLCCz7.js +1 -0
- package/dist/client/assets/horizon-BUw7H-hv.js +1 -0
- package/dist/client/assets/horizon-bright-Cn-bp-IR.js +1 -0
- package/dist/client/assets/houston-DnULxvSX.js +1 -0
- package/dist/client/assets/html-GMplVEZG.js +1 -0
- package/dist/client/assets/html-derivative-BFtXZ54Q.js +1 -0
- package/dist/client/assets/http-jrhK8wxY.js +1 -0
- package/dist/client/assets/hurl-irOxFIW8.js +1 -0
- package/dist/client/assets/hxml-Bvhsp5Yf.js +1 -0
- package/dist/client/assets/hy-DFXneXwc.js +1 -0
- package/dist/client/assets/imba-DGztddWO.js +1 -0
- package/dist/client/assets/index-Do7324M0.css +32 -0
- package/dist/client/assets/index-ktE9DLCD.js +2620 -0
- package/dist/client/assets/ini-BEwlwnbL.js +1 -0
- package/dist/client/assets/java-CylS5w8V.js +1 -0
- package/dist/client/assets/javascript-wDzz0qaB.js +1 -0
- package/dist/client/assets/jinja-4LBKfQ-Z.js +1 -0
- package/dist/client/assets/jison-wvAkD_A8.js +1 -0
- package/dist/client/assets/json-Cp-IABpG.js +1 -0
- package/dist/client/assets/json5-C9tS-k6U.js +1 -0
- package/dist/client/assets/jsonc-Des-eS-w.js +1 -0
- package/dist/client/assets/jsonl-DcaNXYhu.js +1 -0
- package/dist/client/assets/jsonnet-DFQXde-d.js +1 -0
- package/dist/client/assets/jssm-C2t-YnRu.js +1 -0
- package/dist/client/assets/jsx-g9-lgVsj.js +1 -0
- package/dist/client/assets/julia-CxzCAyBv.js +1 -0
- package/dist/client/assets/just-Cw27pwNe.js +1 -0
- package/dist/client/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
- package/dist/client/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
- package/dist/client/assets/kanagawa-wave-DWedfzmr.js +1 -0
- package/dist/client/assets/kdl-DV7GczEv.js +1 -0
- package/dist/client/assets/kotlin-BdnUsdx6.js +1 -0
- package/dist/client/assets/kusto-DZf3V79B.js +1 -0
- package/dist/client/assets/laserwave-DUszq2jm.js +1 -0
- package/dist/client/assets/latex-CWtU0Tv5.js +1 -0
- package/dist/client/assets/lean-BZvkOJ9d.js +1 -0
- package/dist/client/assets/less-B1dDrJ26.js +1 -0
- package/dist/client/assets/light-plus-B7mTdjB0.js +1 -0
- package/dist/client/assets/liquid-DYVedYrR.js +1 -0
- package/dist/client/assets/llvm-DjAJT7YJ.js +1 -0
- package/dist/client/assets/log-2UxHyX5q.js +1 -0
- package/dist/client/assets/logo-BtOb2qkB.js +1 -0
- package/dist/client/assets/lua-BaeVxFsk.js +1 -0
- package/dist/client/assets/luau-C-HG3fhB.js +1 -0
- package/dist/client/assets/make-CHLpvVh8.js +1 -0
- package/dist/client/assets/markdown-Cvjx9yec.js +1 -0
- package/dist/client/assets/marko-CnJfTvn9.js +1 -0
- package/dist/client/assets/material-theme-D5KoaKCx.js +1 -0
- package/dist/client/assets/material-theme-darker-BfHTSMKl.js +1 -0
- package/dist/client/assets/material-theme-lighter-B0m2ddpp.js +1 -0
- package/dist/client/assets/material-theme-ocean-CyktbL80.js +1 -0
- package/dist/client/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
- package/dist/client/assets/matlab-D7o27uSR.js +1 -0
- package/dist/client/assets/mdc-BMNejdWA.js +1 -0
- package/dist/client/assets/mdx-Cmh6b_Ma.js +1 -0
- package/dist/client/assets/mermaid-mWjccvbQ.js +1 -0
- package/dist/client/assets/min-dark-CafNBF8u.js +1 -0
- package/dist/client/assets/min-light-CTRr51gU.js +1 -0
- package/dist/client/assets/mipsasm-CKIfxQSi.js +1 -0
- package/dist/client/assets/mojo-rZm6bMo-.js +1 -0
- package/dist/client/assets/monokai-D4h5O-jR.js +1 -0
- package/dist/client/assets/moonbit-_H4v1dQx.js +1 -0
- package/dist/client/assets/move-IF9eRakj.js +1 -0
- package/dist/client/assets/narrat-DRg8JJMk.js +1 -0
- package/dist/client/assets/nextflow-Zz6hmt5N.js +1 -0
- package/dist/client/assets/nextflow-groovy-BeH2EWoN.js +1 -0
- package/dist/client/assets/nginx-BpAMiNFr.js +1 -0
- package/dist/client/assets/night-owl-C39BiMTA.js +1 -0
- package/dist/client/assets/night-owl-light-CMTm3GFP.js +1 -0
- package/dist/client/assets/nim-CVrawwO9.js +1 -0
- package/dist/client/assets/nix-CwoSXNpI.js +1 -0
- package/dist/client/assets/nord-Ddv68eIx.js +1 -0
- package/dist/client/assets/nushell-Cz2AlsmD.js +1 -0
- package/dist/client/assets/objective-c-DXmwc3jG.js +1 -0
- package/dist/client/assets/objective-cpp-CLxacb5B.js +1 -0
- package/dist/client/assets/ocaml-C0hk2d4L.js +1 -0
- package/dist/client/assets/odin-BBf5iR-q.js +1 -0
- package/dist/client/assets/one-dark-pro-DVMEJ2y_.js +1 -0
- package/dist/client/assets/one-light-C3Wv6jpd.js +1 -0
- package/dist/client/assets/openscad-C4EeE6gA.js +1 -0
- package/dist/client/assets/pascal-D93ZcfNL.js +1 -0
- package/dist/client/assets/perl-C0TMdlhV.js +1 -0
- package/dist/client/assets/php-Dhbhpdrm.js +1 -0
- package/dist/client/assets/pierre-dark-DF2SEV7i.js +1 -0
- package/dist/client/assets/pierre-light-DOlZxES8.js +1 -0
- package/dist/client/assets/pkl-u5AG7uiY.js +1 -0
- package/dist/client/assets/plastic-3e1v2bzS.js +1 -0
- package/dist/client/assets/plsql-ChMvpjG-.js +1 -0
- package/dist/client/assets/po-BTJTHyun.js +1 -0
- package/dist/client/assets/poimandres-CS3Unz2-.js +1 -0
- package/dist/client/assets/polar-C0HS_06l.js +1 -0
- package/dist/client/assets/postcss-CXtECtnM.js +1 -0
- package/dist/client/assets/powerquery-CEu0bR-o.js +1 -0
- package/dist/client/assets/powershell-Dpen1YoG.js +1 -0
- package/dist/client/assets/prisma-Dd19v3D-.js +1 -0
- package/dist/client/assets/prolog-CbFg5uaA.js +1 -0
- package/dist/client/assets/proto-C7zT0LnQ.js +1 -0
- package/dist/client/assets/pug-CGlum2m_.js +1 -0
- package/dist/client/assets/puppet-BMWR74SV.js +1 -0
- package/dist/client/assets/purescript-CklMAg4u.js +1 -0
- package/dist/client/assets/python-B6aJPvgy.js +1 -0
- package/dist/client/assets/qml-3beO22l8.js +1 -0
- package/dist/client/assets/qmldir-C8lEn-DE.js +1 -0
- package/dist/client/assets/qss-IeuSbFQv.js +1 -0
- package/dist/client/assets/r-Dspwwk_N.js +1 -0
- package/dist/client/assets/racket-BqYA7rlc.js +1 -0
- package/dist/client/assets/raku-DXvB9xmW.js +1 -0
- package/dist/client/assets/razor-Uh8Bk_45.js +1 -0
- package/dist/client/assets/red-bN70gL4F.js +1 -0
- package/dist/client/assets/reg-C-SQnVFl.js +1 -0
- package/dist/client/assets/regexp-CDVJQ6XC.js +1 -0
- package/dist/client/assets/rel-C3B-1QV4.js +1 -0
- package/dist/client/assets/riscv-BM1_JUlF.js +1 -0
- package/dist/client/assets/ron-D8l8udqQ.js +1 -0
- package/dist/client/assets/rose-pine-dawn-DHQR4-dF.js +1 -0
- package/dist/client/assets/rose-pine-moon-D4_iv3hh.js +1 -0
- package/dist/client/assets/rose-pine-qdsjHGoJ.js +1 -0
- package/dist/client/assets/rosmsg-BJDFO7_C.js +1 -0
- package/dist/client/assets/rst-BrH8l1NY.js +1 -0
- package/dist/client/assets/ruby-Dw2BHqvy.js +1 -0
- package/dist/client/assets/rust-B1yitclQ.js +1 -0
- package/dist/client/assets/sas-cz2c8ADy.js +1 -0
- package/dist/client/assets/sass-Cj5Yp3dK.js +1 -0
- package/dist/client/assets/scala-C151Ov-r.js +1 -0
- package/dist/client/assets/scheme-C98Dy4si.js +1 -0
- package/dist/client/assets/scss-OYdSNvt2.js +1 -0
- package/dist/client/assets/sdbl-DVxCFoDh.js +1 -0
- package/dist/client/assets/shaderlab-Dg9Lc6iA.js +1 -0
- package/dist/client/assets/shellscript-Yzrsuije.js +1 -0
- package/dist/client/assets/shellsession-BADoaaVG.js +1 -0
- package/dist/client/assets/slack-dark-BthQWCQV.js +1 -0
- package/dist/client/assets/slack-ochin-DqwNpetd.js +1 -0
- package/dist/client/assets/smalltalk-BERRCDM3.js +1 -0
- package/dist/client/assets/snazzy-light-Bw305WKR.js +1 -0
- package/dist/client/assets/solarized-dark-DXbdFlpD.js +1 -0
- package/dist/client/assets/solarized-light-L9t79GZl.js +1 -0
- package/dist/client/assets/solidity-rGO070M0.js +1 -0
- package/dist/client/assets/soy-Brmx7dQM.js +1 -0
- package/dist/client/assets/sparql-rVzFXLq3.js +1 -0
- package/dist/client/assets/splunk-BtCnVYZw.js +1 -0
- package/dist/client/assets/sql-BLtJtn59.js +1 -0
- package/dist/client/assets/ssh-config-_ykCGR6B.js +1 -0
- package/dist/client/assets/stata-BH5u7GGu.js +1 -0
- package/dist/client/assets/stylus-BEDo0Tqx.js +1 -0
- package/dist/client/assets/surrealql-Bq5Q-fJD.js +1 -0
- package/dist/client/assets/svelte-C_ipcX3V.js +1 -0
- package/dist/client/assets/swift-D82vCrfD.js +1 -0
- package/dist/client/assets/synthwave-84-CbfX1IO0.js +1 -0
- package/dist/client/assets/system-verilog-CnnmHF94.js +1 -0
- package/dist/client/assets/systemd-4A_iFExJ.js +1 -0
- package/dist/client/assets/talonscript-CkByrt1z.js +1 -0
- package/dist/client/assets/tasl-QIJgUcNo.js +1 -0
- package/dist/client/assets/tcl-dwOrl1Do.js +1 -0
- package/dist/client/assets/templ-P3uqSqPl.js +1 -0
- package/dist/client/assets/terraform-BETggiCN.js +1 -0
- package/dist/client/assets/tex-idrVyKtj.js +1 -0
- package/dist/client/assets/tokyo-night-hegEt444.js +1 -0
- package/dist/client/assets/toml-vGWfd6FD.js +1 -0
- package/dist/client/assets/ts-tags-zn1MmPIZ.js +1 -0
- package/dist/client/assets/tsv-B_m7g4N7.js +1 -0
- package/dist/client/assets/tsx-COt5Ahok.js +1 -0
- package/dist/client/assets/turtle-BsS91CYL.js +1 -0
- package/dist/client/assets/twig-DNn4PbVi.js +1 -0
- package/dist/client/assets/typescript-BPQ3VLAy.js +1 -0
- package/dist/client/assets/typespec-BGHnOYBU.js +1 -0
- package/dist/client/assets/typst-DHCkPAjA.js +1 -0
- package/dist/client/assets/v-BcVCzyr7.js +1 -0
- package/dist/client/assets/vala-CsfeWuGM.js +1 -0
- package/dist/client/assets/vb-D17OF-Vu.js +1 -0
- package/dist/client/assets/verilog-BQ8w6xss.js +1 -0
- package/dist/client/assets/vesper-DU1UobuO.js +1 -0
- package/dist/client/assets/vhdl-CeAyd5Ju.js +1 -0
- package/dist/client/assets/viml-CJc9bBzg.js +1 -0
- package/dist/client/assets/vitesse-black-Bkuqu6BP.js +1 -0
- package/dist/client/assets/vitesse-dark-D0r3Knsf.js +1 -0
- package/dist/client/assets/vitesse-light-CVO1_9PV.js +1 -0
- package/dist/client/assets/vue-DN_0RTcg.js +1 -0
- package/dist/client/assets/vue-html-AaS7Mt5G.js +1 -0
- package/dist/client/assets/vue-vine-CQOfvN7w.js +1 -0
- package/dist/client/assets/vyper-CDx5xZoG.js +1 -0
- package/dist/client/assets/wasm-CG6Dc4jp.js +1 -0
- package/dist/client/assets/wasm-MzD3tlZU.js +1 -0
- package/dist/client/assets/wenyan-BV7otONQ.js +1 -0
- package/dist/client/assets/wgsl-Dx-B1_4e.js +1 -0
- package/dist/client/assets/wikitext-BhOHFoWU.js +1 -0
- package/dist/client/assets/wit-5i3qLPDT.js +1 -0
- package/dist/client/assets/wolfram-lXgVvXCa.js +1 -0
- package/dist/client/assets/xml-sdJ4AIDG.js +1 -0
- package/dist/client/assets/xsl-CtQFsRM5.js +1 -0
- package/dist/client/assets/yaml-Buea-lGh.js +1 -0
- package/dist/client/assets/zenscript-DVFEvuxE.js +1 -0
- package/dist/client/assets/zig-VOosw3JB.js +1 -0
- package/dist/client/chat-sounds/Blow.mp3 +0 -0
- package/dist/client/chat-sounds/Bottle.mp3 +0 -0
- package/dist/client/chat-sounds/Frog.mp3 +0 -0
- package/dist/client/chat-sounds/Funk.mp3 +0 -0
- package/dist/client/chat-sounds/Glass.mp3 +0 -0
- package/dist/client/chat-sounds/Ping.mp3 +0 -0
- package/dist/client/chat-sounds/Pop.mp3 +0 -0
- package/dist/client/chat-sounds/Purr.mp3 +0 -0
- package/dist/client/chat-sounds/Tink.mp3 +0 -0
- package/dist/client/editor-icons/cursor.png +0 -0
- package/dist/client/editor-icons/custom.png +0 -0
- package/dist/client/editor-icons/default-app.png +0 -0
- package/dist/client/editor-icons/finder.png +0 -0
- package/dist/client/editor-icons/preview.png +0 -0
- package/dist/client/editor-icons/terminal.png +0 -0
- package/dist/client/editor-icons/windsurf.png +0 -0
- package/dist/client/editor-icons/xcode.png +0 -0
- package/dist/client/favicon.png +0 -0
- package/dist/client/fonts/body-medium.woff2 +0 -0
- package/dist/client/fonts/body-regular-italic.woff2 +0 -0
- package/dist/client/fonts/body-regular.woff2 +0 -0
- package/dist/client/fonts/body-semibold.woff2 +0 -0
- package/dist/client/icon-192.png +0 -0
- package/dist/client/icon-512.png +0 -0
- package/dist/client/icon-maskable-512.png +0 -0
- package/dist/client/icon.svg +4 -0
- package/dist/client/index.html +34 -0
- package/dist/client/manifest.webmanifest +46 -0
- package/dist/client/screenshot-light.png +0 -0
- package/dist/client/screenshot.png +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-latin-ext-wght-normal-CcLUaPy7.woff2 +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-latin-wght-normal-DLoelf7F.woff2 +0 -0
- package/dist/export-viewer/assets/bricolage-grotesque-vietnamese-wght-normal-BUzh504Q.woff2 +0 -0
- package/dist/export-viewer/assets/index-D1qUumZR.js +410 -0
- package/dist/export-viewer/assets/index-gG2nMW51.css +1 -0
- package/dist/export-viewer/editor-icons/cursor.png +0 -0
- package/dist/export-viewer/editor-icons/custom.png +0 -0
- package/dist/export-viewer/editor-icons/default-app.png +0 -0
- package/dist/export-viewer/editor-icons/finder.png +0 -0
- package/dist/export-viewer/editor-icons/preview.png +0 -0
- package/dist/export-viewer/editor-icons/terminal.png +0 -0
- package/dist/export-viewer/editor-icons/windsurf.png +0 -0
- package/dist/export-viewer/editor-icons/xcode.png +0 -0
- package/dist/export-viewer/fonts/body-medium.woff2 +0 -0
- package/dist/export-viewer/fonts/body-regular-italic.woff2 +0 -0
- package/dist/export-viewer/fonts/body-regular.woff2 +0 -0
- package/dist/export-viewer/fonts/body-semibold.woff2 +0 -0
- package/dist/export-viewer/index.html +14 -0
- package/package.json +99 -0
- package/src/server/__fixtures__/claude-session-empty.jsonl +0 -0
- package/src/server/__fixtures__/claude-session-malformed.jsonl +3 -0
- package/src/server/__fixtures__/claude-session-valid.jsonl +6 -0
- package/src/server/agent.test.ts +2369 -0
- package/src/server/agent.ts +1927 -0
- package/src/server/analytics.test.ts +313 -0
- package/src/server/analytics.ts +131 -0
- package/src/server/app-settings.test.ts +233 -0
- package/src/server/app-settings.ts +548 -0
- package/src/server/auth.test.ts +329 -0
- package/src/server/auth.ts +204 -0
- package/src/server/auto-continue/e2e.test.ts +215 -0
- package/src/server/auto-continue/events.test.ts +30 -0
- package/src/server/auto-continue/events.ts +35 -0
- package/src/server/auto-continue/limit-detector.test.ts +153 -0
- package/src/server/auto-continue/limit-detector.ts +159 -0
- package/src/server/auto-continue/read-model.test.ts +109 -0
- package/src/server/auto-continue/read-model.ts +83 -0
- package/src/server/auto-continue/schedule-manager.test.ts +155 -0
- package/src/server/auto-continue/schedule-manager.ts +116 -0
- package/src/server/claude-session-importer.test.ts +214 -0
- package/src/server/claude-session-importer.ts +187 -0
- package/src/server/claude-session-mapper.test.ts +88 -0
- package/src/server/claude-session-mapper.ts +106 -0
- package/src/server/claude-session-parser.test.ts +38 -0
- package/src/server/claude-session-parser.ts +67 -0
- package/src/server/claude-session-scanner.test.ts +49 -0
- package/src/server/claude-session-scanner.ts +24 -0
- package/src/server/claude-session-types.ts +61 -0
- package/src/server/cli-runtime.test.ts +523 -0
- package/src/server/cli-runtime.ts +405 -0
- package/src/server/cli-supervisor.ts +102 -0
- package/src/server/cli.ts +64 -0
- package/src/server/cloudflare-tunnel/agent-integration.test.ts +76 -0
- package/src/server/cloudflare-tunnel/agent-integration.ts +55 -0
- package/src/server/cloudflare-tunnel/detector.test.ts +72 -0
- package/src/server/cloudflare-tunnel/detector.ts +44 -0
- package/src/server/cloudflare-tunnel/e2e.test.ts +194 -0
- package/src/server/cloudflare-tunnel/events.test.ts +43 -0
- package/src/server/cloudflare-tunnel/events.ts +31 -0
- package/src/server/cloudflare-tunnel/gateway.ts +143 -0
- package/src/server/cloudflare-tunnel/lifecycle.test.ts +48 -0
- package/src/server/cloudflare-tunnel/lifecycle.ts +62 -0
- package/src/server/cloudflare-tunnel/read-model.test.ts +69 -0
- package/src/server/cloudflare-tunnel/read-model.ts +80 -0
- package/src/server/cloudflare-tunnel/tunnel-manager.test.ts +116 -0
- package/src/server/cloudflare-tunnel/tunnel-manager.ts +165 -0
- package/src/server/codex-app-server-protocol.ts +487 -0
- package/src/server/codex-app-server.test.ts +1816 -0
- package/src/server/codex-app-server.ts +1475 -0
- package/src/server/diff-store.test.ts +737 -0
- package/src/server/diff-store.ts +2199 -0
- package/src/server/discovery.test.ts +211 -0
- package/src/server/discovery.ts +301 -0
- package/src/server/event-store.test.ts +797 -0
- package/src/server/event-store.ts +1421 -0
- package/src/server/events.ts +217 -0
- package/src/server/external-open.test.ts +112 -0
- package/src/server/external-open.ts +345 -0
- package/src/server/generate-commit-message.test.ts +79 -0
- package/src/server/generate-commit-message.ts +126 -0
- package/src/server/generate-title.ts +76 -0
- package/src/server/harness-types.ts +19 -0
- package/src/server/keybindings.test.ts +144 -0
- package/src/server/keybindings.ts +178 -0
- package/src/server/llm-provider.test.ts +134 -0
- package/src/server/llm-provider.ts +207 -0
- package/src/server/machine-name.ts +22 -0
- package/src/server/paths-route.test.ts +64 -0
- package/src/server/paths.ts +35 -0
- package/src/server/process-utils.test.ts +12 -0
- package/src/server/process-utils.ts +47 -0
- package/src/server/project-paths.test.ts +95 -0
- package/src/server/project-paths.ts +191 -0
- package/src/server/provider-catalog.test.ts +69 -0
- package/src/server/provider-catalog.ts +87 -0
- package/src/server/quick-response.test.ts +440 -0
- package/src/server/quick-response.ts +300 -0
- package/src/server/read-models.test.ts +509 -0
- package/src/server/read-models.ts +230 -0
- package/src/server/restart.test.ts +27 -0
- package/src/server/restart.ts +30 -0
- package/src/server/server.ts +616 -0
- package/src/server/share.test.ts +180 -0
- package/src/server/share.ts +150 -0
- package/src/server/standalone-export.test.ts +224 -0
- package/src/server/standalone-export.ts +419 -0
- package/src/server/terminal-manager.test.ts +315 -0
- package/src/server/terminal-manager.ts +350 -0
- package/src/server/test-helpers/async-event-queue.ts +52 -0
- package/src/server/test-helpers/wait-for.ts +14 -0
- package/src/server/title-generation.live.test.ts +44 -0
- package/src/server/update-manager.test.ts +158 -0
- package/src/server/update-manager.ts +222 -0
- package/src/server/update-strategy.test.ts +237 -0
- package/src/server/update-strategy.ts +241 -0
- package/src/server/uploads.test.ts +292 -0
- package/src/server/uploads.ts +131 -0
- package/src/server/ws-router.test.ts +2292 -0
- package/src/server/ws-router.ts +1465 -0
- package/src/shared/analytics.ts +30 -0
- package/src/shared/branding.test.ts +31 -0
- package/src/shared/branding.ts +77 -0
- package/src/shared/dev-ports.test.ts +113 -0
- package/src/shared/dev-ports.ts +134 -0
- package/src/shared/ports.ts +2 -0
- package/src/shared/protocol.ts +257 -0
- package/src/shared/share.ts +27 -0
- package/src/shared/tools.test.ts +164 -0
- package/src/shared/tools.ts +327 -0
- package/src/shared/types.test.ts +25 -0
- 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(""report" <draft>.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
|
+
})
|