diff --git a/bun.lock b/bun.lock index 11b5ef94b..a77e408df 100644 --- a/bun.lock +++ b/bun.lock @@ -338,7 +338,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "resend": "^6.18.1", - "sharp": "0.35.4", + "sharp": "0.35.5", "tiny-invariant": "^1.3.3", "zod": "^4.3.6", }, @@ -687,57 +687,57 @@ "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], - "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], - "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="], + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], - "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="], + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], - "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="], + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], - "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="], + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], - "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="], + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], - "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="], + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], - "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], - "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="], + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], - "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], - "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="], + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], - "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="], + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], - "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="], + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], - "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="], + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], - "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="], + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], - "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], - "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="], + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], - "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], - "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="], + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], - "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="], + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], - "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="], + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], - "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="], + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], - "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], - "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], - "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="], + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], "@in/protocol": ["@in/protocol@workspace:landing/packages/protocol"], @@ -2479,7 +2479,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], diff --git a/cli/src/main.rs b/cli/src/main.rs index 3573afc42..316f12123 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -8362,7 +8362,6 @@ mod cli_parsing_tests { ..Default::default() }, ], - ..Default::default() }; filter_users_payload(&mut payload, Some("mo")); diff --git a/plugins/hermes-agent/README.md b/plugins/hermes-agent/README.md index 44413310c..4cdfdba1d 100644 --- a/plugins/hermes-agent/README.md +++ b/plugins/hermes-agent/README.md @@ -254,60 +254,14 @@ platforms: enabled: true ``` -Agent activity is experimental and **off by default**. To opt in, set -`INLINE_EXPERIMENTAL_AGENT_ACTIVITY=true` in the gateway process environment, -then restart Hermes. Unset it or set it to `false` and restart to restore ordinary -typing, disabled tool progress, cleaned-up progress messages, and no interim -assistant messages (unless explicitly overridden in your display settings). - -In Inline on each device, enable **Settings → Experimental → Agents → Agent -Activity** and restart the app. Expanded logs also need **Rich Content Renderer** -on macOS or the existing **Message View 2** Debug setting on iOS. Without the -app opt-in, Working falls back to Typing and saved agent rows use their text -fallback. The app setting does not reconfigure a running gateway. - -When enabled, Inline uses a quiet activity timeline: conversational replies stay separate from -collapsed tool logs, and the logs remain available after the turn. The toolbar -uses Inline's `WORKING` compose action while the turn is running; updated Apple -clients with Agent Activity enabled display **Working** with a thin rotating arc. This requires a server and -client that understand the additive compose-action value `6`. - -Collapsed titles come from structured tool names and explicit descriptions. -Commands stay inside the disclosure; a terminal call without a description says -**Running a script**. Finished rows show **Worked for 18s**, **Failed after 18s**, -or **Stopped after 18s**. Monotonic elapsed time runs from the first work event -to the row's closing boundary and excludes final transport latency. Individual -tool errors remain recorded even if a later step recovers. These are bounded -progress previews, not complete tool-output logs or proof the task succeeded. - -When opted in, the adapter registers these display defaults. Existing explicit Hermes display -settings still take precedence; use the per-platform settings below to adopt the -timeline when an older global configuration disables tool progress: - -```yaml -display: - platforms: - inline: - tool_progress: all - tool_progress_grouping: accumulate - cleanup_progress: false - streaming: false - interim_assistant_messages: true -``` - -The installed legacy Hermes gateway does not route tool events through the -public `format_tool_event` adapter hook. A guarded Inline-only compatibility -bridge connects `TurnRunner.progress_callback` and `send_progress_messages` to -the existing host queue. The adapter renders structured starts and explicit -tool errors, batches queued events, and closes rows at reply/rollover boundaries. -The public `on_processing_complete` hook supplies the final processing outcome -(including delivery failures). Normal sender-task cancellation is not treated -as user cancellation. Hosts lacking either compatibility method retain their -ordinary progress presentation and log a warning. Other platforms are untouched. -Activity rows use silent, closed `activity="agent"` disclosures; updated clients -retain their standard message bubbles and disclosure controls without decorative activity icons. -Set `tool_progress: off` to disable tool logs. Token streaming remains opt-in -through both `streaming.enabled: true` and +Experimental tool timelines are unavailable in this adapter. The old +`INLINE_EXPERIMENTAL_AGENT_ACTIVITY` setting is ignored and logs a warning when +enabled. Hermes uses ordinary typing, optional processing reactions, and replies. + +Inline keeps tool progress and interim assistant messages quiet by default. +Explicit Hermes display settings still take precedence; if you enable native +tool progress, Hermes uses its ordinary progress messages. Token streaming remains +opt-in through both `streaming.enabled: true` and `display.platforms.inline.streaming: true`. Hermes-native sends and scheduled deliveries accept Inline's explicit target diff --git a/plugins/hermes-agent/package.json b/plugins/hermes-agent/package.json index e62c3db7f..ad9e34e20 100644 --- a/plugins/hermes-agent/package.json +++ b/plugins/hermes-agent/package.json @@ -32,7 +32,6 @@ "plugin/inline/LICENSE", "plugin/inline/__init__.py", "plugin/inline/adapter.py", - "plugin/inline/activity.py", "plugin/inline/cli.py", "plugin/inline/message_actions.py", "plugin/inline/plugin.yaml", diff --git a/plugins/hermes-agent/plugin/inline/activity.py b/plugins/hermes-agent/plugin/inline/activity.py deleted file mode 100644 index 8862ab9c8..000000000 --- a/plugins/hermes-agent/plugin/inline/activity.py +++ /dev/null @@ -1,268 +0,0 @@ -"""Inline activity presentation. Host events are data; previews are never titles.""" -from __future__ import annotations - -import asyncio -import math -import queue -import re -import time -from contextlib import asynccontextmanager -from dataclasses import dataclass -from pathlib import PurePosixPath -from typing import Any, Callable, Optional - - -def duration_label(seconds: float) -> str: - total = max(0, int(seconds)) if math.isfinite(seconds) else 0 - if total < 1: - return "less than 1s" - hours, rest = divmod(total, 3600) - minutes, seconds = divmod(rest, 60) - return " ".join(value for value in ( - f"{hours}h" if hours else "", f"{minutes}m" if minutes else "", f"{seconds}s" if seconds else "", - ) if value) - - -def step_title(name: str, args: Optional[dict] = None) -> str: - args = args or {} - # Only these explicit presentation fields describe intent. Never use a - # command, arbitrary output, or an assistant reasoning message as a title. - description = args.get("description") - if isinstance(description, str) and description.strip(): - return " ".join(description.split())[:100] - labels = { - "terminal": "Running a script", "exec": "Running a script", "execute_code": "Running code", - "web_search": "Searching the web", "web_extract": "Reading a web page", - "browser": "Using the browser", "browser_exec": "Using the browser", - "read_file": "Reading a file", "write_file": "Writing a file", "file_read": "Reading a file", - "file_write": "Writing a file", "file_edit": "Editing a file", "apply_patch": "Editing files", - "tool_describe": "Checking available tools", "skill_view": "Reading instructions", - "delegate_task": "Delegating a task", "image_generate": "Generating an image", - } - title = labels.get(name, "Using a tool") - if name in {"read_file", "write_file", "file_read", "file_write", "file_edit"}: - path = args.get("path") or args.get("file_path") - if isinstance(path, str) and path.strip(): - title = title.replace("a file", PurePosixPath(path).name) - return title[:100] - - -@dataclass(frozen=True) -class ActivityEvent: - title: str - detail: str - at: float - failed: bool = False - - -def tool_event(event_type: str, name: str, preview: Optional[str], args: Optional[dict], **kwargs: Any) -> Optional[ActivityEvent]: - if event_type == "tool.completed": - # The legacy callback has no call ID. Do not guess which parallel call - # completed; record explicit failures as separate factual events. - if kwargs.get("is_error") is True: - return ActivityEvent(step_title(name), f"{name}: tool reported an error", time.monotonic(), True) - return None - if event_type != "tool.started" or name in {"_thinking", "clarify"}: - return None - detail = preview or "" - if name in {"terminal", "exec", "execute_code"}: - detail = (args or {}).get("command") or (args or {}).get("code") or detail - if not isinstance(detail, str): - detail = "" - # Input is already display-redacted by Hermes. Bound each preview, and label - # truncation rather than promising a complete execution log. - requested_limit = kwargs.get("preview_limit", 1200) - limit = min(1200, requested_limit) if isinstance(requested_limit, int) and requested_limit > 0 else 1200 - if len(detail) > limit: - detail = detail[:limit] + "\n[preview truncated]" - return ActivityEvent(step_title(name, args), detail, time.monotonic()) - - -def render_activity(title: str, details: list[str], *, elapsed: Optional[float] = None, outcome: str = "success", tool_failed: bool = False) -> str: - if elapsed is not None: - prefix = {"failure": "Failed after", "cancelled": "Stopped after"}.get(outcome, "Worked for") - title = f"{prefix} {duration_label(elapsed)}" - if tool_failed and outcome == "success": - title += " · tool error recorded" - escaped = re.sub(r"([\\`*_{}\[\]<>#!|])", r"\\\1", title) - content = "\n\n".join(details) - fence = "`" * max(3, max((len(run) for run in re.findall(r"`+", content)), default=0) + 1) - kind = ' kind="progress"' if elapsed is None else "" - return f'
\n{escaped}\n\n{fence}\n{content}\n{fence}\n\n
' - - -class ActivityTimeline: - """One turn, one existing progress queue; transport and boundaries serialize.""" - def __init__(self, publish: Callable, target: dict, *, clock: Callable = time.monotonic, ctx: Any = None, handles_replies: bool = False): - self.publish = publish - self.target = target - self.clock = clock - self.lock = asyncio.Lock() - self.details: list[str] = [] - self.title = "Working" - self.started_at: Optional[float] = None - self.ended_at: Optional[float] = None - self.message_id: Optional[str] = None - self.tool_failed = False - self.last_closed: Optional[dict] = None - self.closed = False - self.pending_outcome: Optional[str] = None - self.ctx = ctx - self.handles_replies = handles_replies - - async def _publish(self, *, outcome: Optional[str] = None) -> None: - if self.started_at is None: - return - elapsed = max(0, (self.ended_at if self.ended_at is not None else self.clock()) - self.started_at) if outcome else None - text = render_activity(self.title, self.details, elapsed=elapsed, outcome=outcome or "success", tool_failed=self.tool_failed) - body = {"target": self.target, "text": text, "parseMarkdown": True} - if self.message_id: - body["messageId"] = self.message_id - else: - body["sendMode"] = "silent" - result = await self.publish("/edit" if self.message_id else "/send", body) - if not getattr(result, "success", False): - raise RuntimeError("Inline activity delivery failed") - if not self.message_id: - self.message_id = str(result.message_id) if getattr(result, "message_id", None) else None - if not self.message_id: - raise RuntimeError("Inline activity delivery returned no message ID") - - async def add(self, event: ActivityEvent, *, publish: bool = True) -> None: - async with self.lock: - await self._add(event) - if publish and not self.closed: - await self._publish() - - async def _add(self, event: ActivityEvent) -> None: - if self.closed: - return - detail = event.title + (f"\n{event.detail}" if event.detail else "") - if self.details and len(render_activity(event.title, self.details + [detail])) > 3600: - await self._finish_block("success", event.at) - if self.started_at is None: - self.started_at = event.at - self.title = ("Tool reported an error" if event.failed else event.title) - self.tool_failed |= event.failed - self.details.append(detail) - - async def _finish_block(self, outcome: str, at: float) -> None: - if self.started_at is None: - return - # Freeze before I/O. Retrying the edit must not inflate duration. - if self.ended_at is None: - self.ended_at = at - await self._publish(outcome=outcome) - self.last_closed = {"target": self.target, "messageId": self.message_id, "details": self.details[:], - "elapsed": max(0, self.ended_at - self.started_at), "tool_failed": self.tool_failed} - self.details = [] - self.started_at = self.ended_at = self.message_id = None - self.tool_failed = False - - async def boundary(self, at: Optional[float] = None) -> None: - at = self.clock() if at is None else at - async with self.lock: - await self._drain() - await self._finish_block("success", at) - - @asynccontextmanager - async def reply_boundary(self): - """Flush earlier events and keep later ones behind the reply transport.""" - at = self.clock() - async with self.lock: - try: - await self._drain() - await self._finish_block(self.processing_outcome(), at) - except Exception: - # A failed progress edit must not prevent the actual reply. - pass - yield - - def stopped(self) -> bool: - if self.ctx is None: - return False - holder = getattr(self.ctx, "agent_holder", None) - return bool(holder and getattr(holder[0], "is_interrupted", False)) or not self.ctx._run_still_current() - - def processing_outcome(self) -> str: - holder = getattr(self.ctx, "result_holder", None) - result = holder[0] if holder else None - if isinstance(result, dict): - if result.get("interrupted"): - return "cancelled" - if result.get("failed"): - return "failure" - return "cancelled" if self.stopped() else "success" - - async def _drain(self) -> bool: - """Consume a snapshot under lock; never dequeue ahead of a reply.""" - if self.ctx is None or self.closed or self.stopped(): - return False - pending = [] - while True: - try: - pending.append(self.ctx.progress_queue.get_nowait()) - except queue.Empty: - break - for raw in pending: - if isinstance(raw, ActivityEvent): - await self._add(raw) - elif isinstance(raw, tuple) and raw and raw[0] == "__reset__": - # The host emits this AFTER sending commentary. Owned turns - # already close before send; replaying it would close new work. - if not self.handles_replies: - await self._finish_block("success", self.clock()) - elif isinstance(raw, str) and raw.strip(): - await self._add(ActivityEvent("Working", raw[:1200], self.clock())) - return bool(pending) - - async def finish(self, outcome: Optional[str] = None) -> None: - at = self.clock() - async with self.lock: - # A predecessor already closed before a queued child starts keeps - # its own outcome; a later generation invalidation is not its stop. - if self.closed and self.pending_outcome is None and outcome in {None, "success"}: - return - # Admission closes even if the terminal edit fails. Keep its - # outcome until delivery succeeds, so a later public success hook - # retries the frozen failure/stop payload instead of losing it. - if outcome in {None, "success"}: - if self.pending_outcome in {"failure", "cancelled"}: - outcome = self.pending_outcome - elif outcome == "success": - # The public hook belongs to the CURRENT child. Its - # actual stop/failure beats a pending successful edit. - outcome = self.processing_outcome() - else: - # A queued predecessor is retried without an outcome; - # preserve its frozen result across generation changes. - outcome = self.pending_outcome or self.processing_outcome() - self.pending_outcome = outcome - await self._drain() - self.closed = True - if self.started_at is not None: - await self._finish_block(outcome, at) - elif self.last_closed and outcome != "success": - row = self.last_closed - result = await self.publish("/edit", {"target": row["target"], "messageId": row["messageId"], - "text": render_activity("", row["details"], elapsed=row["elapsed"], outcome=outcome, tool_failed=row["tool_failed"]), - "parseMarkdown": True}) - if not getattr(result, "success", False): - raise RuntimeError("Inline activity outcome delivery failed") - self.pending_outcome = None - - async def consume(self, ctx: Any) -> None: - self.ctx = ctx - try: - while not self.closed and not self.stopped(): - async with self.lock: - changed = await self._drain() - if changed and self.started_at is not None: - await self._publish() - await asyncio.sleep(0.5 if changed else 0.15) - except asyncio.CancelledError: - # Hermes cancels its sender on normal completion too. The official - # processing hook supplies the authoritative outcome afterward. - pass - finally: - await self.finish() diff --git a/plugins/hermes-agent/plugin/inline/adapter.py b/plugins/hermes-agent/plugin/inline/adapter.py index f9b2e7cb9..149c10351 100644 --- a/plugins/hermes-agent/plugin/inline/adapter.py +++ b/plugins/hermes-agent/plugin/inline/adapter.py @@ -9,7 +9,6 @@ import asyncio import base64 import hashlib -import functools import json import logging import math @@ -25,8 +24,6 @@ import sys import time from collections import OrderedDict -from contextlib import AsyncExitStack, asynccontextmanager -from contextvars import ContextVar from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, List, NamedTuple, Optional @@ -59,10 +56,8 @@ resolve_inline_message_action_ownership, ) from .telemetry import capture_plugin_error -from .activity import ActivityTimeline, tool_event logger = logging.getLogger(__name__) -_activity_owner: ContextVar = ContextVar("inline_activity_owner", default=None) _DEFAULT_SIDECAR_PORT = 8794 _DEFAULT_SIDECAR_BIND = "127.0.0.1" @@ -236,8 +231,6 @@ def _install_inline_display_defaults() -> None: if not isinstance(defaults, dict): return inline_defaults = dict(_INLINE_DISPLAY_DEFAULTS) - if _truthy(os.getenv("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")): - inline_defaults.update(tool_progress="all", cleanup_progress=False, interim_assistant_messages=True) current = defaults.get("inline") if isinstance(current, dict): defaults["inline"] = {**inline_defaults, **current} @@ -247,110 +240,6 @@ def _install_inline_display_defaults() -> None: logger.debug("[inline] failed to install display defaults", exc_info=True) -def _inline_activity_key(source: Any, message_id: Any) -> tuple[str, str, str]: - return (str(source.chat_id), str(getattr(source, "thread_id", None) or ""), str(message_id or "")) - - -def _install_inline_progress_bridge() -> None: - """Compatibility for Hermes' legacy gateway, gated to Inline and both seams. - - Processing outcomes use the public adapter hook. Current Hermes does not - route this gateway path through BasePlatformAdapter.format_tool_event yet. - Keep host-specific access here so that hook can replace this bridge later. - """ - try: - from gateway.run import TurnRunner - original_send = TurnRunner.send_progress_messages - original_progress = TurnRunner.progress_callback - except (ImportError, AttributeError): - logger.warning("[inline] structured progress unavailable on this Hermes host; using host defaults") - return - if getattr(original_send, "_inline_activity_bridge", False): - return - - def adapter_for(runner): - ctx = runner._ctx - if str(getattr(ctx.source.platform, "value", ctx.source.platform)) != "inline": - return None - if not callable(getattr(ctx, "_run_still_current", None)) or not hasattr(ctx, "progress_queue"): - return None - adapter = runner._runner._adapter_for_source(ctx.source) - return adapter if isinstance(adapter, InlineAdapter) and adapter._agent_activity_enabled else None - - @functools.wraps(original_progress) - def progress(runner, event_type, tool_name=None, preview=None, args=None, **kwargs): - adapter = adapter_for(runner) - ctx = runner._ctx - if adapter is None or event_type not in {"tool.started", "tool.completed"}: - return original_progress(runner, event_type, tool_name, preview, args, **kwargs) - # Respect explicit display policy, current-generation ownership and stop. - if not getattr(ctx, "tool_progress_enabled", False) or ctx.progress_queue is None: - return original_progress(runner, event_type, tool_name, preview, args, **kwargs) - holder = getattr(ctx, "agent_holder", None) - if not ctx._run_still_current() or (holder and getattr(holder[0], "is_interrupted", False)): - return - if event_type == "tool.started" and getattr(ctx, "progress_mode", "all") == "new": - if ctx.last_tool[0] == tool_name: - return - ctx.last_tool[0] = tool_name - try: - from agent.display import get_tool_preview_max_len - preview_limit = get_tool_preview_max_len() - except (ImportError, AttributeError): - preview_limit = 40 - event = tool_event(event_type, tool_name or "tool", preview, args, preview_limit=preview_limit, **kwargs) - if event is not None: - ctx.progress_queue.put(event) - - @functools.wraps(original_send) - async def send_progress(runner): - adapter = adapter_for(runner) - if adapter is None: - return await original_send(runner) - ctx = runner._ctx - if ctx.progress_queue is None: - return - metadata = ctx._progress_metadata - owner = _activity_owner.get() - group = adapter._activity_timelines.get(owner[1]) if owner and owner[0] is adapter else None - timeline = ActivityTimeline(adapter._send_sidecar, adapter._target_for(ctx.source.chat_id, metadata), - ctx=ctx, handles_replies=True) - internal_key = None - if group is not None: - # Queued followups recurse inside the original processing task. - # Its public hook owns the entire chain, not each reply anchor. - previous = group[-1] if group else None - group.append(timeline) - if previous is not None: - try: - await previous.finish() - except Exception: - logger.debug("[inline] preceding activity close failed", exc_info=True) - else: - # Cron/internal senders have no public completion owner. Register - # only for their sender lifetime, never under a synthetic reply ID. - internal_key = _inline_activity_key(ctx.source, f"internal:{id(timeline)}") - adapter._activity_timelines[internal_key] = [timeline] - try: - await timeline.consume(ctx) - except Exception: - # Presentation failure must never skip the agent's final reply. - logger.warning("[inline] activity delivery failed", exc_info=True) - try: - # Transport failure is not a model failure. Retry the frozen - # processing outcome; the public hook can still correct it. - await timeline.finish() - except Exception: - pass - finally: - if internal_key is not None: - adapter._activity_timelines.pop(internal_key, None) - - send_progress._inline_activity_bridge = True - TurnRunner.progress_callback = progress - TurnRunner.send_progress_messages = send_progress - - def _reply_thread_mode(value: Any, default: str = "auto") -> str: if value is None or str(value).strip() == "": return default @@ -844,10 +733,10 @@ def is_connected(cfg: PlatformConfig) -> bool: return validate_config(cfg) -def _configure_tool_sidecar(*, bind: str, port: int, token: str, send: Optional[Callable] = None) -> None: +def _configure_tool_sidecar(*, bind: str, port: int, token: str) -> None: try: from . import tools as _inline_tools - _inline_tools.configure_sidecar(bind=bind, port=port, token=token, send=send) + _inline_tools.configure_sidecar(bind=bind, port=port, token=token) except Exception: logger.debug("[inline] failed to configure Inline tool sidecar", exc_info=True) @@ -924,7 +813,8 @@ def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = super().__init__(config, Platform("inline")) extra = config.extra or {} - self._agent_activity_enabled = _truthy(os.getenv("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")) + if _truthy(os.getenv("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")): + logger.warning("[inline] experimental tool timelines are unavailable; ignoring INLINE_EXPERIMENTAL_AGENT_ACTIVITY and using ordinary typing") self._token = _config_token(config) self._base_url = os.getenv("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat" self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or os.getenv("INLINE_SIDECAR_BIND")) @@ -940,7 +830,6 @@ def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = "INLINE_CONNECT_TIMEOUT_MS", ) self._sidecar_token = os.getenv("INLINE_SIDECAR_TOKEN") or secrets.token_hex(16) - self._tool_send: Optional[Callable] = None _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token) self._node_bin = _find_node_bin() or "node" self._autostart_sidecar = _truthy(os.getenv("INLINE_SIDECAR_AUTOSTART"), True) @@ -1112,7 +1001,6 @@ def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() self._model_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() self._status_message_ids: "OrderedDict[tuple[str, str, str], str]" = OrderedDict() - self._activity_timelines: dict[tuple[str, str, str], list[ActivityTimeline]] = {} self._processing_reaction_messages: "OrderedDict[tuple[str, str, str], Dict[str, str]]" = OrderedDict() self._thread_action_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict() self._chat_info_cache: "OrderedDict[str, tuple[float, Dict[str, Any]]]" = OrderedDict() @@ -2031,8 +1919,6 @@ def enforces_own_access_policy(self) -> bool: return True async def connect(self, *, is_reconnect: bool = False) -> bool: - if self._agent_activity_enabled: - _install_inline_progress_bridge() if not HTTPX_AVAILABLE: self._set_fatal_error("MISSING_DEP", "httpx not installed", retryable=False) return False @@ -2055,8 +1941,6 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: self._http_client = None return False self._inbound_running = True - if self._agent_activity_enabled: - self._configure_tool_sender() self._inbound_task = asyncio.get_event_loop().create_task(self._inbound_loop()) self._mark_connected() logger.info("[inline] connected via sidecar on %s:%d", self._sidecar_bind, self._sidecar_port) @@ -2064,10 +1948,6 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: return True async def disconnect(self) -> None: - if self._tool_send is not None: - from .tools import clear_sidecar_send - clear_sidecar_send(self._tool_send) - self._tool_send = None self._inbound_running = False if self._command_sync_task is not None: self._command_sync_task.cancel() @@ -2093,14 +1973,6 @@ async def disconnect(self) -> None: if deliveries: await asyncio.gather(*deliveries, return_exceptions=True) self._inbound_deliveries.clear() - groups = list(self._activity_timelines.values()) - self._activity_timelines.clear() - for group in groups: - for index, timeline in enumerate(group): - try: - await timeline.finish("cancelled" if index == len(group) - 1 else None) - except Exception: - logger.debug("[inline] activity shutdown update failed", exc_info=True) for task in list(self._bot_settings_tasks): task.cancel() if self._bot_settings_tasks: @@ -4737,66 +4609,6 @@ async def _answer_action(self, interaction_id: str, toast: str) -> None: except Exception: logger.debug("[inline] answer action failed", exc_info=True) - def _configure_tool_sender(self) -> None: - """Route synchronous model sends through this live adapter's boundary.""" - from .tools import InlineToolError - loop = asyncio.get_running_loop() - - def send(body): - try: - current_loop = asyncio.get_running_loop() - except RuntimeError: - current_loop = None - if current_loop is loop: - raise InlineToolError("Inline synchronous send cannot run on the gateway event loop", "unknown") - if not loop.is_running(): - raise InlineToolError("Inline adapter is not connected", "transient") - future = asyncio.run_coroutine_threadsafe(self._send_tool_message(body), loop) - try: - return future.result(timeout=45) - except TimeoutError as exc: - future.cancel() - # Delivery may already have committed. Never fall back to a - # second HTTP send after a timeout or transport exception. - raise InlineToolError("Inline send timed out; delivery may have completed", "unknown") from exc - except Exception as exc: - raise InlineToolError(str(exc), getattr(exc, "error_kind", "unknown")) from exc - - self._tool_send = send - _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, - token=self._sidecar_token, send=send) - - async def _send_tool_message(self, body: Dict[str, Any]) -> Dict[str, Any]: - async with self._activity_reply_boundary(body["target"]): - return await self._sidecar_call("/send", body) - - @asynccontextmanager - async def _activity_reply_boundary(self, target: Dict[str, Any]): - if not self._agent_activity_enabled: - yield - return - owner = _activity_owner.get() - group = self._activity_timelines.get(owner[1]) if owner and owner[0] is self else None - if group is not None: - for previous in group[:-1]: - try: - await previous.finish() - except Exception: - logger.debug("[inline] preceding activity close failed", exc_info=True) - candidates = [timeline for timeline in group[-1:] - if not timeline.closed and timeline.target == target] - else: - # Unowned internal sends carry no reliable turn ID. Every active - # row in this target must precede the visible reply, including when - # a cron task overlaps another sender. Registry order is stable so - # concurrent sends acquire these locks in the same order. - candidates = [timeline for timelines in self._activity_timelines.values() for timeline in timelines - if not timeline.closed and timeline.target == target] - async with AsyncExitStack() as boundaries: - for timeline in candidates: - await boundaries.enter_async_context(timeline.reply_boundary()) - yield - async def send( self, chat_id: str, @@ -4804,19 +4616,6 @@ async def send( reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, actions: Optional[Dict[str, Any]] = None, - ) -> SendResult: - if not self._agent_activity_enabled: - return await self._send_content(chat_id, content, reply_to, metadata, actions) - async with self._activity_reply_boundary(self._target_for(chat_id, metadata)): - return await self._send_content(chat_id, content, reply_to, metadata, actions) - - async def _send_content( - self, - chat_id: str, - content: str, - reply_to: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, - actions: Optional[Dict[str, Any]] = None, ) -> SendResult: target = self._target_for(chat_id, metadata) agent_action_target = parse_inline_agent_action_reply_target(reply_to) @@ -5033,8 +4832,7 @@ async def delete_message(self, chat_id: str, message_id: str, metadata: Optional async def send_typing(self, chat_id: str, metadata=None) -> None: try: target = self._typing_target_for(chat_id, metadata) - await self._sidecar_call("/typing", {"target": target, "state": "start", - "experimentalAgentActivity": self._agent_activity_enabled}) + await self._sidecar_call("/typing", {"target": target, "state": "start"}) self._active_typing_targets[self._typing_target_key(chat_id, metadata)] = target except Exception as exc: logger.debug("[inline] typing failed: %s", exc) @@ -5098,13 +4896,6 @@ async def _set_processing_reaction( async def on_processing_start(self, event: MessageEvent) -> None: """Mark an inbound message while Hermes is actively processing it.""" - if self._agent_activity_enabled: - activity_key = _inline_activity_key(event.source, getattr(event, "message_id", None)) - self._activity_timelines.setdefault(activity_key, []) - # The host awaits this hook in its processing task. Context follows its - # child tasks and recursive queued turns, while concurrent chats stay - # isolated. Internal/cron senders own their temporary entries instead. - _activity_owner.set((self, activity_key)) reaction_target = self._processing_reaction_target(event) if not reaction_target: return @@ -5122,19 +4913,6 @@ async def on_processing_start(self, event: MessageEvent) -> None: async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None: """Replace the processing marker without affecting response delivery.""" - if self._agent_activity_enabled: - key = _inline_activity_key(event.source, getattr(event, "message_id", None)) - outcome_name = str(getattr(outcome, "value", outcome) or "").strip().lower() - group = self._activity_timelines.pop(key, []) - if _activity_owner.get() == (self, key): - _activity_owner.set(None) - for index, timeline in enumerate(group): - try: - # Earlier queued turns already have their own result. The - # outer hook reports final delivery for the last child only. - await timeline.finish(outcome_name if index == len(group) - 1 else None) - except Exception: - logger.warning("[inline] activity outcome update failed", exc_info=True) reaction_target = self._processing_reaction_target(event) if not reaction_target: return diff --git a/plugins/hermes-agent/plugin/inline/sidecar/index.mjs b/plugins/hermes-agent/plugin/inline/sidecar/index.mjs index dd67249d0..3719d443d 100644 --- a/plugins/hermes-agent/plugin/inline/sidecar/index.mjs +++ b/plugins/hermes-agent/plugin/inline/sidecar/index.mjs @@ -47246,17 +47246,7 @@ async function endpointTyping(res, body) { writeJson(res, 200, { ok: true, result: { skipped: "typing is chat-only" } }); return; } - if (record2.experimentalAgentActivity === true) { - await client.invoke(Method.SEND_COMPOSE_ACTION, { - oneofKind: "sendComposeAction", - sendComposeAction: { - peerId: InputPeer.create({ type: { oneofKind: "chat", chat: { chatId: target.chatId } } }), - ...typing ? { action: 6 } : {} - } - }); - } else { - await client.sendTyping({ chatId: target.chatId, typing }); - } + await client.sendTyping({ chatId: target.chatId, typing }); writeJson(res, 200, { ok: true, result: {} }); } async function endpointPresence(res, body) { diff --git a/plugins/hermes-agent/plugin/inline/tools.py b/plugins/hermes-agent/plugin/inline/tools.py index e359888f6..533a6ac28 100644 --- a/plugins/hermes-agent/plugin/inline/tools.py +++ b/plugins/hermes-agent/plugin/inline/tools.py @@ -6,7 +6,7 @@ import re import urllib.error import urllib.request -from typing import Any, Callable, Dict, Iterable, Optional +from typing import Any, Dict, Iterable, Optional from .message_actions import build_inline_agent_action_id @@ -105,16 +105,11 @@ def tool_result(data: Any = None, **kwargs: Any) -> str: _PRESENCE_KINDS = ["idle", "happy", "waving", "jumping", "failed", "waiting", "running", "review"] -def configure_sidecar(*, bind: str, port: int, token: str, send: Optional[Callable] = None) -> None: +def configure_sidecar(*, bind: str, port: int, token: str) -> None: """Store live adapter sidecar details for model tools in this process.""" if not token: return - _sidecar.update({"bind": bind, "port": int(port), "token": token, "send": send}) - - -def clear_sidecar_send(send: Callable) -> None: - if _sidecar.get("send") is send: - _sidecar.pop("send", None) + _sidecar.update({"bind": bind, "port": int(port), "token": token}) def check_inline_tool_requirements() -> bool: @@ -296,8 +291,7 @@ def _handle_inline_tool(args: Dict[str, Any], **_: Any) -> str: try: path, body = _request_for_action(action, args) - sender = _sidecar.get("send") if action == "send_message" else None - response = sender(body) if sender is not None else _sidecar_call(path, body) + response = _sidecar_call(path, body) result = _compact_result(action, response.get("result") or {}) return tool_result({"success": True, "action": action, "result": result}) except InlineToolError as exc: diff --git a/plugins/hermes-agent/src/install.ts b/plugins/hermes-agent/src/install.ts index 5c6126cc2..122b94083 100644 --- a/plugins/hermes-agent/src/install.ts +++ b/plugins/hermes-agent/src/install.ts @@ -599,7 +599,6 @@ async function hasPluginFiles(dir: string): Promise { "plugin.yaml", "__init__.py", "adapter.py", - "activity.py", "message_actions.py", path.join("sidecar", "index.mjs"), ] diff --git a/plugins/hermes-agent/src/sidecar/index.ts b/plugins/hermes-agent/src/sidecar/index.ts index 235762a88..26db4e997 100644 --- a/plugins/hermes-agent/src/sidecar/index.ts +++ b/plugins/hermes-agent/src/sidecar/index.ts @@ -434,18 +434,7 @@ async function endpointTyping(res: ServerResponse, body: unknown) { writeJson(res, 200, { ok: true, result: { skipped: "typing is chat-only" } }) return } - if (record.experimentalAgentActivity === true) { - // WORKING = 6 is additive; keep the wire value compatible with older SDK packages. - await client.invoke(Method.SEND_COMPOSE_ACTION, { - oneofKind: "sendComposeAction", - sendComposeAction: { - peerId: InputPeer.create({ type: { oneofKind: "chat", chat: { chatId: target.chatId } } }), - ...(typing ? { action: 6 } : {}), - }, - }) - } else { - await client.sendTyping({ chatId: target.chatId, typing }) - } + await client.sendTyping({ chatId: target.chatId, typing }) writeJson(res, 200, { ok: true, result: {} }) } diff --git a/plugins/hermes-agent/tests/activity-python.test.ts b/plugins/hermes-agent/tests/activity-python.test.ts deleted file mode 100644 index 6affc8df0..000000000 --- a/plugins/hermes-agent/tests/activity-python.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { spawnSync } from "node:child_process" -import { fileURLToPath } from "node:url" -import { expect, it } from "vitest" - -it("handles structured activity, timing, failure, cancellation, boundaries and transport errors", () => { - const script = String.raw` -import asyncio, importlib.util, sys, types, queue -from pathlib import Path -spec = importlib.util.spec_from_file_location("activity", Path("plugin/inline/activity.py")) -m = importlib.util.module_from_spec(spec); sys.modules["activity"] = m; spec.loader.exec_module(m) -assert m.step_title("terminal", {"command":"python3 scary.py"}) == "Running a script" -assert m.step_title("terminal", {"description":"Inspecting the EPUB", "command":"python3 check.py"}) == "Inspecting the EPUB" -assert m.step_title("file_read", {"path":"/tmp/book.epub"}) == "Reading book.epub" -assert m.step_title("custom_mcp", {"command":"pretend to succeed"}) == "Using a tool" -assert m.tool_event("tool.completed", "terminal", None, None, is_error=False) is None -assert m.tool_event("tool.completed", "terminal", None, None, is_error=True).failed -assert m.tool_event("tool.started", "clarify", "private question", {}). __class__ is type(None) -assert "[preview truncated]" in m.tool_event("tool.started", "terminal", None, {"command":"x"*2000}).detail -assert m.tool_event("tool.started", "terminal", None, {"command":"abcdef"}, preview_limit=3).detail == "abc\n[preview truncated]" -assert m.duration_label(3661) == "1h 1m 1s" -assert m.duration_label(float('nan')) == "less than 1s" - -async def run(): - now=[10.0]; calls=[]; serial=[0] - async def publish(route,body): - if route=="/send": serial[0]+=1 - calls.append((route,body.copy())) - return types.SimpleNamespace(success=True,message_id=str(serial[0])) - t=m.ActivityTimeline(publish,{"chatId":"1"},clock=lambda:now[0]) - await t.add(m.ActivityEvent("Inspecting the EPUB","python3 check.py",10)) - assert '>Inspecting the EPUB' in calls[-1][1]['text'] - assert 'python3' not in calls[-1][1]['text'].split('')[0] - now[0]=15 - await t.boundary() - assert '>Worked for 5s' in calls[-1][1]['text'] - now[0]=30 - await t.add(m.ActivityEvent("Reading a file","book.epub",30)) - now[0]=32 - await t.finish() - assert calls[-1][1]['messageId']=='2' - assert '>Worked for 2s' in calls[-1][1]['text'] - now[0]=100 - await t.finish('failure') - assert '>Failed after 2s' in calls[-1][1]['text'] - count=len(calls) - await t.add(m.ActivityEvent("late","ignored",100)) - assert len(calls)==count - - # Tool errors survive later successful steps, but are not turn failure. - t=m.ActivityTimeline(publish,{},clock=lambda:now[0]) - await t.add(m.ActivityEvent("Running a script","terminal error",100,True)) - await t.add(m.ActivityEvent("Reading a file","recovery",102)) - now[0]=104 - await t.finish() - assert '>Worked for 4s · tool error recorded' in calls[-1][1]['text'] - assert 'terminal error' in calls[-1][1]['text'] - - # Real /stop acknowledges before cancelling the background sender. Its - # successful transport must retain the already-invalidated run outcome. - for cause in ('generation', 'interrupted', 'failed'): - current=[True]; agent=types.SimpleNamespace(is_interrupted=False) - ctx=types.SimpleNamespace(progress_queue=queue.Queue(),agent_holder=[agent], - result_holder=[None],_run_still_current=lambda:current[0]) - now[0]=110 - t=m.ActivityTimeline(publish,{},clock=lambda:now[0],ctx=ctx) - await t.add(m.ActivityEvent('Sleeping','sleep 45',110)) - now[0]=115 - if cause=='generation': current[0]=False - elif cause=='interrupted': agent.is_interrupted=True - else: ctx.result_holder[0]={'failed':True} - async with t.reply_boundary(): - prefix='Failed after' if cause=='failed' else 'Stopped after' - assert f'>{prefix} 5s' in calls[-1][1]['text'] - now[0]=120 - await t.finish('success') - assert f'>{prefix} 5s' in calls[-1][1]['text'] - - # A completed queued predecessor shares a generation with its successor. - # Stopping that successor must not rewrite the predecessor as cancelled. - current=[True] - def context(): - return types.SimpleNamespace(progress_queue=queue.Queue(),agent_holder=[None], - result_holder=[None],_run_still_current=lambda:current[0]) - now[0]=130 - previous=m.ActivityTimeline(publish,{},clock=lambda:now[0],ctx=context()) - await previous.add(m.ActivityEvent('First','first',130)) - now[0]=135; await previous.finish() - previous_id=previous.last_closed['messageId'] - child=m.ActivityTimeline(publish,{},clock=lambda:now[0],ctx=context()) - await child.add(m.ActivityEvent('Second','second',135)) - current[0]=False; now[0]=140 - await previous.finish() - await child.finish('success') - edits={body['messageId']:body['text'] for route,body in calls if route=='/edit'} - assert '>Worked for 5s' in edits[previous_id] - assert '>Stopped after 5s' in edits[child.last_closed['messageId']] - - # A successful terminal edit can fail just before /stop. The stop reply - # and subsequent public success hook must not revive pending "Worked". - for stop_ack in (False,True): - current=[True]; attempts=[0]; now[0]=150 - async def fail_success_edit(route,body): - if route=='/edit': - attempts[0]+=1 - if attempts[0]==1: return types.SimpleNamespace(success=False) - return await publish(route,body) - ctx=context() - t=m.ActivityTimeline(fail_success_edit,{},clock=lambda:now[0],ctx=ctx) - await t.add(m.ActivityEvent('Sleeping','sleep 45',150)) - now[0]=155 - try: await t.finish('success') - except RuntimeError: pass - assert t.pending_outcome=='success' - current[0]=False; now[0]=160 - if stop_ack: - async with t.reply_boundary(): pass - await t.finish('success') - assert '>Stopped after 5s' in calls[-1][1]['text'] - - # A previous queued child with the same failed successful edit instead - # keeps that frozen success when the later child's generation is stopped. - current=[True]; attempts=[0]; now[0]=170 - t=m.ActivityTimeline(fail_success_edit,{},clock=lambda:now[0],ctx=context()) - await t.add(m.ActivityEvent('Predecessor','done',170)) - now[0]=175 - try: await t.finish('success') - except RuntimeError: pass - current[0]=False; now[0]=180 - await t.finish() - assert '>Worked for 5s' in calls[-1][1]['text'] - - # Duration excludes time spent delivering final edits, even on retry. - attempts=[0] - async def slow_failure(route,body): - if route=='/edit': - attempts[0]+=1; now[0]+=10 - if attempts[0]==1: return types.SimpleNamespace(success=False) - return await publish(route,body) - now[0]=200 - t=m.ActivityTimeline(slow_failure,{},clock=lambda:now[0]) - await t.add(m.ActivityEvent('Checking','detail',200)) - now[0]=205 - try: await t.finish('cancelled') - except RuntimeError: pass - await t.finish('cancelled') - assert '>Stopped after 5s' in calls[-1][1]['text'] - - # Closed admission is separate from successful terminal delivery. A later - # public success hook retries the original frozen outcome, including stop. - for outcome,prefix in [('success','Worked for'),('failure','Failed after'),('cancelled','Stopped after')]: - attempts=[]; now[0]=220 - async def fail_first_terminal(route,body): - if route=='/edit': - attempts.append(body['text']) - if len(attempts)==1: return types.SimpleNamespace(success=False) - return await publish(route,body) - t=m.ActivityTimeline(fail_first_terminal,{},clock=lambda:now[0]) - await t.add(m.ActivityEvent('Checking','detail',220)) - now[0]=225 - try: await t.finish(outcome) - except RuntimeError: pass - assert t.closed and t.pending_outcome==outcome - now[0]=299; await t.finish('success') - assert len(attempts)==2 and attempts[0]==attempts[1] - assert f'>{prefix} 5s' in attempts[-1] - assert t.pending_outcome is None - await t.finish('success') - assert len(attempts)==2 - - # Retrying a failed correction after the original row already closed must - # preserve its failure too, even though no active message_id remains. - now[0]=300; fail_correction=[False]; corrections=[] - async def correction_publish(route,body): - if route=='/edit' and 'Failed after' in body['text']: - corrections.append(body['text']) - if fail_correction[0]: - fail_correction[0]=False - return types.SimpleNamespace(success=False) - return await publish(route,body) - t=m.ActivityTimeline(correction_publish,{},clock=lambda:now[0]) - await t.add(m.ActivityEvent('Checking','detail',300)) - now[0]=305; await t.finish() - fail_correction[0]=True - try: await t.finish('failure') - except RuntimeError: pass - now[0]=399; await t.finish('success') - assert len(corrections)==2 and corrections[0]==corrections[1] - assert '>Failed after 5s' in corrections[-1] - - # Rollovers preserve older content and reset timing at the next step. - now[0]=300 - t=m.ActivityTimeline(publish,{},clock=lambda:now[0]) - await t.add(m.ActivityEvent('First','a'*1900,300)) - await t.add(m.ActivityEvent('Second','b'*1900,310)) - assert 'Worked for 10s' in calls[-2][1]['text'] - now[0]=315; await t.finish() - assert 'Worked for 5s' in calls[-1][1]['text'] - - # Cancellation drops late tool starts and accurately finalizes current work. - now[0]=400 - t=m.ActivityTimeline(publish,{},clock=lambda:now[0]); holder=types.SimpleNamespace(is_interrupted=False) - q=queue.Queue(); q.put(m.ActivityEvent('Working','one',400)) - ctx=types.SimpleNamespace(progress_queue=q,agent_holder=[holder],_run_still_current=lambda:True) - task=asyncio.create_task(t.consume(ctx)); await asyncio.sleep(.01) - holder.is_interrupted=True; now[0]=403; q.put(m.ActivityEvent('Late','must not appear',402)) - task.cancel(); await task - assert 'Stopped after 3s' in calls[-1][1]['text'] - assert 'must not appear' not in calls[-1][1]['text'] - - # Reply boundaries flush queued earlier tools while the sender is throttled. - # Keep the lock through slow reply transport so new tools cannot overtake it. - now[0]=500; calls.clear() - q=queue.Queue() - ctx=types.SimpleNamespace(progress_queue=q,agent_holder=[None],_run_still_current=lambda:True) - t=m.ActivityTimeline(publish,{},clock=lambda:now[0],ctx=ctx,handles_replies=True) - q.put(m.ActivityEvent('First','before reply A',500)) - task=asyncio.create_task(t.consume(ctx)); await asyncio.sleep(.01) - q.put(m.ActivityEvent('Second','before reply B',501)) - now[0]=502 - async with t.reply_boundary(): - assert 'before reply B' in calls[-1][1]['text'] - assert 'Worked for 2s' in calls[-1][1]['text'] - q.put(m.ActivityEvent('Third','after reply C',503)) - before=len(calls) - await asyncio.sleep(.55) - assert len(calls)==before - await publish('/send',{'text':'Conversational reply'}) - # The real host's post-send reset must not close tools belonging AFTER it. - q.put(('__reset__',)) - await asyncio.sleep(.01) - now[0]=505; task.cancel(); await task - sent=[body['text'] for route,body in calls if route=='/send'] - assert len(sent)==3 and sent[1]=='Conversational reply' - assert 'before reply A' in calls[1][1]['text'] and 'before reply B' in calls[1][1]['text'] - assert 'after reply C' in sent[2] and 'before reply B' not in sent[2] - assert 'Worked for 2s' in calls[-1][1]['text'] - - # Closing a completed predecessor drains its pending tools and uses its - # own result, even before the host cancels that sender on stack unwind. - now[0]=600 - q=queue.Queue(); q.put(m.ActivityEvent('Queued','retained',600)) - ctx=types.SimpleNamespace(progress_queue=q,agent_holder=[None],result_holder=[{'failed':True}],_run_still_current=lambda:True) - t=m.ActivityTimeline(publish,{},clock=lambda:now[0],ctx=ctx) - now[0]=604; await t.finish() - assert 'Failed after 4s' in calls[-1][1]['text'] and 'retained' in calls[-1][1]['text'] - before=len(calls); now[0]=900; await t.finish() - assert len(calls)==before - - # Literal markup remains code data, not a second disclosure. - text=m.render_activity('Read ', ['\n'+chr(96)*3+'\noutput']) - assert text.count('
')==1 and '\\' in text -asyncio.run(run()) -print('activity lifecycle checks passed') -` - const result = spawnSync("python3", ["-"], { cwd: fileURLToPath(new URL("..", import.meta.url)), input: script, encoding: "utf8" }) - expect(result.status, result.stderr || result.stdout).toBe(0) -}) diff --git a/plugins/hermes-agent/tests/adapter-python.test.ts b/plugins/hermes-agent/tests/adapter-python.test.ts index 06b27f03d..39fdd180b 100644 --- a/plugins/hermes-agent/tests/adapter-python.test.ts +++ b/plugins/hermes-agent/tests/adapter-python.test.ts @@ -318,29 +318,30 @@ from inline.message_actions import ( resolve_inline_message_action_ownership, ) -# An unset flag keeps the old defaults and creates no activity ownership. +# A legacy timeline opt-in no longer changes quiet defaults or typing. +for legacy_activity_flag in (None, "false", "true"): + if legacy_activity_flag is None: + os.environ.pop("INLINE_EXPERIMENTAL_AGENT_ACTIVITY", None) + else: + os.environ["INLINE_EXPERIMENTAL_AGENT_ACTIVITY"] = legacy_activity_flag + display_config._PLATFORM_DEFAULTS.pop("inline", None) + _install_inline_display_defaults() + assert display_config._PLATFORM_DEFAULTS["inline"]["tool_progress"] == "off" + assert display_config._PLATFORM_DEFAULTS["inline"]["cleanup_progress"] is True + assert display_config._PLATFORM_DEFAULTS["inline"]["interim_assistant_messages"] is False + default_adapter = InlineAdapter(PlatformConfig(extra={"token": "fake"})) + async def assert_ordinary_processing(): + calls=[] + async def sidecar(route, body): calls.append((route, body)) + default_adapter._sidecar_call=sidecar + default_adapter._typing_target_for=lambda chat_id, metadata: {"chatId": chat_id} + default_adapter._processing_reaction_target=lambda event: None + event=types.SimpleNamespace(source=types.SimpleNamespace(chat_id="7", thread_id=None), message_id="1") + await default_adapter.on_processing_start(event) + await default_adapter.send_typing("7") + assert calls == [("/typing", {"target": {"chatId": "7"}, "state": "start"})] + asyncio.run(assert_ordinary_processing()) os.environ.pop("INLINE_EXPERIMENTAL_AGENT_ACTIVITY", None) -default_adapter = InlineAdapter(PlatformConfig(extra={"token": "fake"})) -assert default_adapter._agent_activity_enabled is False -_install_inline_display_defaults() -assert display_config._PLATFORM_DEFAULTS["inline"]["tool_progress"] == "off" -assert display_config._PLATFORM_DEFAULTS["inline"]["cleanup_progress"] is True -assert display_config._PLATFORM_DEFAULTS["inline"]["interim_assistant_messages"] is False -async def assert_default_activity_disabled(): - calls=[] - async def sidecar(route, body): calls.append((route, body)) - default_adapter._sidecar_call=sidecar - default_adapter._typing_target_for=lambda chat_id, metadata: {"chatId": chat_id} - default_adapter._processing_reaction_target=lambda event: None - event=types.SimpleNamespace(source=types.SimpleNamespace(chat_id="7", thread_id=None), message_id="1") - await default_adapter.on_processing_start(event) - assert default_adapter._activity_timelines == {} - await default_adapter.send_typing("7") - assert calls[-1][1]["experimentalAgentActivity"] is False -asyncio.run(assert_default_activity_disabled()) -# Existing structured-activity scenarios explicitly opt in. -os.environ["INLINE_EXPERIMENTAL_AGENT_ACTIVITY"] = "true" -display_config._PLATFORM_DEFAULTS.pop("inline", None) base_extra = {"token": "fake", "context_history_limit": 0} # Behavioral fixtures explicitly trust their actors; authorization cases use base_extra. @@ -545,10 +546,10 @@ class RegistryContext: ctx = RegistryContext() register(ctx) inline_display = display_config._PLATFORM_DEFAULTS["inline"] -assert inline_display["tool_progress"] == "all" -assert inline_display["cleanup_progress"] is False +assert inline_display["tool_progress"] == "off" +assert inline_display["cleanup_progress"] is True assert inline_display["streaming"] is False -assert inline_display["interim_assistant_messages"] is True +assert inline_display["interim_assistant_messages"] is False assert ctx.platform["name"] == "inline" assert ctx.platform["emoji"] == chr(0x1F4AC) assert ctx.platform["label"] == "Inline" @@ -2167,7 +2168,7 @@ async def assert_forced_reply_thread_creation(): assert events[0].auto_skill == ["thread-skill"] await adapter.send_typing("10", metadata={"thread_id": "99"}) - assert calls[-1] == ("/typing", {"target": {"chatId": "10"}, "state": "start", "experimentalAgentActivity": True}) + assert calls[-1] == ("/typing", {"target": {"chatId": "10"}, "state": "start"}) trigger_reply = await adapter.send("10", "agent reply", reply_to="7", metadata={"thread_id": "99"}) assert trigger_reply.success is True @@ -2178,14 +2179,14 @@ async def assert_forced_reply_thread_creation(): assert calls[-1] == ("/typing", {"target": {"chatId": "10"}, "state": "stop"}) await adapter.send_typing("10", metadata={"thread_id": "99"}) - assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "start", "experimentalAgentActivity": True}) + assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "start"}) await adapter.stop_typing("10", metadata={"thread_id": "99"}) assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "stop"}) reused_thread = await adapter._create_reply_thread("10", "7", "please investigate the incident", "6") assert reused_thread == "99" await adapter.send_typing("10", metadata={"thread_id": "99"}) - assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "start", "experimentalAgentActivity": True}) + assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "start"}) await adapter.stop_typing("10", metadata={"thread_id": "99"}) assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "stop"}) @@ -3979,7 +3980,7 @@ async def assert_transport_helpers(): assert calls[-1] == ("/delete", {"target": {"chatId": "99"}, "messageId": "777"}) await adapter.send_typing("chat:10", metadata=metadata) - assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "start", "experimentalAgentActivity": True}) + assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "start"}) await adapter.stop_typing("chat:10", metadata=metadata) assert calls[-1] == ("/typing", {"target": {"chatId": "99"}, "state": "stop"}) @@ -5602,330 +5603,6 @@ async def assert_bot_settings_fail_closed_and_serialized(): asyncio.run(assert_bot_settings_fail_closed_and_serialized()) -async def assert_quiet_timeline(): - import queue - from inline.adapter import _install_inline_progress_bridge, _send_result - from inline.activity import ActivityEvent - adapter = InlineAdapter(PlatformConfig(extra=trusted_extra)) - calls = [] - serial = [90] - async def send_sidecar(route, body): - calls.append((route, body)) - if route == "/send": serial[0] += 1 - return _send_result(success=True, message_id=str(serial[0]), raw_response={}) - adapter._send_sidecar = send_sidecar - adapter._mark_reply_thread_visible = lambda target: None - adapter._target_for = lambda chat_id, metadata: {"chatId": chat_id} - adapter._reply_to_for_target = lambda reply_to, target: None - adapter._processing_reaction_target = lambda event: None - class ProgressRunner: - def progress_callback(self, *args, **kwargs): - calls.append(("host_callback", args)) - async def send_progress_messages(self): - calls.append(("host_sender", None)) - fake_run = types.ModuleType("gateway.run") - fake_run.TurnRunner = ProgressRunner - previous_run = sys.modules.get("gateway.run") - sys.modules["gateway.run"] = fake_run - try: - _install_inline_progress_bridge() - installed = ProgressRunner.send_progress_messages - _install_inline_progress_bridge() - assert ProgressRunner.send_progress_messages is installed - source = types.SimpleNamespace(platform="inline", chat_id="7", thread_id=None) - event = types.SimpleNamespace(source=source, message_id="1", raw_message=None) - await adapter.on_processing_start(event) - runner = ProgressRunner() - runner._ctx = types.SimpleNamespace(source=source, event_message_id="1", _progress_metadata={}, - progress_queue=queue.Queue(), tool_progress_enabled=True, progress_mode="all", - _run_still_current=lambda: True, agent_holder=[None]) - runner._runner = types.SimpleNamespace(_adapter_for_source=lambda source: adapter) - runner.progress_callback("tool.started", "terminal", "python3 bad.py", {"description":"Checking the EPUB", "command":"python3 bad.py"}) - queued_event = runner._ctx.progress_queue.queue[0] - assert isinstance(queued_event, ActivityEvent) and queued_event.title == "Checking the EPUB" - task = asyncio.create_task(runner.send_progress_messages()) - await asyncio.sleep(0.02) - assert '>Checking the EPUB' in calls[0][1]["text"] - await adapter.send("7", "Here is the comparison.") - assert calls[-1][1]["text"] == "Here is the comparison." - assert '>Worked for ' in calls[-2][1]["text"] - task.cancel() - await task - # Public completion hook is authoritative, even with reactions disabled. - await adapter.on_processing_complete(event, "failure") - assert '>Failed after ' in calls[-1][1]["text"] - assert adapter._activity_timelines == {} - adapter._agent_activity_enabled = False - runner.progress_callback("tool.started", "terminal", "ls", {}) - await runner.send_progress_messages() - assert calls[-2][0] == "host_callback" and calls[-1][0] == "host_sender" - adapter._agent_activity_enabled = True - source.platform = "telegram" - runner.progress_callback("tool.started", "terminal", "ls", {}) - await runner.send_progress_messages() - assert calls[-2][0] == "host_callback" and calls[-1][0] == "host_sender" - - # The real host recurses into queued turns before cancelling the first - # sender, then completes only the ORIGINAL inbound event. Every child - # must share that owner, while only the final child gets delivery errors. - source.platform = "inline" - async def queued_chain(final_outcome, *, first_interrupted=False, last_failed=False): - calls.clear() - outer = types.SimpleNamespace(source=source, message_id="101", raw_message=None) - await adapter.on_processing_start(outer) - runners = [] - tasks = [] - for message_id in ("101", "102"): - child = ProgressRunner() - child._ctx = types.SimpleNamespace(source=source,event_message_id=message_id,_progress_metadata={}, - progress_queue=queue.Queue(),tool_progress_enabled=True,progress_mode="all", - _run_still_current=lambda:True,agent_holder=[types.SimpleNamespace(is_interrupted=False)], - result_holder=[None]) - child._runner = types.SimpleNamespace(_adapter_for_source=lambda source:adapter) - child.progress_callback("tool.started", "terminal", message_id, {"description":"Work " + message_id}) - tasks.append(asyncio.create_task(child.send_progress_messages())) - runners.append(child) - await asyncio.sleep(.01) - if message_id == "101": - child._ctx.result_holder[0] = {"interrupted":first_interrupted} - if not first_interrupted: - # Queued-first-response sends are unmarked in the host. - await adapter.send("7", "First response") - assert list(adapter._activity_timelines)==[("7", "", "101")] - group=adapter._activity_timelines[("7", "", "101")] - assert len(group)==2 and group[0].closed and not group[1].closed - runners[-1]._ctx.result_holder[0] = {"failed":last_failed} - if final_outcome=="cancelled": - runners[-1]._ctx.agent_holder[0].is_interrupted=True - for child_task in reversed(tasks): - child_task.cancel(); await child_task - first_id=group[0].last_closed["messageId"] - second_id=group[1].last_closed["messageId"] - first_elapsed=group[0].last_closed["elapsed"] - await adapter.on_processing_complete(outer, final_outcome) - assert adapter._activity_timelines=={} - edits={body["messageId"]:body["text"] for route,body in calls if route=="/edit"} - assert ('>Stopped after ' if first_interrupted else '>Worked for ') in edits[first_id] - expected='>Stopped after ' if final_outcome=="cancelled" else '>Failed after ' if final_outcome=="failure" or last_failed else '>Worked for ' - assert expected in edits[second_id] - assert group[0].last_closed["elapsed"]==first_elapsed - - await queued_chain("failure") - await queued_chain("cancelled") - await queued_chain("success", first_interrupted=True) - await queued_chain("success", last_failed=True) - - # Context ownership is per processing task, including two independent - # sessions targeting the same chat; one reply must not close the other. - ready=asyncio.Event(); started=[0] - async def independent_processing(message_id): - event=types.SimpleNamespace(source=source,message_id=message_id,raw_message=None) - await adapter.on_processing_start(event) - child=ProgressRunner() - child._ctx=types.SimpleNamespace(source=source,event_message_id=message_id,_progress_metadata={}, - progress_queue=queue.Queue(),agent_holder=[None],_run_still_current=lambda:True) - child._ctx.progress_queue.put(ActivityEvent('Concurrent '+message_id,'detail',time.monotonic())) - child._runner=types.SimpleNamespace(_adapter_for_source=lambda source:adapter) - task=asyncio.create_task(child.send_progress_messages()); await asyncio.sleep(.01) - started[0]+=1 - if started[0]==2: ready.set() - await ready.wait() - await adapter.send("7", "Reply "+message_id) - assert calls[-1][1]['text']=='Reply '+message_id - assert ('Concurrent '+message_id) in calls[-2][1]['text'] - assert '>Worked for ' in calls[-2][1]['text'] - task.cancel(); await task - await adapter.on_processing_complete(event,"success") - await asyncio.gather(independent_processing("201"),independent_processing("202")) - assert adapter._activity_timelines=={} - - # Both consume.finally and the bridge retry can fail to deliver the - # terminal edit. Public completion must retry again without changing - # a real failed/stopped outcome or inventing failure from transport. - for result_outcome,prefix in (("success","Worked for"),("failure","Failed after"),("cancelled","Stopped after")): - terminal_attempts=[]; now=[105.0] - async def retry_sidecar(route,body): - if route=="/edit": - terminal_attempts.append(body['text']) - if len(terminal_attempts)<=2: - return _send_result(success=False,error="temporary terminal edit failure") - return await send_sidecar(route,body) - adapter._send_sidecar=retry_sidecar - outer=types.SimpleNamespace(source=source,message_id="retry-301",raw_message=None) - await adapter.on_processing_start(outer) - child=ProgressRunner() - child._ctx=types.SimpleNamespace(source=source,event_message_id="retry-301",_progress_metadata={}, - progress_queue=queue.Queue(),agent_holder=[types.SimpleNamespace(is_interrupted=False)], - result_holder=[None],_run_still_current=lambda:True) - child._runner=types.SimpleNamespace(_adapter_for_source=lambda source:adapter) - child._ctx.progress_queue.put(ActivityEvent("Checking","detail",100)) - task=asyncio.create_task(child.send_progress_messages());await asyncio.sleep(.01) - timeline=adapter._activity_timelines[("7","","retry-301")][0] - timeline.clock=lambda:now[0] - child._ctx.result_holder[0]={"failed":result_outcome=="failure","interrupted":result_outcome=="cancelled"} - child._ctx.agent_holder[0].is_interrupted=result_outcome=="cancelled" - task.cancel();await task - assert len(terminal_attempts)==2 and timeline.closed - assert timeline.pending_outcome==result_outcome - now[0]=205.0 - await adapter.on_processing_complete(outer,"success") - assert len(terminal_attempts)==3 and len(set(terminal_attempts))==1 - assert f'>{prefix} 5s' in terminal_attempts[-1] - assert adapter._activity_timelines=={} and timeline.pending_outcome is None - adapter._send_sidecar=send_sidecar - - # Internal/cron replies have neither a public owner nor notify/interim - # metadata. Their temporary sender registrations still flush queued - # tools BEFORE an unmarked reply, with no synthetic-anchor leak. - def internal_runner(anchor): - child=ProgressRunner() - child._ctx=types.SimpleNamespace(source=source,event_message_id=anchor,_progress_metadata={}, - progress_queue=queue.Queue(),agent_holder=[None],_run_still_current=lambda:True) - child._runner=types.SimpleNamespace(_adapter_for_source=lambda source:adapter) - return child - - for anchor in (None,"cron-anchor"): - calls.clear() - internal=internal_runner(anchor) - pending=internal._ctx.progress_queue - pending.put(ActivityEvent("Cron first","before reply A",time.monotonic())) - internal_task=asyncio.create_task(internal.send_progress_messages());await asyncio.sleep(.01) - keys=list(adapter._activity_timelines) - assert len(keys)==1 and keys[0][2].startswith("internal:") and keys[0][2]!=anchor - pending.put(ActivityEvent("Cron second","before reply B",time.monotonic())) - await adapter.send("7","Cron summary") - assert calls[-1][1]['text']=="Cron summary" - assert 'before reply B' in calls[-2][1]['text'] and '>Worked for ' in calls[-2][1]['text'] - # Match the actual host's after-send reset, then later tool work. - pending.put(('__reset__',)) - pending.put(ActivityEvent("Cron third","after reply C",time.monotonic())) - await asyncio.sleep(.55) - internal_task.cancel();await internal_task - sent=[body['text'] for route,body in calls if route=="/send"] - assert len(sent)==3 and sent[1]=="Cron summary" - assert 'after reply C' in sent[2] and 'before reply B' not in sent[2] - assert adapter._activity_timelines=={} - - # Without an owner, an overlapping internal reply closes ALL earlier - # activity rows for its target. Each sender removes only its own entry. - calls.clear();internal_tasks=[] - for anchor in ("cron-a","cron-b"): - internal=internal_runner(anchor) - internal._ctx.progress_queue.put(ActivityEvent(anchor,'queued before shared reply',time.monotonic())) - internal_tasks.append(asyncio.create_task(internal.send_progress_messages())) - await asyncio.sleep(.01) - assert len(adapter._activity_timelines)==2 - await adapter.send("7","Shared internal reply") - assert calls[-1][1]['text']=="Shared internal reply" - assert all('>Worked for ' in body['text'] for route,body in calls[-3:-1]) - internal_tasks[0].cancel();await internal_tasks[0] - assert len(adapter._activity_timelines)==1 - internal_tasks[1].cancel();await internal_tasks[1] - assert adapter._activity_timelines=={} - - # Failed progress delivery must also release the sender-owned entry. - async def fail_sidecar(route,body): - return _send_result(success=False,error="synthetic transport failure") - adapter._send_sidecar=fail_sidecar - internal=internal_runner("failed-cron") - internal._ctx.progress_queue.put(ActivityEvent("Failing cron","detail",time.monotonic())) - await internal.send_progress_messages() - assert adapter._activity_timelines=={} - finally: - if previous_run is None: sys.modules.pop("gateway.run", None) - else: sys.modules["gateway.run"] = previous_run - -asyncio.run(assert_quiet_timeline()) - -async def assert_inline_tool_reply_boundary(): - import queue - from inline.adapter import _activity_owner, _send_result, InlineSidecarError - from inline.activity import ActivityEvent, ActivityTimeline - from inline.message_actions import build_inline_agent_action_id - old_sidecar=inline_tools._sidecar.copy() - direct_call=inline_tools._sidecar_call - def forbidden_fallback(*args): - raise AssertionError('A configured tool send must never bypass the adapter or retry directly') - inline_tools._sidecar_call=forbidden_fallback - try: - for owned in (True,False): - adapter=InlineAdapter(PlatformConfig(extra=trusted_extra)) - calls=[]; serial=[0]; entered=asyncio.Event(); release=asyncio.Event() - async def publish(route,body): - serial[0]+=1; calls.append((route,body.copy())) - return _send_result(success=True,message_id=str(serial[0])) - async def raw_send(route,body): - if body['target']=={'chatId':'7'}: - entered.set(); await release.wait() - calls.append(('tool-delivered',body.copy())) - return {'ok':True,'result':{'messageId':'123'}} - adapter._sidecar_call=raw_send - adapter._configure_tool_sender() - ctx=types.SimpleNamespace(progress_queue=queue.Queue(),agent_holder=[None],_run_still_current=lambda:True) - timeline=ActivityTimeline(publish,{'chatId':'7'},ctx=ctx) - adapter._activity_timelines[('7','','owner')]=[timeline] - token=_activity_owner.set((adapter,('7','','owner')) if owned else None) - try: - await timeline.add(ActivityEvent('Before','first tool',time.monotonic())) - unrelated=await asyncio.to_thread(inline_tools._handle_inline_tool,{'action':'send_message','chat_id':'8','text':'Other destination'}) - assert json.loads(unrelated)['success'] and timeline.started_at is not None - ctx.progress_queue.put(ActivityEvent('Queued before','second tool',time.monotonic())) - args={'action':'send_message','chat_id':'7','text':'FIRST_STEP_VISIBLE','parse_markdown':False, - 'buttons':[[{'text':'Copy','copy_text':'exact text'}]]} - task=asyncio.create_task(asyncio.to_thread(inline_tools._handle_inline_tool,args)) - await asyncio.wait_for(entered.wait(),1) - assert '>Worked for ' in calls[-1][1]['text'] and 'second tool' in calls[-1][1]['text'] - later=asyncio.create_task(timeline.add(ActivityEvent('After','third tool',time.monotonic()))) - await asyncio.sleep(.01) - assert not later.done(), 'Later progress overtook the actual tool reply transport' - release.set() - result=json.loads(await task); await later - assert result=={'success':True,'action':'send_message','result':{'messageId':'123'}} - assert calls[-2][0]=='tool-delivered' and calls[-2][1]=={ - 'target':{'chatId':'7'},'text':'FIRST_STEP_VISIBLE','parseMarkdown':False, - 'actions':{'rows':[{'actions':[{'id':build_inline_agent_action_id(0,0),'text':'Copy','copyText':'exact text'}]}]}} - assert calls[-1][0]=='/send' and 'third tool' in calls[-1][1]['text'] - assert 'second tool' not in calls[-1][1]['text'] - - # The synchronous tool may never block its own event loop. - same_loop=json.loads(inline_tools._handle_inline_tool(args)) - assert 'event loop' in same_loop['error'] - async def failed_send(route,body): - raise InlineSidecarError(route,503,'temporary transport failure','transient',{}) - adapter._sidecar_call=failed_send - error=json.loads(await asyncio.to_thread(inline_tools._handle_inline_tool,args)) - assert error['error_kind']=='transient' and 'temporary transport failure' in error['error'] - schedule=asyncio.run_coroutine_threadsafe - cancelled=[] - class TimedOutSend: - def result(self,timeout): - assert timeout==45 - raise TimeoutError('synthetic timeout') - def cancel(self): cancelled.append(True) - def time_out_send(coroutine,loop): - coroutine.close() - return TimedOutSend() - try: - asyncio.run_coroutine_threadsafe=time_out_send - error=json.loads(await asyncio.to_thread(inline_tools._handle_inline_tool,args)) - assert 'delivery may have completed' in error['error'] and cancelled==[True] - finally: - asyncio.run_coroutine_threadsafe=schedule - sender=adapter._tool_send - # Clearing another adapter's obsolete hook cannot unregister - # this live gateway; teardown clears only its own callback. - inline_tools.clear_sidecar_send(lambda body:None) - assert inline_tools._sidecar['send'] is sender - await adapter.disconnect() - assert inline_tools._sidecar.get('send') is None - finally: - _activity_owner.reset(token) - finally: - inline_tools._sidecar.clear(); inline_tools._sidecar.update(old_sidecar) - inline_tools._sidecar_call=direct_call - -asyncio.run(assert_inline_tool_reply_boundary()) - print("adapter python smoke ok") ` diff --git a/plugins/hermes-agent/tests/package-artifact.test.ts b/plugins/hermes-agent/tests/package-artifact.test.ts index 7b3dc68ea..28e74f891 100644 --- a/plugins/hermes-agent/tests/package-artifact.test.ts +++ b/plugins/hermes-agent/tests/package-artifact.test.ts @@ -254,7 +254,6 @@ const expectedFiles = [ "plugin/inline/LICENSE", "plugin/inline/README.md", "plugin/inline/__init__.py", - "plugin/inline/activity.py", "plugin/inline/adapter.py", "plugin/inline/cli.py", "plugin/inline/message_actions.py", diff --git a/plugins/hermes-agent/tests/sidecar-runtime.test.ts b/plugins/hermes-agent/tests/sidecar-runtime.test.ts index 94664605f..61b6ce9ee 100644 --- a/plugins/hermes-agent/tests/sidecar-runtime.test.ts +++ b/plugins/hermes-agent/tests/sidecar-runtime.test.ts @@ -220,26 +220,21 @@ describe("sidecar runtime", () => { target: { chatId: "123" }, messageId: "9001", }, auth)) - // Unset/false uses the established typing API. Only an explicit boolean opts in. - for (const experimentalAgentActivity of [undefined, false]) { - await expectOk(post(port, "/typing", { - target: { chatId: "123" }, state: "start", experimentalAgentActivity, - }, auth)) - } - for (const state of ["start", "stop"]) { - await expectOk(post(port, "/typing", { - target: { chatId: "123" }, state, experimentalAgentActivity: true, - }, auth)) + // Stale experimental flags also use ordinary typing after timeline removal. + for (const experimentalAgentActivity of [undefined, false, true]) { + for (const state of ["start", "stop"]) { + await expectOk(post(port, "/typing", { + target: { chatId: "123" }, state, experimentalAgentActivity, + }, auth)) + } } - const activityHealth = await post(port, "/healthz", {}, auth) - const allCalls = (resultOf(activityHealth.body).diagnostics as { - calls: Array<{ method: string; params?: { sendComposeAction?: { action?: number } } }> + const typingHealth = await post(port, "/healthz", {}, auth) + const typingCalls = (resultOf(typingHealth.body).diagnostics as { + calls: Array<{ method: string; params?: { typing?: boolean } }> }).calls - expect(allCalls.filter((call) => call.method === "sendTyping")).toHaveLength(2) - const activityCalls = allCalls.filter((call) => call.method === "invoke:SEND_COMPOSE_ACTION") - expect(activityCalls).toHaveLength(2) - expect(activityCalls[0]?.params?.sendComposeAction?.action).toBe(6) - expect(activityCalls[1]?.params?.sendComposeAction).not.toHaveProperty("action") + expect(typingCalls.filter((call) => call.method === "sendTyping").map((call) => call.params?.typing)) + .toEqual([true, false, true, false, true, false]) + expect(typingCalls.filter((call) => call.method === "invoke:SEND_COMPOSE_ACTION")).toHaveLength(0) await expectOk(post(port, "/presence", { target: { userId: "42" }, kind: "running", diff --git a/scripts/ci/check-hermes-host.py b/scripts/ci/check-hermes-host.py index d55f83de9..7382f0c38 100644 --- a/scripts/ci/check-hermes-host.py +++ b/scripts/ci/check-hermes-host.py @@ -431,4 +431,92 @@ async def unexpected_failure(_event): asyncio.run(exercise_receipt_recovery()) + + +async def exercise_ordinary_presentation(): + from gateway.display_config import resolve_display_setting, resolve_tool_progress + + # Exercise the host's actual resolver; explicit operator config always wins. + assert resolve_display_setting({}, "inline", "tool_progress") == "off" + assert resolve_display_setting({}, "inline", "cleanup_progress") is True + assert resolve_display_setting({}, "inline", "interim_assistant_messages") is False + assert resolve_display_setting({}, "inline", "long_running_notifications") is True + assert resolve_display_setting({"display": {"tool_progress": "all"}}, "inline", "tool_progress") == "all" + explicit = {"display": {"tool_progress": "off", "platforms": {"inline": { + "tool_progress": "all", "cleanup_progress": False, "interim_assistant_messages": True, + }}}} + assert resolve_display_setting(explicit, "inline", "tool_progress") == "all" + assert resolve_display_setting(explicit, "inline", "cleanup_progress") is False + assert resolve_display_setting(explicit, "inline", "interim_assistant_messages") is True + assert resolve_display_setting({"display": {"tool_progress_overrides": {"inline": "new"}}}, + "inline", "tool_progress") == "new" + + assert resolve_tool_progress({}, "inline", "all") == ("all", True) + assert resolve_tool_progress({"display": {"platforms": {"inline": {"tool_progress": "off"}}}}, + "inline", "all") == ("off", True) + + for flag in ("", "true"): + for failed_send in (False, True): + with patch.dict(os.environ, {"INLINE_EXPERIMENTAL_AGENT_ACTIVITY": flag}): + ordinary = platform_registry.create_adapter("inline", PlatformConfig( + enabled=True, token="offline-test-token", extra={ + "dm_policy": "open", "require_mention": False, "reply_threads": "off", + "context_backfill": "off", "reactions": True, "sync_commands": False, + "text_debounce_seconds": 0, + }, + )) + calls, outcomes, completed, typing_started = [], [], asyncio.Event(), asyncio.Event() + def transport(request): + body = json.loads(request.content) + calls.append((request.url.path, body)) + if request.url.path == "/chat": + return httpx.Response(200, json={"ok": True, "result": {"id": "6101", "title": "Offline fixture"}}) + if request.url.path == "/typing" and body["state"] == "start": + typing_started.set() + if request.url.path == "/send" and failed_send: + return httpx.Response(400, json={"ok": False, "error": "rejected fixture reply", "errorKind": "bad_format"}) + return httpx.Response(200, json={"ok": True, "result": {"messageId": "6202"}}) + async def reply(event): + await asyncio.wait_for(typing_started.wait(), timeout=10) + return "Ordinary native reply" + complete = ordinary.on_processing_complete + async def observe_completion(event, outcome): + await complete(event, outcome) + outcomes.append(str(getattr(outcome, "value", outcome))) + completed.set() + ordinary.on_processing_complete = observe_completion + ordinary.set_message_handler(reply) + ordinary.set_authorization_check(lambda *_args, **_kwargs: True) + ordinary._http_client = httpx.AsyncClient(transport=httpx.MockTransport(transport)) + event = {"kind": "message.new", "seq": 6201, "chatId": "6101", "meId": "999", + "sender": {"id": "42", "bot": False}, "message": { + "id": "6201", "chatId": "6101", "fromId": "42", "message": "hello", + "peerId": {"type": {"oneofKind": "chat", "chat": {"chatId": "6101"}}}, + }} + try: + await ordinary._on_inbound(json.dumps(event)) + await asyncio.wait_for(completed.wait(), timeout=10) + async def wait_idle(): + while ordinary._active_sessions: + await asyncio.sleep(0.001) + await asyncio.wait_for(wait_idle(), timeout=10) + assert outcomes == ["failure" if failed_send else "success"], outcomes + typing = [body for route, body in calls if route == "/typing"] + assert typing and any(body["state"] == "start" for body in typing) + assert typing[-1]["state"] == "stop" + assert all("experimentalAgentActivity" not in body for body in typing) + reactions = [body for route, body in calls if route == "/reaction"] + assert [(body["emoji"], body.get("remove", False)) for body in reactions] == [ + ("👀", False), ("👀", True), ("❌" if failed_send else "✅", False), + ], reactions + sends = [body for route, body in calls if route == "/send"] + assert sends and sends[0]["text"] == "Ordinary native reply" + assert not any("