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("