diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index edcbc0b87..08337b2f5 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -178,12 +178,14 @@ RUN cd /roomote && pnpm dlx esbuild@0.25.5 packages/db/src/run-migrations.ts \ # Runtime-only dependency tree for the externals tsup does not bundle. Install # from its standalone lockfile so unrelated workspace packages stay excluded. COPY .docker/app/runtime-deps/api /runtime-deps/ -# The zod parity check guards the Fast agent's native tool runtime: it -# symlinks an on-disk zod into each generated OpenCode tool directory via -# require.resolve, which the api bundle (noExternal) cannot satisfy on its -# own. A version drifting from the workspace would silently change what the -# generated tool sources execute against. +# Version parity keeps API externals aligned with the workspace. Zod also +# guards the Fast agent's native tool runtime, which symlinks it into each +# generated OpenCode tool directory via require.resolve. RUN cd /runtime-deps && pnpm install --prod --frozen-lockfile && \ + test "$(node -p "require(require('node:path').join(require('node:path').dirname(require.resolve('dompurify')), '../package.json')).version")" = \ + "$(cd /roomote/apps/api && node -p "require(require('node:path').join(require('node:path').dirname(require.resolve('dompurify')), '../package.json')).version")" && \ + test "$(node -p "require('jsdom/package.json').version")" = \ + "$(cd /roomote/apps/api && node -p "require('jsdom/package.json').version")" && \ test "$(node -p "require('snowflake-sdk/package.json').version")" = \ "$(cd /roomote/apps/api && node -p "require('snowflake-sdk/package.json').version")" && \ test "$(node -p "require('zod/package.json').version")" = \ @@ -310,6 +312,12 @@ FROM runtime-base AS runtime-inference-base ARG OPENCODE_CLI_VERSION=1.18.10 +# Fast mode uses bounded, temporary partial Git fetches to discover +# repository-defined skills without exposing a checkout to the model. +RUN apt -qq update && \ + apt -qq install -y git && \ + rm -rf /var/lib/apt/lists/* + # The install and version check run as root; with the image's HOME=/tmp they # would bake root-owned dotdirs (npm cache, OpenCode's data/config/cache # dirs) into the layer. The runtime user shares that HOME and must be able to @@ -376,6 +384,7 @@ COPY --chown=roomote-app:roomote-app --from=build-web /roomote/apps/docs ./apps/ COPY --from=build-api /roomote/apps/api/package.json ./apps/api/ COPY --from=build-api /roomote/apps/api/dist ./apps/api/dist/ COPY --from=build-api /runtime-deps/node_modules ./apps/api/node_modules/ +COPY --chown=roomote-app:roomote-app --from=build-api /roomote/packages/cloud-agents/src/server/workflows/skills/standard ./skills/standard/ COPY --from=build-api /migrate /roomote/migrate/ COPY --from=build-controller /roomote/releases /roomote/releases/ COPY --from=build-controller /roomote/apps/controller/package.json ./apps/controller/ @@ -388,8 +397,10 @@ COPY --from=build-preview-proxy /roomote/apps/preview-proxy/package.json ./apps/ COPY --from=build-preview-proxy /roomote/apps/preview-proxy/dist ./apps/preview-proxy/dist/ COPY --from=build-preview-proxy /runtime-deps/node_modules ./apps/preview-proxy/node_modules/ COPY --from=github-cli /usr/bin/gh /usr/local/bin/gh -RUN command -v gh >/dev/null && command -v opencode >/dev/null && \ - cd /roomote/apps/bullmq && node -e "require.resolve('zod/package.json')" +RUN command -v git >/dev/null && command -v gh >/dev/null && \ + command -v opencode >/dev/null && \ + cd /roomote/apps/bullmq && node -e "require.resolve('zod/package.json')" && \ + ls -d /roomote/node_modules/.pnpm/zod@*/node_modules/zod >/dev/null USER roomote-app:roomote-app EXPOSE 3000 3001 3002 8081 diff --git a/.docker/app/runtime-deps/api/package.json b/.docker/app/runtime-deps/api/package.json index 40db5798d..305563c1e 100644 --- a/.docker/app/runtime-deps/api/package.json +++ b/.docker/app/runtime-deps/api/package.json @@ -4,6 +4,8 @@ "private": true, "packageManager": "pnpm@10.29.3", "dependencies": { + "dompurify": "3.4.13", + "jsdom": "26.1.0", "snowflake-sdk": "2.4.3", "zod": "3.25.76" }, diff --git a/.docker/app/runtime-deps/api/pnpm-lock.yaml b/.docker/app/runtime-deps/api/pnpm-lock.yaml index b25ebf577..317e9b7ed 100644 --- a/.docker/app/runtime-deps/api/pnpm-lock.yaml +++ b/.docker/app/runtime-deps/api/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: .: dependencies: + dompurify: + specifier: 3.4.13 + version: 3.4.13 + jsdom: + specifier: 26.1.0 + version: 26.1.0 snowflake-sdk: specifier: 2.4.3 version: 2.4.3(asn1.js@5.4.1) @@ -20,6 +26,9 @@ importers: packages: + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-crypto/sha1-browser@5.2.0': resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} @@ -211,6 +220,34 @@ packages: resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -266,6 +303,9 @@ packages: '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typespec/ts-http-runtime@0.3.8': resolution: {integrity: sha512-bLMpVcWZNzq6lYOybwFwOAR1IXKcHnhUNqYeHjl1bET/qE3jFPFH+p8Wrh3rU4xwdnifPxmKNESBYnvnmc75aA==} engines: {node: '>=22.0.0'} @@ -348,10 +388,18 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -361,6 +409,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + default-browser-id@5.0.1: resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} engines: {node: '>=18'} @@ -377,6 +428,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -387,6 +441,10 @@ packages: enabled@2.0.0: resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -503,6 +561,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -515,6 +577,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -533,6 +599,9 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -548,6 +617,15 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-bigint@1.0.0: resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} @@ -589,6 +667,9 @@ packages: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -622,6 +703,9 @@ packages: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + oauth4webapi@3.8.6: resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} @@ -640,6 +724,9 @@ packages: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-expression-matcher@1.6.2: resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==} engines: {node: '>=14.0.0'} @@ -648,10 +735,17 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -666,6 +760,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -689,12 +787,30 @@ packages: strnum@2.4.1: resolution: {integrity: sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + toml@3.0.0: resolution: {integrity: sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -705,10 +821,31 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + winston-transport@4.9.0: resolution: {integrity: sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A==} engines: {node: '>= 12.0.0'} @@ -717,19 +854,46 @@ packages: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + xml-naming@0.3.0: resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==} engines: {node: '>=16.0.0'} + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 @@ -1114,6 +1278,26 @@ snapshots: '@colors/colors@1.6.0': {} + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -1189,6 +1373,9 @@ snapshots: '@types/triple-beam@1.3.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@typespec/ts-http-runtime@0.3.8': dependencies: http-proxy-agent: 7.0.2 @@ -1277,12 +1464,24 @@ snapshots: dependencies: delayed-stream: 1.0.0 + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + data-uri-to-buffer@4.0.1: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + default-browser-id@5.0.1: {} default-browser@5.5.0: @@ -1294,6 +1493,10 @@ snapshots: delayed-stream@1.0.0: {} + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1306,6 +1509,8 @@ snapshots: enabled@2.0.0: {} + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -1435,6 +1640,10 @@ snapshots: dependencies: parse-passwd: 1.0.0 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -1456,6 +1665,10 @@ snapshots: transitivePeerDependencies: - supports-color + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + inherits@2.0.4: {} is-docker@2.2.1: {} @@ -1466,6 +1679,8 @@ snapshots: dependencies: is-docker: 3.0.0 + is-potential-custom-element-name@1.0.1: {} + is-stream@2.0.1: {} is-unsafe@2.0.0: {} @@ -1478,6 +1693,33 @@ snapshots: dependencies: is-inside-container: 1.0.0 + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + json-bigint@1.0.0: dependencies: bignumber.js: 9.3.1 @@ -1531,6 +1773,8 @@ snapshots: safe-stable-stringify: 2.5.0 triple-beam: 1.4.1 + lru-cache@10.4.3: {} + math-intrinsics@1.1.0: {} mime-db@1.52.0: {} @@ -1557,6 +1801,8 @@ snapshots: fetch-blob: 3.2.0 formdata-polyfill: 4.0.10 + nwsapi@2.2.24: {} + oauth4webapi@3.8.6: {} one-time@1.0.0: @@ -1577,16 +1823,24 @@ snapshots: parse-passwd@1.0.0: {} + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-expression-matcher@1.6.2: {} proxy-from-env@2.1.0: {} + punycode@2.3.1: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 + rrweb-cssom@0.8.0: {} + run-applescript@7.1.0: {} safe-buffer@5.2.1: {} @@ -1595,6 +1849,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} simple-lru-cache@0.0.2: {} @@ -1647,18 +1905,51 @@ snapshots: dependencies: anynum: 1.0.1 + symbol-tree@3.2.4: {} + text-hex@1.0.0: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + toml@3.0.0: {} + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + triple-beam@1.4.1: {} tslib@2.8.1: {} util-deprecate@1.0.2: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-streams-polyfill@3.3.3: {} + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + winston-transport@4.9.0: dependencies: logform: 2.7.0 @@ -1679,10 +1970,16 @@ snapshots: triple-beam: 1.4.1 winston-transport: 4.9.0 + ws@8.21.3: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 + xml-name-validator@5.0.0: {} + xml-naming@0.3.0: {} + xmlchars@2.2.0: {} + zod@3.25.76: {} diff --git a/CHANGELOG.md b/CHANGELOG.md index 45ca3cfc6..b497743f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,43 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.44.0 (2026-08-26) + +This release adds shared memory, skill discovery, and presentational widgets to Fast sessions, expands automation delivery across chat providers, and improves session and pull request review reliability. + +### Highlights + +- Save durable context from Fast sessions and recall it in later work through connected memory providers. +- Discover and load packaged or repository-defined skills from Fast before delegating work. +- Render safe status cards, tables, plans, and other presentational widgets in Fast session transcripts. +- Deliver Fast automation reports across Slack, Discord, Microsoft Teams, and Telegram, with direct chat continuation where supported. + +### Minor changes + +- Deliver Fast automation reports across Slack, Discord, Microsoft Teams, and Telegram channel or direct-message destinations, with web continuation everywhere and direct chat continuation where the provider supports Fast session identity. +- Let Fast sessions discover and load packaged and repository-defined skill documents through bounded, session-safe tools without exposing filesystem access. +- Let Fast save durable preferences, decisions, corrections, and facts to shared memory so they can be recalled in later sessions, while supporting pluggable memory providers and making concurrent memory ingestion more reliable. +- Render safe presentational widgets such as status cards, tables, and plans directly in Fast session transcripts while keeping raw HTML confined to the sandboxed web view. + +### Patch changes + +- Deliver Fast custom automation reports to their owners through configured Slack direct messages, and fail runs clearly when their configured Fast destination cannot be resolved or supported. +- Reconcile image-only Fast replies with their canonical transcript events so optimistic messages do not remain duplicated or stale. +- Keep Fast sessions moving through retryable provider failures without repeating completed tool effects, and forward follow-up instructions to active tasks before posting the confirmation reply. +- Make Fast coding-task kickoffs clearer by describing the repository work and naming the target repository when it is known without exposing internal orchestration details. +- Restore web-initiated Fast turns in standalone production images by shipping native-tool runtime dependencies and removing stale generated tool files during setup. +- Honor each user's Fast response mode preference on the homepage even when the browser has a saved workspace, while keeping explicit environment links and active workspace choices ahead of the personal default. +- Deliver completed pull request review findings reliably by tracking the review lifecycle with structured metadata instead of variable status wording. +- Keep pull request review threads clear by removing stale action buttons when newer feedback arrives or an action is handled, while preserving the latest actionable controls across Slack, Discord, and Telegram. +- Settle Slack task cards when work becomes idle or waits for input, then return them to an active state when work resumes without losing delayed final output. +- Keep the current page visible while authenticated navigation loads and correct the label shown when an input request is cancelled. +- Show Slack pull request review resolutions as subdued context notes instead of prominent message sections after an action is handled. +- Show the redacted task memory submitted by an agent in the save tool result so users can inspect what was recorded without exposing secret-shaped values. +- Deliver Teams Fast automation reports and failure states to newly created owner direct messages by using the persisted session route when no active route row exists. + ## 0.43.0 (2026-08-26) -This release brings Fast conversations into the dashboard, introduces a streamlined Memory experience, launches useful starter tasks directly from setup, and adds optional GitHub review checks. +This release brings Fast sessions into the dashboard, introduces a streamlined Memory experience, launches useful starter tasks directly from setup, and adds optional GitHub review checks. ### Highlights @@ -23,7 +57,7 @@ This release brings Fast conversations into the dashboard, introduces a streamli ### Patch changes - Keep Slack pull request status and resumed task previews accurate across ordinary, Fast-delegated, retried, reopened, and completed task paths. -- Keep Fast conversations useful across follow-ups and longer work by preserving native context through helper restarts, exposing deployment task inspection, responding naturally to corrections, and sharing concise progress when work takes time. +- Keep Fast sessions useful across follow-ups and longer work by preserving native context through helper restarts, exposing deployment task inspection, responding naturally to corrections, and sharing concise progress when work takes time. - Make pull request feedback and review checks more reliable by coalescing duplicate actionable notifications, completing checks when reviews finish, and showing provisional findings sooner when a summary is delayed. - Render automation result tables in Slack with valid cell payloads, including tables with visually empty cells. - Show the first user message as a Fast session's temporary title instead of exposing a timestamp-like conversation identifier while title generation finishes. @@ -71,7 +105,7 @@ This release adds a shared task board, expands Fast delegation and preferences, ### Highlights - Coordinate team work from a shared board organized by task lifecycle, ownership, and context. -- Launch multiple independent tasks from one Fast conversation, choose their coding models, and consult focused reasoning subagents. +- Launch multiple independent tasks from one Fast session, choose their coding models, and consult focused reasoning subagents. - Send actionable GitHub check failures back to linked tasks and their originating conversations. - Warn operators about provider usage limits and deliver configuration issues to admins even without a configured channel. @@ -88,11 +122,11 @@ This release adds a shared task board, expands Fast delegation and preferences, - Give Brain pull-request pages the PR description and labels from GitHub, GitLab, Gitea, Bitbucket, and Azure DevOps so agents can recall why a change was made, not only its title. - Enrich Brain pull-request pages with files changed, code areas, line totals, and review outcomes across supported source-control providers so agents can find the changes that affected a part of the codebase. -- Keep Fast conversations moving with clearer delegated replies, Slack task cards that resume after follow-ups, recovery from missing runtime dependencies and transient provider outages, and accurate guidance when a provider blocks a response. +- Keep Fast sessions moving with clearer delegated replies, Slack task cards that resume after follow-ups, recovery from missing runtime dependencies and transient provider outages, and accurate guidance when a provider blocks a response. - Show Fast response mode to every user in Personal Settings and apply each saved preference to eligible linked Slack and Discord messages without requiring deployment configuration. - Trust only explicitly configured Roomote GitHub App slugs for managed pull-request activity, and keep Roomote attribution in pull-request descriptions to one canonical entry. - Make onboarding easier to follow with clearer account, inference-provider, source-control, and environment guidance throughout the setup flow. -- Make pull-request feedback in Fast conversations reliable by delivering review activity consistently, keeping Slack review actions usable after delegated tasks settle, suppressing duplicate or stale results, and reducing notification pressure on provider quotas. +- Make pull-request feedback in Fast sessions reliable by delivering review activity consistently, keeping Slack review actions usable after delegated tasks settle, suppressing duplicate or stale results, and reducing notification pressure on provider quotas. - Keep Brain task-memory history accurate by recording completed tasks reliably, distinguishing real ingestion gaps from completed backfills, and preventing the history-ingestion banner from returning after completion. - Finish Slack task cards with the delegated agent's real result after Fast work settles, preserve actionable input requests, and keep terminal error cards stable while delivery retries. - Give Fast and normal Roomote agents the semantic product release in their core prompt context while omitting channel tags and unavailable versions. @@ -467,12 +501,12 @@ This release expands Amazon Bedrock and deployment controls, refreshes Automatio ### Patch changes - Keep Azure Container Apps sandboxes suspended until Roomote deliberately wakes them, recover cleanly from leftover workers, and stop retrying runs whose sandboxes were deleted. Thanks to @tebieshi for contributing this improvement. -- Stop ChatGPT subscription connections from waiting forever on expired or blocked device codes, explain why authorization stopped, and offer a clean restart. Thanks to @daniel-lxs for contributing this improvement. +- Stop ChatGPT subscription connections from waiting forever on expired or blocked device codes, explain why authorization stopped, and offer a clean restart. - Recommend Claude Sonnet 5 at medium reasoning for code review when operators apply supported provider presets, while retaining Opus for planning. - Keep Discord task requests through account linking, focus onboarding on the required personal account connection, and preserve automation reply threads when tasks resume. - Keep the Users settings invite list focused on links that still have uses remaining while retaining consumed invite records. - Add a direct Personal settings shortcut to the signed-in user menu. -- Make ChatGPT, xAI, and GitHub Copilot device connections handle expiry, rate limits, restarts, and stale polling consistently, with clearer terminal errors across all three providers. Thanks to @daniel-lxs for contributing this improvement. +- Make ChatGPT, xAI, and GitHub Copilot device connections handle expiry, rate limits, restarts, and stale polling consistently, with clearer terminal errors across all three providers. - Give sandbox providers more time to finish rate-limited starts before aborting, and offer a retry when a task start fails before producing output. - Show terminal command output again in the web task view, with collapsible details and a copy button for easier inspection. - Restore the previous deployment metadata and controller after a failed self-hosted image pull so operators can retry upgrades without breaking the next backup. @@ -691,7 +725,7 @@ This release makes Roomote easier to reach across communication channels while r ### Patch changes -- Let interrupted MCP OAuth connections resume safely after sign-in, route GitHub issue links to their matching environment, and keep source-control attribution and review follow-up behavior accurate. Thanks to @daniel-lxs for contributing the MCP OAuth improvement. +- Let interrupted MCP OAuth connections resume safely after sign-in, route GitHub issue links to their matching environment, and keep source-control attribution and review follow-up behavior accurate. - Improve deployment and task reliability with faster encrypted configuration access, request timing diagnostics, safer custom MCP environment-variable handling, and quieter automation discovery scans. Thanks to @mrubens for contributing these improvements. - Show a workspace-shaped loading state while task history hydrates, preserve accepted or dismissed PR feedback in Discord, and add a Discord community link to the release-update dialog. diff --git a/apps/api/package.json b/apps/api/package.json index 07cf8d78c..911435cf9 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -42,8 +42,10 @@ "@sentry/node": "10.45.0", "@trpc/client": "^11.15.0", "@trpc/server": "^11.15.0", + "dompurify": "3.4.13", "hono": "4.12.34", "jose": "^6.2.3", + "jsdom": "26.1.0", "p-map": "^7.0.4", "snowflake-sdk": "^2.4.3", "undici": "^7.29.0", diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts index df2a929e8..ea27c413e 100644 --- a/apps/api/src/handlers/custom-automations/index.ts +++ b/apps/api/src/handlers/custom-automations/index.ts @@ -23,8 +23,8 @@ import { import { ALL_REPOSITORIES, FAST_EXECUTION, + getCommunicationAutomationTargetKind, type BackgroundAutomationProvider, - type BackgroundAutomationTargetKind, type CustomAutomationScheduleMode, type OptionalAutomationTarget, } from '@roomote/types'; @@ -64,7 +64,6 @@ const writeSchema = z.object({ targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(), targetMode: z.enum(['channel', 'direct_message']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z.string().trim().min(1).max(500).optional(), }); const updateSchema = z.object({ @@ -80,7 +79,6 @@ const updateSchema = z.object({ .optional(), targetMode: z.enum(['channel', 'direct_message']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z.string().trim().min(1).max(500).optional(), }); const UNIQUE_VIOLATION_CODE = '23505'; @@ -218,7 +216,7 @@ async function requireAdmin(auth: McpAuth): Promise { function buildTarget( input: Pick< z.infer, - 'targetProvider' | 'targetMode' | 'targetChannelId' | 'targetServiceUrl' + 'targetProvider' | 'targetMode' | 'targetChannelId' >, ownerUserId: string, ): OptionalAutomationTarget { @@ -228,27 +226,13 @@ function buildTarget( throw new Error('targetChannelId is required when targetProvider is set.'); } - const kinds: Record = { - slack: 'slack_channel', - discord: 'discord_channel', - teams: 'teams_channel', - telegram: 'telegram_chat', - }; - const userKinds: Record = { - slack: 'slack_user', - discord: 'discord_user', - teams: 'teams_user', - telegram: 'telegram_user', - }; return { provider: input.targetProvider as BackgroundAutomationProvider, - targetKind: directMessage - ? userKinds[input.targetProvider]! - : kinds[input.targetProvider]!, + targetKind: getCommunicationAutomationTargetKind( + input.targetProvider, + directMessage ? 'direct_message' : 'channel', + ), externalRef: directMessage ? ownerUserId : input.targetChannelId!, - ...(!directMessage && input.targetServiceUrl - ? { metadata: { serviceUrl: input.targetServiceUrl } } - : {}), }; } @@ -465,8 +449,7 @@ customAutomationsRouter.patch('/:id', async (c) => { const destinationChanged = parsed.data.targetProvider !== undefined || parsed.data.targetMode !== undefined || - parsed.data.targetChannelId !== undefined || - parsed.data.targetServiceUrl !== undefined; + parsed.data.targetChannelId !== undefined; if ( !clearTarget && destinationChanged && @@ -478,10 +461,6 @@ customAutomationsRouter.patch('/:id', async (c) => { 'targetChannelId is required when targetProvider is set.', ); } - const existingServiceUrl = - typeof existingTarget.metadata?.serviceUrl === 'string' - ? existingTarget.metadata.serviceUrl - : undefined; const automation = await updateCustomAutomation(c.req.param('id'), { name: parsed.data.name ?? existing.name, prompt: parsed.data.prompt ?? existing.prompt, @@ -508,8 +487,6 @@ customAutomationsRouter.patch('/:id', async (c) => { targetProvider, targetMode, targetChannelId, - targetServiceUrl: - parsed.data.targetServiceUrl ?? existingServiceUrl, }, existing.createdByUserId ?? adminId(c), ) diff --git a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts index 007535043..c8a479623 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({ reply: vi.fn(), resolveWorkspace: vi.fn(), startTask: vi.fn(), + recordProviderMessage: vi.fn(), })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -32,6 +33,11 @@ vi.mock('@roomote/cloud-agents/server', () => ({ .mockResolvedValue({ id: 'fast-session-1' }), })); +vi.mock('@roomote/sdk/server', () => ({ + recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, + resolveUserMcpServerConfigs: vi.fn(async () => ({})), +})); + vi.mock('@roomote/communication/discord-event', () => ({ getDiscordMessageCreate: mocks.getMessage, })); diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 964971b62..23fbdb259 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -61,6 +61,9 @@ const mocks = vi.hoisted(() => ({ answerFast: vi.fn(), hasFastDefault: vi.fn(), hasFastSession: vi.fn(), + findFastReplySession: vi.fn(), + isFastProviderMessage: vi.fn(), + recordProviderMessage: vi.fn(), })); vi.mock('../../account-link-help.js', () => ({ @@ -106,6 +109,10 @@ vi.mock('@roomote/sdk/server', () => ({ upsertDiscordInstallation: mocks.upsertInstallation, enqueueDiscordGatewayEvent: mocks.enqueueGatewayEvent, claimPendingPrReviewActionsForThread: vi.fn(async () => []), + findFastAgentSessionForProviderReply: mocks.findFastReplySession, + isFastAgentProviderMessage: mocks.isFastProviderMessage, + recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, + resolveUserMcpServerConfigs: vi.fn(async () => ({})), })); vi.mock('@roomote/sdk/server/communication', () => ({ @@ -299,6 +306,9 @@ describe('Discord Gateway event handler', () => { mocks.answerFast.mockResolvedValue('A quick answer'); mocks.hasFastDefault.mockResolvedValue(false); mocks.hasFastSession.mockResolvedValue(false); + mocks.findFastReplySession.mockResolvedValue(null); + mocks.isFastProviderMessage.mockResolvedValue(false); + mocks.recordProviderMessage.mockResolvedValue(true); mocks.reply.mockResolvedValue({ messageId: 'reply-1' }); mocks.createDirectMessage.mockResolvedValue({ id: 'dm-private-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-msg-1' }); @@ -1441,6 +1451,88 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); + it('continues the Fast session bound to a Discord DM report reply', async () => { + mocks.findFastReplySession.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + userId: 'roomote-user-1', + conversation: { + surface: 'discord', + workspaceId: 'dm', + conversationId: 'automation-run-1', + replyTarget: { channelId: 'dm-1' }, + }, + }); + + const response = await postEvent( + envelope( + message({ + content: 'Investigate the second finding', + message_reference: { message_id: 'fast-report-1' }, + }), + ), + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ fastAnswered: true, fastContinued: true }), + ); + expect(mocks.findFastReplySession).toHaveBeenCalledWith({ + provider: 'discord', + workspaceId: 'dm', + channelId: 'dm-1', + replyToMessageId: 'fast-report-1', + }); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ + question: 'Investigate the second finding', + conversation: expect.objectContaining({ + conversationId: 'automation-run-1', + }), + }), + ); + expect(mocks.findAutomationReportRun).not.toHaveBeenCalled(); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + }); + + it('fails closed when a different Discord DM user replies to a Fast report', async () => { + mocks.findFastReplySession.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + userId: 'another-roomote-user', + conversation: { + surface: 'discord', + workspaceId: 'dm', + conversationId: 'automation-run-1', + replyTarget: { channelId: 'dm-1' }, + }, + }); + + const response = await postEvent( + envelope(message({ message_reference: { message_id: 'fast-report-1' } })), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'discord_fast_session_user_mismatch', + }); + expect(mocks.answerFast).not.toHaveBeenCalled(); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + }); + + it('does not fall through when a Discord Fast message is replayed from another route', async () => { + mocks.isFastProviderMessage.mockResolvedValue(true); + + const response = await postEvent( + envelope(message({ message_reference: { message_id: 'fast-report-1' } })), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'discord_fast_session_route_mismatch', + }); + expect(mocks.findAutomationReportRun).not.toHaveBeenCalled(); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + }); + it('nudges an unlinked mentioned user without launching work', async () => { mocks.findMappedUserId.mockResolvedValue(null); mocks.getChannel.mockResolvedValue({ diff --git a/apps/api/src/handlers/discord/__tests__/pr-review-action.test.ts b/apps/api/src/handlers/discord/__tests__/pr-review-action.test.ts index f8b1f3a92..9f00414ae 100644 --- a/apps/api/src/handlers/discord/__tests__/pr-review-action.test.ts +++ b/apps/api/src/handlers/discord/__tests__/pr-review-action.test.ts @@ -1,14 +1,17 @@ const mocks = vi.hoisted(() => ({ claimPending: vi.fn(), + claimThread: vi.fn(), dispatchFollowUp: vi.fn(), completeActionDispatch: vi.fn(), findMappedUser: vi.fn(), reply: vi.fn(), + editMessage: vi.fn(), + getMessage: vi.fn(), })); vi.mock('@roomote/sdk/server', () => ({ claimPendingPrReviewAction: mocks.claimPending, - claimPendingPrReviewActionsForThread: vi.fn(), + claimPendingPrReviewActionsForThread: mocks.claimThread, dispatchPrReviewFollowUp: mocks.dispatchFollowUp, completePendingPrReviewActionDispatch: mocks.completeActionDispatch, enableAutoHandlePrReviewFeedback: vi.fn(), @@ -17,7 +20,15 @@ vi.mock('@roomote/sdk/server', () => ({ vi.mock('../replies.js', () => ({ replyToDiscordEvent: mocks.reply })); -import { handleDiscordPrReviewActionCallback } from '../pr-review-action.js'; +import { + handleDiscordPrReviewActionCallback, + retireDiscordPrReviewOffersBestEffort, +} from '../pr-review-action.js'; + +const provider = { + editMessage: mocks.editMessage, + getMessage: mocks.getMessage, +}; describe('handleDiscordPrReviewActionCallback', () => { beforeEach(() => { @@ -32,11 +43,13 @@ describe('handleDiscordPrReviewActionCallback', () => { followUpPrompt: 'Address the feedback.', }); mocks.dispatchFollowUp.mockResolvedValue({ outcome: 'queued', runId: 7 }); + mocks.editMessage.mockResolvedValue(undefined); + mocks.getMessage.mockResolvedValue(null); }); it('preserves the feedback card and renders auto-resolve as a regular message', async () => { await handleDiscordPrReviewActionCallback({ - provider: {} as never, + provider: provider as never, applicationId: 'app-1', interaction: { id: 'interaction-1', @@ -73,6 +86,11 @@ describe('handleDiscordPrReviewActionCallback', () => { }), ); expect(mocks.reply.mock.calls[0]?.[0]).not.toHaveProperty('buttons'); + expect(mocks.editMessage).toHaveBeenCalledWith({ + channelId: 'thread-1', + messageId: 'message-1', + text: 'Review feedback: add a regression test.', + }); expect(mocks.dispatchFollowUp).toHaveBeenCalledWith({ provider: 'discord', taskId: 'task-1', @@ -88,7 +106,7 @@ describe('handleDiscordPrReviewActionCallback', () => { mocks.claimPending.mockResolvedValue(null); await handleDiscordPrReviewActionCallback({ - provider: {} as never, + provider: provider as never, applicationId: 'app-1', interaction: { id: 'interaction-1', @@ -124,13 +142,18 @@ describe('handleDiscordPrReviewActionCallback', () => { text: 'Review feedback: add a regression test.\n\n-# This offer was already handled or has expired.', }), ); + expect(mocks.editMessage).toHaveBeenCalledWith({ + channelId: 'thread-1', + messageId: 'message-1', + text: 'Review feedback: add a regression test.', + }); }); it('renders the resolution as subtext when the feedback is empty', async () => { mocks.claimPending.mockResolvedValue(null); await handleDiscordPrReviewActionCallback({ - provider: {} as never, + provider: provider as never, applicationId: 'app-1', interaction: { id: 'interaction-1', @@ -172,7 +195,7 @@ describe('handleDiscordPrReviewActionCallback', () => { const content = 'x'.repeat(2_000); await handleDiscordPrReviewActionCallback({ - provider: {} as never, + provider: provider as never, applicationId: 'app-1', interaction: { id: 'interaction-1', @@ -210,3 +233,72 @@ describe('handleDiscordPrReviewActionCallback', () => { ); }); }); + +describe('retireDiscordPrReviewOffersBestEffort', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.editMessage.mockResolvedValue(undefined); + }); + + it('removes controls from every offer claimed by a typed reply', async () => { + mocks.claimThread.mockResolvedValue([ + { + messageId: 'message-1', + channelId: 'channel-1', + threadId: 'thread-1', + }, + { + messageId: 'message-2', + channelId: 'channel-1', + threadId: 'thread-1', + }, + ]); + mocks.getMessage + .mockResolvedValueOnce({ text: 'First review offer' }) + .mockResolvedValueOnce({ text: 'Second review offer' }); + + retireDiscordPrReviewOffersBestEffort({ + provider: provider as never, + channelId: 'channel-1', + threadId: 'thread-1', + }); + + await vi.waitFor(() => { + expect(mocks.editMessage).toHaveBeenCalledTimes(2); + }); + expect(mocks.editMessage).toHaveBeenNthCalledWith(1, { + channelId: 'thread-1', + messageId: 'message-1', + text: 'First review offer', + }); + expect(mocks.editMessage).toHaveBeenNthCalledWith(2, { + channelId: 'thread-1', + messageId: 'message-2', + text: 'Second review offer', + }); + }); + + it('continues retiring offers after one provider cleanup fails', async () => { + mocks.claimThread.mockResolvedValue([ + { messageId: 'message-1', channelId: 'channel-1', threadId: 'thread-1' }, + { messageId: 'message-2', channelId: 'channel-1', threadId: 'thread-1' }, + ]); + mocks.getMessage + .mockRejectedValueOnce(new Error('Discord unavailable')) + .mockResolvedValueOnce({ text: 'Second review offer' }); + + retireDiscordPrReviewOffersBestEffort({ + provider: provider as never, + channelId: 'channel-1', + threadId: 'thread-1', + }); + + await vi.waitFor(() => { + expect(mocks.editMessage).toHaveBeenCalledWith({ + channelId: 'thread-1', + messageId: 'message-2', + text: 'Second review offer', + }); + }); + }); +}); diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index d0d7c1c54..eaae53e29 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -24,7 +24,10 @@ import { setThreadReplyFooterRecord, withThreadReplyFooterLock, } from '@roomote/communication'; -import { resolveUserMcpServerConfigs } from '@roomote/sdk/server'; +import { + recordFastAgentConversationMessageBestEffort, + resolveUserMcpServerConfigs, +} from '@roomote/sdk/server'; import { ALL_REPOSITORIES } from '@roomote/types'; import { replyToDiscordEvent } from './replies.js'; @@ -146,6 +149,11 @@ export async function processDiscordFastAgentMessage(input: { ...(message ? { replyToMessageId: message.id } : {}), text: textWithFooter, }); + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.lastTextMessageId ?? posted.messageId, + }); return { messageId: posted.lastTextMessageId ?? posted.messageId, textWithoutFooter: getDiscordFooterlessFinalChunk({ diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index 58e079e23..edd28619c 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -37,6 +37,8 @@ import { consumeDiscordLinkCode, findDiscordInstallationByGuildId, findDiscordMappedUserId, + findFastAgentSessionForProviderReply, + isFastAgentProviderMessage, restoreDiscordLinkCode, upsertDiscordInstallation, upsertDiscordUserMapping, @@ -543,8 +545,40 @@ async function processDiscordGatewayEvent( : {}), }; const forceNewTask = command?.name === 'new'; - const repliedToAutomationReport = + const repliedFastSession = !forceNewTask && message?.message_reference?.message_id + ? await findFastAgentSessionForProviderReply({ + provider: 'discord', + workspaceId: channel.guildId ?? 'dm', + channelId: metadata.communicationChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + replyToMessageId: message.message_reference.message_id, + }) + : null; + if ( + !forceNewTask && + !repliedFastSession && + message?.message_reference?.message_id && + (await isFastAgentProviderMessage({ + provider: 'discord', + messageId: message.message_reference.message_id, + })) + ) { + return { ok: true, ignored: 'discord_fast_session_route_mismatch' }; + } + if ( + repliedFastSession && + channel.isDirectMessage && + repliedFastSession.userId !== senderUserId + ) { + return { ok: true, ignored: 'discord_fast_session_user_mismatch' }; + } + const repliedToAutomationReport = + !forceNewTask && + !repliedFastSession && + message?.message_reference?.message_id ? await findTaskBackedAutomationReportRun({ provider: 'discord', channelId: metadata.communicationChannelId, @@ -580,8 +614,9 @@ async function processDiscordGatewayEvent( launchOwnerUserId: senderUserId, }) : null; - const isFastAgentConversation = - channel.isThread || channel.isDirectMessage + const isFastAgentConversation = Boolean( + repliedFastSession ?? + (channel.isThread || channel.isDirectMessage ? await hasFastAgentSession({ surface: 'discord', workspaceId: channel.guildId ?? 'dm', @@ -591,7 +626,8 @@ async function processDiscordGatewayEvent( ...(channel.isThread ? { threadId: channel.channelId } : {}), }, }) - : false; + : false), + ); const isRoomoteThread = Boolean( activeRun || completedRun || @@ -772,7 +808,8 @@ async function processDiscordGatewayEvent( applicationId: resolved.applicationId, channel, metadata, - conversationId: channel.channelId, + conversationId: + repliedFastSession?.conversation.conversationId ?? channel.channelId, activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], }); return { ok: true, fastAnswered: true, fastContinued: true }; @@ -995,6 +1032,7 @@ async function processDiscordGatewayEvent( ); // A typed reply supersedes any pending PR review offers here. retireDiscordPrReviewOffersBestEffort({ + provider: resolved.provider, channelId: metadata.communicationChannelId, threadId: metadata.communicationThreadId ?? null, }); @@ -1108,6 +1146,7 @@ async function processDiscordGatewayEvent( }); // A typed reply supersedes any pending PR review offers here. retireDiscordPrReviewOffersBestEffort({ + provider: resolved.provider, channelId: metadata.communicationChannelId, threadId: metadata.communicationThreadId ?? null, }); diff --git a/apps/api/src/handlers/discord/pr-review-action.ts b/apps/api/src/handlers/discord/pr-review-action.ts index 1c470e50e..1218e70ae 100644 --- a/apps/api/src/handlers/discord/pr-review-action.ts +++ b/apps/api/src/handlers/discord/pr-review-action.ts @@ -64,6 +64,35 @@ export async function handleDiscordPrReviewActionCallback(input: { }; const user = input.interaction.member?.user ?? input.interaction.user; const mappedUserId = await findDiscordMappedUserId(user?.id); + const clearOfferButtons = async (params: { + channelId: string; + messageId: string; + text?: string; + }) => { + try { + const text = + params.text ?? + ( + await input.provider.getMessage({ + channelId: params.channelId, + messageId: params.messageId, + }) + )?.text; + if (text === undefined) return; + + await input.provider.editMessage({ + channelId: params.channelId, + messageId: params.messageId, + text, + }); + } catch (error) { + apiLogger.warn( + `[discord] Failed to clear PR review action buttons from ${params.messageId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + }; if (input.choice !== 'dismiss' && !mappedUserId) { // Not claimed: a teammate with a linked account can still accept. @@ -79,10 +108,28 @@ export async function handleDiscordPrReviewActionCallback(input: { }); if (!pending) { + if (input.interaction.message) { + await clearOfferButtons({ + channelId: input.interaction.message.channel_id, + messageId: input.interaction.message.id, + text: input.interaction.message.content, + }); + } await replyToOffer('This offer was already handled or has expired.'); return; } + const offerMessageId = pending.messageId ?? input.interaction.message?.id; + if (offerMessageId) { + await clearOfferButtons({ + channelId: pending.threadId ?? pending.channelId, + messageId: offerMessageId, + ...(input.interaction.message + ? { text: input.interaction.message.content } + : {}), + }); + } + if (input.choice === 'dismiss') { await replyToOffer('Dismissed.'); return; @@ -145,22 +192,47 @@ export async function handleDiscordPrReviewActionCallback(input: { /** * Retires any pending PR review offers bound to a Discord conversation * because a typed reply superseded them. Claims atomically so later clicks - * report "already handled"; the buttons stay visible but dead (Discord - * message component editing is not wired up yet). Fire-and-forget. + * report "already handled" and strips the controls from posted messages. + * Fire-and-forget. */ export function retireDiscordPrReviewOffersBestEffort({ + provider, channelId, threadId, }: { + provider: DiscordCommunicationProvider; channelId: string; threadId: string | null; }): void { void (async () => { - await claimPendingPrReviewActionsForThread({ + const claimed = await claimPendingPrReviewActionsForThread({ provider: 'discord', channelId, threadId, }); + for (const pending of claimed) { + if (!pending.messageId) continue; + try { + const destinationId = pending.threadId ?? pending.channelId; + const message = await provider.getMessage({ + channelId: destinationId, + messageId: pending.messageId, + }); + if (message) { + await provider.editMessage({ + channelId: destinationId, + messageId: pending.messageId, + text: message.text, + }); + } + } catch (error) { + apiLogger.warn( + `[discord] Failed to clear PR review action buttons from ${pending.messageId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } })().catch((error: unknown) => { apiLogger.warn( `[discord] Failed to retire PR review offers for channel ${channelId}: ${ diff --git a/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.lifecycle.test.ts b/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.lifecycle.test.ts new file mode 100644 index 000000000..f39002bf0 --- /dev/null +++ b/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.lifecycle.test.ts @@ -0,0 +1,153 @@ +// pnpm --filter @roomote/api test src/handlers/github/__tests__/notifyPrReviewActivity.lifecycle.test.ts + +const { + mockCompleteGithubPrReviewCheckFromSummary, + mockEnqueuePrReviewNotification, + mockStartPrReviewNotificationCycle, +} = vi.hoisted(() => ({ + mockCompleteGithubPrReviewCheckFromSummary: vi + .fn() + .mockResolvedValue(undefined), + mockEnqueuePrReviewNotification: vi + .fn() + .mockResolvedValue({ notifiedTaskCount: 1 }), + mockStartPrReviewNotificationCycle: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('@roomote/env', async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + Env: { + R_GITHUB_APP_SLUG: 'roomote', + R_GITHUB_ADDITIONAL_APP_SLUGS: 'roomote-community', + }, + }; +}); + +vi.mock('@roomote/sdk/server', () => ({ + completeGithubPrReviewCheckFromSummary: + mockCompleteGithubPrReviewCheckFromSummary, + enqueuePrReviewNotification: mockEnqueuePrReviewNotification, + startPrReviewNotificationCycle: mockStartPrReviewNotificationCycle, +})); + +import { setConfiguredGitHubAppSlugCache } from '@roomote/github'; + +import { queuePrReviewSummaryNotification } from '../notifyPrReviewActivity'; + +/* oxlint-disable typescript/no-explicit-any */ + +const REVIEW_HEAD_SHA = '037c1c632f4f4cc6b6a52d23c59ed17d8f56e4e4'; +const COMMENT_ID = 5426226987; +const CREATED_AT = '2026-08-26T13:44:18.000Z'; +const COMPLETED_AT = '2026-08-26T13:48:07.000Z'; + +const IN_PROGRESS_BODY = [ + ``, + '', + 'I am reviewing the updated PR head now.', + '', + '', + '', + `Reviewing ${REVIEW_HEAD_SHA.slice(0, 7)}`, +].join('\n'); + +const TERMINAL_BODY = [ + ``, + '', + '1 issue outstanding. [See task](https://roomote.dev/task/reviewtask)', + '', + '', + '- [ ] Validate image values before they satisfy the empty-message guard.', + '', + `Reviewed ${REVIEW_HEAD_SHA.slice(0, 7)}`, +].join('\n'); + +function summaryPayload({ + body, + updatedAt, + previousBody, +}: { + body: string; + updatedAt: string; + previousBody?: string; +}): any { + return { + installation: { id: 1 }, + repository: { full_name: 'RooCodeInc/Roomote' }, + issue: { + number: 1688, + html_url: 'https://github.com/RooCodeInc/Roomote/pull/1688', + pull_request: { + html_url: 'https://github.com/RooCodeInc/Roomote/pull/1688', + }, + }, + comment: { + id: COMMENT_ID, + body, + created_at: CREATED_AT, + updated_at: updatedAt, + html_url: `https://github.com/RooCodeInc/Roomote/pull/1688#issuecomment-${COMMENT_ID}`, + user: { login: 'roomote-community[bot]' }, + }, + ...(previousBody + ? { + changes: { + body: { from: previousBody }, + }, + } + : {}), + }; +} + +describe('PR review-summary lifecycle replay', () => { + beforeEach(() => { + setConfiguredGitHubAppSlugCache({ + value: 'roomote', + expiresAt: Date.now() + 60_000, + }); + mockCompleteGithubPrReviewCheckFromSummary.mockClear(); + mockEnqueuePrReviewNotification.mockClear(); + mockStartPrReviewNotificationCycle.mockClear(); + }); + + afterEach(() => { + setConfiguredGitHubAppSlugCache(null); + }); + + it('opens the in-progress cycle and enqueues only the terminal finding', async () => { + await queuePrReviewSummaryNotification( + summaryPayload({ + body: IN_PROGRESS_BODY, + updatedAt: CREATED_AT, + }), + ); + + expect(mockStartPrReviewNotificationCycle).toHaveBeenCalledOnce(); + expect(mockEnqueuePrReviewNotification).not.toHaveBeenCalled(); + + await queuePrReviewSummaryNotification( + summaryPayload({ + body: TERMINAL_BODY, + previousBody: IN_PROGRESS_BODY, + updatedAt: COMPLETED_AT, + }), + ); + + expect(mockEnqueuePrReviewNotification).toHaveBeenCalledOnce(); + expect(mockEnqueuePrReviewNotification).toHaveBeenCalledWith( + expect.objectContaining({ + repository: 'RooCodeInc/Roomote', + prNumber: 1688, + event: expect.objectContaining({ + kind: 'review_summary', + summary: '1 issue outstanding.', + reviewHeadSha: REVIEW_HEAD_SHA, + roomoteAuthored: true, + }), + }), + ); + }); +}); diff --git a/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts b/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts index 98957fa50..704c3e2c1 100644 --- a/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts +++ b/apps/api/src/handlers/github/__tests__/notifyPrReviewActivity.test.ts @@ -61,10 +61,31 @@ vi.mock('@roomote/cloud-agents/server', () => ({ return content.slice(afterStart, endIndex).trim(); }, - isReviewInProgressStatusLine: (line: string) => - /^(Self-reviewing the PR(?: with fresh eyes)? now\.|Reviewing the PR now\.|Re-reviewing new commits now\.)/i.test( - line.trim(), - ), + isReviewSummaryInProgress: (body: string) => { + const marker = body.match(//i)?.[0]; + const markerVersion = marker?.match(/\bversion=(\d+)\b/i)?.[1]; + const markerPhase = marker?.match(/\bphase=(reviewing|reviewed)\b/i)?.[1]; + + if (markerVersion === '2' && markerPhase) { + return markerPhase.toLowerCase() === 'reviewing'; + } + + const footer = body.trimEnd().split('\n').at(-1)?.trim(); + const phase = footer?.match(/^\s*(Reviewing|Reviewed)(?:\s|<)/i)?.[1]; + + if (phase) { + return phase.toLowerCase() === 'reviewing'; + } + + const status = body.match( + /([\s\S]*?)/, + )?.[1]; + const firstLine = status?.trim().split('\n')[0] ?? ''; + + return /^(Self-reviewing the PR(?: with fresh eyes)? now\.|Reviewing the PR now\.|Re-reviewing new commits now\.|I am reviewing the updated PR head now\.)/i.test( + firstLine, + ); + }, })); import { setConfiguredGitHubAppSlugCache } from '@roomote/github'; @@ -552,6 +573,14 @@ const IN_PROGRESS_SUMMARY_BODY = [ '', ].join('\n'); +const NATURAL_IN_PROGRESS_SUMMARY_BODY = [ + '', + '', + 'I am reviewing the updated PR head now. [See task](https://roomote.dev/task/x)', + '', + 'Reviewing f0c89ce', +].join('\n'); + const ALL_ADDRESSED_SUMMARY_BODY = [ '', '', @@ -666,6 +695,27 @@ describe('buildPrReviewSummaryNotification', () => { }); }); + it('uses the review footer when in-progress status prose varies', () => { + expect( + buildPrReviewSummaryNotification( + summaryPayload({ body: NATURAL_IN_PROGRESS_SUMMARY_BODY }), + ), + ).toBeNull(); + + expect( + buildPrReviewSummaryNotification( + summaryPayload({ + body: TERMINAL_SUMMARY_BODY, + previousBody: NATURAL_IN_PROGRESS_SUMMARY_BODY, + }), + )?.input.event, + ).toMatchObject({ + kind: 'review_summary', + summary: '1 minor doc note; no blocking issues.', + roomoteAuthored: true, + }); + }); + it('skips fixer terminal-to-terminal rewrites of the pinned summary', () => { expect( buildPrReviewSummaryNotification( @@ -877,6 +927,28 @@ describe('queuePrReviewSummaryNotification', () => { }); }); + it('opens and completes a cycle when in-progress status prose varies', async () => { + await queuePrReviewSummaryNotification( + summaryPayload({ body: NATURAL_IN_PROGRESS_SUMMARY_BODY }), + ); + await queuePrReviewSummaryNotification( + summaryPayload({ + body: TERMINAL_SUMMARY_BODY, + previousBody: NATURAL_IN_PROGRESS_SUMMARY_BODY, + }), + ); + + expect(mockStartPrReviewNotificationCycle).toHaveBeenCalledOnce(); + expect(mockEnqueuePrReviewNotification).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + kind: 'review_summary', + summary: '1 minor doc note; no blocking issues.', + }), + }), + ); + }); + it('opens a distinct cycle when the same SHA is reviewed again', async () => { const nextUpdatedAt = '2026-08-10T20:30:00.000Z'; diff --git a/apps/api/src/handlers/github/notifyPrReviewActivity.ts b/apps/api/src/handlers/github/notifyPrReviewActivity.ts index d126e1731..ec9a710bc 100644 --- a/apps/api/src/handlers/github/notifyPrReviewActivity.ts +++ b/apps/api/src/handlers/github/notifyPrReviewActivity.ts @@ -5,7 +5,7 @@ import { REVIEW_STATUS_START_MARKER, REVIEW_SUMMARY_MARKER, getMarkedSection, - isReviewInProgressStatusLine, + isReviewSummaryInProgress, } from '@roomote/cloud-agents/server'; import { Schemas as GitHubSchemas } from '@roomote/github'; import { @@ -276,8 +276,10 @@ function sanitizeReviewSummaryStatus(statusContent: string): string { /** * Parses the head SHA out of the review-summary marker line, e.g. - * ``. Requires at - * least a short-sha (7 hex chars), matching parseReviewSummaryMarkerSha. + * ``. + * Requires at least a short-sha (7 hex chars), matching + * parseReviewSummaryMarkerSha. SHA remains the first attribute for mixed-version + * compatibility with older webhook consumers. */ function getReviewSummaryMarkerSha(body: string): string | null { const match = body.match( @@ -398,8 +400,7 @@ function buildPrReviewSummaryLifecycle( return null; } - const firstStatusLine = statusContent.split('\n')[0] ?? ''; - const currentInProgress = isReviewInProgressStatusLine(firstStatusLine); + const currentInProgress = isReviewSummaryInProgress(body); const previousBody = 'changes' in eventPayload ? eventPayload.changes.body?.from : undefined; const previousStatusLine = @@ -407,8 +408,9 @@ function buildPrReviewSummaryLifecycle( ? getReviewStatusFirstLine(previousBody) : null; const previousInProgress = + typeof previousBody === 'string' && previousStatusLine !== null && - isReviewInProgressStatusLine(previousStatusLine); + isReviewSummaryInProgress(previousBody); const markerSha = getReviewSummaryMarkerSha(body); const reviewTaskId = getReviewTaskId(body); const revision = getIssueCommentRevision(eventPayload, context); diff --git a/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts b/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts index afe1bca28..f89704b90 100644 --- a/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts @@ -2,7 +2,10 @@ import { createServer, type Server } from 'node:http'; import type { AddressInfo } from 'node:net'; import { Hono } from 'hono'; -import type { RunTokenContext } from '@roomote/types'; +import { + BRAIN_MCP_READ_INSTRUCTIONS, + type RunTokenContext, +} from '@roomote/types'; import type { Variables } from '../../../types'; @@ -16,8 +19,6 @@ vi.mock('@roomote/sdk/server', () => ({ resolveBrainInferenceProvider: mockResolveBrainProvider, })); -import { BRAIN_MCP_INSTRUCTIONS } from '@roomote/types'; - import { createGbrainMcpProxy, GBRAIN_READ_TOOL_NAMES } from '../gbrain'; function createRunToken(): RunTokenContext { @@ -198,16 +199,11 @@ describe('createGbrainMcpProxy', () => { ); }); -describe('allowlist and instructions stay in step', () => { - it('names every exposed tool in the agent instructions, and exposes every named one', () => { - // A tool exposed but unexplained is chosen from gbrain's own description, - // which is written for a different product; a tool explained but not - // exposed sends the agent at something that 403s. - const named = GBRAIN_READ_TOOL_NAMES.filter((tool) => - BRAIN_MCP_INSTRUCTIONS.includes(`\`${tool}\``), - ); - - expect(named).toEqual([...GBRAIN_READ_TOOL_NAMES]); +describe('Brain agent allowlist', () => { + it('keeps the specialized read instructions aligned with exposed tools', () => { + for (const tool of GBRAIN_READ_TOOL_NAMES) { + expect(BRAIN_MCP_READ_INSTRUCTIONS).toContain(`\`${tool}\``); + } }); it('exposes no write or admin surface', () => { diff --git a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts index 718b40b25..39ce28d91 100644 --- a/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts +++ b/apps/api/src/handlers/slack/dispatch/__tests__/pr-review-action.test.ts @@ -206,7 +206,7 @@ describe('handleSlackPrReviewActionYes', () => { await handleSlackPrReviewActionYes(makePayload('pr_review_action_yes')); expect(dispatchFollowUpMock).not.toHaveBeenCalled(); - expect(updateMessageMock).not.toHaveBeenCalled(); + expect(updateMessageMock).toHaveBeenCalled(); expect(postSlackInteractiveResponseMock).toHaveBeenCalledWith( 'https://hooks.slack.test/response', expect.objectContaining({ @@ -240,7 +240,7 @@ describe('handleSlackPrReviewActionYes', () => { text: expect.stringContaining('no longer be resumed'), }), ); - expect(updateMessageMock).not.toHaveBeenCalled(); + expect(updateMessageMock).toHaveBeenCalled(); }); }); @@ -260,11 +260,13 @@ describe('handleSlackPrReviewActionAuto', () => { message: { blocks: expect.arrayContaining([ expect.objectContaining({ - type: 'section', - text: { - type: 'mrkdwn', - text: 'OK, <@U1>. Future review feedback on this PR will get resolved automatically.', - }, + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: '_OK, <@U1>. Future review feedback on this PR will get resolved automatically._', + }, + ], }), ]), }, @@ -296,7 +298,7 @@ describe('handleSlackPrReviewActionAuto', () => { await handleSlackPrReviewActionAuto(makePayload('pr_review_action_auto')); expect(dispatchFollowUpMock).not.toHaveBeenCalled(); - expect(updateMessageMock).not.toHaveBeenCalled(); + expect(updateMessageMock).toHaveBeenCalled(); expect(postSlackInteractiveResponseMock).toHaveBeenCalledWith( 'https://hooks.slack.test/response', expect.objectContaining({ @@ -334,14 +336,14 @@ describe('handleSlackPrReviewActionDismiss', () => { ); }); - it('reports an expired offer instead of updating the message', async () => { + it('reports an expired offer and removes its stale controls', async () => { claimPendingMock.mockResolvedValue(null); await handleSlackPrReviewActionDismiss( makePayload('pr_review_action_dismiss'), ); - expect(updateMessageMock).not.toHaveBeenCalled(); + expect(updateMessageMock).toHaveBeenCalled(); expect(postSlackInteractiveResponseMock).toHaveBeenCalledWith( 'https://hooks.slack.test/response', expect.objectContaining({ diff --git a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts index 0f76eff49..547e411e1 100644 --- a/apps/api/src/handlers/slack/dispatch/pr-review-action.ts +++ b/apps/api/src/handlers/slack/dispatch/pr-review-action.ts @@ -39,11 +39,9 @@ async function getSlackTeamNotifier(teamId: string) { async function updateNotificationMessage({ payload, resolution, - resolutionType, }: { payload: SlackInteractivePayload; resolution: string; - resolutionType?: 'context' | 'section'; }): Promise { try { const { slack } = await getSlackTeamNotifier(payload.team.id); @@ -55,7 +53,6 @@ async function updateNotificationMessage({ blocks: buildResolvedSlackPrReviewMessageBlocks( payload.message.blocks, resolution, - resolutionType, ), }, }); @@ -134,6 +131,10 @@ async function handleAcceptedPrReviewAction({ }); if (!pending) { + await updateNotificationMessage({ + payload, + resolution: 'Already handled or expired.', + }); await respondEphemeral( payload, 'This offer was already handled or has expired. Reply in the thread to ask again.', @@ -158,6 +159,10 @@ async function handleAcceptedPrReviewAction({ payload, 'Failed to start the follow-up. Reply in the thread to ask again.', ); + await updateNotificationMessage({ + payload, + resolution: 'Failed to start the follow-up.', + }); } } @@ -204,6 +209,10 @@ async function dispatchAcceptedPrReviewAction({ ); if (!enableAutoHandle) { + await updateNotificationMessage({ + payload, + resolution: 'This task can no longer be resumed.', + }); return; } } else { @@ -217,7 +226,6 @@ async function dispatchAcceptedPrReviewAction({ await updateNotificationMessage({ payload, resolution, - ...(enableAutoHandle ? { resolutionType: 'section' } : {}), }); } @@ -266,6 +274,10 @@ export async function handleSlackPrReviewActionDismiss( }); if (!pending) { + await updateNotificationMessage({ + payload, + resolution: 'Already handled or expired.', + }); await respondEphemeral( payload, 'This offer was already handled or has expired.', diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index f1e2756aa..62dcf0573 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -39,6 +39,10 @@ const { claimPendingOutOfBandMock, releaseClaimedOutOfBandMock, callViaEmojiConfigMock, + continueFastReplyMock, + findFastReplySessionMock, + findTeamsConversationRouteMock, + isFastProviderMessageMock, } = vi.hoisted(() => ({ authAccountsFindFirstMock: vi.fn(), authAccountsFindManyMock: vi.fn(), @@ -97,6 +101,10 @@ const { claimPendingOutOfBandMock: vi.fn(), releaseClaimedOutOfBandMock: vi.fn(), callViaEmojiConfigMock: vi.fn(), + continueFastReplyMock: vi.fn(), + findFastReplySessionMock: vi.fn(), + findTeamsConversationRouteMock: vi.fn(), + isFastProviderMessageMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -265,6 +273,7 @@ vi.mock('@roomote/communication/teams-provider', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + continueFastAgentSurfaceReply: continueFastReplyMock, createTeamsCommunicationProviderFromRuntimeCredentials: vi.fn(async () => envMock.R_TEAMS_BOT_APP_ID && envMock.R_TEAMS_BOT_APP_PASSWORD ? { @@ -275,6 +284,9 @@ vi.mock('@roomote/sdk/server', () => ({ } : null, ), + findFastAgentSessionForProviderReply: findFastReplySessionMock, + findTeamsConversationRoute: findTeamsConversationRouteMock, + isFastAgentProviderMessage: isFastProviderMessageMock, })); vi.mock('@roomote/cloud-agents/server', () => ({ @@ -354,6 +366,13 @@ function createJwtPayload(payload: Record) { describe('Teams webhook handler', () => { beforeEach(() => { vi.clearAllMocks(); + continueFastReplyMock.mockResolvedValue(true); + findFastReplySessionMock.mockResolvedValue(null); + findTeamsConversationRouteMock.mockResolvedValue({ + serviceUrl: 'https://smba.trafficmanager.net/amer/', + workspaceId: 'tenant-1', + }); + isFastProviderMessageMock.mockResolvedValue(false); envMock.R_TEAMS_BOT_APP_ID = 'bot-app-id'; envMock.R_MICROSOFT_CLIENT_ID = 'microsoft-client-id'; envMock.R_MICROSOFT_CLIENT_SECRET = 'microsoft-client-secret'; @@ -651,6 +670,167 @@ describe('Teams webhook handler', () => { }); }); + it('continues the bound Fast session before ordinary Teams task routing', async () => { + teamsUserMappingFindFirstMock.mockResolvedValueOnce({ + userId: 'mapped-user-1', + }); + findFastReplySessionMock.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: 'automation-run-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer bot-framework-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + conversation: { + id: '19:conversation@thread.v2;messageid=activity-root', + tenantId: 'tenant-1', + conversationType: 'channel', + }, + replyToId: 'fast-report-1', + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastContinued: true, + }); + expect(findFastReplySessionMock).toHaveBeenCalledWith({ + provider: 'teams', + workspaceId: 'tenant-1', + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + replyToMessageId: 'fast-report-1', + }); + expect(findTeamsConversationRouteMock).toHaveBeenCalledWith( + '19:conversation@thread.v2', + 'tenant-1', + ); + expect(continueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: '11111111-1111-4111-8111-111111111111', + userId: 'mapped-user-1', + question: 'continue', + currentMessageId: 'activity-2', + }), + ); + expect(findFirstMock).not.toHaveBeenCalled(); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + }); + + it('fails closed when a different linked Teams user replies to a Fast message', async () => { + teamsUserMappingFindFirstMock.mockResolvedValueOnce({ + userId: 'mapped-user-2', + }); + findFastReplySessionMock.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: 'automation-run-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer bot-framework-token', + 'content-type': 'application/json', + }, + body: JSON.stringify(createTeamsActivity()), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + expect(continueFastReplyMock).not.toHaveBeenCalled(); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + }); + + it('does not fall through when a Fast message is replayed from another Teams route', async () => { + teamsUserMappingFindFirstMock.mockResolvedValueOnce({ + userId: 'mapped-user-1', + }); + isFastProviderMessageMock.mockResolvedValue(true); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer bot-framework-token', + 'content-type': 'application/json', + }, + body: JSON.stringify(createTeamsActivity()), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_route_mismatch', + }); + expect(continueFastReplyMock).not.toHaveBeenCalled(); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + }); + + it('fails closed when the Fast session no longer has an active Teams installation route', async () => { + teamsUserMappingFindFirstMock.mockResolvedValueOnce({ + userId: 'mapped-user-1', + }); + findFastReplySessionMock.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: 'automation-run-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + findTeamsConversationRouteMock.mockResolvedValue(null); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer bot-framework-token', + 'content-type': 'application/json', + }, + body: JSON.stringify(createTeamsActivity()), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_installation_unavailable', + }); + expect(continueFastReplyMock).not.toHaveBeenCalled(); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + }); + it('queues untagged Teams thread replies for matching active task runs using the root thread id', async () => { teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 2f07d8738..6ca0b5295 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -8,6 +8,7 @@ import { getTeamsActivityChannelId, getTeamsActivityCommunicationMetadata, getTeamsActivityAudioAttachments, + getTeamsBaseConversationId, getTeamsActivityImageAttachments, getTeamsActivityTeamId, getTeamsActivityTenantId, @@ -25,7 +26,13 @@ import { buildTaskLaunchAcknowledgementText, } from '@roomote/communication/chat-messages'; import type { TeamsCommunicationProvider } from '@roomote/communication/teams-provider'; -import { createTeamsCommunicationProviderFromRuntimeCredentials } from '@roomote/sdk/server'; +import { + continueFastAgentSurfaceReply, + createTeamsCommunicationProviderFromRuntimeCredentials, + findFastAgentSessionForProviderReply, + findTeamsConversationRoute, + isFastAgentProviderMessage, +} from '@roomote/sdk/server'; import { exchangeMicrosoftDelegatedGraphToken, extractTeamsGraphHostedContentIds, @@ -334,7 +341,7 @@ async function persistTeamsInstallationFromActivity( teamName: activity.channelData?.team?.name ?? null, channelId: channelId ?? null, channelName: activity.channelData?.channel?.name ?? null, - conversationId: activity.conversation.id, + conversationId: getTeamsBaseConversationId(activity.conversation.id), conversationType: activity.conversation.conversationType ?? null, botAppId, botUserId: activity.recipient?.id ?? null, @@ -352,7 +359,7 @@ async function persistTeamsInstallationFromActivity( teamName: activity.channelData?.team?.name ?? null, channelId: channelId ?? null, channelName: activity.channelData?.channel?.name ?? null, - conversationId: activity.conversation.id, + conversationId: getTeamsBaseConversationId(activity.conversation.id), conversationType: activity.conversation.conversationType ?? null, botAppId, botUserId: activity.recipient?.id ?? null, @@ -1946,6 +1953,96 @@ teams.post('/', async (c) => { }, ); } + const replyToMessageId = activity.replyToId?.trim(); + const tenantId = metadata.teamsTenantId; + const fastChannelId = getTeamsBaseConversationId( + metadata.communicationChannelId, + ); + const fastSession = + mappedUserId && tenantId + ? await findFastAgentSessionForProviderReply({ + provider: 'teams', + workspaceId: tenantId, + channelId: fastChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + ...(replyToMessageId ? { replyToMessageId } : {}), + }) + : null; + if (!fastSession && replyToMessageId) { + const isKnownFastMessage = await isFastAgentProviderMessage({ + provider: 'teams', + messageId: replyToMessageId, + }); + if (isKnownFastMessage) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_route_mismatch', + }); + } + } + if (fastSession) { + if (!mappedUserId || fastSession.userId !== mappedUserId) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + } + if (fastSession.conversation.surface !== 'teams') { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_surface_mismatch', + }); + } + const activeRoute = await findTeamsConversationRoute( + fastSession.conversation.replyTarget.channelId, + tenantId, + ); + if (!activeRoute) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_installation_unavailable', + }); + } + + const fastMessage = await attachTeamsActivityMediaToQueuedMessage( + activity, + queuedMessage, + { userId: mappedUserId }, + ); + const question = fastMessage.text.trim(); + if (!question) { + return c.json({ ok: true, queued: false, reason: 'fast_message_empty' }); + } + void continueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId: mappedUserId, + senderDisplayName: activity.from?.name?.trim() || null, + question, + currentMessageId: queuedMessage.ts, + ...(fastMessage.images ? { images: fastMessage.images } : {}), + }) + .then((continued) => { + if (!continued) { + apiLogger.warn( + `[teams] Fast session ${fastSession.id} could not resolve an active delivery route`, + ); + } + }) + .catch((error) => { + apiLogger.error( + `[teams] Fast session ${fastSession.id} continuation failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + return c.json({ ok: true, fastAnswered: true, fastContinued: true }); + } const activeRun = await findActiveTeamsTaskRun({ conversationId: metadata.communicationChannelId, threadId: metadata.communicationThreadId, diff --git a/apps/api/src/handlers/telegram/pr-review-action.ts b/apps/api/src/handlers/telegram/pr-review-action.ts index 6001d29ed..a53b33c7c 100644 --- a/apps/api/src/handlers/telegram/pr-review-action.ts +++ b/apps/api/src/handlers/telegram/pr-review-action.ts @@ -68,19 +68,22 @@ export async function handleTelegramPrReviewActionCallback(params: { callbackQueryId: query.id, text: 'This offer was already handled or has expired.', }); + if (chatId && messageId) { + await clearTelegramMessageButtonsBestEffort({ chatId, messageId }); + } return; } + if (chatId && messageId) { + await clearTelegramMessageButtonsBestEffort({ chatId, messageId }); + } + if (choice === 'dismiss') { await answerTelegramCallbackQueryBestEffort({ callbackQueryId: query.id, text: 'Dismissed.', }); - if (chatId && messageId) { - await clearTelegramMessageButtonsBestEffort({ chatId, messageId }); - } - return; } @@ -142,8 +145,6 @@ export async function handleTelegramPrReviewActionCallback(params: { }); if (chatId && messageId) { - await clearTelegramMessageButtonsBestEffort({ chatId, messageId }); - if (dispatched.outcome !== 'unavailable') { await postTelegramMessageBestEffort({ chatId, diff --git a/apps/api/tsup.config.ts b/apps/api/tsup.config.ts index 38f4681dd..421ffaa71 100644 --- a/apps/api/tsup.config.ts +++ b/apps/api/tsup.config.ts @@ -18,9 +18,14 @@ export default defineConfig({ js: `import { createRequire as __createRequire } from 'module';const require = __createRequire(import.meta.url);`, }, esbuildOptions(options) { - // Exclude native modules and their runtime tree from bundling. + // Keep runtime-only dependency trees out of the API bundle. // tsup-level `external` is ignored when `noExternal: [/.*/]` is enabled, // so this must be applied at the esbuild layer. - options.external = [...(options.external ?? []), 'snowflake-sdk']; + options.external = [ + ...(options.external ?? []), + 'dompurify', + 'jsdom', + 'snowflake-sdk', + ]; }, }); diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index 6a8a7cfa2..19a10881a 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -19,6 +19,8 @@ const { mockDiscordPostMessage, mockStickyFooterPost, mockSetPendingPrReviewAction, + mockAttachPendingPrReviewActionMessage, + mockRetirePrReviewActionMessages, mockDispatchFollowUp, mockFindAutoHandlePrReviewFeedbackPreference, mockNotifyFastAgentParent, @@ -47,6 +49,8 @@ const { mockDiscordPostMessage: vi.fn(), mockStickyFooterPost: vi.fn(), mockSetPendingPrReviewAction: vi.fn(), + mockAttachPendingPrReviewActionMessage: vi.fn(), + mockRetirePrReviewActionMessages: vi.fn(), mockDispatchFollowUp: vi.fn(), mockFindAutoHandlePrReviewFeedbackPreference: vi.fn(), mockNotifyFastAgentParent: vi.fn(), @@ -185,6 +189,7 @@ vi.mock('@roomote/sdk/server', () => ({ completeCanonicalPrReviewAutoDispatch: mockCompleteCanonicalAutoDispatch, recordPrReviewNotificationDeliveryBestEffort: mockRecordDelivery, setPendingPrReviewAction: mockSetPendingPrReviewAction, + retirePrReviewActionMessagesBestEffort: mockRetirePrReviewActionMessages, dispatchPrReviewFollowUp: mockDispatchFollowUp, findAutoHandlePrReviewFeedbackPreference: mockFindAutoHandlePrReviewFeedbackPreference, @@ -193,7 +198,8 @@ vi.mock('@roomote/sdk/server', () => ({ renewPrReviewNotificationRequestLease: mockRenewLease, isDurablePrReviewNotificationRequest: mockIsDurable, migrateLegacyPrReviewNotificationRequest: mockMigrateLegacy, - attachPendingPrReviewActionMessage: vi.fn().mockResolvedValue(true), + attachPendingPrReviewActionMessageWithRetirement: + mockAttachPendingPrReviewActionMessage, })); import type { Job } from 'bullmq'; @@ -263,6 +269,11 @@ describe('prReviewNotificationJob', () => { }); mockRecordDelivery.mockResolvedValue(undefined); mockNotifyFastAgentParent.mockResolvedValue(false); + mockAttachPendingPrReviewActionMessage.mockResolvedValue({ + attached: true, + superseded: [], + }); + mockRetirePrReviewActionMessages.mockResolvedValue(undefined); mockFindAutoHandlePrReviewFeedbackPreference.mockResolvedValue(null); mockStickyFooterPost.mockResolvedValue('999.888'); mockPostMessage.mockResolvedValue({ @@ -678,6 +689,22 @@ describe('prReviewNotificationJob', () => { }); it('posts Yes/Dismiss action buttons and stores the pending offer when the triage produced a follow-up', async () => { + const superseded = { + nonce: 'old-nonce', + provider: 'slack', + taskId: 'task-1', + repository: 'owner/repo', + prNumber: 42, + prUrl: 'https://github.com/owner/repo/pull/42', + channelId: 'C123', + threadId: '111.222', + followUpPrompt: 'Old prompt', + messageId: '888.777', + }; + mockAttachPendingPrReviewActionMessage.mockResolvedValue({ + attached: true, + superseded: [superseded], + }); mockPrepareDelivery.mockResolvedValue({ post: true, route: { @@ -736,6 +763,14 @@ describe('prReviewNotificationJob', () => { for (const element of actionsBlock.elements) { expect(JSON.parse(element.value)).toEqual({ nonce: storedNonce }); } + expect(mockAttachPendingPrReviewActionMessage).toHaveBeenCalledWith( + storedNonce, + '999.888', + expect.objectContaining({ + context: expect.objectContaining({ nonce: storedNonce }), + }), + ); + expect(mockRetirePrReviewActionMessages).toHaveBeenCalledWith([superseded]); // The task-history record carries the question as trailing text. expect(mockRecordDelivery).toHaveBeenCalledWith( @@ -793,6 +828,38 @@ describe('prReviewNotificationJob', () => { ); }); + it('attaches Discord actions to the final button-bearing message', async () => { + mockPrepareDelivery.mockResolvedValue({ + post: true, + route: { + provider: 'discord', + channelId: 'channel-1', + threadId: 'thread-1', + }, + text: 'formatted-message', + followUpQuestion: 'Want me to take a look?', + followUpPrompt: 'Address the review feedback on owner/repo#42.', + }); + mockDiscordPostMessage.mockResolvedValue({ + provider: 'discord', + channelId: 'channel-1', + threadId: 'thread-1', + messageId: 'first-message', + lastTextMessageId: 'message-with-actions', + }); + + await prReviewNotificationJob(makeJob() as never); + + const storedNonce = mockSetPendingPrReviewAction.mock.calls[0]?.[0]?.nonce; + expect(mockAttachPendingPrReviewActionMessage).toHaveBeenCalledWith( + storedNonce, + 'message-with-actions', + expect.objectContaining({ + context: expect.objectContaining({ nonce: storedNonce }), + }), + ); + }); + it('keeps Teams routes on the plain trailing-question text', async () => { mockPrepareDelivery.mockResolvedValue({ post: true, diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index 276aa0179..be37abd21 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -15,7 +15,7 @@ import { PR_REVIEW_NOTIFICATION_DEFER_MS, PR_REVIEW_NOTIFICATION_MAX_DEFERRALS, PrReviewNotificationRateLimitError, - attachPendingPrReviewActionMessage, + attachPendingPrReviewActionMessageWithRetirement, beginCanonicalPrReviewAutoDispatch, beginCanonicalPrReviewPrompt, buildPrReviewNotificationPostInput, @@ -30,6 +30,7 @@ import { finalizePrReviewNotificationRequest, isDurablePrReviewNotificationRequest, renewPrReviewNotificationRequestLease, + retirePrReviewActionMessagesBestEffort, migrateLegacyPrReviewNotificationRequest, notifyFastAgentParentOnPrFeedback, preparePrReviewNotificationDelivery, @@ -173,6 +174,12 @@ function getFastParentButtonRoute( }; } + // Teams and Telegram can receive the Fast parent event itself, but the PR + // action-button renderer does not yet have provider-native callbacks there. + if (conversation.surface !== 'discord') { + return null; + } + return { provider: 'discord', channelId: conversation.replyTarget.channelId, @@ -223,21 +230,27 @@ async function postPrReviewNotification({ // Stored before posting: an orphaned record just expires, while a posted // message without a record would leave dead buttons. const nonce = action ? (canonicalDeliveryId ?? randomUUID()) : null; + const pendingAction = + action && nonce && isButtonRouteProvider(route.provider) + ? { + nonce, + provider: route.provider, + ...(route.provider === 'slack' + ? { slackTeamId: route.slackTeamId } + : {}), + taskId, + repository: action.repository, + prNumber: action.prNumber, + prUrl: action.prUrl, + channelId: route.channelId, + threadId: route.threadId ?? null, + followUpPrompt: action.followUpPrompt, + ...(canonicalDeliveryId ? { canonicalDeliveryId } : {}), + } + : null; - if (action && nonce && isButtonRouteProvider(route.provider)) { - await setPendingPrReviewAction({ - nonce, - provider: route.provider, - ...(route.provider === 'slack' ? { slackTeamId: route.slackTeamId } : {}), - taskId, - repository: action.repository, - prNumber: action.prNumber, - prUrl: action.prUrl, - channelId: route.channelId, - threadId: route.threadId ?? null, - followUpPrompt: action.followUpPrompt, - ...(canonicalDeliveryId ? { canonicalDeliveryId } : {}), - }); + if (pendingAction) { + await setPendingPrReviewAction(pendingAction); } if (route.provider === 'slack') { @@ -275,14 +288,21 @@ async function postPrReviewNotification({ }); if (nonce && messageTs) { - const attached = await attachPendingPrReviewActionMessage( - nonce, - messageTs, - canonicalLeaseToken ? { leaseToken: canonicalLeaseToken } : {}, - ); + const { attached, superseded } = + await attachPendingPrReviewActionMessageWithRetirement( + nonce, + messageTs, + { + ...(canonicalLeaseToken ? { leaseToken: canonicalLeaseToken } : {}), + ...(pendingAction ? { context: pendingAction } : {}), + }, + ); if (canonicalDeliveryId && !attached) { throw new Error('Canonical PR review prompt lost its posting fence'); } + if (superseded.length > 0) { + await retirePrReviewActionMessagesBestEffort(superseded); + } } return messageTs; @@ -321,14 +341,21 @@ async function postPrReviewNotification({ const posted = await adapter.postMessage(postInput); if (nonce && posted?.messageId) { - const attached = await attachPendingPrReviewActionMessage( - nonce, - posted.messageId, - canonicalLeaseToken ? { leaseToken: canonicalLeaseToken } : {}, - ); + const { attached, superseded } = + await attachPendingPrReviewActionMessageWithRetirement( + nonce, + posted.lastTextMessageId ?? posted.messageId, + { + ...(canonicalLeaseToken ? { leaseToken: canonicalLeaseToken } : {}), + ...(pendingAction ? { context: pendingAction } : {}), + }, + ); if (canonicalDeliveryId && !attached) { throw new Error('Canonical PR review prompt lost its posting fence'); } + if (superseded.length > 0) { + await retirePrReviewActionMessagesBestEffort(superseded); + } } return posted?.messageId ?? null; diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts index b1d7402e8..b8286b488 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-outbox-drain.test.ts @@ -5,16 +5,26 @@ const { mockResolveBrainProvider, mockBackfillEvents, mockClaimEvents, + mockClaimFastEvents, + mockConversationRows, mockGetSyncState, + mockMarkFastEvent, + mockSettleFastEvent, mockPullRequestFacts, + mockReleaseFastEvents, mockRunBrainCollectors, } = vi.hoisted(() => ({ mockResolveConnection: vi.fn(), mockResolveBrainProvider: vi.fn(), mockBackfillEvents: vi.fn(), mockClaimEvents: vi.fn(), + mockClaimFastEvents: vi.fn(), + mockConversationRows: vi.fn(), mockGetSyncState: vi.fn(), + mockMarkFastEvent: vi.fn(), + mockSettleFastEvent: vi.fn(), mockPullRequestFacts: vi.fn(), + mockReleaseFastEvents: vi.fn(), mockRunBrainCollectors: vi.fn(), })); @@ -37,11 +47,18 @@ vi.mock('@roomote/db/server', async (importOriginal) => { where: vi.fn(() => ({ orderBy: vi.fn(() => ({ limit: mockPullRequestFacts })), })), + leftJoin: vi.fn(() => ({ + where: vi.fn(() => ({ limit: mockConversationRows })), + })), })), })), }, backfillBrainMemoryEvents: mockBackfillEvents, claimPendingBrainMemoryEvents: mockClaimEvents, + claimPendingFastAgentMemoryEvents: mockClaimFastEvents, + markFastAgentMemoryEvent: mockMarkFastEvent, + settleFastAgentMemoryEvent: mockSettleFastEvent, + releaseFastAgentMemoryEvents: mockReleaseFastEvents, getBrainSyncState: mockGetSyncState, upsertBrainSyncState: vi.fn(), }; @@ -55,6 +72,9 @@ beforeEach(() => { vi.clearAllMocks(); mockGetSyncState.mockResolvedValue(null); mockClaimEvents.mockResolvedValue([]); + mockClaimFastEvents.mockResolvedValue([]); + mockConversationRows.mockResolvedValue([]); + mockSettleFastEvent.mockResolvedValue('settled'); mockPullRequestFacts.mockResolvedValue([]); mockRunBrainCollectors.mockResolvedValue({ backfillProgressed: false, @@ -65,6 +85,7 @@ beforeEach(() => { import { brainCollectorsJob, brainOutboxDrainJob, + buildFastMemoryPage, buildPullRequestFactPage, buildMemoryPage, callBrainWriteTool, @@ -461,6 +482,14 @@ describe('redactBrainText', () => { expect(output).toContain('after'); }); + it('redacts private key blocks with repeated begin markers', () => { + const input = `${'-----BEGIN PRIVATE KEY-----\n'.repeat(1_000)}secret\n-----END PRIVATE KEY-----`; + + const output = redactBrainText(input); + + expect(output).toBe('[REDACTED]'); + }); + it('leaves ordinary prose and identifiers alone', () => { const input = 'Completed task tasks/abc123: merged owner/repo#42 at 2026-08-13.'; @@ -590,5 +619,155 @@ describe('Brain readiness gate', () => { await brainOutboxDrainJob(); expect(mockClaimEvents).toHaveBeenCalled(); + expect(mockClaimFastEvents).toHaveBeenCalled(); + }); +}); + +describe('fast conversation memory pages', () => { + const baseInput = { + conversationId: '11111111-2222-3333-4444-555555555555', + conversationTitle: 'Deploy preferences', + userName: 'Sam Lee', + userId: 'user-1', + surface: 'slack', + memory: '- prefers deploys on Fridays\n- calls staging "the sandbox"', + createdAt: new Date('2026-08-01T09:00:00Z'), + updatedAt: new Date('2026-08-20T10:00:00Z'), + }; + + it('files the page under the conversation-specific memories slug', () => { + const page = buildFastMemoryPage(baseInput); + + expect(page.slug).toBe( + 'memories/fast/11111111-2222-3333-4444-555555555555', + ); + expect(page.content).toContain('type: conversation-memory'); + expect(page.content).toContain('provenance: roomote-fast-memory'); + expect(page.content).toContain('roomote_user_id: user-1'); + expect(page.content).toContain('date: 2026-08-20'); + expect(page.content).toContain('- prefers deploys on Fridays'); + }); + + it('falls back to a stable title for an untitled conversation', () => { + const page = buildFastMemoryPage({ + ...baseInput, + conversationTitle: null, + userName: null, + }); + + expect(page.title).toBe('Fast conversation 11111111'); + expect(page.content).not.toContain('saved_by'); + }); + + it('redacts credential-shaped strings before ingestion', () => { + const page = buildFastMemoryPage({ + ...baseInput, + memory: '- the token is ghp_abcdefghijklmnopqrstuvwxyz012345', + }); + + expect(page.content).not.toContain('ghp_abcdefghijklmnopqrstuvwxyz'); + expect(page.content).toContain('[REDACTED]'); + }); +}); + +describe('fast conversation memory drain', () => { + beforeEach(() => { + mockResolveConnection.mockResolvedValue({ + baseUrl: 'http://brain.test', + token: 'ingest-token', + }); + mockResolveBrainProvider.mockResolvedValue({ + providerId: 'openrouter', + apiKey: 'sk-or', + }); + mockGetSyncState.mockResolvedValue({ backfillCompletedAt: new Date() }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const event = { + id: 'event-1', + conversationId: 'conversation-1', + memory: '- prefers deploys on Fridays', + revision: 3, + attempts: 1, + createdAt: new Date('2026-08-01T09:00:00Z'), + updatedAt: new Date('2026-08-20T10:00:00Z'), + }; + + it('writes the page with the ingest credential and marks the event done', async () => { + const fetchMock = vi.fn(async () => Response.json({ result: {} })); + vi.stubGlobal('fetch', fetchMock); + mockClaimFastEvents.mockResolvedValueOnce([event]).mockResolvedValue([]); + mockConversationRows.mockResolvedValue([ + { + title: 'Deploy preferences', + surface: 'slack', + userId: 'user-1', + userName: 'Sam Lee', + }, + ]); + + await brainOutboxDrainJob(); + + expect(fetchMock).toHaveBeenCalledWith( + 'http://brain.test/mcp', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + authorization: 'Bearer ingest-token', + }), + body: expect.stringContaining('memories/fast/conversation-1'), + }), + ); + expect(mockSettleFastEvent).toHaveBeenCalledWith( + expect.anything(), + 'event-1', + 3, + 'done', + ); + }); + + it('skips an event whose conversation no longer exists', async () => { + mockClaimFastEvents.mockResolvedValueOnce([event]).mockResolvedValue([]); + mockConversationRows.mockResolvedValue([]); + + await brainOutboxDrainJob(); + + expect(mockMarkFastEvent).toHaveBeenCalledWith( + expect.anything(), + 'event-1', + 'skipped', + 'conversation no longer exists', + ); + }); + + it('hands the batch back on backpressure instead of burning retries', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('rate limited', { status: 429 })), + ); + const second = { ...event, id: 'event-2', conversationId: 'c-2' }; + mockClaimFastEvents + .mockResolvedValueOnce([event, second]) + .mockResolvedValue([]); + mockConversationRows.mockResolvedValue([ + { title: null, surface: 'web', userId: 'user-1', userName: null }, + ]); + + await brainOutboxDrainJob(); + + expect(mockMarkFastEvent).toHaveBeenCalledWith( + expect.anything(), + 'event-1', + 'pending', + expect.stringContaining('rate limited'), + ); + expect(mockReleaseFastEvents).toHaveBeenCalledWith(expect.anything(), [ + 'event-1', + 'event-2', + ]); }); }); diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index 45dd2e1f6..6b157e0a5 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -1,21 +1,30 @@ +import { redactBrainText } from '@roomote/communication/redact-brain-text'; import { db, backfillBrainMemoryEvents, claimPendingBrainMemoryEvents, + claimPendingFastAgentMemoryEvents, getBrainSyncState, upsertBrainSyncState, environments, + fastAgentConversations, markBrainMemoryEvent, + markFastAgentMemoryEvent, + settleBrainMemoryEvent, releaseBrainMemoryEvents, + releaseFastAgentMemoryEvents, + settleFastAgentMemoryEvent, pullRequestFacts, taskPullRequests, taskRuns, + users, and, eq, gt, gte, or, renameBrainSyncStateFamilyPrefix, + type FastAgentMemoryEventRow, } from '@roomote/db/server'; import { parseBrainToolPayloads, @@ -57,31 +66,7 @@ const PR_FACTS_COLLECTOR_ID = BRAIN_COLLECTOR_IDS.pullRequestFacts; const PR_FACTS_OVERLAP_MS = 24 * 60 * 60 * 1000; const BACKFILL_CONTINUATION_DELAY_MS = 1_000; -/** - * Deterministic pre-ingestion redaction. This is a structural boundary, not a - * prompt: nothing leaves for the brain without passing through it. Patterns - * mirror the sandbox worker-env scrub list; keep the two in sync when adding - * a credential shape. - */ -const SECRET_PATTERNS: RegExp[] = [ - /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, - /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{20,}\b/g, - /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, - /\bsk-[A-Za-z0-9_-]{20,}\b/g, - /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, - /\bAKIA[0-9A-Z]{16}\b/g, - /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/g, -]; - -export function redactBrainText(text: string): string { - let redacted = text; - - for (const pattern of SECRET_PATTERNS) { - redacted = redacted.replace(pattern, '[REDACTED]'); - } - - return redacted; -} +export { redactBrainText }; type IngestPage = { slug: string; @@ -257,12 +242,13 @@ export function buildMemoryPage(input: { } /** - * Drain the brain_memory_events transactional outbox. Runs on the - * shared scheduler queue; claims use FOR UPDATE SKIP LOCKED so overlapping - * ticks never double-process. When the brain is enabled, completed tasks - * feed it deployment-wide (the corpus is company-wide by definition; - * enabling the integration is the ingestion consent). Skip rules decide - * whether a claimed event becomes a memory ('done'), is skipped, or retries. + * Drain the brain_memory_events and fast_agent_memory_events transactional + * outboxes. Runs on the shared scheduler queue; claims use FOR UPDATE SKIP + * LOCKED so overlapping ticks never double-process. When the brain is + * enabled, completed tasks and Fast conversation memories feed it + * deployment-wide (the corpus is company-wide by definition; enabling the + * integration is the ingestion consent). Skip rules decide whether a claimed + * event becomes a memory ('done'), is skipped, or retries. */ export async function brainOutboxDrainJob(): Promise { const connection = await resolveReadyBrain(); @@ -280,6 +266,14 @@ export async function brainOutboxDrainJob(): Promise { break; } } + + for (let batch = 0; batch < MAX_BATCHES_PER_TICK; batch++) { + const drained = await drainOneFastMemoryBatch(connection); + + if (!drained) { + break; + } + } } /** @@ -530,10 +524,17 @@ async function drainOneBatch(connection: { }); await postToBrain(page, connection); - await markBrainMemoryEvent(db, event.id, 'done'); + const settleResult = await settleBrainMemoryEvent( + db, + event.id, + event.revision, + 'done', + ); console.log( - `${LOG_PREFIX} ingested memory for run ${event.runId} (${page.slug})`, + settleResult === 'settled' + ? `${LOG_PREFIX} ingested memory for run ${event.runId} (${page.slug})` + : `${LOG_PREFIX} run ${event.runId} gained a newer summary mid-write; re-ingesting next tick (${page.slug})`, ); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -562,12 +563,17 @@ async function drainOneBatch(connection: { const terminal = event.attempts >= MAX_ATTEMPTS; - await markBrainMemoryEvent( - db, - event.id, - terminal ? 'failed' : 'pending', - message, - ); + if (terminal) { + await settleBrainMemoryEvent( + db, + event.id, + event.revision, + 'failed', + message, + ); + } else { + await markBrainMemoryEvent(db, event.id, 'pending', message); + } console.warn( `${LOG_PREFIX} ${terminal ? 'permanently failed' : 'will retry'} run ${ @@ -580,6 +586,164 @@ async function drainOneBatch(connection: { return true; } +/** + * Build the memory page for a Fast conversation's remembered facts. Same + * conservative posture as task memories: structured provenance fields only, + * the accumulated facts as body, deterministic redaction over the whole page. + * `created` is the outbox row's creation time so idempotent re-puts of an + * unchanged memory do not read as content changes. + */ +export function buildFastMemoryPage(input: { + conversationId: string; + conversationTitle: string | null; + userName: string | null; + userId: string; + surface: string; + memory: string; + createdAt: Date; + updatedAt: Date; +}): IngestPage { + const title = + input.conversationTitle ?? + `Fast conversation ${input.conversationId.slice(0, 8)}`; + + const content = [ + ...renderBrainFrontmatter({ + type: BRAIN_PAGE_TYPES.conversationMemory, + title, + created: input.createdAt, + fields: [ + `roomote_conversation_id: ${input.conversationId}`, + `roomote_user_id: ${input.userId}`, + input.userName && `saved_by: ${JSON.stringify(input.userName)}`, + `surface: ${input.surface}`, + // GBrain derives effective_date from this conventional field. The + // last save is the honest date for a page whose content grows. + `date: ${input.updatedAt.toISOString().slice(0, 10)}`, + 'provenance: roomote-fast-memory', + ], + }), + '', + `# ${title}`, + '', + '## Remembered facts', + '', + input.memory, + '', + ].join('\n'); + + return { + slug: `${brainNamespacePrefix('memories')}fast/${input.conversationId}`, + title, + content: redactBrainText(content), + }; +} + +/** Returns false when no pending conversation-memory events remained. */ +async function drainOneFastMemoryBatch(connection: { + baseUrl: string; + token: string; +}): Promise { + const events: FastAgentMemoryEventRow[] = + await claimPendingFastAgentMemoryEvents(db, CLAIM_BATCH_SIZE); + + if (events.length === 0) { + return false; + } + + for (const [index, event] of events.entries()) { + try { + const [conversation] = await db + .select({ + title: fastAgentConversations.title, + surface: fastAgentConversations.surface, + userId: fastAgentConversations.userId, + userName: users.name, + }) + .from(fastAgentConversations) + .leftJoin(users, eq(users.id, fastAgentConversations.userId)) + .where(eq(fastAgentConversations.id, event.conversationId)) + .limit(1); + + if (!conversation) { + await markFastAgentMemoryEvent( + db, + event.id, + 'skipped', + 'conversation no longer exists', + ); + continue; + } + + const page = buildFastMemoryPage({ + conversationId: event.conversationId, + conversationTitle: conversation.title, + userName: conversation.userName, + userId: conversation.userId, + surface: conversation.surface, + memory: event.memory, + createdAt: event.createdAt, + updatedAt: event.updatedAt, + }); + + await postToBrain(page, connection); + const settleResult = await settleFastAgentMemoryEvent( + db, + event.id, + event.revision, + 'done', + ); + + console.log( + settleResult === 'settled' + ? `${LOG_PREFIX} ingested memory for conversation ${event.conversationId} (${page.slug})` + : `${LOG_PREFIX} conversation ${event.conversationId} gained facts mid-write; re-ingesting next tick (${page.slug})`, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + // Same backpressure contract as the task outbox: 429 and cannot-embed + // are not this event's fault; hand the rest of the batch back and let + // a later tick retry the same idempotent slug. + if (isBrainRateLimited(error) || isBrainNotReady(error)) { + await markFastAgentMemoryEvent(db, event.id, 'pending', message); + await releaseFastAgentMemoryEvents(db, [ + event.id, + ...events.slice(index + 1).map((pending) => pending.id), + ]); + console.log( + `${LOG_PREFIX} ${ + isBrainRateLimited(error) ? 'rate limited by' : 'cannot embed into' + } the brain; pausing conversation-memory drain until next tick`, + ); + return false; + } + + const terminal = event.attempts >= MAX_ATTEMPTS; + + if (terminal) { + await settleFastAgentMemoryEvent( + db, + event.id, + event.revision, + 'failed', + message, + ); + } else { + await markFastAgentMemoryEvent(db, event.id, 'pending', message); + } + + console.warn( + `${LOG_PREFIX} ${ + terminal ? 'permanently failed' : 'will retry' + } conversation ${event.conversationId} (attempt ${event.attempts}): ${message}`, + ); + } + } + + return true; +} + /** Per-pass ceiling on PR fact pages. A durable keyset resumes immediately. */ const PR_FACTS_BATCH_SIZE = 500; diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 76e61bd75..233661c11 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -126,11 +126,16 @@ environment (or across all active repositories), or runs the prompt directly in **Fast** without starting a sandbox. A Fast run can still delegate a normal task when repository or workspace execution is required. -Fast runs with a Slack or Discord channel destination create a new report thread -for each run. Fast runs without a supported channel are stored as channel-less -Fast conversations; their output will be available in the upcoming Fast runs -view. Until that view ships, use a Slack or Discord channel when the result must -be visible outside the automation's latest-run status. +Fast runs deliver to every custom-automation report destination: Slack, +Discord, Microsoft Teams, or Telegram, as either a channel/chat or a direct +message to the automation owner. Each run keeps a distinct Fast session, and +the report links back to that session in the web app. Slack, Discord, and +Microsoft Teams replies continue the Fast session directly in chat. Teams only +resumes after verifying the active tenant installation, conversation, and +linked user. Telegram can deliver the same reports and continue them from the +web app, but inbound Telegram replies still use its normal task-routing flow. +Runs with no report destination remain stored Fast sessions and do not post to +chat. A run can skip or fail before execution when its configuration or launch state prevents it from starting. @@ -157,6 +162,9 @@ schedule, model, estimated inference cost, and elapsed time. Direct-message destinations require the automation owner to link an account for the selected communications provider and make the bot reachable there. +Microsoft Teams channel destinations must match an active conversation already +known to the connected Teams installation; custom Bot Framework service URLs +are not accepted. Use **Run now** on an enabled automation to test it immediately. Each card has its own run state, so starting one custom automation does not prevent you from @@ -178,9 +186,9 @@ an explicit IANA timezone. Admins can also manage custom automations conversationally through the `manage_custom_automations` tool: list, resolve a schedule, create, update, delete, or run an enabled automation immediately. The tool is available both -from a Roomote task and directly in a Fast conversation, so a quick chat +from a Roomote task and directly in a Fast session, so a quick chat message can handle the full automation lifecycle without launching a task. -Fast conversations can also reach the deployment's enabled MCP servers, the +Fast sessions can also reach the deployment's enabled MCP servers, the same ones delegated tasks use. Use the tool's model-list action to see the deployment's enabled model IDs and default before setting an override. Model IDs preserve the configured inference route: `openrouter/...` targets diff --git a/apps/docs/cost-analytics.mdx b/apps/docs/cost-analytics.mdx index b4f71c81a..c1e5dc315 100644 --- a/apps/docs/cost-analytics.mdx +++ b/apps/docs/cost-analytics.mdx @@ -5,7 +5,7 @@ description: Review Roomote inference spend by task type, source, environment, p --- Cost Analytics helps deployment teams understand how Roomote uses inference -across tasks, Fast-mode conversations, and automations. It reports the cost of +across tasks, Fast sessions, and automations. It reports the cost of recorded model usage in US dollars, so you can spot the environments, models, and work types that drive spend. diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 45b4d93e9..9fbfd2b5e 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -1,13 +1,13 @@ --- title: Fast sessions icon: zap -description: Chat with the fast orchestrator from the dashboard and review every Fast conversation's transcript. +description: Chat with the fast orchestrator from the dashboard and review every Fast session transcript. --- Fast is Roomote's conversational orchestrator: it answers directly when it can -and delegates execution work into tasks when needed. A Fast session is one -persisted Fast conversation, whether it started in Slack, Discord, an -automation, or the web dashboard. +and delegates execution work into tasks when needed. A Fast session persists +across Slack, Discord, Microsoft Teams, Telegram, an automation, or the web +dashboard. ## Start a Fast session from the dashboard @@ -24,16 +24,26 @@ appear in the transcript with links to their task pages. A session's transcript shows prompts, replies, and the tool activity behind them, rendered with the same transcript view as tasks, with a generated title -that updates as the conversation evolves. The view updates in real time while +that updates as the session evolves. The view updates in real time while a turn is running, so you can watch tool calls complete and replies land without refreshing. +Fast sessions can also render presentational widgets such as status cards, +tables, and plans directly in the transcript. Widget HTML is sanitized and +sandboxed in the web view; chat surfaces receive only the widget's text +fallback. + ## Reply to a session Every session has a reply box at the bottom of the transcript; follow-ups -continue the same conversation with full context. For conversations that live +continue the same session with full context. For sessions that live on another surface, such as a Slack thread, Roomote's answer is posted back into the originating thread with a quoted copy of your web message, so the -conversation stays in one place for everyone following it there. Fast replies -in Slack and Discord carry the same "Reply or use the web app" footer as task -replies, linking to the session view. +session stays in one place for everyone following it there. Fast replies +across Slack, Discord, Microsoft Teams, and Telegram carry a "Reply or use the +web app" footer linking to the session view. Slack and Discord can also resume +Fast directly from chat. Microsoft Teams replies to Fast session and automation +messages also continue the same session after Roomote verifies the tenant, +installation, conversation, and linked user. Telegram currently uses the +session view for Fast follow-ups because its inbound webhook route does not yet +carry Fast session identity. diff --git a/apps/docs/integrations/index.mdx b/apps/docs/integrations/index.mdx index 94edff6f6..19b3e6581 100644 --- a/apps/docs/integrations/index.mdx +++ b/apps/docs/integrations/index.mdx @@ -70,7 +70,7 @@ from [Personal Settings](/personal-settings). | | Error and performance investigation | Admin connection once | | | Data warehouse exploration | Admin connection once | | | Read-only database access in Supabase | Enable first, then teammates link accounts | -| | Shared memory context across tasks | Admin connection once | +| | Shared memory across tasks and Fast sessions | Admin connection once | | | Deployments, logs, and domain availability | Admin connection once | | | Public X posts, users, trends, and news | Admin connection once | | | Paid external capabilities via Zero | Admin connection once | @@ -113,7 +113,8 @@ For most teams, this order works well: from specs and runbooks 5. one data integration, such as Snowflake, Supabase, or Neon, when tasks need database or warehouse context -6. Supermemory when you want durable team context to carry across tasks +6. Supermemory when you want durable team context to carry across tasks and + Fast sessions After each connection, run a small task that uses the new context. For example, ask Roomote to summarize a linked issue, inspect a monitoring alert, or explain diff --git a/apps/docs/integrations/supermemory.mdx b/apps/docs/integrations/supermemory.mdx index fe353fedf..5f9a8a523 100644 --- a/apps/docs/integrations/supermemory.mdx +++ b/apps/docs/integrations/supermemory.mdx @@ -1,16 +1,17 @@ --- title: Supermemory -description: Save and recall shared memories across Roomote tasks. +description: Save and recall shared memories across Roomote tasks and Fast sessions. icon: '/logo/integrations/supermemory.svg' --- Connect Supermemory when your deployment needs durable context that can carry -across tasks, such as team preferences, recurring decisions, or important -project conventions. +across tasks and Fast sessions, such as team preferences, recurring +decisions, or important project conventions. ## When to use it - Recall relevant context from earlier Roomote tasks before starting new work +- Carry durable context into Fast sessions - Preserve explicit team preferences that should affect future tasks - Keep durable decisions available without adding them to every prompt @@ -20,13 +21,20 @@ Admins connect Supermemory once from **Settings > Integrations**. ## What to expect -Supermemory is a shared deployment memory store for Roomote tasks. When it is -connected, Roomote proactively saves durable shared context so future tasks can -reuse it — team preferences, lasting decisions, workflow conventions, and -recurring gotchas — without waiting for someone to say "remember this." +Supermemory is a shared deployment memory store for Roomote tasks and Fast +sessions. When it is connected, Roomote proactively saves durable shared +context from completed tasks so future work can reuse it — team preferences, +lasting decisions, workflow conventions, and recurring gotchas — without +waiting for someone to say "remember this." + +In Fast, ask Roomote to remember a durable preference, decision, correction, or +fact. Fast uses Supermemory's available writing tools, and later tasks or +conversations can recall that context. When more than one memory integration is +connected, Roomote uses one as the primary store and avoids writing the same +learning to every store. Task status, secrets, code snippets, one-off progress notes, and anything already encoded in the repository still do not belong in memory. -Roomote can also recall relevant memories near the start of substantive work and -use them as additional context. +Roomote can also recall relevant memories near the start of substantive tasks +and requests and use them as additional context. diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index aaddc2cdf..71b23c927 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -157,9 +157,9 @@ when Memory is created because it sizes the vector store. ## How agents use it -Agents get Memory as an MCP server with read-only tools. They can search it, -recall relevant pages, browse what exists, and ask for a synthesized answer -with sources. They cannot write to it directly. +Agents get the built-in Memory as an MCP server with read-only tools. They can +search it, recall relevant pages, browse what exists, and ask for a synthesized +answer with sources. They cannot write to the built-in Memory directly. For a substantive topic, agents query Memory before consulting overlapping Slack, GitHub, task-history, meeting, or pull-request sources. They check those @@ -172,6 +172,19 @@ a slug it controls, after scrubbing credential-shaped strings. An agent can therefore contribute what only it knows without being able to touch any other page. +Fast sessions use the same pipeline. Ask Fast to remember something — or +state a durable preference, decision, or correction — and it saves the fact to +the session's own memory entry, which the platform redacts and files just +like a task memory. Saved facts become searchable after the next ingestion +pass, so they surface in later sessions rather than instantly. + +Additional memory integrations, such as Supermemory, can also provide shared +context to tasks and Fast sessions. Roomote chooses one connected memory +store for the initial recall so multiple stores do not repeat the same preflight. +Agents can still consult another store when it has distinct context or you ask +for it by name. Each integration uses its own available memory-writing tools; +Roomote does not duplicate the same learning across stores. + Memories carry the environment they came from, so a page written while working in staging is distinguishable from one written against production. diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 86f2176e4..02f976dee 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -64,10 +64,11 @@ Personal Settings also include app preferences such as: - **Mind Reader Mode** to expand LLM thoughts by default in task conversations; you can still collapse or expand individual thought messages - **Narration Mode** for a more streamlined task conversation view -- **Fast response mode** to use fast responses by default for messages sent from - your linked Slack and Discord accounts. This preference does not apply to the - dashboard, GitHub, Teams, or Telegram. You can still use `!fast` explicitly in - Slack whether the preference is on or off. +- **Fast response mode** to select Fast by default for new homepage prompts and + use Fast responses by default for messages sent from your linked Slack and + Discord accounts. An explicit homepage workspace choice takes precedence. + The chat preference does not apply to GitHub, Teams, or Telegram. You can + still use `!fast` explicitly in Slack whether the preference is on or off. Most teammates only need profile, linked accounts, and theme settings. diff --git a/apps/docs/providers/source-control/github.mdx b/apps/docs/providers/source-control/github.mdx index eb510eab9..38f668e17 100644 --- a/apps/docs/providers/source-control/github.mdx +++ b/apps/docs/providers/source-control/github.mdx @@ -219,8 +219,8 @@ Once the app is installed and an environment maps the repository: Issue mentions use the environment mapped to the repository. Map the repository to an environment before mentioning Roomote on issues. -When a task delegated from a Fast Slack or Discord conversation opens a pull -request, actionable review feedback returns to that same conversation. Use +When a task delegated from a Fast session in Slack or Discord opens a pull +request, actionable review feedback returns to that same session. Use **Resolve these issues** to address the current feedback, **Auto-resolve on this PR** to handle later actionable feedback automatically, or **Dismiss** to take no action. diff --git a/apps/docs/skills.mdx b/apps/docs/skills.mdx index 4522be357..ca0fd115a 100644 --- a/apps/docs/skills.mdx +++ b/apps/docs/skills.mdx @@ -90,9 +90,13 @@ Skills configured in Roomote settings are different: environments, even when the guidance is not checked into a repository. - **Repository-defined skills are codebase-level.** They apply when the task is working in that repository and the active workflow finds them relevant. -- **Both are supplemental.** They help Roomote perform specialized work after - a Roomote task is underway; they do not replace the built-in task flow, - environment setup, or the user's prompt. +- **Fast mode can discover repository-defined skills before starting a task.** + It reads only the checked-in skill Markdown from repositories in configured + environments. If the workflow needs a workspace, Fast starts a task in a + matching environment and the task loads its checked-out copy of the skill. +- **Both are supplemental.** They help Roomote perform specialized work; they + do not replace the built-in task flow, environment setup, or the user's + prompt. - **Built-in Roomote skills stay authoritative.** If a custom or repository-defined skill uses the same name as a built-in Roomote workflow, Roomote's built-in workflow wins. Use distinct names for team skills. diff --git a/apps/docs/source-control.mdx b/apps/docs/source-control.mdx index dd6d551b3..291e24be4 100644 --- a/apps/docs/source-control.mdx +++ b/apps/docs/source-control.mdx @@ -87,7 +87,7 @@ flagged issue is clearly fixed and the provider supports thread resolution. ## Pull request status notifications When a tracked, Roomote-created GitHub pull request becomes conflicted, Roomote -notifies the Slack, Teams, Telegram, Discord, or Fast conversation where the +notifies the Slack, Teams, Telegram, Discord, or Fast session where the task started. The notification is automatic and is sent once for each transition into a conflicting state. If the pull request becomes clean and then conflicts again, Roomote sends a new notification. diff --git a/apps/docs/tasks.mdx b/apps/docs/tasks.mdx index 0bac4e8b7..ce1107bea 100644 --- a/apps/docs/tasks.mdx +++ b/apps/docs/tasks.mdx @@ -117,8 +117,8 @@ You can send follow-up instructions while a task is active. If a task has completed and Roomote has a restorable snapshot, a follow-up can resume from that prior workspace instead of starting over. -When a task delegated from a Fast conversation asks for input, reply naturally -in the originating conversation. If one input request is pending, Roomote +When a task delegated from a Fast session asks for input, reply naturally in +the originating session. If one input request is pending, Roomote applies a matching reply to that request instead of treating it as a separate task instruction. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index d0db61c48..b24a93f24 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -33,6 +33,11 @@ const nextConfig: NextConfig = { 'bullmq', 'ioredis', 'postgres', + // The Fast native tool runtime symlinks an on-disk zod into generated + // OpenCode tool directories (fast-agent-native-tool-bridge). Bundling it + // makes require.resolve return a webpack module id with no file on disk, + // which breaks every web-run Fast turn in the standalone image. + 'zod', ], // Always bundle the env files the runtime may need so preview deploys can // load preview secrets even when build-time env detection resolves differently. diff --git a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx index 3f61637a3..a49ad66e2 100644 --- a/apps/web/src/app/(authenticated)/home/Home.client.test.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.client.test.tsx @@ -19,6 +19,8 @@ let currentEnvironments: Array<{ id: string; name: string }> | undefined = [ { id: 'env-2', name: 'Secondary Env' }, ]; let currentEnvironmentsPending = false; +let currentCommunicationsFastModeDefault = false; +let currentPersonalPreferencesLoading = false; const { mockPush, @@ -93,6 +95,20 @@ vi.mock('@/hooks/environments', () => ({ }), })); +vi.mock('@/hooks/usePersonalPreferences', () => ({ + usePersonalPreferences: () => ({ + preferences: { + colorTheme: 'system', + mindReaderMode: false, + narrationMode: false, + communicationsFastModeDefault: currentCommunicationsFastModeDefault, + }, + isLoading: currentPersonalPreferencesLoading, + isUpdating: false, + setPreferences: vi.fn(), + }), +})); + vi.mock('@/hooks/task-runs', () => ({ useCreateStandardTaskRun: mockUseCreateStandardTaskRun, useRouteHomeTask: mockUseRouteHomeTask, @@ -155,19 +171,27 @@ vi.mock('@/components/tasks', async () => { const { useEffect } = await vi.importActual('react'); const { useFormContext } = await vi.importActual('react-hook-form'); + const { useWorkspaceStorage } = await vi.importActual< + typeof import('@/hooks/useWorkspaceStorage') + >('@/hooks/useWorkspaceStorage'); return { ...actual, SelectWorkspace: ({ allowAuto, allowFast, + autoSelectDefaultWorkspace, + onInvalidWorkspaceReset, allowBranchSelection, }: { allowAuto?: boolean; allowFast?: boolean; + autoSelectDefaultWorkspace?: boolean; + onInvalidWorkspaceReset?: () => void; allowBranchSelection?: boolean; }) => { const { watch, setValue } = useFormContext(); + const { setWorkspace } = useWorkspaceStorage(); const repository = watch('repository'); const environmentId = watch('environmentId'); @@ -179,13 +203,24 @@ vi.mock('@/components/tasks', async () => { setValue('repository', AUTO_WORKSPACE_VALUE); setValue('environmentId', undefined); setValue('branch', ''); - }, [allowAuto, environmentId, setValue]); + setWorkspace({ workspace: { type: 'auto' } }); + onInvalidWorkspaceReset?.(); + }, [ + allowAuto, + environmentId, + onInvalidWorkspaceReset, + setValue, + setWorkspace, + ]); return (
{repository ?? ''} {environmentId ?? ''} {String(Boolean(allowAuto))} + + {String(Boolean(autoSelectDefaultWorkspace))} + {String(Boolean(allowBranchSelection))} @@ -361,6 +396,8 @@ describe('Home', () => { { id: 'env-2', name: 'Secondary Env' }, ]; currentEnvironmentsPending = false; + currentCommunicationsFastModeDefault = false; + currentPersonalPreferencesLoading = false; localStorage.clear(); vi.clearAllMocks(); @@ -428,6 +465,84 @@ describe('Home', () => { expect(mockCreateStandardTaskRun).not.toHaveBeenCalled(); }); + it('defaults to Fast when the personal preference is enabled', async () => { + currentCommunicationsFastModeDefault = true; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('repository')).toHaveTextContent( + FAST_EXECUTION, + ); + }); + expect( + screen.getByTestId('auto-select-default-workspace'), + ).toHaveTextContent('false'); + }); + + it('keeps the normal Auto default when the personal preference is disabled', async () => { + render(); + + await waitFor(() => { + expect(screen.getByTestId('repository')).toHaveTextContent( + AUTO_WORKSPACE_VALUE, + ); + }); + expect( + screen.getByTestId('auto-select-default-workspace'), + ).toHaveTextContent('true'); + }); + + it('waits for the preference before allowing another workspace default', async () => { + currentEnvironments = [{ id: 'env-sole', name: 'Only Env' }]; + currentPersonalPreferencesLoading = true; + + const { rerender } = render(); + + expect( + screen.getByTestId('auto-select-default-workspace'), + ).toHaveTextContent('false'); + expect(screen.getByTestId('repository')).toHaveTextContent( + AUTO_WORKSPACE_VALUE, + ); + + currentCommunicationsFastModeDefault = true; + currentPersonalPreferencesLoading = false; + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('repository')).toHaveTextContent( + FAST_EXECUTION, + ); + }); + }); + + it.each([ + { + name: 'environment', + workspace: { type: 'environment', id: 'env-1' }, + }, + { + name: 'repository', + workspace: { type: 'repository', value: 'RooCodeInc/Roomote' }, + }, + ])('prefers Fast over a persisted $name workspace', async ({ workspace }) => { + currentCommunicationsFastModeDefault = true; + localStorage.setItem( + 'roomote-workspace:deployment', + JSON.stringify({ workspace }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('repository')).toHaveTextContent( + FAST_EXECUTION, + ); + expect(screen.getByTestId('environment')).toHaveTextContent(''); + }); + }); + it('starts a Fast session with an image-only prompt', async () => { mockPreparePromptAttachments.mockResolvedValueOnce({ text: '', @@ -1152,7 +1267,27 @@ describe('Home', () => { expect(mockCreateStandardTaskRun).not.toHaveBeenCalled(); }); - it('prefers environmentId from the URL when present', async () => { + it('restores the Fast preference after normalizing a stale persisted workspace', async () => { + currentCommunicationsFastModeDefault = true; + localStorage.setItem( + 'roomote-workspace:deployment', + JSON.stringify({ + workspace: { type: 'environment', id: 'env-stale' }, + }), + ); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('repository')).toHaveTextContent( + FAST_EXECUTION, + ); + expect(screen.getByTestId('environment')).toHaveTextContent(''); + }); + }); + + it('prefers environmentId from the URL when Fast is preferred', async () => { + currentCommunicationsFastModeDefault = true; currentSearchParams = 'environmentId=env-created'; render(); diff --git a/apps/web/src/app/(authenticated)/home/Home.tsx b/apps/web/src/app/(authenticated)/home/Home.tsx index 803e5b274..9d007064c 100644 --- a/apps/web/src/app/(authenticated)/home/Home.tsx +++ b/apps/web/src/app/(authenticated)/home/Home.tsx @@ -26,6 +26,7 @@ import { cn } from '@/lib/utils'; import { getTaskLaunchDisabledReason } from '@/lib/managed-access'; import { useEnvironments } from '@/hooks/environments'; +import { usePersonalPreferences } from '@/hooks/usePersonalPreferences'; import { useAuthorizedUser } from '@/hooks/useUser'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; import { @@ -301,7 +302,14 @@ export function Home({ const watchedRepository = form.watch('repository'); const { workspace, setWorkspace } = useWorkspaceStorage(); + const { preferences, isLoading: isPersonalPreferencesLoading } = + usePersonalPreferences(); const hasRestoredWorkspace = useRef(false); + const shouldRestoreDefaultWorkspace = useRef(false); + + const handleInvalidWorkspaceReset = useCallback(() => { + shouldRestoreDefaultWorkspace.current = true; + }, []); const clearRoutingState = useCallback(() => { setRoutingState('idle'); @@ -323,8 +331,21 @@ export function Home({ }, [form, setWorkspace]); useEffect(() => { + const restoredWorkspace = workspace.workspace as + | WorkspaceSelection['workspace'] + | undefined; + if (hasRestoredWorkspace.current) { - return; + if ( + !shouldRestoreDefaultWorkspace.current || + restoredWorkspace?.type !== 'auto' || + form.getValues('repository') !== AUTO_WORKSPACE_VALUE + ) { + return; + } + + hasRestoredWorkspace.current = false; + shouldRestoreDefaultWorkspace.current = false; } if (environmentIdParam) { @@ -340,9 +361,22 @@ export function Home({ return; } - const restoredWorkspace = workspace.workspace as - | WorkspaceSelection['workspace'] - | undefined; + if (form.getValues('repository') !== AUTO_WORKSPACE_VALUE) { + hasRestoredWorkspace.current = true; + return; + } + + if (isPersonalPreferencesLoading) { + return; + } + + if (preferences.communicationsFastModeDefault) { + form.setValue('repository', FAST_EXECUTION); + form.setValue('environmentId', undefined); + form.setValue('branch', ''); + hasRestoredWorkspace.current = true; + return; + } if (restoredWorkspace?.type === 'repository') { form.setValue('repository', restoredWorkspace.value); @@ -387,6 +421,8 @@ export function Home({ environments.isPending, environments.isSuccess, form, + isPersonalPreferencesLoading, + preferences.communicationsFastModeDefault, setWorkspace, workspace, ]); @@ -723,6 +759,11 @@ export function Home({
diff --git a/apps/web/src/app/(authenticated)/loading.tsx b/apps/web/src/app/(authenticated)/loading.tsx deleted file mode 100644 index 4e4eff2cb..000000000 --- a/apps/web/src/app/(authenticated)/loading.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Skeleton } from '@/components/system'; - -export default function AuthenticatedRouteLoading() { - return ( -
-
-
- - -
- -
- - - -
-
- Loading page -
- ); -} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx index 56dd03bb1..5d8930f6d 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.client.test.tsx @@ -222,6 +222,63 @@ describe('FastSessionTranscript', () => { expect(screen.getAllByText('launch_task')).toHaveLength(1); }); + it('renders trusted Fast show_widget results with the shared sandboxed preview', () => { + render( + Ready

', + css: null, + height: 240, + textFallback: null, + }), + rawInput: { arguments: { html: '

Ready

' } }, + }, + source: 'web', + nativeSessionId: 'opencode-1', + nativeMessageId: null, + createdAt: new Date('2026-01-01T00:00:01.000Z'), + }, + ]} + />, + ); + + const iframe = screen.getByTitle('Fast status'); + expect(iframe).toHaveAttribute('sandbox', ''); + expect(iframe).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(iframe).toHaveAttribute( + 'srcdoc', + expect.stringContaining("default-src 'none'"), + ); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( { reasoningEffort: null, }); }); + + expect( + await screen.findAllByRole('button', { + name: 'Open conversation image attachment 1', + }), + ).toHaveLength(1); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + { + id: 'user-image-1', + eventId: 'turn-image-1:user', + turnId: 'turn-image-1', + turnSeq: 0, + ts: Date.now(), + eventType: ACP_ENVELOPE_EVENT_TYPES.UserPrompt, + role: 'user', + contentBlocks: [ + { type: 'text', text: '' }, + { type: 'image', mimeType: 'image/png', data: 'image-1' }, + ], + metadata: { visibleInTranscript: true }, + payload: {}, + source: 'web', + nativeSessionId: null, + nativeMessageId: null, + createdAt: new Date().toISOString(), + }, + ], + }); + }); + + expect( + screen.getAllByRole('button', { + name: 'Open conversation image attachment 1', + }), + ).toHaveLength(1); }); it('keeps the drafted reply when the send fails', async () => { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 33353f09e..8066694fc 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ACP_ENVELOPE_EVENT_TYPES, + getImageUrisFromContentBlocks, getTextFromContentBlocks, inferAcpMessageKind, type AcpEventType, @@ -42,6 +43,13 @@ function compareTranscriptMessages(a: TranscriptMessage, b: TranscriptMessage) { return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; } +function getUserMessageIdentity(message: TranscriptMessage) { + return JSON.stringify([ + getTextFromContentBlocks(message.contentBlocks)?.trim() ?? '', + getImageUrisFromContentBlocks(message.contentBlocks), + ]); +} + export function FastSessionTranscript({ sessionId, initialMessages, @@ -71,6 +79,7 @@ export function FastSessionTranscript({ >( () => new Map(initialMessages.map((message) => [message.eventId, message])), ); + const serverMessagesRef = useRef(serverMessages); const [optimisticMessages, setOptimisticMessages] = useState< TranscriptMessage[] >([]); @@ -85,13 +94,32 @@ export function FastSessionTranscript({ const { messages } = JSON.parse(event.data) as { messages: TranscriptMessage[]; }; - setServerMessages((previous) => { - const next = new Map(previous); - for (const message of messages) { - next.set(message.eventId, message); - } - return next; - }); + const previous = serverMessagesRef.current; + const canonicalUserMessages = messages.filter( + (message) => + message.role === 'user' && !previous.has(message.eventId), + ); + const next = new Map(previous); + for (const message of messages) { + next.set(message.eventId, message); + } + serverMessagesRef.current = next; + setServerMessages(next); + + if (canonicalUserMessages.length > 0) { + setOptimisticMessages((current) => { + const pending = [...current]; + for (const canonical of canonicalUserMessages) { + const index = pending.findIndex( + (optimistic) => + getUserMessageIdentity(optimistic) === + getUserMessageIdentity(canonical), + ); + if (index >= 0) pending.splice(index, 1); + } + return pending; + }); + } } catch { // Ignore malformed frames; the next poll re-sends current state. } @@ -116,22 +144,9 @@ export function FastSessionTranscript({ }, [sessionId]); const messages = useMemo(() => { - const serverList = [...serverMessages.values()]; - const serverUserTexts = new Set( - serverList - .filter((message) => message.role === 'user') - .map((message) => - getTextFromContentBlocks(message.contentBlocks)?.trim(), - ) - .filter(Boolean), + return [...serverMessages.values(), ...optimisticMessages].sort( + compareTranscriptMessages, ); - const pending = optimisticMessages.filter( - (message) => - !serverUserTexts.has( - getTextFromContentBlocks(message.contentBlocks)?.trim(), - ), - ); - return [...serverList, ...pending].sort(compareTranscriptMessages); }, [serverMessages, optimisticMessages]); const uiMessages = useMemo( @@ -182,6 +197,16 @@ export function FastSessionTranscript({ } optimisticId = `optimistic:${Date.now()}:${Math.random().toString(36).slice(2)}`; + const imageBlocks: TranscriptMessage['contentBlocks'] = images.flatMap( + (image) => { + const match = /^data:(image\/[^;,]+);base64,(.+)$/i.exec( + image.trim(), + ); + return match?.[1] && match[2] + ? [{ type: 'image', mimeType: match[1], data: match[2] }] + : []; + }, + ); const optimistic: TranscriptMessage = { id: optimisticId, eventId: optimisticId, @@ -190,7 +215,10 @@ export function FastSessionTranscript({ ts: Date.now(), eventType: ACP_ENVELOPE_EVENT_TYPES.UserPrompt, role: 'user', - contentBlocks: [{ type: 'text', text: prepared.text }], + contentBlocks: [ + { type: 'text', text: prepared.text }, + ...imageBlocks, + ], metadata: { visibleInTranscript: true }, payload: {}, source: 'web', diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index ade5d409c..384fa5a32 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -34,6 +34,8 @@ export type SessionInfo = { const SURFACE_LABELS: Record = { slack: 'Slack', discord: 'Discord', + teams: 'Microsoft Teams', + telegram: 'Telegram', automation: 'Automation', web: 'Web', }; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx index 5caebd639..b798960b6 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/AcpTextMessage.tsx @@ -147,7 +147,7 @@ function getRequestUserInputResponseDisplay( .filter((text) => text.length > 0); return { - title: data.resolution === 'cancelled' ? 'Cancelled requested input' : null, + title: data.resolution === 'cancelled' ? 'Cancelled input request' : null, questionTexts, }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/show-widget-tool-result.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/show-widget-tool-result.client.test.ts index 4f63ecb76..764dd13b9 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/show-widget-tool-result.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/show-widget-tool-result.client.test.ts @@ -95,6 +95,50 @@ describe('resolveShowWidgetForToolMessage', () => { }); }); + it('parses trusted Fast-native show_widget results', () => { + const widget = resolveShowWidgetForToolMessage( + buildResult({ + isMcp: false, + isRoomoteNativeTool: true, + mcpServerName: null, + serverName: null, + output: JSON.stringify({ + success: true, + shown: true, + title: 'Fast status', + html: '

ready

', + css: null, + height: 280, + textFallback: null, + }), + }), + ); + + expect(widget).toMatchObject({ + title: 'Fast status', + html: '

ready

', + height: 280, + }); + }); + + it('ignores unmarked native tools named show_widget', () => { + const widget = resolveShowWidgetForToolMessage( + buildResult({ + isMcp: false, + isRoomoteNativeTool: false, + mcpServerName: null, + serverName: null, + output: JSON.stringify({ + success: true, + shown: true, + html: '

untrusted

', + }), + }), + ); + + expect(widget).toBeNull(); + }); + it('ignores in-progress rawInput and only renders successful tool results', () => { const widget = resolveShowWidgetForToolMessage( buildCall({ diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts index 66ff06c88..9c8aaab66 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/show-widget-tool-result.ts @@ -84,6 +84,15 @@ function isRoomoteMcpServer( return getMcpServerName(data) === ROOMOTE_MCP_SERVER_NAME; } +function isTrustedRoomoteWidgetTool( + data: AcpToolCallUiMessage['data'] | AcpToolResultUiMessage['data'], +): boolean { + return ( + (data.isMcp === true && isRoomoteMcpServer(data)) || + (data.isMcp === false && data.isRoomoteNativeTool === true) + ); +} + function clampWidgetHeight(height: unknown): number { if (typeof height !== 'number' || !Number.isFinite(height)) { return SHOW_WIDGET_DEFAULT_HEIGHT; @@ -139,7 +148,7 @@ function isSettledToolResult( export function resolveShowWidgetForToolMessage( msg: AcpToolCallUiMessage | AcpToolResultUiMessage, ): ShowWidgetPayload | null { - if (msg.data.isMcp !== true || !isRoomoteMcpServer(msg.data)) { + if (!isTrustedRoomoteWidgetTool(msg.data)) { return null; } diff --git a/apps/web/src/components/settings/UserPreferencesSection.test.tsx b/apps/web/src/components/settings/UserPreferencesSection.test.tsx index 507a880af..ee1d48ab3 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.test.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.test.tsx @@ -168,7 +168,7 @@ describe('UserPreferencesSection', () => { expect(screen.getByText('Fast response mode')).toHaveClass('font-semibold'); expect( screen.getByText( - 'Use fast responses by default for linked Slack and Discord messages. Dashboard, GitHub, Teams, and Telegram are unaffected; `!fast` remains available in Slack.', + 'Use fast responses by default for homepage prompts and linked Slack and Discord messages. GitHub, Teams, and Telegram are unaffected; `!fast` remains available in Slack.', ), ).toBeInTheDocument(); }); diff --git a/apps/web/src/components/settings/UserPreferencesSection.tsx b/apps/web/src/components/settings/UserPreferencesSection.tsx index 6654d2e2d..a2689bc93 100644 --- a/apps/web/src/components/settings/UserPreferencesSection.tsx +++ b/apps/web/src/components/settings/UserPreferencesSection.tsx @@ -140,9 +140,9 @@ export function UserPreferencesSection() { Fast response mode

- Use fast responses by default for linked Slack and Discord - messages. Dashboard, GitHub, Teams, and Telegram are unaffected; - `!fast` remains available in Slack. + Use fast responses by default for homepage prompts and linked + Slack and Discord messages. GitHub, Teams, and Telegram are + unaffected; `!fast` remains available in Slack.

diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx index d3f31036f..5965fa397 100644 --- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx +++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx @@ -1265,7 +1265,9 @@ describe('AutomationsSettings', () => { ); expect(screen.getByText('Delegated task model')).toBeInTheDocument(); expect( - screen.getByText(/available in the upcoming Fast runs view/), + screen.getByText( + 'This run is stored as a Fast conversation without posting to chat.', + ), ).toBeInTheDocument(); fireEvent.click(screen.getByRole('combobox', { name: 'Environment' })); expect( @@ -1321,7 +1323,8 @@ describe('AutomationsSettings', () => { scheduleMode: 'daily', cronExpression: null, model: null, - environmentId: 'env-1', + executionMode: 'fast', + environmentId: '__fast__', target: { provider: 'slack', targetKind: 'slack_user', @@ -1352,6 +1355,11 @@ describe('AutomationsSettings', () => { 'Results are sent privately to your linked Slack account.', ), ).toBeInTheDocument(); + expect( + screen.getByText( + 'Each Fast run posts here, and replies continue the Fast session.', + ), + ).toBeInTheDocument(); }); it('shows DM me for non-Slack custom automation destinations', async () => { @@ -1366,7 +1374,8 @@ describe('AutomationsSettings', () => { scheduleMode: 'daily', cronExpression: null, model: null, - environmentId: 'env-1', + executionMode: 'fast', + environmentId: '__fast__', target: { provider: 'discord', targetKind: 'discord_user', @@ -1397,6 +1406,54 @@ describe('AutomationsSettings', () => { 'Results are sent privately to your linked Discord account.', ), ).toBeInTheDocument(); + expect( + screen.getByText( + 'Each Fast run posts here, and replies continue the Fast session.', + ), + ).toBeInTheDocument(); + }); + + it('explains that Teams replies continue the Fast session', async () => { + state.settingsQuery.data.capabilities.teamsConnected = true; + state.customAutomations = [ + { + id: 'automation-teams-fast', + name: 'Teams daily brief', + prompt: 'Summarize my priorities.', + enabled: true, + scheduleMode: 'daily', + cronExpression: null, + model: null, + executionMode: 'fast', + environmentId: '__fast__', + target: { + provider: 'teams', + targetKind: 'teams_user', + externalRef: 'user-1', + }, + lastRunAt: null, + lastSucceededAt: null, + lastFailedAt: null, + lastError: null, + lastLaunchedTaskId: null, + createdByName: 'Ada', + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + }, + ]; + + render(); + fireEvent.click( + await screen.findByRole('button', { + name: 'Configure Teams daily brief', + }), + ); + + expect( + screen.getByText( + 'Each Fast run posts here, and replies continue the Fast session.', + ), + ).toBeInTheDocument(); }); it('only offers connected providers as custom automation destinations', async () => { diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx index a3308a61c..ade8f27e6 100644 --- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx +++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx @@ -61,7 +61,6 @@ type CustomAutomationFormState = { targetProvider: 'none' | 'slack' | 'discord' | 'teams' | 'telegram'; targetMode: 'channel' | 'direct_message'; targetChannelId: string; - targetServiceUrl: string; }; type AutomationDestinationProvider = Exclude< @@ -80,7 +79,6 @@ const EMPTY_FORM: CustomAutomationFormState = { targetProvider: 'slack', targetMode: 'channel', targetChannelId: '', - targetServiceUrl: '', }; const SCHEDULE_OPTIONS: Array<{ @@ -187,14 +185,12 @@ function targetFromRow(row: CustomAutomationListItem): { provider: CustomAutomationFormState['targetProvider']; mode: CustomAutomationFormState['targetMode']; channelId: string; - serviceUrl: string; } { if (!row.target.provider || !row.target.externalRef) { return { provider: 'none', mode: 'channel', channelId: '', - serviceUrl: '', }; } @@ -204,10 +200,6 @@ function targetFromRow(row: CustomAutomationListItem): { row.target.provider === 'telegram' ? row.target.provider : 'slack'; - const serviceUrl = - typeof row.target.metadata?.serviceUrl === 'string' - ? row.target.metadata.serviceUrl - : ''; return { provider, mode: isBackgroundAutomationUserTargetKind(row.target.targetKind) @@ -216,7 +208,6 @@ function targetFromRow(row: CustomAutomationListItem): { channelId: isBackgroundAutomationUserTargetKind(row.target.targetKind) ? '' : (row.target.externalRef ?? ''), - serviceUrl, }; } @@ -240,7 +231,6 @@ function formFromRow( targetProvider: targetIsConnected ? target.provider : 'none', targetMode: target.mode, targetChannelId: targetIsConnected ? target.channelId : '', - targetServiceUrl: targetIsConnected ? target.serviceUrl : '', }; } @@ -264,11 +254,6 @@ function writeInputFromRow(row: CustomAutomationListItem) { : {}), } : {}), - ...(target.provider === 'teams' && - target.mode === 'channel' && - target.serviceUrl - ? { targetServiceUrl: target.serviceUrl } - : {}), }; } @@ -568,7 +553,6 @@ export function CustomAutomationsSection() { targetProvider: 'none', targetMode: 'channel', targetChannelId: '', - targetServiceUrl: '', }, ); }, [capabilitiesLoaded, connectedDestinationProviders]); @@ -615,11 +599,6 @@ export function CustomAutomationsSection() { : {}), } : {}), - ...(form.targetProvider === 'teams' && - form.targetMode === 'channel' && - form.targetServiceUrl.trim() - ? { targetServiceUrl: form.targetServiceUrl.trim() } - : {}), }; if (editingId) { @@ -816,7 +795,6 @@ export function CustomAutomationsSection() { : value === 'discord' ? managerDiscordChannelId : '', - targetServiceUrl: '', })) } > @@ -859,8 +837,6 @@ export function CustomAutomationsSection() { ? managerDiscordChannelId : '' : '', - targetServiceUrl: - value === 'channel' ? current.targetServiceUrl : '', })) } > @@ -940,40 +916,14 @@ export function CustomAutomationsSection() { )} )} - {form.targetProvider === 'teams' && - form.targetMode === 'channel' ? ( -
- - - setForm((current) => ({ - ...current, - targetServiceUrl: event.target.value, - })) - } - placeholder="Optional" - /> -
- ) : null} - {form.environmentId === FAST_EXECUTION && - !( - (form.targetProvider === 'slack' || - form.targetProvider === 'discord') && - form.targetMode === 'channel' - ) ? ( + {form.environmentId === FAST_EXECUTION ? (

- This run is stored as a Fast conversation. Its output will be - available in the upcoming Fast runs view. + {form.targetProvider === 'none' + ? 'This run is stored as a Fast conversation without posting to chat.' + : form.targetProvider === 'telegram' + ? 'Each Fast run posts here. Continue the session from the web app; chat replies on this provider do not resume Fast yet.' + : 'Each Fast run posts here, and replies continue the Fast session.'}

) : null} diff --git a/apps/web/src/components/tasks/SelectEnvironmentOrRepository.test.tsx b/apps/web/src/components/tasks/SelectEnvironmentOrRepository.test.tsx index efdcca1b6..9b8663544 100644 --- a/apps/web/src/components/tasks/SelectEnvironmentOrRepository.test.tsx +++ b/apps/web/src/components/tasks/SelectEnvironmentOrRepository.test.tsx @@ -114,17 +114,21 @@ const WorkspaceValuesProbe = ({ const SelectEnvironmentOrRepositoryHarness = ({ allowAuto = false, allowFast = false, + autoSelectDefaultWorkspace = true, repositoryFilter, defaultValues, onValuesChange, + onInvalidWorkspaceReset, onCreateRepository, }: { allowAuto?: boolean; allowFast?: boolean; + autoSelectDefaultWorkspace?: boolean; /** Omit for no filter (homepage Auto). Pass a repo full name to filter. */ repositoryFilter?: string; defaultValues: Partial; onValuesChange: (values: WorkspaceSelectionValues) => void; + onInvalidWorkspaceReset?: () => void; onCreateRepository?: () => void; }) => { const form = useForm({ @@ -141,6 +145,8 @@ const SelectEnvironmentOrRepositoryHarness = ({ repositoryFilter={repositoryFilter} allowAuto={allowAuto} allowFast={allowFast} + autoSelectDefaultWorkspace={autoSelectDefaultWorkspace} + onInvalidWorkspaceReset={onInvalidWorkspaceReset} onCreate={vi.fn()} onCreateRepository={onCreateRepository} onEdit={vi.fn()} @@ -324,6 +330,53 @@ describe('SelectEnvironmentOrRepository', () => { }); }); + it('leaves Auto unchanged when default workspace selection is deferred', async () => { + let latestValues: WorkspaceSelectionValues | undefined; + + render( + { + latestValues = values; + }} + />, + ); + + await waitFor(() => { + expect(latestValues?.repository).toBe(AUTO_WORKSPACE_VALUE); + }); + expect(latestValues?.environmentId).toBeUndefined(); + expect(setWorkspace).not.toHaveBeenCalled(); + }); + + it('reports when an invalid persisted environment is reset', async () => { + let latestValues: WorkspaceSelectionValues | undefined; + const onInvalidWorkspaceReset = vi.fn(); + + render( + { + latestValues = values; + }} + onInvalidWorkspaceReset={onInvalidWorkspaceReset} + />, + ); + + await waitFor(() => { + expect(latestValues?.repository).toBe(AUTO_WORKSPACE_VALUE); + expect(latestValues?.environmentId).toBeUndefined(); + }); + expect(onInvalidWorkspaceReset).toHaveBeenCalledOnce(); + }); + it('re-defaults to the sole environment after a programmatic reset backs out to Auto', async () => { let latestValues: WorkspaceSelectionValues | undefined; let setFormValues: diff --git a/apps/web/src/components/tasks/SelectEnvironmentOrRepository.tsx b/apps/web/src/components/tasks/SelectEnvironmentOrRepository.tsx index a1925f137..fb7d1bb5b 100644 --- a/apps/web/src/components/tasks/SelectEnvironmentOrRepository.tsx +++ b/apps/web/src/components/tasks/SelectEnvironmentOrRepository.tsx @@ -53,6 +53,8 @@ interface SelectEnvironmentOrRepositoryProps { lockedBranch?: string; allowAuto?: boolean; allowFast?: boolean; + autoSelectDefaultWorkspace?: boolean; + onInvalidWorkspaceReset?: () => void; onCreate: () => void; onCreateRepository?: () => void; onEdit: (e: React.MouseEvent, envId: string) => void; @@ -64,6 +66,8 @@ export const SelectEnvironmentOrRepository = ({ lockedBranch, allowAuto = false, allowFast = false, + autoSelectDefaultWorkspace = true, + onInvalidWorkspaceReset, onCreate, onCreateRepository, onEdit, @@ -121,7 +125,11 @@ export const SelectEnvironmentOrRepository = ({ ); useEffect(() => { - if (environments.isPending || !environments.isSuccess) { + if ( + !autoSelectDefaultWorkspace || + environments.isPending || + !environments.isSuccess + ) { return; } @@ -184,6 +192,7 @@ export const SelectEnvironmentOrRepository = ({ setHasAppliedDefaultWorkspace(true); }, [ hasAppliedDefaultWorkspace, + autoSelectDefaultWorkspace, allowAuto, repositoryFilter, environments.isPending, @@ -226,6 +235,7 @@ export const SelectEnvironmentOrRepository = ({ setValue('repository', AUTO_WORKSPACE_VALUE); setValue('branch', ''); setWorkspace({ workspace: { type: 'auto' } }); + onInvalidWorkspaceReset?.(); // Allow the sole remaining environment (if any) to become the default. setHasAppliedDefaultWorkspace(false); return; @@ -255,6 +265,7 @@ export const SelectEnvironmentOrRepository = ({ setValue('repository', AUTO_WORKSPACE_VALUE); setValue('branch', ''); setWorkspace({ workspace: { type: 'auto' } }); + onInvalidWorkspaceReset?.(); }, [ allowAuto, environmentId, @@ -265,6 +276,7 @@ export const SelectEnvironmentOrRepository = ({ repositories.isSuccess, repositories.data, repository, + onInvalidWorkspaceReset, setValue, setWorkspace, ]); diff --git a/apps/web/src/components/tasks/SelectWorkspace.tsx b/apps/web/src/components/tasks/SelectWorkspace.tsx index ac0baedd1..23d9d633d 100644 --- a/apps/web/src/components/tasks/SelectWorkspace.tsx +++ b/apps/web/src/components/tasks/SelectWorkspace.tsx @@ -22,6 +22,8 @@ export const SelectWorkspace = ({ lockedBranch, allowAuto = false, allowFast = false, + autoSelectDefaultWorkspace = true, + onInvalidWorkspaceReset, allowBranchSelection = true, environmentBranchRepositoryFullName, environmentBranchDefault, @@ -30,6 +32,8 @@ export const SelectWorkspace = ({ lockedBranch?: string; allowAuto?: boolean; allowFast?: boolean; + autoSelectDefaultWorkspace?: boolean; + onInvalidWorkspaceReset?: () => void; allowBranchSelection?: boolean; environmentBranchRepositoryFullName?: string; environmentBranchDefault?: string; @@ -113,6 +117,8 @@ export const SelectWorkspace = ({ lockedBranch={lockedBranch} allowAuto={allowAuto} allowFast={allowFast} + autoSelectDefaultWorkspace={autoSelectDefaultWorkspace} + onInvalidWorkspaceReset={onInvalidWorkspaceReset} onCreate={handleCreateEnvironment} onCreateRepository={handleCreateRepository} onEdit={handleUpdateEnvironment} diff --git a/apps/web/src/trpc/commands/automations/custom-automations.ts b/apps/web/src/trpc/commands/automations/custom-automations.ts index e3a4645ec..6d709d6e2 100644 --- a/apps/web/src/trpc/commands/automations/custom-automations.ts +++ b/apps/web/src/trpc/commands/automations/custom-automations.ts @@ -23,10 +23,10 @@ import { import { ALL_REPOSITORIES, FAST_EXECUTION, + getCommunicationAutomationTargetKind, isScheduleOnlyBackgroundAutomationFrequency, type AutomationTarget, type BackgroundAutomationProvider, - type BackgroundAutomationTargetKind, type CustomAutomationScheduleMode, type OptionalAutomationTarget, } from '@roomote/types'; @@ -97,7 +97,6 @@ export type CustomAutomationWriteInput = { targetProvider?: 'slack' | 'discord' | 'teams' | 'telegram'; targetMode?: 'channel' | 'direct_message'; targetChannelId?: string; - targetServiceUrl?: string | null; }; function toListItem( @@ -158,39 +157,16 @@ function buildTarget( ); } - const targetKindByProvider: Record< - NonNullable, - BackgroundAutomationTargetKind - > = { - slack: 'slack_channel', - discord: 'discord_channel', - teams: 'teams_channel', - telegram: 'telegram_chat', - }; - const userTargetKindByProvider: Record< - NonNullable, - BackgroundAutomationTargetKind - > = { - slack: 'slack_user', - discord: 'discord_user', - teams: 'teams_user', - telegram: 'telegram_user', - }; - const provider = input.targetProvider as BackgroundAutomationProvider; const target: AutomationTarget = { provider, - targetKind: directMessage - ? userTargetKindByProvider[input.targetProvider] - : targetKindByProvider[input.targetProvider], + targetKind: getCommunicationAutomationTargetKind( + input.targetProvider, + directMessage ? 'direct_message' : 'channel', + ), externalRef, }; - const serviceUrl = input.targetServiceUrl?.trim(); - if (!directMessage && serviceUrl) { - target.metadata = { serviceUrl }; - } - return target; } diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts index 4e1c286ac..822f503b9 100644 --- a/apps/web/src/trpc/routers/_app.ts +++ b/apps/web/src/trpc/routers/_app.ts @@ -812,13 +812,6 @@ const automationsRouter = createRouter({ .optional(), targetMode: z.enum(['channel', 'direct_message']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z - .string() - .trim() - .min(1) - .max(500) - .nullable() - .optional(), }), ) .mutation(({ ctx: { auth }, input }) => @@ -859,13 +852,6 @@ const automationsRouter = createRouter({ .optional(), targetMode: z.enum(['channel', 'direct_message']).optional(), targetChannelId: z.string().trim().min(1).max(160).optional(), - targetServiceUrl: z - .string() - .trim() - .min(1) - .max(500) - .nullable() - .optional(), }), ) .mutation(({ ctx: { auth }, input }) => diff --git a/apps/worker/package.json b/apps/worker/package.json index 6de6f357e..7ed11d942 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -32,12 +32,10 @@ "@trpc/server": "^11.15.0", "chokidar": "^4.0.3", "commander": "^14.0.2", - "dompurify": "3.4.13", "execa": "9.6.1", "hono": "4.12.34", "http-proxy": "^1.18.1", "ignore": "^7.0.5", - "jsdom": "26.1.0", "jsonwebtoken": "^9.0.3", "mime-types": "^2.1.35", "node-pty": "^1.1.0", @@ -53,7 +51,6 @@ "@roomote/config-eslint": "workspace:^", "@roomote/config-typescript": "workspace:^", "@types/http-proxy": "^1.17.17", - "@types/jsdom": "21.1.7", "@types/jsonwebtoken": "^9.0.10", "@types/mime-types": "^2.1.4", "@types/node": "^24.10.13", diff --git a/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts b/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts index d0bb31916..62bfa4605 100644 --- a/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts +++ b/apps/worker/src/callbacks/__tests__/slack-live-task-stream.test.ts @@ -758,6 +758,86 @@ describe('Slack live task card', () => { }); }); + it('settles idle without a completion and re-opens when running', async () => { + const taskRun = createTaskRun(); + const context = {}; + await updateSlackLiveTaskStream( + taskRun, + { type: 'text', ts: 1000, text: 'Finishing the task.' }, + context, + ); + + await reportSlackLiveTaskStatus(taskRun, RunStatus.Idle, context); + await reportSlackLiveTaskStatus(taskRun, RunStatus.Running, context); + + expect(renderedCard(2)).toEqual({ + status: 'complete', + output: 'Task completed.', + }); + expect(renderedCard(3)).toEqual({ + status: 'in_progress', + output: 'Agent started, getting to work…', + }); + }); + + it('keeps working when running resumes during an idle settle', async () => { + const taskRun = createTaskRun(); + const context = {}; + let releaseIdle!: () => void; + mocks.renderCard.mockImplementationOnce( + () => + new Promise<{ card: boolean; updated: boolean }>((resolve) => { + releaseIdle = () => resolve({ card: true, updated: true }); + }), + ); + + const idle = reportSlackLiveTaskStatus(taskRun, RunStatus.Idle, context); + await vi.waitFor(() => expect(mocks.renderCard).toHaveBeenCalledOnce()); + const running = reportSlackLiveTaskStatus( + taskRun, + RunStatus.Running, + context, + ); + + releaseIdle(); + await Promise.all([idle, running]); + await updateSlackLiveTaskStream( + taskRun, + { type: 'text', ts: 1000, text: 'Working again.' }, + context, + ); + + expect(renderedCard(2)).toEqual({ + status: 'in_progress', + output: 'Agent started, getting to work…', + }); + expect(renderedCard(3)).toEqual({ + status: 'in_progress', + output: 'Working again.', + }); + }); + + it('replaces a generic idle fallback with a delayed real completion', async () => { + const taskRun = createTaskRun(); + const context = {}; + + await reportSlackLiveTaskStatus(taskRun, RunStatus.Idle, context); + await updateSlackLiveTaskStream( + taskRun, + { type: 'completion', ts: 1000, text: 'Ready for review.' }, + context, + ); + + expect(renderedCard(1)).toEqual({ + status: 'complete', + output: 'Task completed.', + }); + expect(renderedCard(2)).toEqual({ + status: 'complete', + output: 'Ready for review.', + }); + }); + it('replaces a provisional idle completion with a delayed real completion', async () => { const taskRun = createTaskRun(); const context = {}; @@ -876,7 +956,7 @@ describe('Slack live task card', () => { }); }); - it('keeps an idle card active when the task is waiting for user input', async () => { + it('settles while waiting for input and re-opens when the user answers', async () => { const taskRun = createTaskRun(); const context = {}; await updateSlackLiveTaskStream( @@ -890,12 +970,31 @@ describe('Slack live task card', () => { context, ); await reportSlackLiveTaskStatus(taskRun, RunStatus.Idle, context); + await updateSlackLiveTaskStream( + taskRun, + { + type: 'request_user_input_response', + ts: 1001, + response: { + requestId: 'request-1', + sessionId: 'session-1', + turnId: 'turn-1', + callId: 'call-1', + answers: {}, + resolution: 'submitted', + }, + }, + context, + ); - expect(mocks.renderCard).toHaveBeenCalledOnce(); - expect(renderedCard(1)).toEqual({ - status: 'in_progress', + expect(renderedCard(2)).toEqual({ + status: 'complete', output: 'Waiting for your input…', }); + expect(renderedCard(3)).toEqual({ + status: 'in_progress', + output: 'Continuing with your answer…', + }); }); it('wires callbacks only for runs that opted into a card', async () => { diff --git a/apps/worker/src/callbacks/slack-live-task-stream.ts b/apps/worker/src/callbacks/slack-live-task-stream.ts index ca8fca432..64f947137 100644 --- a/apps/worker/src/callbacks/slack-live-task-stream.ts +++ b/apps/worker/src/callbacks/slack-live-task-stream.ts @@ -367,6 +367,7 @@ export async function updateSlackLiveTaskStream( if (event.type === 'request_user_input_response') { state.status = 'in_progress'; state.awaitingInput = false; + state.finalMessage = undefined; state.message = CONTINUING_MESSAGE; state.provisionalCompletion = false; await renderCard(taskRun, context); @@ -388,15 +389,15 @@ export async function finishSlackLiveTaskStream( } if (status === RunStatus.Idle) { - if ( - state.settled || - state.awaitingInput || - state.finalMessage === undefined - ) { + if (state.settled) { return; } state.status = 'complete'; - state.message = state.finalMessage; + if (!state.awaitingInput) { + state.message = + state.finalMessage ?? SLACK_LIVE_TASK_CARD_MESSAGES.completed; + state.provisionalCompletion = state.finalMessage === undefined; + } await renderCard(taskRun, context, { settle: true }); return; } diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-memory.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-memory.test.ts new file mode 100644 index 000000000..d28eb0265 --- /dev/null +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/task-memory.test.ts @@ -0,0 +1,92 @@ +const { saveTaskMemory } = vi.hoisted(() => ({ + saveTaskMemory: vi.fn(), +})); + +vi.mock('../tasks-api-client', () => ({ + saveTaskMemory, +})); + +import { handleSaveTaskMemory } from '../task-memory'; + +const originalEnv = { ...process.env }; + +describe('save task memory tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.ROOMOTE_TASK_RUN_ID = '42'; + process.env.ROOMOTE_CLOUD_TOKEN = 'run-token'; + process.env.ROOMOTE_PLATFORM_API_URL = 'https://platform.example.com'; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('returns the submitted memory when saved', async () => { + const memory = { + outcome: + 'Made task memories visible with token sk-abcdefghijklmnopqrstuvwxyz redacted.', + decisions: ['Return the submitted input without server metadata.'], + rationale: + 'The tool call already contains Bearer example1234567890 this content.', + reusableFacts: ['Tool results are rendered from their JSON text.'], + unresolvedQuestions: [ + 'Could ghp_abcdefghijklmnopqrstuvwxyz leak elsewhere?', + ], + }; + saveTaskMemory.mockResolvedValue({ saved: true }); + + const result = await handleSaveTaskMemory(memory); + + expect(JSON.parse(result.content[0]?.text ?? '')).toEqual({ + success: true, + saved: true, + note: 'Recorded for the shared Brain.', + memory: { + ...memory, + outcome: 'Made task memories visible with token [REDACTED] redacted.', + rationale: 'The tool call already contains [REDACTED] this content.', + unresolvedQuestions: ['Could [REDACTED] leak elsewhere?'], + }, + }); + + expect(saveTaskMemory).toHaveBeenCalledWith(expect.any(Object), 42, memory); + }); + + it('returns the submitted memory when not saved', async () => { + const memory = { outcome: 'No durable learning was found.' }; + saveTaskMemory.mockResolvedValue({ + saved: false, + reason: 'Memory was too short.', + }); + + const result = await handleSaveTaskMemory(memory); + + expect(JSON.parse(result.content[0]?.text ?? '')).toEqual({ + success: true, + saved: false, + reason: 'Memory was too short.', + memory, + }); + }); + + it('redacts private keys split across memory fields', async () => { + const memory = { + outcome: 'before\n-----BEGIN PRIVATE KEY-----\nsecret', + rationale: 'more secret', + decisions: ['-----END PRIVATE KEY-----\nafter'], + }; + saveTaskMemory.mockResolvedValue({ saved: true }); + + const result = await handleSaveTaskMemory(memory); + + expect(JSON.parse(result.content[0]?.text ?? '')).toMatchObject({ + memory: { + outcome: 'before\n[REDACTED]', + rationale: '[REDACTED]', + decisions: ['[REDACTED]\nafter'], + }, + }); + expect(saveTaskMemory).toHaveBeenCalledWith(expect.any(Object), 42, memory); + }); +}); diff --git a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts index 04cee66e8..9a20ae3ce 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/__tests__/tool-descriptions.test.ts @@ -3,6 +3,11 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; +import { + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE, + SHOW_WIDGET_HEIGHT_DESCRIPTION, + SHOW_WIDGET_THEME_GUIDANCE, +} from '@roomote/cloud-agents/show-widget'; import { MANAGE_CUSTOM_AUTOMATIONS_TOOL } from '@roomote/types'; const thisFilePath = fileURLToPath(import.meta.url); @@ -355,10 +360,9 @@ describe('roomote MCP tool descriptions', () => { expect(tool.config.description).toContain( 'Do not use it for ordinary prose', ); - expect(tool.config.description).toContain('rw-card'); - expect(tool.config.description).toContain('`--rw-*` theme variables'); + expect(tool.config.description).toContain(SHOW_WIDGET_THEME_GUIDANCE); expect(tool.config.description).toContain( - 'Keep widgets compact enough to fit without scrolling', + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE, ); expect(tool.config.description).toContain( 'HTML, CSS, and inline SVG are displayed in a sandboxed iframe', @@ -374,8 +378,8 @@ describe('roomote MCP tool descriptions', () => { expect(getInputSchemaField(tool, 'css').description).toContain( '--rw-surface', ); - expect(getInputSchemaField(tool, 'height').description).toContain( - 'without a vertical scrollbar', + expect(getInputSchemaField(tool, 'height').description).toBe( + SHOW_WIDGET_HEIGHT_DESCRIPTION, ); expect(getInputSchemaField(tool, 'textFallback').description).toContain( 'originating chat surface', diff --git a/apps/worker/src/mcp/roomote-mcp-server/index.ts b/apps/worker/src/mcp/roomote-mcp-server/index.ts index ddd77eee8..950e7bc89 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/index.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/index.ts @@ -48,7 +48,12 @@ import { handleUpdateEnvironment, } from './create-environment.js'; import { handleRequestEnvironmentVariables } from './request-environment-variables.js'; -import { handleShowWidget } from './show-widget.js'; +import { + handleShowWidget, + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE, + SHOW_WIDGET_HEIGHT_DESCRIPTION, + SHOW_WIDGET_THEME_GUIDANCE, +} from './show-widget.js'; import { handleSendChatReply } from './send-chat-reply.js'; import { handleRelayFastAgentChatReply } from './relay-fast-agent-chat-reply.js'; import { @@ -165,9 +170,10 @@ roomoteMcpServer.registerTool( 'Use it when a structured or visual presentation is clearer than plain text, or to demonstrate how something would look. ' + 'Examples include mock UI, status cards, tables, annotated plans, and other visual examples. ' + 'HTML, CSS, and inline SVG are displayed in a sandboxed iframe with scripts disabled and network requests blocked. ' + - 'Prefer semantic HTML with the built-in widget classes (`rw-card`, `rw-stack`, `rw-row`, `rw-grid`, `rw-stat`, `rw-badge`, `rw-callout`, `rw-muted`) so the widget follows the host task theme. ' + - 'For custom CSS, use the provided `--rw-*` theme variables instead of hard-coded colors; omit css when the built-in styles are sufficient. ' + - 'Keep widgets compact enough to fit without scrolling: use concise labels and a small number of cards, rows, or table entries, and choose a height that fully fits the expected content. Use ordinary prose or an artifact for long content. ' + + SHOW_WIDGET_THEME_GUIDANCE + + ' ' + + SHOW_WIDGET_FIXED_CANVAS_GUIDANCE + + ' ' + 'Do not use it for ordinary prose or collecting user input; use request_user_input when you need answers. ' + 'Optional textFallback is delivered to the originating chat surface (Slack/Teams/Telegram/Discord) when the task was started from chat.', inputSchema: { @@ -184,12 +190,7 @@ roomoteMcpServer.registerTool( .describe( 'Optional extra CSS injected after the built-in widget defaults. Prefer --rw-background, --rw-surface, --rw-surface-muted, --rw-text, --rw-text-muted, --rw-border, --rw-primary, --rw-accent, --rw-success, --rw-warning, and --rw-danger instead of hard-coded colors.', ), - height: z - .number() - .optional() - .describe( - 'Optional widget iframe height in pixels (clamped to 120-800; default 320). Choose the smallest height that fully fits the expected content without a vertical scrollbar.', - ), + height: z.number().optional().describe(SHOW_WIDGET_HEIGHT_DESCRIPTION), textFallback: z .string() .optional() diff --git a/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts b/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts index a7a73adce..4f45f3ad1 100644 --- a/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts +++ b/apps/worker/src/mcp/roomote-mcp-server/show-widget.ts @@ -1,264 +1,26 @@ -import type { DOMPurify } from 'dompurify'; +import { + prepareShowWidget, + type ShowWidgetInput, +} from '@roomote/cloud-agents/show-widget'; -import { catchError, errorResult, successResult } from './tool-result.js'; +import { errorResult, successResult } from './tool-result.js'; import type { ToolResult } from './types.js'; -const SHOW_WIDGET_MAX_HTML_CHARS = 100_000; -const SHOW_WIDGET_MAX_CSS_CHARS = 50_000; -const SHOW_WIDGET_MAX_TITLE_CHARS = 200; -const SHOW_WIDGET_MAX_TEXT_FALLBACK_CHARS = 4_000; -const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; -export const SHOW_WIDGET_DEFAULT_HEIGHT = 320; -export const SHOW_WIDGET_MIN_HEIGHT = 120; -export const SHOW_WIDGET_MAX_HEIGHT = 800; - -type ShowWidgetInput = { - html: string; - title?: string; - css?: string; - height?: number; - textFallback?: string; -}; - -type ShowWidgetSuccess = { - success: true; - shown: true; - title: string | null; - html: string; - css: string | null; - height: number; - textFallback: string | null; -}; - -function asTrimmedString(value: unknown): string | null { - if (typeof value !== 'string') { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -let purifierPromise: Promise | null = null; - -async function getPurifier(): Promise { - if (purifierPromise) { - return purifierPromise; - } - - purifierPromise = Promise.all([import('dompurify'), import('jsdom')]).then( - ([{ default: createDOMPurify }, { JSDOM }]) => { - const purifier = createDOMPurify(new JSDOM('').window); - - purifier.addHook('uponSanitizeAttribute', (node, data) => { - const name = data.attrName.toLowerCase(); - const value = String(data.attrValue ?? '') - .trim() - .toLowerCase(); - - if ( - name.startsWith('on') || - name === 'srcdoc' || - name === 'formaction' || - name === 'xlink:href' - ) { - data.keepAttr = false; - return; - } - - if (node.namespaceURI === SVG_NAMESPACE) { - if (name === 'href' && !value.startsWith('#')) { - data.keepAttr = false; - return; - } - - const withoutLocalReferences = value.replace( - /url\(\s*(['"]?)#[^'"()\s]+\1\s*\)/gi, - '', - ); - if (/url\s*\(/i.test(withoutLocalReferences)) { - data.keepAttr = false; - return; - } - } - - if ( - name === 'href' || - name === 'src' || - name === 'poster' || - name === 'action' || - name === 'srcset' - ) { - if ( - value.startsWith('http:') || - value.startsWith('https:') || - value.startsWith('//') || - value.startsWith('javascript:') || - value.startsWith('vbscript:') || - value.startsWith('data:') || - value.startsWith('blob:') - ) { - data.keepAttr = false; - } - } - }); - - return purifier; - }, - ); - - return purifierPromise; -} - -/** - * Sanitize model HTML with a parser-backed allowlist (DOMPurify + JSDOM). - * Regex multi-pass deletion is intentionally avoided because nested tags can - * reconstitute blocked markup across passes. - */ -export async function sanitizeWidgetHtml(html: string): Promise { - const purifier = await getPurifier(); - - return purifier.sanitize(html, { - USE_PROFILES: { html: true, svg: true }, - FORBID_TAGS: [ - 'script', - 'iframe', - 'object', - 'embed', - 'form', - 'base', - 'meta', - 'link', - 'style', - 'math', - 'foreignobject', - 'image', - 'use', - 'animate', - 'animatecolor', - 'animatemotion', - 'animatetransform', - 'filter', - 'set', - 'mpath', - 'noscript', - 'template', - 'video', - 'audio', - 'source', - 'track', - 'portal', - 'frame', - 'frameset', - 'applet', - ], - ALLOW_DATA_ATTR: false, - ADD_FORBID_CONTENTS: ['script', 'style'], - }); -} - -/** - * Allow only local stylesheet declarations. Strip network-capable constructs - * and any markup that could break out of a