create-harness-vibe-coding 0.8.19 → 0.9.3
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/CHANGELOG.md +49 -0
- package/README-CN.md +4 -2
- package/README.md +23 -4
- package/package.json +17 -2
- package/src/generator.js +19 -6
- package/src/index.js +531 -38
- package/src/prompts.js +4 -4
- package/src/ui/dist/assets/AgentsRoute-BGGs90wH.js +7 -0
- package/src/ui/dist/assets/Assistant-Bold-gm-uSS1B.woff2 +0 -0
- package/src/ui/dist/assets/Assistant-Medium-DrcxCXg3.woff2 +0 -0
- package/src/ui/dist/assets/Assistant-Regular-DVxZuzxb.woff2 +0 -0
- package/src/ui/dist/assets/Assistant-SemiBold-SCI4bEL9.woff2 +0 -0
- package/src/ui/dist/assets/Combination-DhBLEmof.js +42 -0
- package/src/ui/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/src/ui/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/src/ui/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/src/ui/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/src/ui/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/src/ui/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/src/ui/dist/assets/RolesRoute-DnodCSz0.js +2 -0
- package/src/ui/dist/assets/RuntimePicker-CucD8TGn.js +7 -0
- package/src/ui/dist/assets/SettingsRoute-DquMWjac.js +2 -0
- package/src/ui/dist/assets/TaskList-Bxc0Uktx.js +7 -0
- package/src/ui/dist/assets/TerminalDrawer-CDusYltc.js +36 -0
- package/src/ui/dist/assets/WorkflowRoute-BN8yHmUs.css +1 -0
- package/src/ui/dist/assets/WorkflowRoute-D-0wVDpY.js +334 -0
- package/src/ui/dist/assets/abnfDiagram-VRR7QNED-zkRKkaxO.js +2 -0
- package/src/ui/dist/assets/apl-B4CMkyY2.js +2 -0
- package/src/ui/dist/assets/ar-SA-G6X2FPQ2-BiHaArx5.js +11 -0
- package/src/ui/dist/assets/arc-CBnLtOFO.js +2 -0
- package/src/ui/dist/assets/architectureDiagram-ZJ3FMSHR-DWXRXsh1.js +37 -0
- package/src/ui/dist/assets/asciiarmor-Df11BRmG.js +2 -0
- package/src/ui/dist/assets/asn1-EdZsLKOL.js +2 -0
- package/src/ui/dist/assets/asterisk-B-8jnY81.js +2 -0
- package/src/ui/dist/assets/az-AZ-76LH7QW2-CiT05voO.js +2 -0
- package/src/ui/dist/assets/bg-BG-XCXSNQG7-L-UM9tNf.js +6 -0
- package/src/ui/dist/assets/blockDiagram-677ZJIJ3-BIyh3Bry.js +133 -0
- package/src/ui/dist/assets/bn-BD-2XOGV67Q-NdgbA0bC.js +6 -0
- package/src/ui/dist/assets/brainfuck-C4LP7Hcl.js +2 -0
- package/src/ui/dist/assets/c4Diagram-LMCZKHZV-CA54vWWM.js +11 -0
- package/src/ui/dist/assets/ca-ES-6MX7JW3Y-BKZZsQ5J.js +9 -0
- package/src/ui/dist/assets/channel-Da_-zcAu.js +2 -0
- package/src/ui/dist/assets/chunk-2Q5K7J3B-DE4cxr1v.js +2 -0
- package/src/ui/dist/assets/chunk-32BRIVSS-i7FwbEU9.js +2 -0
- package/src/ui/dist/assets/chunk-5VM5RSS4-BhIcOYSu.js +16 -0
- package/src/ui/dist/assets/chunk-EX3LRPZG-Ci9zZRIs.js +232 -0
- package/src/ui/dist/assets/chunk-JWPE2WC7-BFdkcz-G.js +2 -0
- package/src/ui/dist/assets/chunk-MOJQB5TN-Dzmapl_S.js +89 -0
- package/src/ui/dist/assets/chunk-RYQCIY6F-RTmp7FOy.js +2 -0
- package/src/ui/dist/assets/chunk-V7JOEXUC-DInU3v3D.js +207 -0
- package/src/ui/dist/assets/chunk-VR4S4FIN-BHoyGBQO.js +2 -0
- package/src/ui/dist/assets/chunk-XXDRQBXY-DfeLZNZx.js +2 -0
- package/src/ui/dist/assets/classDiagram-OUVF2IWQ-FKbUS0bu.js +2 -0
- package/src/ui/dist/assets/classDiagram-v2-EOCWNBFH-FKbUS0bu.js +2 -0
- package/src/ui/dist/assets/clike-B9uivgTg.js +2 -0
- package/src/ui/dist/assets/clojure-BMjYHr_A.js +2 -0
- package/src/ui/dist/assets/cmake-BQqOBYOt.js +2 -0
- package/src/ui/dist/assets/cobol-CWcv1MsR.js +2 -0
- package/src/ui/dist/assets/coffeescript-S37ZYGWr.js +2 -0
- package/src/ui/dist/assets/commonlisp-DBKNyK5s.js +2 -0
- package/src/ui/dist/assets/cose-bilkent-JH36ORCC-_5PUJ615.js +2 -0
- package/src/ui/dist/assets/crystal-SjHAIU92.js +2 -0
- package/src/ui/dist/assets/cs-CZ-2BRQDIVT-DnkfnhDU.js +12 -0
- package/src/ui/dist/assets/css-BnMrqG3P.js +2 -0
- package/src/ui/dist/assets/cynefin-VYW2F7L2-CmkcFv-b.js +179 -0
- package/src/ui/dist/assets/cynefinDiagram-TSTJHNR4-D72VZn8H.js +63 -0
- package/src/ui/dist/assets/cypher-C_CwsFkJ.js +2 -0
- package/src/ui/dist/assets/cytoscape.esm-DTSO7Bv0.js +332 -0
- package/src/ui/dist/assets/d-pRatUO7H.js +2 -0
- package/src/ui/dist/assets/da-DK-5WZEPLOC-C54tQUgd.js +6 -0
- package/src/ui/dist/assets/dagre-VKFMJZFB-0BsAkrmo.js +5 -0
- package/src/ui/dist/assets/de-DE-XR44H4JA-DGfZlR10.js +9 -0
- package/src/ui/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/src/ui/dist/assets/diagram-FQU43EPY-C1ZJDOca.js +4 -0
- package/src/ui/dist/assets/diagram-G47NLZAW-D3pr3FSP.js +25 -0
- package/src/ui/dist/assets/diagram-NH7WQ7WH-Bwry0NV-.js +25 -0
- package/src/ui/dist/assets/diagram-OA4YK3LP-L6g09cmV.js +31 -0
- package/src/ui/dist/assets/diagram-WEI45ONY-Bh1puYVX.js +42 -0
- package/src/ui/dist/assets/diff-DbItnlRl.js +2 -0
- package/src/ui/dist/assets/directory-open-01563666-DWU9wJ6I.js +2 -0
- package/src/ui/dist/assets/directory-open-4ed118d0-CunoC1EB.js +2 -0
- package/src/ui/dist/assets/dockerfile-BKs6k2Af.js +2 -0
- package/src/ui/dist/assets/dtd-DF_7sFjM.js +2 -0
- package/src/ui/dist/assets/dylan-DwRh75JA.js +2 -0
- package/src/ui/dist/assets/ebnf-CDyGwa7X.js +2 -0
- package/src/ui/dist/assets/ebnfDiagram-CCIWWBDH-BC_mBRR5.js +2 -0
- package/src/ui/dist/assets/ecl-Cabwm37j.js +2 -0
- package/src/ui/dist/assets/eiffel-CnydiIhH.js +2 -0
- package/src/ui/dist/assets/el-GR-BZB4AONW-DsnwWweL.js +11 -0
- package/src/ui/dist/assets/elm-vLlmbW-K.js +2 -0
- package/src/ui/dist/assets/erDiagram-Q63AITRT-Bkxam36l.js +86 -0
- package/src/ui/dist/assets/erlang-BNw1qcRV.js +2 -0
- package/src/ui/dist/assets/es-ES-U4NZUMDT-CRxBcQgC.js +10 -0
- package/src/ui/dist/assets/eu-ES-A7QVB2H4-CTd_E7YR.js +12 -0
- package/src/ui/dist/assets/excalidraw-default-bMWIgNCy.svg +1 -0
- package/src/ui/dist/assets/fa-IR-HGAKTJCU-DO56k7eG.js +9 -0
- package/src/ui/dist/assets/factor-kuTfRLto.js +2 -0
- package/src/ui/dist/assets/fcl-Kvtd6kyn.js +2 -0
- package/src/ui/dist/assets/fi-FI-Z5N7JZ37-DEDCF9WB.js +7 -0
- package/src/ui/dist/assets/file-open-002ab408-DIuFHtCF.js +2 -0
- package/src/ui/dist/assets/file-open-7c801643-684qeFg4.js +2 -0
- package/src/ui/dist/assets/file-save-3189631c-C1wFhQhH.js +2 -0
- package/src/ui/dist/assets/file-save-745eba88-Bb9F9Kg7.js +2 -0
- package/src/ui/dist/assets/flowDiagram-23GEKE2U-C3SAEao7.js +157 -0
- package/src/ui/dist/assets/forth-Ffai-XNe.js +2 -0
- package/src/ui/dist/assets/fortran-DYz_wnZ1.js +2 -0
- package/src/ui/dist/assets/fr-FR-RHASNOE6-DE2nPyDt.js +10 -0
- package/src/ui/dist/assets/ganttDiagram-NO4QXBWP-U-J_Xi-_.js +293 -0
- package/src/ui/dist/assets/gas-Bneqetm1.js +2 -0
- package/src/ui/dist/assets/gherkin-heZmZLOM.js +2 -0
- package/src/ui/dist/assets/gitGraphDiagram-IHSO6WYX-BADVbD-a.js +107 -0
- package/src/ui/dist/assets/gl-ES-HMX3MZ6V-mCqVgQHC.js +11 -0
- package/src/ui/dist/assets/graph-C9eacEi8.js +2 -0
- package/src/ui/dist/assets/groovy-D9Dt4D0W.js +2 -0
- package/src/ui/dist/assets/haskell-Cw1EW3IL.js +2 -0
- package/src/ui/dist/assets/haxe-H-WmDvRZ.js +2 -0
- package/src/ui/dist/assets/he-IL-6SHJWFNN-KnaLrCzw.js +11 -0
- package/src/ui/dist/assets/hi-IN-IWLTKZ5I-wuqiaaWt.js +5 -0
- package/src/ui/dist/assets/http-DBlCnlav.js +2 -0
- package/src/ui/dist/assets/hu-HU-A5ZG7DT2-DLg_zm22.js +8 -0
- package/src/ui/dist/assets/id-ID-SAP4L64H-MB2mXqcR.js +11 -0
- package/src/ui/dist/assets/idl-BEugSyMb.js +2 -0
- package/src/ui/dist/assets/image-blob-reduce.esm-HwCm7IZ0.js +8 -0
- package/src/ui/dist/assets/index-39SJtrt0.js +2 -0
- package/src/ui/dist/assets/index-BQ0LIrW-.js +2 -0
- package/src/ui/dist/assets/index-BcAUh2Nm.css +1 -0
- package/src/ui/dist/assets/index-BcThOK-i.js +331 -0
- package/src/ui/dist/assets/index-BnxV3k_S.js +3 -0
- package/src/ui/dist/assets/index-C7MZBHbT.js +117 -0
- package/src/ui/dist/assets/index-CCvKI2rM.js +2 -0
- package/src/ui/dist/assets/index-CZvmcCab.js +8 -0
- package/src/ui/dist/assets/index-Cp3hLKNl.js +2 -0
- package/src/ui/dist/assets/index-D7ayFxcx.js +2 -0
- package/src/ui/dist/assets/index-DAYxSQGU.js +2 -0
- package/src/ui/dist/assets/index-DD-HeMV0.css +1 -0
- package/src/ui/dist/assets/index-DPMxd3Qn.js +2 -0
- package/src/ui/dist/assets/index-DSe2jx3k.js +4 -0
- package/src/ui/dist/assets/index-DUirCkgW.js +2 -0
- package/src/ui/dist/assets/index-DaKPQ2hN.js +159 -0
- package/src/ui/dist/assets/index-DzpuR0Jg.js +2 -0
- package/src/ui/dist/assets/index-POndwsCI.js +2 -0
- package/src/ui/dist/assets/index-R8NM9I_9.js +2 -0
- package/src/ui/dist/assets/index-TCDuDhBj.js +2 -0
- package/src/ui/dist/assets/index-TckzASdM.js +2 -0
- package/src/ui/dist/assets/index-Yi5ytFFr.js +2 -0
- package/src/ui/dist/assets/infoDiagram-FWYZ7A6U-CiGBlFqH.js +3 -0
- package/src/ui/dist/assets/init-Gi6I4Gst.js +2 -0
- package/src/ui/dist/assets/ishikawaDiagram-FXEZZL3T-B1aheRbz.js +71 -0
- package/src/ui/dist/assets/it-IT-JPQ66NNP-DT3HSU7X.js +12 -0
- package/src/ui/dist/assets/ja-JP-DBVTYXUO-CsfJjjWt.js +9 -0
- package/src/ui/dist/assets/javascript-iXu5QeM3.js +2 -0
- package/src/ui/dist/assets/journeyDiagram-5HDEW3XC-DtnJPxub.js +140 -0
- package/src/ui/dist/assets/julia-DuME0IfC.js +2 -0
- package/src/ui/dist/assets/kaa-6HZHGXH3-DQz-1-Gn.js +2 -0
- package/src/ui/dist/assets/kab-KAB-ZGHBKWFO-CaH-LSoK.js +9 -0
- package/src/ui/dist/assets/kanban-definition-HUTT4EX6-CZatSm3e.js +90 -0
- package/src/ui/dist/assets/katex-C5jXJg4s.js +258 -0
- package/src/ui/dist/assets/kk-KZ-P5N5QNE5-BMXmcm55.js +2 -0
- package/src/ui/dist/assets/km-KH-HSX4SM5Z-ZeoJNBpN.js +12 -0
- package/src/ui/dist/assets/ko-KR-MTYHY66A-BeaLRKph.js +10 -0
- package/src/ui/dist/assets/ku-TR-6OUDTVRD-B_28KrkR.js +10 -0
- package/src/ui/dist/assets/layout-DEXfKzaS.js +2 -0
- package/src/ui/dist/assets/linear-CpEDnQRC.js +2 -0
- package/src/ui/dist/assets/livescript-BwQOo05w.js +2 -0
- package/src/ui/dist/assets/lt-LT-XHIRWOB4-BxDcnUhp.js +4 -0
- package/src/ui/dist/assets/lua-VAEuO923.js +2 -0
- package/src/ui/dist/assets/lv-LV-5QDEKY6T-DpKDl1_y.js +8 -0
- package/src/ui/dist/assets/map-Czzmt4hB.js +2 -0
- package/src/ui/dist/assets/mathematica-DTrFuWx2.js +2 -0
- package/src/ui/dist/assets/mbox-CNhZ1qSd.js +2 -0
- package/src/ui/dist/assets/mermaidLoader-CBO31t0J.js +305 -0
- package/src/ui/dist/assets/mermaidLoader-CEK31ho9.css +1 -0
- package/src/ui/dist/assets/mindmap-definition-LN4V7U3C-CQ0TccEr.js +97 -0
- package/src/ui/dist/assets/mirc-CjQqDB4T.js +2 -0
- package/src/ui/dist/assets/mllike-CXdrOF99.js +2 -0
- package/src/ui/dist/assets/modelica-Dc1JOy9r.js +2 -0
- package/src/ui/dist/assets/mr-IN-CRQNXWMA-BKCZT58p.js +14 -0
- package/src/ui/dist/assets/mscgen-BA5vi2Kp.js +2 -0
- package/src/ui/dist/assets/mumps-BT43cFF4.js +2 -0
- package/src/ui/dist/assets/my-MM-5M5IBNSE-D34k0G9T.js +2 -0
- package/src/ui/dist/assets/nb-NO-T6EIAALU-DXr_KW8J.js +11 -0
- package/src/ui/dist/assets/nginx-DdIZxoE0.js +2 -0
- package/src/ui/dist/assets/nl-NL-IS3SIHDZ-C1L0aVDR.js +9 -0
- package/src/ui/dist/assets/nn-NO-6E72VCQL-CLWVSxbZ.js +9 -0
- package/src/ui/dist/assets/nsis-LdVXkNf5.js +2 -0
- package/src/ui/dist/assets/ntriples-BfvgReVJ.js +2 -0
- package/src/ui/dist/assets/oc-FR-POXYY2M6-nf8_J3eR.js +9 -0
- package/src/ui/dist/assets/octave-Ck1zUtKM.js +2 -0
- package/src/ui/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/src/ui/dist/assets/oz-BzwKVEFT.js +2 -0
- package/src/ui/dist/assets/pa-IN-N4M65BXN-CAMJ-xd6.js +5 -0
- package/src/ui/dist/assets/pascal--L3eBynH.js +2 -0
- package/src/ui/dist/assets/pegDiagram-2B236MQR-Bj2RcmRd.js +2 -0
- package/src/ui/dist/assets/percentages-BXMCSKIN-s9yWFdr6.js +160 -0
- package/src/ui/dist/assets/perl-CdXCOZ3F.js +2 -0
- package/src/ui/dist/assets/pica-BGv_AhAB.js +8 -0
- package/src/ui/dist/assets/pieDiagram-ENE6RG2P-obpzz8dC.js +40 -0
- package/src/ui/dist/assets/pig-CevX1Tat.js +2 -0
- package/src/ui/dist/assets/pl-PL-T2D74RX3-DKJ5H85v.js +10 -0
- package/src/ui/dist/assets/powershell-CFHJl5sT.js +2 -0
- package/src/ui/dist/assets/properties-C78fOPTZ.js +2 -0
- package/src/ui/dist/assets/protobuf-ChK-085T.js +2 -0
- package/src/ui/dist/assets/{proxy-D0vUbds5.js → proxy-BSmwef4H.js} +2 -2
- package/src/ui/dist/assets/pt-BR-5N22H2LF-B5kow-kN.js +10 -0
- package/src/ui/dist/assets/pt-PT-UZXXM6DQ-B2G34fso.js +10 -0
- package/src/ui/dist/assets/pug-DeIclll2.js +2 -0
- package/src/ui/dist/assets/puppet-DMA9R1ak.js +2 -0
- package/src/ui/dist/assets/python-BuPzkPfP.js +2 -0
- package/src/ui/dist/assets/q-pXgVlZs6.js +2 -0
- package/src/ui/dist/assets/quadrantDiagram-ABIIQ3AL-Cl-T7NkJ.js +8 -0
- package/src/ui/dist/assets/r-B6wPVr8A.js +2 -0
- package/src/ui/dist/assets/railroadDiagram-RFXS5EU6-DMq82RHg.js +2 -0
- package/src/ui/dist/assets/{refresh-cw-JNmExijd.js → refresh-cw-CbFd7guV.js} +2 -2
- package/src/ui/dist/assets/requirementDiagram-TGXJPOKE-tGInMDgU.js +85 -0
- package/src/ui/dist/assets/ro-RO-JPDTUUEW-BwLdZW91.js +12 -0
- package/src/ui/dist/assets/roundRect-0PYZxl1G.js +2 -0
- package/src/ui/dist/assets/rpm-CTu-6PCP.js +2 -0
- package/src/ui/dist/assets/ru-RU-B4JR7IUQ-DpdAgIJc.js +10 -0
- package/src/ui/dist/assets/ruby-B2Rjki9n.js +2 -0
- package/src/ui/dist/assets/runtimeBrand-BoKPimsz.js +2 -0
- package/src/ui/dist/assets/sankeyDiagram-HTMAVEWB-C7X-ET4m.js +41 -0
- package/src/ui/dist/assets/sas-B4kiWyti.js +2 -0
- package/src/ui/dist/assets/save-D1CXHwBw.js +12 -0
- package/src/ui/dist/assets/scheme-C41bIUwD.js +2 -0
- package/src/ui/dist/assets/sequenceDiagram-DBY2YBRQ-Bn24i_nY.js +163 -0
- package/src/ui/dist/assets/settings-2-DtSNAAcU.js +22 -0
- package/src/ui/dist/assets/shell-CjFT_Tl9.js +2 -0
- package/src/ui/dist/assets/shield-check-BVbJ8T_N.js +7 -0
- package/src/ui/dist/assets/si-LK-N5RQ5JYF-naJaUy5u.js +2 -0
- package/src/ui/dist/assets/sieve-C3Gn_uJK.js +2 -0
- package/src/ui/dist/assets/simple-mode-GW_nhZxv.js +2 -0
- package/src/ui/dist/assets/sizeCapture-X5ZJPWSS-DAQHfm82.js +2 -0
- package/src/ui/dist/assets/sk-SK-C5VTKIMK-1NPz1J_d.js +7 -0
- package/src/ui/dist/assets/sl-SI-NN7IZMDC-D8RfmNQw.js +7 -0
- package/src/ui/dist/assets/smalltalk-CnHTOXQT.js +2 -0
- package/src/ui/dist/assets/solr-DehyRSwq.js +2 -0
- package/src/ui/dist/assets/sparql-DkYu6x3z.js +2 -0
- package/src/ui/dist/assets/spreadsheet-BCZA_wO0.js +2 -0
- package/src/ui/dist/assets/sql-D0XecflT.js +2 -0
- package/src/ui/dist/assets/stateDiagram-2N3HPSRC-Ck48D1ck.js +2 -0
- package/src/ui/dist/assets/stateDiagram-v2-6OUMAXLB-CdfRCJAJ.js +2 -0
- package/src/ui/dist/assets/stex-C3f8Ysf7.js +2 -0
- package/src/ui/dist/assets/style-C3QI7VPf.css +1 -0
- package/src/ui/dist/assets/stylus-B533Al4x.js +2 -0
- package/src/ui/dist/assets/subset-shared.chunk-eAtLHmbi.js +85 -0
- package/src/ui/dist/assets/subset-worker.chunk-DeHCTnTk.js +2 -0
- package/src/ui/dist/assets/sv-SE-XGPEYMSR-BeOYCU-8.js +11 -0
- package/src/ui/dist/assets/swift-BzpIVaGY.js +2 -0
- package/src/ui/dist/assets/swimlanes-5IMT3BWC-DOy22UjP.js +3 -0
- package/src/ui/dist/assets/swimlanesDiagram-G3AALYLV-sicPbVOg.js +9 -0
- package/src/ui/dist/assets/ta-IN-2NMHFXQM-CtS68WDd.js +10 -0
- package/src/ui/dist/assets/tcl-DVfN8rqt.js +2 -0
- package/src/ui/dist/assets/{terminal-BBQvnOAK.js → terminal-DEby5Y_P.js} +2 -2
- package/src/ui/dist/assets/terminalControl-BkFeDVXo.js +134 -0
- package/src/ui/dist/assets/textile-CnDTJFAw.js +2 -0
- package/src/ui/dist/assets/th-TH-HPSO5L25-DCHY9-Co.js +3 -0
- package/src/ui/dist/assets/tiddlywiki-DO-Gjzrf.js +2 -0
- package/src/ui/dist/assets/tiki-DGYXhP31.js +2 -0
- package/src/ui/dist/assets/timeline-definition-FHXFAJF6-DVkG72qO.js +121 -0
- package/src/ui/dist/assets/toml-Bm5Em-hy.js +2 -0
- package/src/ui/dist/assets/tr-TR-DEFEU3FU-y5-vjTv8.js +8 -0
- package/src/ui/dist/assets/troff-wAsdV37c.js +2 -0
- package/src/ui/dist/assets/ttcn-CfJYG6tj.js +2 -0
- package/src/ui/dist/assets/ttcn-cfg-B9xdYoR4.js +2 -0
- package/src/ui/dist/assets/turtle-B1tBg_DP.js +2 -0
- package/src/ui/dist/assets/uk-UA-QMV73CPH-BRbR5It6.js +7 -0
- package/src/ui/dist/assets/useReducedMotion-D07XP52Q.js +12 -0
- package/src/ui/dist/assets/vb-CmGdzxic.js +2 -0
- package/src/ui/dist/assets/vbscript-BuJXcnF6.js +2 -0
- package/src/ui/dist/assets/velocity-D8B20fx6.js +2 -0
- package/src/ui/dist/assets/vennDiagram-L72KCM5P-DwMwBlf-.js +35 -0
- package/src/ui/dist/assets/verilog-C6RDOZhf.js +2 -0
- package/src/ui/dist/assets/vhdl-lSbBsy5d.js +2 -0
- package/src/ui/dist/assets/vi-VN-M7AON7JQ-s6wSBv2s.js +6 -0
- package/src/ui/dist/assets/wardleyDiagram-EHGQE667-CNuuvzEg.js +79 -0
- package/src/ui/dist/assets/webidl-ZXfAyPTL.js +2 -0
- package/src/ui/dist/assets/{x-DoIOGAJz.js → x-B84wq78k.js} +2 -2
- package/src/ui/dist/assets/xquery-DzFWVndE.js +2 -0
- package/src/ui/dist/assets/xychartDiagram-FW5EYKEG-dUpZxmn6.js +8 -0
- package/src/ui/dist/assets/yacas-BJ4BC0dw.js +2 -0
- package/src/ui/dist/assets/z80-Hz9HOZM7.js +2 -0
- package/src/ui/dist/assets/zh-CN-LNUGB5OW-Cu_-POQN.js +11 -0
- package/src/ui/dist/assets/zh-HK-E62DVLB3-BCx4W4qZ.js +2 -0
- package/src/ui/dist/assets/zh-TW-RAJ6MFWO-02wx_Mrj.js +10 -0
- package/src/ui/dist/icons/excalidraw.svg +1 -0
- package/src/ui/dist/icons/markdown.svg +1 -0
- package/src/ui/dist/index.html +2 -2
- package/src/ui/package.json +10 -0
- package/src/ui/playwright.config.ts +36 -0
- package/src/ui/pnpm-workspace.yaml +2 -0
- package/src/ui/public/icons/excalidraw.svg +1 -0
- package/src/ui/public/icons/markdown.svg +1 -0
- package/src/ui/tsconfig.json +2 -1
- package/src/ui/vite.config.ts +1 -0
- package/src/wf-ui-server/__tests__/a2a-store.test.mjs +432 -15
- package/src/wf-ui-server/__tests__/action-registry-loader.test.mjs +156 -0
- package/src/wf-ui-server/__tests__/claude-stream-json.test.mjs +397 -0
- package/src/wf-ui-server/__tests__/codex-appserver.test.mjs +392 -0
- package/src/wf-ui-server/__tests__/component-node-api.test.mjs +881 -0
- package/src/wf-ui-server/__tests__/component-node-store.test.mjs +401 -0
- package/src/wf-ui-server/__tests__/control-plane-acceptance.test.mjs +2027 -0
- package/src/wf-ui-server/__tests__/control-plane-cli-smoke.test.mjs +1014 -0
- package/src/wf-ui-server/__tests__/dispatch-lifecycle.test.mjs +330 -0
- package/src/wf-ui-server/__tests__/effort-compatibility-red-092.test.mjs +151 -0
- package/src/wf-ui-server/__tests__/graph-layout-agent-tree.test.mjs +254 -0
- package/src/wf-ui-server/__tests__/graph-layout-tidy.test.mjs +260 -0
- package/src/wf-ui-server/__tests__/graph-layout.test.mjs +84 -0
- package/src/wf-ui-server/__tests__/node-config-api.test.mjs +384 -0
- package/src/wf-ui-server/__tests__/opencode-server.test.mjs +526 -0
- package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +2 -2
- package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +71 -1
- package/src/wf-ui-server/__tests__/runtime-config.test.mjs +3 -3
- package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +21 -0
- package/src/wf-ui-server/__tests__/runtime-review.test.mjs +828 -0
- package/src/wf-ui-server/__tests__/server.integration.test.mjs +1463 -11
- package/src/wf-ui-server/__tests__/session-cleanup.test.mjs +159 -0
- package/src/wf-ui-server/__tests__/session-registry.test.mjs +52 -3
- package/src/wf-ui-server/__tests__/settings.test.mjs +12 -2
- package/src/wf-ui-server/__tests__/spawn-gate.test.mjs +78 -0
- package/src/wf-ui-server/__tests__/task-group-parser.test.mjs +120 -0
- package/src/wf-ui-server/__tests__/task-group-tag.test.mjs +264 -0
- package/src/wf-ui-server/__tests__/task-parser.test.mjs +49 -3
- package/src/wf-ui-server/__tests__/terminal-context-hardening.test.mjs +122 -0
- package/src/wf-ui-server/__tests__/terminal-context.test.mjs +164 -0
- package/src/wf-ui-server/__tests__/terminal-store.test.mjs +4 -4
- package/src/wf-ui-server/__tests__/wf-browser-api.test.mjs +599 -0
- package/src/wf-ui-server/__tests__/wf-browser-store.test.mjs +184 -0
- package/src/wf-ui-server/__tests__/workflow-action-registry.test.mjs +282 -0
- package/src/wf-ui-server/__tests__/workflow-agent-communication.test.mjs +411 -0
- package/src/wf-ui-server/__tests__/workflow-agent-context.test.mjs +323 -0
- package/src/wf-ui-server/__tests__/workflow-agent-cooperation-permissions.test.mjs +318 -0
- package/src/wf-ui-server/__tests__/workflow-agent-messages-structured.test.mjs +458 -0
- package/src/wf-ui-server/__tests__/workflow-agent-routing.test.mjs +434 -0
- package/src/wf-ui-server/__tests__/workflow-agent-runtime.test.mjs +324 -0
- package/src/wf-ui-server/__tests__/workflow-api-integration.test.mjs +263 -0
- package/src/wf-ui-server/__tests__/workflow-api-phase3.test.mjs +1213 -0
- package/src/wf-ui-server/__tests__/workflow-claude-materialize.test.mjs +209 -0
- package/src/wf-ui-server/__tests__/workflow-cli-registry-manuals.test.mjs +173 -0
- package/src/wf-ui-server/__tests__/workflow-cli-surface.test.mjs +176 -0
- package/src/wf-ui-server/__tests__/workflow-codex-resume-id.test.mjs +556 -0
- package/src/wf-ui-server/__tests__/workflow-cooperation-enforcement.test.mjs +553 -0
- package/src/wf-ui-server/__tests__/workflow-depth-resume-dock.test.mjs +846 -0
- package/src/wf-ui-server/__tests__/workflow-display-node.test.mjs +150 -0
- package/src/wf-ui-server/__tests__/workflow-edge-update.test.mjs +296 -0
- package/src/wf-ui-server/__tests__/workflow-file-cache-lock-watch.test.mjs +380 -0
- package/src/wf-ui-server/__tests__/workflow-file-formats.test.mjs +994 -0
- package/src/wf-ui-server/__tests__/workflow-file-node-actions.test.mjs +294 -0
- package/src/wf-ui-server/__tests__/workflow-goal-actions.test.mjs +357 -0
- package/src/wf-ui-server/__tests__/workflow-goal-task-autoclose.test.mjs +256 -0
- package/src/wf-ui-server/__tests__/workflow-graceful-stop.test.mjs +108 -0
- package/src/wf-ui-server/__tests__/workflow-graph-undo.test.mjs +524 -0
- package/src/wf-ui-server/__tests__/workflow-layout.test.mjs +732 -0
- package/src/wf-ui-server/__tests__/workflow-magnetic-topology.test.mjs +222 -0
- package/src/wf-ui-server/__tests__/workflow-markdown-lock.test.mjs +359 -0
- package/src/wf-ui-server/__tests__/workflow-mcp-hub.test.mjs +174 -0
- package/src/wf-ui-server/__tests__/workflow-node-manual-schema.test.mjs +241 -0
- package/src/wf-ui-server/__tests__/workflow-node-runtime.test.mjs +1991 -0
- package/src/wf-ui-server/__tests__/workflow-node-store-utils.test.mjs +125 -0
- package/src/wf-ui-server/__tests__/workflow-registry-drift.test.mjs +296 -0
- package/src/wf-ui-server/__tests__/workflow-resource-handle-four-side.test.mjs +217 -0
- package/src/wf-ui-server/__tests__/workflow-role-profile.test.mjs +234 -0
- package/src/wf-ui-server/__tests__/workflow-set-model.test.mjs +261 -0
- package/src/wf-ui-server/__tests__/workflow-skill-group-context-enabled.test.mjs +131 -0
- package/src/wf-ui-server/__tests__/workflow-skill-group-enable-disable.test.mjs +186 -0
- package/src/wf-ui-server/__tests__/workflow-skills-hub.test.mjs +125 -0
- package/src/wf-ui-server/__tests__/workflow-state-reconcile.test.mjs +139 -0
- package/src/wf-ui-server/__tests__/workflow-subagent-strategy-matrix.test.mjs +529 -0
- package/src/wf-ui-server/__tests__/workflow-team-flow.test.mjs +278 -0
- package/src/wf-ui-server/__tests__/workflow-terminal-control-live.test.mjs +294 -0
- package/src/wf-ui-server/__tests__/workflow-terminal-control.test.mjs +631 -0
- package/src/wf-ui-server/__tests__/workflow-timer-wakeup.test.mjs +318 -0
- package/src/wf-ui-server/__tests__/workflow-unified-resume.test.mjs +537 -0
- package/src/wf-ui-server/__tests__/workspace-api-hardening.test.mjs +187 -0
- package/src/wf-ui-server/__tests__/workspace-api.test.mjs +382 -0
- package/src/wf-ui-server/__tests__/workspace-reveal.test.mjs +138 -0
- package/src/wf-ui-server/__tests__/workspace-store-hardening.test.mjs +255 -0
- package/src/wf-ui-server/__tests__/workspace-store.test.mjs +208 -0
- package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +52 -25
- package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +12 -6
- package/src/wf-ui-server/__tests__/ws-wf-browser.integration.test.mjs +488 -0
- package/src/wf-ui-server/a2a-store.mjs +3007 -89
- package/src/wf-ui-server/action-registry.mjs +277 -0
- package/src/wf-ui-server/bridge-store.mjs +276 -0
- package/src/wf-ui-server/chat-driver.mjs +220 -0
- package/src/wf-ui-server/chat-store.mjs +109 -0
- package/src/wf-ui-server/claude-transcript-materializer.mjs +235 -0
- package/src/wf-ui-server/codex-update-prompt.mjs +53 -0
- package/src/wf-ui-server/component-node-store.mjs +689 -0
- package/src/wf-ui-server/composition-api.mjs +436 -0
- package/src/wf-ui-server/control-plane-token.mjs +17 -0
- package/src/wf-ui-server/dispatch-contract.mjs +150 -0
- package/src/wf-ui-server/drivers/claude-stream-json.mjs +371 -0
- package/src/wf-ui-server/drivers/codex-appserver.mjs +513 -0
- package/src/wf-ui-server/drivers/opencode-server.mjs +486 -0
- package/src/wf-ui-server/excalidraw-svg.mjs +141 -0
- package/src/wf-ui-server/file-format-adapters.mjs +511 -0
- package/src/wf-ui-server/file-watcher.mjs +215 -0
- package/src/wf-ui-server/graph-layout.mjs +778 -0
- package/src/wf-ui-server/harness-env.mjs +46 -0
- package/src/wf-ui-server/json-store-utils.mjs +48 -0
- package/src/wf-ui-server/model-capability.mjs +773 -0
- package/src/wf-ui-server/node-config-store.mjs +233 -0
- package/src/wf-ui-server/node-store-utils.mjs +88 -0
- package/src/wf-ui-server/peer-capsule.mjs +56 -1
- package/src/wf-ui-server/pty-adapter.mjs +513 -11
- package/src/wf-ui-server/reveal-foreground.mjs +69 -0
- package/src/wf-ui-server/runtime-detector.mjs +52 -6
- package/src/wf-ui-server/server.mjs +5613 -403
- package/src/wf-ui-server/session-cleanup.mjs +363 -0
- package/src/wf-ui-server/session-registry.mjs +179 -3
- package/src/wf-ui-server/settings.mjs +25 -0
- package/src/wf-ui-server/spawn-gate.mjs +46 -0
- package/src/wf-ui-server/task-parser.mjs +88 -3
- package/src/wf-ui-server/terminal-store.mjs +225 -11
- package/src/wf-ui-server/timer-wakeup-scheduler.mjs +226 -0
- package/src/wf-ui-server/wf-browser-store.mjs +733 -0
- package/src/wf-ui-server/workflow-agent-context.mjs +1731 -0
- package/src/wf-ui-server/workflow-capability-node-store.mjs +676 -0
- package/src/wf-ui-server/workflow-event-node-store.mjs +623 -0
- package/src/wf-ui-server/workflow-goal-node-store.mjs +328 -0
- package/src/wf-ui-server/workflow-graph-store.mjs +623 -0
- package/src/wf-ui-server/workflow-mcp-hub.mjs +286 -0
- package/src/wf-ui-server/workflow-node-runtime.mjs +1767 -0
- package/src/wf-ui-server/workflow-node-settings-store.mjs +339 -0
- package/src/wf-ui-server/workflow-node-types/agent-node.mjs +920 -0
- package/src/wf-ui-server/workflow-node-types/display-node.mjs +151 -0
- package/src/wf-ui-server/workflow-node-types/excalidraw-node.mjs +145 -0
- package/src/wf-ui-server/workflow-node-types/file-node.mjs +484 -0
- package/src/wf-ui-server/workflow-node-types/github-trigger-node.mjs +207 -0
- package/src/wf-ui-server/workflow-node-types/goal-node.mjs +491 -0
- package/src/wf-ui-server/workflow-node-types/markdown-node.mjs +225 -0
- package/src/wf-ui-server/workflow-node-types/mcp-connector-node.mjs +71 -0
- package/src/wf-ui-server/workflow-node-types/role-profile-store.mjs +191 -0
- package/src/wf-ui-server/workflow-node-types/skill-group-node.mjs +98 -0
- package/src/wf-ui-server/workflow-node-types/timer-node.mjs +607 -0
- package/src/wf-ui-server/workflow-ontology.mjs +681 -0
- package/src/wf-ui-server/workflow-operation-store.mjs +115 -0
- package/src/wf-ui-server/workflow-skills-hub.mjs +366 -0
- package/src/wf-ui-server/workflow-skills-market.mjs +723 -0
- package/src/wf-ui-server/workspace-store.mjs +951 -0
- package/src/wf-ui-server/ws-chat.mjs +299 -0
- package/src/wf-ui-server/ws-events.mjs +45 -20
- package/src/wf-ui-server/ws-terminal.mjs +165 -22
- package/src/wf-ui-server/ws-wf-browser.mjs +419 -0
- package/templates/common/.claude/agents/review-manager.md +33 -29
- package/templates/common/.claude/commands/wf-browser.md +1 -1
- package/templates/common/.claude/commands/wf-help.md +5 -3
- package/templates/common/.claude/commands/wf-init.md +44 -0
- package/templates/common/.claude/commands/wf-learn.md +34 -8
- package/templates/common/.claude/commands/wf-max.md +19 -9
- package/templates/common/.claude/commands/wf-review.md +3 -3
- package/templates/common/.claude/commands/wf-search.md +55 -0
- package/templates/common/.claude/commands/wf-task-archive.md +5 -4
- package/templates/common/.claude/commands/wf-task-list.md +6 -3
- package/templates/common/.claude/commands/wf-task-record.md +10 -2
- package/templates/common/.claude/commands/wf-ui.md +12 -5
- package/templates/common/.claude/commands/wf-update.md +4 -0
- package/templates/common/.claude/commands/wf.md +12 -1
- package/templates/common/.claude/rules/ecc/common.md +10 -1
- package/templates/common/.claude/skills/wf/SKILL.md +36 -0
- package/templates/common/.claude/skills/wf-browser/SKILL.md +427 -122
- package/templates/common/.claude/skills/wf-browser/references/BROWSER_CONTROL_PATTERNS.md +43 -0
- package/templates/common/.claude/skills/wf-init/SKILL.md +33 -0
- package/templates/common/.claude/skills/wf-learn/SKILL.md +106 -26
- package/templates/common/.claude/skills/wf-max/SKILL.md +45 -11
- package/templates/common/.claude/skills/wf-review/SKILL.md +26 -54
- package/templates/common/.claude/skills/wf-search/SKILL.md +122 -0
- package/templates/common/.claude/skills/wf-search/references/evidence-contract.md +77 -0
- package/templates/common/.claude/skills/wf-search/references/policies/compare.md +38 -0
- package/templates/common/.claude/skills/wf-search/references/policies/fact.md +39 -0
- package/templates/common/.claude/skills/wf-search/references/policies/troubleshoot.md +39 -0
- package/templates/common/.claude/skills/wf-search/references/policies/verify.md +44 -0
- package/templates/common/.claude/skills/wf-search/references/source-routing.md +40 -0
- package/templates/common/.claude/skills/wf-search/references/tool-adapters.md +38 -0
- package/templates/common/.claude/skills/wf-ui/SKILL.md +146 -12
- package/templates/common/.claude/skills/wf-update/SKILL.md +5 -1
- package/templates/common/.harness-version +188 -85
- package/templates/common/.opencode/agents/review-manager.md +31 -27
- package/templates/common/.opencode/commands/wf-browser.md +1 -1
- package/templates/common/.opencode/commands/wf-help.md +5 -3
- package/templates/common/.opencode/commands/wf-init.md +44 -0
- package/templates/common/.opencode/commands/wf-learn.md +34 -8
- package/templates/common/.opencode/commands/wf-max.md +19 -9
- package/templates/common/.opencode/commands/wf-review.md +3 -3
- package/templates/common/.opencode/commands/wf-search.md +55 -0
- package/templates/common/.opencode/commands/wf-task-archive.md +5 -7
- package/templates/common/.opencode/commands/wf-task-list.md +6 -6
- package/templates/common/.opencode/commands/wf-task-record.md +10 -5
- package/templates/common/.opencode/commands/wf-ui.md +12 -5
- package/templates/common/.opencode/commands/wf-update.md +4 -0
- package/templates/common/.opencode/commands/wf.md +12 -1
- package/templates/common/CLAUDE.md +61 -3
- package/templates/common/Harness/MEMORY.md +8 -5
- package/templates/common/Harness/README.md +14 -10
- package/templates/common/Harness/a2a/action-registry.json +4397 -0
- package/templates/common/Harness/a2a/role-graph.json +16 -3
- package/templates/common/Harness/a2a/skills/terminal-control.json +45 -1
- package/templates/common/Harness/a2a/skills/wf-ui-map.json +50 -0
- package/templates/common/Harness/a2a/skills/workflow-agent-node.json +143 -0
- package/templates/common/Harness/a2a/skills/workflow-context.json +95 -0
- package/templates/common/Harness/a2a/skills/workflow-diagram-node.json +60 -0
- package/templates/common/Harness/a2a/skills/workflow-display-node.json +69 -0
- package/templates/common/Harness/a2a/skills/workflow-file-node.json +92 -0
- package/templates/common/Harness/a2a/skills/workflow-goal-node.json +76 -0
- package/templates/common/Harness/a2a/skills/workflow-markdown-node.json +79 -0
- package/templates/common/Harness/a2a/skills/workflow-mcp-connector-node.json +59 -0
- package/templates/common/Harness/a2a/skills/workflow-node-actions.json +105 -0
- package/templates/common/Harness/a2a/skills/workflow-node-map.json +71 -0
- package/templates/common/Harness/a2a/skills/workflow-ontology.json +54 -0
- package/templates/common/Harness/a2a/skills/workflow-resource-node.json +68 -0
- package/templates/common/Harness/a2a/skills/workflow-skill-group-node.json +56 -0
- package/templates/common/Harness/a2a/skills/workflow-timer-node.json +81 -0
- package/templates/common/Harness/a2a/workflows.json +33 -0
- package/templates/common/Harness/ownership.manifest.json +263 -2
- package/templates/common/Harness/project/architecture.md +499 -0
- package/templates/common/Harness/research/README.md +20 -0
- package/templates/common/Harness/scripts/README.md +70 -6
- package/templates/common/Harness/scripts/a2a-terminal.mjs +12 -4
- package/templates/common/Harness/scripts/context-budget.mjs +7 -3
- package/templates/common/Harness/scripts/lib/memory-context.mjs +400 -0
- package/templates/common/Harness/scripts/lib/path-scope.mjs +77 -0
- package/templates/common/Harness/scripts/lib/research-policy.mjs +99 -0
- package/templates/common/Harness/scripts/lib/wf-search.mjs +189 -0
- package/templates/common/Harness/scripts/memory-context.mjs +63 -0
- package/templates/common/Harness/scripts/research-policy.mjs +27 -0
- package/templates/common/Harness/scripts/task-context.mjs +547 -0
- package/templates/common/Harness/scripts/task-group-index.mjs +242 -0
- package/templates/common/Harness/scripts/task-state.mjs +659 -95
- package/templates/common/Harness/scripts/validate-harness.mjs +70 -28
- package/templates/common/Harness/scripts/wf-auto-update-prompt.mjs +24 -0
- package/templates/common/Harness/scripts/wf-remove.mjs +10 -0
- package/templates/common/Harness/scripts/wf-search.mjs +10 -0
- package/templates/common/Harness/scripts/wf-ui-control.mjs +3144 -0
- package/templates/common/Harness/scripts/wf-update-check.mjs +274 -51
- package/templates/common/Harness/scripts/wf-update-runner.mjs +53 -16
- package/templates/common/Harness/settings.json +10 -0
- package/templates/common/Harness/specs/guides/SETUP.md +4 -3
- package/templates/common/Harness/specs/protocols/HARNESS_BRIDGE.md +429 -48
- package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +24 -0
- package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +14 -11
- package/templates/common/Harness/specs/runtime/command-surface.json +28 -0
- package/templates/common/Harness/specs/runtime/context-loading.md +6 -0
- package/templates/common/Harness/specs/runtime/dispatch.md +9 -1
- package/templates/common/Harness/specs/runtime/subagents.md +20 -5
- package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +19 -0
- package/templates/common/Harness/specs/workflows/WF-AUTO.md +21 -0
- package/templates/common/Harness/specs/workflows/WF-KERNEL.md +4 -2
- package/templates/common/Harness/specs/workflows/WF-MAX.md +39 -12
- package/templates/common/Harness/specs/workflows/WF-STATE.md +62 -14
- package/templates/common/Harness/specs/workflows/WF.md +32 -0
- package/templates/common/Harness/tasks/INDEX.json +7 -0
- package/templates/common/Harness/tasks/INDEX.md +12 -0
- package/templates/common/Harness/tasks/_template/PLAN.md +6 -0
- package/templates/common/Harness/tasks/_template/PROGRESS.md +5 -0
- package/templates/common/Harness/tasks/_template/STATE.json +22 -0
- package/templates/common/Harness/templates/TEST_PLAN.template.md +5 -4
- package/templates/common/Harness/templates/UI_CONTRACT.template.md +7 -5
- package/templates/common/Harness/wf-browser/README.md +69 -0
- package/templates/common/README.md +17 -0
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +0 -17
- package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +0 -1
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +0 -7
- package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +0 -1
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +0 -12
- package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +0 -1
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js +0 -50
- package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +0 -1
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +0 -74
- package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +0 -1
- package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +0 -1
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +0 -39
- package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +0 -1
- package/src/ui/dist/assets/index-BOVYWntB.js +0 -117
- package/src/ui/dist/assets/index-BOVYWntB.js.map +0 -1
- package/src/ui/dist/assets/index-DaUObq0H.css +0 -1
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +0 -7
- package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +0 -1
- package/src/ui/dist/assets/plus-QuTJyoT3.js +0 -7
- package/src/ui/dist/assets/plus-QuTJyoT3.js.map +0 -1
- package/src/ui/dist/assets/proxy-D0vUbds5.js.map +0 -1
- package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +0 -1
- package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +0 -1
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +0 -7
- package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +0 -1
- package/src/ui/dist/assets/x-DoIOGAJz.js.map +0 -1
- package/src/ui/pnpm-lock.yaml +0 -1582
- package/src/ui/src/App.tsx +0 -86
- package/src/ui/src/api.ts +0 -93
- package/src/ui/src/components/AgentsRoute.tsx +0 -503
- package/src/ui/src/components/Footer.tsx +0 -69
- package/src/ui/src/components/Header.tsx +0 -175
- package/src/ui/src/components/LoadingView.tsx +0 -22
- package/src/ui/src/components/RolesRoute.tsx +0 -169
- package/src/ui/src/components/SettingsRoute.tsx +0 -166
- package/src/ui/src/components/TaskList.tsx +0 -388
- package/src/ui/src/components/TerminalDrawer.tsx +0 -611
- package/src/ui/src/components/WorkflowRoute.tsx +0 -670
- package/src/ui/src/hooks/useReducedMotion.ts +0 -17
- package/src/ui/src/hooks/useServerConnection.ts +0 -114
- package/src/ui/src/index.css +0 -281
- package/src/ui/src/main.tsx +0 -11
- package/src/ui/src/types.ts +0 -108
- /package/src/ui/dist/assets/{TerminalDrawer-6GBZ9nXN.css → terminalControl-6GBZ9nXN.css} +0 -0
|
@@ -0,0 +1,2027 @@
|
|
|
1
|
+
// control-plane-acceptance.test.mjs
|
|
2
|
+
//
|
|
3
|
+
// Layer 1 backend in-process integration acceptance suite for the full
|
|
4
|
+
// control-plane matrix:
|
|
5
|
+
// - Agent API matrix (A1-A8): sessions, snapshots, edges, input,
|
|
6
|
+
// output, 1-to-1 / 1-to-many messages, reply aggregation
|
|
7
|
+
// - Timer-Agent matrix (T1-T8): edge wakeup, magnetic-group wakeup,
|
|
8
|
+
// multi-agent fan-out, Goal ref on wakeup, Goal does NOT wake, loop bound,
|
|
9
|
+
// stop/disable cleanup, single-Goal-per-group rejection
|
|
10
|
+
// - Functional Node matrix (N1-N15): markdown find/read/lock/conflict,
|
|
11
|
+
// file meta/text/refresh, excalidraw scene read/save/patch/invalid,
|
|
12
|
+
// goal state machine, agent unified API, timer policy + internal actions
|
|
13
|
+
// - Prompt / Manual matrix (P1-P6): init.md identity, workflow context,
|
|
14
|
+
// markdown/diagram/timer/goal manual injection, jargon-free language
|
|
15
|
+
// - E2E chain (E1): create/connect agents -> shared markdown
|
|
16
|
+
// -> structured request -> replies -> timer wakeup -> goal check
|
|
17
|
+
//
|
|
18
|
+
// Pattern: node:test + assert/strict, in-process HTTP server on port 0,
|
|
19
|
+
// temp roots from makeHarnessTempRoot(), fake PTYs via registerPtyProcess.
|
|
20
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
import { makeHarnessTempRoot } from '../../../tests/support/temp-root.js';
|
|
26
|
+
import { startServer, stopServer } from '../server.mjs';
|
|
27
|
+
import { SessionRegistry } from '../session-registry.mjs';
|
|
28
|
+
import { registerPtyProcess, unregisterPtyProcess } from '../ws-terminal.mjs';
|
|
29
|
+
import {
|
|
30
|
+
persistSession,
|
|
31
|
+
readTerminalRange,
|
|
32
|
+
appendTerminalData,
|
|
33
|
+
} from '../terminal-store.mjs';
|
|
34
|
+
import {
|
|
35
|
+
loadWorkflowGraphMap,
|
|
36
|
+
writeWorkflowGraphMap,
|
|
37
|
+
} from '../a2a-store.mjs';
|
|
38
|
+
import { executeNodeAction } from '../workflow-node-runtime.mjs';
|
|
39
|
+
import { listBridgeMessages } from '../bridge-store.mjs';
|
|
40
|
+
import {
|
|
41
|
+
isTimerSchedulerActive,
|
|
42
|
+
stopTimerScheduler,
|
|
43
|
+
syncTimerScheduler,
|
|
44
|
+
} from '../timer-wakeup-scheduler.mjs';
|
|
45
|
+
|
|
46
|
+
// ── HTTP helper ──
|
|
47
|
+
function jsonRequest(baseUrl, route, { method = 'GET', body, token = '' } = {}) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const url = new URL(route, baseUrl);
|
|
50
|
+
const payload = body ? JSON.stringify(body) : null;
|
|
51
|
+
const req = http.request({
|
|
52
|
+
hostname: url.hostname,
|
|
53
|
+
port: url.port,
|
|
54
|
+
path: `${url.pathname}${url.search}`,
|
|
55
|
+
method,
|
|
56
|
+
headers: {
|
|
57
|
+
...(payload ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) } : {}),
|
|
58
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
59
|
+
},
|
|
60
|
+
}, (res) => {
|
|
61
|
+
let data = '';
|
|
62
|
+
res.on('data', chunk => { data += chunk; });
|
|
63
|
+
res.on('end', () => {
|
|
64
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(data) }); }
|
|
65
|
+
catch { resolve({ status: res.statusCode, body: data }); }
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
req.on('error', reject);
|
|
69
|
+
if (payload) req.write(payload);
|
|
70
|
+
req.end();
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Project seeding helpers ──
|
|
75
|
+
function seedRoot(prefix) {
|
|
76
|
+
const root = makeHarnessTempRoot(prefix);
|
|
77
|
+
fs.mkdirSync(path.join(root, 'Harness', 'tasks'), { recursive: true });
|
|
78
|
+
fs.mkdirSync(path.join(root, 'Harness', 'a2a'), { recursive: true });
|
|
79
|
+
return root;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function seedAgentSession(root, nodeId, sessionId, overrides = {}) {
|
|
83
|
+
persistSession(root, {
|
|
84
|
+
sessionId,
|
|
85
|
+
graphNodeId: nodeId,
|
|
86
|
+
runtime: overrides.runtime || 'claude',
|
|
87
|
+
agentKind: overrides.agentKind || 'subagent',
|
|
88
|
+
role: overrides.role || 'Agent',
|
|
89
|
+
roleTitle: overrides.roleTitle || '',
|
|
90
|
+
displayName: overrides.displayName || overrides.roleTitle || '',
|
|
91
|
+
status: 'running',
|
|
92
|
+
attachMode: true,
|
|
93
|
+
taskId: null,
|
|
94
|
+
...overrides,
|
|
95
|
+
});
|
|
96
|
+
const graph = loadWorkflowGraphMap(root);
|
|
97
|
+
writeWorkflowGraphMap(root, {
|
|
98
|
+
...graph,
|
|
99
|
+
version: graph.version + 1,
|
|
100
|
+
nodes: [
|
|
101
|
+
...(graph.nodes || []).filter(n => (n.nodeId || n.id) !== nodeId),
|
|
102
|
+
{
|
|
103
|
+
nodeId,
|
|
104
|
+
sessionId,
|
|
105
|
+
kind: 'terminal-session',
|
|
106
|
+
runtime: overrides.runtime || 'claude',
|
|
107
|
+
agentKind: overrides.agentKind || 'subagent',
|
|
108
|
+
role: overrides.role || 'Agent',
|
|
109
|
+
label: overrides.label || overrides.displayName || overrides.role || 'Agent',
|
|
110
|
+
status: 'running',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
return { nodeId, sessionId };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writeGraphEdges(root, edges) {
|
|
118
|
+
const graph = loadWorkflowGraphMap(root);
|
|
119
|
+
writeWorkflowGraphMap(root, {
|
|
120
|
+
...graph,
|
|
121
|
+
version: graph.version + 1,
|
|
122
|
+
edges: [...(graph.edges || []), ...edges],
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function dockLink(a, b) {
|
|
127
|
+
const pair = [a, b].sort();
|
|
128
|
+
return {
|
|
129
|
+
id: `dock:${pair[0]}::${pair[1]}`,
|
|
130
|
+
nodeIds: pair,
|
|
131
|
+
anchorId: pair[0],
|
|
132
|
+
draggedId: pair[1],
|
|
133
|
+
side: 'top',
|
|
134
|
+
edges: [],
|
|
135
|
+
connections: [
|
|
136
|
+
{
|
|
137
|
+
source: a,
|
|
138
|
+
target: b,
|
|
139
|
+
relation: 'wf-bridge',
|
|
140
|
+
direction: 'source-to-target',
|
|
141
|
+
sourceHandle: 'dock',
|
|
142
|
+
targetHandle: 'dock',
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function seedActiveTask(root, taskId = 'task-cp-acceptance') {
|
|
149
|
+
fs.mkdirSync(path.join(root, 'Harness', 'tasks', taskId), { recursive: true });
|
|
150
|
+
fs.writeFileSync(path.join(root, 'Harness', 'PROGRESS.md'), `# PROGRESS.md\n\n## Active Task\n\n- ${taskId}\n`, 'utf8');
|
|
151
|
+
fs.writeFileSync(path.join(root, 'Harness', 'tasks', taskId, 'STATE.json'), JSON.stringify({
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
taskId,
|
|
154
|
+
status: 'active',
|
|
155
|
+
title: 'Control plane goal',
|
|
156
|
+
nextAction: 'Complete the control plane matrix',
|
|
157
|
+
phase: 'implement',
|
|
158
|
+
acceptance: [],
|
|
159
|
+
planItems: [],
|
|
160
|
+
links: { dependsOn: [], blocks: [], related: [] },
|
|
161
|
+
}));
|
|
162
|
+
fs.writeFileSync(path.join(root, 'Harness', 'tasks', taskId, 'PLAN.md'), '# Plan\n', 'utf8');
|
|
163
|
+
fs.writeFileSync(path.join(root, 'Harness', 'tasks', taskId, 'PROGRESS.md'), '# Progress\n', 'utf8');
|
|
164
|
+
return `goal-${taskId}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function pastIso(seconds = 2) {
|
|
168
|
+
return new Date(Date.now() - seconds * 1000).toISOString();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function futureIso(seconds = 3600) {
|
|
172
|
+
return new Date(Date.now() + seconds * 1000).toISOString();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function waitFor(fn, { timeout = 5000, step = 25 } = {}) {
|
|
176
|
+
const deadline = Date.now() + timeout;
|
|
177
|
+
let last;
|
|
178
|
+
while (Date.now() < deadline) {
|
|
179
|
+
last = await fn();
|
|
180
|
+
if (last) return last;
|
|
181
|
+
await new Promise(resolve => setTimeout(resolve, step));
|
|
182
|
+
}
|
|
183
|
+
throw new Error(`waitFor timed out; last=${JSON.stringify(last)}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Suite ──
|
|
187
|
+
describe('Layer 1 Control-Plane Acceptance', () => {
|
|
188
|
+
let root;
|
|
189
|
+
let registry;
|
|
190
|
+
let server;
|
|
191
|
+
let baseUrl;
|
|
192
|
+
const registeredPties = [];
|
|
193
|
+
|
|
194
|
+
beforeEach(async () => {
|
|
195
|
+
root = seedRoot('cp-accept-');
|
|
196
|
+
registry = new SessionRegistry();
|
|
197
|
+
const started = await startServer({
|
|
198
|
+
projectRoot: root,
|
|
199
|
+
host: '127.0.0.1',
|
|
200
|
+
port: 0,
|
|
201
|
+
sessionRegistry: registry,
|
|
202
|
+
eventsWs: false,
|
|
203
|
+
});
|
|
204
|
+
server = started.server;
|
|
205
|
+
baseUrl = `http://127.0.0.1:${started.port}`;
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
afterEach(async () => {
|
|
209
|
+
for (const sessionId of registeredPties.splice(0)) {
|
|
210
|
+
unregisterPtyProcess(sessionId);
|
|
211
|
+
}
|
|
212
|
+
stopTimerScheduler();
|
|
213
|
+
if (server) {
|
|
214
|
+
await stopServer(server);
|
|
215
|
+
server = null;
|
|
216
|
+
}
|
|
217
|
+
if (root) {
|
|
218
|
+
fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
219
|
+
root = null;
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Suite-local helpers ──
|
|
224
|
+
function registerPty(sessionId, writes) {
|
|
225
|
+
registerPtyProcess(sessionId, { write: data => writes.push(String(data)) });
|
|
226
|
+
registeredPties.push(sessionId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function createAgentViaApi(runtime = 'claude', agentKind = 'subagent', role = 'Subagent', roleTitle = 'implementer', extra = {}) {
|
|
230
|
+
const res = await jsonRequest(baseUrl, '/api/sessions', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
body: {
|
|
233
|
+
runtime,
|
|
234
|
+
agentKind,
|
|
235
|
+
role,
|
|
236
|
+
roleTitle,
|
|
237
|
+
displayName: roleTitle,
|
|
238
|
+
responsibility: `${roleTitle} agent`,
|
|
239
|
+
capabilities: ['terminal'],
|
|
240
|
+
objective: 'Control plane acceptance agent',
|
|
241
|
+
attachGraphNode: true,
|
|
242
|
+
deferPtySpawn: true,
|
|
243
|
+
...extra,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
247
|
+
return { sessionId: res.body.sessionId, nodeId: `session-${res.body.sessionId}`, body: res.body };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function createTimerViaApi(title, overrides = {}) {
|
|
251
|
+
const res = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
body: {
|
|
254
|
+
type: 'timer',
|
|
255
|
+
title,
|
|
256
|
+
enabled: true,
|
|
257
|
+
schedule: { mode: 'interval', intervalSeconds: 60 },
|
|
258
|
+
heartbeat: { base: { enabled: true, intervalSeconds: 60, nextDueAt: futureIso() } },
|
|
259
|
+
...overrides,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
263
|
+
return { nodeId: res.body.node.nodeId, body: res.body };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function armTimer(timerNodeId, intervalSeconds) {
|
|
267
|
+
const res = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(timerNodeId)}/actions/timer.configure`, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
body: { heartbeat: { base: { enabled: true, intervalSeconds, nextDueAt: pastIso() } } },
|
|
270
|
+
});
|
|
271
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function timerState(timerNodeId) {
|
|
275
|
+
const res = await executeNodeAction(root, timerNodeId, 'timer.read', {});
|
|
276
|
+
return res.result;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function wakeupEntries(timerNodeId, sessionId) {
|
|
280
|
+
return listBridgeMessages(root, { fromSessionId: `wakeup-${timerNodeId}`, toSessionId: sessionId }).entries;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ══════════════════════════════════════════════════════════════════
|
|
284
|
+
// Agent API Matrix (A1-A8)
|
|
285
|
+
// ══════════════════════════════════════════════════════════════════
|
|
286
|
+
describe('Agent API Matrix (A1-A8)', () => {
|
|
287
|
+
it('A1 - POST /api/sessions creates an Agent node with identity and node card', async () => {
|
|
288
|
+
const created = await jsonRequest(baseUrl, '/api/sessions', {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
body: {
|
|
291
|
+
runtime: 'claude',
|
|
292
|
+
agentKind: 'main',
|
|
293
|
+
role: 'Main Agent',
|
|
294
|
+
roleTitle: 'ceo',
|
|
295
|
+
displayName: 'Controller',
|
|
296
|
+
responsibility: 'Coordinate the worker team.',
|
|
297
|
+
capabilities: ['terminal-control', 'dispatch'],
|
|
298
|
+
objective: 'Control plane coverage',
|
|
299
|
+
attachGraphNode: true,
|
|
300
|
+
deferPtySpawn: true,
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
304
|
+
const session = created.body;
|
|
305
|
+
assert.ok(session.sessionId, 'session must carry a sessionId');
|
|
306
|
+
const nodeId = `session-${session.sessionId}`;
|
|
307
|
+
assert.equal(session.graphNodeId, nodeId, 'session must be bound to its graph node');
|
|
308
|
+
assert.equal(session.displayName, 'Controller');
|
|
309
|
+
assert.equal(session.roleTitle, 'ceo');
|
|
310
|
+
assert.equal(session.roleProfileRef, `Harness/a2a/agent-roles/${nodeId}.md`);
|
|
311
|
+
assert.equal(session.status, 'blocked', 'deferPtySpawn must keep the session blocked');
|
|
312
|
+
|
|
313
|
+
const snapshot = await jsonRequest(baseUrl, '/api/workflow');
|
|
314
|
+
assert.equal(snapshot.status, 200);
|
|
315
|
+
const graphNode = (snapshot.body.nodes || []).find(n => n.sessionId === session.sessionId);
|
|
316
|
+
assert.ok(graphNode, 'graph must contain the new Agent node');
|
|
317
|
+
assert.equal(graphNode.kind, 'terminal-session');
|
|
318
|
+
assert.equal(graphNode.graphNodeId, nodeId);
|
|
319
|
+
|
|
320
|
+
const card = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeId)}`);
|
|
321
|
+
assert.equal(card.status, 200, JSON.stringify(card.body));
|
|
322
|
+
assert.equal(card.body.node.kind, 'agent');
|
|
323
|
+
assert.equal(card.body.node.nodeId, nodeId);
|
|
324
|
+
assert.equal(card.body.node.sessionId, session.sessionId);
|
|
325
|
+
assert.ok(card.body.node.status, 'node card must carry status metadata');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('A2 - snapshot exposes nodes/edges/capsuleDockLinks and version advances on create/connect/delete', async () => {
|
|
329
|
+
const v0 = (await jsonRequest(baseUrl, '/api/a2a/snapshot')).body.snapshotVersion;
|
|
330
|
+
|
|
331
|
+
const a = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo');
|
|
332
|
+
const b = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer');
|
|
333
|
+
const v1 = (await jsonRequest(baseUrl, '/api/a2a/snapshot')).body.snapshotVersion;
|
|
334
|
+
assert.ok(v1 > v0, `creating an Agent node must advance the graph version (${v0} -> ${v1})`);
|
|
335
|
+
|
|
336
|
+
const connected = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
337
|
+
method: 'POST',
|
|
338
|
+
body: { from: a.nodeId, to: b.nodeId, relation: 'delegation', direction: 'bidirectional' },
|
|
339
|
+
});
|
|
340
|
+
assert.equal(connected.status, 201, JSON.stringify(connected.body));
|
|
341
|
+
const v2 = (await jsonRequest(baseUrl, '/api/a2a/snapshot')).body.snapshotVersion;
|
|
342
|
+
assert.ok(v2 > v1, `connecting nodes must advance the graph version (${v1} -> ${v2})`);
|
|
343
|
+
|
|
344
|
+
const snapshot = (await jsonRequest(baseUrl, '/api/a2a/snapshot')).body;
|
|
345
|
+
assert.ok(Array.isArray(snapshot.nodes), 'snapshot must expose nodes');
|
|
346
|
+
assert.ok(snapshot.nodes.some(n => n.sessionId === a.sessionId), 'snapshot must contain Agent A');
|
|
347
|
+
assert.ok(snapshot.nodes.some(n => n.sessionId === b.sessionId), 'snapshot must contain Agent B');
|
|
348
|
+
assert.equal(snapshot.edges.length, 1);
|
|
349
|
+
assert.equal(snapshot.edges[0].relation, 'delegation');
|
|
350
|
+
assert.equal(snapshot.edges[0].direction, 'bidirectional');
|
|
351
|
+
assert.ok(Array.isArray(snapshot.graph.capsuleDockLinks), 'snapshot graph must expose capsuleDockLinks');
|
|
352
|
+
|
|
353
|
+
const deleted = await jsonRequest(baseUrl, `/api/a2a/graph-nodes/${encodeURIComponent(b.nodeId)}`, { method: 'DELETE' });
|
|
354
|
+
assert.equal(deleted.status, 200, JSON.stringify(deleted.body));
|
|
355
|
+
const v3 = (await jsonRequest(baseUrl, '/api/a2a/snapshot')).body.snapshotVersion;
|
|
356
|
+
assert.ok(v3 > v2, `deleting a node must advance the graph version (${v2} -> ${v3})`);
|
|
357
|
+
const after = (await jsonRequest(baseUrl, '/api/a2a/snapshot')).body;
|
|
358
|
+
assert.ok(!after.nodes.some(n => n.sessionId === b.sessionId), 'deleted node must leave the snapshot');
|
|
359
|
+
assert.equal(after.edges.length, 0, 'edges referencing the deleted node must be cleaned up');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('A3 - connecting two Agents exposes each side in the other workflow context', async () => {
|
|
363
|
+
const a = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo');
|
|
364
|
+
const b = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer');
|
|
365
|
+
const connected = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
366
|
+
method: 'POST',
|
|
367
|
+
body: { from: a.nodeId, to: b.nodeId, relation: 'delegation', direction: 'bidirectional' },
|
|
368
|
+
});
|
|
369
|
+
assert.equal(connected.status, 201, JSON.stringify(connected.body));
|
|
370
|
+
|
|
371
|
+
const ctxA = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent(a.nodeId)}`);
|
|
372
|
+
assert.equal(ctxA.status, 200, JSON.stringify(ctxA.body));
|
|
373
|
+
const peerA = (ctxA.body.context.connectedPeers || []).find(p => p.nodeId === b.nodeId);
|
|
374
|
+
assert.ok(peerA, `A context must list B as a connected peer: ${JSON.stringify(ctxA.body.context.connectedPeers)}`);
|
|
375
|
+
assert.equal(peerA.type, 'agent');
|
|
376
|
+
assert.equal(peerA.sessionId, b.sessionId);
|
|
377
|
+
|
|
378
|
+
const ctxB = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent(b.nodeId)}`);
|
|
379
|
+
const peerB = (ctxB.body.context.connectedPeers || []).find(p => p.nodeId === a.nodeId);
|
|
380
|
+
assert.ok(peerB, 'B context must list A as a connected peer');
|
|
381
|
+
assert.equal(peerB.type, 'agent');
|
|
382
|
+
assert.equal(peerB.sessionId, a.sessionId);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('A4 - POST /api/sessions/:id/input forwards to the fake PTY and records stdin', async () => {
|
|
386
|
+
const agent = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer');
|
|
387
|
+
const writes = [];
|
|
388
|
+
registerPty(agent.sessionId, writes);
|
|
389
|
+
|
|
390
|
+
const sent = await jsonRequest(baseUrl, `/api/sessions/${agent.sessionId}/input`, {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
body: { data: 'echo hello' },
|
|
393
|
+
});
|
|
394
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.body));
|
|
395
|
+
assert.equal(sent.body.ok, true);
|
|
396
|
+
assert.deepEqual(writes, ['echo hello'], 'fake PTY must receive the input');
|
|
397
|
+
|
|
398
|
+
const range = readTerminalRange(root, { sessionId: agent.sessionId });
|
|
399
|
+
const stdinEntry = (range.entries || []).find(e => e.stream === 'stdin');
|
|
400
|
+
assert.ok(stdinEntry, 'terminal store must record the stdin write');
|
|
401
|
+
assert.equal(stdinEntry.data, 'echo hello');
|
|
402
|
+
assert.ok(stdinEntry.seq >= 1);
|
|
403
|
+
|
|
404
|
+
const requestsPath = path.join(root, 'Harness', 'a2a', 'sessions', agent.sessionId, 'input-requests.jsonl');
|
|
405
|
+
const lines = fs.readFileSync(requestsPath, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
406
|
+
const request = JSON.parse(lines[lines.length - 1]);
|
|
407
|
+
assert.equal(request.type, 'terminal.input.requested');
|
|
408
|
+
assert.equal(request.data, 'echo hello');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('A5 - GET /api/terminals/:id/range returns written entries with tail/fromSeq/toSeq', async () => {
|
|
412
|
+
const sessionId = 'session-a5-range';
|
|
413
|
+
persistSession(root, {
|
|
414
|
+
sessionId,
|
|
415
|
+
graphNodeId: 'agent-a5',
|
|
416
|
+
runtime: 'claude',
|
|
417
|
+
agentKind: 'subagent',
|
|
418
|
+
role: 'Subagent',
|
|
419
|
+
status: 'running',
|
|
420
|
+
attachMode: true,
|
|
421
|
+
taskId: null,
|
|
422
|
+
});
|
|
423
|
+
appendTerminalData(root, { sessionId, runtime: 'claude' }, 'line-one', 'stdout');
|
|
424
|
+
appendTerminalData(root, { sessionId, runtime: 'claude' }, 'line-two', 'stdout');
|
|
425
|
+
appendTerminalData(root, { sessionId, runtime: 'claude' }, 'line-three', 'stdout');
|
|
426
|
+
|
|
427
|
+
const tail = await jsonRequest(baseUrl, '/api/terminals/session-a5-range/range?tail=2');
|
|
428
|
+
assert.equal(tail.status, 200);
|
|
429
|
+
assert.equal(tail.body.entries.length, 2);
|
|
430
|
+
assert.deepEqual(tail.body.entries.map(e => e.data), ['line-two', 'line-three']);
|
|
431
|
+
|
|
432
|
+
const windowed = await jsonRequest(baseUrl, '/api/terminals/session-a5-range/range?fromSeq=2&toSeq=2');
|
|
433
|
+
assert.equal(windowed.body.entries.length, 1);
|
|
434
|
+
assert.equal(windowed.body.entries[0].seq, 2);
|
|
435
|
+
assert.equal(windowed.body.entries[0].data, 'line-two');
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('A6 - agent.sendMessage delivers the harness-request prefix and records requestId/contextRefs/toRole', async () => {
|
|
439
|
+
seedAgentSession(root, 'agent-a6-a', 'session-a6-a', { agentKind: 'main', role: 'Main Agent' });
|
|
440
|
+
seedAgentSession(root, 'agent-a6-b', 'session-a6-b', {});
|
|
441
|
+
writeGraphEdges(root, [
|
|
442
|
+
{ id: 'edge-a6-a-b', from: 'agent-a6-a', to: 'agent-a6-b', relation: 'delegation', direction: 'bidirectional' },
|
|
443
|
+
]);
|
|
444
|
+
const writesB = [];
|
|
445
|
+
registerPty('session-a6-b', writesB);
|
|
446
|
+
|
|
447
|
+
const sent = await jsonRequest(baseUrl, '/api/workflow/nodes/agent-a6-a/actions/agent.sendMessage', {
|
|
448
|
+
method: 'POST',
|
|
449
|
+
body: {
|
|
450
|
+
to: 'agent-a6-b',
|
|
451
|
+
text: 'STRUCTURED_A6',
|
|
452
|
+
requestId: 'req-a6-1',
|
|
453
|
+
toRole: 'implementer',
|
|
454
|
+
contextRefs: [{ nodeId: 'markdown-x1', relation: 'shared-context' }],
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
assert.equal(sent.status, 200, JSON.stringify(sent.body));
|
|
458
|
+
assert.equal(sent.body.result.mode, 'direct');
|
|
459
|
+
assert.equal(sent.body.result.requestId, 'req-a6-1');
|
|
460
|
+
const envelopeA6 = '[harness-request req-a6-1 to-role=implementer contextRefs=markdown-x1] STRUCTURED_A6';
|
|
461
|
+
assert.ok(writesB.length >= 1, 'envelope typing must start immediately');
|
|
462
|
+
assert.equal(writesB[0], '[', 'envelope typing must start with the first prefix char synchronously');
|
|
463
|
+
await waitFor(() => (writesB.join('') === `${envelopeA6}\r` ? writesB : null), { timeout: 8000 });
|
|
464
|
+
assert.equal(writesB.join(''), `${envelopeA6}\r`, 'the typed envelope must join to the exact prefix + text + \\r');
|
|
465
|
+
assert.equal(writesB[writesB.length - 1], '\r', 'the delivered sequence must end with the single \\r');
|
|
466
|
+
assert.equal(writesB.join('').includes('\n'), false, 'no \\n may be injected');
|
|
467
|
+
assert.equal(writesB.filter(w => w === '\r').length, 1, 'exactly one \\r (0x0D) in the delivered sequence');
|
|
468
|
+
|
|
469
|
+
const bridge = listBridgeMessages(root, { fromSessionId: 'session-a6-a', toSessionId: 'session-a6-b' });
|
|
470
|
+
assert.equal(bridge.entries.length, 1);
|
|
471
|
+
const entry = bridge.entries[0];
|
|
472
|
+
assert.equal(entry.requestId, 'req-a6-1');
|
|
473
|
+
assert.equal(entry.toRole, 'implementer');
|
|
474
|
+
assert.deepEqual(entry.contextRefs, [{ nodeId: 'markdown-x1', relation: 'shared-context' }]);
|
|
475
|
+
assert.equal(entry.deliveryMode, 'direct');
|
|
476
|
+
assert.equal(entry.fromNodeId, 'agent-a6-a');
|
|
477
|
+
assert.equal(entry.toNodeId, 'agent-a6-b');
|
|
478
|
+
assert.equal(entry.data.replace(/\r$/, ''), 'STRUCTURED_A6', 'bridge data must stay the plain text body');
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('A7 - agent.broadcastMessage delivers independent per-recipient inputs with recipientIndex/recipientCount', async () => {
|
|
482
|
+
seedAgentSession(root, 'agent-a7-a', 'session-a7-a', { agentKind: 'main', role: 'Main Agent' });
|
|
483
|
+
seedAgentSession(root, 'agent-a7-b', 'session-a7-b', {});
|
|
484
|
+
seedAgentSession(root, 'agent-a7-c', 'session-a7-c', {});
|
|
485
|
+
seedAgentSession(root, 'agent-a7-d', 'session-a7-d', {});
|
|
486
|
+
writeGraphEdges(root, [
|
|
487
|
+
{ id: 'e7-a-b', from: 'agent-a7-a', to: 'agent-a7-b', relation: 'delegation', direction: 'bidirectional' },
|
|
488
|
+
{ id: 'e7-a-c', from: 'agent-a7-a', to: 'agent-a7-c', relation: 'delegation', direction: 'bidirectional' },
|
|
489
|
+
{ id: 'e7-a-d', from: 'agent-a7-a', to: 'agent-a7-d', relation: 'delegation', direction: 'bidirectional' },
|
|
490
|
+
]);
|
|
491
|
+
const writes = { b: [], c: [], d: [] };
|
|
492
|
+
registerPty('session-a7-b', writes.b);
|
|
493
|
+
registerPty('session-a7-c', writes.c);
|
|
494
|
+
registerPty('session-a7-d', writes.d);
|
|
495
|
+
|
|
496
|
+
const res = await jsonRequest(baseUrl, '/api/workflow/nodes/agent-a7-a/actions/agent.broadcastMessage', {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
body: { to: ['agent-a7-b', 'agent-a7-c', 'agent-a7-d'], text: 'GROUP_A7', requestId: 'req-a7-1', toRole: 'worker' },
|
|
499
|
+
});
|
|
500
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
501
|
+
assert.equal(res.body.result.deliveredCount, 3);
|
|
502
|
+
assert.equal(res.body.result.failedCount, 0);
|
|
503
|
+
assert.equal(res.body.result.requestId, 'req-a7-1');
|
|
504
|
+
const envelopeA7 = '[harness-request req-a7-1 to-role=worker] GROUP_A7';
|
|
505
|
+
for (const key of ['b', 'c', 'd']) {
|
|
506
|
+
assert.ok(writes[key].length >= 1, `${key} must receive exactly one independent input`);
|
|
507
|
+
assert.equal(writes[key][0], '[', `${key} must start typing the envelope prefix synchronously`);
|
|
508
|
+
}
|
|
509
|
+
for (const key of ['b', 'c', 'd']) {
|
|
510
|
+
await waitFor(() => (writes[key].join('') === `${envelopeA7}\r` ? writes[key] : null), { timeout: 8000 });
|
|
511
|
+
assert.equal(writes[key].join(''), `${envelopeA7}\r`, `${key} must submit the typed body with a single \\r`);
|
|
512
|
+
assert.equal(writes[key][writes[key].length - 1], '\r', `${key} \\r must be the final write`);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const entries = ['b', 'c', 'd'].map((key, index) => {
|
|
516
|
+
const bridge = listBridgeMessages(root, { fromSessionId: 'session-a7-a', toSessionId: `session-a7-${key}` });
|
|
517
|
+
assert.equal(bridge.entries.length, 1);
|
|
518
|
+
const entry = bridge.entries[0];
|
|
519
|
+
assert.equal(entry.requestId, 'req-a7-1');
|
|
520
|
+
assert.equal(entry.toRole, 'worker');
|
|
521
|
+
assert.equal(entry.recipientIndex, index + 1, 'recipientIndex must be per-recipient');
|
|
522
|
+
assert.equal(entry.recipientCount, 3, 'recipientCount must be the fan-out size');
|
|
523
|
+
return entry;
|
|
524
|
+
});
|
|
525
|
+
assert.equal(new Set(entries.map(e => e.messageId)).size, 1, 'one broadcast shares one messageId');
|
|
526
|
+
|
|
527
|
+
// The same requestId aggregates across recipients.
|
|
528
|
+
const aggregated = await jsonRequest(baseUrl, '/api/workflow/nodes/agent-a7-a/actions/agent.readMessages', {
|
|
529
|
+
method: 'POST',
|
|
530
|
+
body: { requestId: 'req-a7-1' },
|
|
531
|
+
});
|
|
532
|
+
assert.equal(aggregated.status, 200);
|
|
533
|
+
assert.equal(aggregated.body.result.entries.length, 3);
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('A8 - replies share the requestId and replyTo resolves to the originating message', async () => {
|
|
537
|
+
seedAgentSession(root, 'agent-a8-a', 'session-a8-a', { agentKind: 'main', role: 'Main Agent' });
|
|
538
|
+
seedAgentSession(root, 'agent-a8-b', 'session-a8-b', {});
|
|
539
|
+
seedAgentSession(root, 'agent-a8-c', 'session-a8-c', {});
|
|
540
|
+
writeGraphEdges(root, [
|
|
541
|
+
{ id: 'e8-a-b', from: 'agent-a8-a', to: 'agent-a8-b', relation: 'delegation', direction: 'bidirectional' },
|
|
542
|
+
{ id: 'e8-a-c', from: 'agent-a8-a', to: 'agent-a8-c', relation: 'delegation', direction: 'bidirectional' },
|
|
543
|
+
]);
|
|
544
|
+
registerPty('session-a8-a', []);
|
|
545
|
+
registerPty('session-a8-b', []);
|
|
546
|
+
registerPty('session-a8-c', []);
|
|
547
|
+
|
|
548
|
+
const broadcast = await jsonRequest(baseUrl, '/api/workflow/nodes/agent-a8-a/actions/agent.broadcastMessage', {
|
|
549
|
+
method: 'POST',
|
|
550
|
+
body: { to: ['agent-a8-b', 'agent-a8-c'], text: 'REQ_A8', requestId: 'req-a8-1', threadId: 'thread-a8' },
|
|
551
|
+
});
|
|
552
|
+
assert.equal(broadcast.status, 200, JSON.stringify(broadcast.body));
|
|
553
|
+
const originMessageId = broadcast.body.result.messageId;
|
|
554
|
+
assert.ok(originMessageId);
|
|
555
|
+
|
|
556
|
+
for (const [sender, replyText] of [['agent-a8-b', 'REPLY_B8'], ['agent-a8-c', 'REPLY_C8']]) {
|
|
557
|
+
const replied = await jsonRequest(baseUrl, `/api/workflow/nodes/${sender}/actions/agent.sendMessage`, {
|
|
558
|
+
method: 'POST',
|
|
559
|
+
body: { to: 'agent-a8-a', text: replyText, requestId: 'req-a8-1', threadId: 'thread-a8', replyTo: originMessageId },
|
|
560
|
+
});
|
|
561
|
+
assert.equal(replied.status, 200, JSON.stringify(replied.body));
|
|
562
|
+
assert.equal(replied.body.result.requestId, 'req-a8-1');
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const read = await jsonRequest(baseUrl, '/api/workflow/nodes/agent-a8-a/actions/agent.readMessages', {
|
|
566
|
+
method: 'POST',
|
|
567
|
+
body: { requestId: 'req-a8-1' },
|
|
568
|
+
});
|
|
569
|
+
assert.equal(read.status, 200, JSON.stringify(read.body));
|
|
570
|
+
assert.equal(read.body.result.mode, 'request');
|
|
571
|
+
assert.equal(read.body.result.entries.length, 4, '2 deliveries + 2 replies in one request thread');
|
|
572
|
+
const replies = read.body.result.entries.filter(e => String(e.data).replace(/\r$/, '').startsWith('REPLY'));
|
|
573
|
+
assert.equal(replies.length, 2);
|
|
574
|
+
for (const reply of replies) {
|
|
575
|
+
assert.equal(reply.requestId, 'req-a8-1');
|
|
576
|
+
assert.equal(reply.replyTo, originMessageId, 'replies must reference the originating messageId');
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const peerRead = await jsonRequest(baseUrl, '/api/workflow/nodes/agent-a8-a/actions/agent.readMessages', {
|
|
580
|
+
method: 'POST',
|
|
581
|
+
body: { peer: 'agent-a8-b' },
|
|
582
|
+
});
|
|
583
|
+
assert.equal(peerRead.status, 200);
|
|
584
|
+
assert.equal(peerRead.body.result.entries.length, 2);
|
|
585
|
+
assert.equal(peerRead.body.result.entries[1].replyTo, originMessageId);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
// ══════════════════════════════════════════════════════════════════
|
|
590
|
+
// Timer-Agent Matrix (T1-T8)
|
|
591
|
+
// ══════════════════════════════════════════════════════════════════
|
|
592
|
+
describe('Timer-Agent Matrix (T1-T8)', () => {
|
|
593
|
+
it('T1 - Timer normal edge wakeup reaches the connected Agent message queue', async () => {
|
|
594
|
+
const timer = await createTimerViaApi('T1 Edge Timer');
|
|
595
|
+
const timerNodeId = timer.nodeId;
|
|
596
|
+
seedAgentSession(root, 'agent-t1', 'session-t1', {});
|
|
597
|
+
writeGraphEdges(root, [
|
|
598
|
+
{ id: 'e-t1', from: timerNodeId, to: 'agent-t1', relation: 'event', direction: 'source-to-target' },
|
|
599
|
+
]);
|
|
600
|
+
await armTimer(timerNodeId, 60);
|
|
601
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
602
|
+
|
|
603
|
+
const entries = await waitFor(() => {
|
|
604
|
+
const result = wakeupEntries(timerNodeId, 'session-t1');
|
|
605
|
+
return result.length ? result : null;
|
|
606
|
+
});
|
|
607
|
+
assert.equal(entries.length, 1);
|
|
608
|
+
const entry = entries[0];
|
|
609
|
+
assert.equal(entry.deliveryMode, 'wakeup');
|
|
610
|
+
assert.equal(entry.source, 'timer.wakeup');
|
|
611
|
+
assert.equal(entry.toNodeId, 'agent-t1');
|
|
612
|
+
assert.equal(entry.fromNodeId, timerNodeId);
|
|
613
|
+
const envelope = JSON.parse(entry.data);
|
|
614
|
+
assert.equal(envelope.type, 'wakeup');
|
|
615
|
+
assert.equal(envelope.timerNodeId, timerNodeId);
|
|
616
|
+
assert.equal(envelope.goalNodeId, null, 'no Goal in the group -> goalNodeId null');
|
|
617
|
+
assert.ok(Number.isFinite(Date.parse(envelope.firedAt)));
|
|
618
|
+
assert.equal(envelope.intervalSeconds, 60);
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
it('T2 - magnetic group wakeup reaches group members only', async () => {
|
|
622
|
+
const timer = await createTimerViaApi('T2 Group Timer');
|
|
623
|
+
const timerNodeId = timer.nodeId;
|
|
624
|
+
seedAgentSession(root, 'agent-t2-a', 'session-t2-a', {});
|
|
625
|
+
seedAgentSession(root, 'agent-t2-b', 'session-t2-b', {});
|
|
626
|
+
seedAgentSession(root, 'agent-t2-c', 'session-t2-c', {});
|
|
627
|
+
const graph = loadWorkflowGraphMap(root);
|
|
628
|
+
writeWorkflowGraphMap(root, {
|
|
629
|
+
...graph,
|
|
630
|
+
version: graph.version + 1,
|
|
631
|
+
edges: [],
|
|
632
|
+
capsuleDockLinks: [
|
|
633
|
+
dockLink('agent-t2-a', timerNodeId),
|
|
634
|
+
dockLink('agent-t2-b', timerNodeId),
|
|
635
|
+
],
|
|
636
|
+
});
|
|
637
|
+
await armTimer(timerNodeId, 60);
|
|
638
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
639
|
+
|
|
640
|
+
for (const member of ['session-t2-a', 'session-t2-b']) {
|
|
641
|
+
await waitFor(() => {
|
|
642
|
+
const result = wakeupEntries(timerNodeId, member);
|
|
643
|
+
return result.length ? result : null;
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
const aEntries = wakeupEntries(timerNodeId, 'session-t2-a');
|
|
647
|
+
const bEntries = wakeupEntries(timerNodeId, 'session-t2-b');
|
|
648
|
+
assert.equal(aEntries.length, 1);
|
|
649
|
+
assert.equal(bEntries.length, 1);
|
|
650
|
+
assert.equal(aEntries[0].messageId, bEntries[0].messageId, 'one fire dispatches one shared wakeup messageId');
|
|
651
|
+
const cEntries = wakeupEntries(timerNodeId, 'session-t2-c');
|
|
652
|
+
assert.equal(cEntries.length, 0, 'non-group member must receive no wakeup');
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it('T3 - one Timer wakes all three docked Agents with no duplicates', async () => {
|
|
656
|
+
const timer = await createTimerViaApi('T3 Fan-out Timer');
|
|
657
|
+
const timerNodeId = timer.nodeId;
|
|
658
|
+
seedAgentSession(root, 'agent-t3-b', 'session-t3-b', {});
|
|
659
|
+
seedAgentSession(root, 'agent-t3-c', 'session-t3-c', {});
|
|
660
|
+
seedAgentSession(root, 'agent-t3-d', 'session-t3-d', {});
|
|
661
|
+
const graph = loadWorkflowGraphMap(root);
|
|
662
|
+
writeWorkflowGraphMap(root, {
|
|
663
|
+
...graph,
|
|
664
|
+
version: graph.version + 1,
|
|
665
|
+
edges: [],
|
|
666
|
+
capsuleDockLinks: [
|
|
667
|
+
dockLink('agent-t3-b', timerNodeId),
|
|
668
|
+
dockLink('agent-t3-c', timerNodeId),
|
|
669
|
+
dockLink('agent-t3-d', timerNodeId),
|
|
670
|
+
],
|
|
671
|
+
});
|
|
672
|
+
await armTimer(timerNodeId, 60);
|
|
673
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
674
|
+
|
|
675
|
+
const messageIds = new Set();
|
|
676
|
+
for (const member of ['session-t3-b', 'session-t3-c', 'session-t3-d']) {
|
|
677
|
+
const entries = await waitFor(() => {
|
|
678
|
+
const result = wakeupEntries(timerNodeId, member);
|
|
679
|
+
return result.length ? result : null;
|
|
680
|
+
});
|
|
681
|
+
assert.equal(entries.length, 1, `no duplicate wakeup for ${member}`);
|
|
682
|
+
assert.equal(entries[0].deliveryMode, 'wakeup');
|
|
683
|
+
messageIds.add(entries[0].messageId);
|
|
684
|
+
}
|
|
685
|
+
assert.equal(messageIds.size, 1, 'one fire must use one shared wakeup messageId');
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
it('T4 - wakeup envelope carries the magnetic group Goal node id', async () => {
|
|
689
|
+
const goalNodeId = seedActiveTask(root, 'task-t4-goal');
|
|
690
|
+
const timer = await createTimerViaApi('T4 Goal Timer');
|
|
691
|
+
const timerNodeId = timer.nodeId;
|
|
692
|
+
seedAgentSession(root, 'agent-t4', 'session-t4', {});
|
|
693
|
+
const graph = loadWorkflowGraphMap(root);
|
|
694
|
+
writeWorkflowGraphMap(root, {
|
|
695
|
+
...graph,
|
|
696
|
+
version: graph.version + 1,
|
|
697
|
+
edges: [],
|
|
698
|
+
capsuleDockLinks: [
|
|
699
|
+
dockLink('agent-t4', timerNodeId),
|
|
700
|
+
dockLink(timerNodeId, goalNodeId),
|
|
701
|
+
],
|
|
702
|
+
});
|
|
703
|
+
await armTimer(timerNodeId, 60);
|
|
704
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
705
|
+
|
|
706
|
+
const entries = await waitFor(() => {
|
|
707
|
+
const result = wakeupEntries(timerNodeId, 'session-t4');
|
|
708
|
+
return result.length ? result : null;
|
|
709
|
+
});
|
|
710
|
+
const envelope = JSON.parse(entries[0].data);
|
|
711
|
+
assert.equal(envelope.type, 'wakeup');
|
|
712
|
+
assert.equal(envelope.goalNodeId, goalNodeId, 'wakeup must carry the group goal node id');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('T5 - changing Goal state never wakes the Agent and the Goal exposes no wakeup action', async () => {
|
|
716
|
+
const goalNodeId = seedActiveTask(root, 'task-t5-goal');
|
|
717
|
+
const timer = await createTimerViaApi('T5 Quiet Timer'); // armed far in the future
|
|
718
|
+
const timerNodeId = timer.nodeId;
|
|
719
|
+
seedAgentSession(root, 'agent-t5', 'session-t5', {});
|
|
720
|
+
const graph = loadWorkflowGraphMap(root);
|
|
721
|
+
writeWorkflowGraphMap(root, {
|
|
722
|
+
...graph,
|
|
723
|
+
version: graph.version + 1,
|
|
724
|
+
edges: [],
|
|
725
|
+
capsuleDockLinks: [
|
|
726
|
+
dockLink('agent-t5', timerNodeId),
|
|
727
|
+
dockLink(timerNodeId, goalNodeId),
|
|
728
|
+
dockLink('agent-t5', goalNodeId),
|
|
729
|
+
],
|
|
730
|
+
});
|
|
731
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
732
|
+
assert.equal(isTimerSchedulerActive(), true, 'scheduler runs while an enabled timer exists');
|
|
733
|
+
|
|
734
|
+
const updated = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.update`, {
|
|
735
|
+
method: 'POST',
|
|
736
|
+
body: { nextAction: 'T5 goal state change' },
|
|
737
|
+
});
|
|
738
|
+
assert.equal(updated.status, 200, JSON.stringify(updated.body));
|
|
739
|
+
assert.equal(updated.body.result.state.nextAction, 'T5 goal state change');
|
|
740
|
+
|
|
741
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
742
|
+
assert.equal(wakeupEntries(timerNodeId, 'session-t5').length, 0, 'Goal state changes must not wake the Agent');
|
|
743
|
+
const read = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(timerNodeId)}/actions/timer.read`, {
|
|
744
|
+
method: 'POST',
|
|
745
|
+
body: {},
|
|
746
|
+
});
|
|
747
|
+
assert.equal(read.body.result.eventCount, 0, 'the timer must not have fired from a Goal change');
|
|
748
|
+
|
|
749
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-t5')}`);
|
|
750
|
+
const goalRef = (ctx.body.context.connectedGoalRefs || []).find(ref => ref.nodeId === goalNodeId);
|
|
751
|
+
assert.ok(goalRef, 'agent context must expose the connected Goal ref');
|
|
752
|
+
assert.ok(!JSON.stringify(goalRef.capabilities || []).match(/wake/i), 'Goal affordance exposes no wakeup action');
|
|
753
|
+
const timerAffordance = (ctx.body.context.affordances || []).find(item => item.type === 'timer');
|
|
754
|
+
assert.ok(timerAffordance, 'timer affordance must be present');
|
|
755
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.dispatchWakeup'), 'agents must not self-wake');
|
|
756
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.fire'));
|
|
757
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.tick'));
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
it('T6 - bounded loop maxIterations stops the Timer after the configured fire count', async () => {
|
|
761
|
+
const timer = await createTimerViaApi('T6 Loop Timer', {
|
|
762
|
+
schedule: { mode: 'interval', intervalSeconds: 1 },
|
|
763
|
+
heartbeat: { base: { enabled: true, intervalSeconds: 1, nextDueAt: futureIso() } },
|
|
764
|
+
loop: { enabled: true, maxIterations: 2, runCount: 0 },
|
|
765
|
+
});
|
|
766
|
+
const timerNodeId = timer.nodeId;
|
|
767
|
+
seedAgentSession(root, 'agent-t6', 'session-t6', {});
|
|
768
|
+
const graph = loadWorkflowGraphMap(root);
|
|
769
|
+
writeWorkflowGraphMap(root, {
|
|
770
|
+
...graph,
|
|
771
|
+
version: graph.version + 1,
|
|
772
|
+
edges: [],
|
|
773
|
+
capsuleDockLinks: [dockLink('agent-t6', timerNodeId)],
|
|
774
|
+
});
|
|
775
|
+
await armTimer(timerNodeId, 1);
|
|
776
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
777
|
+
|
|
778
|
+
await waitFor(async () => {
|
|
779
|
+
const state = await timerState(timerNodeId);
|
|
780
|
+
return state.eventCount >= 2 ? state : null;
|
|
781
|
+
}, { timeout: 7000 });
|
|
782
|
+
|
|
783
|
+
const afterTwo = await timerState(timerNodeId);
|
|
784
|
+
assert.equal(afterTwo.eventCount, 2, 'timer must fire exactly maxIterations times');
|
|
785
|
+
assert.equal(afterTwo.loop.runCount, 2);
|
|
786
|
+
assert.equal(afterTwo.loop.enabled, false, 'loop must mark itself complete after maxIterations');
|
|
787
|
+
|
|
788
|
+
await new Promise(resolve => setTimeout(resolve, 1600));
|
|
789
|
+
const final = await timerState(timerNodeId);
|
|
790
|
+
assert.equal(final.eventCount, 2, 'a third fire must not advance past the loop bound');
|
|
791
|
+
assert.equal(final.loop.runCount, 2);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('T7 - timer.disable stops the scheduler and no further wakeups are dispatched', async () => {
|
|
795
|
+
const timer = await createTimerViaApi('T7 Disable Timer', {
|
|
796
|
+
controlPolicy: { agentCanDisable: true },
|
|
797
|
+
});
|
|
798
|
+
const timerNodeId = timer.nodeId;
|
|
799
|
+
seedAgentSession(root, 'agent-t7', 'session-t7', {});
|
|
800
|
+
const graph = loadWorkflowGraphMap(root);
|
|
801
|
+
writeWorkflowGraphMap(root, {
|
|
802
|
+
...graph,
|
|
803
|
+
version: graph.version + 1,
|
|
804
|
+
edges: [],
|
|
805
|
+
capsuleDockLinks: [dockLink('agent-t7', timerNodeId)],
|
|
806
|
+
});
|
|
807
|
+
await armTimer(timerNodeId, 60);
|
|
808
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
809
|
+
|
|
810
|
+
await waitFor(() => {
|
|
811
|
+
const result = wakeupEntries(timerNodeId, 'session-t7');
|
|
812
|
+
return result.length ? result : null;
|
|
813
|
+
});
|
|
814
|
+
assert.equal(isTimerSchedulerActive(), true, 'scheduler must be active while an enabled timer exists');
|
|
815
|
+
|
|
816
|
+
const disabled = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(timerNodeId)}/actions/timer.disable`, {
|
|
817
|
+
method: 'POST',
|
|
818
|
+
body: {},
|
|
819
|
+
});
|
|
820
|
+
assert.equal(disabled.status, 200, JSON.stringify(disabled.body));
|
|
821
|
+
assert.equal(disabled.body.result.state.enabled, false);
|
|
822
|
+
assert.equal(disabled.body.result.state.heartbeat.base.enabled, false);
|
|
823
|
+
assert.equal(isTimerSchedulerActive(), false, 'no enabled timers remain, so the scheduler must stop');
|
|
824
|
+
|
|
825
|
+
await new Promise(resolve => setTimeout(resolve, 250));
|
|
826
|
+
const after = wakeupEntries(timerNodeId, 'session-t7');
|
|
827
|
+
assert.equal(after.length, 1, 'no wakeup may be dispatched after disable');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('T8 - second Goal into a Timer+Agent group rejected by HTTP route and agent.connectNodes', async () => {
|
|
831
|
+
const firstGoalNodeId = seedActiveTask(root, 'task-t8-goal');
|
|
832
|
+
const created = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
833
|
+
method: 'POST',
|
|
834
|
+
body: { type: 'timer', title: 'T8 Timer', enabled: false, schedule: { mode: 'manual', intervalSeconds: 60 } },
|
|
835
|
+
});
|
|
836
|
+
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
837
|
+
const timerNodeId = created.body.node.nodeId;
|
|
838
|
+
seedAgentSession(root, 'agent-t8', 'session-t8', { agentKind: 'main', role: 'Main Agent' });
|
|
839
|
+
let graph = loadWorkflowGraphMap(root);
|
|
840
|
+
writeWorkflowGraphMap(root, {
|
|
841
|
+
...graph,
|
|
842
|
+
version: graph.version + 1,
|
|
843
|
+
edges: [],
|
|
844
|
+
capsuleDockLinks: [
|
|
845
|
+
dockLink('agent-t8', timerNodeId),
|
|
846
|
+
dockLink(timerNodeId, firstGoalNodeId),
|
|
847
|
+
],
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
// Inject a second goal-shaped node into the same magnetic group.
|
|
851
|
+
graph = loadWorkflowGraphMap(root);
|
|
852
|
+
writeWorkflowGraphMap(root, {
|
|
853
|
+
...graph,
|
|
854
|
+
version: graph.version + 1,
|
|
855
|
+
nodes: [...(graph.nodes || []), { nodeId: 'goal-second', label: 'Second Goal' }],
|
|
856
|
+
capsuleDockLinks: [...(graph.capsuleDockLinks || []), dockLink('agent-t8', 'goal-second')],
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
const viaHttp = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
860
|
+
method: 'POST',
|
|
861
|
+
body: { from: 'agent-t8', to: 'goal-second', relation: 'goal' },
|
|
862
|
+
});
|
|
863
|
+
assert.equal(viaHttp.status, 409, JSON.stringify(viaHttp.body));
|
|
864
|
+
assert.equal(viaHttp.body.error, 'goal_already_bound');
|
|
865
|
+
assert.ok(String(viaHttp.body.message).includes('already has a Goal'), viaHttp.body.message);
|
|
866
|
+
assert.equal(viaHttp.body.existingGoalNodeId, firstGoalNodeId);
|
|
867
|
+
assert.equal(viaHttp.body.timerNodeId, timerNodeId);
|
|
868
|
+
|
|
869
|
+
await assert.rejects(
|
|
870
|
+
() => executeNodeAction(root, 'agent-t8', 'agent.connectNodes', {
|
|
871
|
+
to: 'goal-second',
|
|
872
|
+
relation: 'goal',
|
|
873
|
+
actorNodeId: 'agent-t8',
|
|
874
|
+
}),
|
|
875
|
+
(error) => {
|
|
876
|
+
assert.equal(error.code, 'goal_already_bound');
|
|
877
|
+
assert.equal(error.existingGoalNodeId, firstGoalNodeId);
|
|
878
|
+
assert.equal(error.timerNodeId, timerNodeId);
|
|
879
|
+
return true;
|
|
880
|
+
},
|
|
881
|
+
);
|
|
882
|
+
});
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// ══════════════════════════════════════════════════════════════════
|
|
886
|
+
// Functional Node Control Matrix (N1-N15)
|
|
887
|
+
// ══════════════════════════════════════════════════════════════════
|
|
888
|
+
describe('Functional Node Control Matrix (N1-N15)', () => {
|
|
889
|
+
function nodeAction(nodeId, action, body = {}) {
|
|
890
|
+
return jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeId)}/actions/${action}`, { method: 'POST', body });
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async function createNode(type, body = {}) {
|
|
894
|
+
const res = await jsonRequest(baseUrl, '/api/workflow/nodes', { method: 'POST', body: { type, ...body } });
|
|
895
|
+
assert.equal(res.status, 201, JSON.stringify(res.body));
|
|
896
|
+
return res.body.node.nodeId;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
describe('Markdown (N1-N5)', () => {
|
|
900
|
+
it('N1 - markdown.find returns metadata only, never content', async () => {
|
|
901
|
+
const board = await createNode('markdown', { title: 'Sprint Board', markdown: '# Sprint\nsecret body' });
|
|
902
|
+
const design = await createNode('markdown', { title: 'Design Notes', markdown: '# Design\nsecret body' });
|
|
903
|
+
|
|
904
|
+
const byNodeId = await nodeAction(board, 'markdown.find', { nodeId: board });
|
|
905
|
+
assert.equal(byNodeId.status, 200);
|
|
906
|
+
assert.equal(byNodeId.body.result.count, 1);
|
|
907
|
+
const match = byNodeId.body.result.matches[0];
|
|
908
|
+
assert.equal(match.nodeId, board);
|
|
909
|
+
assert.equal(match.title, 'Sprint Board');
|
|
910
|
+
assert.equal(match.revision, 1);
|
|
911
|
+
assert.ok(match.stateRef && match.stateRef.path, 'find must return the stateRef');
|
|
912
|
+
assert.ok(!('markdown' in match), 'find must never return content');
|
|
913
|
+
|
|
914
|
+
const byTitle = await nodeAction(board, 'markdown.find', { title: 'design' });
|
|
915
|
+
assert.equal(byTitle.body.result.count, 1);
|
|
916
|
+
assert.equal(byTitle.body.result.matches[0].nodeId, design);
|
|
917
|
+
assert.ok(!('markdown' in byTitle.body.result.matches[0]), 'title search must never return content');
|
|
918
|
+
|
|
919
|
+
const none = await nodeAction(board, 'markdown.find', { title: 'does-not-exist' });
|
|
920
|
+
assert.equal(none.body.result.count, 0);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('N2 - markdown.read returns content and revision', async () => {
|
|
924
|
+
const nodeId = await createNode('markdown', { title: 'Read Me', markdown: '# Read\nbody' });
|
|
925
|
+
const res = await nodeAction(nodeId, 'markdown.read');
|
|
926
|
+
assert.equal(res.status, 200);
|
|
927
|
+
assert.equal(res.body.result.markdown, '# Read\nbody');
|
|
928
|
+
assert.equal(res.body.result.revision, 1);
|
|
929
|
+
assert.equal(res.body.result.title, 'Read Me');
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it('N3 - acquireLock -> append/patch -> releaseLock grows revision with correct content', async () => {
|
|
933
|
+
const nodeId = await createNode('markdown', { title: 'Locked Write', markdown: 'v1' });
|
|
934
|
+
const lock = await nodeAction(nodeId, 'markdown.acquireLock', { lockOwner: 'agent-n3' });
|
|
935
|
+
assert.equal(lock.status, 200, JSON.stringify(lock.body));
|
|
936
|
+
assert.ok(lock.body.result.lockId);
|
|
937
|
+
assert.equal(lock.body.result.owner, 'agent-n3');
|
|
938
|
+
assert.equal(lock.body.result.revision, 1);
|
|
939
|
+
|
|
940
|
+
const appended = await nodeAction(nodeId, 'markdown.append', {
|
|
941
|
+
markdown: '\nv2',
|
|
942
|
+
lockId: lock.body.result.lockId,
|
|
943
|
+
expectedRevision: 1,
|
|
944
|
+
});
|
|
945
|
+
assert.equal(appended.status, 200, JSON.stringify(appended.body));
|
|
946
|
+
assert.equal(appended.body.result.revision, 2);
|
|
947
|
+
|
|
948
|
+
const patched = await nodeAction(nodeId, 'markdown.patch', {
|
|
949
|
+
diff: { op: 'insert', text: 'X', offset: 0 },
|
|
950
|
+
lockId: lock.body.result.lockId,
|
|
951
|
+
expectedRevision: 2,
|
|
952
|
+
});
|
|
953
|
+
assert.equal(patched.status, 200, JSON.stringify(patched.body));
|
|
954
|
+
assert.equal(patched.body.result.revision, 3);
|
|
955
|
+
|
|
956
|
+
const read = await nodeAction(nodeId, 'markdown.read');
|
|
957
|
+
assert.equal(read.body.result.markdown, 'Xv1\nv2');
|
|
958
|
+
|
|
959
|
+
const released = await nodeAction(nodeId, 'markdown.releaseLock', {
|
|
960
|
+
lockOwner: 'agent-n3',
|
|
961
|
+
lockId: lock.body.result.lockId,
|
|
962
|
+
});
|
|
963
|
+
assert.equal(released.status, 200);
|
|
964
|
+
assert.equal(released.body.result.released, true);
|
|
965
|
+
const after = await nodeAction(nodeId, 'markdown.read');
|
|
966
|
+
assert.equal(after.body.result.revision, 3);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('N4 - foreign writer while locked gets markdown_locked and content stays unchanged', async () => {
|
|
970
|
+
const nodeId = await createNode('markdown', { title: 'Locked Notes', markdown: 'body' });
|
|
971
|
+
const lock = await nodeAction(nodeId, 'markdown.acquireLock', { lockOwner: 'agent-n4' });
|
|
972
|
+
assert.equal(lock.status, 200);
|
|
973
|
+
|
|
974
|
+
const foreign = await nodeAction(nodeId, 'markdown.append', { markdown: '\nclobber' });
|
|
975
|
+
assert.equal(foreign.status, 409, JSON.stringify(foreign.body));
|
|
976
|
+
assert.equal(foreign.body.error.code, 'markdown_locked');
|
|
977
|
+
assert.equal(foreign.body.holder, 'agent-n4');
|
|
978
|
+
assert.ok(foreign.body.expiresAt, 'error must carry the lock expiry');
|
|
979
|
+
|
|
980
|
+
const read = await nodeAction(nodeId, 'markdown.read');
|
|
981
|
+
assert.equal(read.body.result.markdown, 'body', 'no write may land while a foreign lock is held');
|
|
982
|
+
assert.equal(read.body.result.revision, 1);
|
|
983
|
+
|
|
984
|
+
await nodeAction(nodeId, 'markdown.releaseLock', { lockOwner: 'agent-n4', lockId: lock.body.result.lockId });
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('N5 - stale expectedRevision returns markdown_conflict with currentRevision', async () => {
|
|
988
|
+
const nodeId = await createNode('markdown', { title: 'Merge Notes', markdown: 'base' });
|
|
989
|
+
await nodeAction(nodeId, 'markdown.append', { markdown: '\nA writes' }); // -> revision 2
|
|
990
|
+
|
|
991
|
+
const stale = await nodeAction(nodeId, 'markdown.replace', { markdown: 'B overwrite', expectedRevision: 1 });
|
|
992
|
+
assert.equal(stale.status, 409, JSON.stringify(stale.body));
|
|
993
|
+
assert.equal(stale.body.error.code, 'markdown_conflict');
|
|
994
|
+
assert.equal(stale.body.currentRevision, 2);
|
|
995
|
+
assert.equal(stale.body.expectedRevision, 1);
|
|
996
|
+
|
|
997
|
+
const read = await nodeAction(nodeId, 'markdown.read');
|
|
998
|
+
assert.equal(read.body.result.revision, 2);
|
|
999
|
+
assert.ok(read.body.result.markdown.includes('A writes'), 'conflict must not overwrite');
|
|
1000
|
+
assert.ok(!read.body.result.markdown.includes('B overwrite'), 'conflict must not write');
|
|
1001
|
+
|
|
1002
|
+
const retried = await nodeAction(nodeId, 'markdown.replace', { markdown: 'merged', expectedRevision: 2 });
|
|
1003
|
+
assert.equal(retried.status, 200, JSON.stringify(retried.body));
|
|
1004
|
+
assert.equal(retried.body.result.revision, 3);
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe('File (N6-N8)', () => {
|
|
1009
|
+
it('N6 - file.readMeta returns path/size/mime metadata', async () => {
|
|
1010
|
+
fs.writeFileSync(path.join(root, 'notes.txt'), 'hello world');
|
|
1011
|
+
const nodeId = await createNode('file', {
|
|
1012
|
+
title: 'Notes',
|
|
1013
|
+
file: { source: 'workspace', path: 'notes.txt', name: 'notes.txt', mime: 'text/plain' },
|
|
1014
|
+
});
|
|
1015
|
+
const res = await nodeAction(nodeId, 'file.readMeta');
|
|
1016
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
1017
|
+
const file = res.body.result.file;
|
|
1018
|
+
assert.equal(file.path, 'notes.txt');
|
|
1019
|
+
assert.equal(file.size, 11);
|
|
1020
|
+
assert.ok(String(file.mime).startsWith('text/plain'), `expected text mime, got ${file.mime}`);
|
|
1021
|
+
assert.equal(file.exists, true);
|
|
1022
|
+
assert.equal(file.stale, false);
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('N7 - file.readText honors offset/limit and never exceeds bounds', async () => {
|
|
1026
|
+
fs.writeFileSync(path.join(root, 'data.txt'), '0123456789');
|
|
1027
|
+
const nodeId = await createNode('file', {
|
|
1028
|
+
title: 'Data',
|
|
1029
|
+
file: { source: 'workspace', path: 'data.txt', name: 'data.txt', mime: 'text/plain' },
|
|
1030
|
+
});
|
|
1031
|
+
const res = await nodeAction(nodeId, 'file.readText', { offset: 2, limit: 4 });
|
|
1032
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
1033
|
+
assert.equal(res.body.result.text, '2345');
|
|
1034
|
+
assert.equal(res.body.result.bytesRead, 4);
|
|
1035
|
+
assert.equal(res.body.result.truncated, true, 'a partial read must report truncated');
|
|
1036
|
+
|
|
1037
|
+
const beyond = await nodeAction(nodeId, 'file.readText', { offset: 20, limit: 4 });
|
|
1038
|
+
assert.equal(beyond.body.result.text, '');
|
|
1039
|
+
assert.equal(beyond.body.result.bytesRead, 0);
|
|
1040
|
+
assert.equal(beyond.body.result.truncated, false);
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it('N8 - file.refresh updates state/meta after the file changes on disk', async () => {
|
|
1044
|
+
fs.writeFileSync(path.join(root, 'grow.txt'), 'a');
|
|
1045
|
+
const nodeId = await createNode('file', {
|
|
1046
|
+
title: 'Grow',
|
|
1047
|
+
file: { source: 'workspace', path: 'grow.txt', name: 'grow.txt', mime: 'text/plain' },
|
|
1048
|
+
});
|
|
1049
|
+
const before = await nodeAction(nodeId, 'file.readMeta');
|
|
1050
|
+
assert.equal(before.body.result.file.size, 1);
|
|
1051
|
+
|
|
1052
|
+
fs.writeFileSync(path.join(root, 'grow.txt'), 'abcdef');
|
|
1053
|
+
const refreshed = await nodeAction(nodeId, 'file.refresh');
|
|
1054
|
+
assert.equal(refreshed.status, 200, JSON.stringify(refreshed.body));
|
|
1055
|
+
assert.equal(refreshed.body.result.file.size, 6);
|
|
1056
|
+
assert.equal(refreshed.body.result.file.exists, true);
|
|
1057
|
+
assert.equal(refreshed.body.result.file.stale, false);
|
|
1058
|
+
|
|
1059
|
+
const after = await nodeAction(nodeId, 'file.readMeta');
|
|
1060
|
+
assert.equal(after.body.result.file.size, 6);
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
describe('Excalidraw (N9-N12)', () => {
|
|
1065
|
+
it('N9 - readScene returns scene elements/appState/files', async () => {
|
|
1066
|
+
const nodeId = await createNode('excalidraw', { title: 'Diagram' });
|
|
1067
|
+
const res = await nodeAction(nodeId, 'excalidraw.readScene');
|
|
1068
|
+
assert.equal(res.status, 200, JSON.stringify(res.body));
|
|
1069
|
+
const scene = res.body.result.scene;
|
|
1070
|
+
assert.ok(Array.isArray(scene.elements), 'scene must carry elements');
|
|
1071
|
+
assert.ok(scene.appState && typeof scene.appState === 'object', 'scene must carry appState');
|
|
1072
|
+
assert.ok(scene.files && typeof scene.files === 'object', 'scene must carry files');
|
|
1073
|
+
assert.equal(res.body.result.revision, 1);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
it('N10 - saveScene persists the complete scene and grows revision', async () => {
|
|
1077
|
+
const nodeId = await createNode('excalidraw', { title: 'Diagram' });
|
|
1078
|
+
const scene = {
|
|
1079
|
+
elements: [{ id: 'el-1', type: 'rectangle', x: 0, y: 0, width: 10, height: 10 }],
|
|
1080
|
+
appState: { viewBackgroundColor: '#ffffff' },
|
|
1081
|
+
files: {},
|
|
1082
|
+
};
|
|
1083
|
+
const saved = await nodeAction(nodeId, 'excalidraw.saveScene', { scene });
|
|
1084
|
+
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
|
1085
|
+
assert.equal(saved.body.result.revision, 2);
|
|
1086
|
+
|
|
1087
|
+
const read = await nodeAction(nodeId, 'excalidraw.readScene');
|
|
1088
|
+
assert.equal(read.body.result.scene.elements.length, 1);
|
|
1089
|
+
assert.equal(read.body.result.scene.elements[0].id, 'el-1');
|
|
1090
|
+
assert.equal(read.body.result.scene.appState.viewBackgroundColor, '#ffffff');
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
it('N11 - patchScene applies an incremental patch and preserves old elements', async () => {
|
|
1094
|
+
const nodeId = await createNode('excalidraw', { title: 'Diagram' });
|
|
1095
|
+
await nodeAction(nodeId, 'excalidraw.saveScene', {
|
|
1096
|
+
scene: { elements: [{ id: 'el-1', type: 'rectangle' }], appState: {}, files: {} },
|
|
1097
|
+
});
|
|
1098
|
+
const patched = await nodeAction(nodeId, 'excalidraw.patchScene', {
|
|
1099
|
+
patch: { appState: { viewBackgroundColor: '#fafafa' } },
|
|
1100
|
+
});
|
|
1101
|
+
assert.equal(patched.status, 200, JSON.stringify(patched.body));
|
|
1102
|
+
|
|
1103
|
+
const read = await nodeAction(nodeId, 'excalidraw.readScene');
|
|
1104
|
+
assert.equal(read.body.result.scene.elements.length, 1, 'old elements must be preserved');
|
|
1105
|
+
assert.equal(read.body.result.scene.elements[0].id, 'el-1');
|
|
1106
|
+
assert.equal(read.body.result.scene.appState.viewBackgroundColor, '#fafafa');
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('N12 - invalid scene payload returns 4xx and leaves the old scene unchanged', async () => {
|
|
1110
|
+
const nodeId = await createNode('excalidraw', { title: 'Diagram' });
|
|
1111
|
+
const before = await nodeAction(nodeId, 'excalidraw.readScene');
|
|
1112
|
+
const invalid = await nodeAction(nodeId, 'excalidraw.saveScene', { scene: 'not-an-object' });
|
|
1113
|
+
assert.ok(invalid.status >= 400 && invalid.status < 500, `expected 4xx, got ${invalid.status}`);
|
|
1114
|
+
const after = await nodeAction(nodeId, 'excalidraw.readScene');
|
|
1115
|
+
assert.equal(after.body.result.revision, before.body.result.revision);
|
|
1116
|
+
assert.deepEqual(after.body.result.scene.elements, before.body.result.scene.elements);
|
|
1117
|
+
});
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
describe('Goal / Agent / Timer control (N13-N15)', () => {
|
|
1121
|
+
it('N13 - goal state machine: add/delete/replace/check/uncheck/complete/reopen', async () => {
|
|
1122
|
+
const goalNodeId = seedActiveTask(root, 'task-n13-goal');
|
|
1123
|
+
const goalAction = (action, body = {}) =>
|
|
1124
|
+
jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.${action}`, { method: 'POST', body });
|
|
1125
|
+
|
|
1126
|
+
const added = await goalAction('add', {
|
|
1127
|
+
planItems: [{ text: 'One' }, { text: 'Two' }],
|
|
1128
|
+
acceptance: [{ text: 'Accept N13' }],
|
|
1129
|
+
});
|
|
1130
|
+
assert.equal(added.status, 200, JSON.stringify(added.body));
|
|
1131
|
+
assert.equal(added.body.result.state.planItems.length, 2);
|
|
1132
|
+
assert.equal(added.body.result.state.planItems[0].id, 'P-001');
|
|
1133
|
+
assert.equal(added.body.result.state.planItems[1].id, 'P-002');
|
|
1134
|
+
assert.ok(added.body.result.revision > 0);
|
|
1135
|
+
|
|
1136
|
+
const deleted = await goalAction('delete', { planItemIds: ['P-002', 'P-999'] });
|
|
1137
|
+
assert.equal(deleted.body.result.state.planItems.length, 1);
|
|
1138
|
+
assert.equal(deleted.body.result.state.planItems[0].id, 'P-001');
|
|
1139
|
+
assert.deepEqual(deleted.body.result.skipped, ['P-999']);
|
|
1140
|
+
|
|
1141
|
+
const replaced = await goalAction('replace', {
|
|
1142
|
+
planItems: [{ id: 'P-001', text: 'One rewritten' }, { text: 'Fresh' }],
|
|
1143
|
+
});
|
|
1144
|
+
assert.equal(replaced.body.result.state.planItems.length, 2);
|
|
1145
|
+
assert.equal(replaced.body.result.state.planItems[0].id, 'P-001');
|
|
1146
|
+
assert.equal(replaced.body.result.state.planItems[0].text, 'One rewritten');
|
|
1147
|
+
assert.equal(replaced.body.result.state.planItems[1].id, 'P-002');
|
|
1148
|
+
assert.equal(replaced.body.result.state.planItems[1].text, 'Fresh');
|
|
1149
|
+
|
|
1150
|
+
const checked = await goalAction('check', { planItemIds: ['P-001', 'P-002'] });
|
|
1151
|
+
assert.equal(checked.body.result.state.planItems.every(item => item.status === 'done'), true);
|
|
1152
|
+
assert.equal(checked.body.result.state.status, 'active', 'checking items must not complete the Goal');
|
|
1153
|
+
|
|
1154
|
+
const completed = await goalAction('complete', { note: 'All done for N13', evidenceRefs: ['n13-tests'] });
|
|
1155
|
+
assert.equal(completed.status, 200, JSON.stringify(completed.body));
|
|
1156
|
+
assert.equal(completed.body.result.state.status, 'proposed-complete');
|
|
1157
|
+
assert.equal(completed.body.result.state.confirmation.state, 'proposed');
|
|
1158
|
+
assert.deepEqual(completed.body.result.state.confirmation.evidenceRefs, ['n13-tests']);
|
|
1159
|
+
|
|
1160
|
+
const reopened = await goalAction('reopen', { note: 'One more pass' });
|
|
1161
|
+
assert.equal(reopened.body.result.state.status, 'active');
|
|
1162
|
+
assert.equal(reopened.body.result.state.confirmation.state, 'returned');
|
|
1163
|
+
|
|
1164
|
+
const unchecked = await goalAction('uncheck', { planItemIds: ['P-001'] });
|
|
1165
|
+
assert.equal(unchecked.body.result.state.planItems[0].status, 'todo');
|
|
1166
|
+
assert.equal(unchecked.body.result.state.planItems[1].status, 'done');
|
|
1167
|
+
|
|
1168
|
+
const read = await goalAction('read');
|
|
1169
|
+
assert.equal(read.body.result.status, 'active');
|
|
1170
|
+
assert.equal(read.body.result.planItems.length, 2);
|
|
1171
|
+
assert.equal(read.body.result.taskId, 'task-n13-goal');
|
|
1172
|
+
});
|
|
1173
|
+
|
|
1174
|
+
it('N14 - agent unified API: readOutput/sendInput/sendMessage/readMessages via the same action route', async () => {
|
|
1175
|
+
// agent.sendInput is delegated to the server control plane, so the
|
|
1176
|
+
// acting session must live in the registry.
|
|
1177
|
+
const main = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo');
|
|
1178
|
+
const nodeId = main.nodeId;
|
|
1179
|
+
const sessionId = main.sessionId;
|
|
1180
|
+
seedAgentSession(root, 'agent-n14-b', 'session-n14-b', {});
|
|
1181
|
+
writeGraphEdges(root, [
|
|
1182
|
+
{ id: 'e-n14', from: nodeId, to: 'agent-n14-b', relation: 'delegation', direction: 'bidirectional' },
|
|
1183
|
+
]);
|
|
1184
|
+
const writes = { n14: [], b: [] };
|
|
1185
|
+
registerPty(sessionId, writes.n14);
|
|
1186
|
+
registerPty('session-n14-b', writes.b);
|
|
1187
|
+
const base = `/api/workflow/nodes/${nodeId}/actions/`;
|
|
1188
|
+
|
|
1189
|
+
const output = await jsonRequest(baseUrl, `${base}agent.readOutput`, { method: 'POST', body: { tail: 10 } });
|
|
1190
|
+
assert.equal(output.status, 200, JSON.stringify(output.body));
|
|
1191
|
+
assert.equal(output.body.result.sessionId, sessionId);
|
|
1192
|
+
assert.ok(Array.isArray(output.body.result.entries));
|
|
1193
|
+
|
|
1194
|
+
const input = await jsonRequest(baseUrl, `${base}agent.sendInput`, { method: 'POST', body: { data: 'input-n14' } });
|
|
1195
|
+
assert.equal(input.status, 200, JSON.stringify(input.body));
|
|
1196
|
+
assert.equal(input.body.result.ok, true);
|
|
1197
|
+
assert.deepEqual(writes.n14, ['input-n14']);
|
|
1198
|
+
|
|
1199
|
+
const message = await jsonRequest(baseUrl, `${base}agent.sendMessage`, {
|
|
1200
|
+
method: 'POST',
|
|
1201
|
+
body: { to: 'agent-n14-b', text: 'MSG_N14' },
|
|
1202
|
+
});
|
|
1203
|
+
assert.equal(message.status, 200, JSON.stringify(message.body));
|
|
1204
|
+
assert.equal(message.body.result.mode, 'direct');
|
|
1205
|
+
assert.equal(message.body.result.deliveries.length, 1);
|
|
1206
|
+
assert.equal(message.body.result.deliveries[0].ok, true);
|
|
1207
|
+
assert.equal(writes.b[0], 'M', 'the first char of the submitted body must be written synchronously');
|
|
1208
|
+
await waitFor(() => (writes.b.join('') === 'MSG_N14\r' ? writes.b : null), { timeout: 5000 });
|
|
1209
|
+
assert.equal(writes.b.join(''), 'MSG_N14\r', 'the typed body must join to the exact text + \\r');
|
|
1210
|
+
assert.equal(writes.b[writes.b.length - 1], '\r', 'the submitted body must end with a single \\r');
|
|
1211
|
+
assert.equal(writes.b.join('').includes('\n'), false, 'no \\n may be injected');
|
|
1212
|
+
assert.equal(writes.b.filter(w => w === '\r').length, 1, 'exactly one \\r (0x0D)');
|
|
1213
|
+
|
|
1214
|
+
const messages = await jsonRequest(baseUrl, `${base}agent.readMessages`, {
|
|
1215
|
+
method: 'POST',
|
|
1216
|
+
body: { peer: 'agent-n14-b' },
|
|
1217
|
+
});
|
|
1218
|
+
assert.equal(messages.status, 200);
|
|
1219
|
+
assert.equal(messages.body.result.entries.length, 1);
|
|
1220
|
+
assert.equal(messages.body.result.entries[0].data.replace(/\r$/, ''), 'MSG_N14');
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it('N15 - timer policy: read/configure/setInterval/enable/disable work; fire/tick/dispatchWakeup stay internal', async () => {
|
|
1224
|
+
const created = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1225
|
+
method: 'POST',
|
|
1226
|
+
body: { type: 'timer', title: 'N15 Policy Timer', enabled: false, schedule: { mode: 'interval', intervalSeconds: 60 } },
|
|
1227
|
+
});
|
|
1228
|
+
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
1229
|
+
const timerNodeId = created.body.node.nodeId;
|
|
1230
|
+
seedAgentSession(root, 'agent-n15', 'session-n15', {});
|
|
1231
|
+
const controlEdge = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
1232
|
+
method: 'POST',
|
|
1233
|
+
body: { from: 'agent-n15', to: timerNodeId, relation: 'control', direction: 'source-to-target' },
|
|
1234
|
+
});
|
|
1235
|
+
assert.equal(controlEdge.status, 201, JSON.stringify(controlEdge.body));
|
|
1236
|
+
const action = (name, body = {}) =>
|
|
1237
|
+
jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(timerNodeId)}/actions/timer.${name}`, { method: 'POST', body });
|
|
1238
|
+
|
|
1239
|
+
const read = await action('read');
|
|
1240
|
+
assert.equal(read.status, 200);
|
|
1241
|
+
assert.equal(read.body.result.enabled, false);
|
|
1242
|
+
assert.equal(read.body.result.schedule.intervalSeconds, 60);
|
|
1243
|
+
|
|
1244
|
+
const configured = await action('configure', { schedule: { intervalSeconds: 120 } });
|
|
1245
|
+
assert.equal(configured.status, 200, JSON.stringify(configured.body));
|
|
1246
|
+
assert.equal(configured.body.result.schedule.intervalSeconds, 120);
|
|
1247
|
+
|
|
1248
|
+
// Agent-authored control is gated by the control policy (defaults deny
|
|
1249
|
+
// disable and interval changes).
|
|
1250
|
+
const deniedInterval = await action('setInterval', { intervalSeconds: 30, actorNodeId: 'agent-n15' });
|
|
1251
|
+
assert.equal(deniedInterval.status, 403, JSON.stringify(deniedInterval.body));
|
|
1252
|
+
assert.equal(deniedInterval.body.error.code, 'CONTROL_DENIED');
|
|
1253
|
+
const deniedDisable = await action('disable', { actorNodeId: 'agent-n15' });
|
|
1254
|
+
assert.equal(deniedDisable.status, 403);
|
|
1255
|
+
assert.equal(deniedDisable.body.error.code, 'CONTROL_DENIED');
|
|
1256
|
+
|
|
1257
|
+
const unchanged = await action('read');
|
|
1258
|
+
assert.equal(unchanged.body.result.schedule.intervalSeconds, 120, 'denied control must not mutate the timer');
|
|
1259
|
+
assert.equal(unchanged.body.result.enabled, false);
|
|
1260
|
+
|
|
1261
|
+
// Internal runtime actions are never exposed to Agents over HTTP.
|
|
1262
|
+
const fire = await action('fire');
|
|
1263
|
+
assert.equal(fire.status, 403, JSON.stringify(fire.body));
|
|
1264
|
+
assert.equal(fire.body.error.code, 'TIMER_INTERNAL_ACTION_DENIED');
|
|
1265
|
+
const tick = await action('tick');
|
|
1266
|
+
assert.equal(tick.status, 403);
|
|
1267
|
+
assert.equal(tick.body.error.code, 'TIMER_INTERNAL_ACTION_DENIED');
|
|
1268
|
+
const dispatch = await action('dispatchWakeup');
|
|
1269
|
+
assert.equal(dispatch.status, 403);
|
|
1270
|
+
assert.equal(dispatch.body.error.code, 'TIMER_INTERNAL_ACTION_DENIED');
|
|
1271
|
+
|
|
1272
|
+
// Backend/human control still works through the same route. The policy
|
|
1273
|
+
// gates timer.disable for every actor by default (agentCanDisable
|
|
1274
|
+
// false), so a plain configure is the non-Agent control path.
|
|
1275
|
+
const enabled = await action('enable');
|
|
1276
|
+
assert.equal(enabled.status, 200, JSON.stringify(enabled.body));
|
|
1277
|
+
assert.equal(enabled.body.result.state.enabled, true);
|
|
1278
|
+
const disabled = await action('configure', { enabled: false });
|
|
1279
|
+
assert.equal(disabled.status, 200, JSON.stringify(disabled.body));
|
|
1280
|
+
assert.equal(disabled.body.result.enabled, false);
|
|
1281
|
+
|
|
1282
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-n15')}`);
|
|
1283
|
+
const timerRef = (ctx.body.context.connectedEventRefs || []).find(ref => ref.nodeId === timerNodeId);
|
|
1284
|
+
assert.ok(timerRef, 'agent context must expose the connected timer');
|
|
1285
|
+
assert.equal(timerRef.canControl, true, 'control edge must grant Agent timer control');
|
|
1286
|
+
assert.ok(timerRef.allowedActions.includes('timer.configure'));
|
|
1287
|
+
assert.ok(timerRef.allowedActions.includes('timer.enable'));
|
|
1288
|
+
assert.ok(!timerRef.allowedActions.includes('timer.dispatchWakeup'));
|
|
1289
|
+
assert.ok(!timerRef.allowedActions.includes('timer.fire'));
|
|
1290
|
+
assert.ok(!timerRef.allowedActions.includes('timer.tick'));
|
|
1291
|
+
assert.ok(!timerRef.allowedActions.includes('timer.disable'), 'policy denies Agent disable');
|
|
1292
|
+
|
|
1293
|
+
// The ontology affordance explicitly denies the backend-internal trio.
|
|
1294
|
+
const timerAffordance = (ctx.body.context.affordances || []).find(item => item.type === 'timer');
|
|
1295
|
+
assert.ok(timerAffordance, 'timer affordance must be present');
|
|
1296
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.dispatchWakeup'));
|
|
1297
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.fire'));
|
|
1298
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.tick'));
|
|
1299
|
+
});
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1304
|
+
// Prompt / Manual Matrix (P1-P6)
|
|
1305
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1306
|
+
describe('Prompt / Manual Matrix (P1-P6)', () => {
|
|
1307
|
+
it('P1 - Agent startup init.md carries identity, role profile fields, and roleProfileRef', async () => {
|
|
1308
|
+
const agent = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer', {
|
|
1309
|
+
displayName: 'Backend Worker',
|
|
1310
|
+
responsibility: 'Implement backend tests within the write set.',
|
|
1311
|
+
capabilities: ['api-backend', 'testing'],
|
|
1312
|
+
});
|
|
1313
|
+
const initPath = path.join(root, 'Harness', 'a2a', 'nodes', agent.sessionId, 'init.md');
|
|
1314
|
+
assert.ok(fs.existsSync(initPath), 'init.md must be written to the node home');
|
|
1315
|
+
const init = fs.readFileSync(initPath, 'utf8');
|
|
1316
|
+
assert.match(init, /- Display name: Backend Worker/);
|
|
1317
|
+
assert.match(init, /- Role title: implementer/);
|
|
1318
|
+
assert.match(init, /- Responsibility: Implement backend tests within the write set\./);
|
|
1319
|
+
assert.match(init, /- Capabilities: api-backend, testing/);
|
|
1320
|
+
assert.match(init, /- Role profile: Harness\/a2a\/agent-roles\/session-[0-9a-f-]+\.md — read this file; it is your identity and mandate\./);
|
|
1321
|
+
assert.match(init, /- Session: [0-9a-f-]+/);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
it('P2 - workflow-context includes identity, connectedPeers, connectedNodeManuals, allowedActions', async () => {
|
|
1325
|
+
seedAgentSession(root, 'agent-p2', 'session-p2', { agentKind: 'main', role: 'Main Agent' });
|
|
1326
|
+
seedAgentSession(root, 'agent-p2-b', 'session-p2-b', {});
|
|
1327
|
+
writeGraphEdges(root, [
|
|
1328
|
+
{ id: 'e-p2', from: 'agent-p2', to: 'agent-p2-b', relation: 'delegation', direction: 'bidirectional' },
|
|
1329
|
+
]);
|
|
1330
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-p2')}`);
|
|
1331
|
+
assert.equal(ctx.status, 200, JSON.stringify(ctx.body));
|
|
1332
|
+
const context = ctx.body.context;
|
|
1333
|
+
assert.equal(context.nodeId, 'agent-p2');
|
|
1334
|
+
assert.equal(context.sessionId, 'session-p2');
|
|
1335
|
+
assert.ok(context.identity, 'identity must be present');
|
|
1336
|
+
assert.equal(context.identity.nodeId, 'agent-p2');
|
|
1337
|
+
assert.ok(Array.isArray(context.connectedPeers), 'connectedPeers must be an array');
|
|
1338
|
+
assert.ok(
|
|
1339
|
+
context.connectedPeers.some(p => p.nodeId === 'agent-p2-b' && p.type === 'agent'),
|
|
1340
|
+
'connected peer must be listed',
|
|
1341
|
+
);
|
|
1342
|
+
assert.ok(Array.isArray(context.connectedNodeManuals), 'connectedNodeManuals must be an array');
|
|
1343
|
+
assert.ok(Array.isArray(context.availableActions), 'allowedActions must be exposed');
|
|
1344
|
+
assert.ok(context.availableActions.includes('agent.sendMessage'));
|
|
1345
|
+
assert.ok(context.availableActions.includes('agent.sendInput'));
|
|
1346
|
+
assert.ok(context.availableActions.includes('agent.readMessages'));
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
it('P3 - connecting a Markdown node injects the workflow-markdown-node manual', async () => {
|
|
1350
|
+
seedAgentSession(root, 'agent-p3', 'session-p3', {});
|
|
1351
|
+
const md = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1352
|
+
method: 'POST',
|
|
1353
|
+
body: { type: 'markdown', title: 'P3 Board', markdown: '# board' },
|
|
1354
|
+
});
|
|
1355
|
+
assert.equal(md.status, 201, JSON.stringify(md.body));
|
|
1356
|
+
const mdNodeId = md.body.node.nodeId;
|
|
1357
|
+
const edge = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
1358
|
+
method: 'POST',
|
|
1359
|
+
body: { from: 'agent-p3', to: mdNodeId, relation: 'wf-bridge' },
|
|
1360
|
+
});
|
|
1361
|
+
assert.equal(edge.status, 201, JSON.stringify(edge.body));
|
|
1362
|
+
|
|
1363
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-p3')}`);
|
|
1364
|
+
const manual = (ctx.body.context.connectedNodeManuals || []).find(m => m.nodeType === 'markdown');
|
|
1365
|
+
assert.ok(manual, `markdown manual must be injected: ${JSON.stringify(ctx.body.context.connectedNodeManuals)}`);
|
|
1366
|
+
assert.equal(manual.skillId, 'workflow-markdown-node');
|
|
1367
|
+
assert.equal(manual.nodeId, mdNodeId);
|
|
1368
|
+
assert.ok(manual.text && manual.text.length > 0, 'manual must carry agent-facing text');
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
it('P4 - connecting an Excalidraw node injects the workflow-diagram-node manual', async () => {
|
|
1372
|
+
seedAgentSession(root, 'agent-p4', 'session-p4', {});
|
|
1373
|
+
const draw = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1374
|
+
method: 'POST',
|
|
1375
|
+
body: { type: 'excalidraw', title: 'P4 Draw' },
|
|
1376
|
+
});
|
|
1377
|
+
assert.equal(draw.status, 201, JSON.stringify(draw.body));
|
|
1378
|
+
const drawNodeId = draw.body.node.nodeId;
|
|
1379
|
+
const edge = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
1380
|
+
method: 'POST',
|
|
1381
|
+
body: { from: 'agent-p4', to: drawNodeId, relation: 'wf-bridge' },
|
|
1382
|
+
});
|
|
1383
|
+
assert.equal(edge.status, 201, JSON.stringify(edge.body));
|
|
1384
|
+
|
|
1385
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-p4')}`);
|
|
1386
|
+
const manual = (ctx.body.context.connectedNodeManuals || []).find(m => m.nodeType === 'excalidraw');
|
|
1387
|
+
assert.ok(manual, 'excalidraw manual must be injected');
|
|
1388
|
+
assert.equal(manual.skillId, 'workflow-diagram-node');
|
|
1389
|
+
assert.equal(manual.nodeId, drawNodeId);
|
|
1390
|
+
assert.ok(manual.text && manual.text.length > 0);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('P5 - Timer and Goal connections inject manuals; Timer wakes, Goal does not', async () => {
|
|
1394
|
+
const goalNodeId = seedActiveTask(root, 'task-p5-goal');
|
|
1395
|
+
const created = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1396
|
+
method: 'POST',
|
|
1397
|
+
body: { type: 'timer', title: 'P5 Timer', enabled: false, schedule: { mode: 'manual', intervalSeconds: 60 } },
|
|
1398
|
+
});
|
|
1399
|
+
assert.equal(created.status, 201, JSON.stringify(created.body));
|
|
1400
|
+
const timerNodeId = created.body.node.nodeId;
|
|
1401
|
+
seedAgentSession(root, 'agent-p5', 'session-p5', {});
|
|
1402
|
+
const graph = loadWorkflowGraphMap(root);
|
|
1403
|
+
writeWorkflowGraphMap(root, {
|
|
1404
|
+
...graph,
|
|
1405
|
+
version: graph.version + 1,
|
|
1406
|
+
edges: [],
|
|
1407
|
+
capsuleDockLinks: [
|
|
1408
|
+
dockLink('agent-p5', timerNodeId),
|
|
1409
|
+
dockLink(timerNodeId, goalNodeId),
|
|
1410
|
+
dockLink('agent-p5', goalNodeId),
|
|
1411
|
+
],
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-p5')}`);
|
|
1415
|
+
const context = ctx.body.context;
|
|
1416
|
+
|
|
1417
|
+
const timerManual = (context.connectedNodeManuals || []).find(m => m.nodeType === 'timer');
|
|
1418
|
+
assert.ok(timerManual, 'timer manual must be injected');
|
|
1419
|
+
assert.equal(timerManual.skillId, 'workflow-timer-node');
|
|
1420
|
+
const goalManual = (context.connectedNodeManuals || []).find(m => m.nodeType === 'goal');
|
|
1421
|
+
assert.ok(goalManual, 'goal manual must be injected');
|
|
1422
|
+
assert.equal(goalManual.skillId, 'workflow-goal-node');
|
|
1423
|
+
|
|
1424
|
+
const timerRef = (context.connectedEventRefs || []).find(ref => ref.nodeId === timerNodeId);
|
|
1425
|
+
assert.ok(timerRef, 'timer ref must be present');
|
|
1426
|
+
assert.equal(timerRef.type, 'timer');
|
|
1427
|
+
assert.equal(timerRef.canControl, false, 'no control edge -> timer cannot be controlled by the Agent');
|
|
1428
|
+
assert.ok(!timerRef.allowedActions.includes('timer.dispatchWakeup'), 'agents cannot invoke timer.dispatchWakeup');
|
|
1429
|
+
assert.ok(!timerRef.allowedActions.includes('timer.enable'), 'no control edge -> no timer control actions');
|
|
1430
|
+
assert.ok(timerRef.deniedActions.includes('timer.enable'));
|
|
1431
|
+
|
|
1432
|
+
// The ontology affordance denies the backend-internal wakeup trio.
|
|
1433
|
+
const timerAffordance = (context.affordances || []).find(item => item.type === 'timer');
|
|
1434
|
+
assert.ok(timerAffordance, 'timer affordance must be present');
|
|
1435
|
+
assert.ok(timerAffordance.allowedActions.includes('timer.read'));
|
|
1436
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.dispatchWakeup'));
|
|
1437
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.fire'));
|
|
1438
|
+
assert.ok(timerAffordance.deniedActions.includes('timer.tick'));
|
|
1439
|
+
|
|
1440
|
+
const goalRef = (context.connectedGoalRefs || []).find(ref => ref.nodeId === goalNodeId);
|
|
1441
|
+
assert.ok(goalRef, 'goal ref must be present');
|
|
1442
|
+
assert.equal(goalRef.type, 'goal');
|
|
1443
|
+
assert.ok(goalRef.capabilities.includes('goal.read'));
|
|
1444
|
+
assert.ok(!goalRef.capabilities.some(cap => /wake/i.test(cap)), 'Goal exposes no wakeup action');
|
|
1445
|
+
assert.ok(
|
|
1446
|
+
!context.availableActions.some(action => /wake/i.test(action)),
|
|
1447
|
+
'Agent has no wakeup action of its own',
|
|
1448
|
+
);
|
|
1449
|
+
});
|
|
1450
|
+
|
|
1451
|
+
it('P6 - agent-facing guidance avoids A2A/broadcast/thread jargon and uses plain language', async () => {
|
|
1452
|
+
seedAgentSession(root, 'agent-p6', 'session-p6', {});
|
|
1453
|
+
const md = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1454
|
+
method: 'POST',
|
|
1455
|
+
body: { type: 'markdown', title: 'P6 Board', markdown: '# board' },
|
|
1456
|
+
});
|
|
1457
|
+
const edge = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
1458
|
+
method: 'POST',
|
|
1459
|
+
body: { from: 'agent-p6', to: md.body.node.nodeId, relation: 'wf-bridge' },
|
|
1460
|
+
});
|
|
1461
|
+
assert.equal(edge.status, 201, JSON.stringify(edge.body));
|
|
1462
|
+
|
|
1463
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent('agent-p6')}`);
|
|
1464
|
+
const context = ctx.body.context;
|
|
1465
|
+
const guidance = context.teamGuidance || '';
|
|
1466
|
+
assert.ok(guidance, 'team guidance must be injected into the agent context');
|
|
1467
|
+
assert.doesNotMatch(guidance, /\bA2A\b/, 'team guidance must avoid A2A terminology');
|
|
1468
|
+
assert.doesNotMatch(guidance, /\bbroadcast\b/i, 'team guidance must avoid broadcast terminology');
|
|
1469
|
+
assert.doesNotMatch(guidance, /\bthread\b/i, 'team guidance must avoid thread terminology');
|
|
1470
|
+
assert.match(guidance, /plain language/, 'team guidance must tell the agent to report in plain language');
|
|
1471
|
+
|
|
1472
|
+
for (const manual of context.connectedNodeManuals || []) {
|
|
1473
|
+
assert.doesNotMatch(manual.text, /\bA2A\b/, `${manual.skillId} manual must avoid A2A terminology`);
|
|
1474
|
+
assert.doesNotMatch(manual.text, /\bbroadcast\b/i, `${manual.skillId} manual must avoid broadcast terminology`);
|
|
1475
|
+
assert.doesNotMatch(manual.text, /\bthread\b/i, `${manual.skillId} manual must avoid thread terminology`);
|
|
1476
|
+
}
|
|
1477
|
+
});
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1481
|
+
// Node Map Lifecycle (E0)
|
|
1482
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1483
|
+
describe('Node Map Lifecycle (E0)', () => {
|
|
1484
|
+
it('E0 - clear node map via agent.deleteNodes (all=true) and verify empty/version-tracked state', async () => {
|
|
1485
|
+
// Seed: create several nodes (agents, components) to prove the map is populated.
|
|
1486
|
+
const main = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo', { displayName: 'E0 Controller' });
|
|
1487
|
+
const workerB = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer', { displayName: 'Worker B' });
|
|
1488
|
+
const workerC = await createAgentViaApi('claude', 'subagent', 'Subagent', 'reviewer', { displayName: 'Worker C' });
|
|
1489
|
+
const md = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1490
|
+
method: 'POST',
|
|
1491
|
+
body: { type: 'markdown', title: 'E0 Board', markdown: '# E0' },
|
|
1492
|
+
});
|
|
1493
|
+
assert.equal(md.status, 201, JSON.stringify(md.body));
|
|
1494
|
+
const mdNodeId = md.body.node.nodeId;
|
|
1495
|
+
const draw = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1496
|
+
method: 'POST',
|
|
1497
|
+
body: { type: 'excalidraw', title: 'E0 Diagram' },
|
|
1498
|
+
});
|
|
1499
|
+
assert.equal(draw.status, 201, JSON.stringify(draw.body));
|
|
1500
|
+
const drawNodeId = draw.body.node.nodeId;
|
|
1501
|
+
|
|
1502
|
+
const graphBefore = loadWorkflowGraphMap(root);
|
|
1503
|
+
assert.ok(graphBefore.nodes.length >= 5, `graph must contain at least 5 nodes, got ${graphBefore.nodes.length}`);
|
|
1504
|
+
const vBefore = graphBefore.version;
|
|
1505
|
+
|
|
1506
|
+
// Main Agent clears all deletable nodes via the API route.
|
|
1507
|
+
const cleared = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(main.nodeId)}/actions/agent.deleteNodes`, {
|
|
1508
|
+
method: 'POST',
|
|
1509
|
+
body: { all: true, actorNodeId: main.nodeId },
|
|
1510
|
+
});
|
|
1511
|
+
assert.equal(cleared.status, 200, JSON.stringify(cleared.body));
|
|
1512
|
+
assert.equal(cleared.body.ok, true);
|
|
1513
|
+
assert.equal(cleared.body.action, 'agent.deleteNodes');
|
|
1514
|
+
// The actor itself must be skipped, not deleted.
|
|
1515
|
+
assert.ok(
|
|
1516
|
+
(cleared.body.result.skippedNodeIds || []).includes(main.nodeId),
|
|
1517
|
+
`actor node must be skipped: ${JSON.stringify(cleared.body.result)}`,
|
|
1518
|
+
);
|
|
1519
|
+
assert.ok(cleared.body.result.deletedNodeIds.length >= 3, `must delete at least 3 non-actor nodes: ${JSON.stringify(cleared.body.result)}`);
|
|
1520
|
+
|
|
1521
|
+
// After clearing, the actor remains but other nodes are gone.
|
|
1522
|
+
const graphAfter = loadWorkflowGraphMap(root);
|
|
1523
|
+
const afterNodeIds = (graphAfter.nodes || []).map(n => n.nodeId || n.id);
|
|
1524
|
+
assert.ok(afterNodeIds.includes(main.nodeId), 'actor must survive the clear');
|
|
1525
|
+
assert.ok(!afterNodeIds.includes(workerB.nodeId), `worker B ${workerB.nodeId} must be gone`);
|
|
1526
|
+
assert.ok(!afterNodeIds.includes(workerC.nodeId), `worker C ${workerC.nodeId} must be gone`);
|
|
1527
|
+
assert.ok(!afterNodeIds.includes(mdNodeId), `markdown ${mdNodeId} must be gone`);
|
|
1528
|
+
assert.ok(!afterNodeIds.includes(drawNodeId), `excalidraw ${drawNodeId} must be gone`);
|
|
1529
|
+
assert.ok(graphAfter.version > vBefore, `version must advance: ${vBefore} -> ${graphAfter.version}`);
|
|
1530
|
+
|
|
1531
|
+
// Edge cleanup: edges referencing deleted nodes are removed.
|
|
1532
|
+
assert.equal((graphAfter.edges || []).length, 0, 'no edges may remain after full clear');
|
|
1533
|
+
assert.equal((graphAfter.capsuleDockLinks || []).length, 0, 'no dock links may remain after full clear');
|
|
1534
|
+
});
|
|
1535
|
+
|
|
1536
|
+
it('E0b - after clearing non-live nodes, snapshot returns a baseline state with only the surviving actor', async () => {
|
|
1537
|
+
// Seed: Main Agent (actor) + a non-live graph-only node (no session).
|
|
1538
|
+
// Live-agent nodes are skipped during bulk delete; this test proves
|
|
1539
|
+
// that non-live nodes are correctly removed.
|
|
1540
|
+
const main = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo', { displayName: 'E0b Lone Actor' });
|
|
1541
|
+
const md = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1542
|
+
method: 'POST',
|
|
1543
|
+
body: { type: 'markdown', title: 'E0b Board', markdown: '# E0b' },
|
|
1544
|
+
});
|
|
1545
|
+
assert.equal(md.status, 201, JSON.stringify(md.body));
|
|
1546
|
+
const mdNodeId = md.body.node.nodeId;
|
|
1547
|
+
const draw = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1548
|
+
method: 'POST',
|
|
1549
|
+
body: { type: 'excalidraw', title: 'E0b Diagram' },
|
|
1550
|
+
});
|
|
1551
|
+
assert.equal(draw.status, 201, JSON.stringify(draw.body));
|
|
1552
|
+
const drawNodeId = draw.body.node.nodeId;
|
|
1553
|
+
|
|
1554
|
+
assert.ok((loadWorkflowGraphMap(root).nodes || []).length >= 3, 'at least 3 nodes before clear');
|
|
1555
|
+
|
|
1556
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(main.nodeId)}/actions/agent.deleteNodes`, {
|
|
1557
|
+
method: 'POST',
|
|
1558
|
+
body: { all: true, actorNodeId: main.nodeId },
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
// Snapshot via both public routes must be consistent — only the actor survives.
|
|
1562
|
+
const workflowSnapshot = await jsonRequest(baseUrl, '/api/workflow');
|
|
1563
|
+
assert.equal(workflowSnapshot.status, 200);
|
|
1564
|
+
const a2aSnapshot = await jsonRequest(baseUrl, '/api/a2a/snapshot');
|
|
1565
|
+
assert.equal(a2aSnapshot.status, 200);
|
|
1566
|
+
assert.ok(a2aSnapshot.body.snapshotVersion > 1, 'snapshot version must be trackable');
|
|
1567
|
+
|
|
1568
|
+
for (const snapshot of [workflowSnapshot.body, a2aSnapshot.body]) {
|
|
1569
|
+
const nodeIds = (snapshot.nodes || []).map(n => n.nodeId || n.id);
|
|
1570
|
+
assert.ok(nodeIds.includes(main.nodeId), 'surviving actor must be present');
|
|
1571
|
+
assert.ok(!nodeIds.includes(mdNodeId), 'deleted markdown node must be absent');
|
|
1572
|
+
assert.ok(!nodeIds.includes(drawNodeId), 'deleted excalidraw node must be absent');
|
|
1573
|
+
// Edges are cleaned up when all deletable non-live nodes are removed.
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1579
|
+
// $wf-max Fanout Semantics (E7)
|
|
1580
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1581
|
+
describe('$wf-max Fanout Semantics (E7)', () => {
|
|
1582
|
+
it('E7 - Main Agent creates 3 role-distinct subagents, dispatches work, and aggregates replies', async () => {
|
|
1583
|
+
const ceo = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo', {
|
|
1584
|
+
displayName: 'CEO',
|
|
1585
|
+
responsibility: 'Coordinate fan-out.',
|
|
1586
|
+
});
|
|
1587
|
+
const roles = [
|
|
1588
|
+
{ roleTitle: 'implementer', displayName: 'Implementer', responsibility: 'Write code.' },
|
|
1589
|
+
{ roleTitle: 'reviewer', displayName: 'Reviewer', responsibility: 'Review code.' },
|
|
1590
|
+
{ roleTitle: 'tester', displayName: 'Tester', responsibility: 'Test code.' },
|
|
1591
|
+
];
|
|
1592
|
+
const workers = [];
|
|
1593
|
+
for (const role of roles) {
|
|
1594
|
+
const worker = await createAgentViaApi('claude', 'subagent', 'Subagent', role.roleTitle, {
|
|
1595
|
+
displayName: role.displayName,
|
|
1596
|
+
responsibility: role.responsibility,
|
|
1597
|
+
capabilities: ['terminal'],
|
|
1598
|
+
});
|
|
1599
|
+
workers.push(worker);
|
|
1600
|
+
// Connect CEO to each worker.
|
|
1601
|
+
const edge = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
1602
|
+
method: 'POST',
|
|
1603
|
+
body: { from: ceo.nodeId, to: worker.nodeId, relation: 'delegation', direction: 'bidirectional' },
|
|
1604
|
+
});
|
|
1605
|
+
assert.equal(edge.status, 201, JSON.stringify(edge.body));
|
|
1606
|
+
}
|
|
1607
|
+
const writes = { ceo: [], workers: [] };
|
|
1608
|
+
registerPty(ceo.sessionId, writes.ceo);
|
|
1609
|
+
for (const w of workers) {
|
|
1610
|
+
const ww = [];
|
|
1611
|
+
registerPty(w.sessionId, ww);
|
|
1612
|
+
writes.workers.push(ww);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// CEO broadcasts the same request to all three workers (fan-out).
|
|
1616
|
+
const targets = workers.map(w => w.nodeId);
|
|
1617
|
+
const broadcast = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(ceo.nodeId)}/actions/agent.broadcastMessage`, {
|
|
1618
|
+
method: 'POST',
|
|
1619
|
+
body: {
|
|
1620
|
+
to: targets,
|
|
1621
|
+
text: 'wf-max task: implement, review, and test the login module.',
|
|
1622
|
+
requestId: 'wf-max-req-1',
|
|
1623
|
+
threadId: 'wf-max-thread',
|
|
1624
|
+
toRole: 'worker',
|
|
1625
|
+
contextRefs: [{ nodeId: 'plan-md', relation: 'shared-context' }],
|
|
1626
|
+
},
|
|
1627
|
+
});
|
|
1628
|
+
assert.equal(broadcast.status, 200, JSON.stringify(broadcast.body));
|
|
1629
|
+
assert.equal(broadcast.body.result.mode, 'broadcast');
|
|
1630
|
+
assert.equal(broadcast.body.result.deliveredCount, 3);
|
|
1631
|
+
assert.equal(broadcast.body.result.failedCount, 0);
|
|
1632
|
+
assert.equal(broadcast.body.result.requestId, 'wf-max-req-1');
|
|
1633
|
+
// Each worker received the envelope with the shared requestId, typed
|
|
1634
|
+
// char-by-char (first char synchronously, rest at 12ms gaps + \\r).
|
|
1635
|
+
const fanoutEnvelope = '[harness-request wf-max-req-1 to-role=worker contextRefs=plan-md] wf-max task: implement, review, and test the login module.';
|
|
1636
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1637
|
+
assert.ok(writes.workers[i].length >= 1, `worker ${i} must receive the broadcast input`);
|
|
1638
|
+
assert.equal(writes.workers[i][0], '[', `worker ${i} must start typing the envelope prefix synchronously`);
|
|
1639
|
+
}
|
|
1640
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1641
|
+
await waitFor(() => (writes.workers[i].join('') === `${fanoutEnvelope}\r` ? writes.workers[i] : null), { timeout: 8000 });
|
|
1642
|
+
assert.ok(writes.workers[i].join('').includes('wf-max-req-1'));
|
|
1643
|
+
assert.ok(writes.workers[i].join('').includes('wf-max task'));
|
|
1644
|
+
assert.equal(writes.workers[i][writes.workers[i].length - 1], '\r', `worker ${i} envelope must end with the submit enter`);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
// Each worker replies with its own deliverable.
|
|
1648
|
+
const replyTexts = ['IMPLEMENTED: login module', 'REVIEWED: login module - LGTM', 'TESTED: login module - 3/3 pass'];
|
|
1649
|
+
for (let i = 0; i < 3; i += 1) {
|
|
1650
|
+
const reply = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(workers[i].nodeId)}/actions/agent.sendMessage`, {
|
|
1651
|
+
method: 'POST',
|
|
1652
|
+
body: { to: ceo.nodeId, text: replyTexts[i], requestId: 'wf-max-req-1', threadId: 'wf-max-thread' },
|
|
1653
|
+
});
|
|
1654
|
+
assert.equal(reply.status, 200, JSON.stringify(reply.body));
|
|
1655
|
+
assert.equal(reply.body.result.requestId, 'wf-max-req-1');
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// CEO aggregates by requestId — all 6 entries (3 broadcasts + 3 replies).
|
|
1659
|
+
const aggregated = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(ceo.nodeId)}/actions/agent.readMessages`, {
|
|
1660
|
+
method: 'POST',
|
|
1661
|
+
body: { requestId: 'wf-max-req-1' },
|
|
1662
|
+
});
|
|
1663
|
+
assert.equal(aggregated.status, 200, JSON.stringify(aggregated.body));
|
|
1664
|
+
assert.equal(aggregated.body.result.entries.length, 6, '3 dispatches + 3 replies');
|
|
1665
|
+
assert.ok(aggregated.body.result.entries.every(e => e.requestId === 'wf-max-req-1'));
|
|
1666
|
+
const replyEntries = aggregated.body.result.entries.filter(e => String(e.data).replace(/\r$/, '').match(/^(IMPLEMENTED|REVIEWED|TESTED):/));
|
|
1667
|
+
assert.equal(replyEntries.length, 3, 'all 3 workers replied');
|
|
1668
|
+
const replyData = replyEntries.map(e => String(e.data).replace(/\r$/, '')).sort();
|
|
1669
|
+
assert.deepEqual(replyData, replyTexts.sort());
|
|
1670
|
+
|
|
1671
|
+
// Every worker's role profile is preserved and distinct.
|
|
1672
|
+
for (const worker of workers) {
|
|
1673
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent(worker.nodeId)}`);
|
|
1674
|
+
assert.ok(ctx.body.context.identity.displayName, `worker ${worker.nodeId} must have a displayName`);
|
|
1675
|
+
assert.ok(ctx.body.context.identity.roleTitle, `worker ${worker.nodeId} must have a roleTitle`);
|
|
1676
|
+
}
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
it('E7b - broadcasted requestId aggregates across senders for multi-recipient inbox', async () => {
|
|
1680
|
+
const ceo = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo', { displayName: 'CEO' });
|
|
1681
|
+
const w1 = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer', { displayName: 'W1' });
|
|
1682
|
+
const w2 = await createAgentViaApi('claude', 'subagent', 'Subagent', 'reviewer', { displayName: 'W2' });
|
|
1683
|
+
for (const w of [w1, w2]) {
|
|
1684
|
+
const edge = await jsonRequest(baseUrl, '/api/workflow/edges', {
|
|
1685
|
+
method: 'POST',
|
|
1686
|
+
body: { from: ceo.nodeId, to: w.nodeId, relation: 'delegation', direction: 'bidirectional' },
|
|
1687
|
+
});
|
|
1688
|
+
assert.equal(edge.status, 201);
|
|
1689
|
+
registerPty(w.sessionId, []);
|
|
1690
|
+
}
|
|
1691
|
+
registerPty(ceo.sessionId, []);
|
|
1692
|
+
|
|
1693
|
+
// Two independent senders use the same requestId — replies aggregate together.
|
|
1694
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(ceo.nodeId)}/actions/agent.sendMessage`, {
|
|
1695
|
+
method: 'POST',
|
|
1696
|
+
body: { to: w1.nodeId, text: 'Task A', requestId: 'shared-req-1' },
|
|
1697
|
+
});
|
|
1698
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(ceo.nodeId)}/actions/agent.sendMessage`, {
|
|
1699
|
+
method: 'POST',
|
|
1700
|
+
body: { to: w2.nodeId, text: 'Task B', requestId: 'shared-req-1' },
|
|
1701
|
+
});
|
|
1702
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(w1.nodeId)}/actions/agent.sendMessage`, {
|
|
1703
|
+
method: 'POST',
|
|
1704
|
+
body: { to: ceo.nodeId, text: 'Result A', requestId: 'shared-req-1' },
|
|
1705
|
+
});
|
|
1706
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(w2.nodeId)}/actions/agent.sendMessage`, {
|
|
1707
|
+
method: 'POST',
|
|
1708
|
+
body: { to: ceo.nodeId, text: 'Result B', requestId: 'shared-req-1' },
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
const aggregated = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(ceo.nodeId)}/actions/agent.readMessages`, {
|
|
1712
|
+
method: 'POST',
|
|
1713
|
+
body: { requestId: 'shared-req-1' },
|
|
1714
|
+
});
|
|
1715
|
+
assert.equal(aggregated.status, 200);
|
|
1716
|
+
const entryTexts = aggregated.body.result.entries.map(e => String(e.data).replace(/\r$/, '')).sort();
|
|
1717
|
+
assert.deepEqual(entryTexts, ['Result A', 'Result B', 'Task A', 'Task B']);
|
|
1718
|
+
});
|
|
1719
|
+
});
|
|
1720
|
+
|
|
1721
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1722
|
+
// Final Evidence Summary (E18)
|
|
1723
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1724
|
+
describe('Final Evidence Summary (E18)', () => {
|
|
1725
|
+
it('E18 - Main Agent reads mailbox, Markdown, Goal, and Timer evidence and produces a summary', async () => {
|
|
1726
|
+
const main = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo', { displayName: 'Main' });
|
|
1727
|
+
const worker = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer', { displayName: 'Worker' });
|
|
1728
|
+
registerPty(main.sessionId, []);
|
|
1729
|
+
registerPty(worker.sessionId, []);
|
|
1730
|
+
|
|
1731
|
+
const goalNodeId = seedActiveTask(root, 'task-e18-summary');
|
|
1732
|
+
const md = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1733
|
+
method: 'POST',
|
|
1734
|
+
body: { type: 'markdown', title: 'E18 Shared Plan', markdown: '# E18 Plan\n- Step 1\n- Step 2' },
|
|
1735
|
+
});
|
|
1736
|
+
assert.equal(md.status, 201, JSON.stringify(md.body));
|
|
1737
|
+
const mdNodeId = md.body.node.nodeId;
|
|
1738
|
+
const timer = await createTimerViaApi('E18 Timer');
|
|
1739
|
+
const timerNodeId = timer.nodeId;
|
|
1740
|
+
|
|
1741
|
+
// Connect everything.
|
|
1742
|
+
for (const [from, to, rel] of [
|
|
1743
|
+
[main.nodeId, worker.nodeId, 'delegation'],
|
|
1744
|
+
[main.nodeId, mdNodeId, 'wf-bridge'],
|
|
1745
|
+
[main.nodeId, goalNodeId, 'goal'],
|
|
1746
|
+
]) {
|
|
1747
|
+
const edge = await jsonRequest(baseUrl, '/api/workflow/edges', { method: 'POST', body: { from, to, relation: rel } });
|
|
1748
|
+
assert.equal(edge.status, 201, JSON.stringify(edge.body));
|
|
1749
|
+
}
|
|
1750
|
+
// Magnetic group: main + timer + goal
|
|
1751
|
+
const graph = loadWorkflowGraphMap(root);
|
|
1752
|
+
writeWorkflowGraphMap(root, {
|
|
1753
|
+
...graph,
|
|
1754
|
+
version: graph.version + 1,
|
|
1755
|
+
capsuleDockLinks: [
|
|
1756
|
+
dockLink(main.nodeId, timerNodeId),
|
|
1757
|
+
dockLink(timerNodeId, goalNodeId),
|
|
1758
|
+
],
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
// Send structured requests and receive replies.
|
|
1762
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(main.nodeId)}/actions/agent.sendMessage`, {
|
|
1763
|
+
method: 'POST',
|
|
1764
|
+
body: { to: worker.nodeId, text: 'Please implement Step 1', requestId: 'e18-req-1', contextRefs: [{ nodeId: mdNodeId, relation: 'shared-context' }] },
|
|
1765
|
+
});
|
|
1766
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(worker.nodeId)}/actions/agent.sendMessage`, {
|
|
1767
|
+
method: 'POST',
|
|
1768
|
+
body: { to: main.nodeId, text: 'Step 1 done', requestId: 'e18-req-1' },
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
// Write shared markdown.
|
|
1772
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(mdNodeId)}/actions/markdown.append`, {
|
|
1773
|
+
method: 'POST',
|
|
1774
|
+
body: { markdown: '\n- Step 1 completed by Worker' },
|
|
1775
|
+
});
|
|
1776
|
+
|
|
1777
|
+
// Add and check Goal items.
|
|
1778
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.add`, {
|
|
1779
|
+
method: 'POST',
|
|
1780
|
+
body: { planItems: [{ text: 'Step 1' }, { text: 'Step 2' }] },
|
|
1781
|
+
});
|
|
1782
|
+
await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.check`, {
|
|
1783
|
+
method: 'POST',
|
|
1784
|
+
body: { planItemIds: ['P-001'] },
|
|
1785
|
+
});
|
|
1786
|
+
|
|
1787
|
+
// Arm the timer for a single wakeup.
|
|
1788
|
+
await armTimer(timerNodeId, 60);
|
|
1789
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
1790
|
+
await waitFor(() => {
|
|
1791
|
+
const entries = wakeupEntries(timerNodeId, main.sessionId);
|
|
1792
|
+
return entries.length ? entries : null;
|
|
1793
|
+
});
|
|
1794
|
+
|
|
1795
|
+
// ── Evidence aggregation: Main Agent reads every source ──
|
|
1796
|
+
const evidence = {};
|
|
1797
|
+
|
|
1798
|
+
// 1. Mailbox evidence.
|
|
1799
|
+
const mailbox = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(main.nodeId)}/actions/agent.readMessages`, {
|
|
1800
|
+
method: 'POST',
|
|
1801
|
+
body: { requestId: 'e18-req-1' },
|
|
1802
|
+
});
|
|
1803
|
+
assert.equal(mailbox.status, 200);
|
|
1804
|
+
assert.ok(mailbox.body.result.entries.length >= 2, 'mailbox must have dispatch + reply');
|
|
1805
|
+
evidence.mailboxCount = mailbox.body.result.entries.length;
|
|
1806
|
+
|
|
1807
|
+
// 2. Markdown evidence.
|
|
1808
|
+
const mdRead = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(mdNodeId)}/actions/markdown.read`, {
|
|
1809
|
+
method: 'POST',
|
|
1810
|
+
body: {},
|
|
1811
|
+
});
|
|
1812
|
+
assert.equal(mdRead.status, 200);
|
|
1813
|
+
assert.ok(mdRead.body.result.markdown.includes('Step 1 completed'), 'markdown must reflect progress');
|
|
1814
|
+
evidence.markdownRevision = mdRead.body.result.revision;
|
|
1815
|
+
|
|
1816
|
+
// 3. Goal evidence.
|
|
1817
|
+
const goalRead = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.read`, {
|
|
1818
|
+
method: 'POST',
|
|
1819
|
+
body: {},
|
|
1820
|
+
});
|
|
1821
|
+
assert.equal(goalRead.status, 200);
|
|
1822
|
+
assert.equal(goalRead.body.result.taskId, 'task-e18-summary');
|
|
1823
|
+
const doneItems = goalRead.body.result.planItems.filter(i => i.status === 'done');
|
|
1824
|
+
assert.ok(doneItems.length >= 1, 'at least one goal item must be done');
|
|
1825
|
+
evidence.goalDoneCount = doneItems.length;
|
|
1826
|
+
evidence.goalTotalCount = goalRead.body.result.planItems.length;
|
|
1827
|
+
|
|
1828
|
+
// 4. Timer evidence.
|
|
1829
|
+
const timerRead = await executeNodeAction(root, timerNodeId, 'timer.read', {});
|
|
1830
|
+
assert.ok(timerRead.result.eventCount >= 1, 'timer must have fired');
|
|
1831
|
+
evidence.timerEventCount = timerRead.result.eventCount;
|
|
1832
|
+
|
|
1833
|
+
// 5. Wakeup evidence.
|
|
1834
|
+
const wakeupEntriesForMain = wakeupEntries(timerNodeId, main.sessionId);
|
|
1835
|
+
assert.ok(wakeupEntriesForMain.length >= 1, 'main must have wakeup entries');
|
|
1836
|
+
const envelope = JSON.parse(wakeupEntriesForMain[0].data);
|
|
1837
|
+
assert.equal(envelope.type, 'wakeup');
|
|
1838
|
+
assert.equal(envelope.goalNodeId, goalNodeId);
|
|
1839
|
+
evidence.wakeupCount = wakeupEntriesForMain.length;
|
|
1840
|
+
|
|
1841
|
+
// 6. Context evidence.
|
|
1842
|
+
const ctx = await jsonRequest(baseUrl, `/api/workflow/context/${encodeURIComponent(main.nodeId)}`);
|
|
1843
|
+
assert.equal(ctx.status, 200);
|
|
1844
|
+
const connectedTypes = (ctx.body.context.connectedPeers || []).map(p => p.type);
|
|
1845
|
+
assert.ok(connectedTypes.includes('agent'), 'must have agent peer');
|
|
1846
|
+
evidence.connectedPeerCount = ctx.body.context.connectedPeers.length;
|
|
1847
|
+
evidence.manualCount = (ctx.body.context.connectedNodeManuals || []).length;
|
|
1848
|
+
|
|
1849
|
+
// ── Summary assertion: all evidence sources are non-empty and coherent ──
|
|
1850
|
+
assert.ok(evidence.mailboxCount >= 2, `mailbox: ${evidence.mailboxCount}`);
|
|
1851
|
+
// markdown starts at revision 1; one append → revision 2.
|
|
1852
|
+
assert.ok(evidence.markdownRevision >= 2, `markdown revision: ${evidence.markdownRevision}`);
|
|
1853
|
+
assert.ok(evidence.goalDoneCount >= 1, `goal done: ${evidence.goalDoneCount}`);
|
|
1854
|
+
assert.ok(evidence.goalTotalCount >= 2, `goal total: ${evidence.goalTotalCount}`);
|
|
1855
|
+
assert.ok(evidence.timerEventCount >= 1, `timer events: ${evidence.timerEventCount}`);
|
|
1856
|
+
assert.ok(evidence.wakeupCount >= 1, `wakeup count: ${evidence.wakeupCount}`);
|
|
1857
|
+
assert.ok(evidence.connectedPeerCount >= 1, `connected peers: ${evidence.connectedPeerCount}`);
|
|
1858
|
+
assert.ok(evidence.manualCount >= 1, `manuals: ${evidence.manualCount}`);
|
|
1859
|
+
|
|
1860
|
+
// The summary can be assembled entirely from the backend-derived evidence above.
|
|
1861
|
+
const summary = {
|
|
1862
|
+
taskId: 'task-e18-summary',
|
|
1863
|
+
mailbox: { entries: evidence.mailboxCount },
|
|
1864
|
+
markdown: { revision: evidence.markdownRevision },
|
|
1865
|
+
goal: { done: evidence.goalDoneCount, total: evidence.goalTotalCount },
|
|
1866
|
+
timer: { events: evidence.timerEventCount, wakeups: evidence.wakeupCount },
|
|
1867
|
+
connections: { peers: evidence.connectedPeerCount, manuals: evidence.manualCount },
|
|
1868
|
+
};
|
|
1869
|
+
assert.ok(summary.mailbox.entries >= 2);
|
|
1870
|
+
assert.ok(summary.goal.done >= 1);
|
|
1871
|
+
assert.ok(summary.timer.events >= 1);
|
|
1872
|
+
});
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1876
|
+
// E2E Chain (E1)
|
|
1877
|
+
// ══════════════════════════════════════════════════════════════════
|
|
1878
|
+
describe('E2E Chain (E1)', () => {
|
|
1879
|
+
it('E1 - full chain: create/connect agents, shared markdown, structured request, replies, timer wakeup, goal check', async () => {
|
|
1880
|
+
const main = await createAgentViaApi('claude', 'main', 'Main Agent', 'ceo', {
|
|
1881
|
+
displayName: 'Main',
|
|
1882
|
+
responsibility: 'Run the E1 chain.',
|
|
1883
|
+
});
|
|
1884
|
+
const workerB = await createAgentViaApi('claude', 'subagent', 'Subagent', 'implementer', { displayName: 'Worker B' });
|
|
1885
|
+
const workerC = await createAgentViaApi('claude', 'subagent', 'Subagent', 'reviewer', { displayName: 'Worker C' });
|
|
1886
|
+
const nodeA = main.nodeId;
|
|
1887
|
+
const nodeB = workerB.nodeId;
|
|
1888
|
+
const nodeC = workerC.nodeId;
|
|
1889
|
+
const writes = { a: [], b: [], c: [] };
|
|
1890
|
+
registerPty(main.sessionId, writes.a);
|
|
1891
|
+
registerPty(workerB.sessionId, writes.b);
|
|
1892
|
+
registerPty(workerC.sessionId, writes.c);
|
|
1893
|
+
|
|
1894
|
+
const goalNodeId = seedActiveTask(root, 'task-e1-chain');
|
|
1895
|
+
const md = await jsonRequest(baseUrl, '/api/workflow/nodes', {
|
|
1896
|
+
method: 'POST',
|
|
1897
|
+
body: { type: 'markdown', title: 'Shared Plan', markdown: '# Shared Plan' },
|
|
1898
|
+
});
|
|
1899
|
+
assert.equal(md.status, 201, JSON.stringify(md.body));
|
|
1900
|
+
const mdNodeId = md.body.node.nodeId;
|
|
1901
|
+
const timer = await createTimerViaApi('E1 Timer');
|
|
1902
|
+
const timerNodeId = timer.nodeId;
|
|
1903
|
+
|
|
1904
|
+
const edgeHelper = (from, to, relation, direction = 'bidirectional') =>
|
|
1905
|
+
jsonRequest(baseUrl, '/api/workflow/edges', { method: 'POST', body: { from, to, relation, direction } });
|
|
1906
|
+
assert.equal((await edgeHelper(nodeA, nodeB, 'delegation')).status, 201);
|
|
1907
|
+
assert.equal((await edgeHelper(nodeA, nodeC, 'delegation')).status, 201);
|
|
1908
|
+
assert.equal((await edgeHelper(nodeA, mdNodeId, 'wf-bridge')).status, 201);
|
|
1909
|
+
assert.equal((await edgeHelper(nodeA, goalNodeId, 'goal')).status, 201);
|
|
1910
|
+
|
|
1911
|
+
// A writes the shared Markdown (durable shared context).
|
|
1912
|
+
const written = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(mdNodeId)}/actions/markdown.append`, {
|
|
1913
|
+
method: 'POST',
|
|
1914
|
+
body: { markdown: '\n- E1 plan item', actorNodeId: nodeA },
|
|
1915
|
+
});
|
|
1916
|
+
assert.equal(written.status, 200, JSON.stringify(written.body));
|
|
1917
|
+
const mdRead = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(mdNodeId)}/actions/markdown.read`, {
|
|
1918
|
+
method: 'POST',
|
|
1919
|
+
body: {},
|
|
1920
|
+
});
|
|
1921
|
+
assert.ok(mdRead.body.result.markdown.includes('- E1 plan item'));
|
|
1922
|
+
|
|
1923
|
+
// A sends one structured request to B and broadcasts the same request to C.
|
|
1924
|
+
const request = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeA)}/actions/agent.sendMessage`, {
|
|
1925
|
+
method: 'POST',
|
|
1926
|
+
body: {
|
|
1927
|
+
to: nodeB,
|
|
1928
|
+
text: 'DO_E1_B',
|
|
1929
|
+
requestId: 'req-e1-1',
|
|
1930
|
+
toRole: 'implementer',
|
|
1931
|
+
contextRefs: [{ nodeId: mdNodeId, relation: 'shared-context' }],
|
|
1932
|
+
},
|
|
1933
|
+
});
|
|
1934
|
+
assert.equal(request.status, 200, JSON.stringify(request.body));
|
|
1935
|
+
assert.equal(request.body.result.requestId, 'req-e1-1');
|
|
1936
|
+
const broadcast = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeA)}/actions/agent.broadcastMessage`, {
|
|
1937
|
+
method: 'POST',
|
|
1938
|
+
body: { to: [nodeC], text: 'DO_E1_C', requestId: 'req-e1-1', toRole: 'reviewer' },
|
|
1939
|
+
});
|
|
1940
|
+
assert.equal(broadcast.status, 200, JSON.stringify(broadcast.body));
|
|
1941
|
+
const envelopeB = `[harness-request req-e1-1 to-role=implementer contextRefs=${mdNodeId}] DO_E1_B`;
|
|
1942
|
+
const envelopeC = '[harness-request req-e1-1 to-role=reviewer] DO_E1_C';
|
|
1943
|
+
assert.equal(writes.b[0], '[', 'B must start typing the envelope prefix synchronously');
|
|
1944
|
+
assert.equal(writes.c[0], '[', 'C must start typing the envelope prefix synchronously');
|
|
1945
|
+
await waitFor(() => (writes.b.join('') === `${envelopeB}\r` ? writes.b : null), { timeout: 8000 });
|
|
1946
|
+
await waitFor(() => (writes.c.join('') === `${envelopeC}\r` ? writes.c : null), { timeout: 8000 });
|
|
1947
|
+
assert.equal(writes.b.join(''), `${envelopeB}\r`, 'B envelope must join to the exact prefix + text + \\r');
|
|
1948
|
+
assert.equal(writes.c.join(''), `${envelopeC}\r`, 'C envelope must join to the exact prefix + text + \\r');
|
|
1949
|
+
assert.equal(writes.b[writes.b.length - 1], '\r', 'B sequence must end with the single \\r');
|
|
1950
|
+
assert.equal(writes.c[writes.c.length - 1], '\r', 'C sequence must end with the single \\r');
|
|
1951
|
+
assert.equal(writes.b.join('').includes('\n'), false, 'no \\n may be injected');
|
|
1952
|
+
assert.equal(writes.c.join('').includes('\n'), false, 'no \\n may be injected');
|
|
1953
|
+
assert.equal(writes.b.filter(w => w === '\r').length, 1, 'exactly one \\r (0x0D) on B');
|
|
1954
|
+
assert.equal(writes.c.filter(w => w === '\r').length, 1, 'exactly one \\r (0x0D) on C');
|
|
1955
|
+
|
|
1956
|
+
// B and C receive and reply with the same requestId.
|
|
1957
|
+
const replyB = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeB)}/actions/agent.sendMessage`, {
|
|
1958
|
+
method: 'POST',
|
|
1959
|
+
body: { to: nodeA, text: 'DONE_B', requestId: 'req-e1-1' },
|
|
1960
|
+
});
|
|
1961
|
+
const replyC = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeC)}/actions/agent.sendMessage`, {
|
|
1962
|
+
method: 'POST',
|
|
1963
|
+
body: { to: nodeA, text: 'DONE_C', requestId: 'req-e1-1' },
|
|
1964
|
+
});
|
|
1965
|
+
assert.equal(replyB.status, 200, JSON.stringify(replyB.body));
|
|
1966
|
+
assert.equal(replyC.status, 200, JSON.stringify(replyC.body));
|
|
1967
|
+
|
|
1968
|
+
// Timer wakes A through the magnetic group (dock links).
|
|
1969
|
+
const graph = loadWorkflowGraphMap(root);
|
|
1970
|
+
writeWorkflowGraphMap(root, {
|
|
1971
|
+
...graph,
|
|
1972
|
+
version: graph.version + 1,
|
|
1973
|
+
edges: graph.edges || [],
|
|
1974
|
+
capsuleDockLinks: [
|
|
1975
|
+
dockLink(nodeA, timerNodeId),
|
|
1976
|
+
dockLink(timerNodeId, goalNodeId),
|
|
1977
|
+
],
|
|
1978
|
+
});
|
|
1979
|
+
await armTimer(timerNodeId, 60);
|
|
1980
|
+
await syncTimerScheduler(root, { intervalMs: 20 });
|
|
1981
|
+
const wakeupEntriesForA = await waitFor(() => {
|
|
1982
|
+
const result = wakeupEntries(timerNodeId, main.sessionId);
|
|
1983
|
+
return result.length ? result : null;
|
|
1984
|
+
});
|
|
1985
|
+
const envelope = JSON.parse(wakeupEntriesForA[0].data);
|
|
1986
|
+
assert.equal(envelope.type, 'wakeup');
|
|
1987
|
+
assert.equal(envelope.timerNodeId, timerNodeId);
|
|
1988
|
+
assert.equal(envelope.goalNodeId, goalNodeId);
|
|
1989
|
+
|
|
1990
|
+
// A reads the Goal, aggregates replies, and checks the Goal item.
|
|
1991
|
+
const goalRead = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.read`, {
|
|
1992
|
+
method: 'POST',
|
|
1993
|
+
body: {},
|
|
1994
|
+
});
|
|
1995
|
+
assert.equal(goalRead.status, 200, JSON.stringify(goalRead.body));
|
|
1996
|
+
assert.equal(goalRead.body.result.taskId, 'task-e1-chain');
|
|
1997
|
+
const goalAdd = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.add`, {
|
|
1998
|
+
method: 'POST',
|
|
1999
|
+
body: { planItems: [{ text: 'E1 chain complete' }] },
|
|
2000
|
+
});
|
|
2001
|
+
assert.equal(goalAdd.status, 200, JSON.stringify(goalAdd.body));
|
|
2002
|
+
const checked = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(goalNodeId)}/actions/goal.check`, {
|
|
2003
|
+
method: 'POST',
|
|
2004
|
+
body: { planItemIds: ['P-001'] },
|
|
2005
|
+
});
|
|
2006
|
+
assert.equal(checked.status, 200, JSON.stringify(checked.body));
|
|
2007
|
+
assert.equal(checked.body.result.state.planItems[0].status, 'done');
|
|
2008
|
+
|
|
2009
|
+
const aggregated = await jsonRequest(baseUrl, `/api/workflow/nodes/${encodeURIComponent(nodeA)}/actions/agent.readMessages`, {
|
|
2010
|
+
method: 'POST',
|
|
2011
|
+
body: { requestId: 'req-e1-1' },
|
|
2012
|
+
});
|
|
2013
|
+
assert.equal(aggregated.status, 200, JSON.stringify(aggregated.body));
|
|
2014
|
+
assert.equal(aggregated.body.result.entries.length, 4, '2 asks + 2 replies in one request thread');
|
|
2015
|
+
const texts = aggregated.body.result.entries.map(e => String(e.data).replace(/\r$/, '')).sort();
|
|
2016
|
+
assert.deepEqual(texts, ['DONE_B', 'DONE_C', 'DO_E1_B', 'DO_E1_C']);
|
|
2017
|
+
assert.ok(aggregated.body.result.entries.every(e => e.requestId === 'req-e1-1'));
|
|
2018
|
+
|
|
2019
|
+
// The final graph snapshot contains every participant.
|
|
2020
|
+
const snapshot = await jsonRequest(baseUrl, '/api/workflow');
|
|
2021
|
+
const nodeIds = snapshot.body.nodes.map(n => n.nodeId || n.id);
|
|
2022
|
+
for (const expected of [nodeA, nodeB, nodeC, mdNodeId, timerNodeId, goalNodeId]) {
|
|
2023
|
+
assert.ok(nodeIds.includes(expected), `snapshot must contain ${expected}`);
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
});
|
|
2027
|
+
});
|