diff --git a/.agents/skills/changeset-release-pr/SKILL.md b/.agents/skills/changeset-release-pr/SKILL.md index 0bf6d617e..a6c700d6a 100644 --- a/.agents/skills/changeset-release-pr/SKILL.md +++ b/.agents/skills/changeset-release-pr/SKILL.md @@ -1,16 +1,17 @@ --- name: changeset-release-pr -description: 'Prepare the next Roomote release PR: review what merged to develop, fill missing release notes, ensure meaningful user-facing features are documented, confirm patch/minor/major when unspecified, run the product version script, and open the final release PR. Use when asked to prep, cut, or write notes for a release.' +description: 'Prepare Roomote releases: cut the normal release PR from develop, or ship an urgent patch directly from latest main and synchronize its version back to develop. Use when asked to prep, cut, hotfix, or write notes for a release.' --- # Changeset Release PR -Use this skill to prepare the single release PR that cuts the next Roomote -version. The deliverable is a normal PR against `develop` containing the root -version bump, final `CHANGELOG.md` entry, any public documentation updates needed -for the release's meaningful user-facing features, and deletion of every +By default, use this skill to prepare the single release PR that cuts the next +Roomote version. The deliverable is a normal PR against `develop` containing the +root version bump, final `CHANGELOG.md` entry, any public documentation updates +needed for the release's meaningful user-facing features, and deletion of every consumed pending changeset. Merging it makes CI open the frozen Promote PR to -`main`. +`main`. For an urgent patch that cannot wait for that path, use the clearly +separated direct-to-main hotfix workflow below. ## How releases work here @@ -271,6 +272,178 @@ Commit the generated release artifacts on a feature branch and open a PR against - a note that squash-merging the PR cuts the release and automatically opens the frozen **Promote vX.Y.Z to production** PR against `main` +## Emergency direct-to-main hotfix path + +Use this path only for an urgent production patch that cannot wait for the +normal `develop` release flow above. The normal workflow remains the default. +This exception produces two PRs: + +1. a complete patch and generated release artifacts targeting `main` +2. a companion PR targeting `develop` that changes only root `package.json` and + `CHANGELOG.md` + +The production PR must merge first. Do not merge the companion until the tag, +images, and GitHub Release have all shipped successfully. + +### A. Establish the release state + +Fetch both branches and tags, then branch from the exact latest `main` tip: + +```bash +git fetch --tags origin \ + refs/heads/main:refs/remotes/origin/main \ + refs/heads/develop:refs/remotes/origin/develop +git switch --create hotfix/ origin/main +git tag --sort=-version:refname | head -5 +node -p "require('./package.json').version" +head -20 CHANGELOG.md +``` + +Before continuing, verify: + +- `HEAD` is exactly `origin/main`. +- The newest shipped `vX.Y.Z` tag, root version, and top changelog section agree. +- `main` has no pending non-README changeset that `pnpm run version` would + accidentally consume with the hotfix. +- No newer unshipped Promote PR or release candidate conflicts with the patch + version. Resolve release ordering rather than guessing. +- The fix is a patch and every required prerequisite is already on `main` or is + explicitly included because it is safe to ship with the fix. +- The patch preserves the schema N-1 rollback guarantee and does not require an + incompatible migration, destructive schema change, or coordinated rollout + that makes direct release unsafe. + +### B. Apply the complete fix + +Identify the exact merged fix on `develop`, including tests, docs, and required +supporting changes. Read its source PR and compare its merge commit to `main`; +do not copy only the most obvious runtime file. Cherry-pick the smallest complete +commit set, usually the squash commit: + +```bash +git cherry-pick +git diff --stat origin/main...HEAD +git diff --name-status origin/main...HEAD +git diff ^.. -- +git diff origin/main...HEAD -- +``` + +Account for every intentional difference caused by prerequisites or conflict +resolution. Exclude unrelated features, pending `develop` changesets, and +release artifacts from another version. + +When the fix depends on an external service or production-like integration, +repeat the smallest safe live check that proves the original symptom is fixed. +Record the environment and observable result without exposing secrets or private +data. If a live check is irrelevant or unavailable, say why and rely on focused +deterministic tests. + +### C. Generate and validate the patch release + +Add one hotfix-specific patch changeset using the same format as step 8. Commit +the complete fix and changeset separately when useful for auditability, then +consume the changeset through the repository-owned command: + +```bash +pnpm run version +``` + +Never run `changeset version`, and never hand-edit the generated version or +changelog heading. Polish the generated top changelog summary and highlights as +described in step 9, then commit root `package.json` and `CHANGELOG.md` as the +version commit. + +Verify that: + +- the root version is exactly one patch above the shipped `main` version +- the top `CHANGELOG.md` section contains the complete hotfix note +- the hotfix changeset was consumed and no non-README `.changeset/*.md` remains +- no workspace package version changed +- `origin/main...HEAD` contains only the complete fix plus root `package.json` + and `CHANGELOG.md` + +Preserve the changeset commit in branch history even though the version commit +deletes its file. Run the release scripts, focused product tests and typechecks, +formatting check, and full pre-push gate: + +```bash +pnpm test:release-scripts +pnpm exec oxfmt --check . +node scripts/pre-push-checks.mjs +``` + +Repeat any relevant live check after final conflict resolution or release edits. +Failed or unavailable checks are release blockers unless a maintainer explicitly +accepts and records the risk. + +### D. Open the production hotfix PR + +Open the hotfix PR against `main`. Include the source fix PR/commit and +prerequisites, previous and next versions, exact patch scope and intentional +differences, all validation and live-check results, rollback instructions, and a +link to the companion PR once available. Require a **merge commit**; never squash +or rebase this PR. + +Rollback guidance must distinguish two states: + +- Before the tag is consumed externally, revert the hotfix merge commit if that + is still operationally safe. +- After publication, redeploy the previous immutable `vX.Y.Z`; never move, + delete, or reuse a published tag. Describe data or schema cleanup separately + and preserve the N-1 rollback contract. + +### E. Prepare the companion develop sync + +After the production branch is final, create a branch from latest +`origin/develop` and bring over only the generated product version and changelog: + +```bash +git switch --create chore/sync--on-develop origin/develop +git restore --source -- package.json CHANGELOG.md +git diff --name-only origin/develop +git diff --exit-code -- package.json CHANGELOG.md +git diff --exit-code origin/develop -- .changeset +``` + +Do not cherry-pick the runtime fix; it should already be on `develop`. Do not run +`pnpm run version` on this branch because that would consume unrelated pending +`develop` changesets. The first diff must list exactly `package.json` and +`CHANGELOG.md`; the other two must be empty. If `develop` independently changed +either release artifact, reconcile deliberately without touching pending +changesets and stop if the companion cannot remain a truthful two-file sync. + +Open the companion PR against `develop`. It must be squash-merged only after all +production gates below succeed. Merging it early can make +`.github/workflows/release.yml` freeze the moving `develop` tip as +`release/vX.Y.Z` and open an incorrect Promote PR. + +### F. Enforce merge and release order + +Do not collapse or reorder these gates: + +1. Merge the production hotfix PR into `main` with a merge commit. +2. Wait for **Tag Product Release** to succeed for that `main` merge. +3. Verify the remote annotated tag exists and resolves to the released tree with + `git ls-remote --tags origin refs/tags/vX.Y.Z` and, after fetching, + `git rev-parse 'vX.Y.Z^{}'`. +4. Wait for the tag-triggered **Publish GHCR Images** workflow to succeed, + including image publication and GitHub Release creation. Verify the GitHub + Release and immutable image tags; tag creation alone is not completion. +5. Only then squash-merge the companion PR into `develop`. + +After the tag exists, the `develop` Release workflow sees an already shipped +version and exits without creating a candidate. If a production gate fails, +leave the companion open while the release is repaired or rolled back. + +PRs [#1840](https://github.com/RooCodeInc/Roomote/pull/1840) and +[#1841](https://github.com/RooCodeInc/Roomote/pull/1841) demonstrate this +two-branch workflow. Their Notion-specific live checks are an optional example, +not a requirement for unrelated hotfixes. + +End the hotfix path by reporting both PR URLs, released version, source fix and +prerequisite commits, exact file scope, live-check result or reason omitted, +validation results, current merge-order gate, and rollback risks. + ## Guardrails - Never edit `CHANGELOG.md` or the root version by hand; generate both with diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index 08337b2f5..76d5fad28 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -378,6 +378,7 @@ RUN chmod +x /entrypoint.sh COPY --chown=roomote-app:roomote-app --from=build-web /roomote/apps/web/.next/standalone ./ COPY --chown=roomote-app:roomote-app --from=build-web /roomote/apps/web/.next/static ./apps/web/.next/static/ COPY --chown=roomote-app:roomote-app --from=build-web /roomote/apps/web/public ./apps/web/public/ +COPY --chown=roomote-app:roomote-app CHANGELOG.md ./CHANGELOG.md # The web server (cwd /roomote/apps/web) reads setup-flow docs from # ../docs at request time, so the MDX sources must ship in the image. COPY --chown=roomote-app:roomote-app --from=build-web /roomote/apps/docs ./apps/docs/ diff --git a/.docker/gbrain/entrypoint.sh b/.docker/gbrain/entrypoint.sh index e7276212b..0abacb3f7 100644 --- a/.docker/gbrain/entrypoint.sh +++ b/.docker/gbrain/entrypoint.sh @@ -175,13 +175,26 @@ elif [ -n "${OPENAI_API_KEY:-}" ]; then DEFAULT_CHAT_MODEL="openai:gpt-5.6-luna" else # No credential: the server still boots and serves, it just cannot embed or - # synthesize. Roomote gates every Brain code path on the same keys, so it + # synthesize. Roomote gates every Brain code path on the same signal, so it # will not talk to this container either. BRAIN_PROVIDER="none" DEFAULT_EMBEDDING_MODEL="" DEFAULT_CHAT_MODEL="" fi +# Gateway mode holds no real provider key, so chat defaults to the +# `roomote/helper` sentinel: the Roomote gateway answers it with the +# deployment's helper model instead of forwarding to a provider, which is what +# frees synthesis from needing a Brain provider key. Convergence rule: the +# chat model is a plain env export re-derived on every boot (env wins over +# anything the brain stored at init), so a brain that previously defaulted to +# gpt-5.6-luna picks this up on its next boot — while an operator's explicit +# GBRAIN_MODEL (below) or R_BRAIN_MODEL (applied by the gateway per request) +# still wins over the default. +if [ -n "${OPENAI_BASE_URL:-}" ] && [ "$BRAIN_PROVIDER" != "none" ]; then + DEFAULT_CHAT_MODEL="${BRAIN_PROVIDER}:roomote/helper" +fi + # An operator-chosen embedding model arrives as a bare id (text-embedding-3-large) # because it is written once and must survive a provider switch; gbrain wants # it provider-qualified. Qualify it with whichever provider this container @@ -280,35 +293,14 @@ gbrain config set dream.synthesize.link_manifest true >/dev/null gbrain config set agent.use_gateway_loop true >/dev/null echo "[gbrain-entrypoint] corpus checkout: $BRAIN_DIR (filesystem + Postgres index)" -# Route gbrain's OpenRouter reranker through the same Roomote credential -# gateway as embeddings and chat. Do this after initialization so exposing an -# OpenRouter-compatible endpoint does not change which provider gbrain chooses -# when it creates the Brain. An empty forwarded setting restores the default, -# including after a deployment previously selected another reranker. -GBRAIN_RERANKER_MODEL="${GBRAIN_RERANKER_MODEL:-openrouter:voyageai/rerank-2.5-lite}" -case "$GBRAIN_RERANKER_MODEL" in - openrouter:*) - if [ -z "${OPENROUTER_BASE_URL:-}" ] && [ -n "${OPENAI_BASE_URL:-}" ]; then - OPENROUTER_BASE_URL="${OPENAI_BASE_URL%/}" - case "$OPENROUTER_BASE_URL" in - */v1) ;; - *) OPENROUTER_BASE_URL="$OPENROUTER_BASE_URL/v1" ;; - esac - export OPENROUTER_BASE_URL - fi - if [ -z "${OPENROUTER_API_KEY:-}" ] && [ -n "${OPENAI_API_KEY:-}" ]; then - OPENROUTER_API_KEY="$OPENAI_API_KEY" - export OPENROUTER_API_KEY - fi - if [ -z "${OPENROUTER_BASE_URL:-}" ] || [ -z "${OPENROUTER_API_KEY:-}" ]; then - echo "[gbrain-entrypoint] WARNING: $GBRAIN_RERANKER_MODEL needs OPENROUTER_BASE_URL and OPENROUTER_API_KEY." - echo "[gbrain-entrypoint] WARNING: reranking will remain fail-open until the gateway is configured." - fi - ;; -esac - -gbrain config set search.reranker.model "$GBRAIN_RERANKER_MODEL" >/dev/null -echo "[gbrain-entrypoint] reranker: $GBRAIN_RERANKER_MODEL" +# The Brain does not use a reranker. gbrain's own init already writes +# `search.reranker.enabled false` for installs keyed the way ours are, but +# make the choice explicit so every brain — including ones created before +# this line and ones hit by upstream mode-bundle default flips — converges +# on the same shipped behavior. Retrieval is hybrid RRF; autocut no-ops +# without rerank scores by design. +gbrain config set search.reranker.enabled false >/dev/null +echo "[gbrain-entrypoint] reranker: disabled" # Adding a key to a brain created without one is a first-class flow rather # than an edge case: on hosts whose compose parser ignores `profiles` the diff --git a/.env.production.example b/.env.production.example index eb8f77702..20791d1f1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -142,21 +142,19 @@ DEFAULT_COMPUTE_PROVIDER=docker # R_GITHUB_APP_SLUG= # Optional comma-separated GitHub App slugs that are also trusted as Roomote-managed. # R_GITHUB_ADDITIONAL_APP_SLUGS= -# Self-run Brain inference (embeddings/rerank stay on your hardware; chat +# Self-run Brain embeddings (embeddings stay on your hardware; chat # synthesis keeps using the configured provider). With the bundled service, # set COMPOSE_PROFILES=brain,local-inference and ALL of the settings below — -# the model names and dimensions must match what the inference server +# the model name and dimensions must match what the inference server # serves, and the embedding pair is create-time: set everything BEFORE the # Brain's first boot. gbrain's defaults (text-embedding-3-small, 1536) name -# models the bundled server does not serve, so the URLs alone are not a +# models the bundled server does not serve, so the URL alone is not a # working configuration. Self-run model names pass through unchanged and # must exactly match the ids served by the upstream. # R_BRAIN_EMBEDDINGS_UPSTREAM_URL=http://infinity:7997 -# R_BRAIN_RERANK_UPSTREAM_URL=http://infinity:7997 # R_BRAIN_INFERENCE_UPSTREAM_API_KEY= # R_BRAIN_EMBEDDING_MODEL=BAAI/bge-m3 # R_BRAIN_EMBEDDING_DIMENSIONS=1024 -# R_BRAIN_RERANKER_MODEL=BAAI/bge-reranker-v2-m3 # R_GITHUB_APP_ID= # Raw GitHub App private-key PEM with newlines escaped as \n; do not base64 it. # R_GITHUB_APP_PRIVATE_KEY= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f922a5b3c..c5d06726e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,6 +116,7 @@ jobs: fi release_sha="$(git rev-parse HEAD)" + shipping_guard_sha="$candidate_sha" git push origin "${release_sha}:refs/heads/${release_branch}" candidate_status="Candidate refreshed to \`${release_sha}\` from \`develop\`." echo "Refreshed ${release_branch} to ${release_sha}" @@ -129,15 +130,40 @@ jobs: fi release_sha="$bump_sha" + shipping_guard_sha="$release_sha" candidate_status="Frozen at \`${release_sha}\` — the commit where \`${version}\` was versioned. Commits merged to \`develop\` afterward require an explicit candidate refresh or ship in the next release." if git ls-remote --exit-code --heads origin "$release_branch" >/dev/null 2>&1; then - echo "Release branch ${release_branch} already exists; leaving it frozen." + remote_release_sha="$(git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" | cut -f1)" + if [ "$remote_release_sha" != "$release_sha" ]; then + echo "Release branch ${release_branch} points to ${remote_release_sha}, expected ${release_sha}; refusing to write Promote PR metadata for a different candidate." + exit 1 + fi + echo "Release branch ${release_branch} already exists at ${release_sha}; leaving it frozen." else git push origin "${release_sha}:refs/heads/${release_branch}" echo "Cut ${release_branch} at ${release_sha}" fi fi + remote_release_sha="$(git ls-remote --exit-code --heads origin "refs/heads/${release_branch}" | cut -f1)" + if [ "$remote_release_sha" != "$release_sha" ]; then + echo "Release branch ${release_branch} moved to ${remote_release_sha}, expected ${release_sha}; refusing to update Promote PR metadata." + exit 1 + fi + + git fetch origin "refs/heads/main:refs/remotes/origin/main" --tags --quiet + if git rev-parse "$tag" >/dev/null 2>&1 || git merge-base --is-ancestor "$shipping_guard_sha" origin/main; then + echo "Cannot update Promote PR metadata for ${tag}: the candidate reached main while the branch was being prepared." + exit 1 + fi + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + existing="$(gh pr list --base main --head "$release_branch" --state open --json url --jq '.[0].url // empty')" + if [ -z "$existing" ]; then + echo "Cannot refresh ${tag}: the Promote PR closed while the branch was being updated." + exit 1 + fi + fi + notes="$(node scripts/release/extract-changelog-section.mjs "$version" || true)" body_file="$(mktemp)" { diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f2289e7..772636b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,64 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.0.0 (2026-08-30) + +Roomote 1.0 makes Fast the default entry point for conversations, enables Memory by default on new hosted deployments, and completes the Session-centered automation, analytics, and artifact experience. + +### Highlights + +- Start unpinned requests in Fast by default, while choosing an environment or repository still starts coding work immediately. +- Enable Memory by default for new hosted deployments and recall public Discord discussions, visible Linear issues, and richer Notion database properties. +- Follow conversations, delegated executions, artifacts, reviews, and costs in a searchable Session workspace across desktop and mobile. +- Continue Fast across supported chat providers, delegate parallel work with attachments, preview generated HTML safely, and announce default-branch changes. + +### Major changes + +- Make Fast the default entry point for unpinned Roomote requests across the web dashboard and supported chat providers, and enable Memory by default for new hosted deployments. Select an environment or repository when work should start directly in a coding task; existing deployments keep their current Memory setting. + +### Minor changes + +- Start and continue linked Fast conversations directly from Microsoft Teams and Telegram, matching the existing Slack and Discord experience. +- React to Roomote Fast replies across Slack, Discord, Microsoft Teams, and Telegram to provide context for a follow-up or let the conversation stay quiet. +- Announce default-branch pushes across supported source-control providers with concise, pull-request-aware summaries, direct change links, and durable Slack, Discord, Microsoft Teams, or Telegram destinations. +- Open Analytics on Costs by default and break out Session orchestration and Memory synthesis so teams can understand where inference spend comes from. +- Let Fast launch multiple independent coding tasks from one turn, forward image and supported file context into delegated work, and show nested startup progress without losing retries or results. +- Let Fast inspect GitHub Actions runs, jobs, and logs to explain CI failures while keeping the diagnostic path read-only. +- Open generated HTML artifacts as safely sandboxed previews with a source-code toggle, while keeping presentational widgets available in web transcripts and linked chat previews. +- Add public Discord discussions and visible Linear issues to Memory, preserve richer Linear planning metadata, and render readable Notion database properties for more complete recall. +- Let administrators enable Memory without a dedicated synthesis-provider key, enable it by default for new hosted deployments, and surface newly ingested pages within minutes instead of waiting for a later maintenance pass. +- Finish setup in one Roomote Session that launches and tracks selected starter tasks, with clearer guidance about the value and limits of hosted trial inference. +- Make Sessions the primary workspace for Roomote work, with dashboard launch and search, recent-session navigation, delegated execution details, artifacts, reviews, costs, stable titles, and responsive mobile layouts in one continuous conversation. +- Browse current and previous Roomote releases directly in the update dialog, with the latest release expanded and newer remotely detected updates kept visible even when their notes are not yet available in the running image. +- Open current and previous Roomote release notes directly from About Roomote in the existing in-app release-history dialog. +- Follow delegated work more clearly in Sessions with live nested-task activity, a conversation-wide artifact gallery, cleaner task details and navigation, and modernized search, filter, board, and list controls. +- Include one safely validated, representative pull-request screenshot in Slack Merge Announcer reports when the pull request provides a suitable image. +- Show richer task context throughout Sessions with accumulated pull requests, actor and source details, clearer status indicators, direct access to a sole running task, and full task workspaces for Session deep links. +- Make Roomote MCP Session-first so ordinary start, search, summary, message, and follow-up operations use Sessions by default while explicit task IDs continue to target individual coding tasks. +- Have Fast acknowledge substantive human requests before starting model-invoked work, using a brief reply or eligible Slack reaction without duplicating immediate answers, clarifications, or delegated-task kickoffs. + +### Patch changes + +- Keep Fast sessions quiet through short transient provider recoveries: retries stay silent unless the wait grows past 30 seconds, all retryable provider errors share a six-retry budget with bounded jittered backoff, and warm-session progress refreshes the recovery budget the way completed coding-task turns do. +- Queue delegated-task updates durably for their Fast parent so busy conversations process child progress and completion in order instead of rejecting or killing the parent event after 30 seconds. +- Keep pull-request review follow-through reliable by showing actionable feedback in Fast and standard web tasks, clearing resolved Roomote findings, preserving the correct destination branch and attribution, and avoiding duplicate review requests. +- Disable anonymous usage reporting in the bundled Infinity service so self-hosted local Memory embeddings stay quiet by default. +- Keep Fast Sessions stable through cold starts and refreshes by preserving conversation context, model and reasoning choices, generated titles, pull-request status, and recovery state without duplicate or stale transcript notices. +- Keep Slack Fast thread titles synchronized with generated and manually edited Session titles instead of leaving conversations labeled `Thread`. +- Show platform issue alerts sent directly to Slack deployment admins with the same actionable automation card used for configured alert destinations. +- Keep expanded tool-call details readable in Task and Fast transcripts by wrapping long YAML values within the transcript instead of clipping or overflowing them. +- Keep Session context accurate by including attached task inference spend in total costs, opening inline task links in their owning Session, and hiding running or artifact states when they no longer apply. +- Keep Slack-backed Sessions and automations reliable by deferring silent custom-automation delivery, suppressing no-op placeholders, validating Fast titles without retry loops, restoring clear Configure actions, and using the correct Merge Announcer icon. +- Restore required runtime dependencies in standalone deployment images while continuing to bundle in-app release history. +- Show the shared progress spinner while tool calls are active, then restore each tool or integration icon when the call settles. +- Let Fast mode discover, load, and explicitly invoke environment-scoped Settings skills while preserving built-in skill precedence and bounded access. +- Complete large Notion historical discovery scans in the fast continuation loop instead of spreading database-row traversal across weeks of scheduled passes. +- Keep tasks moving when visual proof takes too long by applying one five-minute deadline across capture, retries, and recovery before returning a graceful proof blocker. +- Include authorized Settings skills in Fast mode's unscoped skill inventory. +- Keep running nested-task activity visible when a Session is loaded directly or reconnects, while still hiding it during genuine new parent responses. +- Restore authorized deployment MCP tools in Fast advisor and judge consultations while keeping Fast-native orchestration and custom automation tools confined to the parent Session. +- Keep Fast conversations responsive through API restarts by closing out in-flight turns gracefully during shutdown, so replies no longer disappear and sessions no longer get stuck waiting on an abandoned turn. + ## 0.45.1 (2026-08-29) This patch restores complete Notion database discovery across Memory and the built-in Notion MCP. diff --git a/README.md b/README.md index ffe9f22dc..71f84a979 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,8 @@ Roomote handles the work that pulls you off your main project: migration files, boilerplate. - **Build small features.** "Add a dark mode toggle to settings." It writes the code, runs the app, takes a screenshot, and opens a PR with a preview link. +- **Start from scratch.** Create an empty GitHub repository from Roomote, then + use the first task to build the project in an isolated environment. - **Triage issues.** Connect Linear, Jira, or GitHub Issues. It reads new tickets, asks clarifying questions, and starts working. diff --git a/apps/api/src/__tests__/graceful-shutdown.test.ts b/apps/api/src/__tests__/graceful-shutdown.test.ts new file mode 100644 index 000000000..ac00384d9 --- /dev/null +++ b/apps/api/src/__tests__/graceful-shutdown.test.ts @@ -0,0 +1,121 @@ +const mocks = vi.hoisted(() => ({ + abortActiveFastAgentTurns: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + abortActiveFastAgentTurns: mocks.abortActiveFastAgentTurns, + FastAgentProcessShutdownError: class extends Error { + constructor(public readonly signal: NodeJS.Signals) { + super(`Fast turn interrupted by API shutdown (${signal}).`); + this.name = 'FastAgentProcessShutdownError'; + } + }, +})); + +import type { ServerType } from '@hono/node-server'; + +import { + gracefullyShutdownApi, + installApiGracefulShutdown, +} from '../graceful-shutdown'; + +describe('gracefullyShutdownApi', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('aborts Fast turns before waiting for active requests to close', async () => { + let finishClose: ((error?: Error) => void) | undefined; + const server = { + close: vi.fn((callback: (error?: Error) => void) => { + finishClose = callback; + }), + } as unknown as ServerType; + let finishAbort: (() => void) | undefined; + const abortTurns = vi.fn( + () => + new Promise((resolve) => { + finishAbort = () => resolve(1); + }), + ); + const exitProcess = vi.fn() as unknown as (code?: number) => never; + const flushSentry = vi.fn().mockResolvedValue(undefined); + + const shutdown = gracefullyShutdownApi(server, 'SIGTERM', { + abortTurns, + exitProcess, + flushSentry, + }); + + expect(abortTurns).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'FastAgentProcessShutdownError', + signal: 'SIGTERM', + }), + ); + expect(server.close).toHaveBeenCalledOnce(); + expect(exitProcess).not.toHaveBeenCalled(); + + finishAbort?.(); + finishClose?.(); + await shutdown; + + expect(flushSentry).toHaveBeenCalledOnce(); + expect(exitProcess).toHaveBeenCalledWith(0); + }); + + it('exits unsuccessfully when the HTTP server cannot close', async () => { + const closeError = new Error('close failed'); + const server = { + close: vi.fn((callback: (error?: Error) => void) => callback(closeError)), + } as unknown as ServerType; + const abortTurns = vi.fn().mockResolvedValue(0); + const exitProcess = vi.fn() as unknown as (code?: number) => never; + const flushSentry = vi.fn().mockResolvedValue(undefined); + const logError = vi.fn(); + + await gracefullyShutdownApi(server, 'SIGINT', { + abortTurns, + exitProcess, + flushSentry, + logError, + }); + + expect(logError).toHaveBeenCalledWith( + '[api] Graceful shutdown failed', + closeError, + ); + expect(exitProcess).toHaveBeenCalledWith(1); + }); + + it.each(['SIGTERM', 'SIGINT'] as const)( + 'forces exit when %s arrives again during shutdown', + (signal) => { + const server = { close: vi.fn() } as unknown as ServerType; + const exitProcess = vi.fn() as unknown as (code?: number) => never; + const signalHandlers = new Map void>(); + const on = vi.spyOn(process, 'on').mockImplementation((( + registeredSignal: NodeJS.Signals, + handler: () => void, + ) => { + signalHandlers.set(registeredSignal, handler); + return process; + }) as typeof process.on); + + try { + const cleanup = installApiGracefulShutdown(server, { + abortTurns: vi.fn(() => new Promise(() => undefined)), + exitProcess, + }); + + signalHandlers.get(signal)?.(); + expect(exitProcess).not.toHaveBeenCalled(); + signalHandlers.get(signal)?.(); + expect(exitProcess).toHaveBeenCalledWith(1); + cleanup(); + } finally { + on.mockRestore(); + } + }, + ); +}); diff --git a/apps/api/src/__tests__/route-policy-enforcement.test.ts b/apps/api/src/__tests__/route-policy-enforcement.test.ts index 18bad890c..97ef2c303 100644 --- a/apps/api/src/__tests__/route-policy-enforcement.test.ts +++ b/apps/api/src/__tests__/route-policy-enforcement.test.ts @@ -306,7 +306,14 @@ describe('route policy enforcement', () => { request, ); const publicBody = (await publicResponse.json()) as { - result?: { tools?: Array<{ name: string }> }; + result?: { + tools?: Array<{ + name: string; + inputSchema?: { + properties?: { action?: { enum?: string[] } }; + }; + }>; + }; }; expect(publicResponse.status).toBe(200); expect(publicBody.result?.tools?.map((tool) => tool.name)).toContain( @@ -315,6 +322,114 @@ describe('route policy enforcement', () => { expect(publicBody.result?.tools?.map((tool) => tool.name)).toContain( 'manage_custom_automations', ); + const manageTasks = publicBody.result?.tools?.find( + (tool) => tool.name === 'manage_tasks', + ); + expect(manageTasks?.inputSchema?.properties?.action?.enum).toEqual( + expect.arrayContaining([ + 'start', + 'search', + 'get_summary', + 'get_messages', + 'send_message', + 'search_tasks', + 'launch', + ]), + ); + + const sessionSearchResponse = await createApiApp().request( + 'http://localhost/mcp', + { + ...request, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 3, + method: 'tools/call', + params: { name: 'manage_tasks', arguments: { action: 'search' } }, + }), + }, + ); + const sessionSearchBody = (await sessionSearchResponse.json()) as { + result?: { structuredContent?: unknown }; + }; + expect(sessionSearchBody.result?.structuredContent).toMatchObject({ + sessions: expect.any(Array), + }); + + const taskSearchResponse = await createApiApp().request( + 'http://localhost/mcp', + { + ...request, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 4, + method: 'tools/call', + params: { + name: 'manage_tasks', + arguments: { action: 'search_tasks' }, + }, + }), + }, + ); + const taskSearchBody = (await taskSearchResponse.json()) as { + result?: { structuredContent?: unknown }; + }; + expect(taskSearchBody.result?.structuredContent).toMatchObject({ + tasks: expect.any(Array), + }); + + const invalidTaskSearchResponse = await createApiApp().request( + 'http://localhost/mcp', + { + ...request, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 5, + method: 'tools/call', + params: { + name: 'manage_tasks', + arguments: { + action: 'search_tasks', + status: 'needs_input', + }, + }, + }), + }, + ); + const invalidTaskSearchBody = + (await invalidTaskSearchResponse.json()) as { + result?: { isError?: boolean; structuredContent?: unknown }; + }; + expect(invalidTaskSearchBody.result?.isError).toBe(true); + expect(invalidTaskSearchBody.result?.structuredContent).toMatchObject({ + error: + 'status must be one of: active, completed, all when search resolves to tasks', + }); + + const invalidLegacySearchResponse = await createApiApp().request( + 'http://localhost/mcp', + { + ...request, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 6, + method: 'tools/call', + params: { + name: 'manage_tasks', + arguments: { + action: 'search', + pullRequest: 'owner/repo#1', + status: 'needs_input', + }, + }, + }), + }, + ); + const invalidLegacySearchBody = + (await invalidLegacySearchResponse.json()) as { + result?: { isError?: boolean }; + }; + expect(invalidLegacySearchBody.result?.isError).toBe(true); const callResponse = await createApiApp().request( 'http://localhost/mcp', diff --git a/apps/api/src/graceful-shutdown.ts b/apps/api/src/graceful-shutdown.ts new file mode 100644 index 000000000..69bb0413c --- /dev/null +++ b/apps/api/src/graceful-shutdown.ts @@ -0,0 +1,61 @@ +import type { ServerType } from '@hono/node-server'; +import { + abortActiveFastAgentTurns, + FastAgentProcessShutdownError, +} from '@roomote/cloud-agents/server'; + +type ApiShutdownOptions = { + abortTurns?: typeof abortActiveFastAgentTurns; + exitProcess?: (code?: number) => never; + flushSentry?: () => Promise; + logError?: (...args: Parameters) => void; +}; + +export async function gracefullyShutdownApi( + server: ServerType, + signal: NodeJS.Signals, + { + abortTurns = abortActiveFastAgentTurns, + exitProcess = process.exit, + flushSentry = async () => undefined, + logError = (...args) => console.error(...args), + }: ApiShutdownOptions = {}, +): Promise { + const abortPromise = abortTurns(new FastAgentProcessShutdownError(signal)); + const closeError = await new Promise((resolve) => { + server.close((error) => resolve(error ?? null)); + }); + await abortPromise; + if (closeError) { + logError('[api] Graceful shutdown failed', closeError); + } + await flushSentry(); + exitProcess(closeError ? 1 : 0); +} + +export function installApiGracefulShutdown( + server: ServerType, + options: ApiShutdownOptions = {}, +): () => void { + let shuttingDown = false; + const handlers = new Map void>(); + + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + const handler = () => { + if (shuttingDown) { + (options.exitProcess ?? process.exit)(1); + return; + } + shuttingDown = true; + void gracefullyShutdownApi(server, signal, options); + }; + handlers.set(signal, handler); + process.on(signal, handler); + } + + return () => { + for (const [signal, handler] of handlers) { + process.off(signal, handler); + } + }; +} diff --git a/apps/api/src/handlers/__tests__/merge-announcer-push.test.ts b/apps/api/src/handlers/__tests__/merge-announcer-push.test.ts new file mode 100644 index 000000000..0f6ae810f --- /dev/null +++ b/apps/api/src/handlers/__tests__/merge-announcer-push.test.ts @@ -0,0 +1,696 @@ +import { + enrichGitHubMergeAnnouncerEvent, + normalizeAdoPush, + normalizeBitbucketPush, + normalizeGiteaPush, + normalizeGitHubPush, + normalizeGitLabPush, +} from '../merge-announcer-push'; +import { adoPushWebhookSchema } from '../ado/types'; +import { bitbucketPushWebhookSchema } from '../bitbucket/types'; +import { giteaPushWebhookSchema } from '../gitea/types'; +import { gitLabPushWebhookSchema } from '../gitlab/types'; + +describe('Merge announcer push normalization', () => { + it('normalizes GitHub commit and pusher metadata', () => { + expect( + normalizeGitHubPush({ + ref: 'refs/heads/main', + compare: 'https://github.com/acme/widgets/compare/a...b', + size: 1, + pusher: { name: 'alice' }, + sender: { login: 'alice-fallback' }, + repository: { + id: 1, + full_name: 'acme/widgets', + html_url: 'https://github.com/acme/widgets', + }, + commits: [ + { + id: 'abc', + message: 'Ship widget', + author: { name: 'Bob', username: 'bob' }, + }, + ], + }), + ).toMatchObject({ + provider: 'github', + ref: 'refs/heads/main', + pusher: 'alice', + repository: { externalId: '1', host: 'github.com' }, + commits: [{ id: 'abc', author: { username: 'bob' } }], + }); + }); + + it('enriches GitHub merge pushes with bounded PR metadata and file stats', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { + id: 1, + full_name: 'acme/widgets', + html_url: 'https://github.com/acme/widgets', + }, + commits: [{ id: 'abcdef1234567890', message: 'Merge pull request #7' }], + }; + const event = normalizeGitHubPush(payload)!; + const listPullRequestsAssociatedWithCommit = vi.fn().mockResolvedValue({ + data: [ + { number: 7, state: 'closed', base: { ref: 'main' } }, + { number: 8, state: 'open', base: { ref: 'main' } }, + ], + }); + const get = vi.fn().mockResolvedValue({ + data: { + number: 7, + html_url: 'https://github.com/acme/widgets/pull/7', + title: 'Ship widget export', + body: `Adds the export and updates validation. + +![Product screenshot](https://github.com/user-attachments/assets/product-preview)`, + merged_at: '2026-08-29T12:00:00Z', + merge_commit_sha: payload.after, + base: { ref: 'main' }, + changed_files: 24, + additions: 120, + deletions: 15, + }, + }); + const listFiles = vi.fn().mockResolvedValue({ + data: [ + { + filename: 'src/widget.ts', + status: 'modified', + additions: 20, + deletions: 4, + patch: 'unbounded patch content must not be retained', + }, + ], + }); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { listPullRequestsAssociatedWithCommit }, + pulls: { get, listFiles }, + }, + }); + const enriched = await enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }); + + expect(getInstallationOctokit).toHaveBeenCalledWith({ installationId: 99 }); + expect(listPullRequestsAssociatedWithCommit).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'widgets', + commit_sha: payload.after, + per_page: 10, + }); + expect(get).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'widgets', + pull_number: 7, + }); + expect(listFiles).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'widgets', + pull_number: 7, + per_page: 20, + page: 1, + }); + expect(enriched.pullRequest).toEqual({ + number: 7, + url: 'https://github.com/acme/widgets/pull/7', + title: 'Ship widget export', + body: `Adds the export and updates validation. + +![Product screenshot](https://github.com/user-attachments/assets/product-preview)`, + changedFileCount: 24, + additions: 120, + deletions: 15, + changedFiles: [ + { + path: 'src/widget.ts', + status: 'modified', + additions: 20, + deletions: 4, + }, + ], + }); + expect(JSON.stringify(enriched)).not.toContain('unbounded patch content'); + }); + + it('enriches the associated PR while GitHub merge state is settling', async () => { + const payload = { + ref: 'refs/heads/develop', + after: '9d606d9be06853438da729770fa04bb4e81d45e7', + installation: { id: 99 }, + repository: { + id: 1, + full_name: 'RooCodeInc/Roomote', + default_branch: 'develop', + }, + commits: [ + { + id: '9d606d9be06853438da729770fa04bb4e81d45e7', + message: + '[Fix] Memory titles overflow the Explore memories card (#1766)', + }, + ], + }; + const event = normalizeGitHubPush(payload)!; + const listPullRequestsAssociatedWithCommit = vi.fn().mockResolvedValue({ + data: [{ number: 1766, state: 'open', base: { ref: 'develop' } }], + }); + const get = vi.fn().mockResolvedValue({ + data: { + number: 1766, + html_url: 'https://github.com/RooCodeInc/Roomote/pull/1766', + title: '[Fix] Memory titles overflow the Explore memories card', + body: 'Keep memory titles inside the card.', + merged_at: null, + merge_commit_sha: payload.after, + base: { ref: 'develop' }, + changed_files: 1, + additions: 20, + deletions: 20, + }, + }); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { listPullRequestsAssociatedWithCommit }, + pulls: { + get, + listFiles: vi.fn().mockResolvedValue({ data: [] }), + }, + }, + }); + + const enriched = await enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }); + + expect(listPullRequestsAssociatedWithCommit).toHaveBeenCalledOnce(); + expect(enriched.pullRequest).toMatchObject({ + number: 1766, + url: 'https://github.com/RooCodeInc/Roomote/pull/1766', + }); + }); + + it.each([ + 'fix: keep Session artifacts in execution details (#1896)', + 'Merge pull request #1896 from RooCodeInc/fix/session-artifacts', + ])( + 'uses a verified PR number from the tip commit while GitHub associations settle: %s', + async (message) => { + const payload = { + ref: 'refs/heads/develop', + after: 'd17604dc8bbca8a20ebe1d68f722f72889c95c5e', + installation: { id: 99 }, + repository: { + id: 1, + full_name: 'RooCodeInc/Roomote', + default_branch: 'develop', + }, + commits: [ + { + id: 'd17604dc8bbca8a20ebe1d68f722f72889c95c5e', + message, + }, + ], + }; + const event = normalizeGitHubPush(payload)!; + const listPullRequestsAssociatedWithCommit = vi.fn().mockResolvedValue({ + data: [], + }); + const get = vi.fn().mockResolvedValue({ + data: { + number: 1896, + html_url: 'https://github.com/RooCodeInc/Roomote/pull/1896', + title: '[Improve] Keep Session artifacts in execution details', + body: 'Open artifacts inside the execution-details panel without leaving the Session.', + merge_commit_sha: payload.after, + base: { ref: 'develop' }, + changed_files: 2, + additions: 271, + deletions: 100, + }, + }); + const listFiles = vi.fn().mockResolvedValue({ + data: [ + { + filename: + 'apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx', + status: 'modified', + additions: 92, + deletions: 9, + }, + ], + }); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { listPullRequestsAssociatedWithCommit }, + pulls: { get, listFiles }, + }, + }); + + const enriched = await enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }); + + expect(listPullRequestsAssociatedWithCommit).toHaveBeenCalledOnce(); + expect(get).toHaveBeenCalledWith({ + owner: 'RooCodeInc', + repo: 'Roomote', + pull_number: 1896, + }); + expect(enriched.pullRequest).toEqual({ + number: 1896, + url: 'https://github.com/RooCodeInc/Roomote/pull/1896', + title: '[Improve] Keep Session artifacts in execution details', + body: 'Open artifacts inside the execution-details panel without leaving the Session.', + changedFileCount: 2, + additions: 271, + deletions: 100, + changedFiles: [ + { + path: 'apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx', + status: 'modified', + additions: 92, + deletions: 9, + }, + ], + }); + }, + ); + + it('rejects a PR number from the tip commit when its merge SHA does not match', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { id: 1, full_name: 'acme/widgets' }, + commits: [{ id: 'abcdef1234567890', message: 'Ship widget (#7)' }], + }; + const event = normalizeGitHubPush(payload)!; + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { + listPullRequestsAssociatedWithCommit: vi + .fn() + .mockResolvedValue({ data: [] }), + }, + pulls: { + get: vi.fn().mockResolvedValue({ + data: { + merge_commit_sha: 'different-sha', + base: { ref: 'main' }, + }, + }), + listFiles: vi.fn(), + }, + }, + }); + + await expect( + enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }), + ).resolves.toBe(event); + }); + + it('does not infer a PR number when GitHub returns associated candidates', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { id: 1, full_name: 'acme/widgets' }, + commits: [{ id: 'abcdef1234567890', message: 'Ship widget (#9)' }], + }; + const event = normalizeGitHubPush(payload)!; + const get = vi.fn().mockResolvedValue({ + data: { + merge_commit_sha: 'different-sha', + base: { ref: 'main' }, + }, + }); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { + listPullRequestsAssociatedWithCommit: vi.fn().mockResolvedValue({ + data: [{ number: 7, base: { ref: 'main' } }], + }), + }, + pulls: { get, listFiles: vi.fn() }, + }, + }); + + await expect( + enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }), + ).resolves.toBe(event); + expect(get).toHaveBeenCalledTimes(1); + expect(get).toHaveBeenCalledWith({ + owner: 'acme', + repo: 'widgets', + pull_number: 7, + }); + }); + + it('does not infer a PR number from a commit other than the pushed tip', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { id: 1, full_name: 'acme/widgets' }, + commits: [{ id: 'different-sha', message: 'Ship widget (#7)' }], + }; + const event = normalizeGitHubPush(payload)!; + const get = vi.fn(); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { + listPullRequestsAssociatedWithCommit: vi + .fn() + .mockResolvedValue({ data: [] }), + }, + pulls: { get, listFiles: vi.fn() }, + }, + }); + + await expect( + enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }), + ).resolves.toBe(event); + expect(get).not.toHaveBeenCalled(); + }); + + it('keeps commit-only context when GitHub PR enrichment fails', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { + id: 1, + full_name: 'acme/widgets', + html_url: 'https://github.com/acme/widgets', + }, + commits: [{ id: 'abcdef1234567890', message: 'Ship widget' }], + }; + const event = normalizeGitHubPush(payload)!; + const getInstallationOctokit = vi + .fn() + .mockRejectedValue(new Error('GitHub unavailable')); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + await expect( + enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }), + ).resolves.toBe(event); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve merged pull request'), + ); + + warn.mockRestore(); + }); + + it('keeps verified PR metadata and stats when changed files are unavailable', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { id: 1, full_name: 'acme/widgets' }, + commits: [{ id: 'abcdef1234567890', message: 'Merge pull request #7' }], + }; + const event = normalizeGitHubPush(payload)!; + const listFiles = vi.fn().mockRejectedValue(new Error('files unavailable')); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { + listPullRequestsAssociatedWithCommit: vi.fn().mockResolvedValue({ + data: [{ number: 7, state: 'closed', base: { ref: 'main' } }], + }), + }, + pulls: { + get: vi.fn().mockResolvedValue({ + data: { + number: 7, + html_url: 'https://github.com/acme/widgets/pull/7', + title: 'Ship widget export', + body: 'Detailed rationale', + merged_at: '2026-08-29T12:00:00Z', + merge_commit_sha: payload.after, + base: { ref: 'main' }, + changed_files: 24, + additions: 120, + deletions: 15, + }, + }), + listFiles, + }, + }, + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + const enriched = await enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }); + + expect(enriched.pullRequest).toEqual({ + number: 7, + url: 'https://github.com/acme/widgets/pull/7', + title: 'Ship widget export', + body: 'Detailed rationale', + changedFileCount: 24, + additions: 120, + deletions: 15, + }); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch changed files'), + ); + warn.mockRestore(); + }); + + it('rejects associated PRs whose merge SHA is not the pushed tip', async () => { + const payload = { + ref: 'refs/heads/main', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { id: 1, full_name: 'acme/widgets' }, + commits: [{ id: 'abcdef1234567890', message: 'Ship widget' }], + }; + const event = normalizeGitHubPush(payload)!; + const listFiles = vi.fn(); + const getInstallationOctokit = vi.fn().mockResolvedValue({ + rest: { + repos: { + listPullRequestsAssociatedWithCommit: vi.fn().mockResolvedValue({ + data: [ + { number: 7, state: 'closed', base: { ref: 'main' } }, + { number: 8, state: 'closed', base: { ref: 'main' } }, + ], + }), + }, + pulls: { + get: vi + .fn() + .mockResolvedValueOnce({ + data: { + merge_commit_sha: 'different-sha', + base: { ref: 'main' }, + }, + }) + .mockResolvedValueOnce({ + data: { + merge_commit_sha: payload.after, + base: { ref: 'release' }, + }, + }), + listFiles, + }, + }, + }); + + await expect( + enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }), + ).resolves.toBe(event); + expect(listFiles).not.toHaveBeenCalled(); + }); + + it('does not query PR associations for non-default GitHub branches', async () => { + const payload = { + ref: 'refs/heads/feature/widget', + after: 'abcdef1234567890', + installation: { id: 99 }, + repository: { + id: 1, + full_name: 'acme/widgets', + default_branch: 'main', + }, + commits: [{ id: 'abcdef1234567890', message: 'Ship widget' }], + }; + const event = normalizeGitHubPush(payload)!; + const getInstallationOctokit = vi.fn(); + + await expect( + enrichGitHubMergeAnnouncerEvent(payload, event, { + getInstallationOctokit: getInstallationOctokit as never, + }), + ).resolves.toBe(event); + expect(getInstallationOctokit).not.toHaveBeenCalled(); + }); + + it('normalizes GitLab pushes and branch deletion', () => { + const payload = gitLabPushWebhookSchema.parse({ + object_kind: 'push', + ref: 'refs/heads/main', + after: '0000000000000000000000000000000000000000', + user_username: 'gitlab-user', + project: { + id: 2, + path_with_namespace: 'acme/widgets', + web_url: 'https://gitlab.example.com/acme/widgets', + }, + commits: [ + { + id: 'def', + message: 'Update widget', + author: { name: 'Dana', email: 'dana@example.com' }, + }, + ], + }); + + expect(normalizeGitLabPush(payload)).toMatchObject({ + provider: 'gitlab', + deleted: true, + pusher: 'gitlab-user', + repository: { externalId: '2', host: 'gitlab.example.com' }, + commits: [{ author: { email: 'dana@example.com' } }], + }); + }); + + it('normalizes Gitea pusher and author usernames', () => { + const payload = giteaPushWebhookSchema.parse({ + ref: 'refs/heads/main', + pusher: { username: 'gitea-user' }, + repository: { + id: 3, + full_name: 'acme/widgets', + html_url: 'https://gitea.example.com/acme/widgets', + }, + commits: [ + { + id: 'ghi', + message: 'Refine widget', + author: { username: 'erin', name: 'Erin' }, + }, + ], + }); + + expect(normalizeGiteaPush(payload)).toMatchObject({ + provider: 'gitea', + pusher: 'gitea-user', + repository: { externalId: '3', host: 'gitea.example.com' }, + commits: [{ author: { username: 'erin' } }], + }); + }); + + it('normalizes Bitbucket branch changes and excludes tag changes', () => { + const payload = bitbucketPushWebhookSchema.parse({ + actor: { nickname: 'bitbucket-user' }, + repository: { + uuid: '{repo-4}', + full_name: 'acme/widgets', + links: { html: { href: 'https://bitbucket.org/acme/widgets' } }, + }, + push: { + changes: [ + { + new: { name: 'main', type: 'branch' }, + commits: [ + { + hash: 'jkl', + message: 'Polish widget', + author: { + raw: 'Frank ', + user: { nickname: 'frank' }, + }, + }, + ], + }, + { new: { name: 'v1.0.0', type: 'tag' }, commits: [] }, + ], + }, + }); + + expect(normalizeBitbucketPush(payload)).toEqual([ + expect.objectContaining({ + provider: 'bitbucket', + ref: 'refs/heads/main', + pusher: 'bitbucket-user', + repository: { + externalId: '{repo-4}', + fullName: 'acme/widgets', + host: 'bitbucket.org', + htmlUrl: 'https://bitbucket.org/acme/widgets', + }, + commits: [ + expect.objectContaining({ + author: expect.objectContaining({ username: 'frank' }), + }), + ], + }), + ]); + }); + + it('normalizes Azure DevOps ref updates and pushed-by attribution', () => { + const payload = adoPushWebhookSchema.parse({ + id: 'delivery-5', + eventType: 'git.push', + resourceContainers: { + account: { baseUrl: 'https://dev.azure.com/acme/' }, + }, + resource: { + repository: { + id: 'repo-5', + name: 'widgets', + project: { id: 'project-1', name: 'platform' }, + webUrl: 'https://dev.azure.com/acme/platform/_git/widgets', + }, + refUpdates: [{ name: 'refs/heads/main' }], + pushedBy: { displayName: 'Grace Hopper' }, + commits: [ + { + commitId: 'mno', + comment: 'Document widget', + author: { name: 'Heidi', email: 'heidi@example.com' }, + }, + ], + }, + }); + + expect(normalizeAdoPush(payload)).toEqual([ + expect.objectContaining({ + provider: 'ado', + ref: 'refs/heads/main', + pusher: 'Grace Hopper', + repository: expect.objectContaining({ + externalId: 'repo-5', + host: 'dev.azure.com', + }), + commits: [ + expect.objectContaining({ + author: expect.objectContaining({ + name: 'Heidi', + email: 'heidi@example.com', + }), + }), + ], + }), + ]); + }); +}); diff --git a/apps/api/src/handlers/ado/__tests__/handlePullRequest.test.ts b/apps/api/src/handlers/ado/__tests__/handlePullRequest.test.ts index 9777a5342..fd62054da 100644 --- a/apps/api/src/handlers/ado/__tests__/handlePullRequest.test.ts +++ b/apps/api/src/handlers/ado/__tests__/handlePullRequest.test.ts @@ -491,6 +491,9 @@ describe('handleAdoPullRequest', () => { 42, 'merged', ); + expect(mockRecordPrStatusChangeInTaskHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ targetBranch: 'main' }), + ); expect(mockScheduleSourceControlPullRequestFactSync).toHaveBeenCalledWith({ provider: 'ado', repositoryFullName: 'acme/Platform/backend', diff --git a/apps/api/src/handlers/ado/__tests__/index.test.ts b/apps/api/src/handlers/ado/__tests__/index.test.ts index 81ab572a8..1908df034 100644 --- a/apps/api/src/handlers/ado/__tests__/index.test.ts +++ b/apps/api/src/handlers/ado/__tests__/index.test.ts @@ -5,6 +5,7 @@ const { mockHandleAdoPullRequest, mockHandleAdoWorkItemComment, mockHandleAdoBuild, + mockHandleMergeAnnouncerPush, mockRecordWebhook, mockResolveDeploymentEnvVar, } = vi.hoisted(() => ({ @@ -12,6 +13,7 @@ const { mockHandleAdoPullRequest: vi.fn(), mockHandleAdoWorkItemComment: vi.fn(), mockHandleAdoBuild: vi.fn(), + mockHandleMergeAnnouncerPush: vi.fn(), mockRecordWebhook: vi.fn(), mockResolveDeploymentEnvVar: vi.fn(), })); @@ -20,6 +22,10 @@ vi.mock('@roomote/db/server', () => ({ resolveDeploymentEnvVar: mockResolveDeploymentEnvVar, })); +vi.mock('@roomote/sdk/server', () => ({ + handleMergeAnnouncerPush: mockHandleMergeAnnouncerPush, +})); + vi.mock('../../logging', () => ({ apiLogger: { debug: vi.fn(), @@ -56,6 +62,7 @@ describe('ado webhook router', () => { mockHandleAdoPullRequest.mockReset(); mockHandleAdoWorkItemComment.mockReset(); mockHandleAdoBuild.mockReset(); + mockHandleMergeAnnouncerPush.mockReset(); mockRecordWebhook.mockReset(); mockResolveDeploymentEnvVar.mockReset(); mockResolveDeploymentEnvVar.mockImplementation(async (name: string) => @@ -65,6 +72,7 @@ describe('ado webhook router', () => { mockHandleAdoPullRequest.mockResolvedValue({ status: 'ok' }); mockHandleAdoWorkItemComment.mockResolvedValue({ status: 'ok' }); mockHandleAdoBuild.mockResolvedValue({ status: 'ok' }); + mockHandleMergeAnnouncerPush.mockResolvedValue({ status: 'ok' }); mockRecordWebhook.mockImplementation( async ( _deliveryId: string, @@ -134,6 +142,45 @@ describe('ado webhook router', () => { ); }); + it('records and routes normalized git.push webhooks', async () => { + const payload = { + id: 'push-delivery', + eventType: 'git.push', + resource: { + repository: { + id: 'repo-1', + name: 'backend', + project: { id: 'project-1', name: 'Platform' }, + webUrl: 'https://dev.azure.com/acme/Platform/_git/backend', + }, + refUpdates: [{ name: 'refs/heads/main' }], + pushedBy: { displayName: 'Alice' }, + commits: [{ id: 'abc', message: 'Ship backend' }], + }, + }; + + const response = await app.request('http://localhost/api/webhooks/ado', { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Basic YW55OmFkby1zZWNyZXQ=', + }, + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(200); + expect(mockRecordWebhook).toHaveBeenCalledWith( + 'push-delivery', + 'git.push', + expect.anything(), + expect.any(Function), + { provider: 'ado' }, + ); + expect(mockHandleMergeAnnouncerPush).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'ado', pusher: 'Alice' }), + ); + }); + it('passes updated notification type hints from service-hook URLs', async () => { const payload = { id: 'delivery-2', diff --git a/apps/api/src/handlers/ado/handlePullRequest.ts b/apps/api/src/handlers/ado/handlePullRequest.ts index cc588b4f5..a203a0ee0 100644 --- a/apps/api/src/handlers/ado/handlePullRequest.ts +++ b/apps/api/src/handlers/ado/handlePullRequest.ts @@ -262,6 +262,7 @@ export async function handleAdoPullRequest( pullRequest, repositoryFullName: repoFullName, }), + targetBranch: stripAdoGitRefPrefix(pullRequest.targetRefName), status: 'closed', actorLogin: getAdoIdentityName(payload.resource.closedBy) ?? @@ -308,6 +309,7 @@ export async function handleAdoPullRequest( pullRequest, repositoryFullName: repoFullName, }), + targetBranch: stripAdoGitRefPrefix(pullRequest.targetRefName), status: 'merged', actorLogin: getAdoIdentityName(payload.resource.closedBy) ?? diff --git a/apps/api/src/handlers/ado/index.ts b/apps/api/src/handlers/ado/index.ts index e819f4a9a..853f71e40 100644 --- a/apps/api/src/handlers/ado/index.ts +++ b/apps/api/src/handlers/ado/index.ts @@ -3,9 +3,11 @@ import { createHash } from 'node:crypto'; import { Hono } from 'hono'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { handleMergeAnnouncerPush } from '@roomote/sdk/server'; import { apiLogger, logApiError } from '../../logging'; import { recordWebhook } from '../github/recordWebhook'; +import { normalizeAdoPush } from '../merge-announcer-push'; import { type AdoUpdatedNotificationType, handleAdoPullRequest, @@ -18,6 +20,7 @@ import { adoBuildCompleteWebhookSchema, adoPullRequestCommentWebhookSchema, adoPullRequestWebhookSchema, + adoPushWebhookSchema, adoWorkItemCommentedWebhookSchema, } from './types'; import { verifyAdoWebhook } from './verifyWebhook'; @@ -32,6 +35,7 @@ const ADO_PULL_REQUEST_COMMENT_EVENT = 'ms.vss-code.git-pullrequest-comment-event'; const ADO_WORK_ITEM_COMMENTED_EVENT = 'workitem.commented'; const ADO_BUILD_COMPLETE_EVENT = 'build.complete'; +const ADO_PUSH_EVENT = 'git.push'; function getAdoUpdatedNotificationType( value: string | undefined, @@ -146,6 +150,30 @@ ado.post('/', async (c) => { return c.json({ message: 'webhook_processed' }); } + if (eventName === ADO_PUSH_EVENT) { + const payload = adoPushWebhookSchema.parse(parsedJson); + + await recordWebhook( + deliveryId, + eventName, + payload, + async () => { + const results = await Promise.all( + normalizeAdoPush(payload).map((event) => + handleMergeAnnouncerPush(event), + ), + ); + return ( + results.find((result) => result.status === 'error') ?? + results[0] ?? { status: 'ok', message: 'No branch updates' } + ); + }, + { provider: 'ado' }, + ); + + return c.json({ message: 'webhook_processed' }); + } + if (!ADO_PULL_REQUEST_EVENTS.has(eventName)) { await recordWebhook( deliveryId, diff --git a/apps/api/src/handlers/ado/types.ts b/apps/api/src/handlers/ado/types.ts index ba0617a70..1f42a8e6e 100644 --- a/apps/api/src/handlers/ado/types.ts +++ b/apps/api/src/handlers/ado/types.ts @@ -251,3 +251,46 @@ export const adoBuildCompleteWebhookSchema = z export type AdoBuildCompleteWebhook = z.infer< typeof adoBuildCompleteWebhookSchema >; + +const adoPushCommitSchema = z + .object({ + id: z.string().optional(), + commitId: z.string().optional(), + message: z.string().optional(), + comment: z.string().optional(), + url: z.string().optional(), + author: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + displayName: z.string().optional(), + uniqueName: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const adoRefUpdateSchema = z + .object({ + name: z.string(), + isDelete: z.boolean().optional(), + newObjectId: z.string().optional(), + }) + .passthrough(); + +export const adoPushWebhookSchema = z + .object({ + resource: z + .object({ + repository: adoRepositorySchema, + refUpdates: z.array(adoRefUpdateSchema), + commits: z.array(adoPushCommitSchema), + pushedBy: adoIdentitySchema.optional(), + createdBy: adoIdentitySchema.optional(), + }) + .passthrough(), + }) + .merge(adoWebhookBaseSchema); + +export type AdoPushWebhook = z.infer; diff --git a/apps/api/src/handlers/bitbucket/__tests__/handlePullRequest.test.ts b/apps/api/src/handlers/bitbucket/__tests__/handlePullRequest.test.ts index c53ff24ca..5b4a9505f 100644 --- a/apps/api/src/handlers/bitbucket/__tests__/handlePullRequest.test.ts +++ b/apps/api/src/handlers/bitbucket/__tests__/handlePullRequest.test.ts @@ -259,6 +259,9 @@ describe('handleBitbucketPullRequest', () => { 42, 'merged', ); + expect(mockRecordPrStatusChangeInTaskHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ targetBranch: 'main' }), + ); expect(mockScheduleSourceControlPullRequestFactSync).toHaveBeenCalledWith({ provider: 'bitbucket', repositoryFullName: 'acme/backend', diff --git a/apps/api/src/handlers/bitbucket/__tests__/index.test.ts b/apps/api/src/handlers/bitbucket/__tests__/index.test.ts index 5cb5e5ca5..c6d0bc42f 100644 --- a/apps/api/src/handlers/bitbucket/__tests__/index.test.ts +++ b/apps/api/src/handlers/bitbucket/__tests__/index.test.ts @@ -18,6 +18,7 @@ const handleBitbucketComment = vi.fn(async () => ({ status: 'ok' as const })); const handleBitbucketCommitStatus = vi.fn(async () => ({ status: 'ok' as const, })); +const handleMergeAnnouncerPush = vi.fn(async () => ({ status: 'ok' as const })); const resolveDeploymentEnvVar = vi.fn(); vi.mock('../../github/recordWebhook', () => ({ @@ -49,6 +50,12 @@ vi.mock('@roomote/db/server', () => ({ ): ReturnType => resolveDeploymentEnvVar(...args), })); +vi.mock('@roomote/sdk/server', () => ({ + handleMergeAnnouncerPush: ( + ...args: Parameters + ): ReturnType => + handleMergeAnnouncerPush(...args), +})); function sign(body: string): string { return createHmac('sha256', 'bitbucket-secret').update(body).digest('hex'); @@ -61,6 +68,7 @@ describe('bitbucket webhook router', () => { handleBitbucketPullRequest.mockClear(); handleBitbucketComment.mockClear(); handleBitbucketCommitStatus.mockClear(); + handleMergeAnnouncerPush.mockClear(); resolveDeploymentEnvVar.mockImplementation(async (name: string) => name === 'BITBUCKET_WEBHOOK_SECRET' ? 'bitbucket-secret' : null, ); @@ -112,6 +120,51 @@ describe('bitbucket webhook router', () => { ); }); + it('routes normalized repo:push events', async () => { + const body = JSON.stringify({ + actor: { nickname: 'alice' }, + repository: { + full_name: 'ws/repo', + uuid: '{uuid}', + links: { html: { href: 'https://bitbucket.org/ws/repo' } }, + }, + push: { + changes: [ + { + new: { name: 'main', type: 'branch' }, + commits: [{ hash: 'abc', message: 'Ship backend' }], + }, + ], + }, + }); + const app = await mountApp(); + const response = await app.request( + 'http://localhost/api/webhooks/bitbucket', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-hub-signature-256': `sha256=${sign(body)}`, + 'x-event-key': 'repo:push', + 'x-request-uuid': 'push-delivery', + }, + body, + }, + ); + + expect(response.status).toBe(200); + expect(recordWebhook).toHaveBeenCalledWith( + 'push-delivery', + 'repo:push', + expect.anything(), + expect.any(Function), + { provider: 'bitbucket' }, + ); + expect(handleMergeAnnouncerPush).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'bitbucket', pusher: 'alice' }), + ); + }); + it('routes pullrequest:comment_created events', async () => { const body = JSON.stringify({ pullrequest: { diff --git a/apps/api/src/handlers/bitbucket/handlePullRequest.ts b/apps/api/src/handlers/bitbucket/handlePullRequest.ts index 4121d8987..f3fdbbfea 100644 --- a/apps/api/src/handlers/bitbucket/handlePullRequest.ts +++ b/apps/api/src/handlers/bitbucket/handlePullRequest.ts @@ -138,6 +138,7 @@ export async function handleBitbucketPullRequest( prNumber, prTitle: pullRequest.title, prUrl: getBitbucketPullRequestUrl(payload), + targetBranch: getBitbucketPullRequestBaseRef(pullRequest), status, actorLogin: getBitbucketUsername(payload.actor) ?? 'someone on Bitbucket', diff --git a/apps/api/src/handlers/bitbucket/index.ts b/apps/api/src/handlers/bitbucket/index.ts index fc4587486..ddc014276 100644 --- a/apps/api/src/handlers/bitbucket/index.ts +++ b/apps/api/src/handlers/bitbucket/index.ts @@ -3,9 +3,11 @@ import { createHash } from 'node:crypto'; import { Hono } from 'hono'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { handleMergeAnnouncerPush } from '@roomote/sdk/server'; import { apiLogger, logApiError } from '../../logging'; import { recordWebhook } from '../github/recordWebhook'; +import { normalizeBitbucketPush } from '../merge-announcer-push'; import { handleBitbucketComment } from './handleComment'; import { handleBitbucketCommitStatus } from './handleCommitStatus'; import { handleBitbucketPullRequest } from './handlePullRequest'; @@ -13,6 +15,7 @@ import { bitbucketCommitStatusWebhookSchema, bitbucketPullRequestCommentWebhookSchema, bitbucketPullRequestWebhookSchema, + bitbucketPushWebhookSchema, } from './types'; import { verifyBitbucketWebhook } from './verifyWebhook'; @@ -34,6 +37,7 @@ const BITBUCKET_COMMIT_STATUS_EVENTS = new Set([ 'repo:commit_status_created', 'repo:commit_status_updated', ]); +const BITBUCKET_PUSH_EVENT = 'repo:push'; function getBitbucketDeliveryId({ body, @@ -106,6 +110,30 @@ bitbucket.post('/', async (c) => { return c.json({ message: 'webhook_processed' }); } + if (eventName === BITBUCKET_PUSH_EVENT) { + const payload = bitbucketPushWebhookSchema.parse(parsedJson); + + await recordWebhook( + deliveryId, + eventName, + payload, + async () => { + const results = await Promise.all( + normalizeBitbucketPush(payload).map((event) => + handleMergeAnnouncerPush(event), + ), + ); + return ( + results.find((result) => result.status === 'error') ?? + results[0] ?? { status: 'ok', message: 'No branch updates' } + ); + }, + { provider: 'bitbucket' }, + ); + + return c.json({ message: 'webhook_processed' }); + } + if (!BITBUCKET_PULLREQUEST_EVENTS.has(eventName)) { await recordWebhook( deliveryId, diff --git a/apps/api/src/handlers/bitbucket/types.ts b/apps/api/src/handlers/bitbucket/types.ts index e0340f2f3..7b7ee7201 100644 --- a/apps/api/src/handlers/bitbucket/types.ts +++ b/apps/api/src/handlers/bitbucket/types.ts @@ -156,6 +156,58 @@ export type BitbucketCommitStatusWebhook = z.infer< typeof bitbucketCommitStatusWebhookSchema >; +const bitbucketPushCommitSchema = z + .object({ + hash: z.string(), + message: z.string(), + links: z + .object({ html: bitbucketHtmlLinkSchema.optional() }) + .passthrough() + .optional(), + author: z + .object({ + raw: z.string().optional(), + user: bitbucketUserSchema.optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const bitbucketPushRefSchema = z + .object({ + name: z.string(), + type: z.string().optional(), + }) + .passthrough(); + +export const bitbucketPushWebhookSchema = z + .object({ + repository: bitbucketRepositorySchema, + actor: bitbucketUserSchema.optional(), + push: z + .object({ + changes: z.array( + z + .object({ + old: bitbucketPushRefSchema.nullable().optional(), + new: bitbucketPushRefSchema.nullable().optional(), + closed: z.boolean().optional(), + commits: z.array(bitbucketPushCommitSchema).optional(), + links: z + .object({ html: bitbucketHtmlLinkSchema.optional() }) + .passthrough() + .optional(), + }) + .passthrough(), + ), + }) + .passthrough(), + }) + .passthrough(); + +export type BitbucketPushWebhook = z.infer; + export function getBitbucketPullRequestNumber( pullRequest: BitbucketPullRequestWebhook['pullrequest'], ): number { diff --git a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts index 13d2d27c0..4b0c5407a 100644 --- a/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts +++ b/apps/api/src/handlers/brain-inference/__tests__/brain-inference.test.ts @@ -6,11 +6,13 @@ const { mockGetBrainGatewayToken, mockResolveBrainInferenceProvider, mockMapBrainModelName, + mockGenerateTrackedNonTaskText, mockEnv, } = vi.hoisted(() => ({ mockGetBrainGatewayToken: vi.fn(), mockResolveBrainInferenceProvider: vi.fn(), mockMapBrainModelName: vi.fn(), + mockGenerateTrackedNonTaskText: vi.fn(), mockEnv: {} as Record, })); @@ -22,7 +24,12 @@ vi.mock('@roomote/sdk/server', () => ({ mapBrainModelName: mockMapBrainModelName, })); -const { brainInference } = await import('../index'); +vi.mock('@roomote/cloud-agents/server/non-task-provider-usage', () => ({ + generateTrackedNonTaskText: mockGenerateTrackedNonTaskText, + NON_TASK_INFERENCE_SURFACES: { brainSynthesis: 'brain_synthesis' }, +})); + +const { brainInference, BRAIN_HELPER_MODEL_ID } = await import('../index'); const GATEWAY_TOKEN = 'brain-gateway-token-value-0123456789'; @@ -146,57 +153,6 @@ describe('brain inference gateway', () => { }); }); - it('routes reranking through OpenRouter without exposing its key to gbrain', async () => { - const fetchMock = vi.fn( - async (_url: string, _init: RequestInit) => - new Response(JSON.stringify({ results: [] }), { status: 200 }), - ); - vi.stubGlobal('fetch', fetchMock); - - const body = { - model: 'cohere/rerank-v3.5', - query: 'Which result is relevant?', - documents: ['relevant', 'unrelated'], - top_n: 2, - }; - const response = await post('/v1/rerank', { - token: GATEWAY_TOKEN, - body, - }); - - expect(response.status).toBe(200); - const [url, init] = fetchMock.mock.calls[0]!; - expect(url).toBe('https://openrouter.ai/api/v1/rerank'); - expect((init.headers as Headers).get('authorization')).toBe( - `Bearer ${OPENROUTER.apiKey}`, - ); - expect(JSON.parse(init.body as string)).toEqual(body); - }); - - it('reports reranking as unavailable when only OpenAI is configured', async () => { - mockResolveBrainInferenceProvider.mockResolvedValue({ - providerId: 'openai', - apiKey: 'sk-openai-provider-key', - }); - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - - const response = await post('/v1/rerank', { - token: GATEWAY_TOKEN, - body: { - model: 'cohere/rerank-v3.5', - query: 'query', - documents: ['document'], - }, - }); - - expect(response.status).toBe(503); - await expect(response.json()).resolves.toMatchObject({ - error: expect.stringContaining('OpenRouter'), - }); - expect(fetchMock).not.toHaveBeenCalled(); - }); - it('surfaces an unreachable provider as 502 rather than a crash', async () => { vi.stubGlobal( 'fetch', @@ -265,27 +221,35 @@ describe('local inference upstreams', () => { expect(mockResolveBrainInferenceProvider).not.toHaveBeenCalled(); }); - it('allows rerank without OpenRouter when a rerank upstream is set', async () => { - mockEnv.R_BRAIN_RERANK_UPSTREAM_URL = 'http://infinity:7997/'; - mockResolveBrainInferenceProvider.mockResolvedValue({ - providerId: 'openai' as const, - apiKey: 'sk-openai', + it('rejects the removed rerank path like any other unlisted path', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/rerank', { + token: GATEWAY_TOKEN, + body: { model: 'bge-reranker-base', query: 'q', documents: ['a'] }, }); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('keeps the trailing slash on a configured upstream from doubling up', async () => { + mockEnv.R_BRAIN_EMBEDDINGS_UPSTREAM_URL = 'http://infinity:7997/'; const fetchMock = vi .fn() - .mockResolvedValue( - new Response(JSON.stringify({ results: [] }), { status: 200 }), - ); + .mockResolvedValue(new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); - const response = await post('/v1/rerank', { + const response = await post('/v1/embeddings', { token: GATEWAY_TOKEN, - body: { model: 'bge-reranker-base', query: 'q', documents: ['a'] }, + body: { model: 'bge-small-en-v1.5', input: ['a'] }, }); expect(response.status).toBe(200); - // Trailing slash on the configured URL must not double up. - expect(fetchMock.mock.calls[0]![0]).toBe('http://infinity:7997/v1/rerank'); + expect(fetchMock.mock.calls[0]![0]).toBe( + 'http://infinity:7997/v1/embeddings', + ); }); it('sends no authorization header when the upstream has no key', async () => { @@ -306,7 +270,6 @@ describe('local inference upstreams', () => { it('keeps chat on the provider even when upstreams are configured', async () => { mockEnv.R_BRAIN_EMBEDDINGS_UPSTREAM_URL = 'http://infinity:7997'; - mockEnv.R_BRAIN_RERANK_UPSTREAM_URL = 'http://infinity:7997'; const fetchMock = vi .fn() .mockResolvedValue(new Response('{}', { status: 200 })); @@ -332,3 +295,185 @@ describe('local inference upstreams', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('helper-model synthesis', () => { + it('answers the sentinel with the deployment helper model, never a provider', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + mockGenerateTrackedNonTaskText.mockResolvedValue('a sourced answer'); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + max_tokens: 512, + messages: [ + { role: 'system', content: 'You cite sources.' }, + { role: 'user', content: 'What changed last week?' }, + { role: 'assistant', content: 'Let me look.' }, + { + role: 'user', + content: [ + { type: 'text', text: 'Focus on the API.' }, + { type: 'image_url', image_url: { url: 'ignored' } }, + ], + }, + ], + }, + }); + + expect(response.status).toBe(200); + const payload = (await response.json()) as Record; + expect(payload).toMatchObject({ + object: 'chat.completion', + model: BRAIN_HELPER_MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'a sourced answer' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }); + expect(payload.id).toMatch(/^brain-helper-/); + + expect(mockGenerateTrackedNonTaskText).toHaveBeenCalledExactlyOnceWith({ + surface: 'brain_synthesis', + modelRole: 'small', + system: 'You cite sources.', + prompt: + 'What changed last week?\n\nAssistant: Let me look.\n\nFocus on the API.', + maxOutputTokens: 512, + timeoutMs: 120_000, + }); + + // No provider must be involved: this path exists for deployments with no + // Brain provider key at all. + expect(mockResolveBrainInferenceProvider).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects streaming sentinel requests instead of faking SSE', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + stream: true, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: expect.stringContaining('streaming'), + }); + expect(mockGenerateTrackedNonTaskText).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('translates response_format into a strict JSON instruction', async () => { + mockGenerateTrackedNonTaskText.mockResolvedValue('{"ok":true}'); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + messages: [{ role: 'user', content: 'summarize' }], + response_format: { + type: 'json_schema', + json_schema: { name: 'summary', schema: { type: 'object' } }, + }, + }, + }); + + expect(response.status).toBe(200); + const call = mockGenerateTrackedNonTaskText.mock.calls[0]![0] as { + system?: string; + }; + expect(call.system).toContain('only valid JSON'); + expect(call.system).toContain('"type":"object"'); + }); + + it('reports a helper failure as 502 without a stack', async () => { + mockGenerateTrackedNonTaskText.mockRejectedValue( + new Error('Model configuration is required\nfor non-task model calls.'), + ); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.status).toBe(502); + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('Brain helper-model synthesis failed'); + expect(payload.error).not.toContain('\n'); + }); + + it('forwards to the provider when R_BRAIN_MODEL overrides the sentinel', async () => { + mockEnv.R_BRAIN_MODEL = 'openai/gpt-5.6-mini'; + const fetchMock = vi.fn( + async (_url: string, _init: RequestInit) => + new Response(JSON.stringify({ choices: [] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: BRAIN_HELPER_MODEL_ID, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.status).toBe(200); + expect(mockGenerateTrackedNonTaskText).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const init = fetchMock.mock.calls[0]![1]; + expect(JSON.parse(init.body as string).model).toBe('openai/gpt-5.6-mini'); + }); + + it('answers non-sentinel chat with the helper model when no provider key exists', async () => { + mockResolveBrainInferenceProvider.mockResolvedValue(null); + mockGenerateTrackedNonTaskText.mockResolvedValue('expanded query'); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const response = await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { + model: 'gpt-5.2', + messages: [{ role: 'user', content: 'expand this' }], + }, + }); + + expect(response.status).toBe(200); + const payload = (await response.json()) as { id: string; model: string }; + expect(payload.id).toMatch(/^brain-helper-/); + expect(mockGenerateTrackedNonTaskText).toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('leaves non-sentinel chat models on the provider path', async () => { + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ choices: [] }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + + await post('/v1/chat/completions', { + token: GATEWAY_TOKEN, + body: { model: 'openai/gpt-5.6-luna', messages: [] }, + }); + + expect(mockGenerateTrackedNonTaskText).not.toHaveBeenCalled(); + expect(mockResolveBrainInferenceProvider).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/brain-inference/index.ts b/apps/api/src/handlers/brain-inference/index.ts index 398a507aa..69c37e607 100644 --- a/apps/api/src/handlers/brain-inference/index.ts +++ b/apps/api/src/handlers/brain-inference/index.ts @@ -1,7 +1,11 @@ -import { timingSafeEqual } from 'node:crypto'; +import { randomUUID, timingSafeEqual } from 'node:crypto'; import { Hono } from 'hono'; +import { + generateTrackedNonTaskText, + NON_TASK_INFERENCE_SURFACES, +} from '@roomote/cloud-agents/server/non-task-provider-usage'; import { Env } from '@roomote/env'; import { @@ -20,14 +24,33 @@ import type { Variables } from '../../types'; const LOG_PREFIX = '[Brain Inference]'; /** - * The Brain's whole inference surface: embeddings for recall, reranking for - * precision, and chat for sourced synthesis and query expansion. Deliberately - * narrower than the task-sandbox gateway's allowlist, because this credential - * is a static deployment secret rather than a short-lived run token. + * Sentinel chat-model id the Brain requests in gateway mode. It is not a real + * provider model: the gateway answers it itself through the deployment's + * helper ("small") model, which is what lets a Brain synthesize without any + * Brain-specific provider key. An operator's `R_BRAIN_MODEL` still wins — the + * sentinel is only the default the gbrain entrypoint configures. + */ +export const BRAIN_HELPER_MODEL_ID = 'roomote/helper'; + +/** How long a helper-model synthesis call may run before failing the request. */ +const HELPER_SYNTHESIS_TIMEOUT_MS = 120_000; + +/** + * gbrain caps its own synthesis output; when it does not say, stay modest — + * the helper model is a summarizer, not a long-form writer. + */ +const HELPER_SYNTHESIS_DEFAULT_MAX_OUTPUT_TOKENS = 2048; + +/** + * The Brain's whole inference surface: embeddings for recall and chat for + * sourced synthesis and query expansion. Deliberately narrower than the + * task-sandbox gateway's allowlist, because this credential is a static + * deployment secret rather than a short-lived run token. Reranking is not + * part of the Brain: retrieval is hybrid RRF, and the reranker is disabled + * per-brain by the gbrain entrypoint. */ const BRAIN_ALLOWED_PATHS = new Set([ '/v1/embeddings', - '/v1/rerank', '/v1/chat/completions', '/v1/responses', ]); @@ -119,13 +142,13 @@ async function rewriteBody( } /** - * A self-run inference upstream for one gateway path. Embeddings and rerank - * are the Brain's bulk data paths (memory text in, vectors/scores out), so - * they are the ones a deployment may want on its own hardware; chat synthesis - * stays with the configured model provider. Model names pass through - * unrewritten — the upstream owns its own model registry, and every Brain is - * locked to its embedding model at creation, so the name must mean exactly - * one thing forever. + * A self-run inference upstream for one gateway path. Embeddings are the + * Brain's bulk data path (memory text in, vectors out), so they are the one + * a deployment may want on its own hardware; chat synthesis stays with the + * configured model provider. Model names pass through unrewritten — the + * upstream owns its own model registry, and every Brain is locked to its + * embedding model at creation, so the name must mean exactly one thing + * forever. */ function resolveLocalUpstream( upstreamPath: string, @@ -133,9 +156,7 @@ function resolveLocalUpstream( const baseUrl = upstreamPath === '/v1/embeddings' ? Env.R_BRAIN_EMBEDDINGS_UPSTREAM_URL - : upstreamPath === '/v1/rerank' - ? Env.R_BRAIN_RERANK_UPSTREAM_URL - : undefined; + : undefined; if (!baseUrl?.trim()) { return null; @@ -147,6 +168,95 @@ function resolveLocalUpstream( }; } +/** + * Flatten OpenAI-style message content to plain text. Array content keeps its + * text parts (joined) and drops the rest; the helper path is text-only. + */ +function messageContentText(content: unknown): string { + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content + .flatMap((part) => { + const record = + part && typeof part === 'object' + ? (part as Record) + : undefined; + + return typeof record?.text === 'string' ? [record.text] : []; + }) + .join('\n'); + } + + return ''; +} + +/** + * Convert an OpenAI chat request into the system/prompt pair + * generateTrackedNonTaskText speaks. System messages concatenate into the + * system string; everything else concatenates in order into the prompt, with + * non-user roles labeled so multi-turn context stays attributable. + */ +function toHelperPromptParts(messages: unknown): { + system: string; + prompt: string; +} { + const systemParts: string[] = []; + const promptParts: string[] = []; + + for (const message of Array.isArray(messages) ? messages : []) { + const record = + message && typeof message === 'object' + ? (message as Record) + : undefined; + const role = typeof record?.role === 'string' ? record.role : 'user'; + const text = messageContentText(record?.content); + + if (!text.trim()) { + continue; + } + + if (role === 'system') { + systemParts.push(text); + } else if (role === 'user') { + promptParts.push(text); + } else { + promptParts.push( + `${role.charAt(0).toUpperCase()}${role.slice(1)}: ${text}`, + ); + } + } + + return { + system: systemParts.join('\n\n'), + prompt: promptParts.join('\n\n'), + }; +} + +/** + * gbrain relies on `response_format` for its structured synthesis calls, but + * the helper path runs through a plain-text prompt; translate the contract + * into a strict instruction instead of dropping it silently. + */ +function jsonResponseInstruction(responseFormat: unknown): string | null { + const record = + responseFormat && typeof responseFormat === 'object' + ? (responseFormat as Record) + : undefined; + + if (record?.type === 'json_object') { + return 'Respond with only valid JSON. No prose, no code fences.'; + } + + if (record?.type === 'json_schema') { + return `Respond with only valid JSON that conforms to this JSON Schema. No prose, no code fences.\n${JSON.stringify(record.json_schema ?? {})}`; + } + + return null; +} + /** * Inference gateway for this deployment's Brain. * @@ -194,6 +304,117 @@ brainInference.post('/*', async (c) => { return c.json({ error: 'Path is not allowed through this gateway' }, 403); } + // The helper-model sentinel is answered here, before provider resolution, + // because it exists precisely for deployments with no Brain provider key: + // synthesis rides the deployment's helper model instead. An operator's + // R_BRAIN_MODEL still wins — the sentinel is rewritten to it and forwarded + // through the ordinary provider path below. + let helperOverrideBody: string | undefined; + + if (upstreamPath === '/v1/chat/completions') { + let parsedBody: Record | undefined; + + try { + const candidate = JSON.parse(await c.req.text()) as unknown; + + parsedBody = + candidate && typeof candidate === 'object' && !Array.isArray(candidate) + ? (candidate as Record) + : undefined; + } catch { + // Not JSON we understand; the provider path forwards it untouched. + } + + const answerWithHelperModel = async (body: Record) => { + if (body.stream === true) { + // gbrain's gateway chat is non-streaming by design; refuse rather + // than pretend an SSE stream that would never come. + return c.json( + { + error: + 'The Brain helper model does not support streaming. Retry without stream.', + }, + 400, + ); + } + + const { system, prompt } = toHelperPromptParts(body.messages); + const jsonInstruction = jsonResponseInstruction(body.response_format); + const systemWithFormat = [system, jsonInstruction] + .filter((part): part is string => Boolean(part)) + .join('\n\n'); + + try { + const text = await generateTrackedNonTaskText({ + surface: NON_TASK_INFERENCE_SURFACES.brainSynthesis, + modelRole: 'small', + system: systemWithFormat || undefined, + prompt, + maxOutputTokens: + typeof body.max_tokens === 'number' && + Number.isFinite(body.max_tokens) && + body.max_tokens > 0 + ? body.max_tokens + : HELPER_SYNTHESIS_DEFAULT_MAX_OUTPUT_TOKENS, + timeoutMs: HELPER_SYNTHESIS_TIMEOUT_MS, + }); + + return c.json({ + id: `brain-helper-${randomUUID()}`, + object: 'chat.completion', + created: Math.floor(Date.now() / 1000), + model: BRAIN_HELPER_MODEL_ID, + choices: [ + { + index: 0, + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }, + ], + // Advisory only: gbrain logs usage but never bills from it, and + // the real usage is already recorded by the tracked call above. + usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, + }); + } catch (error) { + const detail = ( + error instanceof Error ? error.message : String(error) + ).replace(/\s+/g, ' '); + + console.warn( + formatSingleLineLog(`${LOG_PREFIX} Helper synthesis failed`, { + error: detail, + }), + ); + + return c.json( + { error: `Brain helper-model synthesis failed: ${detail}` }, + 502, + ); + } + }; + + if (parsedBody?.model === BRAIN_HELPER_MODEL_ID) { + const overrideModel = Env.R_BRAIN_MODEL?.trim(); + + if (overrideModel) { + helperOverrideBody = JSON.stringify({ + ...parsedBody, + model: overrideModel, + }); + } else { + return answerWithHelperModel(parsedBody); + } + } else if (parsedBody && !(await resolveBrainInferenceProvider())) { + // No Brain provider key configured. gbrain's expansion and chat send + // concrete model ids, not the sentinel, so without this they would + // 503 on the provider path below — instead every chat request rides + // the deployment's helper model, exactly like the sentinel. A + // Brain-specific key, when configured, still takes this path's place + // so an operator can bill Memory inference separately. + return answerWithHelperModel(parsedBody); + } + } + const localUpstream = resolveLocalUpstream(upstreamPath); if (localUpstream) { @@ -266,20 +487,6 @@ brainInference.post('/*', async (c) => { ); } - // gbrain's OpenRouter reranker speaks the same authenticated gateway - // contract as embeddings and chat, but OpenAI itself has no compatible - // rerank endpoint. Fail explicitly instead of forwarding a doomed request - // to api.openai.com and obscuring the missing capability as a 404. - if (upstreamPath === '/v1/rerank' && resolved.providerId !== 'openrouter') { - return c.json( - { - error: - 'Brain reranking requires an OpenRouter provider configured in Settings, or a local rerank upstream (R_BRAIN_RERANK_UPSTREAM_URL).', - }, - 503, - ); - } - const provider = getInferenceGatewayProvider(resolved.providerId); if (!provider?.authHeader) { @@ -307,7 +514,10 @@ brainInference.post('/*', async (c) => { : resolved.apiKey, ); - const body = await rewriteBody(await c.req.text(), resolved); + const body = await rewriteBody( + helperOverrideBody ?? (await c.req.text()), + resolved, + ); const startedAt = Date.now(); let upstream: Response; diff --git a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts index 63e40394b..04e7ce761 100644 --- a/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/callback-actions.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ finalizeWorkItem: vi.fn(), releaseWorkItem: vi.fn(), handlePrReviewAction: vi.fn(), + processFastAgentMessage: vi.fn(), })); vi.mock('@roomote/db/server', () => ({ @@ -42,12 +43,28 @@ vi.mock('../routing-confirmation.js', () => ({ vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUser, })); +vi.mock('../../fast-agent-entry.js', () => ({ + resolveFastAgentEntryMode: ({ + userDefaultEnabled, + fastAvailable, + }: { + userDefaultEnabled: boolean; + fastAvailable?: boolean; + }) => (userDefaultEnabled && fastAvailable !== false ? 'default' : null), +})); vi.mock('../setup-suggestions.js', () => ({ claimDiscordSuggestionLaunch: mocks.claimSuggestion, })); vi.mock('../task-orchestration.js', () => ({ startNewDiscordTask: mocks.startNewTask, })); +vi.mock('../fast-agent.js', () => ({ + getDiscordFastConversationId: vi.fn( + (channel: { channelId: string }, eventId: string) => + channel.channelId || eventId, + ), + startDiscordFastAgentResponse: mocks.processFastAgentMessage, +})); vi.mock('../task-launch.js', () => ({ resolveDiscordChannelContext: mocks.resolveChannel, resolveDiscordWorkspace: mocks.resolveWorkspace, @@ -75,6 +92,7 @@ describe('Discord component callbacks', () => { mocks.reply.mockResolvedValue({ messageId: 'response-1' }); mocks.findMappedUser.mockResolvedValue('user-1'); mocks.findSuggestionByMessage.mockResolvedValue('suggestion-1'); + mocks.processFastAgentMessage.mockResolvedValue({ accepted: true }); mocks.releaseWorkItem.mockResolvedValue(true); mocks.resolveWorkspace.mockResolvedValue({ environmentId: 'env-1', @@ -151,6 +169,182 @@ describe('Discord component callbacks', () => { expect(mocks.claimSuggestionByMessage).not.toHaveBeenCalled(); }); + it('starts a Fast session for a router-backed suggestion when Fast is the default', async () => { + mocks.claimSuggestionByMessage.mockResolvedValue({ + outcome: 'claimed', + suggestion: { + id: 'suggestion-1', + title: 'Fix tests', + brief: 'Repair the flaky suite.', + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + usesRouterLaunch: true, + launchClaimedAt: new Date('2026-08-28T00:00:00.000Z'), + }, + }); + mocks.resolveChannel.mockResolvedValue({ + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }); + mocks.finalizeWorkItem.mockResolvedValue({ id: 'suggestion-1' }); + const postMessage = vi.fn(); + + await handleDiscordSuggestionReaction({ + provider: { postMessage } as never, + applicationId: 'app-1', + channel: { + channelId: 'thread-1', + channelName: 'Suggested tasks', + channelType: 11, + guildId: 'guild-1', + parentChannelId: 'channel-1', + isDirectMessage: false, + isThread: true, + }, + channelId: 'thread-1', + messageId: 'suggestion-message-1', + eventId: 'reaction-1', + sender: { id: 'discord-user-1', username: 'matt' }, + }); + + expect(mocks.processFastAgentMessage).toHaveBeenCalledWith( + expect.objectContaining({ + eventId: 'reaction-1', + senderUserId: 'user-1', + channel: expect.objectContaining({ channelId: 'thread-1' }), + question: 'Fix tests\n\nRepair the flaky suite.', + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.finalizeWorkItem).toHaveBeenCalledWith(expect.anything(), { + id: 'suggestion-1', + taskId: null, + claimedAt: new Date('2026-08-28T00:00:00.000Z'), + }); + }); + + it('starts a coding task for a pinned suggestion', async () => { + const claimedAt = new Date('2026-08-28T00:00:00.000Z'); + mocks.claimSuggestionByMessage.mockResolvedValue({ + outcome: 'claimed', + suggestion: { + id: 'suggestion-1', + title: 'Fix tests', + brief: 'Repair the flaky suite.', + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + usesRouterLaunch: false, + launchClaimedAt: claimedAt, + }, + }); + mocks.resolveChannel.mockResolvedValue({ + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }); + mocks.startNewTask.mockResolvedValue({ + status: 'started', + launchResult: { id: 42, taskId: 'task-1' }, + taskUrl: 'https://roomote.example/tasks/task-1', + }); + mocks.finalizeWorkItem.mockResolvedValue({ id: 'suggestion-1' }); + const postMessage = vi.fn(); + + await handleDiscordSuggestionReaction({ + provider: { postMessage } as never, + applicationId: 'app-1', + channel: { + channelId: 'thread-1', + channelName: 'Suggested tasks', + channelType: 11, + guildId: 'guild-1', + parentChannelId: 'channel-1', + isDirectMessage: false, + isThread: true, + }, + channelId: 'thread-1', + messageId: 'suggestion-message-1', + eventId: 'reaction-1', + sender: { id: 'discord-user-1', username: 'matt' }, + }); + + expect(mocks.startNewTask).toHaveBeenCalled(); + expect(mocks.processFastAgentMessage).not.toHaveBeenCalled(); + expect(mocks.finalizeWorkItem).toHaveBeenCalledWith(expect.anything(), { + id: 'suggestion-1', + taskId: 'task-1', + claimedAt, + }); + }); + + it('releases the suggestion when the Fast session is busy', async () => { + const claimedAt = new Date('2026-08-28T00:00:00.000Z'); + mocks.processFastAgentMessage.mockResolvedValue({ + accepted: false, + reason: 'Fast session is busy.', + }); + mocks.claimSuggestionByMessage.mockResolvedValue({ + outcome: 'claimed', + suggestion: { + id: 'suggestion-1', + title: 'Fix tests', + brief: 'Repair the flaky suite.', + investigationContext: null, + targetRepositoryFullName: null, + targetEnvironmentId: null, + usesRouterLaunch: true, + launchClaimedAt: claimedAt, + }, + }); + mocks.resolveChannel.mockResolvedValue({ + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }); + const postMessage = vi.fn(); + + await handleDiscordSuggestionReaction({ + provider: { postMessage } as never, + applicationId: 'app-1', + channel: { + channelId: 'thread-1', + channelName: 'Suggested tasks', + channelType: 11, + guildId: 'guild-1', + parentChannelId: 'channel-1', + isDirectMessage: false, + isThread: true, + }, + channelId: 'thread-1', + messageId: 'suggestion-message-1', + eventId: 'reaction-1', + sender: { id: 'discord-user-1', username: 'matt' }, + }); + + expect(mocks.finalizeWorkItem).not.toHaveBeenCalled(); + expect(mocks.releaseWorkItem).toHaveBeenCalledWith(expect.anything(), { + id: 'suggestion-1', + claimedAt, + }); + expect(postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Could not start'), + }), + ); + }); + it('cancels an active run only when it belongs to the interaction channel', async () => { const run = { id: 17, diff --git a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts index 598a722e2..fcdee832e 100644 --- a/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts +++ b/apps/api/src/handlers/discord/__tests__/channel-auto-start.test.ts @@ -20,7 +20,6 @@ const mocks = vi.hoisted(() => ({ createDirectMessage: vi.fn(), postMessage: vi.fn(), addReaction: vi.fn(), - hasFastDefault: vi.fn(), processFast: vi.fn(), })); @@ -41,10 +40,6 @@ vi.mock('@roomote/sdk/server', () => ({ findDiscordMappedUserId: mocks.findMappedUserId, })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - vi.mock('../../shared/channel-launch-gate.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../shared/channel-launch-gate.js') @@ -120,6 +115,24 @@ function messagePayload(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so launch-path tests +// use an attachment-only human message (no text for Fast mode to answer). +function attachmentOnlyPayload(overrides: Record = {}) { + return messagePayload({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + function gatewayEvent(payload: Record): DiscordGatewayEvent { return { eventId: String(payload.id), @@ -204,13 +217,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { mocks.createDirectMessage.mockResolvedValue({ id: 'dm-1' }); mocks.postMessage.mockResolvedValue({ messageId: 'dm-message-1' }); mocks.addReaction.mockResolvedValue(undefined); - mocks.hasFastDefault.mockResolvedValue(false); mocks.processFast.mockResolvedValue(undefined); }); - it('routes a linked user default to Fast mode before channel auto-start launch', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - + it('routes a linked-human text message to Fast mode before channel auto-start launch', async () => { await expect(runHandler({})).resolves.toBe(true); await flushBackgroundWork(); @@ -266,6 +276,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { runHandler({ payload: messagePayload({ content: '', + author: { id: 'alert-bot', username: 'alerts', bot: true }, message_snapshots: [ { message: { @@ -315,8 +326,10 @@ describe('maybeHandleDiscordChannelAutoStart', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('launches a linked-human message with instructions as the prompt prefix', async () => { - await expect(runHandler({})).resolves.toBe(true); + it('launches a linked-human attachment message with instructions as the prompt prefix', async () => { + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -347,7 +360,7 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('forwards message_reference into startNewDiscordTask for reply launches', async () => { await expect( runHandler({ - payload: messagePayload({ + payload: attachmentOnlyPayload({ type: 19, message_reference: { message_id: 'parent-message-1', @@ -472,7 +485,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'skip', reason: 'not an incident' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.evaluateGate).toHaveBeenCalledWith( @@ -507,7 +522,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { debug: { llmDecision: 'error', reason: 'provider unavailable' }, }); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).not.toHaveBeenCalled(); @@ -521,7 +538,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('replies when task startup throws', async () => { mocks.startNewTask.mockRejectedValue(new Error('task queue unavailable')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.postMessage).toHaveBeenCalledWith({ @@ -590,7 +609,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('never lets a reaction failure abort the launch', async () => { mocks.addReaction.mockRejectedValue(new Error('rate limited')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.startNewTask).toHaveBeenCalledTimes(1); @@ -603,7 +624,9 @@ describe('maybeHandleDiscordChannelAutoStart', () => { it('releases the routing lock when the launch fails', async () => { mocks.startNewTask.mockRejectedValue(new Error('boom')); - await expect(runHandler({})).resolves.toBe(true); + await expect( + runHandler({ payload: attachmentOnlyPayload() }), + ).resolves.toBe(true); await flushBackgroundWork(); expect(mocks.redis.del).toHaveBeenCalledWith( 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 df5e2127b..40e336888 100644 --- a/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts +++ b/apps/api/src/handlers/discord/__tests__/fast-agent.test.ts @@ -42,6 +42,14 @@ vi.mock('@roomote/communication/discord-event', () => ({ getDiscordMessageCreate: mocks.getMessage, })); +vi.mock('@roomote/communication', async (importOriginal) => ({ + ...(await importOriginal()), + resolveFastSessionReplyFooterContext: vi.fn(async () => ({ + linkedPrs: [], + livePreviewUrl: null, + })), +})); + vi.mock('@roomote/env', () => ({ Env: { R_APP_URL: 'https://roomote.example.com' }, })); @@ -170,6 +178,76 @@ describe('processDiscordFastAgentMessage', () => { expect(mocks.releaseLock).toHaveBeenCalledOnce(); }); + it('allows silence only for an undirected turn with another human participant', async () => { + mocks.fetchHistory.mockResolvedValue([ + { id: '100', user: 'discord-user-2', text: 'Hi', attachments: [] }, + { + id: '101', + user: 'application-1', + botId: 'application-1', + text: 'Hello', + attachments: [], + }, + ]); + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + + await processDiscordFastAgentMessage({ + event: { eventId: 'event-1' } as never, + question: 'Makes sense', + sender: { id: 'discord-user-1', username: 'matt' } as never, + senderUserId: 'user-1', + provider: provider as never, + applicationId: 'application-1', + channel: { + channelId: 'channel-1', + guildId: 'guild-1', + isDirectMessage: false, + isThread: true, + } as never, + metadata: { + communicationChannelId: 'parent-1', + communicationThreadId: 'channel-1', + } as never, + conversationId: 'channel-1', + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ allowSilentAmbientReply: true }), + ); + }); + + it('requires a response for a directed turn with another human participant', async () => { + mocks.fetchHistory.mockResolvedValue([ + { id: '100', user: 'discord-user-2', text: 'Hi', attachments: [] }, + ]); + const provider = { editMessage: vi.fn().mockResolvedValue(undefined) }; + + await processDiscordFastAgentMessage({ + event: { eventId: 'event-1' } as never, + question: 'Can you expand on that?', + sender: { id: 'discord-user-1', username: 'matt' } as never, + senderUserId: 'user-1', + provider: provider as never, + applicationId: 'application-1', + channel: { + channelId: 'channel-1', + guildId: 'guild-1', + isDirectMessage: false, + isThread: true, + } as never, + metadata: { + communicationChannelId: 'parent-1', + communicationThreadId: 'channel-1', + } as never, + conversationId: 'channel-1', + directedAtRoomote: true, + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ allowSilentAmbientReply: false }), + ); + }); + it.each([ { channelType: 0, threadType: 11, label: 'text' }, { channelType: 5, threadType: 10, label: 'announcement' }, @@ -278,6 +356,65 @@ describe('processDiscordFastAgentMessage', () => { }, ); + it('anchors the thread and replies on an explicit anchor message (reaction summons)', async () => { + const provider = { + createThreadFromMessage: vi.fn().mockResolvedValue({ + channelId: 'reacted-1', + parentChannelId: 'channel-1', + name: 'Investigate this', + kind: 'thread', + messageId: 'reacted-1', + }), + editMessage: vi.fn().mockResolvedValue(undefined), + }; + mocks.answerQuestion.mockResolvedValueOnce('A quick answer'); + + await processDiscordFastAgentMessage({ + event: { eventId: 'synthetic-1' } as never, + question: 'Investigate this', + sender: { id: 'discord-user-1', username: 'matt' } as never, + senderUserId: 'user-1', + provider: provider as never, + applicationId: 'application-1', + channel: { + channelId: 'channel-1', + channelName: 'general', + channelType: 0, + guildId: 'guild-1', + isDirectMessage: false, + isThread: false, + }, + metadata: { + communicationChannelId: 'channel-1', + communicationMessageId: 'reacted-1', + communicationAnchorMessageId: 'reacted-1', + communicationGuildId: 'guild-1', + } as never, + conversationId: 'reacted-1', + anchorMessageId: 'reacted-1', + }); + + // The synthesized message id ('source-1' from getDiscordMessageCreate) is + // not a real Discord message; the reacted-on message anchors everything. + expect(provider.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'reacted-1', + name: 'Investigate this', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ currentMessageId: 'reacted-1' }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ + channel: expect.objectContaining({ + channelId: 'reacted-1', + isThread: true, + }), + replyToMessageId: 'reacted-1', + }), + ); + }); + it('continues an existing guild thread without creating another thread', async () => { const provider = { createThreadFromMessage: vi.fn(), diff --git a/apps/api/src/handlers/discord/__tests__/index.test.ts b/apps/api/src/handlers/discord/__tests__/index.test.ts index 0b93a629e..bea602cf0 100644 --- a/apps/api/src/handlers/discord/__tests__/index.test.ts +++ b/apps/api/src/handlers/discord/__tests__/index.test.ts @@ -33,6 +33,7 @@ const mocks = vi.hoisted(() => ({ suggestionReaction: vi.fn(), getTaskUrl: vi.fn(), getChannel: vi.fn(), + getMessage: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(), createDirectMessage: vi.fn(), @@ -60,11 +61,12 @@ const mocks = vi.hoisted(() => ({ startGoal: vi.fn(), acquireFastTurnLock: vi.fn(), answerFast: vi.fn(), - hasFastDefault: vi.fn(), hasFastSession: vi.fn(), + findFastMessageSession: vi.fn(), findFastReplySession: vi.fn(), isFastProviderMessage: vi.fn(), recordProviderMessage: vi.fn(), + queueFastSurfaceReply: vi.fn(), })); vi.mock('../../account-link-help.js', () => ({ @@ -110,9 +112,11 @@ vi.mock('@roomote/sdk/server', () => ({ upsertDiscordInstallation: mocks.upsertInstallation, enqueueDiscordGatewayEvent: mocks.enqueueGatewayEvent, claimPendingPrReviewActionsForThread: vi.fn(async () => []), + findFastAgentSessionForProviderMessage: mocks.findFastMessageSession, findFastAgentSessionForProviderReply: mocks.findFastReplySession, isFastAgentProviderMessage: mocks.isFastProviderMessage, recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, + queueFastAgentSurfaceReply: mocks.queueFastSurfaceReply, resolveUserMcpServerConfigs: vi.fn(async () => ({})), })); @@ -145,6 +149,14 @@ vi.mock('@roomote/communication/messages', () => ({ setLatestInboundMessageId: mocks.setLatestInbound, })); +vi.mock('@roomote/communication', async (importOriginal) => ({ + ...(await importOriginal()), + resolveFastSessionReplyFooterContext: vi.fn(async () => ({ + linkedPrs: [], + livePreviewUrl: null, + })), +})); + vi.mock('../../tasks/acting-user-sync.js', () => ({ syncActingUserForInboundMessage: mocks.syncActingUser, })); @@ -182,6 +194,10 @@ vi.mock('../callback-actions.js', () => ({ vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireFastTurnLock, answerFastAgentQuestion: mocks.answerFast, + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), resolveApiBaseUrl: () => 'https://roomote.example.com', getTaskUrl: mocks.getTaskUrl, hasFastAgentSession: mocks.hasFastSession, @@ -190,10 +206,6 @@ vi.mock('@roomote/cloud-agents/server', () => ({ .mockResolvedValue({ id: 'fast-session-1' }), })); -vi.mock('../../fast-agent-entry.js', () => ({ - hasCommunicationsFastModeDefault: mocks.hasFastDefault, -})); - import { discord, discordGatewayEventProcessingTimeout } from '../index.js'; import { discordApiEventLeaseRenewal } from '../event-gate.js'; @@ -202,6 +214,7 @@ app.route('/api/internal/discord', discord); const provider = { getChannel: mocks.getChannel, + getMessage: mocks.getMessage, addReaction: mocks.addReaction, removeReaction: mocks.removeReaction, createDirectMessage: mocks.createDirectMessage, @@ -236,6 +249,24 @@ function message(overrides: Record = {}) { }; } +const IMAGE_ATTACHMENT = { + id: 'attachment-1', + filename: 'context.png', + content_type: 'image/png', + size: 1234, + url: 'https://cdn.discordapp.com/attachments/context.png', +}; + +// Fast mode always answers linked-human text messages, so task-orchestration +// tests use attachment-only messages (no text for Fast mode to answer). +function attachmentMessage(overrides: Record = {}) { + return message({ + content: '', + attachments: [IMAGE_ATTACHMENT], + ...overrides, + }); +} + async function postEvent(body: unknown, secret = 'gateway-secret') { return app.request('http://localhost/api/internal/discord/events/process', { method: 'POST', @@ -291,6 +322,7 @@ describe('Discord Gateway event handler', () => { mocks.findCompletedRun.mockResolvedValue(null); mocks.findAutomationReportRun.mockResolvedValue(null); mocks.findSourceRun.mockResolvedValue(null); + mocks.getMessage.mockResolvedValue(null); mocks.removeReaction.mockResolvedValue(undefined); mocks.processAttachments.mockResolvedValue({ images: [], @@ -306,11 +338,12 @@ describe('Discord Gateway event handler', () => { vi.fn().mockResolvedValue(undefined), ); mocks.answerFast.mockResolvedValue('A quick answer'); - mocks.hasFastDefault.mockResolvedValue(false); mocks.hasFastSession.mockResolvedValue(false); + mocks.findFastMessageSession.mockResolvedValue(null); mocks.findFastReplySession.mockResolvedValue(null); mocks.isFastProviderMessage.mockResolvedValue(false); mocks.recordProviderMessage.mockResolvedValue(true); + mocks.queueFastSurfaceReply.mockResolvedValue(true); mocks.reply.mockResolvedValue({ messageId: 'reply-1' }); mocks.createDirectMessage.mockResolvedValue({ id: 'dm-private-1' }); mocks.createThreadFromMessage.mockResolvedValue({ @@ -382,10 +415,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'use API instead', channel: { id: 'thread-1', type: 11, @@ -404,7 +436,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.handleRoutingReply).toHaveBeenCalledWith( expect.objectContaining({ pendingRouteId: 'pending-route-1', - queuedMessage: expect.objectContaining({ text: 'use API instead' }), + queuedMessage: expect.objectContaining({ text: 'Image: context.png' }), }), ); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -420,7 +452,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); - it('turns a configured reaction into a thread task entry', async () => { + it('routes a configured reaction into the fast agent in a thread anchored on the reacted-on message', async () => { mocks.callViaEmojiConfig.mockResolvedValue({ emoji: 'white_check_mark', prompt: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', @@ -431,6 +463,14 @@ describe('Discord Gateway event handler', () => { type: 0, guildId: 'guild-1', }); + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-1', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); const response = await postEvent({ eventId: 'channel-1:message-1:discord-user-1:white_check_mark', @@ -449,28 +489,86 @@ describe('Discord Gateway event handler', () => { }); expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); expect(mocks.channelAutoStart).not.toHaveBeenCalled(); - expect(mocks.addReaction).toHaveBeenCalledWith({ + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-1', - name: '👀', }); - expect(mocks.startNewTask).toHaveBeenCalledWith( + // The fast thread anchors on the real reacted-on message, not the + // synthesized event id. + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith({ + channelId: 'channel-1', + messageId: 'message-1', + name: expect.stringContaining('Act on this'), + }); + expect(mocks.answerFast).toHaveBeenCalledWith( expect.objectContaining({ - requesterDiscordUserId: 'discord-user-1', - launchOwnerUserId: 'roomote-user-1', - queuedMessage: expect.objectContaining({ - text: 'Act on this\n\nAdditional instructions:\nPrioritize safety.', - }), - metadata: expect.objectContaining({ - communicationMessageId: 'message-1', - communicationAnchorMessageId: 'message-1', + question: + 'Act on this\n\nAdditional instructions:\nPrioritize safety.\n\nMessage to act on:\nDeploys are failing on main', + userId: 'roomote-user-1', + currentMessageId: 'message-1', + conversation: expect.objectContaining({ + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'message-1', }), + }), + ); + expect(mocks.reply).toHaveBeenCalledWith( + expect.objectContaining({ replyToMessageId: 'message-1', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-1', + text: expect.stringContaining('A quick answer'), }), ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); + expect(mocks.queueMessage).not.toHaveBeenCalled(); + expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled(); + }); + + it('answers a configured reaction through the fast agent when the reacted-on message cannot be fetched', async () => { + mocks.callViaEmojiConfig.mockResolvedValue({ + emoji: 'white_check_mark', + prompt: 'Act on this', + }); + mocks.getChannel.mockResolvedValue({ + id: 'channel-1', + name: 'general', + type: 0, + guildId: 'guild-1', + }); + mocks.getMessage.mockRejectedValue(new Error('rate limited')); + + const response = await postEvent({ + eventId: 'channel-1:message-1:discord-user-1:white_check_mark', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'channel-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'white_check_mark' }, + member: { + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ question: 'Act on this' }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('starts an exactly tracked suggestion before configured emoji routing', async () => { @@ -512,6 +610,99 @@ describe('Discord Gateway event handler', () => { }), ); expect(mocks.callViaEmojiConfig).not.toHaveBeenCalled(); + expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled(); + }); + + it('queues an unconfigured reaction on the owner’s bound Fast message', async () => { + mocks.getChannel.mockResolvedValue({ + id: 'thread-1', + name: 'Task thread', + type: 11, + guildId: 'guild-1', + parentId: 'channel-1', + }); + mocks.findFastMessageSession.mockResolvedValue({ + id: 'fast-session-1', + userId: 'roomote-user-1', + conversation: { + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'thread-1', + replyTarget: { channelId: 'channel-1', threadId: 'thread-1' }, + }, + }); + + const response = await postEvent({ + eventId: 'thread-1:message-1:discord-user-1:heart', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'thread-1', + message_id: 'message-1', + guild_id: 'guild-1', + emoji: { id: null, name: 'heart' }, + member: { + nick: 'Matt', + user: { id: 'discord-user-1', username: 'matt' }, + }, + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastReactionQueued: true, + }); + expect(mocks.findFastMessageSession).toHaveBeenCalledWith({ + provider: 'discord', + workspaceId: 'guild-1', + channelId: 'channel-1', + threadId: 'thread-1', + messageId: 'message-1', + }); + expect(mocks.queueFastSurfaceReply).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-1', + userId: 'roomote-user-1', + currentMessageId: expect.stringContaining('discord-reaction:'), + replyToMessageId: 'message-1', + externalInput: expect.objectContaining({ + provider: 'discord', + reactions: [{ name: 'heart' }], + }), + }), + ); + }); + + it('rejects a reaction from a different Fast session owner', async () => { + mocks.findFastMessageSession.mockResolvedValue({ + id: 'fast-session-1', + userId: 'another-roomote-user', + conversation: { + surface: 'discord', + workspaceId: 'dm', + conversationId: 'dm-1', + replyTarget: { channelId: 'dm-1' }, + }, + }); + + const response = await postEvent({ + eventId: 'dm-1:message-1:discord-user-1:heart', + eventType: 'MESSAGE_REACTION_ADD', + receivedAt: '2026-07-12T15:00:00.000Z', + payload: { + user_id: 'discord-user-1', + channel_id: 'dm-1', + message_id: 'message-1', + emoji: { id: null, name: 'heart' }, + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'discord_fast_session_user_mismatch', + }); + expect(mocks.queueFastSurfaceReply).not.toHaveBeenCalled(); }); it('rejects an invalid Gateway secret before claiming the event', async () => { @@ -726,8 +917,8 @@ describe('Discord Gateway event handler', () => { }, ); - it('launches a linked DM request through the Discord task orchestrator', async () => { - const response = await postEvent(envelope(message())); + it('launches a linked DM attachment request through the Discord task orchestrator', async () => { + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.completeEvent).toHaveBeenCalledWith({ @@ -747,7 +938,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, queuedMessage: expect.objectContaining({ provider: 'discord', - text: 'Fix the flaky tests', + text: 'Image: context.png', userId: 'roomote-user-1', }), metadata: { @@ -762,8 +953,6 @@ describe('Discord Gateway event handler', () => { }); it('routes an ordinary linked DM message through Fast mode when the user default is enabled', async () => { - mocks.hasFastDefault.mockResolvedValue(true); - const response = await postEvent(envelope(message())); expect(response.status).toBe(200); @@ -798,7 +987,6 @@ describe('Discord Gateway event handler', () => { }); it('starts a new guild-channel Fast conversation in an anchored thread', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.getChannel.mockResolvedValue({ id: 'channel-1', name: 'general', @@ -851,7 +1039,6 @@ describe('Discord Gateway event handler', () => { }); it('passes the model-authored Fast kickoff through the Discord enqueue gate', async () => { - mocks.hasFastDefault.mockResolvedValue(true); const postKickoff = vi.fn().mockResolvedValue(undefined); mocks.startNewTask.mockImplementation( async (input: { @@ -910,7 +1097,6 @@ describe('Discord Gateway event handler', () => { }); it('serializes complete Fast turns before the next Discord message enters the agent', async () => { - mocks.hasFastDefault.mockResolvedValue(true); let grantSecondLock!: (release: () => Promise) => void; const secondLock = new Promise<() => Promise>((resolve) => { grantSecondLock = resolve; @@ -982,7 +1168,6 @@ describe('Discord Gateway event handler', () => { }); it('gives defaulted Discord Fast mode the active task for thread continuation', async () => { - mocks.hasFastDefault.mockResolvedValue(true); mocks.findActiveRun.mockResolvedValue({ id: 23, taskId: 'task-23', @@ -991,6 +1176,7 @@ describe('Discord Gateway event handler', () => { const response = await postEvent(envelope(message())); expect(response.status).toBe(200); + expect(mocks.shouldRouteUnmentioned).not.toHaveBeenCalled(); expect(mocks.answerFast).toHaveBeenCalledWith( expect.objectContaining({ activeTasks: [{ taskId: 'task-23' }] }), ); @@ -1006,11 +1192,11 @@ describe('Discord Gateway event handler', () => { }); const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> can you check if this issue already exists?', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'message-parent', @@ -1047,11 +1233,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: 'Could you expand on the migration note?', message_reference: { message_id: 'announcer-root', channel_id: 'channel-1', @@ -1099,11 +1284,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1117,7 +1302,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.queueMessage).toHaveBeenCalledWith( 'discord', 11, - expect.objectContaining({ text: 'follow up on the first report' }), + expect.objectContaining({ text: 'Image: context.png' }), ); expect(mocks.findActiveRun).not.toHaveBeenCalled(); }); @@ -1148,11 +1333,11 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ id: 'message-2', channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> follow up on the first report', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'roomote' }], message_reference: { message_id: 'announcer-root-one', @@ -1173,7 +1358,7 @@ describe('Discord Gateway event handler', () => { it('still launches when the initial eyes reaction fails', async () => { mocks.addReaction.mockRejectedValueOnce(new Error('rate limited')); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.addReaction).toHaveBeenCalledWith({ @@ -1230,7 +1415,7 @@ describe('Discord Gateway event handler', () => { ); }); - it('queues an ordinary message in an active Discord task thread with full thread context', async () => { + it('queues an attachment-only message in an active Discord task thread with full thread context', async () => { mocks.getChannel.mockResolvedValue({ id: 'thread-1', guildId: 'guild-1', @@ -1246,10 +1431,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Also fix the type error', }), ), ); @@ -1260,7 +1444,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', }), }), ); @@ -1269,7 +1453,7 @@ describe('Discord Gateway event handler', () => { taskId: 'task-23', provider: 'discord', message: expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -1278,7 +1462,7 @@ describe('Discord Gateway event handler', () => { 'discord', 23, expect.objectContaining({ - text: 'Also fix the type error', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), turnPolicy: { reactionsAllowed: true }, }), @@ -1309,10 +1493,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'what about that earlier note?', message_reference: { message_id: 'earlier-1', channel_id: 'thread-1', @@ -1328,7 +1511,7 @@ describe('Discord Gateway event handler', () => { replyToMessageId: 'earlier-1', replyToChannelId: 'thread-1', queuedMessage: expect.objectContaining({ - text: 'what about that earlier note?', + text: 'Image: context.png', }), }), ); @@ -1361,10 +1544,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'yes fix those', }), ), ); @@ -1384,7 +1566,7 @@ describe('Discord Gateway event handler', () => { mocks.findSourceRun.mockResolvedValue({ id: 23, taskId: 'task-23' }); mocks.getTaskUrl.mockReturnValue('https://roomote.example/task/task-23'); - const response = await postEvent(envelope(message())); + const response = await postEvent(envelope(attachmentMessage())); expect(response.status).toBe(200); expect(mocks.queueMessage).not.toHaveBeenCalled(); @@ -1547,6 +1729,7 @@ describe('Discord Gateway event handler', () => { expect(mocks.answerFast).toHaveBeenCalledWith( expect.objectContaining({ question: 'Investigate the second finding', + allowSilentAmbientReply: false, conversation: expect.objectContaining({ conversationId: 'automation-run-1', }), @@ -1556,6 +1739,51 @@ describe('Discord Gateway event handler', () => { expect(mocks.startNewTask).not.toHaveBeenCalled(); }); + it('requires a response for a native reply to Roomote in a shared Fast thread', async () => { + mocks.getChannel.mockResolvedValue({ + id: 'thread-1', + guildId: 'guild-1', + parentId: 'channel-1', + name: 'fast-thread', + type: 11, + }); + mocks.findFastReplySession.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + userId: 'roomote-user-1', + conversation: { + surface: 'discord', + workspaceId: 'guild-1', + conversationId: 'thread-1', + replyTarget: { channelId: 'channel-1', threadId: 'thread-1' }, + }, + }); + mocks.shouldRouteUnmentioned.mockResolvedValue(false); + mocks.fetchThreadHistory.mockResolvedValue([ + { + id: 'earlier-message', + user: 'discord-user-2', + text: 'Earlier participant message', + attachments: [], + }, + ]); + + const response = await postEvent( + envelope( + message({ + channel_id: 'thread-1', + guild_id: 'guild-1', + content: 'Can you expand on that?', + message_reference: { message_id: 'fast-reply-1' }, + }), + ), + ); + + expect(response.status).toBe(200); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ allowSilentAmbientReply: false }), + ); + }); + it('preserves the root channel for a provider-bound guild Fast continuation', async () => { mocks.getChannel.mockResolvedValue({ id: 'channel-1', @@ -2334,10 +2562,10 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'discussion-thread', guild_id: 'guild-1', - content: '<@bot-1> investigate the flaky build', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ), @@ -2376,10 +2604,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2390,7 +2617,7 @@ describe('Discord Gateway event handler', () => { channelId: 'thread-1', botUserId: 'bot-1', queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', }), }), ); @@ -2413,7 +2640,7 @@ describe('Discord Gateway event handler', () => { intakeAckPinned: true, }, queuedMessage: expect.objectContaining({ - text: 'Make one more change', + text: 'Image: context.png', formattedPrompt: expect.stringContaining(''), }), }), @@ -2440,10 +2667,9 @@ describe('Discord Gateway event handler', () => { const response = await postEvent( envelope( - message({ + attachmentMessage({ channel_id: 'thread-1', guild_id: 'guild-1', - content: 'Make one more change', }), ), ); @@ -2507,10 +2733,10 @@ describe('Discord Gateway event handler', () => { }, ); const originalEvent = envelope( - message({ + attachmentMessage({ channel_id: 'channel-1', guild_id: 'guild-1', - content: '<@bot-1> fix this', + content: '<@bot-1>', mentions: [{ id: 'bot-1', username: 'Roomote', bot: true }], }), ); @@ -2560,7 +2786,7 @@ describe('Discord Gateway event handler', () => { requesterDiscordUserId: 'discord-user-1', launchOwnerUserId: 'roomote-user-1', queuedMessage: expect.objectContaining({ - text: 'fix this', + text: 'Image: context.png', ts: 'message-1', userId: 'roomote-user-1', }), @@ -2577,7 +2803,7 @@ describe('Discord Gateway event handler', () => { }); it('restores the pending request and link code when continuation fails', async () => { - const originalEvent = envelope(message()); + const originalEvent = envelope(attachmentMessage()); mocks.consumeLinkCode.mockResolvedValue('roomote-user-1'); mocks.findMappedUserId.mockResolvedValue('roomote-user-1'); mocks.redisGetdel.mockResolvedValue(JSON.stringify(originalEvent)); @@ -2722,27 +2948,40 @@ describe('Discord Gateway event handler', () => { }, }; + mocks.getMessage.mockResolvedValue({ + provider: 'discord', + id: 'message-target', + user: 'discord-user-2', + text: 'Deploys are failing on main', + channelId: 'channel-1', + fileCount: 0, + }); + const response = await postEvent( envelope(interaction, 'INTERACTION_CREATE'), ); expect(response.status).toBe(200); - expect(mocks.startNewTask).toHaveBeenCalledWith( - expect.objectContaining({ - metadata: expect.objectContaining({ - communicationMessageId: 'message-target', - communicationAnchorMessageId: 'message-target', - }), - replyToMessageId: 'message-target', - replyToChannelId: 'channel-1', - contextThroughMessageId: 'message-target', - }), - ); - expect(mocks.addReaction).toHaveBeenCalledWith({ + // The replayed reaction summon enters the fast agent anchored on the + // reacted-on message, matching direct reaction entry. + expect(mocks.getMessage).toHaveBeenCalledWith({ channelId: 'channel-1', messageId: 'message-target', - name: '👀', }); + expect(mocks.createThreadFromMessage).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: 'channel-1', + messageId: 'message-target', + }), + ); + expect(mocks.answerFast).toHaveBeenCalledWith( + expect.objectContaining({ + question: + 'Act on this\n\nMessage to act on:\nDeploys are failing on main', + currentMessageId: 'message-target', + }), + ); + expect(mocks.startNewTask).not.toHaveBeenCalled(); }); it('requires /link in a DM without consuming the one-shot code', async () => { diff --git a/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts b/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts index a07dd8eec..219ea03be 100644 --- a/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts +++ b/apps/api/src/handlers/discord/__tests__/setup-suggestions.test.ts @@ -262,6 +262,7 @@ describe('Discord setup suggestions', () => { investigationContext: null, targetRepositoryFullName: null, targetEnvironmentId: null, + usesRouterLaunch: true, }); }); }); diff --git a/apps/api/src/handlers/discord/__tests__/unmentioned-thread-reply.test.ts b/apps/api/src/handlers/discord/__tests__/unmentioned-thread-reply.test.ts index 04615012f..eabc352a8 100644 --- a/apps/api/src/handlers/discord/__tests__/unmentioned-thread-reply.test.ts +++ b/apps/api/src/handlers/discord/__tests__/unmentioned-thread-reply.test.ts @@ -126,6 +126,36 @@ describe('shouldRouteUnmentionedDiscordThreadReplyToAgent', () => { ).resolves.toBe(true); }); + it('keeps a reply silent when the previous participant addressed the sender in an open fast-agent thread', async () => { + fetchThreadMessagesMock.mockResolvedValue([ + humanHistory(THREAD_ROOT_ID, USER_1, 'Can you summarize this?'), + botHistory('200', 'Hi there.'), + humanHistory('300', USER_1, `<@${USER_2}> what do you think?`), + ]); + + await expect( + routeDecision(threadReplyMessage({ user: USER_2, content: 'I agree' }), { + mappedUserId: 'roomote-user-2', + ownedThreadUserId: 'roomote-user-1', + isOpenConversationThread: true, + }), + ).resolves.toBe(false); + }); + + it('keeps routing after the sender mentions themself in an open fast-agent thread', async () => { + fetchThreadMessagesMock.mockResolvedValue([ + humanHistory(THREAD_ROOT_ID, USER_1, 'Can you summarize this?'), + botHistory('200', 'Hi there.'), + humanHistory('300', USER_1, `<@${USER_1}> note to self`), + ]); + + await expect( + routeDecision(threadReplyMessage({ user: USER_1 }), { + isOpenConversationThread: true, + }), + ).resolves.toBe(true); + }); + it('keeps routing consecutive replies from the same sender before the bot answers', async () => { fetchThreadMessagesMock.mockResolvedValue([ humanHistory( diff --git a/apps/api/src/handlers/discord/callback-actions.ts b/apps/api/src/handlers/discord/callback-actions.ts index 8b2783ba8..d15b8b294 100644 --- a/apps/api/src/handlers/discord/callback-actions.ts +++ b/apps/api/src/handlers/discord/callback-actions.ts @@ -8,7 +8,6 @@ import { and, db, eq, - finalizeWorkItemLaunched, inArray, isNull, or, @@ -25,7 +24,7 @@ import { findDiscordMappedUserId } from '@roomote/sdk/server'; import { parsePrReviewActionCallbackData } from '@roomote/types'; import { apiLogger } from '../../logging.js'; -import { cancelOrphanedWorkItemRunBestEffort } from '../tasks/orphaned-work-item-run.js'; +import { launchClaimedSuggestedTask } from '../tasks/suggestion-launch.js'; import { claimCurrentThreadSuggestionByMessage, findCurrentThreadSuggestionIdByMessage, @@ -49,6 +48,10 @@ import { } from './task-launch.js'; import { claimDiscordSuggestionLaunch } from './setup-suggestions.js'; import { startNewDiscordTask } from './task-orchestration.js'; +import { + getDiscordFastConversationId, + startDiscordFastAgentResponse, +} from './fast-agent.js'; /** Match Slack cancel reaction (`DEFAULT_SLACK_CANCEL_EMOJI`). */ const DISCORD_CANCEL_REACTION_EMOJI = 'x'; @@ -313,7 +316,7 @@ async function launchClaimedDiscordSuggestion(input: { ) : input.channel; const promptText = [ - `Start this suggested task: ${suggestion.title}`, + suggestion.title, ...(suggestion.brief ? ['', suggestion.brief] : []), ...(suggestion.targetRepositoryFullName ? ['', `Target repository: ${suggestion.targetRepositoryFullName}`] @@ -325,67 +328,130 @@ async function launchClaimedDiscordSuggestion(input: { ? ['', `Context: ${suggestion.investigationContext}`] : []), ].join('\n'); - const queuedMessage: QueuedCommunicationMessage = { - provider: 'discord', - text: promptText, - user: - input.senderDisplayName?.trim() || - input.sender.global_name?.trim() || - input.sender.username, - userId: input.senderUserId, - ts: input.triggerId, - channel: launchChannel.channelId, - turnPolicy: { reactionsAllowed: true }, - }; - const workspaceOverride = suggestion.targetEnvironmentId - ? await resolveDiscordWorkspace({ - type: 'environment', - id: suggestion.targetEnvironmentId, - name: suggestion.targetEnvironmentId, - }) - : undefined; - if (suggestion.targetEnvironmentId && !workspaceOverride) { - throw new Error('The suggestion target environment is unavailable.'); - } - const started = await startNewDiscordTask({ - provider: input.provider, - applicationId: input.applicationId, - requesterDiscordUserId: input.sender.id, - launchOwnerUserId: input.senderUserId, - queuedMessage, - metadata: discordMetadataForChannel({ - channel: launchChannel, - messageId: input.triggerId, - }), - channel: launchChannel, - skipRoutingConfirmation: true, - ...(workspaceOverride ? { workspaceOverride } : {}), + const usesRouterLaunch = suggestion.usesRouterLaunch === true; + let taskUrl: string | undefined; + const launchResult = await launchClaimedSuggestedTask({ + suggestion: { id: suggestion.id, launchClaimedAt: claimedAt }, + policy: { + fastEligible: usesRouterLaunch, + userDefaultEnabled: usesRouterLaunch, + fastAvailable: true, + }, + launch: async (launchMode) => { + if (launchMode === 'fast') { + const fastStart = await startDiscordFastAgentResponse({ + eventId: input.triggerId, + question: promptText, + sender: input.sender, + senderUserId: input.senderUserId, + provider: input.provider, + applicationId: input.applicationId, + channel: input.channel, + metadata: discordMetadataForChannel({ + channel: input.channel, + messageId: input.triggerId, + }), + conversationId: getDiscordFastConversationId( + input.channel, + input.triggerId, + ), + createAnchoredThread: false, + }); + return fastStart.accepted + ? { + accepted: true, + runId: null, + taskId: null, + abort: fastStart.abort, + } + : fastStart; + } + + const queuedMessage: QueuedCommunicationMessage = { + provider: 'discord', + text: promptText, + user: + input.senderDisplayName?.trim() || + input.sender.global_name?.trim() || + input.sender.username, + userId: input.senderUserId, + ts: input.triggerId, + channel: launchChannel.channelId, + turnPolicy: { reactionsAllowed: true }, + }; + const workspaceOverride = suggestion.targetEnvironmentId + ? await resolveDiscordWorkspace({ + type: 'environment', + id: suggestion.targetEnvironmentId, + name: suggestion.targetEnvironmentId, + }) + : undefined; + if (suggestion.targetEnvironmentId && !workspaceOverride) { + throw new Error('The suggestion target environment is unavailable.'); + } + const started = await startNewDiscordTask({ + provider: input.provider, + applicationId: input.applicationId, + requesterDiscordUserId: input.sender.id, + launchOwnerUserId: input.senderUserId, + queuedMessage, + metadata: discordMetadataForChannel({ + channel: launchChannel, + messageId: input.triggerId, + }), + channel: launchChannel, + skipRoutingConfirmation: true, + ...(workspaceOverride ? { workspaceOverride } : {}), + }); + if (started.status !== 'started') { + return { accepted: false }; + } + taskUrl = started.taskUrl; + return { + accepted: true, + runId: started.launchResult.id, + taskId: started.launchResult.taskId, + }; + }, }); - if (started.status !== 'started') { - await releaseWorkItemClaim(db, { id: suggestion.id, claimedAt }); + + if (launchResult.status === 'rejected') { + if (launchResult.reason) { + await input.provider.postMessage({ + channelId: input.channel.parentChannelId ?? input.channel.channelId, + ...(input.channel.parentChannelId + ? { threadId: input.channel.channelId } + : {}), + text: `Could not start “${suggestion.title}” — ${launchResult.reason}`, + }); + } return; } - const finalized = await finalizeWorkItemLaunched(db, { - id: suggestion.id, - taskId: started.launchResult.taskId, - claimedAt, - }); - if (!finalized) { - const cancelNote = await cancelOrphanedWorkItemRunBestEffort( - started.launchResult.id, - ); + if (launchResult.status === 'failed') { + throw launchResult.error; + } + if ( + launchResult.status === 'finalize_lost' || + launchResult.status === 'finalize_failed' + ) { apiLogger.warn( - `[discord] Lost suggestion launch fence for ${suggestion.id}; duplicate run ${started.launchResult.id} was orphaned — ${cancelNote}`, + `[discord] Failed to finalize suggestion ${suggestion.id}; task ${launchResult.taskId ?? 'null'} (run ${launchResult.runId ?? 'null'}) — ${launchResult.cancelNote}`, ); await input.provider.postMessage({ channelId: input.channel.parentChannelId ?? input.channel.channelId, ...(input.channel.parentChannelId ? { threadId: input.channel.channelId } : {}), - text: `“${suggestion.title}” was already started elsewhere — this duplicate task was canceled.`, + text: + launchResult.mode === 'coding' + ? `“${suggestion.title}” was already started elsewhere — this duplicate task was canceled.` + : `“${suggestion.title}” was already started elsewhere.`, }); return; } + if (launchResult.mode === 'fast') { + return; + } await input.provider.postMessage({ channelId: input.channel.parentChannelId ?? input.channel.channelId, ...(input.channel.parentChannelId @@ -394,9 +460,7 @@ async function launchClaimedDiscordSuggestion(input: { text: input.channel.parentChannelId ? `Started “${suggestion.title}” in a new task thread.` : `Started “${suggestion.title}”.`, - ...(started.taskUrl - ? { buttons: [[{ text: 'Follow', url: started.taskUrl }]] } - : {}), + ...(taskUrl ? { buttons: [[{ text: 'Follow', url: taskUrl }]] } : {}), }); } catch (error) { const blockedByReadOnly = isDeploymentReadOnlyError(error); diff --git a/apps/api/src/handlers/discord/channel-auto-start.ts b/apps/api/src/handlers/discord/channel-auto-start.ts index 2fe6de4a6..213b57dc3 100644 --- a/apps/api/src/handlers/discord/channel-auto-start.ts +++ b/apps/api/src/handlers/discord/channel-auto-start.ts @@ -24,7 +24,6 @@ import { } from '@roomote/types'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { checkAutoStartChannelCache } from '../shared/auto-start-cache.js'; import { CHANNEL_AUTO_START_FAILURE_MESSAGE, @@ -279,10 +278,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { getDiscordMessageContent(message), botUserId, ); - if ( - defaultFastQuestion && - (await hasCommunicationsFastModeDefault(mappedUserId)) - ) { + if (defaultFastQuestion) { void processDiscordFastAgentMessage({ event, question: defaultFastQuestion, @@ -297,6 +293,7 @@ export async function maybeHandleDiscordChannelAutoStart(input: { anchorMessageId: message.id, }), conversationId: getDiscordFastConversationId(channel, message.id), + directedAtRoomote: true, }).catch((error) => { apiLogger.error( `[DiscordChannelAutoStart] Failed to answer in Fast mode for ${logContext}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/apps/api/src/handlers/discord/fast-agent.ts b/apps/api/src/handlers/discord/fast-agent.ts index 023ac8e0a..080f33c6f 100644 --- a/apps/api/src/handlers/discord/fast-agent.ts +++ b/apps/api/src/handlers/discord/fast-agent.ts @@ -21,6 +21,7 @@ import { deliverManagedThreadReplyFooter, getDiscordFooterlessFinalChunk, getThreadReplyFooterRecord, + resolveFastSessionReplyFooterContext, setThreadReplyFooterRecord, withThreadReplyFooterLock, } from '@roomote/communication'; @@ -31,6 +32,10 @@ import { import { ALL_REPOSITORIES } from '@roomote/types'; import { buildCommunicationTaskThreadName } from '../tasks/communication-task-thread.js'; +import { + startAcceptedFastAgentTurn, + type FastAgentStartResult, +} from '../fast-agent-entry.js'; import { replyToDiscordEvent } from './replies.js'; import { discordMetadataForChannel, @@ -74,25 +79,41 @@ export function getDiscordFastLaunchSourceEventId(input: { return `${input.eventId}:fast-launch:${digest}`; } -export async function processDiscordFastAgentMessage(input: { - event: DiscordGatewayEvent; - question: string; - sender: DiscordUser; - senderUserId: string; - provider: DiscordCommunicationProvider; - applicationId: string; - channel: DiscordChannelContext; - metadata: ReturnType; - conversationId: string; - createAnchoredThread?: boolean; - interaction?: DiscordInteractionReplyContext; - activeTasks?: { taskId: string }[]; -}): Promise { - const message = getDiscordMessageCreate(input.event); +type DiscordFastAgentSource = + | { event: DiscordGatewayEvent; eventId?: never } + | { event?: never; eventId: string }; + +export async function processDiscordFastAgentMessage( + input: { + question: string; + sender: DiscordUser; + senderUserId: string; + provider: DiscordCommunicationProvider; + applicationId: string; + channel: DiscordChannelContext; + metadata: ReturnType; + conversationId: string; + createAnchoredThread?: boolean; + /** Real Discord message used for replies and anchored threads. */ + anchorMessageId?: string; + interaction?: DiscordInteractionReplyContext; + activeTasks?: { taskId: string }[]; + directedAtRoomote?: boolean; + onAccepted?: (abort: () => Promise) => void; + onRejected?: () => void; + } & DiscordFastAgentSource, +): Promise { + const message = input.event ? getDiscordMessageCreate(input.event) : null; + const eventId = 'eventId' in input ? input.eventId : input.event.eventId; + if (!eventId) { + throw new Error('Discord Fast entry requires a source event id.'); + } + const anchorMessageId = input.anchorMessageId ?? message?.id; let channel = input.channel; let metadata = input.metadata; if ( message && + anchorMessageId && input.createAnchoredThread !== false && !channel.isDirectMessage && !channel.isThread && @@ -100,7 +121,7 @@ export async function processDiscordFastAgentMessage(input: { ) { const thread = await input.provider.createThreadFromMessage({ channelId: channel.channelId, - messageId: message.id, + messageId: anchorMessageId, name: buildCommunicationTaskThreadName(input.question), }); channel = { @@ -131,10 +152,11 @@ export async function processDiscordFastAgentMessage(input: { }; const releaseFastAgentLock = await acquireFastAgentTurnLock({ conversation }); if (!releaseFastAgentLock) { + input.onRejected?.(); console.error( `[Discord] Fast turn lock did not become available for ${conversation.workspaceId}:${conversation.conversationId}`, ); - return; + return false; } try { @@ -154,10 +176,19 @@ export async function processDiscordFastAgentMessage(input: { userId: input.senderUserId, conversation, }); + const footerContext = await resolveFastSessionReplyFooterContext({ + sessionId: session.id, + }); + input.onAccepted?.(() => + releaseFastAgentLock.abort( + new Error('Fast suggestion launch settlement failed.'), + ), + ); const postFastReplyWithFooter = async (text: string) => { const footerText = buildFastSessionReplyFooterText({ provider: 'discord', sessionId: session.id, + ...footerContext, }); const textWithFooter = `${text}\n\n${footerText}`; const channelId = conversation.replyTarget.channelId; @@ -179,7 +210,7 @@ export async function processDiscordFastAgentMessage(input: { applicationId: input.applicationId, channel, ...(input.interaction ? { interaction: input.interaction } : {}), - ...(message ? { replyToMessageId: message.id } : {}), + ...(anchorMessageId ? { replyToMessageId: anchorMessageId } : {}), text: textWithFooter, }); await recordFastAgentConversationMessageBestEffort({ @@ -218,13 +249,21 @@ export async function processDiscordFastAgentMessage(input: { userId: input.senderUserId, apiBaseUrl, conversation, - currentMessageId: message?.id ?? input.interaction?.interaction.id, + currentMessageId: anchorMessageId ?? input.interaction?.interaction.id, signal: releaseFastAgentLock.signal, senderDisplayName: input.interaction?.interaction.member?.nick ?? input.sender.global_name ?? input.sender.username, activeTasks: input.activeTasks, + allowSilentAmbientReply: + !input.directedAtRoomote && + history.some( + (entry) => + !entry.botId && + Boolean(entry.user) && + entry.user !== input.sender.id, + ), adapter: { resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ @@ -268,7 +307,7 @@ export async function processDiscordFastAgentMessage(input: { user: input.sender.global_name?.trim() || input.sender.username, userId: input.senderUserId, ts: getDiscordFastLaunchSourceEventId({ - eventId: input.event.eventId, + eventId, prompt, environmentId, model, @@ -320,6 +359,7 @@ export async function processDiscordFastAgentMessage(input: { const footerText = buildFastSessionReplyFooterText({ provider: 'discord', sessionId: session.id, + ...footerContext, }); const footerChannelId = conversation.replyTarget.channelId; const footerStateThreadId = @@ -399,4 +439,23 @@ export async function processDiscordFastAgentMessage(input: { } finally { await releaseFastAgentLock().catch(() => {}); } + return true; +} + +export function startDiscordFastAgentResponse( + input: Parameters[0], +): Promise { + return startAcceptedFastAgentTurn({ + run: ({ onAccepted, onRejected }) => + processDiscordFastAgentMessage({ + ...input, + onAccepted, + onRejected, + }), + onError: (error) => { + console.error( + `[Discord] Fast suggestion response failed: ${error instanceof Error ? error.message : String(error)}`, + ); + }, + }); } diff --git a/apps/api/src/handlers/discord/index.ts b/apps/api/src/handlers/discord/index.ts index e8a7b8cd8..0cb2408a9 100644 --- a/apps/api/src/handlers/discord/index.ts +++ b/apps/api/src/handlers/discord/index.ts @@ -25,7 +25,12 @@ import { setLatestInboundMessageId, } from '@roomote/communication/messages'; import { reactionEmojiMatches } from '@roomote/communication/reaction-emoji'; -import { getTaskUrl, hasFastAgentSession } from '@roomote/cloud-agents/server'; +import { + buildFastAgentReactionExternalInputQuestion, + getTaskUrl, + hasFastAgentSession, + type FastAgentReactionExternalInput, +} from '@roomote/cloud-agents/server'; import { MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE, RunStatus, @@ -37,8 +42,10 @@ import { consumeDiscordLinkCode, findDiscordInstallationByGuildId, findDiscordMappedUserId, + findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, isFastAgentProviderMessage, + queueFastAgentSurfaceReply, restoreDiscordLinkCode, upsertDiscordInstallation, upsertDiscordUserMapping, @@ -46,7 +53,6 @@ import { } from '@roomote/sdk/server'; import { apiLogger } from '../../logging.js'; -import { hasCommunicationsFastModeDefault } from '../fast-agent-entry.js'; import { getCallRoomoteViaEmojiConfiguration } from '../call-roomote-via-emoji.js'; import { syncActingUserForInboundMessage } from '../tasks/acting-user-sync.js'; import { @@ -309,7 +315,89 @@ async function processDiscordGatewayEvent( reaction.emoji.name, ); if (!configuration) { - return { ok: true, ignored: 'reaction_not_configured' }; + const channel = await resolveDiscordChannelContext( + resolved.provider, + reaction.channel_id, + ); + const metadata = discordMetadataForChannel({ + channel, + messageId: reaction.message_id, + }); + const session = await findFastAgentSessionForProviderMessage({ + provider: 'discord', + workspaceId: channel.guildId ?? 'dm', + channelId: metadata.communicationChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + messageId: reaction.message_id, + }); + if (!session) { + return { ok: true, ignored: 'reaction_not_configured' }; + } + + const senderUserId = await findDiscordMappedUserId(reaction.user_id); + if (!senderUserId) { + await promptDiscordAccountLink({ + provider: resolved.provider, + applicationId: resolved.applicationId, + channel, + discordUserId: reaction.user_id, + replyToMessageId: reaction.message_id, + }); + return { ok: true, ignored: 'discord_reactor_not_linked' }; + } + if (session.userId !== senderUserId) { + return { ok: true, ignored: 'discord_fast_session_user_mismatch' }; + } + + const author = reaction.member?.user ?? { + id: reaction.user_id, + username: `Discord user ${reaction.user_id}`, + }; + const senderDisplayName = + (typeof reaction.member?.nick === 'string' + ? reaction.member.nick + : undefined) ?? + (typeof author.global_name === 'string' + ? author.global_name + : undefined) ?? + author.username; + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'discord', + reactions: [ + { + name: reaction.emoji.name, + ...(reaction.emoji.id ? { id: reaction.emoji.id } : {}), + }, + ], + reactor: { + externalUserId: reaction.user_id, + ...(senderDisplayName ? { displayName: senderDisplayName } : {}), + }, + message: { + workspaceId: channel.guildId ?? 'dm', + channelId: metadata.communicationChannelId, + messageId: reaction.message_id, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + }, + eventId: event.eventId, + }; + const queued = await queueFastAgentSurfaceReply({ + sessionId: session.id, + userId: senderUserId, + senderDisplayName, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + currentMessageId: `discord-reaction:${event.eventId}`, + replyToMessageId: reaction.message_id, + externalInput: reactionInput, + }); + return queued + ? { ok: true, fastReactionQueued: true } + : { ok: true, ignored: 'discord_fast_reaction_route_unavailable' }; } const author = reaction.member?.user ?? { @@ -699,6 +787,7 @@ async function processDiscordGatewayEvent( !channel.isDirectMessage && !command && !isDiscordBotMentioned(message, resolved.botUserId) && + !repliedFastSession && isRoomoteThread ) { const shouldRouteUnmentioned = @@ -745,12 +834,11 @@ async function processDiscordGatewayEvent( userId: senderUserId, }); + // Fast mode is unconditional for ordinary linked-human messages, including + // reaction summons: a configured emoji synthesizes a bot mention that enters + // the fast agent, matching Slack's call-roomote-via-emoji flow. const defaultFastMessage = - message != null && - command == null && - (await hasCommunicationsFastModeDefault(senderUserId)) - ? message - : null; + message != null && command == null ? message : null; if (command?.name === 'goal') { if (!command.objective) { @@ -811,7 +899,16 @@ async function processDiscordGatewayEvent( conversationId: repliedFastSession?.conversation.conversationId ?? channel.channelId, ...(repliedFastSession ? { createAnchoredThread: false } : {}), + // A reaction summon's synthesized message id is not a real Discord + // message; anchor replies on the reacted-on message instead. + ...(reactionTarget + ? { anchorMessageId: reactionTarget.messageId } + : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], + directedAtRoomote: + channel.isDirectMessage || + Boolean(repliedFastSession) || + isDiscordBotMentioned(message, resolved.botUserId), }); return { ok: true, fastAnswered: true, fastContinued: true }; } @@ -823,20 +920,44 @@ async function processDiscordGatewayEvent( ) : ''; if (defaultFastMessage && defaultFastQuestion) { + let fastQuestion = defaultFastQuestion; + if (reactionTarget) { + // Match Slack's emoji summon: inline the reacted-on message so the fast + // agent sees what it was asked to act on even without thread history. + try { + const targetMessage = await resolved.provider.getMessage({ + channelId: reactionTarget.channelId, + messageId: reactionTarget.messageId, + }); + if (targetMessage?.text) { + fastQuestion = `${defaultFastQuestion}\n\nMessage to act on:\n${targetMessage.text}`; + } + } catch (error) { + apiLogger.warn( + `[discord] Could not resolve emoji summon target ${reactionTarget.channelId}:${reactionTarget.messageId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } await processDiscordFastAgentMessage({ event, - question: defaultFastQuestion, + question: fastQuestion, sender, senderUserId, provider: resolved.provider, applicationId: resolved.applicationId, channel, metadata, + // A reaction summon anchors its fast conversation (and any created + // thread) on the reacted-on message, mirroring Slack threading under + // the reacted-on message; the synthesized message id is not a real + // Discord message. conversationId: getDiscordFastConversationId( channel, - defaultFastMessage.id, + reactionTarget?.messageId ?? defaultFastMessage.id, ), + ...(reactionTarget ? { anchorMessageId: reactionTarget.messageId } : {}), activeTasks: activeRun ? [{ taskId: activeRun.taskId }] : [], + directedAtRoomote: true, }); return { ok: true, fastAnswered: true, fastDefaulted: true }; } diff --git a/apps/api/src/handlers/discord/setup-suggestions.ts b/apps/api/src/handlers/discord/setup-suggestions.ts index 62430062d..5135c9f43 100644 --- a/apps/api/src/handlers/discord/setup-suggestions.ts +++ b/apps/api/src/handlers/discord/setup-suggestions.ts @@ -50,6 +50,7 @@ type DiscordSuggestionLaunchClaim = { investigationContext: string | null; targetRepositoryFullName: string | null; targetEnvironmentId?: string | null; + usesRouterLaunch: boolean; launchClaimedAt: Date; }; @@ -219,6 +220,7 @@ export async function claimDiscordSuggestionLaunch(input: { investigationContext: routed ? null : claimed.investigationContext, targetRepositoryFullName: routed ? null : claimed.targetRepositoryFullName, targetEnvironmentId: routed ? null : claimed.targetEnvironmentId, + usesRouterLaunch: routed, launchClaimedAt: claimed.launchClaimedAt, }; } diff --git a/apps/api/src/handlers/discord/unmentioned-thread-reply.ts b/apps/api/src/handlers/discord/unmentioned-thread-reply.ts index 75950241b..8551b7141 100644 --- a/apps/api/src/handlers/discord/unmentioned-thread-reply.ts +++ b/apps/api/src/handlers/discord/unmentioned-thread-reply.ts @@ -44,7 +44,7 @@ function mentionsDiscordUserOtherThanBotWithoutMentioningBot( function mentionsDiscordUserOtherThanBotOrUser( text: string, botUserId: string | undefined, - discordUserId: string, + discordUserId: string | null | undefined, ): boolean { return getMentionedDiscordUserIds(text).some( (userId) => userId !== botUserId && userId !== discordUserId, @@ -66,7 +66,6 @@ function isHumanAuthoredHistoryMessage( function toSharedHistoryMessages( threadMessages: DiscordThreadHistoryMessage[], botUserId: string, - senderDiscordUserId: string, ): UnmentionedThreadHistoryMessage[] { return threadMessages.map((message) => { const isBot = message.botId === botUserId; @@ -79,7 +78,7 @@ function toSharedHistoryMessages( mentionsSomebodyElse: mentionsDiscordUserOtherThanBotOrUser( message.text, botUserId, - senderDiscordUserId, + message.user, ), }; }); @@ -174,11 +173,7 @@ export async function shouldRouteUnmentionedDiscordThreadReplyToAgent(params: { isThreadRootAuthor, isAutomationReportThread: params.isAutomationReportThread, isOpenConversationThread: params.isOpenConversationThread, - threadMessages: toSharedHistoryMessages( - threadMessages, - botUserId, - senderDiscordUserId, - ), + threadMessages: toSharedHistoryMessages(threadMessages, botUserId), compareMessageIds: compareBigIntMessageIds, }); diff --git a/apps/api/src/handlers/fast-agent-entry.test.ts b/apps/api/src/handlers/fast-agent-entry.test.ts index a8d9c61f4..84ada819f 100644 --- a/apps/api/src/handlers/fast-agent-entry.test.ts +++ b/apps/api/src/handlers/fast-agent-entry.test.ts @@ -1,35 +1,79 @@ -const mocks = vi.hoisted(() => ({ - findUser: vi.fn(), -})); +import { + resolveFastAgentEntryMode, + startAcceptedFastAgentTurn, +} from './fast-agent-entry'; -vi.mock('@roomote/db/server', () => ({ - db: { query: { users: { findFirst: mocks.findUser } } }, - eq: vi.fn(), - users: { id: 'users.id' }, -})); - -import { hasCommunicationsFastModeDefault } from './fast-agent-entry'; +describe('startAcceptedFastAgentTurn', () => { + it('rejects lock contention before acceptance', async () => { + await expect( + startAcceptedFastAgentTurn({ + run: async ({ onRejected }) => onRejected(), + onError: vi.fn(), + }), + ).resolves.toEqual({ accepted: false, reason: 'Fast session is busy.' }); + }); -describe('hasCommunicationsFastModeDefault', () => { - beforeEach(() => { - vi.clearAllMocks(); + it('rejects startup failures before acceptance', async () => { + const onError = vi.fn(); + const error = new Error('startup failed'); + await expect( + startAcceptedFastAgentTurn({ + run: async () => { + throw error; + }, + onError, + }), + ).resolves.toEqual({ accepted: false, reason: 'startup failed' }); + expect(onError).toHaveBeenCalledWith(error); }); - it('returns the stored preference', async () => { - mocks.findUser.mockResolvedValue({ - metadata: { communications_fast_mode_default: true }, + it('rejects a processor that exits without an acceptance decision', async () => { + await expect( + startAcceptedFastAgentTurn({ + run: async () => undefined, + onError: vi.fn(), + }), + ).resolves.toEqual({ + accepted: false, + reason: 'Fast session did not accept the request.', }); + }); - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - true, - ); + it('keeps an accepted result when completion later fails', async () => { + const onError = vi.fn(); + const abort = vi.fn(async () => undefined); + const error = new Error('completion failed'); + await expect( + startAcceptedFastAgentTurn({ + run: async ({ onAccepted }) => { + onAccepted(abort); + throw error; + }, + onError, + }), + ).resolves.toEqual({ accepted: true, abort }); + await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(error)); }); +}); - it('returns false when the stored preference is not enabled', async () => { - mocks.findUser.mockResolvedValue({ metadata: {} }); +describe('resolveFastAgentEntryMode', () => { + it('uses Fast for an available configured default', () => { + expect( + resolveFastAgentEntryMode({ + explicitInvocation: false, + userDefaultEnabled: true, + fastAvailable: true, + }), + ).toBe('default'); + }); - await expect(hasCommunicationsFastModeDefault('user-1')).resolves.toBe( - false, - ); + it('keeps coding behavior when Fast is unavailable', () => { + expect( + resolveFastAgentEntryMode({ + explicitInvocation: false, + userDefaultEnabled: true, + fastAvailable: false, + }), + ).toBeNull(); }); }); diff --git a/apps/api/src/handlers/fast-agent-entry.ts b/apps/api/src/handlers/fast-agent-entry.ts index a87bb5f88..dc47a5424 100644 --- a/apps/api/src/handlers/fast-agent-entry.ts +++ b/apps/api/src/handlers/fast-agent-entry.ts @@ -1,32 +1,72 @@ -import { db, eq, users } from '@roomote/db/server'; - type FastAgentEntryMode = 'explicit' | 'default'; +export type FastAgentStartResult = + | { accepted: true; abort: () => Promise } + | { accepted: false; reason: string }; + +export function startAcceptedFastAgentTurn(input: { + run: (callbacks: { + onAccepted: (abort: () => Promise) => void; + onRejected: () => void; + }) => Promise; + busyMessage?: string; + onError: (error: unknown) => void; +}): Promise { + let settled = false; + let settleAcceptance: ((result: FastAgentStartResult) => void) | undefined; + const acceptance = new Promise((resolve) => { + settleAcceptance = resolve; + }); + + void input + .run({ + onAccepted: (abort) => { + if (settled) return; + settled = true; + settleAcceptance?.({ accepted: true, abort }); + }, + onRejected: () => { + if (settled) return; + settled = true; + settleAcceptance?.({ + accepted: false, + reason: input.busyMessage ?? 'Fast session is busy.', + }); + }, + }) + .then(() => { + if (!settled) { + settled = true; + settleAcceptance?.({ + accepted: false, + reason: 'Fast session did not accept the request.', + }); + } + }) + .catch((error) => { + if (!settled) { + settled = true; + settleAcceptance?.({ + accepted: false, + reason: error instanceof Error ? error.message : String(error), + }); + } + input.onError(error); + }); + + return acceptance; +} + export function resolveFastAgentEntryMode(params: { explicitInvocation: boolean; userDefaultEnabled: boolean; + fastAvailable?: boolean; }): FastAgentEntryMode | null { if (params.explicitInvocation) { return 'explicit'; } - return params.userDefaultEnabled ? 'default' : null; -} - -export async function hasCommunicationsFastModeDefault( - userId: string, -): Promise { - const user = await db.query.users.findFirst({ - where: eq(users.id, userId), - columns: { metadata: true }, - }); - const metadata = user?.metadata; - - return ( - typeof metadata === 'object' && - metadata !== null && - !Array.isArray(metadata) && - (metadata as Record).communications_fast_mode_default === - true - ); + return params.userDefaultEnabled && params.fastAvailable !== false + ? 'default' + : null; } diff --git a/apps/api/src/handlers/gitea/__tests__/handlePullRequest.test.ts b/apps/api/src/handlers/gitea/__tests__/handlePullRequest.test.ts index fe7215ce4..2ce99d375 100644 --- a/apps/api/src/handlers/gitea/__tests__/handlePullRequest.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/handlePullRequest.test.ts @@ -344,6 +344,9 @@ describe('handleGiteaPullRequest', () => { 42, 'merged', ); + expect(mockRecordPrStatusChangeInTaskHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ targetBranch: 'main' }), + ); expect(mockScheduleSourceControlPullRequestFactSync).toHaveBeenCalledWith({ provider: 'gitea', repositoryFullName: 'acme/backend', diff --git a/apps/api/src/handlers/gitea/__tests__/index.test.ts b/apps/api/src/handlers/gitea/__tests__/index.test.ts index b8810b02b..d9138a750 100644 --- a/apps/api/src/handlers/gitea/__tests__/index.test.ts +++ b/apps/api/src/handlers/gitea/__tests__/index.test.ts @@ -7,6 +7,7 @@ const { mockHandleGiteaComment, mockHandleGiteaIssue, mockHandleGiteaWorkflowRun, + mockHandleMergeAnnouncerPush, mockRecordWebhook, mockResolveDeploymentEnvVar, } = vi.hoisted(() => ({ @@ -14,6 +15,7 @@ const { mockHandleGiteaComment: vi.fn(), mockHandleGiteaIssue: vi.fn(), mockHandleGiteaWorkflowRun: vi.fn(), + mockHandleMergeAnnouncerPush: vi.fn(), mockRecordWebhook: vi.fn(), mockResolveDeploymentEnvVar: vi.fn(), })); @@ -22,6 +24,10 @@ vi.mock('@roomote/db/server', () => ({ resolveDeploymentEnvVar: mockResolveDeploymentEnvVar, })); +vi.mock('@roomote/sdk/server', () => ({ + handleMergeAnnouncerPush: mockHandleMergeAnnouncerPush, +})); + vi.mock('../../logging', () => ({ apiLogger: { debug: vi.fn(), @@ -63,6 +69,7 @@ describe('gitea webhook router', () => { mockHandleGiteaComment.mockReset(); mockHandleGiteaIssue.mockReset(); mockHandleGiteaWorkflowRun.mockReset(); + mockHandleMergeAnnouncerPush.mockReset(); mockRecordWebhook.mockReset(); mockResolveDeploymentEnvVar.mockReset(); mockResolveDeploymentEnvVar.mockImplementation(async (name: string) => @@ -72,6 +79,7 @@ describe('gitea webhook router', () => { mockHandleGiteaComment.mockResolvedValue({ status: 'ok' }); mockHandleGiteaIssue.mockResolvedValue({ status: 'ok' }); mockHandleGiteaWorkflowRun.mockResolvedValue({ status: 'ok' }); + mockHandleMergeAnnouncerPush.mockResolvedValue({ status: 'ok' }); mockRecordWebhook.mockImplementation( async ( _deliveryId: string, @@ -135,6 +143,43 @@ describe('gitea webhook router', () => { ); }); + it('records and routes normalized push webhooks', async () => { + const payload = { + ref: 'refs/heads/main', + pusher: { username: 'alice' }, + repository: { + id: 123, + full_name: 'acme/backend', + html_url: 'https://git.example.com/acme/backend', + }, + commits: [{ id: 'abc', message: 'Ship backend' }], + }; + const body = JSON.stringify(payload); + + const response = await app.request('http://localhost/api/webhooks/gitea', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-gitea-event': 'push', + 'x-gitea-delivery': 'push-delivery', + 'x-gitea-signature': sign(body), + }, + body, + }); + + expect(response.status).toBe(200); + expect(mockRecordWebhook).toHaveBeenCalledWith( + 'push-delivery', + 'push', + expect.anything(), + expect.any(Function), + { provider: 'gitea' }, + ); + expect(mockHandleMergeAnnouncerPush).toHaveBeenCalledWith( + expect.objectContaining({ provider: 'gitea', pusher: 'alice' }), + ); + }); + it('records and routes pull request comment webhooks', async () => { const payload = { action: 'created', diff --git a/apps/api/src/handlers/gitea/handlePullRequest.ts b/apps/api/src/handlers/gitea/handlePullRequest.ts index 15aa6425d..85a3e30dc 100644 --- a/apps/api/src/handlers/gitea/handlePullRequest.ts +++ b/apps/api/src/handlers/gitea/handlePullRequest.ts @@ -134,6 +134,7 @@ export async function handleGiteaPullRequest( prNumber: payload.number, prTitle: pullRequest.title, prUrl: getPullRequestUrl(payload), + targetBranch: pullRequest.base?.ref, status, actorLogin: getGiteaUsername(payload.sender) ?? 'someone on Gitea', }), diff --git a/apps/api/src/handlers/gitea/index.ts b/apps/api/src/handlers/gitea/index.ts index 03ff8415a..74ee9015a 100644 --- a/apps/api/src/handlers/gitea/index.ts +++ b/apps/api/src/handlers/gitea/index.ts @@ -3,9 +3,11 @@ import { createHash } from 'node:crypto'; import { Hono } from 'hono'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { handleMergeAnnouncerPush } from '@roomote/sdk/server'; import { apiLogger, logApiError } from '../../logging'; import { recordWebhook } from '../github/recordWebhook'; +import { normalizeGiteaPush } from '../merge-announcer-push'; import { handleGiteaComment } from './handleComment'; import { handleGiteaIssue } from './handleIssue'; import { handleGiteaPullRequest } from './handlePullRequest'; @@ -14,6 +16,7 @@ import { giteaIssueWebhookSchema, giteaPullRequestCommentWebhookSchema, giteaPullRequestWebhookSchema, + giteaPushWebhookSchema, giteaWorkflowRunWebhookSchema, } from './types'; import { verifyGiteaWebhook } from './verifyWebhook'; @@ -119,6 +122,20 @@ gitea.post('/', async (c) => { return c.json({ message: 'webhook_processed' }); } + if (eventName === 'push') { + const payload = giteaPushWebhookSchema.parse(parsedJson); + + await recordWebhook( + deliveryId, + 'push', + payload, + () => handleMergeAnnouncerPush(normalizeGiteaPush(payload)), + { provider: 'gitea' }, + ); + + return c.json({ message: 'webhook_processed' }); + } + if (eventName !== 'pull_request' && eventName !== 'pull_request_sync') { await recordWebhook( deliveryId, diff --git a/apps/api/src/handlers/gitea/types.ts b/apps/api/src/handlers/gitea/types.ts index 90e9758ff..14154592a 100644 --- a/apps/api/src/handlers/gitea/types.ts +++ b/apps/api/src/handlers/gitea/types.ts @@ -176,3 +176,33 @@ export const giteaWorkflowRunWebhookSchema = z export type GiteaWorkflowRunWebhook = z.infer< typeof giteaWorkflowRunWebhookSchema >; + +const giteaPushCommitSchema = z + .object({ + id: z.string(), + message: z.string(), + url: z.string().optional(), + author: giteaUserSchema + .extend({ + name: z.string().optional(), + email: z.string().optional(), + }) + .optional(), + }) + .passthrough(); + +export const giteaPushWebhookSchema = z + .object({ + ref: z.string(), + deleted: z.boolean().optional(), + compare_url: z.string().nullable().optional(), + commits: z.array(giteaPushCommitSchema), + pusher: giteaUserSchema.extend({ name: z.string().optional() }).optional(), + sender: giteaUserSchema.optional(), + repository: giteaRepositorySchema.extend({ + default_branch: z.string().optional(), + }), + }) + .passthrough(); + +export type GiteaPushWebhook = z.infer; diff --git a/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts b/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts index 2947eaf23..d07bc57da 100644 --- a/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts +++ b/apps/api/src/handlers/github/__tests__/handleInstallationCreated.test.ts @@ -1,8 +1,12 @@ -const { mockCompletePendingGitHubInstallation, mockSendUserDirectMessage } = - vi.hoisted(() => ({ - mockCompletePendingGitHubInstallation: vi.fn(), - mockSendUserDirectMessage: vi.fn(), - })); +const { + mockCompletePendingGitHubInstallation, + mockSendUserDirectMessage, + mockRequestBrainBackfill, +} = vi.hoisted(() => ({ + mockCompletePendingGitHubInstallation: vi.fn(), + mockSendUserDirectMessage: vi.fn(), + mockRequestBrainBackfill: vi.fn(async () => undefined), +})); vi.mock('@roomote/github', () => ({ completePendingGitHubInstallation: mockCompletePendingGitHubInstallation, @@ -12,6 +16,10 @@ vi.mock('@roomote/sdk/server', () => ({ sendUserDirectMessageBestEffort: mockSendUserDirectMessage, })); +vi.mock('@roomote/sdk/server/request-instance-ping', () => ({ + requestBrainBackfill: mockRequestBrainBackfill, +})); + vi.mock('@roomote/env', () => ({ Env: { R_APP_URL: 'https://roomote.example.com' }, })); @@ -60,6 +68,19 @@ describe('handleInstallationCreated', () => { expect(mockSendUserDirectMessage).not.toHaveBeenCalled(); }); + it('kicks the Memory backfill for pending and direct installs alike', async () => { + mockCompletePendingGitHubInstallation.mockResolvedValue({ + success: false, + error: 'no pending installation', + }); + + await handleInstallationCreated(payload); + + expect(mockRequestBrainBackfill).toHaveBeenCalledWith( + 'github-installation-created', + ); + }); + it('still acks the webhook when completion throws', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockCompletePendingGitHubInstallation.mockRejectedValue( diff --git a/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts b/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts index 5fda2258e..7923811c4 100644 --- a/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts +++ b/apps/api/src/handlers/github/__tests__/handlePrComment.test.ts @@ -5,6 +5,8 @@ const { mockFindLatestTaskRun, mockGetTaskChannelBindings, mockPublishGithubPrReviewCheck, + mockAcquireGithubPrReviewLifecycleLock, + mockReleaseGithubPrReviewLifecycleLock, MockSnapshotResumeAlreadyExistsError, } = vi.hoisted(() => ({ mockGetGitHubAutomationTargets: vi.fn(), @@ -13,6 +15,10 @@ const { mockFindLatestTaskRun: vi.fn(), mockGetTaskChannelBindings: vi.fn(), mockPublishGithubPrReviewCheck: vi.fn(), + mockAcquireGithubPrReviewLifecycleLock: vi.fn(), + mockReleaseGithubPrReviewLifecycleLock: Object.assign(vi.fn(), { + signal: new AbortController().signal, + }), MockSnapshotResumeAlreadyExistsError: class extends Error {}, })); @@ -40,6 +46,7 @@ vi.mock('@roomote/github', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + acquireGithubPrReviewLifecycleLock: mockAcquireGithubPrReviewLifecycleLock, ensureSnapshotResumeGitHubFollowUpFallback: vi.fn(), publishGithubPrReviewCheck: mockPublishGithubPrReviewCheck, })); @@ -132,6 +139,9 @@ describe('handlePrComment', () => { request: vi.fn(), }); mockGetTaskChannelBindings.mockResolvedValue(null); + mockAcquireGithubPrReviewLifecycleLock.mockResolvedValue( + mockReleaseGithubPrReviewLifecycleLock, + ); }); it('returns a normal delivery failure when another GitHub resume wins', async () => { diff --git a/apps/api/src/handlers/github/__tests__/handlePrSynchronize.test.ts b/apps/api/src/handlers/github/__tests__/handlePrSynchronize.test.ts index 5a9130645..8fda37c31 100644 --- a/apps/api/src/handlers/github/__tests__/handlePrSynchronize.test.ts +++ b/apps/api/src/handlers/github/__tests__/handlePrSynchronize.test.ts @@ -3,7 +3,7 @@ import { TaskPayloadKind } from '@roomote/types'; import type { WebhookPullRequestSynchronize } from '../types'; const { - mockAcquireRedisLock, + mockAcquireGithubPrReviewLifecycleLock, mockEnqueueActivePrReviewFollowUp, mockPublishGithubPrReviewCheck, mockEnqueueTask, @@ -16,24 +16,22 @@ const { mockUpdateSet, mockUpdateWhere, } = vi.hoisted(() => ({ - mockAcquireRedisLock: vi.fn(), + mockAcquireGithubPrReviewLifecycleLock: vi.fn(), mockEnqueueActivePrReviewFollowUp: vi.fn(), mockPublishGithubPrReviewCheck: vi.fn(), mockEnqueueTask: vi.fn(), mockGetGitHubAutomationTargets: vi.fn(), mockGetCurrentGitHubPrHeadSha: vi.fn(), mockFindFirstLockedRun: vi.fn(), - mockReleaseLock: vi.fn().mockResolvedValue(undefined), + mockReleaseLock: Object.assign(vi.fn().mockResolvedValue(undefined), { + signal: new AbortController().signal, + }), mockSelect: vi.fn(), mockUpdate: vi.fn(), mockUpdateSet: vi.fn(), mockUpdateWhere: vi.fn(), })); -vi.mock('@roomote/redis', () => ({ - acquireRedisLock: (...args: unknown[]) => mockAcquireRedisLock(...args), -})); - vi.mock('@roomote/cloud-agents/server', () => ({ enqueueTask: (...args: unknown[]) => mockEnqueueTask(...args), })); @@ -44,6 +42,8 @@ vi.mock('../currentPrHead', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + acquireGithubPrReviewLifecycleLock: (...args: unknown[]) => + mockAcquireGithubPrReviewLifecycleLock(...args), enqueueActivePrReviewFollowUp: (...args: unknown[]) => mockEnqueueActivePrReviewFollowUp(...args), publishGithubPrReviewCheck: (...args: unknown[]) => @@ -126,7 +126,7 @@ const payload = { describe('handlePrSynchronize', () => { beforeEach(() => { vi.clearAllMocks(); - mockAcquireRedisLock.mockResolvedValue(mockReleaseLock); + mockAcquireGithubPrReviewLifecycleLock.mockResolvedValue(mockReleaseLock); mockEnqueueActivePrReviewFollowUp.mockResolvedValue(undefined); mockUpdate.mockReturnValue({ set: mockUpdateSet }); mockUpdateSet.mockReturnValue({ where: mockUpdateWhere }); @@ -242,6 +242,7 @@ describe('handlePrSynchronize', () => { expect(mockEnqueueActivePrReviewFollowUp).toHaveBeenCalledWith( expect.objectContaining({ + installationId: 1, runId: 100, taskId: 'task-100', sandboxServerUrl: 'http://sandbox.test', @@ -277,6 +278,7 @@ describe('handlePrSynchronize', () => { taskId: 'task-100', runId: 100, status: 'in_progress', + signal: mockReleaseLock.signal, }); expect(mockReleaseLock).toHaveBeenCalledOnce(); }); @@ -311,6 +313,7 @@ describe('handlePrSynchronize', () => { expect(mockEnqueueActivePrReviewFollowUp).toHaveBeenCalledWith( expect.objectContaining({ + installationId: 1, runId: 100, taskId: 'task-100', eventHeadSha: 'new-head', @@ -404,6 +407,7 @@ describe('handlePrSynchronize', () => { headSha: 'new-head', taskId: 'task-100', runId: 200, + signal: mockReleaseLock.signal, }); expect(mockReleaseLock).toHaveBeenCalledOnce(); }); @@ -415,7 +419,7 @@ describe('handlePrSynchronize', () => { 'Could not resolve the live head for owner/repo#42.', ); - expect(mockAcquireRedisLock).toHaveBeenCalledOnce(); + expect(mockAcquireGithubPrReviewLifecycleLock).toHaveBeenCalledOnce(); expect(mockReleaseLock).toHaveBeenCalledOnce(); expect(mockEnqueueTask).not.toHaveBeenCalled(); }); diff --git a/apps/api/src/handlers/github/__tests__/index.test.ts b/apps/api/src/handlers/github/__tests__/index.test.ts index dbac48847..7cdccb732 100644 --- a/apps/api/src/handlers/github/__tests__/index.test.ts +++ b/apps/api/src/handlers/github/__tests__/index.test.ts @@ -12,6 +12,8 @@ const { mockHandlePrReopen, mockHandlePrSynchronize, mockHandlePushConflictCheck, + mockHandleMergeAnnouncerPush, + mockGetInstallationOctokit, mockQueueBaseBranchMergeabilityCheck, mockQueueTrackedPullRequestMergeabilityCheck, mockIsRepoSkipped, @@ -48,6 +50,8 @@ const { mockHandlePrReopen: vi.fn(), mockHandlePrSynchronize: vi.fn(), mockHandlePushConflictCheck: vi.fn(), + mockHandleMergeAnnouncerPush: vi.fn(), + mockGetInstallationOctokit: vi.fn(), mockQueueBaseBranchMergeabilityCheck: vi.fn(), mockQueueTrackedPullRequestMergeabilityCheck: vi.fn(), mockIsRepoSkipped: vi.fn(), @@ -110,12 +114,14 @@ vi.mock('@roomote/db/server', () => ({ })); vi.mock('@roomote/github', () => ({ + getInstallationOctokit: mockGetInstallationOctokit, isRepoSkipped: mockIsRepoSkipped, resolveConfiguredGitHubAppSlug: mockResolveConfiguredGitHubAppSlug, resolveGitHubRoomoteMentionEnabled: mockResolveGitHubRoomoteMentionEnabled, })); vi.mock('@roomote/sdk/server', () => ({ + handleMergeAnnouncerPush: mockHandleMergeAnnouncerPush, updateTaskPrStatus: mockUpdateTaskPrStatus, upsertGitHubPullRequestFactFromWebhook: mockUpsertGitHubPullRequestFactFromWebhook, @@ -220,6 +226,7 @@ function makePullRequestPayload( updated_at: '2026-08-06T12:00:00Z', user: { login: 'author' }, merged_by: null, + base: { ref: 'develop' }, ...overrides, }, sender: { login: 'actor' }, @@ -244,6 +251,7 @@ describe('github webhook router', () => { mockHandlePrReopen.mockReset(); mockHandlePrSynchronize.mockReset(); mockHandlePushConflictCheck.mockReset(); + mockHandleMergeAnnouncerPush.mockReset(); mockIsRepoSkipped.mockReset(); mockQueuePrReviewActivityNotification.mockReset(); mockQueuePrReviewSummaryNotification.mockReset(); @@ -268,6 +276,8 @@ describe('github webhook router', () => { mockHandlePrComment.mockResolvedValue({ status: 'ok' }); mockHandleGitHubIssueComment.mockResolvedValue({ status: 'ok' }); mockHandleGitHubIssueFixer.mockResolvedValue({ status: 'ok' }); + mockHandlePushConflictCheck.mockResolvedValue({ status: 'ok' }); + mockHandleMergeAnnouncerPush.mockResolvedValue({ status: 'ok' }); mockRecordWebhook.mockImplementation( async ( _deliveryId: string, @@ -1002,6 +1012,7 @@ describe('github webhook router', () => { updated_at: '2026-08-06T12:00:00Z', user: { login: 'author' }, merged_by: merged ? { login: 'merger' } : null, + base: { ref: 'develop' }, }, sender: { login: merged ? 'merger' : 'closer' }, }; @@ -1030,6 +1041,7 @@ describe('github webhook router', () => { expect.objectContaining({ repository: 'test-org/test-repo', prNumber: 42, + targetBranch: 'develop', status, }), ); @@ -1047,7 +1059,16 @@ describe('github webhook router', () => { 'x-github-event': 'push', 'x-hub-signature-256': 'sha256=test', }, - body: JSON.stringify({ ref: 'refs/heads/main' }), + body: JSON.stringify({ + ref: 'refs/heads/main', + repository: { + id: 10, + full_name: 'test-org/test-repo', + html_url: 'https://github.com/test-org/test-repo', + }, + pusher: { name: 'actor' }, + commits: [{ id: 'abc', message: 'Ship change' }], + }), }); expect(response.status).toBe(200); @@ -1056,6 +1077,13 @@ describe('github webhook router', () => { ); expect(webhooksConstructorParams).toEqual([{ secret: 'db-only-secret' }]); expect(mockHandlePushConflictCheck).toHaveBeenCalled(); + expect(mockHandleMergeAnnouncerPush).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'github', + ref: 'refs/heads/main', + pusher: 'actor', + }), + ); }); it('refreshes GitHub mention settings before dispatching handlers', async () => { @@ -1080,6 +1108,48 @@ describe('github webhook router', () => { ).toBeLessThan(mockVerifyAndReceive.mock.invocationCallOrder[0]!); }); + it('returns Merge announcer failures to webhook audit recording', async () => { + let handlerResult: unknown; + mockHandleMergeAnnouncerPush.mockResolvedValue({ + status: 'error', + message: 'delivery failed', + }); + mockRecordWebhook.mockImplementation( + async ( + _deliveryId: string, + _event: string, + _payload: unknown, + handler: () => Promise, + ) => { + handlerResult = await handler(); + }, + ); + + const response = await app.request('http://localhost/api/webhooks/github', { + method: 'POST', + headers: { + 'x-github-delivery': 'delivery-announcer-failure', + 'x-github-event': 'push', + 'x-hub-signature-256': 'sha256=test', + }, + body: JSON.stringify({ + ref: 'refs/heads/main', + repository: { + id: 10, + full_name: 'test-org/test-repo', + html_url: 'https://github.com/test-org/test-repo', + }, + commits: [{ id: 'abc', message: 'Ship change' }], + }), + }); + + expect(response.status).toBe(200); + expect(handlerResult).toEqual({ + status: 'error', + message: 'delivery failed', + }); + }); + it('returns 401 without an installation lookup when the signature is invalid', async () => { mockVerify.mockResolvedValue(false); diff --git a/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts b/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts index f083292aa..e1f08218e 100644 --- a/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts +++ b/apps/api/src/handlers/github/__tests__/notifyPullRequestTerminalStatus.test.ts @@ -81,11 +81,15 @@ vi.mock('@roomote/db/server', () => ({ }, }, tasks: {}, - taskRuns: {}, + taskRuns: { + createdAt: 'taskRuns.createdAt', + id: 'taskRuns.id', + }, slackInstallations: {}, githubInstallations: {}, taskPullRequests: {}, eq: vi.fn((...args: unknown[]) => ({ eq: args })), + desc: vi.fn((column: unknown) => ({ desc: column })), and: vi.fn((...args: unknown[]) => ({ and: args })), inArray: vi.fn((...args: unknown[]) => ({ inArray: args })), isNotNull: vi.fn((column: unknown) => ({ isNotNull: column })), @@ -94,7 +98,7 @@ vi.mock('@roomote/db/server', () => ({ like: vi.fn((...args: unknown[]) => ({ like: args })), })); -import { db } from '@roomote/db/server'; +import { db, desc, taskRuns } from '@roomote/db/server'; import { notifyPullRequestTerminalStatus, SLACK_PR_CLOSED_REACTION_EMOJI, @@ -270,60 +274,42 @@ describe('notifyPullRequestTerminalStatus', () => { expect(SLACK_PR_CLOSED_REACTION_EMOJI).toBe('-1'); }); - it.each([ - { - label: 'direct task-run', - payload: { - communicationProvider: 'slack', - communicationChannelId: 'CSHARED', - communicationThreadId: 'shared-thread-ts', + it('delivers a direct task-run Slack binding for a non-Fast task', async () => { + mockedGithubFind.mockResolvedValue({ id: 1 } as any); + mockedTaskPullRequestsFind.mockResolvedValue([{ taskId: 'task-1' }] as any); + mockedTaskRunsFind.mockResolvedValue([ + { + taskId: 'task-1', + payload: { + communicationProvider: 'slack', + communicationChannelId: 'CSHARED', + communicationThreadId: 'shared-thread-ts', + }, }, - }, - { - label: 'Fast-parent', - payload: fastParentSlackPayload('CSHARED', 'shared-thread-ts'), - }, - ])( - 'normalizes $label Slack bindings through one delivery path', - async ({ payload }) => { - mockedGithubFind.mockResolvedValue({ id: 1 } as any); - mockedTaskPullRequestsFind.mockResolvedValue([ - { taskId: 'task-1' }, - ] as any); - mockedTaskRunsFind.mockResolvedValue([ - { taskId: 'task-1', payload }, - ] as any); - mockedSlackFind.mockResolvedValue({ - botAccessToken: 'xoxb-token', - } as any); - - await notifyPullRequestTerminalStatus({ - ...baseParams, - status: 'closed', - actorLogin: 'closer', - }); - - expect(mockStickyFooterPost).toHaveBeenCalledWith( - expect.objectContaining({ - channel: 'CSHARED', - threadTs: 'shared-thread-ts', - taskId: 'task-1', - }), - ); - expect(mockAddReaction).toHaveBeenCalledWith({ - channel: 'CSHARED', - timestamp: 'shared-thread-ts', - name: SLACK_PR_CLOSED_REACTION_EMOJI, - }); - expect(mockRemoveReaction).toHaveBeenCalledWith({ + ] as any); + mockedSlackFind.mockResolvedValue({ botAccessToken: 'xoxb-token' } as any); + + await notifyPullRequestTerminalStatus({ + ...baseParams, + status: 'closed', + actorLogin: 'closer', + }); + + expect(mockStickyFooterPost).toHaveBeenCalledWith( + expect.objectContaining({ channel: 'CSHARED', - timestamp: 'shared-thread-ts', - name: 'eyes', - }); - }, - ); + threadTs: 'shared-thread-ts', + taskId: 'task-1', + }), + ); + expect(mockAddReaction).toHaveBeenCalledWith({ + channel: 'CSHARED', + timestamp: 'shared-thread-ts', + name: SLACK_PR_CLOSED_REACTION_EMOJI, + }); + }); - it('deduplicates an overlapping Fast-parent binding when cleanup rejects', async () => { + it('skips direct delivery for a Fast-parent task', async () => { mockedGithubFind.mockResolvedValue({ id: 1 } as any); mockedTaskPullRequestsFind.mockResolvedValue([{ taskId: 'task-1' }] as any); mockedTasksFind.mockResolvedValue([ @@ -331,7 +317,7 @@ describe('notifyPullRequestTerminalStatus', () => { id: 'task-1', slackThreadTs: 'thread-ts-1', slackChannelId: 'C123', - linearSessionId: null, + linearSessionId: 'linear-session-1', }, ] as any); mockedTaskRunsFind.mockResolvedValue([ @@ -339,15 +325,51 @@ describe('notifyPullRequestTerminalStatus', () => { taskId: 'task-1', payload: fastParentSlackPayload('C123', 'thread-ts-1'), }, + { taskId: 'task-1', payload: teamsPayload }, + { taskId: 'task-1', payload: telegramPayload }, + { taskId: 'task-1', payload: discordPayload }, ] as any); mockedSlackFind.mockResolvedValue({ botAccessToken: 'xoxb-token' } as any); - mockRemoveReaction.mockRejectedValueOnce(new Error('Slack unavailable')); await notifyPullRequestTerminalStatus(baseParams); - expect(mockStickyFooterPost).toHaveBeenCalledTimes(1); - expect(mockAddReaction).toHaveBeenCalledTimes(1); - expect(mockRemoveReaction).toHaveBeenCalledTimes(1); + expect(vi.mocked(desc)).toHaveBeenNthCalledWith(1, taskRuns.createdAt); + expect(vi.mocked(desc)).toHaveBeenNthCalledWith(2, taskRuns.id); + expect(mockStickyFooterPost).not.toHaveBeenCalled(); + expect(mockPostMessage).not.toHaveBeenCalled(); + expect(mockAddReaction).not.toHaveBeenCalled(); + expect(mockRemoveReaction).not.toHaveBeenCalled(); + expect(mockCreateLinearClient).not.toHaveBeenCalled(); + }); + + it('preserves direct delivery when only an older run had a Fast parent', async () => { + mockedGithubFind.mockResolvedValue({ id: 1 } as any); + mockedTaskPullRequestsFind.mockResolvedValue([{ taskId: 'task-1' }] as any); + mockedTaskRunsFind.mockResolvedValue([ + { + taskId: 'task-1', + payload: { + communicationProvider: 'slack', + communicationChannelId: 'CNEW', + communicationThreadId: 'new-thread-ts', + }, + }, + { + taskId: 'task-1', + payload: fastParentSlackPayload('COLD', 'old-thread-ts'), + }, + ] as any); + mockedSlackFind.mockResolvedValue({ botAccessToken: 'xoxb-token' } as any); + + await notifyPullRequestTerminalStatus(baseParams); + + expect(mockStickyFooterPost).toHaveBeenCalledWith( + expect.objectContaining({ + channel: 'CNEW', + threadTs: 'new-thread-ts', + taskId: 'task-1', + }), + ); }); it('reports a rejected terminal reaction without failing the status post', async () => { diff --git a/apps/api/src/handlers/github/handleInstallationCreated.ts b/apps/api/src/handlers/github/handleInstallationCreated.ts index 46433f31c..8036d02a1 100644 --- a/apps/api/src/handlers/github/handleInstallationCreated.ts +++ b/apps/api/src/handlers/github/handleInstallationCreated.ts @@ -1,5 +1,6 @@ import { completePendingGitHubInstallation } from '@roomote/github'; import { sendUserDirectMessageBestEffort } from '@roomote/sdk/server'; +import { requestBrainBackfill } from '@roomote/sdk/server/request-instance-ping'; import { Env } from '@roomote/env'; import type { WebhookResponse } from '../../types'; @@ -20,6 +21,12 @@ export async function handleInstallationCreated( payload.installation.id, ); + // The webhook is the universal completion point for new installations + // (pending-approval and direct installs alike): repositories just became + // reachable, so start Memory ingestion now rather than waiting out the + // 15-minute schedules. + void requestBrainBackfill('github-installation-created'); + if (result.success) { // The requester was waiting on a GitHub org owner's approval; let them // know on whichever chat integrations they have linked. diff --git a/apps/api/src/handlers/github/handlePrComment.ts b/apps/api/src/handlers/github/handlePrComment.ts index f6b1f0b24..52581739a 100644 --- a/apps/api/src/handlers/github/handlePrComment.ts +++ b/apps/api/src/handlers/github/handlePrComment.ts @@ -12,6 +12,7 @@ import { } from '@roomote/db/server'; import { getInstallationOctokit } from '@roomote/github'; import { + acquireGithubPrReviewLifecycleLock, ensureSnapshotResumeGitHubFollowUpFallback, publishGithubPrReviewCheck, } from '@roomote/sdk/server'; @@ -1350,40 +1351,57 @@ export async function handlePrComment( ); } - const reviewLaunch = await enqueueTask({ - task: { - type: TaskPayloadKind.GithubPrReview, - githubLogin: reviewer.properties.githubLogin, - githubUserId: reviewer.properties.githubUserId, - payload: reviewPayload, - }, - // A human @roomote mention started this review. - initiator: { kind: 'user', userId: reviewer.properties.userId }, - workflow: 'pr_review', - surface: 'github', - trigger: 'message', - prLinkage: { - provider: 'github', - host: reviewer.repo.host ?? toHostFromUrl(prUrl) ?? 'github.com', - repositoryId: reviewer.repo.id, - repository: repository.full_name, - prNumber: pr.number, - prUrl, - prTitle: pr.title, - prSha: headSha, - }, - }); - if (reviewer.settings?.publishGithubCheck) { - await publishGithubPrReviewCheck({ - installationId: githubInstallationId, - repository: repository.full_name, - prNumber: pr.number, - headSha, - taskId: reviewLaunch.taskId, - runId: reviewLaunch.id, + const releaseLifecycleLock = await acquireGithubPrReviewLifecycleLock( + repository.full_name, + pr.number, + ); + if (!releaseLifecycleLock) { + throw new Error( + `Timed out serializing PR review launch for ${repository.full_name}#${pr.number}`, + ); + } + + try { + releaseLifecycleLock.signal.throwIfAborted(); + const reviewLaunch = await enqueueTask({ + task: { + type: TaskPayloadKind.GithubPrReview, + githubLogin: reviewer.properties.githubLogin, + githubUserId: reviewer.properties.githubUserId, + payload: reviewPayload, + }, + // A human @roomote mention started this review. + initiator: { kind: 'user', userId: reviewer.properties.userId }, + workflow: 'pr_review', + surface: 'github', + trigger: 'message', + prLinkage: { + provider: 'github', + host: reviewer.repo.host ?? toHostFromUrl(prUrl) ?? 'github.com', + repositoryId: reviewer.repo.id, + repository: repository.full_name, + prNumber: pr.number, + prUrl, + prTitle: pr.title, + prSha: headSha, + }, }); + if (reviewer.settings?.publishGithubCheck) { + releaseLifecycleLock.signal.throwIfAborted(); + await publishGithubPrReviewCheck({ + installationId: githubInstallationId, + repository: repository.full_name, + prNumber: pr.number, + headSha, + taskId: reviewLaunch.taskId, + runId: reviewLaunch.id, + signal: releaseLifecycleLock.signal, + }); + } + reviewLaunches.push(reviewLaunch); + } finally { + await releaseLifecycleLock(); } - reviewLaunches.push(reviewLaunch); } catch (error) { failedReviewerIds.push(reviewer.id); console.warn( diff --git a/apps/api/src/handlers/github/handlePrOpen.ts b/apps/api/src/handlers/github/handlePrOpen.ts index b7d1fc256..b38823ef5 100644 --- a/apps/api/src/handlers/github/handlePrOpen.ts +++ b/apps/api/src/handlers/github/handlePrOpen.ts @@ -7,7 +7,10 @@ import { TaskPayloadKind, } from '@roomote/types'; import { enqueueTask } from '@roomote/cloud-agents/server'; -import { publishGithubPrReviewCheck } from '@roomote/sdk/server'; +import { + acquireGithubPrReviewLifecycleLock, + publishGithubPrReviewCheck, +} from '@roomote/sdk/server'; import type { WebhookResponse } from '../../types'; import { toHostFromUrl } from '../utils'; @@ -100,54 +103,71 @@ export async function handlePrOpen( reviewerSettings: target.settings, }); - const launch = await enqueueTask({ - task: { - type: TaskPayloadKind.GithubPrReview, - ...getBackgroundGithubTaskProperties(target.properties), - payload: { - repo: repository.full_name, + const releaseLifecycleLock = await acquireGithubPrReviewLifecycleLock( + repository.full_name, + pr.number, + ); + if (!releaseLifecycleLock) { + throw new Error( + `Timed out serializing PR review launch for ${repository.full_name}#${pr.number}`, + ); + } + + try { + releaseLifecycleLock.signal.throwIfAborted(); + const launch = await enqueueTask({ + task: { + type: TaskPayloadKind.GithubPrReview, + ...getBackgroundGithubTaskProperties(target.properties), + payload: { + repo: repository.full_name, + prNumber: pr.number, + prTitle: pr.title, + prUrl: pr.html_url, + headSha, + branchName: pr.head.ref, + ...relayPayload, + } satisfies TaskPayload, + }, + initiator: { + kind: 'automation', + key: 'review_code', + actor: { externalId: String(sender.id), displayName: sender.login }, + }, + workflow: 'pr_review', + surface: 'github', + trigger: 'webhook', + prLinkage: { + provider: 'github', + host: target.repo.host ?? toHostFromUrl(pr.html_url) ?? 'github.com', + repositoryId: target.repo.id, + repository: repository.full_name, prNumber: pr.number, - prTitle: pr.title, prUrl: pr.html_url, - headSha, - branchName: pr.head.ref, - ...relayPayload, - } satisfies TaskPayload, - }, - initiator: { - kind: 'automation', - key: 'review_code', - actor: { externalId: String(sender.id), displayName: sender.login }, - }, - workflow: 'pr_review', - surface: 'github', - trigger: 'webhook', - prLinkage: { - provider: 'github', - host: target.repo.host ?? toHostFromUrl(pr.html_url) ?? 'github.com', - repositoryId: target.repo.id, - repository: repository.full_name, - prNumber: pr.number, - prUrl: pr.html_url, - prTitle: pr.title, - prSha: headSha, - prBaseRef: pr.base?.ref ?? null, - prBaseSha: pr.base?.sha ?? null, - }, - }); - - if (target.settings?.publishGithubCheck) { - await publishGithubPrReviewCheck({ - installationId: installation!.id, - repository: repository.full_name, - prNumber: pr.number, - headSha, - taskId: launch.taskId, - runId: launch.id, + prTitle: pr.title, + prSha: headSha, + prBaseRef: pr.base?.ref ?? null, + prBaseSha: pr.base?.sha ?? null, + }, }); - } - return launch; + if (target.settings?.publishGithubCheck) { + releaseLifecycleLock.signal.throwIfAborted(); + await publishGithubPrReviewCheck({ + installationId: installation!.id, + repository: repository.full_name, + prNumber: pr.number, + headSha, + taskId: launch.taskId, + runId: launch.id, + signal: releaseLifecycleLock.signal, + }); + } + + return launch; + } finally { + await releaseLifecycleLock(); + } }); return { diff --git a/apps/api/src/handlers/github/handlePrSynchronize.ts b/apps/api/src/handlers/github/handlePrSynchronize.ts index 2a4013e72..c1af042df 100644 --- a/apps/api/src/handlers/github/handlePrSynchronize.ts +++ b/apps/api/src/handlers/github/handlePrSynchronize.ts @@ -24,8 +24,8 @@ import { sql, } from '@roomote/db/server'; import { enqueueTask } from '@roomote/cloud-agents/server'; -import { acquireRedisLock } from '@roomote/redis'; import { + acquireGithubPrReviewLifecycleLock, enqueueActivePrReviewFollowUp, publishGithubPrReviewCheck, } from '@roomote/sdk/server'; @@ -118,22 +118,6 @@ async function findExistingReviewTask(repository: string, prNumber: number) { return existingTask; } -async function acquirePrReviewLaunchLock(repository: string, prNumber: number) { - const key = `pr-review-synchronize:${repository}:${prNumber}`; - - for (let attempt = 0; attempt < 100; attempt++) { - const release = await acquireRedisLock(key, { ttlSeconds: 30 }); - - if (release) { - return release; - } - - await new Promise((resolve) => setTimeout(resolve, 100)); - } - - return null; -} - export async function handlePrSynchronize({ installation, repository, @@ -181,7 +165,7 @@ export async function handlePrSynchronize({ } const enqueued = await pMap(targets, async (currentTarget) => { - const releaseLaunchLock = await acquirePrReviewLaunchLock( + const releaseLaunchLock = await acquireGithubPrReviewLifecycleLock( repository.full_name, pr.number, ); @@ -193,6 +177,7 @@ export async function handlePrSynchronize({ } try { + releaseLaunchLock.signal.throwIfAborted(); const headSha = await getCurrentGitHubPrHeadSha({ installationId: installation!.id, repository: repository.full_name, @@ -308,6 +293,7 @@ export async function handlePrSynchronize({ }); await enqueueActivePrReviewFollowUp({ + installationId: installation!.id, runId: followUpRun.id, taskId: followUpRun.taskId, sandboxServerUrl: followUpRun.sandboxServerUrl!, @@ -351,6 +337,7 @@ export async function handlePrSynchronize({ }, }); if (currentTarget.settings?.publishGithubCheck) { + releaseLaunchLock.signal.throwIfAborted(); await publishGithubPrReviewCheck({ installationId: installation!.id, repository: repository.full_name, @@ -359,6 +346,7 @@ export async function handlePrSynchronize({ taskId: followUpRun.taskId, runId: followUpRun.id, status: 'in_progress', + signal: releaseLaunchLock.signal, }); } queuedActiveReviewFollowUp = true; @@ -458,6 +446,7 @@ export async function handlePrSynchronize({ }); if (currentTarget.settings?.publishGithubCheck) { + releaseLaunchLock.signal.throwIfAborted(); await publishGithubPrReviewCheck({ installationId: installation!.id, repository: repository.full_name, @@ -465,6 +454,7 @@ export async function handlePrSynchronize({ headSha, taskId: launch.taskId, runId: launch.id, + signal: releaseLaunchLock.signal, }); } diff --git a/apps/api/src/handlers/github/index.ts b/apps/api/src/handlers/github/index.ts index a3f61c976..4b885b5c7 100644 --- a/apps/api/src/handlers/github/index.ts +++ b/apps/api/src/handlers/github/index.ts @@ -8,6 +8,7 @@ import { resolveGitHubRoomoteMentionEnabled, } from '@roomote/github'; import { + handleMergeAnnouncerPush, recordPrStatusChangeInTaskHistory, updateTaskPrStatus, upsertGitHubPullRequestFactFromWebhook, @@ -52,6 +53,10 @@ import { handleInstallationRepositoriesChange } from './handleInstallationReposi // Utilities: import { isFromKnownInstallation } from './isFromKnownInstallation'; import { recordWebhook } from './recordWebhook'; +import { + enrichGitHubMergeAnnouncerEvent, + normalizeGitHubPush, +} from '../merge-announcer-push'; /** * Fire-and-forget PR status update. Logs errors but never throws. @@ -548,11 +553,20 @@ github.post('/', async (c) => { webhooks.on('push', ({ id, name, payload }) => recordWebhook(id, name, payload, async () => { - const [result] = await Promise.all([ + const mergeAnnouncerEvent = normalizeGitHubPush(payload); + const [result, , mergeAnnouncerResult] = await Promise.all([ handlePushConflictCheck(payload), queueBaseBranchMergeabilityCheck(payload), + mergeAnnouncerEvent + ? enrichGitHubMergeAnnouncerEvent( + payload, + mergeAnnouncerEvent, + ).then(handleMergeAnnouncerPush) + : Promise.resolve({ status: 'ok' as const }), ]); - return result; + return mergeAnnouncerResult.status === 'error' + ? mergeAnnouncerResult + : result; }), ); @@ -645,6 +659,7 @@ github.post('/', async (c) => { prNumber: payload.pull_request.number, prTitle: payload.pull_request.title, prUrl: payload.pull_request.html_url, + targetBranch: payload.pull_request.base.ref, status, actorLogin: (payload.pull_request.merged diff --git a/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts b/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts index 18e762503..fd777e9d2 100644 --- a/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts +++ b/apps/api/src/handlers/github/notifyPullRequestTerminalStatus.ts @@ -6,6 +6,7 @@ import { taskRuns, tasks, eq, + desc, and, inArray, isNull, @@ -128,23 +129,14 @@ type SlackReplyTarget = { }; function resolveSlackReplyTarget(payload: unknown): SlackReplyTarget | null { - const directReplyTarget = - getCommunicationProviderFromTaskPayload(payload) === 'slack' - ? { - channelId: getCommunicationChannelFromTaskPayload(payload), - threadId: getCommunicationThreadIdFromTaskPayload(payload), - } - : null; - const fastConversation = getFastAgentParentFromPayload(payload)?.conversation; - const fastReplyTarget = - fastConversation?.surface === 'slack' ? fastConversation.replyTarget : null; - - return ( - [directReplyTarget, fastReplyTarget].find( - (target): target is SlackReplyTarget => - Boolean(target?.channelId && target.threadId), - ) ?? null - ); + if (getCommunicationProviderFromTaskPayload(payload) !== 'slack') { + return null; + } + + const channelId = getCommunicationChannelFromTaskPayload(payload); + const threadId = getCommunicationThreadIdFromTaskPayload(payload); + + return channelId && threadId ? { channelId, threadId } : null; } function getSlackTarget(taskId: string, payload: unknown): SlackTarget | null { @@ -727,8 +719,9 @@ async function deliverLinearTerminalStatus({ } /** - * Notifies Slack, Teams, Telegram, Discord, and Linear conversations linked to a PR - * when that PR becomes terminal (merged or closed). + * Notifies non-Fast Slack, Teams, Telegram, Discord, and Linear conversations + * linked to a PR when that PR becomes terminal (merged or closed). Fast child + * tasks receive the status through their parent-session platform event instead. * * Resolves the GitHub installation gate (when provided) and provider-scoped * task-PR links once, then fans out delivery per surface: Slack sticky-footer @@ -825,6 +818,7 @@ export async function notifyPullRequestTerminalStatus({ }), db.query.taskRuns.findMany({ where: inArray(taskRuns.taskId, taskIds), + orderBy: [desc(taskRuns.createdAt), desc(taskRuns.id)], columns: { taskId: true, payload: true, @@ -832,10 +826,30 @@ export async function notifyPullRequestTerminalStatus({ }), ]); + // The latest run owns the task's current routing mode. Fast child tasks + // receive this lifecycle event through their parent session, so exclude + // every direct surface binding for those tasks. + const latestRunByTaskId = new Map(); + for (const run of linkedRuns) { + if (!latestRunByTaskId.has(run.taskId)) { + latestRunByTaskId.set(run.taskId, run); + } + } + const fastParentTaskIds = new Set( + [...latestRunByTaskId.values()] + .filter((run) => getFastAgentParentFromPayload(run.payload) !== null) + .map((run) => run.taskId), + ); + const directDeliveryTasks = linkedTasks.filter( + (task) => !fastParentTaskIds.has(task.id), + ); + const directDeliveryRuns = linkedRuns.filter( + (run) => !fastParentTaskIds.has(run.taskId), + ); const slackTargets: SlackTarget[] = []; const linearSessionIds: string[] = []; - for (const task of linkedTasks) { + for (const task of directDeliveryTasks) { if (task.slackThreadTs && task.slackChannelId) { slackTargets.push({ taskId: task.id, @@ -850,20 +864,20 @@ export async function notifyPullRequestTerminalStatus({ } slackTargets.push( - ...linkedRuns + ...directDeliveryRuns .map((run) => getSlackTarget(run.taskId, run.payload)) .filter((target): target is SlackTarget => target !== null), ); - const teamsTargets = linkedRuns + const teamsTargets = directDeliveryRuns .map((run) => getTeamsTarget(run.payload)) .filter((target): target is TeamsTarget => target !== null); - const telegramTargets = linkedRuns + const telegramTargets = directDeliveryRuns .map((run) => getTelegramTarget(run.payload)) .filter((target): target is TelegramTarget => target !== null); - const discordTargets = linkedRuns + const discordTargets = directDeliveryRuns .map((run) => getDiscordTarget(run.payload)) .filter((target): target is DiscordTarget => target !== null); diff --git a/apps/api/src/handlers/gitlab/__tests__/handleMergeRequest.test.ts b/apps/api/src/handlers/gitlab/__tests__/handleMergeRequest.test.ts index 9c8e93315..4e25acd6b 100644 --- a/apps/api/src/handlers/gitlab/__tests__/handleMergeRequest.test.ts +++ b/apps/api/src/handlers/gitlab/__tests__/handleMergeRequest.test.ts @@ -345,6 +345,9 @@ describe('handleGitLabMergeRequest', () => { 42, 'merged', ); + expect(mockRecordPrStatusChangeInTaskHistory).toHaveBeenLastCalledWith( + expect.objectContaining({ targetBranch: 'main' }), + ); expect(mockScheduleSourceControlPullRequestFactSync).toHaveBeenCalledWith({ provider: 'gitlab', repositoryFullName: 'acme/backend', diff --git a/apps/api/src/handlers/gitlab/__tests__/index.test.ts b/apps/api/src/handlers/gitlab/__tests__/index.test.ts index da0661aab..7281a0330 100644 --- a/apps/api/src/handlers/gitlab/__tests__/index.test.ts +++ b/apps/api/src/handlers/gitlab/__tests__/index.test.ts @@ -5,6 +5,7 @@ const { mockHandleGitLabNote, mockHandleGitLabIssue, mockHandleGitLabPipeline, + mockHandleMergeAnnouncerPush, mockRecordWebhook, mockResolveDeploymentEnvVar, } = vi.hoisted(() => ({ @@ -12,6 +13,7 @@ const { mockHandleGitLabNote: vi.fn(), mockHandleGitLabIssue: vi.fn(), mockHandleGitLabPipeline: vi.fn(), + mockHandleMergeAnnouncerPush: vi.fn(), mockRecordWebhook: vi.fn(), mockResolveDeploymentEnvVar: vi.fn(), })); @@ -20,6 +22,10 @@ vi.mock('@roomote/db/server', () => ({ resolveDeploymentEnvVar: mockResolveDeploymentEnvVar, })); +vi.mock('@roomote/sdk/server', () => ({ + handleMergeAnnouncerPush: mockHandleMergeAnnouncerPush, +})); + vi.mock('../../logging', () => ({ apiLogger: { debug: vi.fn(), @@ -56,6 +62,7 @@ describe('gitlab webhook router', () => { mockHandleGitLabNote.mockReset(); mockHandleGitLabIssue.mockReset(); mockHandleGitLabPipeline.mockReset(); + mockHandleMergeAnnouncerPush.mockReset(); mockRecordWebhook.mockReset(); mockResolveDeploymentEnvVar.mockReset(); // Secrets resolve through encrypted deployment env vars, matching @@ -67,6 +74,7 @@ describe('gitlab webhook router', () => { mockHandleGitLabNote.mockResolvedValue({ status: 'ok' }); mockHandleGitLabIssue.mockResolvedValue({ status: 'ok' }); mockHandleGitLabPipeline.mockResolvedValue({ status: 'ok' }); + mockHandleMergeAnnouncerPush.mockResolvedValue({ status: 'ok' }); mockRecordWebhook.mockImplementation( async ( _deliveryId: string, @@ -131,6 +139,48 @@ describe('gitlab webhook router', () => { ); }); + it('records and routes normalized push webhooks', async () => { + const payload = { + object_kind: 'push', + ref: 'refs/heads/main', + after: 'abc', + user_username: 'alice', + project: { + id: 123, + path_with_namespace: 'acme/backend', + web_url: 'https://gitlab.com/acme/backend', + }, + commits: [{ id: 'abc', message: 'Ship backend' }], + }; + + const response = await app.request('http://localhost/api/webhooks/gitlab', { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-gitlab-event': 'Push Hook', + 'x-gitlab-token': 'gitlab-secret', + 'x-gitlab-event-uuid': 'push-event-uuid', + }, + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(200); + expect(mockRecordWebhook).toHaveBeenCalledWith( + 'push-event-uuid', + 'push', + expect.anything(), + expect.any(Function), + { provider: 'gitlab' }, + ); + expect(mockHandleMergeAnnouncerPush).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'gitlab', + ref: 'refs/heads/main', + pusher: 'alice', + }), + ); + }); + it('records and routes note webhooks', async () => { const payload = { object_kind: 'note', diff --git a/apps/api/src/handlers/gitlab/handleMergeRequest.ts b/apps/api/src/handlers/gitlab/handleMergeRequest.ts index 04d1bdddf..be94510ae 100644 --- a/apps/api/src/handlers/gitlab/handleMergeRequest.ts +++ b/apps/api/src/handlers/gitlab/handleMergeRequest.ts @@ -135,6 +135,7 @@ export async function handleGitLabMergeRequest( prNumber: mergeRequest.iid, prTitle: mergeRequest.title, prUrl: mergeRequest.url, + targetBranch: mergeRequest.target_branch, status, actorLogin: payload.user?.username ?? payload.user?.name ?? 'someone on GitLab', diff --git a/apps/api/src/handlers/gitlab/index.ts b/apps/api/src/handlers/gitlab/index.ts index 167980521..28e9c0a2a 100644 --- a/apps/api/src/handlers/gitlab/index.ts +++ b/apps/api/src/handlers/gitlab/index.ts @@ -3,9 +3,11 @@ import { createHash } from 'node:crypto'; import { Hono } from 'hono'; import { resolveDeploymentEnvVar } from '@roomote/db/server'; +import { handleMergeAnnouncerPush } from '@roomote/sdk/server'; import { apiLogger, logApiError } from '../../logging'; import { recordWebhook } from '../github/recordWebhook'; +import { normalizeGitLabPush } from '../merge-announcer-push'; import { handleGitLabIssue } from './handleIssue'; import { handleGitLabMergeRequest } from './handleMergeRequest'; import { handleGitLabNote } from './handleNote'; @@ -15,6 +17,7 @@ import { gitLabMergeRequestWebhookSchema, gitLabNoteWebhookSchema, gitLabPipelineWebhookSchema, + gitLabPushWebhookSchema, } from './types'; import { verifyGitLabWebhook } from './verifyWebhook'; @@ -29,7 +32,9 @@ function getGitLabDeliveryId({ }): string { return ( headers['webhook-id'] ?? + headers['idempotency-key'] ?? headers['x-gitlab-webhook-uuid'] ?? + headers['x-gitlab-event-uuid'] ?? createHash('sha256').update(body).digest('hex') ); } @@ -107,6 +112,20 @@ gitlab.post('/', async (c) => { return c.json({ message: 'webhook_processed' }); } + if (eventName === 'Push Hook') { + const payload = gitLabPushWebhookSchema.parse(parsedJson); + + await recordWebhook( + deliveryId, + 'push', + payload, + () => handleMergeAnnouncerPush(normalizeGitLabPush(payload)), + { provider: 'gitlab' }, + ); + + return c.json({ message: 'webhook_processed' }); + } + if (eventName !== 'Merge Request Hook') { await recordWebhook( deliveryId, diff --git a/apps/api/src/handlers/gitlab/types.ts b/apps/api/src/handlers/gitlab/types.ts index 6d4ba8edb..35e9d9e4f 100644 --- a/apps/api/src/handlers/gitlab/types.ts +++ b/apps/api/src/handlers/gitlab/types.ts @@ -186,3 +186,35 @@ export const gitLabPipelineWebhookSchema = z .passthrough(); export type GitLabPipelineWebhook = z.infer; + +const gitLabPushCommitSchema = z + .object({ + id: z.string(), + message: z.string(), + url: z.string().optional(), + author: z + .object({ + name: z.string().optional(), + email: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +export const gitLabPushWebhookSchema = z + .object({ + object_kind: z.literal('push'), + ref: z.string(), + after: z.string(), + compare: z.string().nullable().optional(), + total_commits_count: z.number().optional(), + user_name: z.string().optional(), + user_username: z.string().optional(), + user_email: z.string().optional(), + project: gitLabProjectSchema, + commits: z.array(gitLabPushCommitSchema), + }) + .passthrough(); + +export type GitLabPushWebhook = z.infer; diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts index c976adfbe..3354a5db4 100644 --- a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -20,6 +20,13 @@ const { })); vi.mock('@roomote/db/server', () => ({ + DevLoginInferencePlaceholderError: class DevLoginInferencePlaceholderError extends Error { + constructor() { + super( + 'Local development login uses an intentionally invalid inference key.', + ); + } + }, db: { query: { taskRuns: { findFirst: mockFindTaskRun }, @@ -39,6 +46,7 @@ vi.mock('@roomote/sdk/server', () => ({ import { inference } from '../index'; import { resetRoomoteInferenceKeyCache } from '../registry'; +import { DevLoginInferencePlaceholderError } from '@roomote/db/server'; function createApp(authContext: Variables['authContext']) { const app = new Hono<{ Variables: Variables }>(); @@ -149,6 +157,22 @@ describe('inference gateway', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('reports the local dev-login placeholder without contacting an upstream', async () => { + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockRejectedValueOnce( + new DevLoginInferencePlaceholderError(), + ); + + const response = await postMessages(createApp(createRunToken())); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + error: + 'Local development login uses an intentionally invalid inference key.', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('rejects requests with no auth context', async () => { const fetchMock = stubUpstreamFetch(); const response = await postMessages(createApp(undefined)); diff --git a/apps/api/src/handlers/inference/index.ts b/apps/api/src/handlers/inference/index.ts index ec89181f2..d4b466d53 100644 --- a/apps/api/src/handlers/inference/index.ts +++ b/apps/api/src/handlers/inference/index.ts @@ -5,7 +5,12 @@ import { rebaseRoomoteModelIdToUpstream, ROOMOTE_INFERENCE_PROVIDER_ID, } from '@roomote/types'; -import { db, eq, taskRuns } from '@roomote/db/server'; +import { + db, + DevLoginInferencePlaceholderError, + eq, + taskRuns, +} from '@roomote/db/server'; import { recordLlmUsage } from '@roomote/sdk/server'; import type { Variables } from '../../types'; @@ -385,10 +390,12 @@ inference.on(['POST', 'GET'], '/:provider/*', async (c) => { }), ); - return c.json( - { error: `Failed to resolve the ${provider.name} configuration` }, - 500, - ); + return error instanceof DevLoginInferencePlaceholderError + ? c.json({ error: error.message }, 503) + : c.json( + { error: `Failed to resolve the ${provider.name} configuration` }, + 500, + ); } if (!resolution.ok) { diff --git a/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts b/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts index f89704b90..fb0ee5912 100644 --- a/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/gbrain.test.ts @@ -9,14 +9,16 @@ import { import type { Variables } from '../../../types'; -const { mockResolveConnection, mockResolveBrainProvider } = vi.hoisted(() => ({ - mockResolveConnection: vi.fn(), - mockResolveBrainProvider: vi.fn(), -})); +const { mockResolveConnection, mockIsBrainEmbeddingAvailable } = vi.hoisted( + () => ({ + mockResolveConnection: vi.fn(), + mockIsBrainEmbeddingAvailable: vi.fn(), + }), +); vi.mock('@roomote/sdk/server', () => ({ resolveBrainConnection: mockResolveConnection, - resolveBrainInferenceProvider: mockResolveBrainProvider, + isBrainEmbeddingAvailable: mockIsBrainEmbeddingAvailable, })); import { createGbrainMcpProxy, GBRAIN_READ_TOOL_NAMES } from '../gbrain'; @@ -71,16 +73,9 @@ describe('createGbrainMcpProxy', () => { upstreamRequests = []; mockResolveConnection.mockReset(); // A Brain is only offered to agents when it can actually embed, so the - // default for these cases is "a provider is configured". - mockResolveBrainProvider.mockReset(); - mockResolveBrainProvider.mockResolvedValue({ - providerId: 'openrouter', - apiKey: 'sk-or-test', - models: { - embedding: 'openai/text-embedding-3-small', - chat: 'openai/gpt-5.6-luna', - }, - }); + // default for these cases is "an embedder is available". + mockIsBrainEmbeddingAvailable.mockReset(); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); }); afterEach(async () => { @@ -120,12 +115,12 @@ describe('createGbrainMcpProxy', () => { expect(mockResolveConnection).toHaveBeenCalledWith('agent'); }); - it('hides the Brain from agents when no model provider is configured', async () => { + it('hides the Brain from agents when it cannot embed', async () => { mockResolveConnection.mockResolvedValue({ baseUrl: 'http://brain.test', token: 'agent-token', }); - mockResolveBrainProvider.mockResolvedValue(null); + mockIsBrainEmbeddingAvailable.mockResolvedValue(false); const response = await postMcp(createApp(), toolCall('query')); @@ -134,6 +129,23 @@ describe('createGbrainMcpProxy', () => { expect(response.status).toBe(404); }); + it('serves agents on an embedder-only deployment with no provider key', async () => { + // The trial / Anthropic-only case: a self-run embedder is configured and + // no OpenAI/OpenRouter key exists. The drain ingests such a Brain, so the + // read path must let agents query it too. + mockResolveConnection.mockResolvedValue({ + baseUrl: await startUpstream(), + token: 'agent-token', + }); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); + + const response = await postMcp(createApp(), toolCall('query')); + + expect(response.status).toBe(200); + expect(upstreamRequests).toHaveLength(1); + expect(upstreamRequests[0]?.authorization).toBe('Bearer agent-token'); + }); + it.each(['remember', 'forget'])( 'blocks the %s write tool before it reaches upstream', async (name) => { diff --git a/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts b/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts index b14f08af4..102d54539 100644 --- a/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts +++ b/apps/api/src/handlers/mcp/__tests__/slack-thread-reply-quotes.test.ts @@ -13,10 +13,12 @@ const { getActiveSlackRunReplyTargetMock, getCustomAutomationByIdMock, getTaskChannelBindingsMock, + isAppInChannelMock, maybeSendCommunicationThreadReplyMock, postMessageDetailedMock, resolveAutomationResultSubtitleMock, slackInstallationFindFirstMock, + slackInstallationFindManyMock, suppressNextSlackReplyQuoteMock, taskRunFindFirstMock, } = vi.hoisted(() => ({ @@ -29,10 +31,12 @@ const { getActiveSlackRunReplyTargetMock: vi.fn(), getCustomAutomationByIdMock: vi.fn(), getTaskChannelBindingsMock: vi.fn(), + isAppInChannelMock: vi.fn(), maybeSendCommunicationThreadReplyMock: vi.fn(), postMessageDetailedMock: vi.fn(), resolveAutomationResultSubtitleMock: vi.fn(), slackInstallationFindFirstMock: vi.fn(), + slackInstallationFindManyMock: vi.fn(), suppressNextSlackReplyQuoteMock: vi.fn(), taskRunFindFirstMock: vi.fn(), })); @@ -42,7 +46,10 @@ vi.mock('@roomote/db/server', () => ({ asc: vi.fn(), db: { query: { - slackInstallations: { findFirst: slackInstallationFindFirstMock }, + slackInstallations: { + findFirst: slackInstallationFindFirstMock, + findMany: slackInstallationFindManyMock, + }, taskRuns: { findFirst: taskRunFindFirstMock }, tasks: { findFirst: vi.fn().mockResolvedValue(null) }, workItems: { findFirst: vi.fn() }, @@ -93,6 +100,7 @@ vi.mock('@roomote/slack', async (importOriginal) => { setSlackThreadReplyFooterMessageTs: vi.fn(), SlackNotifier: vi.fn( class { + isAppInChannel = isAppInChannelMock; postMessageDetailed = postMessageDetailedMock; }, ), @@ -193,6 +201,10 @@ describe('Slack thread reply quotes', () => { botAccessToken: 'xoxb-test', teamId: 'T123', }); + slackInstallationFindManyMock.mockResolvedValue([ + { botAccessToken: 'xoxb-test', teamId: 'T123' }, + ]); + isAppInChannelMock.mockResolvedValue(true); getTaskChannelBindingsMock.mockResolvedValue(null); maybeSendCommunicationThreadReplyMock.mockResolvedValue(null); resolveAutomationResultSubtitleMock.mockResolvedValue({ @@ -407,6 +419,96 @@ describe('Slack thread reply quotes', () => { ); }); + it('selects the Slack installation that owns a late-bound automation channel', async () => { + taskRunFindFirstMock.mockResolvedValue({ + id: 42, + actingUserId: null, + taskId: 'task-1', + payload: { channel: 'C123', customAutomationId: 'automation-1' }, + }); + slackInstallationFindManyMock.mockResolvedValue([ + { botAccessToken: 'xoxb-other', teamId: 'T_OTHER' }, + { botAccessToken: 'xoxb-owner', teamId: 'T_OWNER' }, + ]); + isAppInChannelMock.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + getCustomAutomationByIdMock.mockResolvedValue({ + id: 'automation-1', + name: 'Daily demo ideas', + scheduleMode: 'daily', + }); + buildThreadReplyImageBlocksMock.mockResolvedValue([]); + + const response = await createApp().request('/mcp/thread_reply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'A result worth reporting' }), + }); + + expect(response.status).toBe(200); + expect(slackInstallationFindFirstMock).not.toHaveBeenCalled(); + expect(isAppInChannelMock).toHaveBeenNthCalledWith(1, 'C123'); + expect(isAppInChannelMock).toHaveBeenNthCalledWith(2, 'C123'); + expect(postMessageDetailedMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: 'C123' }), + ); + }); + + it('rejects an ambiguous late-bound automation channel', async () => { + taskRunFindFirstMock.mockResolvedValue({ + id: 42, + actingUserId: null, + taskId: 'task-1', + payload: { channel: 'C123', customAutomationId: 'automation-1' }, + }); + slackInstallationFindManyMock.mockResolvedValue([ + { botAccessToken: 'xoxb-first', teamId: 'T_FIRST' }, + { botAccessToken: 'xoxb-second', teamId: 'T_SECOND' }, + ]); + isAppInChannelMock.mockResolvedValue(true); + + const response = await createApp().request('/mcp/thread_reply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'A result worth reporting' }), + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: + 'Slack report destination could not be resolved to one active installation', + }); + expect(slackInstallationFindFirstMock).not.toHaveBeenCalled(); + expect(postMessageDetailedMock).not.toHaveBeenCalled(); + }); + + it('returns a retryable error when late-bound channel membership is indeterminate', async () => { + taskRunFindFirstMock.mockResolvedValue({ + id: 42, + actingUserId: null, + taskId: 'task-1', + payload: { channel: 'C123', customAutomationId: 'automation-1' }, + }); + slackInstallationFindManyMock.mockResolvedValue([ + { botAccessToken: 'xoxb-owner', teamId: 'T_OWNER' }, + { botAccessToken: 'xoxb-unknown', teamId: 'T_UNKNOWN' }, + ]); + isAppInChannelMock.mockResolvedValueOnce(true).mockResolvedValueOnce(null); + + const response = await createApp().request('/mcp/thread_reply', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ text: 'A result worth reporting' }), + }); + + expect(response.status).toBe(503); + expect(await response.json()).toEqual({ + error: 'Slack report destination could not be verified; retry shortly', + retryable: true, + }); + expect(slackInstallationFindFirstMock).not.toHaveBeenCalled(); + expect(postMessageDetailedMock).not.toHaveBeenCalled(); + }); + it('consumes the exact pending quote after an image-only reply without rendering it', async () => { const response = await createApp().request('/mcp/thread_reply', { method: 'POST', diff --git a/apps/api/src/handlers/mcp/gbrain.ts b/apps/api/src/handlers/mcp/gbrain.ts index 36a87015a..8e16e0aa4 100644 --- a/apps/api/src/handlers/mcp/gbrain.ts +++ b/apps/api/src/handlers/mcp/gbrain.ts @@ -1,5 +1,5 @@ import { - resolveBrainInferenceProvider, + isBrainEmbeddingAvailable, resolveBrainConnection, } from '@roomote/sdk/server'; @@ -63,16 +63,20 @@ export function createGbrainMcpProxy(options?: { allowAuthTokens?: boolean }) { // service has a Brain, and the read-only agent client is provisioned // headlessly on first use. // - // Both halves are required before an agent is told the Brain exists. A - // Brain container with no provider key configured yet can still answer - // keyword queries, which is worse than absent: recall would look real - // while silently missing everything semantic. - const [connection, provider] = await Promise.all([ + // Both halves are required before an agent is told the Brain exists: a + // place to read from (connection) and a way to embed the query. Without + // an embedding path a Brain can still answer keyword queries, which is + // worse than absent — recall would look real while silently missing + // everything semantic. The embedding check is provider-agnostic, matching + // the ingest side (isBrainEmbeddingAvailable): a self-run embedder is + // enough, so a trial or Anthropic-only tenant whose pages the drain + // ingested can also query them, instead of accumulating unreadable memory. + const [connection, embeddingAvailable] = await Promise.all([ resolveBrainConnection('agent'), - resolveBrainInferenceProvider(), + isBrainEmbeddingAvailable(), ]); - if (!connection || !provider) { + if (!connection || !embeddingAvailable) { throw new McpProxyError( 404, 'The Brain is not configured on this deployment', diff --git a/apps/api/src/handlers/mcp/index.ts b/apps/api/src/handlers/mcp/index.ts index 2b85654fc..038ef88c4 100644 --- a/apps/api/src/handlers/mcp/index.ts +++ b/apps/api/src/handlers/mcp/index.ts @@ -17,6 +17,7 @@ import { communicationMcp } from './communication'; import { environmentsRouter } from '../environments'; import { customAutomationsRouter } from '../custom-automations'; import { tasksRouter } from '../tasks'; +import { sessionsRouter } from '../sessions'; import { createCustomMcpProxy } from './custom-mcp'; import { createGbrainMcpProxy } from './gbrain'; import { createIntegrationMcpProxy } from './integration-mcp'; @@ -103,6 +104,8 @@ mcp.use('/communication/*', mcpAuthMiddleware); mcp.use('/communication', mcpAuthMiddleware); mcp.use('/tasks/*', mcpAuthMiddleware); mcp.use('/tasks', mcpAuthMiddleware); +mcp.use('/sessions/*', mcpAuthMiddleware); +mcp.use('/sessions', mcpAuthMiddleware); mcp.use('/environments/*', mcpAuthMiddleware); mcp.use('/environments', mcpAuthMiddleware); mcp.use('/custom-automations/*', mcpAuthMiddleware); @@ -111,5 +114,6 @@ mcp.use('/custom-automations', mcpAuthMiddleware); mcp.route('/slack', slackMcp); mcp.route('/communication', communicationMcp); mcp.route('/tasks', tasksRouter); +mcp.route('/sessions', sessionsRouter); mcp.route('/environments', environmentsRouter); mcp.route('/custom-automations', customAutomationsRouter); diff --git a/apps/api/src/handlers/mcp/roomote-member-tools.ts b/apps/api/src/handlers/mcp/roomote-member-tools.ts index 111dfc5eb..5002bd44d 100644 --- a/apps/api/src/handlers/mcp/roomote-member-tools.ts +++ b/apps/api/src/handlers/mcp/roomote-member-tools.ts @@ -3,13 +3,18 @@ import { z } from 'zod'; import { ALL_REPOSITORIES, - PRODUCT_NAME, - ROOMOTE_TASK_INSPECTION_ACTIONS, - roomoteTaskInspectionFieldSchemas, + ROOMOTE_MANAGEMENT_TOOL_DESCRIPTION, + ROOMOTE_MANAGEMENT_ACTION_DESCRIPTION, + ROOMOTE_MEMBER_MANAGEMENT_ACTIONS, + getRoomoteSearchStatusError, + resolveRoomoteCommunicationTarget, + roomoteManagementFieldSchemas, + shouldSearchTasks, } from '@roomote/types'; import { environmentsRouter } from '../environments'; import { tasksRouter } from '../tasks'; +import { sessionsRouter } from '../sessions'; import { invokeInProcessApi, toolError, @@ -28,6 +33,7 @@ function invokeMemberApi( auth, mount: (app) => { app.route('/tasks', tasksRouter); + app.route('/sessions', sessionsRouter); app.route('/environments', environmentsRouter); }, path, @@ -36,19 +42,10 @@ function invokeMemberApi( } const manageTasksInputSchema = { - action: z.enum([ - ...ROOMOTE_TASK_INSPECTION_ACTIONS, - 'launch', - 'cancel', - 'send_message', - 'list_environments', - ]), - ...roomoteTaskInspectionFieldSchemas, - message: z.string().optional(), - prompt: z.string().optional(), - environmentId: z.string().optional(), - branch: z.string().optional(), - notifyOnSettle: z.boolean().optional(), + action: z + .enum(ROOMOTE_MEMBER_MANAGEMENT_ACTIONS) + .describe(ROOMOTE_MANAGEMENT_ACTION_DESCRIPTION), + ...roomoteManagementFieldSchemas, } satisfies Record; export function registerRoomoteMemberTools( @@ -58,10 +55,8 @@ export function registerRoomoteMemberTools( server.registerTool( 'manage_tasks', { - title: 'Manage Tasks', - description: - `Manage ${PRODUCT_NAME} tasks as the signed-in member. ` + - 'Use list_environments immediately before launch, search for task history, inspect summaries/messages/compute logs, launch tasks, cancel active tasks, or send follow-up messages.', + title: 'Manage Sessions and Tasks', + description: ROOMOTE_MANAGEMENT_TOOL_DESCRIPTION, inputSchema: manageTasksInputSchema, annotations: { readOnlyHint: false, @@ -72,7 +67,66 @@ export function registerRoomoteMemberTools( }, async (params) => { switch (params.action) { + case 'start': { + if (!params.message?.trim()) { + return toolError({ + error: 'message is required for start', + }); + } + return resultFromApi( + await invokeMemberApi(auth, '/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: params.message }), + }), + ); + } case 'search': { + const statusError = getRoomoteSearchStatusError({ + action: 'search', + pullRequest: params.pullRequest, + status: params.status, + }); + if (statusError) return toolError({ error: statusError }); + if ( + shouldSearchTasks({ + action: 'search', + pullRequest: params.pullRequest, + status: params.status, + }) + ) { + const query = new URLSearchParams(); + if (params.query) query.set('query', params.query); + if (params.status) query.set('status', params.status); + if (params.pullRequest) { + query.set('pullRequest', params.pullRequest); + } + if (params.limit) { + query.set('limit', String(Math.min(params.limit, 100))); + } + if (params.cursor) query.set('cursor', params.cursor); + const suffix = query.size > 0 ? `?${query.toString()}` : ''; + return resultFromApi( + await invokeMemberApi(auth, `/tasks${suffix}`), + ); + } + const query = new URLSearchParams(); + if (params.query) query.set('query', params.query); + if (params.status) query.set('status', params.status); + if (params.limit) + query.set('limit', String(Math.min(params.limit, 100))); + if (params.cursor) query.set('cursor', params.cursor); + const suffix = query.size > 0 ? `?${query.toString()}` : ''; + return resultFromApi( + await invokeMemberApi(auth, `/sessions${suffix}`), + ); + } + case 'search_tasks': { + const statusError = getRoomoteSearchStatusError({ + action: 'search_tasks', + status: params.status, + }); + if (statusError) return toolError({ error: statusError }); const query = new URLSearchParams(); if (params.query) query.set('query', params.query); if (params.status) query.set('status', params.status); @@ -84,18 +138,31 @@ export function registerRoomoteMemberTools( return resultFromApi(await invokeMemberApi(auth, `/tasks${suffix}`)); } case 'get_summary': - case 'get_compute_logs': case 'get_messages': { - if (!params.taskId?.trim()) { + const target = resolveRoomoteCommunicationTarget(params); + if (!target) { return toolError({ - error: `taskId is required for ${params.action}`, + error: `sessionId is required for ${params.action} when taskId is omitted`, }); } - const actionPath = { - get_summary: 'summary', - get_compute_logs: 'compute_logs', - get_messages: 'messages', - }[params.action]; + if (target.kind === 'task') { + const actionPath = + params.action === 'get_summary' ? 'summary' : 'messages'; + const query = new URLSearchParams(); + if (params.action === 'get_messages') { + query.set('order', 'desc'); + if (params.limit) query.set('limit', String(params.limit)); + } + const suffix = query.size > 0 ? `?${query.toString()}` : ''; + return resultFromApi( + await invokeMemberApi( + auth, + `/tasks/${encodeURIComponent(target.id)}/${actionPath}${suffix}`, + ), + ); + } + const actionPath = + params.action === 'get_summary' ? 'summary' : 'messages'; const query = new URLSearchParams(); if (params.action === 'get_messages') { query.set('order', 'desc'); @@ -105,7 +172,20 @@ export function registerRoomoteMemberTools( return resultFromApi( await invokeMemberApi( auth, - `/tasks/${encodeURIComponent(params.taskId)}/${actionPath}${suffix}`, + `/sessions/${encodeURIComponent(target.id)}/${actionPath}${suffix}`, + ), + ); + } + case 'get_compute_logs': { + if (!params.taskId?.trim()) { + return toolError({ + error: 'taskId is required for get_compute_logs', + }); + } + return resultFromApi( + await invokeMemberApi( + auth, + `/tasks/${encodeURIComponent(params.taskId)}/compute_logs`, ), ); } @@ -150,16 +230,20 @@ export function registerRoomoteMemberTools( ); } case 'send_message': { - if (!params.taskId?.trim()) { - return toolError({ error: 'taskId is required for send_message' }); - } if (!params.message?.trim()) { return toolError({ error: 'message is required for send_message' }); } + const target = resolveRoomoteCommunicationTarget(params); + if (!target) { + return toolError({ + error: + 'sessionId is required for send_message when taskId is omitted', + }); + } return resultFromApi( await invokeMemberApi( auth, - `/tasks/${encodeURIComponent(params.taskId)}/send_message`, + `/${target.kind === 'task' ? 'tasks' : 'sessions'}/${encodeURIComponent(target.id)}/send_message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/apps/api/src/handlers/mcp/slack.ts b/apps/api/src/handlers/mcp/slack.ts index 5bad9ec04..cf0538d62 100644 --- a/apps/api/src/handlers/mcp/slack.ts +++ b/apps/api/src/handlers/mcp/slack.ts @@ -892,6 +892,40 @@ slackMcp.post('/clear_reply_quote', async (c) => { return c.json({ success: true }); }); +async function findSlackNotifierForLateBoundChannel( + channelId: string, +): Promise< + | { status: 'resolved'; slack: SlackNotifier; teamId: string } + | { status: 'unresolved' } + | { status: 'indeterminate' } +> { + const installations = await db.query.slackInstallations.findMany({ + columns: { botAccessToken: true, teamId: true }, + where: eq(slackInstallations.isActive, true), + }); + const candidates = await Promise.all( + installations.map(async (installation) => { + const slack = new SlackNotifier(installation.botAccessToken); + return { + membership: await slack.isAppInChannel(channelId), + slack, + teamId: installation.teamId, + }; + }), + ); + if (candidates.some((candidate) => candidate.membership === null)) { + return { status: 'indeterminate' }; + } + + const matchingInstallations = candidates.filter( + (candidate) => candidate.membership === true, + ); + + return matchingInstallations.length === 1 + ? { status: 'resolved', ...matchingInstallations[0]! } + : { status: 'unresolved' }; +} + slackMcp.post('/thread_reply', async (c) => { const { authContext } = c.get('mcpAuth'); @@ -1020,25 +1054,54 @@ slackMcp.post('/thread_reply', async (c) => { const slackTeamId = activeSlackReplyTarget?.slackTeamId ?? getSlackTeamIdFromTaskPayload(taskRun.payload); - const slackInstallation = await db.query.slackInstallations.findFirst({ - columns: { botAccessToken: true, teamId: true }, - where: slackTeamId - ? and( - eq(slackInstallations.isActive, true), - eq(slackInstallations.teamId, slackTeamId), - ) - : eq(slackInstallations.isActive, true), - }); - - if (!slackInstallation?.botAccessToken) { + const needsLateBoundSlackResolution = + !slackTeamId && !slackReplyTarget.threadTs; + const lateBoundSlackResolution = needsLateBoundSlackResolution + ? await findSlackNotifierForLateBoundChannel(slackReplyTarget.channel) + : null; + if (lateBoundSlackResolution?.status === 'indeterminate') { return c.json( - { error: 'No active Slack installation found for this deployment' }, + { + error: 'Slack report destination could not be verified; retry shortly', + retryable: true, + }, + 503, + ); + } + const lateBoundSlack = + lateBoundSlackResolution?.status === 'resolved' + ? lateBoundSlackResolution + : null; + const slackInstallation = needsLateBoundSlackResolution + ? null + : await db.query.slackInstallations.findFirst({ + columns: { botAccessToken: true, teamId: true }, + where: slackTeamId + ? and( + eq(slackInstallations.isActive, true), + eq(slackInstallations.teamId, slackTeamId), + ) + : eq(slackInstallations.isActive, true), + }); + const resolvedSlack = + lateBoundSlack?.slack ?? + (slackInstallation?.botAccessToken + ? new SlackNotifier(slackInstallation.botAccessToken) + : null); + const resolvedSlackTeamId = + lateBoundSlack?.teamId ?? slackInstallation?.teamId ?? null; + + if (!resolvedSlack || !resolvedSlackTeamId) { + return c.json( + { + error: needsLateBoundSlackResolution + ? 'Slack report destination could not be resolved to one active installation' + : 'No active Slack installation found for this deployment', + }, 404, ); } - const slack = new SlackNotifier(slackInstallation.botAccessToken); - const artifactIds = [ ...new Set(parsedBody.images.map((image) => image.artifactId)), ]; @@ -1155,7 +1218,7 @@ slackMcp.post('/thread_reply', async (c) => { }) : blocks; - const rootPostResult = await slack.postMessageDetailed({ + const rootPostResult = await resolvedSlack.postMessageDetailed({ channel: slackReplyTarget.channel, text: getSlackFallbackText(fallbackText, imageBlocks.length), unfurl_links: false, @@ -1328,7 +1391,7 @@ slackMcp.post('/thread_reply', async (c) => { ); } - const replyPostResult = await slack.postMessageDetailed({ + const replyPostResult = await resolvedSlack.postMessageDetailed({ channel: slackReplyTarget.channel, thread_ts: existingThreadTs, text: getSlackFallbackText(fallbackText, imageBlocks.length), @@ -1414,7 +1477,7 @@ slackMcp.post('/thread_reply', async (c) => { ) { try { await removeSlackThreadReplyFooter({ - slack, + slack: resolvedSlack, channel: slackReplyTarget.channel, threadTs: existingThreadTs, messageTs: previousFooterMessageTs, @@ -1443,7 +1506,7 @@ slackMcp.post('/thread_reply', async (c) => { ); try { await removeSlackThreadReplyFooter({ - slack, + slack: resolvedSlack, channel: slackReplyTarget.channel, threadTs: existingThreadTs, messageTs: nextMessageTs, @@ -1475,8 +1538,8 @@ slackMcp.post('/thread_reply', async (c) => { try { await refreshTrackedAutomationThreadRootFooter({ - slack, - slackTeamId: slackInstallation.teamId, + slack: resolvedSlack, + slackTeamId: resolvedSlackTeamId, channel: slackReplyTarget.channel, threadTs: existingThreadTs, taskId: taskRun.taskId, @@ -1581,7 +1644,7 @@ slackMcp.post('/thread_reply', async (c) => { const subject = hasRealTaskRunUser(taskRun.actingUserId) ? await findSlackConversationSubjectByUserId({ userId: taskRun.actingUserId, - slackTeamId: slackInstallation.teamId, + slackTeamId: resolvedSlackTeamId, }) : null; diff --git a/apps/api/src/handlers/merge-announcer-push.ts b/apps/api/src/handlers/merge-announcer-push.ts new file mode 100644 index 000000000..84a86b689 --- /dev/null +++ b/apps/api/src/handlers/merge-announcer-push.ts @@ -0,0 +1,370 @@ +import { getInstallationOctokit } from '@roomote/github'; +import type { + MergeAnnouncerPullRequestContext, + MergeAnnouncerPushEvent, +} from '@roomote/sdk/server'; + +import { toHostFromUrl } from './utils'; +import type { AdoPushWebhook } from './ado/types'; +import type { BitbucketPushWebhook } from './bitbucket/types'; +import type { GiteaPushWebhook } from './gitea/types'; +import type { GitLabPushWebhook } from './gitlab/types'; + +const MAX_GITHUB_ASSOCIATED_PULL_REQUESTS = 10; +const MAX_GITHUB_PULL_REQUEST_CANDIDATES = 3; +const MAX_GITHUB_CHANGED_FILES = 20; + +function getPullRequestNumberFromCommitMessage( + message: string | undefined, +): number | null { + const subject = message?.trim().split('\n')[0] ?? ''; + const match = + subject.match(/\(#(\d+)\)\s*$/u) ?? + subject.match(/^Merge pull request #(\d+)\b/iu); + if (!match?.[1]) return null; + + const pullRequestNumber = Number(match[1]); + return Number.isSafeInteger(pullRequestNumber) && pullRequestNumber > 0 + ? pullRequestNumber + : null; +} + +type GitHubPushWebhook = { + ref: string; + after?: string; + deleted?: boolean; + compare?: string | null; + size?: number; + commits?: Array<{ + id: string; + message: string; + url?: string | null; + author?: { + name?: string | null; + username?: string | null; + email?: string | null; + } | null; + }>; + pusher?: { name?: string | null } | null; + sender?: { login?: string | null } | null; + installation?: { id?: number | null } | null; + repository?: { + id: number; + full_name: string; + default_branch?: string | null; + html_url?: string | null; + }; +}; + +type GitHubMergeAnnouncerDependencies = { + getInstallationOctokit: typeof getInstallationOctokit; +}; + +const githubMergeAnnouncerDependencies: GitHubMergeAnnouncerDependencies = { + getInstallationOctokit, +}; + +export function normalizeGitHubPush( + payload: GitHubPushWebhook, +): MergeAnnouncerPushEvent | null { + if (!payload.repository || !payload.commits) { + return null; + } + + return { + provider: 'github', + ref: payload.ref, + deleted: payload.deleted, + compareUrl: payload.compare, + commitCount: payload.size, + commits: payload.commits, + pusher: payload.pusher?.name ?? payload.sender?.login, + repository: { + externalId: String(payload.repository.id), + fullName: payload.repository.full_name, + host: toHostFromUrl(payload.repository.html_url ?? '') ?? 'github.com', + htmlUrl: payload.repository.html_url, + }, + }; +} + +export async function enrichGitHubMergeAnnouncerEvent( + payload: GitHubPushWebhook, + event: MergeAnnouncerPushEvent, + dependencyOverrides: Partial = {}, +): Promise { + const dependencies = { + ...githubMergeAnnouncerDependencies, + ...dependencyOverrides, + }; + const installationId = payload.installation?.id; + const after = payload.after?.trim(); + const [owner, repo] = payload.repository?.full_name.split('/') ?? []; + const branch = event.ref.startsWith('refs/heads/') + ? event.ref.slice('refs/heads/'.length) + : null; + + if ( + !installationId || + !after || + !owner || + !repo || + !branch || + event.deleted || + event.commits.length === 0 || + (payload.repository?.default_branch && + payload.repository.default_branch !== branch) + ) { + return event; + } + + try { + const octokit = await dependencies.getInstallationOctokit({ + installationId, + }); + const { data: associatedPullRequests } = + await octokit.rest.repos.listPullRequestsAssociatedWithCommit({ + owner, + repo, + commit_sha: after, + per_page: MAX_GITHUB_ASSOCIATED_PULL_REQUESTS, + }); + const tipCommit = event.commits.find((commit) => commit.id === after); + const hintedPullRequestNumber = + associatedPullRequests.length === 0 + ? getPullRequestNumberFromCommitMessage(tipCommit?.message) + : null; + const associatedCandidates = associatedPullRequests + .filter((pullRequest) => pullRequest.base.ref === branch) + .map((pullRequest) => pullRequest.number) + .slice(0, MAX_GITHUB_PULL_REQUEST_CANDIDATES); + const candidates = [ + ...associatedCandidates, + ...(hintedPullRequestNumber && + !associatedCandidates.includes(hintedPullRequestNumber) + ? [hintedPullRequestNumber] + : []), + ]; + const detailResults = await Promise.allSettled( + candidates.map((pullRequestNumber) => + octokit.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestNumber, + }), + ), + ); + const pullRequest = detailResults + .flatMap((result) => + result.status === 'fulfilled' ? [result.value.data] : [], + ) + .find( + (candidate) => + candidate.base.ref === branch && candidate.merge_commit_sha === after, + ); + + if (!pullRequest) { + return event; + } + + let changedFiles: MergeAnnouncerPullRequestContext['changedFiles']; + try { + const { data: files } = await octokit.rest.pulls.listFiles({ + owner, + repo, + pull_number: pullRequest.number, + per_page: MAX_GITHUB_CHANGED_FILES, + page: 1, + }); + changedFiles = files.map((file) => ({ + path: file.filename, + status: file.status, + additions: file.additions, + deletions: file.deletions, + })); + } catch (error) { + console.warn( + `[mergeAnnouncer] Failed to fetch changed files for ${payload.repository?.full_name}#${pullRequest.number}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + return { + ...event, + pullRequest: { + number: pullRequest.number, + url: pullRequest.html_url, + title: pullRequest.title, + body: pullRequest.body, + changedFileCount: pullRequest.changed_files, + additions: pullRequest.additions, + deletions: pullRequest.deletions, + ...(changedFiles ? { changedFiles } : {}), + }, + }; + } catch (error) { + console.warn( + `[mergeAnnouncer] Failed to resolve merged pull request for ${payload.repository?.full_name}@${after}: ${error instanceof Error ? error.message : String(error)}`, + ); + return event; + } +} + +const ZERO_SHA = /^0+$/u; + +export function normalizeGitLabPush( + payload: GitLabPushWebhook, +): MergeAnnouncerPushEvent { + return { + provider: 'gitlab', + ref: payload.ref, + deleted: ZERO_SHA.test(payload.after), + compareUrl: payload.compare, + commitCount: payload.total_commits_count, + commits: payload.commits.map((commit) => ({ + id: commit.id, + message: commit.message, + url: commit.url, + author: commit.author, + })), + pusher: + payload.user_username ?? payload.user_name ?? payload.user_email ?? null, + repository: { + externalId: String(payload.project.id), + fullName: + payload.project.path_with_namespace ?? String(payload.project.id), + host: toHostFromUrl(payload.project.web_url ?? ''), + htmlUrl: payload.project.web_url, + }, + }; +} + +export function normalizeGiteaPush( + payload: GiteaPushWebhook, +): MergeAnnouncerPushEvent { + return { + provider: 'gitea', + ref: payload.ref, + deleted: payload.deleted, + compareUrl: payload.compare_url, + commits: payload.commits.map((commit) => ({ + id: commit.id, + message: commit.message, + url: commit.url, + author: { + name: commit.author?.name ?? commit.author?.full_name, + username: commit.author?.username ?? commit.author?.login, + email: commit.author?.email, + }, + })), + pusher: + payload.pusher?.username ?? + payload.pusher?.login ?? + payload.pusher?.full_name ?? + payload.pusher?.name ?? + payload.sender?.login ?? + null, + repository: { + externalId: String(payload.repository.id), + fullName: payload.repository.full_name, + host: toHostFromUrl(payload.repository.html_url ?? ''), + htmlUrl: payload.repository.html_url, + }, + }; +} + +export function normalizeBitbucketPush( + payload: BitbucketPushWebhook, +): MergeAnnouncerPushEvent[] { + const externalId = String( + payload.repository.uuid ?? + payload.repository.id ?? + payload.repository.full_name, + ); + const htmlUrl = payload.repository.links?.html?.href; + const pusher = + payload.actor?.nickname ?? + payload.actor?.username ?? + payload.actor?.display_name ?? + null; + + return payload.push.changes.flatMap((change) => { + const ref = change.new ?? change.old; + if (!ref?.name || (ref.type && ref.type !== 'branch')) { + return []; + } + + return [ + { + provider: 'bitbucket', + ref: `refs/heads/${ref.name}`, + deleted: change.closed === true || !change.new, + compareUrl: change.links?.html?.href, + commits: (change.commits ?? []).map((commit) => ({ + id: commit.hash, + message: commit.message, + url: commit.links?.html?.href, + author: { + name: commit.author?.user?.display_name ?? commit.author?.raw, + username: + commit.author?.user?.nickname ?? commit.author?.user?.username, + }, + })), + pusher, + repository: { + externalId, + fullName: payload.repository.full_name, + host: toHostFromUrl(htmlUrl ?? '') ?? 'bitbucket.org', + htmlUrl, + }, + } satisfies MergeAnnouncerPushEvent, + ]; + }); +} + +export function normalizeAdoPush( + payload: AdoPushWebhook, +): MergeAnnouncerPushEvent[] { + const repositoryUrl = + payload.resource.repository.webUrl ?? + payload.resource.repository.remoteUrl ?? + payload.resource.repository.url; + const pusher = + payload.resource.pushedBy?.displayName ?? + payload.resource.pushedBy?.uniqueName ?? + payload.resource.pushedBy?.id ?? + payload.resource.createdBy?.displayName ?? + payload.resource.createdBy?.uniqueName ?? + null; + const organizationUrl = + payload.resourceContainers?.account?.baseUrl ?? + payload.resourceContainers?.collection?.baseUrl; + + return payload.resource.refUpdates.map((update) => ({ + provider: 'ado', + ref: update.name, + deleted: update.isDelete || ZERO_SHA.test(update.newObjectId ?? ''), + commits: payload.resource.commits.flatMap((commit) => { + const id = commit.id ?? commit.commitId; + if (!id) return []; + return [ + { + id, + message: commit.message ?? commit.comment ?? 'Untitled commit', + url: commit.url, + author: { + name: commit.author?.displayName ?? commit.author?.name, + username: commit.author?.uniqueName, + email: commit.author?.email, + }, + }, + ]; + }), + pusher, + repository: { + externalId: payload.resource.repository.id, + fullName: `${payload.resource.repository.project.name}/${payload.resource.repository.name}`, + host: toHostFromUrl(repositoryUrl ?? organizationUrl ?? ''), + htmlUrl: repositoryUrl, + }, + })); +} diff --git a/apps/api/src/handlers/sessions/index.test.ts b/apps/api/src/handlers/sessions/index.test.ts new file mode 100644 index 000000000..94731cdb5 --- /dev/null +++ b/apps/api/src/handlers/sessions/index.test.ts @@ -0,0 +1,294 @@ +const mocks = vi.hoisted(() => ({ + getOrCreateFastAgentSession: vi.fn(), + getSessionForFastConversation: vi.fn(), + queueFastAgentSurfaceReply: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', async (importOriginal) => ({ + ...(await importOriginal()), + getOrCreateFastAgentSession: mocks.getOrCreateFastAgentSession, +})); + +vi.mock('@roomote/sdk/server', async (importOriginal) => ({ + ...(await importOriginal()), + queueFastAgentSurfaceReply: mocks.queueFastAgentSurfaceReply, +})); + +vi.mock('@roomote/db/server', async (importOriginal) => ({ + ...(await importOriginal()), + getSessionForFastConversation: mocks.getSessionForFastConversation, +})); + +import { Hono } from 'hono'; +import { + ACP_UI_TOOL_OUTPUT_MAX_CHARS, + type AuthTokenContext, +} from '@roomote/types'; +import { + db, + eq, + fastAgentConversations, + fastAgentMessages, + sessionFactory, + sessions, + sessionTasks, + taskFactory, + tasks, + userFactory, + users, +} from '@roomote/db/server'; + +import type { Variables } from '../../types'; +import { mcpAuthMiddleware } from '../mcp/middleware'; +import { sessionsRouter } from '.'; + +const createdSessionIds: string[] = []; +const createdTaskIds: string[] = []; +const createdUserIds: string[] = []; +const createdConversationIds: string[] = []; + +function createApp(userId: string) { + const app = new Hono<{ Variables: Variables }>(); + app.use('*', async (c, next) => { + const auth: AuthTokenContext = { userId, tokenType: 'auth', version: 1 }; + c.set('authContext', auth); + await next(); + }); + app.use('*', mcpAuthMiddleware); + app.route('/sessions', sessionsRouter); + return app; +} + +afterEach(async () => { + while (createdSessionIds.length > 0) { + await db.delete(sessions).where(eq(sessions.id, createdSessionIds.pop()!)); + } + while (createdTaskIds.length > 0) { + await db.delete(tasks).where(eq(tasks.id, createdTaskIds.pop()!)); + } + while (createdConversationIds.length > 0) { + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, createdConversationIds.pop()!)); + } + while (createdUserIds.length > 0) { + await db.delete(users).where(eq(users.id, createdUserIds.pop()!)); + } + vi.clearAllMocks(); +}); + +describe('MCP session routes', () => { + it('starts a unified session and queues its first turn', async () => { + const user = await userFactory.create(); + createdUserIds.push(user.id); + const sessionId = crypto.randomUUID(); + const fastConversationId = crypto.randomUUID(); + mocks.getOrCreateFastAgentSession.mockResolvedValue({ + id: fastConversationId, + created: true, + }); + mocks.getSessionForFastConversation.mockResolvedValue({ id: sessionId }); + mocks.queueFastAgentSurfaceReply.mockResolvedValue(true); + + const response = await createApp(user.id).request('/sessions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Investigate the failing deployment' }), + }); + + expect(response.status).toBe(201); + await expect(response.json()).resolves.toEqual({ + sessionId, + fastConversationId, + queued: true, + }); + expect(mocks.queueFastAgentSurfaceReply).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: fastConversationId, + userId: user.id, + question: 'Investigate the failing deployment', + }), + ); + }); + + it('returns accessible sessions with nested child-task state', async () => { + const owner = await userFactory.create(); + createdUserIds.push(owner.id); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + title: 'Release investigation', + sourceSurface: 'web', + sourceTrigger: 'message', + }); + createdSessionIds.push(session.id); + const task = await taskFactory.create({ + initiatorUserId: owner.id, + title: 'Inspect release checks', + repositoryName: 'RooCodeInc/Roomote', + }); + createdTaskIds.push(task.id); + await db.insert(sessionTasks).values({ + sessionId: session.id, + taskId: task.id, + origin: 'fast_delegation', + }); + + const listResponse = await createApp(owner.id).request( + '/sessions?query=release', + ); + expect(listResponse.status).toBe(200); + await expect(listResponse.json()).resolves.toMatchObject({ + sessions: [ + { + id: session.id, + title: 'Release investigation', + tasks: [ + { + taskId: task.id, + title: 'Inspect release checks', + origin: 'fast_delegation', + latestRun: null, + }, + ], + }, + ], + }); + + const summaryResponse = await createApp(owner.id).request( + `/sessions/${session.id}/summary`, + ); + expect(summaryResponse.status).toBe(200); + await expect(summaryResponse.json()).resolves.toMatchObject({ + id: session.id, + tasks: [{ taskId: task.id }], + }); + }); + + it('hides sessions from users who are not participants', async () => { + const owner = await userFactory.create(); + const bystander = await userFactory.create(); + createdUserIds.push(owner.id, bystander.id); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + }); + createdSessionIds.push(session.id); + + const response = await createApp(bystander.id).request( + `/sessions/${session.id}/summary`, + ); + expect(response.status).toBe(404); + const messagesResponse = await createApp(bystander.id).request( + `/sessions/${session.id}/messages`, + ); + expect(messagesResponse.status).toBe(404); + }); + + it('returns visible, sanitized session messages newest first', async () => { + const owner = await userFactory.create(); + createdUserIds.push(owner.id); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: owner.id, + surface: 'web', + workspaceId: owner.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + createdConversationIds.push(conversation!.id); + const session = await sessionFactory.create({ + ownerKind: 'user', + ownerUserId: owner.id, + fastConversationId: conversation!.id, + sourceSurface: 'web', + sourceTrigger: 'message', + }); + createdSessionIds.push(session.id); + mocks.queueFastAgentSurfaceReply.mockResolvedValue(true); + await db.insert(fastAgentMessages).values([ + { + conversationId: conversation!.id, + eventId: 'visible-old', + turnId: 'turn-old', + turnSeq: 0, + ts: 1, + eventType: 'roomote_runtime.user_prompt', + role: 'user', + contentBlocks: [{ type: 'text', text: 'Older message' }], + metadata: { visibleInTranscript: true }, + payload: {}, + source: 'web', + }, + { + conversationId: conversation!.id, + eventId: 'hidden-middle', + turnId: 'turn-hidden', + turnSeq: 0, + ts: 2, + eventType: 'roomote_runtime.user_prompt', + role: 'user', + contentBlocks: [{ type: 'text', text: 'Hidden message' }], + metadata: { visibleInTranscript: false }, + payload: {}, + source: 'web', + }, + { + conversationId: conversation!.id, + eventId: 'visible-new', + turnId: 'turn-new', + turnSeq: 0, + ts: 3, + eventType: 'roomote_runtime.tool_result', + role: 'tool', + contentBlocks: [{ type: 'text', text: 'Unbounded output' }], + metadata: { visibleInTranscript: true }, + payload: { output: 'x'.repeat(ACP_UI_TOOL_OUTPUT_MAX_CHARS + 100) }, + source: 'web', + }, + ]); + + const response = await createApp(owner.id).request( + `/sessions/${session.id}/messages`, + ); + expect(response.status).toBe(200); + const body = (await response.json()) as { + returned: number; + messages: Array<{ + text: string; + metadata: Record | null; + }>; + }; + expect(body.returned).toBe(2); + expect(body.messages[0]?.text).not.toBe('Unbounded output'); + expect(body.messages[0]?.metadata).toHaveProperty('truncation'); + expect(body.messages[1]?.text).toBe('Older message'); + + const sendResponse = await createApp(owner.id).request( + `/sessions/${session.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Continue this Session' }), + }, + ); + expect(sendResponse.status).toBe(200); + expect(mocks.queueFastAgentSurfaceReply).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: conversation!.id, + question: 'Continue this Session', + }), + ); + + const legacyIdResponse = await createApp(owner.id).request( + `/sessions/${conversation!.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Wrong identifier kind' }), + }, + ); + expect(legacyIdResponse.status).toBe(404); + }); +}); diff --git a/apps/api/src/handlers/sessions/index.ts b/apps/api/src/handlers/sessions/index.ts new file mode 100644 index 000000000..5ac7061fc --- /dev/null +++ b/apps/api/src/handlers/sessions/index.ts @@ -0,0 +1,416 @@ +import { randomUUID } from 'node:crypto'; + +import { Hono, type Context } from 'hono'; +import { + and, + db, + desc, + deriveSessionStatus, + eq, + exists, + fastAgentMessages, + getSessionForFastConversation, + ilike, + inArray, + isSessionConversationResponding, + isNull, + lt, + or, + sessionParticipants, + sessions, + sessionTasks, + sql, + tasks, +} from '@roomote/db/server'; +import { getOrCreateFastAgentSession } from '@roomote/cloud-agents/server'; +import { queueFastAgentSurfaceReply } from '@roomote/sdk/server'; +import { + SESSION_STATUSES, + type RoomoteSearchSessionsResponse, + type RoomoteSessionChildTask, + type RoomoteSessionMessagesResponse, + type RoomoteSessionSummary, + type RoomoteStartSessionResponse, + type TaskPhase, +} from '@roomote/types'; + +import type { Variables } from '../../types'; +import type { McpAuth } from '../mcp/middleware'; +import { logHandlerError } from '../utils'; +import { getLatestTaskRunsByTaskIds } from '../tasks/helpers'; +import { + getFastSessionMessagesForUser, + sendMessageToFastSessionForUser, +} from '../tasks/fastSessionCommunication'; + +type SessionContext = Context<{ + Variables: Variables & { mcpAuth: McpAuth }; +}>; + +function sessionAccessCondition(userId: string) { + return or( + eq(sessions.ownerUserId, userId), + exists( + db + .select({ one: sql`1` }) + .from(sessionParticipants) + .where( + and( + eq(sessionParticipants.sessionId, sessions.id), + eq(sessionParticipants.userId, userId), + ), + ), + ), + exists( + db + .select({ one: sql`1` }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, sessions.fastConversationId), + sql`${fastAgentMessages.metadata} ->> 'userId' = ${userId}`, + ), + ), + ), + ); +} + +async function findAccessibleSession(userId: string, sessionId: string) { + const [session] = await db + .select() + .from(sessions) + .where( + and( + eq(sessions.id, sessionId), + eq(sessions.visibility, 'visible'), + sessionAccessCondition(userId), + ), + ) + .limit(1); + return session ?? null; +} + +async function sendSessionMessage(c: SessionContext): Promise { + const userId = c.get('mcpAuth').userId; + if (!userId) return c.json({ error: 'User context required' }, 403); + const sessionId = c.req.param('sessionId'); + if (!sessionId) return c.json({ error: 'sessionId is required' }, 400); + + let body: { message?: string }; + try { + body = (await c.req.json()) as { message?: string }; + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + const message = body.message?.trim(); + if (!message) return c.json({ error: 'message is required' }, 400); + + try { + const session = await findAccessibleSession(userId, sessionId); + if (!session) return c.json({ error: 'Session not found' }, 404); + if (!session.fastConversationId) { + return c.json({ error: 'Session has no conversation to continue' }, 409); + } + const result = await sendMessageToFastSessionForUser({ + sessionId: session.fastConversationId, + userId, + message, + }); + if (result.success) return c.json(result); + const { status, ...errorBody } = result; + return c.json(errorBody, { status }); + } catch (error) { + logHandlerError('sendSessionMessage', error); + return c.json({ error: 'Failed to send session message' }, 500); + } +} + +async function getChildTasks(sessionIds: string[]) { + if (sessionIds.length === 0) { + return new Map(); + } + + const rows = await db + .select({ + sessionId: sessionTasks.sessionId, + taskId: tasks.id, + title: tasks.title, + state: tasks.state, + goalStatus: tasks.goalStatus, + repositoryName: tasks.repositoryName, + activityAt: tasks.activityAt, + origin: sessionTasks.origin, + attachedAt: sessionTasks.attachedAt, + }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and(inArray(sessionTasks.sessionId, sessionIds), isNull(tasks.deletedAt)), + ) + .orderBy(sessionTasks.attachedAt); + const latestRuns = await getLatestTaskRunsByTaskIds( + rows.map((row) => row.taskId), + ); + const bySession = new Map(); + + for (const row of rows) { + const run = latestRuns[row.taskId] ?? null; + const tasksForSession = bySession.get(row.sessionId) ?? []; + tasksForSession.push({ + taskId: row.taskId, + title: row.title, + state: row.state, + goalStatus: row.goalStatus, + repositoryName: row.repositoryName, + activityAt: row.activityAt, + origin: row.origin, + attachedAt: row.attachedAt.toISOString(), + latestRun: run + ? { + status: run.status, + taskPhase: run.taskPhase as TaskPhase | null, + error: run.error, + } + : null, + }); + bySession.set(row.sessionId, tasksForSession); + } + + return bySession; +} + +function serializeSession( + session: typeof sessions.$inferSelect, + childTasks: RoomoteSessionChildTask[], +): RoomoteSessionSummary { + return { + id: session.id, + title: session.title, + status: deriveSessionStatus({ + conversationResponding: isSessionConversationResponding(session), + tasks: childTasks.map((task) => ({ + state: task.state, + taskPhase: task.latestRun?.taskPhase ?? null, + goalStatus: task.goalStatus, + })), + }), + sourceSurface: session.sourceSurface, + sourceTrigger: session.sourceTrigger, + activityAt: session.activityAt, + createdAt: session.createdAt.toISOString(), + fastConversationId: session.fastConversationId, + tasks: childTasks, + }; +} + +async function startSession(c: SessionContext): Promise { + const userId = c.get('mcpAuth').userId; + if (!userId) return c.json({ error: 'User context required' }, 403); + + let body: { message?: string }; + try { + body = (await c.req.json()) as { message?: string }; + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + const message = body.message?.trim(); + if (!message) return c.json({ error: 'message is required' }, 400); + + try { + const conversation = { + surface: 'web' as const, + workspaceId: userId, + conversationId: randomUUID(), + }; + const fastSession = await getOrCreateFastAgentSession({ + userId, + conversation, + }); + const session = await getSessionForFastConversation(db, fastSession.id); + const queued = await queueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId, + senderDisplayName: null, + question: message, + currentMessageId: `mcp-${randomUUID()}`, + }); + + if (!session || !queued) { + return c.json({ error: 'Failed to start session' }, 500); + } + + const response = { + sessionId: session.id, + fastConversationId: fastSession.id, + queued: true, + } satisfies RoomoteStartSessionResponse; + return c.json(response, 201); + } catch (error) { + logHandlerError('startSession', error); + return c.json({ error: 'Failed to start session' }, 500); + } +} + +async function searchSessions(c: SessionContext): Promise { + const userId = c.get('mcpAuth').userId; + if (!userId) return c.json({ error: 'User context required' }, 403); + + const query = c.req.query('query')?.trim(); + const status = c.req.query('status'); + const sessionStatus = SESSION_STATUSES.find( + (candidate) => candidate === status, + ); + if (status && !sessionStatus) { + return c.json( + { error: `status must be one of: ${SESSION_STATUSES.join(', ')}` }, + 400, + ); + } + const parsedLimit = Number(c.req.query('limit') ?? 20); + if (!Number.isFinite(parsedLimit)) { + return c.json({ error: 'limit must be a number' }, 400); + } + const limit = Math.min(Math.max(Math.trunc(parsedLimit), 1), 100); + const cursorParam = c.req.query('cursor'); + const cursorActivityAt = cursorParam + ? Number(cursorParam.split(':')[0]) + : null; + const cursorId = cursorParam?.split(':')[1]; + if (cursorParam && !Number.isFinite(cursorActivityAt)) { + return c.json({ error: 'cursor must be activityAt:id' }, 400); + } + + try { + const conditions = [ + eq(sessions.visibility, 'visible'), + isNull(sessions.archivedAt), + sessionAccessCondition(userId), + ]; + if (sessionStatus) { + conditions.push(eq(sessions.cachedStatus, sessionStatus)); + } + if (query) { + const pattern = `%${query.replace(/[\\%_]/g, (character) => `\\${character}`)}%`; + conditions.push( + or( + ilike(sessions.title, pattern), + exists( + db + .select({ one: sql`1` }) + .from(sessionTasks) + .innerJoin(tasks, eq(tasks.id, sessionTasks.taskId)) + .where( + and( + eq(sessionTasks.sessionId, sessions.id), + or( + ilike(tasks.title, pattern), + ilike(tasks.repositoryName, pattern), + ), + ), + ), + ), + ), + ); + } + if (cursorActivityAt !== null) { + conditions.push( + cursorId + ? or( + lt(sessions.activityAt, cursorActivityAt), + and( + eq(sessions.activityAt, cursorActivityAt), + lt(sessions.id, cursorId), + ), + ) + : lt(sessions.activityAt, cursorActivityAt), + ); + } + + const rows = await db + .select() + .from(sessions) + .where(and(...conditions)) + .orderBy(desc(sessions.activityAt), desc(sessions.id)) + .limit(limit + 1); + const page = rows.slice(0, limit); + const childTasks = await getChildTasks(page.map((session) => session.id)); + const last = page.at(-1); + + const response = { + sessions: page.map((session) => + serializeSession(session, childTasks.get(session.id) ?? []), + ), + nextCursor: + rows.length > limit && last ? `${last.activityAt}:${last.id}` : null, + } satisfies RoomoteSearchSessionsResponse; + return c.json(response); + } catch (error) { + logHandlerError('searchSessions', error); + return c.json({ error: 'Failed to search sessions' }, 500); + } +} + +async function getSessionSummary(c: SessionContext): Promise { + const userId = c.get('mcpAuth').userId; + if (!userId) return c.json({ error: 'User context required' }, 403); + const sessionId = c.req.param('sessionId'); + if (!sessionId) return c.json({ error: 'sessionId is required' }, 400); + + try { + const session = await findAccessibleSession(userId, sessionId); + if (!session) return c.json({ error: 'Session not found' }, 404); + const childTasks = await getChildTasks([session.id]); + const response = serializeSession( + session, + childTasks.get(session.id) ?? [], + ); + return c.json(response); + } catch (error) { + logHandlerError('getSessionSummary', error); + return c.json({ error: 'Failed to get session summary' }, 500); + } +} + +async function getSessionMessages(c: SessionContext): Promise { + const userId = c.get('mcpAuth').userId; + if (!userId) return c.json({ error: 'User context required' }, 403); + const sessionId = c.req.param('sessionId'); + if (!sessionId) return c.json({ error: 'sessionId is required' }, 400); + + try { + const session = await findAccessibleSession(userId, sessionId); + if (!session) return c.json({ error: 'Session not found' }, 404); + const parsedLimit = Number(c.req.query('limit') ?? 100); + if (!Number.isFinite(parsedLimit)) { + return c.json({ error: 'limit must be a number' }, 400); + } + const limit = Math.min(Math.max(Math.trunc(parsedLimit), 1), 1000); + const messages = session.fastConversationId + ? await getFastSessionMessagesForUser({ + sessionId: session.fastConversationId, + userId, + limit, + order: 'desc', + }) + : []; + const childTasks = await getChildTasks([session.id]); + + const response = { + sessionId: session.id, + messages: messages ?? [], + returned: messages?.length ?? 0, + tasks: childTasks.get(session.id) ?? [], + } satisfies RoomoteSessionMessagesResponse; + return c.json(response); + } catch (error) { + logHandlerError('getSessionMessages', error); + return c.json({ error: 'Failed to get session messages' }, 500); + } +} + +export const sessionsRouter = new Hono<{ Variables: Variables }>(); +sessionsRouter.get('/', searchSessions); +sessionsRouter.post('/', startSession); +sessionsRouter.get('/:sessionId/summary', getSessionSummary); +sessionsRouter.get('/:sessionId/messages', getSessionMessages); +sessionsRouter.post('/:sessionId/send_message', sendSessionMessage); diff --git a/apps/api/src/handlers/shared/unmentioned-thread-reply.test.ts b/apps/api/src/handlers/shared/unmentioned-thread-reply.test.ts index fb866b0c7..2c6b96066 100644 --- a/apps/api/src/handlers/shared/unmentioned-thread-reply.test.ts +++ b/apps/api/src/handlers/shared/unmentioned-thread-reply.test.ts @@ -134,6 +134,35 @@ describe('evaluateUnmentionedThreadReplyRouting', () => { ).toEqual({ shouldRoute: true, interjectionDetected: false }); }); + it('routes a new sender after another participant speaks in an open Roomote conversation', () => { + expect( + decide({ + isThreadTaskOwner: false, + isThreadRootAuthor: false, + isOpenConversationThread: true, + senderUserId: 'U2', + threadMessages: [ + human('100', 'U1', { mentionsBot: true }), + bot('200'), + human('300', 'U1'), + ], + }), + ).toEqual({ shouldRoute: true, interjectionDetected: false }); + }); + + it('still requires a mention when somebody else was mentioned in an open Roomote conversation', () => { + expect( + decide({ + isOpenConversationThread: true, + threadMessages: [ + human('100', 'U1', { mentionsBot: true }), + bot('200'), + human('300', 'U1', { mentionsSomebodyElse: true }), + ], + }), + ).toEqual({ shouldRoute: false, interjectionDetected: true }); + }); + it('still requires a mention after an interjection in an automation report thread', () => { expect( decide({ diff --git a/apps/api/src/handlers/shared/unmentioned-thread-reply.ts b/apps/api/src/handlers/shared/unmentioned-thread-reply.ts index a6d005298..ff67cd69b 100644 --- a/apps/api/src/handlers/shared/unmentioned-thread-reply.ts +++ b/apps/api/src/handlers/shared/unmentioned-thread-reply.ts @@ -14,10 +14,7 @@ export type UnmentionedThreadHistoryMessage = { isBot: boolean; /** True when this history message @-mentions the Roomote bot. */ mentionsBot: boolean; - /** - * True when this history message mentions a principal other than the bot - * and other than the current sender (another human or another app). - */ + /** True when this history message mentions someone other than its author or the bot. */ mentionsSomebodyElse: boolean; }; @@ -65,9 +62,9 @@ export function compareBigIntMessageIds(left: string, right: string): number { * Eligibility is limited to senders already in the conversation: task owner, * thread starter, or someone who mentioned the bot earlier. Automation reports * and other open Roomote conversations have no single owning participant, so - * any human reply there is eligible. Routing still fails when somebody else - * posted or was mentioned after the bot's last message; a later bot reply - * reopens that window. + * any human reply there is eligible. Speaker changes are expected in open + * conversations, but routing still fails when somebody else was mentioned + * after the bot's last message; a later bot reply reopens that window. */ export function evaluateUnmentionedThreadReplyRouting(input: { eventMessageId: string; @@ -143,7 +140,8 @@ export function evaluateUnmentionedThreadReplyRouting(input: { continue; } - const isMessageFromSomebodyElse = message.authorUserId !== senderUserId; + const isMessageFromSomebodyElse = + !isOpenConversationThread && message.authorUserId !== senderUserId; if (isMessageFromSomebodyElse || message.mentionsSomebodyElse) { return { shouldRoute: false, interjectionDetected: true }; } diff --git a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts index 438ae3530..4f7cacf02 100644 --- a/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts +++ b/apps/api/src/handlers/slack/events/channel-auto-start-unlinked.test.ts @@ -150,7 +150,6 @@ describe('channel auto-start unlinked author', () => { updatedAt: new Date('2026-01-01T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); const { handleMessageOrAppMentionEvent } = diff --git a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts index 46bcc12ef..0fd1f5f05 100644 --- a/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts +++ b/apps/api/src/handlers/slack/events/fast-agent-processing.test.ts @@ -1,9 +1,14 @@ const mocks = vi.hoisted(() => ({ acquireLock: vi.fn(), + acquireRootBindingLock: vi.fn(), hasSession: vi.fn(), releaseLock: vi.fn(), + releaseRootBindingLock: vi.fn(), answerQuestion: vi.fn(), + findConversation: vi.fn(), + getSession: vi.fn(), postThreadMessage: vi.fn(), + recordProviderMessage: vi.fn(), })); vi.mock('@roomote/redis', async (importOriginal) => { @@ -24,16 +29,51 @@ vi.mock('@roomote/redis', async (importOriginal) => { vi.mock('@roomote/cloud-agents/server', () => ({ acquireFastAgentTurnLock: mocks.acquireLock, answerFastAgentQuestion: mocks.answerQuestion, + fastAgentConversationRepository: { findById: mocks.findConversation }, + extractPromptTextAttachments: vi.fn( + async (inputs: Array<{ filename: string; bytes: Uint8Array }>) => ({ + attachmentTexts: inputs.map( + (input) => + `Attachment: ${input.filename}\n${Buffer.from(input.bytes).toString('utf8')}`, + ), + warnings: [], + }), + ), hasFastAgentSession: mocks.hasSession, - getOrCreateFastAgentSession: vi - .fn() - .mockResolvedValue({ id: 'fast-session-1' }), + getOrCreateFastAgentSession: mocks.getSession, +})); + +vi.mock('@roomote/slack', async (importOriginal) => ({ + ...(await importOriginal()), + acquireSlackFastRootBindingLock: mocks.acquireRootBindingLock, })); vi.mock('@roomote/cloud-agents', () => ({ + appendAttachmentTextsToPromptText: ({ + text, + attachmentTexts = [], + }: { + text: string; + attachmentTexts?: string[]; + }) => [text, ...attachmentTexts].filter(Boolean).join('\n\n'), + isRoomoteTextExtractableAttachment: ({ mimeType }: { mimeType?: string }) => + mimeType?.startsWith('text/') ?? false, stripLeadingSlackProductMention: (text: string) => text, })); +vi.mock('@roomote/sdk/server', () => ({ + recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, + resolveUserMcpServerConfigs: vi.fn(async () => ({})), +})); + +vi.mock('@roomote/communication', async (importOriginal) => ({ + ...(await importOriginal()), + resolveFastSessionReplyFooterContext: vi.fn(async () => ({ + linkedPrs: [], + livePreviewUrl: null, + })), +})); + vi.mock('../helpers/thread-posting.js', () => ({ postSlackThreadMarkdownMessage: mocks.postThreadMessage, })); @@ -48,16 +88,35 @@ const processFastAgentMessage = ( params: Omit, ) => processFastAgentMessageImpl({ ...params, launchTask }); +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + describe('processFastAgentMessage', () => { beforeEach(() => { vi.clearAllMocks(); mocks.acquireLock.mockResolvedValue(mocks.releaseLock); + mocks.acquireRootBindingLock.mockResolvedValue( + mocks.releaseRootBindingLock, + ); mocks.hasSession.mockResolvedValue(false); + mocks.getSession.mockImplementation( + async ({ conversation }: { conversation: unknown }) => ({ + id: 'fast-session-1', + conversation, + }), + ); mocks.releaseLock.mockResolvedValue(undefined); + mocks.releaseRootBindingLock.mockResolvedValue(undefined); mocks.postThreadMessage.mockResolvedValue({ status: 'posted', messageId: '101.001', }); + mocks.recordProviderMessage.mockResolvedValue(undefined); mocks.answerQuestion.mockImplementation( async ({ adapter, @@ -119,6 +178,16 @@ describe('processFastAgentMessage', () => { }); expect(mocks.postThreadMessage).toHaveBeenCalledOnce(); + expect(mocks.recordProviderMessage).toHaveBeenCalledWith({ + sessionId: 'fast-session-1', + conversation: { + surface: 'slack', + workspaceId: 'T123', + conversationId: '100.001', + replyTarget: { channelId: 'D123', threadId: '100.001' }, + }, + messageId: '101.001', + }); expect(slack.updateMessage).toHaveBeenCalledWith({ channel: 'D123', ts: '101.001', @@ -150,6 +219,7 @@ describe('processFastAgentMessage', () => { slack: slack as never, userId: 'user-1', teamId: 'T123', + roomoteSlackUserId: 'UROOMOTE', activeTasks: [ { taskId: 'task-1', title: 'Fix API' }, { taskId: 'task-2', title: 'Update docs' }, @@ -159,6 +229,7 @@ describe('processFastAgentMessage', () => { expect(mocks.answerQuestion).toHaveBeenCalledWith( expect.objectContaining({ question: 'investigate this', + slackRoomoteUserId: 'UROOMOTE', currentMessageAgentContext: 'Slack block text:\nState: New', adapter: expect.objectContaining({ launchTask }), activeTasks: [ @@ -169,6 +240,84 @@ describe('processFastAgentMessage', () => { ); }); + it('resumes the canonical Fast session bound to a delayed Slack root', async () => { + const canonicalConversation = { + surface: 'slack' as const, + workspaceId: 'T123', + conversationId: 'automation-1:occurrence-1', + replyTarget: { channelId: 'C123', threadId: '100.001' }, + }; + mocks.hasSession.mockResolvedValue(true); + mocks.getSession.mockResolvedValue({ + id: 'fast-session-1', + conversation: canonicalConversation, + }); + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => []), + }; + + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U123', + text: 'continue', + thread_ts: '100.001', + ts: '100.002', + } as never, + slack: slack as never, + userId: 'user-1', + teamId: 'T123', + continuation: true, + }); + + expect(mocks.acquireLock).toHaveBeenCalledWith({ + conversation: canonicalConversation, + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ conversation: canonicalConversation }), + ); + }); + + it('waits for root binding before resolving an immediate reply session', async () => { + const bindingLock = createDeferred<() => Promise>(); + mocks.acquireRootBindingLock.mockReturnValueOnce(bindingLock.promise); + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => []), + }; + + const processing = processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U123', + text: 'continue', + thread_ts: '100.001', + ts: '100.002', + } as never, + slack: slack as never, + userId: 'user-1', + teamId: 'T123', + continuation: true, + }); + + await vi.waitFor(() => { + expect(mocks.acquireRootBindingLock).toHaveBeenCalledOnce(); + }); + expect(mocks.getSession).not.toHaveBeenCalled(); + + bindingLock.resolve(mocks.releaseRootBindingLock); + await processing; + + expect(mocks.getSession).toHaveBeenCalledOnce(); + }); + it('lets the Fast model answer a bare !fast invocation', async () => { const slack = { addReaction: vi.fn().mockResolvedValue(true), @@ -379,6 +528,60 @@ describe('processFastAgentMessage', () => { ); }); + it('passes authenticated extracted file content to the Fast turn separately', async () => { + const files = [ + { + id: 'F_PLAN', + name: 'plan.md', + mimetype: 'text/markdown', + filetype: 'markdown', + url_private: 'https://files.slack.com/F_PLAN', + url_private_download: 'https://files.slack.com/F_PLAN/download', + size: 128, + }, + ]; + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => []), + processSlackFiles: vi.fn().mockResolvedValue([]), + downloadSlackFile: vi + .fn() + .mockResolvedValue( + Buffer.from('# Plan\nImplement attachment forwarding.'), + ), + }; + + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'D123', + channel_type: 'im', + user: 'U123', + text: '!fast implement this plan', + ts: '100.001', + files, + } as never, + slack: slack as never, + userId: 'user-1', + teamId: 'T123', + }); + + expect(slack.downloadSlackFile).toHaveBeenCalledWith(files[0]); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + question: expect.stringContaining('Implement attachment forwarding.'), + attachmentTexts: [ + expect.stringContaining('Implement attachment forwarding.'), + ], + }), + ); + expect( + JSON.stringify(mocks.answerQuestion.mock.calls[0]?.[0]), + ).not.toContain('url_private_download'); + }); + it('can answer with a reaction without posting a text fallback', async () => { mocks.answerQuestion.mockImplementationOnce( async ({ @@ -834,4 +1037,112 @@ describe('processFastAgentMessage', () => { expect.objectContaining({ question: 'Good, tired' }), ); }); + + it('allows silence for an unmentioned turn with another human participant', async () => { + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => [ + { user: 'U111', username: 'Dan', text: '!fast hi', ts: '100.000' }, + { + user: 'UBOT', + username: 'Roomote', + bot_id: 'B999', + text: 'Hi Dan.', + ts: '100.001', + }, + { user: 'U222', username: 'Matt', text: 'Makes sense', ts: '100.002' }, + ]), + }; + + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U222', + text: 'Makes sense', + ts: '100.002', + thread_ts: '100.000', + } as never, + slack: slack as never, + userId: 'user-2', + teamId: 'T123', + continuation: true, + isExistingConversation: true, + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ allowSilentAmbientReply: true }), + ); + }); + + it.each(['im', 'mpim'] as const)( + 'requires a response in a Slack %s conversation', + async (channelType) => { + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => [ + { user: 'U111', username: 'Dan', text: 'Earlier', ts: '100.000' }, + { user: 'U222', username: 'Matt', text: 'Help', ts: '100.002' }, + ]), + }; + + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'D123', + channel_type: channelType, + user: 'U222', + text: 'Help', + ts: '100.002', + thread_ts: '100.000', + } as never, + slack: slack as never, + userId: 'user-2', + teamId: 'T123', + continuation: true, + isExistingConversation: true, + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ allowSilentAmbientReply: false }), + ); + }, + ); + + it('requires a response for a directed turn with another human participant', async () => { + const slack = { + addReaction: vi.fn().mockResolvedValue(true), + removeReaction: vi.fn().mockResolvedValue(true), + normalizeIncomingText: vi.fn(async (text: string) => text), + fetchThreadMessages: vi.fn(async () => [ + { user: 'U111', username: 'Dan', text: 'Earlier', ts: '100.000' }, + { user: 'U222', username: 'Matt', text: '!fast help', ts: '100.002' }, + ]), + }; + + await processFastAgentMessage({ + event: { + type: 'message', + channel: 'C123', + user: 'U222', + text: '!fast help', + ts: '100.002', + thread_ts: '100.000', + } as never, + slack: slack as never, + userId: 'user-2', + teamId: 'T123', + continuation: true, + isExistingConversation: true, + directedAtRoomote: true, + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ allowSilentAmbientReply: false }), + ); + }); }); diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts new file mode 100644 index 000000000..8b894cdcf --- /dev/null +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.test.ts @@ -0,0 +1,167 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + acquireLock: vi.fn(), + answerQuestion: vi.fn(), + createActivity: vi.fn(() => ({ start: vi.fn(), settle: vi.fn() })), + findConversation: vi.fn(), + findSession: vi.fn(), + getActiveTasks: vi.fn(), + lookupUser: vi.fn(), + postThreadMessage: vi.fn(), + recordProviderMessage: vi.fn(), + releaseLock: vi.fn(), +})); + +vi.mock('@roomote/cloud-agents/server', () => ({ + acquireFastAgentTurnLock: mocks.acquireLock, + answerFastAgentQuestion: mocks.answerQuestion, + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), + fastAgentConversationRepository: { findById: mocks.findConversation }, + getActiveFastAgentTasks: mocks.getActiveTasks, +})); + +vi.mock('@roomote/communication', () => ({ + buildFastSessionReplyFooterText: vi.fn(() => 'footer'), + resolveFastSessionReplyFooterContext: vi.fn(async () => ({})), +})); + +vi.mock('@roomote/sdk/server', () => ({ + findFastAgentSessionForProviderMessage: mocks.findSession, + recordFastAgentConversationMessageBestEffort: mocks.recordProviderMessage, + resolveUserMcpServerConfigs: vi.fn(async () => ({})), +})); + +vi.mock('@roomote/slack', () => ({ + buildSlackThreadReplyFooterBlock: vi.fn(() => ({ type: 'context' })), + createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()), + createFastAgentSlackSessionActivity: mocks.createActivity, + getSlackThreadReplyFooterMessageTs: vi.fn(async () => null), + withSlackThreadReplyFooterLock: vi.fn( + async ({ fn }: { fn: () => Promise }) => fn(), + ), +})); + +vi.mock('../helpers/thread-posting.js', () => ({ + postSlackThreadMarkdownMessage: mocks.postThreadMessage, +})); + +vi.mock('../helpers/user-mapping.js', () => ({ + lookupSlackUserMapping: mocks.lookupUser, +})); + +import { maybeRouteFastAgentReaction } from './fast-agent-reaction.js'; + +describe('Fast Slack reaction input', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.acquireLock.mockResolvedValue(mocks.releaseLock); + mocks.releaseLock.mockResolvedValue(undefined); + mocks.getActiveTasks.mockResolvedValue([]); + mocks.lookupUser.mockResolvedValue({ + activeMapping: { userId: 'user-1' }, + hasInactiveMapping: false, + }); + mocks.findSession.mockResolvedValue({ + id: 'session-1', + userId: 'user-1', + title: 'Investigate Slack agent status', + conversation: { + surface: 'slack', + workspaceId: 'T1', + conversationId: '100.000', + replyTarget: { channelId: 'C1', threadId: '100.000' }, + }, + }); + mocks.answerQuestion.mockResolvedValue(''); + }); + + it('includes the Fast-authored message when a reaction can directly answer it', async () => { + const slack = { + getMessage: vi.fn(async () => ({ + text: 'React to this message with your favorite emoji.', + thread_ts: '100.000', + })), + normalizeIncomingText: vi.fn(async () => '@alice'), + updateMessage: vi.fn(), + }; + + await expect( + maybeRouteFastAgentReaction({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'UALICE', + reaction: 'sparkling_heart', + item: { type: 'message', channel: 'C1', ts: '101.000' }, + event_ts: '102.000', + }, + }), + ).resolves.toBe(true); + + await vi.waitFor(() => expect(mocks.answerQuestion).toHaveBeenCalledOnce()); + expect(mocks.createActivity).toHaveBeenCalledWith({ + slack: expect.anything(), + workspaceId: 'T1', + channel: 'C1', + threadTs: '100.000', + title: 'Investigate Slack agent status', + resolveTitle: expect.any(Function), + }); + expect(mocks.findSession).toHaveBeenCalledWith({ + provider: 'slack', + workspaceId: 'T1', + channelId: 'C1', + messageId: '101.000', + userId: 'user-1', + }); + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + currentMessageId: 'slack-reaction:102.000', + senderExternalId: 'UALICE', + senderDisplayName: '@alice', + input: { + type: 'reaction', + externalInput: expect.objectContaining({ + type: 'reaction_added', + provider: 'slack', + reactions: [{ name: 'sparkling_heart' }], + }), + }, + question: expect.stringContaining( + 'React to this message with your favorite emoji.', + ), + }), + ); + expect(mocks.postThreadMessage).not.toHaveBeenCalled(); + }); + + it('does not route reactions from users who do not own the bound session', async () => { + mocks.findSession.mockResolvedValue(null); + + await expect( + maybeRouteFastAgentReaction({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE' }, + slack: {}, + } as never, + event: { + type: 'reaction_added', + user: 'UBOB', + reaction: 'eyes', + item: { type: 'message', channel: 'C1', ts: '101.000' }, + event_ts: '102.000', + }, + }), + ).resolves.toBe(false); + expect(mocks.answerQuestion).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/slack/events/fast-agent-reaction.ts b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts new file mode 100644 index 000000000..5b2843545 --- /dev/null +++ b/apps/api/src/handlers/slack/events/fast-agent-reaction.ts @@ -0,0 +1,284 @@ +import { Env } from '@roomote/env'; +import { + acquireFastAgentTurnLock, + answerFastAgentQuestion, + buildFastAgentReactionExternalInputQuestion, + fastAgentConversationRepository, + getActiveFastAgentTasks, + type FastAgentReactionExternalInput, +} from '@roomote/cloud-agents/server'; +import { + buildFastSessionReplyFooterText, + resolveFastSessionReplyFooterContext, +} from '@roomote/communication'; +import { + findFastAgentSessionForProviderMessage, + recordFastAgentConversationMessageBestEffort, + resolveUserMcpServerConfigs, +} from '@roomote/sdk/server'; +import { + buildSlackThreadReplyFooterBlock, + createFastAgentSlackLiveTaskLauncher, + createFastAgentSlackSessionActivity, + getSlackThreadReplyFooterMessageTs, + type SlackReactionAddedEvent, + withSlackThreadReplyFooterLock, +} from '@roomote/slack'; + +import { startAcceptedFastAgentTurn } from '../../fast-agent-entry.js'; +import type { SlackWebhookContext } from '../context.js'; +import { postSlackThreadMarkdownMessage } from '../helpers/thread-posting.js'; +import { lookupSlackUserMapping } from '../helpers/user-mapping.js'; + +async function processFastAgentReaction(params: { + context: SlackWebhookContext; + event: SlackReactionAddedEvent; + session: NonNullable< + Awaited> + >; + targetMessage: { text: string; thread_ts?: string }; + reactorDisplayName?: string; + onAccepted: (abort: () => Promise) => void; + onRejected: () => void; +}): Promise { + const { context, event, session } = params; + const conversation = session.conversation; + if (conversation.surface !== 'slack') { + params.onRejected(); + return; + } + + const threadTs = conversation.replyTarget.threadId; + if (!threadTs) { + params.onRejected(); + return; + } + + const releaseTurnLock = await acquireFastAgentTurnLock({ conversation }); + if (!releaseTurnLock) { + params.onRejected(); + return; + } + params.onAccepted(() => + releaseTurnLock.abort(new Error('Slack reaction turn was canceled.')), + ); + + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'slack', + reactions: [{ name: event.reaction }], + reactor: { + externalUserId: event.user, + ...(params.reactorDisplayName + ? { displayName: params.reactorDisplayName } + : {}), + }, + message: { + workspaceId: context.teamId, + channelId: event.item.channel, + messageId: event.item.ts, + threadId: threadTs, + text: params.targetMessage.text, + }, + eventId: event.event_ts, + }; + + try { + const activeTasks = await getActiveFastAgentTasks(session.id); + const footerContext = await resolveFastSessionReplyFooterContext({ + sessionId: session.id, + }); + let didSendVisibleResponse = false; + + const responseText = await answerFastAgentQuestion({ + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + userId: session.userId, + conversation, + currentMessageId: `slack-reaction:${event.event_ts}`, + senderExternalId: event.user, + senderDisplayName: params.reactorDisplayName, + activeTasks, + apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, + signal: releaseTurnLock.signal, + input: { type: 'reaction', externalInput: reactionInput }, + adapter: { + activity: createFastAgentSlackSessionActivity({ + slack: context.slack, + workspaceId: context.teamId, + channel: event.item.channel, + threadTs, + title: session.title, + resolveTitle: async () => + (await fastAgentConversationRepository.findById({ id: session.id })) + ?.title, + }), + resolveMcpServerConfigs: () => + resolveUserMcpServerConfigs({ + userId: session.userId, + apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, + includeRoomoteMemberTools: true, + }), + launchTask: createFastAgentSlackLiveTaskLauncher({ + slack: context.slack, + userId: session.userId, + teamId: context.teamId, + ...(context.slackInstallation.teamDomain + ? { teamDomain: context.slackInstallation.teamDomain } + : {}), + channelId: event.item.channel, + threadTs, + messageId: event.item.ts, + }), + postReply: async ({ message, kickoff }) => { + const posted = await postSlackThreadMarkdownMessage({ + slack: context.slack, + channel: event.item.channel, + threadTs, + text: message, + sourceMessageTs: event.item.ts, + conversationLog: { + userId: session.userId, + slackTeamId: context.teamId, + source: 'fast_agent', + }, + fastSessionFooter: { sessionId: session.id, ...footerContext }, + }); + if (posted === 'failed') { + throw new Error('Slack did not accept the Fast reaction reply.'); + } + if (posted === 'suppressed' && kickoff) { + throw new Error( + 'The Fast kickoff was suppressed because the reacted-to message was deleted.', + ); + } + didSendVisibleResponse = posted !== 'suppressed'; + if (typeof posted !== 'object') return undefined; + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.messageId, + }); + return { messageId: posted.messageId }; + }, + replaceReply: async ({ messageId }, { message }) => { + const updated = await withSlackThreadReplyFooterLock({ + channel: event.item.channel, + threadTs, + fn: async () => { + const footerMessageTs = await getSlackThreadReplyFooterMessageTs( + event.item.channel, + threadTs, + ).catch(() => null); + return context.slack.updateMessage({ + channel: event.item.channel, + ts: messageId, + message: { + text: message, + blocks: [ + { type: 'markdown', text: message }, + ...(footerMessageTs === messageId + ? [ + buildSlackThreadReplyFooterBlock({ + footerText: buildFastSessionReplyFooterText({ + provider: 'slack', + sessionId: session.id, + ...footerContext, + }), + }), + ] + : []), + ], + }, + }); + }, + }); + if (!updated) { + throw new Error('Slack did not update the Fast reaction reply.'); + } + didSendVisibleResponse = true; + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId, + }); + return { messageId }; + }, + }, + }); + + if (responseText.length > 0 && !didSendVisibleResponse) { + const posted = await postSlackThreadMarkdownMessage({ + slack: context.slack, + channel: event.item.channel, + threadTs, + text: responseText, + sourceMessageTs: event.item.ts, + conversationLog: { + userId: session.userId, + slackTeamId: context.teamId, + source: 'fast_agent', + }, + fastSessionFooter: { sessionId: session.id, ...footerContext }, + }); + if (typeof posted === 'object') { + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.messageId, + }); + } + } + } finally { + await releaseTurnLock().catch(() => {}); + } +} + +export async function maybeRouteFastAgentReaction(params: { + context: SlackWebhookContext; + event: SlackReactionAddedEvent; +}): Promise { + const { context, event } = params; + const { activeMapping } = await lookupSlackUserMapping({ + slackUserId: event.user, + teamId: context.teamId, + }); + if (!activeMapping) return false; + + const session = await findFastAgentSessionForProviderMessage({ + provider: 'slack', + workspaceId: context.teamId, + channelId: event.item.channel, + messageId: event.item.ts, + userId: activeMapping.userId, + }); + if (!session) return false; + + const targetMessage = await context.slack.getMessage({ + channel: event.item.channel, + messageTs: event.item.ts, + }); + if (!targetMessage) return true; + + const reactorDisplayName = await context.slack + .normalizeIncomingText(`<@${event.user}>`) + .catch(() => undefined); + await startAcceptedFastAgentTurn({ + run: ({ onAccepted, onRejected }) => + processFastAgentReaction({ + context, + event, + session, + targetMessage, + reactorDisplayName, + onAccepted, + onRejected, + }), + onError: (error) => { + console.error( + `[SlackWebhook] Fast reaction input failed for ${event.item.channel}:${event.item.ts}:`, + error instanceof Error ? error.message : String(error), + ); + }, + }); + return true; +} diff --git a/apps/api/src/handlers/slack/events/fast-agent.ts b/apps/api/src/handlers/slack/events/fast-agent.ts index be5f2eee7..5e6709cd0 100644 --- a/apps/api/src/handlers/slack/events/fast-agent.ts +++ b/apps/api/src/handlers/slack/events/fast-agent.ts @@ -2,24 +2,37 @@ import { getOrCreateFastAgentSession, acquireFastAgentTurnLock, answerFastAgentQuestion, + fastAgentConversationRepository, hasFastAgentSession, type FastAgentActiveTask, type LaunchFastAgentTask, } from '@roomote/cloud-agents/server'; -import { buildFastSessionReplyFooterText } from '@roomote/communication'; import { + buildFastSessionReplyFooterText, + resolveFastSessionReplyFooterContext, +} from '@roomote/communication'; +import { + acquireSlackFastRootBindingLock, buildSlackThreadReplyFooterBlock, + createFastAgentSlackSessionActivity, getSlackThreadReplyFooterMessageTs, withSlackThreadReplyFooterLock, resolveCurrentSlackMessageFiles, type SlackEvent, type SlackNotifier, } from '@roomote/slack'; -import { stripLeadingSlackProductMention } from '@roomote/cloud-agents'; -import { resolveUserMcpServerConfigs } from '@roomote/sdk/server'; +import { + appendAttachmentTextsToPromptText, + stripLeadingSlackProductMention, +} from '@roomote/cloud-agents'; +import { + recordFastAgentConversationMessageBestEffort, + resolveUserMcpServerConfigs, +} from '@roomote/sdk/server'; import { LEADING_FAST_COMMAND_MENTION_PATTERN } from '../constants.js'; import { postSlackThreadMarkdownMessage } from '../helpers/thread-posting.js'; +import { processSlackAttachments } from '../helpers/attachments.js'; export function stripLeadingFastCommandMention(text: string): string { return text.replace(LEADING_FAST_COMMAND_MENTION_PATTERN, '').trimStart(); @@ -66,6 +79,10 @@ export async function processFastAgentMessage(params: { launchTask: LaunchFastAgentTask; processingReactionName?: string; isExistingConversation?: boolean; + directedAtRoomote?: boolean; + roomoteSlackUserId?: string; + onAccepted?: (abort: () => Promise) => void; + onRejected?: () => void; }): Promise { const { event, @@ -79,9 +96,11 @@ export async function processFastAgentMessage(params: { launchTask, processingReactionName = 'eyes', isExistingConversation = false, + directedAtRoomote = false, + roomoteSlackUserId, } = params; const threadId = event.thread_ts || event.ts; - const conversation = { + const incomingConversation = { surface: 'slack' as const, workspaceId: teamId, conversationId: threadId, @@ -91,10 +110,11 @@ export async function processFastAgentMessage(params: { }, }; const releaseFastAgentLock = await acquireFastAgentTurnLock({ - conversation, + conversation: incomingConversation, }); if (!releaseFastAgentLock) { + params.onRejected?.(); console.error( `[SlackWebhook] Fast turn lock did not become available for ${teamId}:${event.channel}:${threadId}`, ); @@ -106,14 +126,53 @@ export async function processFastAgentMessage(params: { stripLeadingFastCommandMention(event.authoredText ?? event.text), ), ); - const question = extractFastQuestion(normalizedText, continuation) ?? ''; + const baseQuestion = extractFastQuestion(normalizedText, continuation) ?? ''; let didAddProcessingReaction = false; + let releaseCanonicalFastAgentLock: Awaited< + ReturnType + > = null; try { - // A false routing result can become stale while waiting for the turn lock. - const hasExistingConversation = - isExistingConversation || (await hasFastAgentSession(conversation)); + // Resolve route-based aliases only after serializing the inbound Slack + // thread. Delayed automation roots retain their original conversation + // identity, so their canonical session has a separate turn lock. + const releaseRootBindingLock = await acquireSlackFastRootBindingLock({ + teamId, + channelId: event.channel, + }); + const { hasExistingConversation, session } = await (async () => { + try { + return { + hasExistingConversation: + isExistingConversation || + (await hasFastAgentSession(incomingConversation)), + session: await getOrCreateFastAgentSession({ + userId, + conversation: incomingConversation, + }), + }; + } finally { + await releaseRootBindingLock().catch(() => {}); + } + })(); + const conversation = session.conversation; + if ( + conversation.surface !== incomingConversation.surface || + conversation.workspaceId !== incomingConversation.workspaceId || + conversation.conversationId !== incomingConversation.conversationId + ) { + releaseCanonicalFastAgentLock = await acquireFastAgentTurnLock({ + conversation, + }); + if (!releaseCanonicalFastAgentLock) { + console.error( + `[SlackWebhook] Canonical Fast turn lock did not become available for session ${session.id}`, + ); + return; + } + } + if (!hasExistingConversation) { didAddProcessingReaction = await slack.addReaction({ channel: event.channel, @@ -122,9 +181,11 @@ export async function processFastAgentMessage(params: { }); } - // Resolved ahead of the turn so replies can carry the session footer; - // the service's own getOrCreate finds this same row. - const session = await getOrCreateFastAgentSession({ userId, conversation }); + params.onAccepted?.(() => + (releaseCanonicalFastAgentLock ?? releaseFastAgentLock).abort( + new Error('Fast suggestion launch settlement failed.'), + ), + ); let threadContext: Awaited> = []; @@ -149,14 +210,20 @@ export async function processFastAgentMessage(params: { eventFiles: event.files, messages: threadContext, }); - const images = currentMessageFiles?.length - ? await slack.processSlackFiles(currentMessageFiles).catch((error) => { - console.error( - `[SlackWebhook] Failed to process Fast message images: ${error instanceof Error ? error.message : String(error)}`, - ); - return []; - }) - : []; + const attachments = await processSlackAttachments({ + slack, + files: currentMessageFiles, + userId, + userTextContext: baseQuestion, + }); + const attachmentTexts = [ + ...attachments.attachmentTexts, + ...attachments.videoDescriptions, + ]; + const question = appendAttachmentTextsToPromptText({ + text: baseQuestion, + attachmentTexts, + }); const serializedThreadContext = threadContext .filter((message) => message.ts !== event.ts) .map((message) => ({ @@ -166,27 +233,54 @@ export async function processFastAgentMessage(params: { ts: message.ts, bot_id: message.bot_id, })); + const hasOtherHumanParticipant = threadContext.some( + (message) => + message.ts !== event.ts && + !message.bot_id && + Boolean(message.user) && + message.user !== event.user, + ); const resolvedActiveTasks = resolveActiveTasks ? await resolveActiveTasks() : activeTasks; + const footerContext = await resolveFastSessionReplyFooterContext({ + sessionId: session.id, + }); const responseText = await answerFastAgentQuestion({ question, - images, + images: attachments.images, + attachmentTexts, currentMessageAgentContext: event.agentContext, threadContext: serializedThreadContext, userId, apiBaseUrl, conversation, currentMessageId: event.ts, - signal: releaseFastAgentLock.signal, + signal: (releaseCanonicalFastAgentLock ?? releaseFastAgentLock).signal, senderExternalId: event.user, senderDisplayName: currentMessage?.user === event.user ? currentMessage.username : undefined, activeTasks: resolvedActiveTasks, + allowSilentAmbientReply: + event.channel_type !== 'im' && + event.channel_type !== 'mpim' && + hasOtherHumanParticipant && + !directedAtRoomote, + ...(roomoteSlackUserId ? { slackRoomoteUserId: roomoteSlackUserId } : {}), adapter: { + activity: createFastAgentSlackSessionActivity({ + slack, + workspaceId: teamId, + channel: event.channel, + threadTs: threadId, + title: session.title, + resolveTitle: async () => + (await fastAgentConversationRepository.findById({ id: session.id })) + ?.title, + }), resolveMcpServerConfigs: () => resolveUserMcpServerConfigs({ userId, @@ -206,7 +300,7 @@ export async function processFastAgentMessage(params: { slackTeamId: teamId, source: 'fast_agent', }, - fastSessionFooter: { sessionId: session.id }, + fastSessionFooter: { sessionId: session.id, ...footerContext }, }); if (posted === 'failed') { throw new Error('Slack did not accept the Fast parent reply.'); @@ -223,9 +317,15 @@ export async function processFastAgentMessage(params: { // message was deleted); treat it as delivered so the turn is not // aborted mid-flight. didSendVisibleResponse = true; - return typeof posted === 'object' - ? { messageId: posted.messageId } - : undefined; + if (typeof posted !== 'object') { + return undefined; + } + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.messageId, + }); + return { messageId: posted.messageId }; }, replaceReply: async ({ messageId }, { message }) => { // Keep the sticky footer when the edited message is its current @@ -252,6 +352,7 @@ export async function processFastAgentMessage(params: { footerText: buildFastSessionReplyFooterText({ provider: 'slack', sessionId: session.id, + ...footerContext, }), }), ] @@ -264,6 +365,11 @@ export async function processFastAgentMessage(params: { if (!updated) { throw new Error('Slack did not update the Fast parent reply.'); } + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId, + }); didSendVisibleResponse = true; return { messageId }; }, @@ -294,7 +400,7 @@ export async function processFastAgentMessage(params: { }); if (responseText.length > 0 && !didSendVisibleResponse) { - await postSlackThreadMarkdownMessage({ + const posted = await postSlackThreadMarkdownMessage({ slack, channel: event.channel, threadTs: threadId, @@ -305,8 +411,15 @@ export async function processFastAgentMessage(params: { slackTeamId: teamId, source: 'fast_agent', }, - fastSessionFooter: { sessionId: session.id }, + fastSessionFooter: { sessionId: session.id, ...footerContext }, }); + if (typeof posted === 'object') { + await recordFastAgentConversationMessageBestEffort({ + sessionId: session.id, + conversation, + messageId: posted.messageId, + }); + } } } finally { if (didAddProcessingReaction) { @@ -318,6 +431,7 @@ export async function processFastAgentMessage(params: { }) .catch(() => {}); } + await releaseCanonicalFastAgentLock?.().catch(() => {}); await releaseFastAgentLock().catch(() => {}); } } diff --git a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts index ed8fac9db..a89e2de93 100644 --- a/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts +++ b/apps/api/src/handlers/slack/events/message-entry-unmentioned-routing.test.ts @@ -6,6 +6,8 @@ const { findRoomoteOwnedSlackThreadMock, markSlackThreadExplicitMentionRequiredMock, getSlackThreadReplyFooterMessageTsMock, + acquireRootBindingLockMock, + releaseRootBindingLockMock, hasFastAgentSessionMock, findActiveSlackTaskRunMock, findCompletedSlackTaskRunWithSnapshotMock, @@ -15,6 +17,8 @@ const { findRoomoteOwnedSlackThreadMock: vi.fn(), markSlackThreadExplicitMentionRequiredMock: vi.fn(), getSlackThreadReplyFooterMessageTsMock: vi.fn(), + acquireRootBindingLockMock: vi.fn(), + releaseRootBindingLockMock: vi.fn(), hasFastAgentSessionMock: vi.fn(), findActiveSlackTaskRunMock: vi.fn(), findCompletedSlackTaskRunWithSnapshotMock: vi.fn(), @@ -36,6 +40,7 @@ vi.mock('@roomote/cloud-agents', () => ({ vi.mock('@roomote/slack', async (importOriginal) => ({ ...(await importOriginal()), + acquireSlackFastRootBindingLock: acquireRootBindingLockMock, createFastAgentSlackLiveTaskLauncher: vi.fn(() => vi.fn()), hasPendingRoutingConfirmation: hasPendingRoutingConfirmationMock, markSlackThreadExplicitMentionRequired: @@ -110,6 +115,14 @@ async function routeDecision(event: never) { }); } +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { beforeEach(() => { vi.clearAllMocks(); @@ -120,6 +133,8 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { }); markSlackThreadExplicitMentionRequiredMock.mockResolvedValue(undefined); getSlackThreadReplyFooterMessageTsMock.mockResolvedValue(null); + acquireRootBindingLockMock.mockResolvedValue(releaseRootBindingLockMock); + releaseRootBindingLockMock.mockResolvedValue(undefined); hasFastAgentSessionMock.mockResolvedValue(false); findActiveSlackTaskRunMock.mockResolvedValue(null); findCompletedSlackTaskRunWithSnapshotMock.mockResolvedValue(null); @@ -152,6 +167,36 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { expect(findRoomoteOwnedSlackThreadMock).not.toHaveBeenCalled(); }, 15_000); + it('waits for delayed root binding before classifying an automatic reply', async () => { + const bindingLock = createDeferred<() => Promise>(); + acquireRootBindingLockMock.mockReturnValueOnce(bindingLock.promise); + hasFastAgentSessionMock.mockResolvedValue(true); + findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); + fetchThreadMessagesMock.mockResolvedValue([ + botMessage(THREAD_TS, 'Automation result'), + ]); + + const decision = routeDecision( + threadReplyEvent({ + user: 'U111', + ts: '102.000', + text: 'Can you follow up?', + }), + ); + + await vi.waitFor(() => { + expect(acquireRootBindingLockMock).toHaveBeenCalledWith({ + teamId: 'T123', + channelId: 'C123', + }); + }); + expect(hasFastAgentSessionMock).not.toHaveBeenCalled(); + + bindingLock.resolve(releaseRootBindingLockMock); + await expect(decision).resolves.toMatchObject({ shouldRoute: true }); + expect(hasFastAgentSessionMock).toHaveBeenCalledOnce(); + }); + it("routes Matt when he joins Dan's existing fast-agent conversation", async () => { hasFastAgentSessionMock.mockResolvedValue(true); findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); @@ -171,6 +216,71 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { ).resolves.toMatchObject({ shouldRoute: true }); }); + it('routes a new participant after the original participant speaks again in a fast-agent thread', async () => { + hasFastAgentSessionMock.mockResolvedValue(true); + findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); + fetchThreadMessagesMock.mockResolvedValue([ + humanMessage('U111', THREAD_TS, '<@UBOT> !fast hi'), + botMessage('101.000', 'Hi there.'), + humanMessage('U111', '102.000', 'One more detail'), + ]); + + await expect( + routeDecision( + threadReplyEvent({ + user: 'U222', + ts: '103.000', + text: 'Can you check that too?', + }), + ), + ).resolves.toMatchObject({ shouldRoute: true }); + }); + + it('keeps a reply silent when the previous participant addressed the sender', async () => { + hasFastAgentSessionMock.mockResolvedValue(true); + findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); + fetchThreadMessagesMock.mockResolvedValue([ + humanMessage('U111', THREAD_TS, '<@UBOT> !fast hi'), + botMessage('101.000', 'Hi there.'), + humanMessage('U111', '102.000', '<@U222> what do you think?'), + ]); + + await expect( + routeDecision( + threadReplyEvent({ + user: 'U222', + ts: '103.000', + text: 'I agree', + }), + ), + ).resolves.toEqual({ shouldRoute: false }); + expect(markSlackThreadExplicitMentionRequiredMock).toHaveBeenCalledWith( + 'C123', + THREAD_TS, + ); + }); + + it('keeps routing after the sender mentions themself in a fast-agent thread', async () => { + hasFastAgentSessionMock.mockResolvedValue(true); + findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); + fetchThreadMessagesMock.mockResolvedValue([ + humanMessage('U111', THREAD_TS, '<@UBOT> !fast hi'), + botMessage('101.000', 'Hi there.'), + humanMessage('U111', '102.000', '<@U111> note to self'), + ]); + + await expect( + routeDecision( + threadReplyEvent({ + user: 'U111', + ts: '103.000', + text: 'One more detail', + }), + ), + ).resolves.toMatchObject({ shouldRoute: true }); + expect(markSlackThreadExplicitMentionRequiredMock).not.toHaveBeenCalled(); + }); + it('keeps a peer-directed reply silent in an existing fast-agent thread', async () => { hasFastAgentSessionMock.mockResolvedValue(true); findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); @@ -187,7 +297,7 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { expect(fetchThreadMessagesMock).not.toHaveBeenCalled(); }); - it('requires an explicit mention after another user interjects in a fast-agent thread', async () => { + it('keeps routing between participants in a fast-agent thread', async () => { hasFastAgentSessionMock.mockResolvedValue(true); findRoomoteOwnedSlackThreadMock.mockResolvedValue(null); fetchThreadMessagesMock.mockResolvedValue([ @@ -204,11 +314,8 @@ describe('shouldRouteUnmentionedSlackThreadReplyToAgent', () => { text: 'Can you continue?', }), ), - ).resolves.toEqual({ shouldRoute: false }); - expect(markSlackThreadExplicitMentionRequiredMock).toHaveBeenCalledWith( - 'C123', - THREAD_TS, - ); + ).resolves.toMatchObject({ shouldRoute: true }); + expect(markSlackThreadExplicitMentionRequiredMock).not.toHaveBeenCalled(); }); it('routes an unmentioned reply directly after the bot last spoke', async () => { diff --git a/apps/api/src/handlers/slack/events/message-entry.ts b/apps/api/src/handlers/slack/events/message-entry.ts index f459d5d25..4810f1492 100644 --- a/apps/api/src/handlers/slack/events/message-entry.ts +++ b/apps/api/src/handlers/slack/events/message-entry.ts @@ -10,6 +10,7 @@ import { ROUTING_AUTO_CONFIRM_TIMEOUT_MS, } from '@roomote/cloud-agents/server'; import { + acquireSlackFastRootBindingLock, autoConfirmRouting, collectAndExtractThreadAttachmentTexts, collectAndProcessThreadImages, @@ -59,7 +60,11 @@ import { isFastCommandInvocation, processFastAgentMessage, } from './fast-agent.js'; -import { resolveFastAgentEntryMode } from '../../fast-agent-entry.js'; +import { + resolveFastAgentEntryMode, + startAcceptedFastAgentTurn, + type FastAgentStartResult, +} from '../../fast-agent-entry.js'; import { processSnapshotResume } from './snapshot-resume.js'; import { dispatchSlackThreadFollowUp, @@ -103,6 +108,30 @@ import { showManualPickerForAutoRouteFallback } from './auto-route-fallback.js'; const REMOVED_EVAL_COMMAND_PATTERN = /^!eval(?:\s|$)/iu; +async function hasBoundSlackFastAgentSession(params: { + teamId: string; + channelId: string; + threadId: string; +}): Promise { + const releaseRootBindingLock = await acquireSlackFastRootBindingLock({ + teamId: params.teamId, + channelId: params.channelId, + }); + try { + return await hasFastAgentSession({ + surface: 'slack', + workspaceId: params.teamId, + conversationId: params.threadId, + replyTarget: { + channelId: params.channelId, + threadId: params.threadId, + }, + }); + } finally { + await releaseRootBindingLock().catch(() => {}); + } +} + export function isRemovedEvalCommandInvocation(text: string): boolean { const mentionStrippedText = text .replace(/^\s*<@[^>]+>[\s,:;.-]*/u, '') @@ -501,11 +530,10 @@ export async function shouldRouteUnmentionedSlackThreadReplyToAgent(params: { if (pendingRoutingConfirmation) { eligibilityReason = 'pending-routing-confirmation'; } else { - isFastAgentThread = await hasFastAgentSession({ - surface: 'slack', - workspaceId: teamId, - conversationId: event.thread_ts, - replyTarget: { channelId: event.channel, threadId: event.thread_ts }, + isFastAgentThread = await hasBoundSlackFastAgentSession({ + teamId, + channelId: event.channel, + threadId: event.thread_ts, }); roomoteThreadMatch = isFastAgentThread @@ -579,7 +607,7 @@ export async function shouldRouteUnmentionedSlackThreadReplyToAgent(params: { mentionsSomebodyElse: mentionsSlackUserOtherThanBotOrUser( message, slackInstallation.botUserId, - event.user, + message.user, ), }; }, @@ -1248,16 +1276,14 @@ async function maybeHandleChannelAutoStart(params: { explicitInvocation: isBareFastCommandInvocation( channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, ), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation( - channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, - ), + userDefaultEnabled: !isRemovedEvalCommandInvocation( + channelAutoStartEvent.authoredText ?? channelAutoStartEvent.text, + ), }) : null; if (fastAgentEntryMode && userMapping) { - startFastAgentResponse({ + void startFastAgentResponse({ event: { ...channelAutoStartEvent, user: channelAutoStartEvent.user }, slackInstallation: context.slackInstallation, userMapping, @@ -1265,6 +1291,12 @@ async function maybeHandleChannelAutoStart(params: { userId: userMapping.userId, teamId: context.teamId, continuation: fastAgentEntryMode === 'default', + directedAtRoomote: + fastAgentEntryMode === 'explicit' || + mentionsSlackBot( + channelAutoStartEvent, + context.slackInstallation.botUserId, + ), processingReactionName: ackEmoji, errorLogPrefix: `❌ Background fast-agent response failed for auto-start thread ${channelAutoStartEvent.ts}:`, }); @@ -1589,7 +1621,7 @@ async function startAutomatedAppMentionTaskWithLock(params: { return true; } -function startFastAgentResponse(params: { +export function startFastAgentResponse(params: { event: SlackEvent; slackInstallation: SlackInstallation; userMapping: SlackUserMapping; @@ -1601,29 +1633,36 @@ function startFastAgentResponse(params: { resolveActiveTasks?: () => Promise<{ taskId: string }[]>; processingReactionName: string; isExistingConversation?: boolean; + directedAtRoomote?: boolean; errorLogPrefix: string; -}): void { +}): Promise { const { errorLogPrefix, ...fastAgentParams } = params; - - processFastAgentMessage({ - ...fastAgentParams, - apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, - launchTask: createFastAgentSlackLiveTaskLauncher({ - slack: params.slack, - userId: params.userId, - teamId: params.teamId, - ...(params.slackInstallation.teamDomain - ? { teamDomain: params.slackInstallation.teamDomain } - : {}), - channelId: params.event.channel, - threadTs: params.event.thread_ts || params.event.ts, - messageId: params.event.ts, - }), - }).catch((error) => { - console.error( - errorLogPrefix, - error instanceof Error ? error.message : String(error), - ); + return startAcceptedFastAgentTurn({ + run: ({ onAccepted, onRejected }) => + processFastAgentMessage({ + ...fastAgentParams, + roomoteSlackUserId: params.slackInstallation.botUserId ?? undefined, + apiBaseUrl: Env.TRPC_URL ?? Env.R_APP_URL, + launchTask: createFastAgentSlackLiveTaskLauncher({ + slack: params.slack, + userId: params.userId, + teamId: params.teamId, + ...(params.slackInstallation.teamDomain + ? { teamDomain: params.slackInstallation.teamDomain } + : {}), + channelId: params.event.channel, + threadTs: params.event.thread_ts || params.event.ts, + messageId: params.event.ts, + }), + onAccepted, + onRejected, + }), + onError: (error) => { + console.error( + errorLogPrefix, + error instanceof Error ? error.message : String(error), + ); + }, }); } @@ -1733,13 +1772,11 @@ async function handleSlackEntryEvent(params: { const authoredEventText = event.authoredText ?? event.text; const fastAgentEntryMode = resolveFastAgentEntryMode({ explicitInvocation: isFastCommandInvocation(authoredEventText), - userDefaultEnabled: - userMapping.communicationsFastModeDefault && - !isRemovedEvalCommandInvocation(authoredEventText), + userDefaultEnabled: !isRemovedEvalCommandInvocation(authoredEventText), }); if (fastAgentEntryMode) { - startFastAgentResponse({ + void startFastAgentResponse({ event, slackInstallation, userMapping, @@ -1755,6 +1792,9 @@ async function handleSlackEntryEvent(params: { activeTaskId: activeRun?.taskId, }), continuation: fastAgentEntryMode === 'default', + directedAtRoomote: + fastAgentEntryMode === 'explicit' || + mentionsSlackBot(event, slackInstallation.botUserId), processingReactionName: ackEmoji, errorLogPrefix: `❌ Background fast-agent response failed for thread ${threadId}:`, }); @@ -1766,15 +1806,14 @@ async function handleSlackEntryEvent(params: { authoredEventText, ) ? false - : await hasFastAgentSession({ - surface: 'slack', - workspaceId: teamId, - conversationId: threadId, - replyTarget: { channelId: event.channel, threadId }, + : await hasBoundSlackFastAgentSession({ + teamId, + channelId: event.channel, + threadId, }); if (isFastAgentContinuation) { - startFastAgentResponse({ + void startFastAgentResponse({ event, slackInstallation, userMapping, diff --git a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts index d3a04c2e6..4be104ee0 100644 --- a/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts +++ b/apps/api/src/handlers/slack/events/reactions-chat-reply-suggestions.test.ts @@ -9,8 +9,10 @@ const mocks = vi.hoisted(() => ({ lookupSlackUserMapping: vi.fn(), startAutoRoutedSlackTask: vi.fn(), startSlackAppMentionTask: vi.fn(), + startFastAgentResponse: vi.fn(), postStartedMessage: vi.fn(), getConfiguration: vi.fn(), + routeFastReaction: vi.fn(), })); const claimedAt = new Date('2026-08-06T00:00:00.000Z'); @@ -122,6 +124,11 @@ vi.mock('../../tasks/orphaned-work-item-run.js', () => ({ vi.mock('./message-entry.js', () => ({ handleMessageOrAppMentionEvent: vi.fn(), + startFastAgentResponse: mocks.startFastAgentResponse, +})); + +vi.mock('./fast-agent-reaction.js', () => ({ + maybeRouteFastAgentReaction: mocks.routeFastReaction, })); vi.mock('./task-suggestion-reaction-contention.js', () => ({ @@ -137,6 +144,7 @@ describe('chat reply suggestion reactions', () => { beforeEach(() => { vi.clearAllMocks(); mocks.getConfiguration.mockResolvedValue(null); + mocks.routeFastReaction.mockResolvedValue(false); mocks.trackedMessageFindFirst.mockResolvedValue({ id: 'tracked-message-1', workItemId: 'work-item-1', @@ -144,10 +152,12 @@ describe('chat reply suggestion reactions', () => { }); mocks.lookupSlackUserMapping.mockResolvedValue({ hasInactiveMapping: false, - activeMapping: { userId: 'user-1' }, + activeMapping: null, }); mocks.claimWorkItem.mockResolvedValue({ launchClaimedAt: claimedAt }); mocks.finalizeWorkItemLaunched.mockResolvedValue(true); + mocks.releaseWorkItemClaim.mockResolvedValue(true); + mocks.postStartedMessage.mockResolvedValue(undefined); mocks.resolveWorkspace.mockResolvedValue({ workspace: { repoForPayload: 'acme/app', @@ -166,9 +176,10 @@ describe('chat reply suggestion reactions', () => { id: 42, taskId: 'task-new', }); + mocks.startFastAgentResponse.mockResolvedValue({ accepted: true }); }); - it('starts and records a task when a user approves a chat reply suggestion', async () => { + it('starts and records a coding task when Fast is unavailable', async () => { const slack = { postMessage: vi.fn(async () => 'seeded-thread-ts'), deleteMessage: vi.fn(async () => undefined), @@ -193,12 +204,12 @@ describe('chat reply suggestion reactions', () => { expect(mocks.startAutoRoutedSlackTask).toHaveBeenCalledWith( expect.objectContaining({ channel: 'C1', - prompt: - 'Start this suggested task: Add retry telemetry\n\nInstrument retry exhaustion.', + prompt: 'Add retry telemetry\n\nInstrument retry exhaustion.', agentPromptTextOverride: 'implementation prompt', }), ); expect(mocks.startSlackAppMentionTask).not.toHaveBeenCalled(); + expect(mocks.startFastAgentResponse).not.toHaveBeenCalled(); expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( expect.anything(), { @@ -208,6 +219,230 @@ describe('chat reply suggestion reactions', () => { }, ); expect(mocks.postStartedMessage).not.toHaveBeenCalled(); + expect(mocks.routeFastReaction).not.toHaveBeenCalled(); + }); + + it('keeps a finalized launch when tracked thread bookkeeping fails', async () => { + updateBuilder.where.mockRejectedValueOnce(new Error('tracking failed')); + const slack = { + postMessage: vi.fn(async () => 'seeded-thread-ts'), + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; + + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); + + expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( + expect.anything(), + { id: 'work-item-1', taskId: 'task-new', claimedAt }, + ); + expect(mocks.releaseWorkItemClaim).not.toHaveBeenCalled(); + expect(slack.deleteMessage).not.toHaveBeenCalled(); + }); + + it('starts a Fast session when Fast is the user default', async () => { + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { + userId: 'user-1', + }, + }); + const slack = { + postMessage: vi.fn(async () => 'seeded-thread-ts'), + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; + + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); + + expect(mocks.startFastAgentResponse).toHaveBeenCalledWith( + expect.objectContaining({ + continuation: true, + userId: 'user-1', + event: expect.objectContaining({ + channel: 'C1', + thread_ts: 'seeded-thread-ts', + agentContext: 'implementation prompt', + }), + }), + ); + expect(mocks.startAutoRoutedSlackTask).not.toHaveBeenCalled(); + expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( + expect.anything(), + { id: 'work-item-1', taskId: null, claimedAt }, + ); + }); + + it('starts a pinned automation suggestion in Fast when Fast is the user default', async () => { + mocks.trackedMessageFindFirst.mockResolvedValue({ + id: 'tracked-message-1', + workItemId: 'work-item-1', + metadata: { suggestionType: 'suggested_tasks' }, + }); + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { userId: 'user-1' }, + }); + const slack = { + postMessage: vi.fn(async () => 'seeded-thread-ts'), + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; + + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); + + expect(mocks.resolveWorkspace).toHaveBeenCalled(); + expect(mocks.startFastAgentResponse).toHaveBeenCalledWith( + expect.objectContaining({ + continuation: true, + userId: 'user-1', + event: expect.objectContaining({ + thread_ts: 'seeded-thread-ts', + agentContext: 'implementation prompt', + }), + }), + ); + expect(mocks.startAutoRoutedSlackTask).not.toHaveBeenCalled(); + expect(mocks.startSlackAppMentionTask).not.toHaveBeenCalled(); + expect(mocks.postStartedMessage).not.toHaveBeenCalled(); + expect(mocks.finalizeWorkItemLaunched).toHaveBeenCalledWith( + expect.anything(), + { id: 'work-item-1', taskId: null, claimedAt }, + ); + }); + + it('releases the claim when the Fast turn lock is busy', async () => { + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { + userId: 'user-1', + }, + }); + mocks.startFastAgentResponse.mockResolvedValue({ + accepted: false, + reason: 'Fast session is busy.', + }); + const slack = { + postMessage: vi + .fn() + .mockResolvedValueOnce('seeded-thread-ts') + .mockResolvedValueOnce('failure-ts'), + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; + + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); + + expect(mocks.releaseWorkItemClaim).toHaveBeenCalledWith(expect.anything(), { + id: 'work-item-1', + claimedAt, + }); + expect(mocks.finalizeWorkItemLaunched).not.toHaveBeenCalled(); + expect(slack.deleteMessage).toHaveBeenCalledWith({ + channel: 'C1', + ts: 'seeded-thread-ts', + }); + expect(slack.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ text: expect.stringContaining('busy') }), + ); + }); + + it('releases the claim when Fast startup fails before acceptance', async () => { + mocks.lookupSlackUserMapping.mockResolvedValue({ + hasInactiveMapping: false, + activeMapping: { + userId: 'user-1', + }, + }); + mocks.startFastAgentResponse.mockRejectedValue( + new Error('Fast startup failed'), + ); + const slack = { + postMessage: vi + .fn() + .mockResolvedValueOnce('seeded-thread-ts') + .mockResolvedValueOnce('failure-ts'), + deleteMessage: vi.fn(async () => undefined), + getMessageMetadata: vi.fn(), + }; + + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE', teamId: 'T1' }, + slack, + } as never, + event: { + type: 'reaction_added', + user: 'U1', + reaction: 'thumbsup', + item: { type: 'message', channel: 'C1', ts: 'card-ts' }, + event_ts: 'event-ts', + }, + }); + + expect(mocks.releaseWorkItemClaim).toHaveBeenCalledWith(expect.anything(), { + id: 'work-item-1', + claimedAt, + }); + expect(mocks.finalizeWorkItemLaunched).not.toHaveBeenCalled(); + expect(slack.postMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Fast startup failed'), + }), + ); }); it('releases the suggestion when routing cannot choose a workspace', async () => { diff --git a/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts b/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts index 6ba08e945..5123d641a 100644 --- a/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts +++ b/apps/api/src/handlers/slack/events/reactions-emoji-trigger.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ getConfiguration: vi.fn(), handleMessage: vi.fn(), + routeFastReaction: vi.fn(), })); vi.mock('../../call-roomote-via-emoji.js', () => ({ @@ -13,6 +14,10 @@ vi.mock('./message-entry.js', () => ({ handleMessageOrAppMentionEvent: mocks.handleMessage, })); +vi.mock('./fast-agent-reaction.js', () => ({ + maybeRouteFastAgentReaction: mocks.routeFastReaction, +})); + import { handleReactionAddedEvent, maybeCallRoomoteViaEmoji, @@ -21,6 +26,7 @@ import { describe('Slack emoji trigger', () => { beforeEach(() => { vi.clearAllMocks(); + mocks.routeFastReaction.mockResolvedValue(false); }); it('turns a configured reaction into an app mention in the target thread', async () => { @@ -90,6 +96,26 @@ describe('Slack emoji trigger', () => { ).resolves.toBe(false); }); + it('ignores reactions authored by the Roomote bot before Fast routing', async () => { + await handleReactionAddedEvent({ + context: { + teamId: 'T1', + slackInstallation: { botUserId: 'UROOMOTE' }, + slack: {}, + } as never, + event: { + type: 'reaction_added', + user: 'UROOMOTE', + reaction: 'eyes', + item: { type: 'message', channel: 'C1', ts: '1' }, + event_ts: '2', + }, + }); + + expect(mocks.getConfiguration).not.toHaveBeenCalled(); + expect(mocks.routeFastReaction).not.toHaveBeenCalled(); + }); + it('gives the configured trigger precedence over thumbs-up suggestion actions', async () => { mocks.getConfiguration.mockResolvedValue({ emoji: 'thumbsup', @@ -118,6 +144,7 @@ describe('Slack emoji trigger', () => { }); expect(mocks.handleMessage).toHaveBeenCalledTimes(1); + expect(mocks.routeFastReaction).not.toHaveBeenCalled(); expect(mocks.handleMessage).toHaveBeenCalledWith( expect.objectContaining({ event: expect.objectContaining({ diff --git a/apps/api/src/handlers/slack/events/reactions.ts b/apps/api/src/handlers/slack/events/reactions.ts index b95702ccb..65d1221a5 100644 --- a/apps/api/src/handlers/slack/events/reactions.ts +++ b/apps/api/src/handlers/slack/events/reactions.ts @@ -35,7 +35,7 @@ import { import { apiLogger } from '../../../logging.js'; import { getCallRoomoteViaEmojiConfiguration } from '../../call-roomote-via-emoji.js'; -import { cancelOrphanedWorkItemRunBestEffort } from '../../tasks/orphaned-work-item-run.js'; +import { launchClaimedSuggestedTask } from '../../tasks/suggestion-launch.js'; import { SLACK_SETUP_SUGGESTION_LOCK_PREFIX, TASK_SUGGESTION_TYPES, @@ -49,7 +49,11 @@ import { type TaskSuggestionReactionLaunchResult, type TaskSuggestionReactionState, } from './task-suggestion-reaction-contention.js'; -import { handleMessageOrAppMentionEvent } from './message-entry.js'; +import { + handleMessageOrAppMentionEvent, + startFastAgentResponse, +} from './message-entry.js'; +import { maybeRouteFastAgentReaction } from './fast-agent-reaction.js'; export async function maybeCallRoomoteViaEmoji(params: { context: SlackWebhookContext; @@ -139,36 +143,20 @@ function getLaunchableSuggestionType( /** * Finalize a successful launch via the shared work_items helper (`launching` → - * `launched` with the task link, guarded so a race is idempotent) and, when it - * wins, record the seeded thread on the tracked suggestion card. Returns false - * when another launcher already finalized the work item. + * `launched` with the task link, guarded so a race is idempotent). Returns + * false when another launcher already finalized the work item. */ -async function markWorkItemLaunched(params: { +async function finalizeSuggestionLaunch(params: { workItemId: string; - trackedMessageId: string; taskId: string | null; /** The claiming launcher's fencing token (claimed row's `launchClaimedAt`). */ claimedAt: Date; - launchedThreadTs?: string; }): Promise { - const finalized = await finalizeWorkItemLaunched(db, { + return finalizeWorkItemLaunched(db, { id: params.workItemId, taskId: params.taskId, claimedAt: params.claimedAt, }); - - if (!finalized) { - return false; - } - - if (params.launchedThreadTs) { - await db - .update(trackedMessages) - .set({ threadTs: params.launchedThreadTs, updatedAt: new Date() }) - .where(eq(trackedMessages.id, params.trackedMessageId)); - } - - return true; } const REMOVED_SLACK_ACCOUNT_LAUNCH_FAILURE = @@ -496,6 +484,7 @@ async function launchTaskSuggestionTaskFromReaction({ ); return false; } + const launchThreadTs = seededThreadTs; const initiator = { kind: 'user' as const, @@ -505,109 +494,171 @@ async function launchTaskSuggestionTaskFromReaction({ : {}), }; - if (usesRouterLaunch) { - const routedLaunch = await startAutoRoutedSlackTask({ - slackInstallation, - slack, - initiator, - trigger: 'manual', - launchUserId: reactingUserMapping.activeMapping?.userId, - slackUserId: reactionEvent.user, - persistedSlackUserId: reactionEvent.user, - initiatingSlackUserId: reactionEvent.user, - channel: channelId, - prompt: `Start this suggested task: ${workItem.title}\n\n${suggestionBrief}`, - threadTs: seededThreadTs, - originMessageTs: seededThreadTs, - agentPromptTextOverride: suggestionTaskPrompt, - skipMcpSetupSuggestion: true, - }); + const activeUserMapping = reactingUserMapping.activeMapping; + const launchResult = await launchClaimedSuggestedTask({ + suggestion: { id: workItemId, launchClaimedAt: claimedAt }, + policy: { + // Pinned scan suggestions still enter Fast; the pin only selects the + // verified workspace if this launch falls back to coding. + fastEligible: suggestionType === 'suggested_tasks', + userDefaultEnabled: Boolean(activeUserMapping), + fastAvailable: Boolean(activeUserMapping), + }, + launch: async (launchMode) => { + if (launchMode === 'fast') { + if (!activeUserMapping) { + return { accepted: false, reason: 'Fast mode is unavailable.' }; + } + const fastStart = await startFastAgentResponse({ + event: { + type: 'app_mention', + channel: channelId, + user: reactionEvent.user, + text: `${workItem.title}\n\n${suggestionBrief}`, + agentContext: suggestionTaskPrompt, + ts: launchThreadTs, + thread_ts: launchThreadTs, + }, + slackInstallation, + userMapping: activeUserMapping, + slack, + userId: activeUserMapping.userId, + teamId, + continuation: true, + processingReactionName: ackEmoji, + errorLogPrefix: `Failed to start Fast suggestion response for work item ${workItemId}:`, + }); + return fastStart.accepted + ? { + accepted: true, + runId: null, + taskId: null, + abort: fastStart.abort, + } + : fastStart; + } - if (routedLaunch.status !== 'started') { - await releaseWorkItemClaim(db, { id: workItemId, claimedAt }); - await slack - .deleteMessage({ channel: channelId, ts: seededThreadTs }) - .catch(() => {}); - await postSuggestionLaunchFailureMessage({ - slack, - channelId, - title: workItem.title, - brief: suggestionBrief, - reason: - routedLaunch.message || - "I couldn't determine which workspace should run this suggestion.", - }); - return true; - } + if (usesRouterLaunch) { + const routedLaunch = await startAutoRoutedSlackTask({ + slackInstallation, + slack, + initiator, + trigger: 'manual', + launchUserId: activeUserMapping?.userId, + slackUserId: reactionEvent.user, + persistedSlackUserId: reactionEvent.user, + initiatingSlackUserId: reactionEvent.user, + channel: channelId, + prompt: `${workItem.title}\n\n${suggestionBrief}`, + threadTs: launchThreadTs, + originMessageTs: launchThreadTs, + agentPromptTextOverride: suggestionTaskPrompt, + skipMcpSetupSuggestion: true, + }); + return routedLaunch.status === 'started' + ? { + accepted: true, + runId: routedLaunch.runId, + taskId: routedLaunch.taskId, + } + : { + accepted: false, + reason: + routedLaunch.message || + "I couldn't determine which workspace should run this suggestion.", + }; + } - taskRun = { - id: routedLaunch.runId, - taskId: routedLaunch.taskId, - }; - } else { - if (!suggestionWorkspace) { - throw new Error('Setup suggestion workspace was not resolved.'); - } + if (!suggestionWorkspace) { + throw new Error('Setup suggestion workspace was not resolved.'); + } + const directLaunch = await startSlackAppMentionTask({ + initiator, + trigger: 'manual', + channel: channelId, + teamId, + slackUserId: reactionEvent.user, + text: suggestionSlackText, + agentPromptText: suggestionTaskPrompt, + ts: launchThreadTs, + threadTs: launchThreadTs, + repo: suggestionWorkspace.repoForPayload, + environmentId: suggestionWorkspace.environmentId, + readinessMessage: suggestionWorkspace.readinessMessage ?? undefined, + webPath: suggestionType === 'setup_onboarding' ? '/setup' : undefined, + ackEmoji, + completionEmoji, + queuedStartedMessage: { + ts: launchThreadTs, + agentName: AGENT_DISPLAY_NAME, + initiatingSlackUserId: reactionEvent.user, + workspaceDisplayName: suggestionWorkspace.workspaceDisplayName, + workspaceOnly: false, + }, + }); + return { + accepted: true, + runId: directLaunch.id, + taskId: directLaunch.taskId, + }; + }, + finalize: (taskId) => + finalizeSuggestionLaunch({ + workItemId, + taskId, + claimedAt, + }), + }); - taskRun = await startSlackAppMentionTask({ - initiator, - trigger: 'manual', - channel: channelId, - teamId, - slackUserId: reactionEvent.user, - text: suggestionSlackText, - agentPromptText: suggestionTaskPrompt, - ts: seededThreadTs, - threadTs: seededThreadTs, - repo: suggestionWorkspace.repoForPayload, - environmentId: suggestionWorkspace.environmentId, - readinessMessage: suggestionWorkspace.readinessMessage ?? undefined, - webPath: suggestionType === 'setup_onboarding' ? '/setup' : undefined, - ackEmoji, - completionEmoji, - queuedStartedMessage: { - ts: seededThreadTs, - agentName: AGENT_DISPLAY_NAME, - initiatingSlackUserId: reactionEvent.user, - workspaceDisplayName: suggestionWorkspace.workspaceDisplayName, - workspaceOnly: false, - }, + if ( + launchResult.status === 'rejected' || + launchResult.status === 'failed' + ) { + await slack + .deleteMessage({ channel: channelId, ts: seededThreadTs }) + .catch(() => {}); + await postSuggestionLaunchFailureMessage({ + slack, + channelId, + title: workItem.title, + brief: suggestionBrief, + reason: + launchResult.status === 'rejected' + ? (launchResult.reason ?? 'The suggestion could not be started.') + : formatErrorForLog(launchResult.error), }); + return true; } - const launched = await markWorkItemLaunched({ - workItemId, - trackedMessageId: suggestionCard.id, - taskId: taskRun.taskId, - claimedAt, - launchedThreadTs: seededThreadTs, - }); - - if (!launched) { - // The task was already enqueued but the fencing guard rejected the - // finalize (our stale claim was reclaimed by another launcher), so this - // run is orphaned from the work item. Best-effort cancel it while it is - // still pre-sandbox; log loudly either way with the cancel outcome. - const cancelNote = - taskRun.id !== null - ? await cancelOrphanedWorkItemRunBestEffort(taskRun.id) - : 'no run id to cancel (reused an existing job)'; - + if ( + launchResult.status === 'finalize_lost' || + launchResult.status === 'finalize_failed' + ) { apiLogger.warn( - `${logPrefix} finalize lost the fencing guard for work item ${workItemId}; task ${taskRun.taskId ?? 'null'} (run ${taskRun.id ?? 'null'}) was orphaned — ${cancelNote}`, + `${logPrefix} failed to finalize work item ${workItemId}; task ${launchResult.taskId ?? 'null'} (run ${launchResult.runId ?? 'null'}) — ${launchResult.cancelNote}`, ); - - // Mirror the claim-lose path: this duplicate must leave no user-visible - // trace. Never post the started message for the canceled orphan, and - // remove the seeded root message so no dangling thread points at it; - // the winning launcher owns the visible lifecycle. await slack .deleteMessage({ channel: channelId, ts: seededThreadTs }) .catch(() => {}); return true; } - if (!usesRouterLaunch && directWorkspaceName) { + taskRun = { id: launchResult.runId, taskId: launchResult.taskId }; + await db + .update(trackedMessages) + .set({ threadTs: launchThreadTs, updatedAt: new Date() }) + .where(eq(trackedMessages.id, suggestionCard.id)) + .catch((error) => { + apiLogger.warn( + `${logPrefix} failed to record launched suggestion thread: ${formatErrorForLog(error)}`, + ); + }); + + if ( + launchResult.mode === 'coding' && + !usesRouterLaunch && + directWorkspaceName + ) { await postTaskSuggestionStartedMessage({ slack, channelId, @@ -616,6 +667,10 @@ async function launchTaskSuggestionTaskFromReaction({ runId: taskRun.id, initiatingSlackUserId: reactionEvent.user, taskId: taskRun.taskId, + }).catch((error) => { + apiLogger.warn( + `${logPrefix} failed to post started message: ${formatErrorForLog(error)}`, + ); }); } @@ -624,97 +679,15 @@ async function launchTaskSuggestionTaskFromReaction({ ); return true; } catch (error) { - if (!taskRun) { - if (seededThreadTs) { - await slack - .deleteMessage({ channel: channelId, ts: seededThreadTs }) - .catch(() => {}); - } - - await releaseWorkItemClaim(db, { id: workItemId, claimedAt }); - apiLogger.debug( - `${logPrefix} reaction launch failed before task run start; claim released`, - ); - throw error; - } - - try { - const recovered = await markWorkItemLaunched({ - workItemId, - trackedMessageId: suggestionCard.id, - taskId: taskRun.taskId, - claimedAt, - launchedThreadTs: seededThreadTs, - }); - - if (!recovered) { - // Same orphan case as the happy path: the task is enqueued but the - // fencing guard rejected the finalize (claim reclaimed). Best-effort - // cancel the orphaned run; log loudly either way with the outcome. - const cancelNote = - taskRun.id !== null - ? await cancelOrphanedWorkItemRunBestEffort(taskRun.id) - : 'no run id to cancel (reused an existing job)'; - - apiLogger.warn( - `${logPrefix} finalize lost the fencing guard during post-enqueue recovery for work item ${workItemId}; task ${taskRun.taskId ?? 'null'} (run ${taskRun.id ?? 'null'}) was orphaned — ${cancelNote}`, - ); - - // Mirror the claim-lose path: never post the started message for the - // canceled orphan, and remove the seeded root message so no dangling - // thread points at it; the winning launcher owns the visible - // lifecycle. - if (seededThreadTs) { - await slack - .deleteMessage({ channel: channelId, ts: seededThreadTs }) - .catch(() => {}); - } - - return true; - } - - apiLogger.debug( - `${logPrefix} reaction launch recovered after post-enqueue failure taskId=${taskRun.taskId} launchedThreadTs=${seededThreadTs ?? 'unknown'}`, - ); - - if (!usesRouterLaunch) { - if (seededThreadTs && directWorkspaceName) { - await postTaskSuggestionStartedMessage({ - slack, - channelId, - threadTs: seededThreadTs, - workspaceName: directWorkspaceName, - runId: taskRun.id, - initiatingSlackUserId: reactionEvent.user, - taskId: taskRun.taskId, - }); - } else { - console.warn( - `${logPrefix} recovered direct launch missing seeded thread or workspace; started message skipped`, - ); - } - } - - apiLogger.debug( - `${logPrefix} completed reaction launch lifecycle taskId=${taskRun.taskId ?? 'null'} launchedThreadTs=${seededThreadTs ?? 'unknown'}`, - ); - - return true; - } catch (recoveryError) { - try { - await releaseWorkItemClaim(db, { id: workItemId, claimedAt }); - } catch (releaseError) { - console.warn( - `${logPrefix} failed to release claim after recovery failure: ${formatErrorForLog(releaseError)}`, - ); - } - - console.warn( - `${logPrefix} failed to backfill launch tracking after post-enqueue failure; claim released for retry: ${formatErrorForLog(recoveryError)}`, - ); - - throw error; + if (seededThreadTs) { + await slack + .deleteMessage({ channel: channelId, ts: seededThreadTs }) + .catch(() => {}); } + await releaseWorkItemClaim(db, { id: workItemId, claimedAt }).catch( + () => undefined, + ); + throw error; } } @@ -849,6 +822,10 @@ export async function handleReactionAddedEvent(params: { } } + if (await maybeRouteFastAgentReaction({ context, event })) { + return; + } + apiLogger.debug( `[SlackWebhook] Reaction no-op for ${event.item.channel}:${event.item.ts}`, ); diff --git a/apps/api/src/handlers/slack/helpers/thread-posting.ts b/apps/api/src/handlers/slack/helpers/thread-posting.ts index c5d4009e0..14e46947e 100644 --- a/apps/api/src/handlers/slack/helpers/thread-posting.ts +++ b/apps/api/src/handlers/slack/helpers/thread-posting.ts @@ -4,7 +4,10 @@ import { findSlackConversationSubjectByUserId, recordSlackConversationMessageBestEffort, } from '@roomote/sdk/server'; -import { buildFastSessionReplyFooterText } from '@roomote/communication'; +import { + buildFastSessionReplyFooterText, + type FastSessionReplyFooterContext, +} from '@roomote/communication'; import { buildStartedBlocks, persistPostedSlackKickoff, @@ -39,7 +42,7 @@ export async function postSlackThreadMarkdownMessage({ source: string; }; /** Attach the sticky Fast session reply footer to this message. */ - fastSessionFooter?: { sessionId: string }; + fastSessionFooter?: { sessionId: string } & FastSessionReplyFooterContext; }): Promise { if (sourceMessageTs) { const sourceMessageExists = await slack.hasMessageInThread({ @@ -67,7 +70,7 @@ export async function postSlackThreadMarkdownMessage({ bodyBlocks: [{ type: 'markdown', text }], footerText: buildFastSessionReplyFooterText({ provider: 'slack', - sessionId: fastSessionFooter.sessionId, + ...fastSessionFooter, }), }) : await slack.postMessage({ diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts index f339fbb5b..b66f5fb43 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.test.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.test.ts @@ -24,7 +24,6 @@ vi.mock('@roomote/db/server', () => ({ users: { id: 'users.id', deletedAt: 'users.deletedAt', - metadata: 'users.metadata', }, })); @@ -52,7 +51,6 @@ describe('lookupSlackUserMapping', () => { updatedAt, matchedUserId: 'user-1', userDeletedAt: null, - userMetadata: { communications_fast_mode_default: true }, }, ]); @@ -68,7 +66,6 @@ describe('lookupSlackUserMapping', () => { userId: 'user-1', createdAt, updatedAt, - communicationsFastModeDefault: true, }, hasInactiveMapping: false, }); @@ -85,7 +82,6 @@ describe('lookupSlackUserMapping', () => { updatedAt: new Date('2024-01-02T00:00:00.000Z'), matchedUserId: 'user-1', userDeletedAt: new Date('2024-02-01T00:00:00.000Z'), - userMetadata: {}, }, ]); diff --git a/apps/api/src/handlers/slack/helpers/user-mapping.ts b/apps/api/src/handlers/slack/helpers/user-mapping.ts index 5537804bc..4aa738842 100644 --- a/apps/api/src/handlers/slack/helpers/user-mapping.ts +++ b/apps/api/src/handlers/slack/helpers/user-mapping.ts @@ -8,9 +8,7 @@ import { } from '@roomote/db/server'; type SlackUserMappingLookup = { - activeMapping: - | (SlackUserMapping & { communicationsFastModeDefault: boolean }) - | null; + activeMapping: SlackUserMapping | null; hasInactiveMapping: boolean; }; @@ -28,7 +26,6 @@ export async function lookupSlackUserMapping(params: { updatedAt: slackUserMappings.updatedAt, matchedUserId: users.id, userDeletedAt: users.deletedAt, - userMetadata: users.metadata, }) .from(slackUserMappings) .leftJoin(users, eq(users.id, slackUserMappings.userId)) @@ -62,12 +59,6 @@ export async function lookupSlackUserMapping(params: { userId: row.userId, createdAt: row.createdAt, updatedAt: row.updatedAt, - communicationsFastModeDefault: - typeof row.userMetadata === 'object' && - row.userMetadata !== null && - !Array.isArray(row.userMetadata) && - (row.userMetadata as Record) - .communications_fast_mode_default === true, }, hasInactiveMapping: false, }; diff --git a/apps/api/src/handlers/tasks/__tests__/fastSessionTaskCommunication.test.ts b/apps/api/src/handlers/tasks/__tests__/fastSessionTaskCommunication.test.ts new file mode 100644 index 000000000..18c91cd77 --- /dev/null +++ b/apps/api/src/handlers/tasks/__tests__/fastSessionTaskCommunication.test.ts @@ -0,0 +1,373 @@ +const mocks = vi.hoisted(() => ({ + queueReply: vi.fn(), + sendMessageToTask: vi.fn(), + steerMessageToTask: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, queueFastAgentSurfaceReply: mocks.queueReply }; +}); + +vi.mock('../sendMessageToTask', () => ({ + sendMessageToTask: mocks.sendMessageToTask, + steerMessageToTask: mocks.steerMessageToTask, +})); + +import { Hono } from 'hono'; +import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; +import { + db, + fastAgentConversations, + fastAgentMessages, + ensureSessionForFastConversation, + runFactory, + taskFactory, + taskMessages, + userFactory, +} from '@roomote/db/server'; + +import type { Variables } from '../../../types'; +import { mcpAuthMiddleware } from '../../mcp/middleware'; +import { getTaskMessages } from '../getTaskMessages'; +import { sendMessage } from '../sendMessage'; +import { steerMessage } from '../steerMessage'; + +function createApp(authContext: AuthTokenContext | RunTokenContext) { + const app = new Hono<{ Variables: Variables }>(); + app.use('*', async (c, next) => { + c.set('authContext', authContext); + await next(); + }); + app.use('*', mcpAuthMiddleware); + app.get('/tasks/:taskId/messages', getTaskMessages); + app.post('/tasks/:taskId/send_message', sendMessage); + app.post('/tasks/:taskId/steer_message', steerMessage); + return app; +} + +async function createSession(userId: string) { + const [session] = await db + .insert(fastAgentConversations) + .values({ + userId, + surface: 'web', + workspaceId: userId, + conversationId: crypto.randomUUID(), + }) + .returning(); + return session!; +} + +async function addMessage(input: { + sessionId: string; + eventId: string; + userId?: string; + visible?: boolean; + text?: string; + ts?: number; +}) { + await db.insert(fastAgentMessages).values({ + conversationId: input.sessionId, + eventId: input.eventId, + turnId: input.eventId, + turnSeq: 0, + ts: input.ts ?? Date.now(), + eventType: 'roomote_runtime.user_prompt', + role: 'user', + contentBlocks: [{ type: 'text', text: input.text ?? input.eventId }], + metadata: { + visibleInTranscript: input.visible ?? true, + ...(input.userId ? { userId: input.userId } : {}), + }, + payload: {}, + source: 'web', + }); +} + +function userAuth(userId: string): AuthTokenContext { + return { userId, tokenType: 'auth', version: 1 }; +} + +describe('Fast session communication through task routes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.queueReply.mockResolvedValue(true); + mocks.sendMessageToTask.mockResolvedValue({ + success: false, + status: 404, + error: 'Task not found', + }); + mocks.steerMessageToTask.mockResolvedValue({ + success: false, + status: 404, + error: 'Task not found', + }); + }); + + it('returns visible Fast transcript messages with the task-shaped contract', async () => { + const owner = await userFactory.create(); + const participant = await userFactory.create(); + const session = await createSession(owner.id); + await addMessage({ + sessionId: session.id, + eventId: 'participant-message', + userId: participant.id, + text: 'Participant text', + ts: 1, + }); + await addMessage({ + sessionId: session.id, + eventId: 'newest-message', + text: 'Newest text', + ts: 3, + }); + await addMessage({ + sessionId: session.id, + eventId: 'hidden-message', + visible: false, + ts: 2, + }); + + for (const userId of [owner.id, participant.id]) { + const response = await createApp(userAuth(userId)).request( + `/tasks/${session.id}/messages`, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + returned: 2, + messages: [ + { + taskId: session.id, + text: 'Participant text', + visibleInTranscript: true, + }, + { + taskId: session.id, + text: 'Newest text', + visibleInTranscript: true, + }, + ], + }); + } + }); + + it('continues past hidden rows to satisfy a limited task transcript', async () => { + const owner = await userFactory.create(); + const task = await taskFactory.create({ initiatorUserId: owner.id }); + const run = await runFactory.create({ + taskId: task.id, + actingUserId: owner.id, + }); + const messages: Array = [ + { + runId: run.id, + taskId: task.id, + ts: 1, + eventType: 'roomote_runtime.assistant_text', + protocol: 'roomote_runtime', + role: 'assistant', + contentBlocks: [{ type: 'text', text: 'Oldest visible' }], + metadata: { visibleInTranscript: true }, + payload: {}, + }, + { + runId: run.id, + taskId: task.id, + ts: 2, + eventType: 'roomote_runtime.assistant_text', + protocol: 'roomote_runtime', + role: 'assistant', + contentBlocks: [{ type: 'text', text: 'Newest visible' }], + metadata: { visibleInTranscript: true }, + payload: {}, + }, + { + runId: run.id, + taskId: task.id, + ts: 3, + eventType: 'roomote_runtime.user_prompt', + protocol: 'roomote_runtime', + role: 'user', + contentBlocks: [{ type: 'text', text: 'Hidden prompt' }], + metadata: { visibleInTranscript: false }, + payload: {}, + }, + ]; + await db.insert(taskMessages).values(messages); + + const response = await createApp(userAuth(owner.id)).request( + `/tasks/${task.id}/messages?limit=2&order=desc`, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + returned: 2, + messages: [{ text: 'Newest visible' }, { text: 'Oldest visible' }], + }); + }); + + it('hides Fast sessions from bystanders and rejects absent or invalid IDs', async () => { + const owner = await userFactory.create(); + const bystander = await userFactory.create(); + const session = await createSession(owner.id); + + for (const taskId of [session.id, crypto.randomUUID(), 'not-an-id']) { + const response = await createApp(userAuth(bystander.id)).request( + `/tasks/${taskId}/messages`, + ); + expect(response.status).toBe(404); + } + }); + + it('queues participant follow-ups after normal task resolution misses', async () => { + const owner = await userFactory.create(); + const participant = await userFactory.create(); + const session = await createSession(owner.id); + await addMessage({ + sessionId: session.id, + eventId: 'participant-message', + userId: participant.id, + }); + + const response = await createApp(userAuth(participant.id)).request( + `/tasks/${session.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: 'Continue this conversation', + images: ['https://example.com/member.png'], + }), + }, + ); + expect(response.status).toBe(200); + expect(mocks.sendMessageToTask).toHaveBeenCalledWith( + expect.objectContaining({ taskId: session.id }), + ); + expect(mocks.queueReply).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: session.id, + userId: participant.id, + question: 'Continue this conversation', + images: ['https://example.com/member.png'], + }), + ); + }); + + it('requires canonical unified Session IDs to use Session routes', async () => { + const owner = await userFactory.create(); + const fastSession = await createSession(owner.id); + const session = await ensureSessionForFastConversation(db, fastSession.id); + await addMessage({ + sessionId: fastSession.id, + eventId: 'unified-session-message', + text: 'Unified session text', + }); + + const app = createApp(userAuth(owner.id)); + const messagesResponse = await app.request(`/tasks/${session.id}/messages`); + expect(messagesResponse.status).toBe(404); + + const sendResponse = await app.request( + `/tasks/${session.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Continue unified session' }), + }, + ); + expect(sendResponse.status).toBe(404); + expect(mocks.queueReply).not.toHaveBeenCalled(); + }); + + it('uses the same Fast fallback for the worker steering route', async () => { + const owner = await userFactory.create(); + const session = await createSession(owner.id); + + const response = await createApp(userAuth(owner.id)).request( + `/tasks/${session.id}/steer_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: 'Worker follow-up', + images: ['https://example.com/worker.png'], + }), + }, + ); + expect(response.status).toBe(200); + expect(mocks.steerMessageToTask).toHaveBeenCalledWith( + expect.objectContaining({ taskId: session.id }), + ); + expect(mocks.queueReply).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: session.id, + question: 'Worker follow-up', + images: ['https://example.com/worker.png'], + }), + ); + }); + + it('preserves normal task send behavior without attempting Fast delivery', async () => { + const user = await userFactory.create(); + mocks.sendMessageToTask.mockResolvedValueOnce({ + success: true, + result: { queued: true }, + }); + + const response = await createApp(userAuth(user.id)).request( + '/tasks/normal-task/send_message', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Normal follow-up' }), + }, + ); + expect(response.status).toBe(200); + expect(mocks.queueReply).not.toHaveBeenCalled(); + }); + + it('preserves actor and provider boundaries for Fast sends', async () => { + const owner = await userFactory.create(); + const bystander = await userFactory.create(); + const session = await createSession(owner.id); + + const denied = await createApp(userAuth(bystander.id)).request( + `/tasks/${session.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Not allowed' }), + }, + ); + expect(denied.status).toBe(404); + + const deploymentRun: RunTokenContext = { + runId: 1, + userId: null, + principal: 'deployment', + tokenType: 'run', + version: 1, + }; + const noActor = await createApp(deploymentRun).request( + `/tasks/${session.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'No actor' }), + }, + ); + expect(noActor.status).toBe(403); + + mocks.queueReply.mockResolvedValueOnce(false); + const unavailable = await createApp(userAuth(owner.id)).request( + `/tasks/${session.id}/send_message`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'Try delivery' }), + }, + ); + expect(unavailable.status).toBe(409); + }); +}); diff --git a/apps/api/src/handlers/tasks/__tests__/getTaskMessages.test.ts b/apps/api/src/handlers/tasks/__tests__/getTaskMessages.test.ts index cc0b5d4e5..63ad52e6d 100644 --- a/apps/api/src/handlers/tasks/__tests__/getTaskMessages.test.ts +++ b/apps/api/src/handlers/tasks/__tests__/getTaskMessages.test.ts @@ -11,6 +11,7 @@ const { descMock, eqMock, mockGetImageUrisFromContentBlocks, + mockGetFastSessionMessagesForUser, mockGetTextFromContentBlocks, mockLogHandlerError, mockResolveAcpTranscriptVisibility, @@ -30,6 +31,7 @@ const { mockGetImageUrisFromContentBlocks: vi.fn(() => [ 'https://example.com/image.png', ]), + mockGetFastSessionMessagesForUser: vi.fn(), mockGetTextFromContentBlocks: vi.fn(() => 'Hello from transcript'), mockLogHandlerError: vi.fn(), mockResolveAcpTranscriptVisibility: vi.fn(() => true), @@ -47,6 +49,10 @@ vi.mock('../helpers', () => ({ visibleTaskHistoryCondition, })); +vi.mock('../fastSessionCommunication', () => ({ + getFastSessionMessagesForUser: mockGetFastSessionMessagesForUser, +})); + vi.mock('../../utils', () => ({ logHandlerError: mockLogHandlerError, })); @@ -112,6 +118,8 @@ describe('getTaskMessages', () => { beforeEach(() => { vi.clearAllMocks(); + mockSelect.mockReset(); + mockGetFastSessionMessagesForUser.mockResolvedValue(null); taskSelectFromMock.mockReturnValue({ where: taskSelectWhereMock, @@ -170,6 +178,7 @@ describe('getTaskMessages', () => { }, ], }); + expect(mockGetFastSessionMessagesForUser).not.toHaveBeenCalled(); }); it('adds the hidden-task-history condition to the task lookup', async () => { @@ -182,6 +191,20 @@ describe('getTaskMessages', () => { expect(andMock.mock.calls[0]).toContain(visibleTaskHistoryCondition); }); + it('omits transcript-hidden task messages from MCP responses', async () => { + mockResolveAcpTranscriptVisibility.mockReturnValueOnce(false); + + const response = await createApp(authContext).request( + 'http://localhost/tasks/task-1/messages', + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + messages: [], + returned: 0, + }); + }); + it('returns 404 when the task is hidden from task history', async () => { taskSelectLimitMock.mockResolvedValueOnce([]); @@ -192,4 +215,48 @@ describe('getTaskMessages', () => { expect(response.status).toBe(404); await expect(response.json()).resolves.toEqual({ error: 'Task not found' }); }); + + it('falls back to a Fast session when no task matches', async () => { + taskSelectLimitMock.mockResolvedValueOnce([]); + mockGetFastSessionMessagesForUser.mockResolvedValueOnce([ + { + id: 'fast-message-1', + taskId: 'fast-session-1', + text: 'Fast response', + }, + ]); + + const response = await createApp(authContext).request( + 'http://localhost/tasks/fast-session-1/messages', + ); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toMatchObject({ + returned: 1, + messages: [{ taskId: 'fast-session-1', text: 'Fast response' }], + }); + expect(mockGetFastSessionMessagesForUser).toHaveBeenCalledWith({ + sessionId: 'fast-session-1', + userId: 'user-1', + limit: undefined, + order: 'asc', + }); + }); + + it('forwards explicit descending order to Fast session fallback', async () => { + taskSelectLimitMock.mockResolvedValueOnce([]); + mockGetFastSessionMessagesForUser.mockResolvedValueOnce([]); + + const response = await createApp(authContext).request( + 'http://localhost/tasks/fast-session-1/messages?order=desc', + ); + + expect(response.status).toBe(200); + expect(mockGetFastSessionMessagesForUser).toHaveBeenCalledWith({ + sessionId: 'fast-session-1', + userId: 'user-1', + limit: undefined, + order: 'desc', + }); + }); }); diff --git a/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts b/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts index 9b02ead86..04de39f62 100644 --- a/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts +++ b/apps/api/src/handlers/tasks/current-thread-suggestion-reaction.ts @@ -14,6 +14,7 @@ export type ClaimedCurrentThreadSuggestion = { investigationContext: string | null; targetRepositoryFullName: string | null; targetEnvironmentId?: string | null; + usesRouterLaunch?: boolean; launchClaimedAt: Date; }; @@ -90,6 +91,7 @@ export async function claimCurrentThreadSuggestionByMessage( ? null : claimed.targetRepositoryFullName, targetEnvironmentId: routed ? null : claimed.targetEnvironmentId, + usesRouterLaunch: routed, launchClaimedAt: claimed.launchClaimedAt, }, }; diff --git a/apps/api/src/handlers/tasks/fastSessionCommunication.ts b/apps/api/src/handlers/tasks/fastSessionCommunication.ts new file mode 100644 index 000000000..74f2ba6cb --- /dev/null +++ b/apps/api/src/handlers/tasks/fastSessionCommunication.ts @@ -0,0 +1,146 @@ +import { randomUUID } from 'node:crypto'; + +import { + and, + asc, + db, + desc, + eq, + fastAgentMessages, + sql, +} from '@roomote/db/server'; +import { + canUserAccessFastAgentSession, + queueFastAgentSurfaceReply, +} from '@roomote/sdk/server'; +import { + ACP_UI_TOOL_OUTPUT_MAX_CHARS, + getImageUrisFromContentBlocks, + getTextFromContentBlocks, + sanitizeEnvelopeFields, +} from '@roomote/types'; +import { z } from 'zod'; + +const canonicalFastSessionIdSchema = z.string().uuid(); + +export async function getFastSessionMessagesForUser(params: { + sessionId: string; + userId: string; + limit?: number; + order: 'asc' | 'desc'; +}) { + if (!canonicalFastSessionIdSchema.safeParse(params.sessionId).success) { + return null; + } + const fastConversationId = params.sessionId; + if ( + !(await canUserAccessFastAgentSession({ + sessionId: fastConversationId, + userId: params.userId, + })) + ) { + return null; + } + + const orderBy = + params.order === 'desc' + ? [ + desc(fastAgentMessages.ts), + desc(fastAgentMessages.turnSeq), + desc(fastAgentMessages.createdAt), + desc(fastAgentMessages.id), + ] + : [ + asc(fastAgentMessages.ts), + asc(fastAgentMessages.turnSeq), + asc(fastAgentMessages.createdAt), + asc(fastAgentMessages.id), + ]; + let query = db + .select({ + id: fastAgentMessages.id, + ts: fastAgentMessages.ts, + eventType: fastAgentMessages.eventType, + role: fastAgentMessages.role, + contentBlocks: fastAgentMessages.contentBlocks, + metadata: fastAgentMessages.metadata, + payload: fastAgentMessages.payload, + }) + .from(fastAgentMessages) + .where( + and( + eq(fastAgentMessages.conversationId, fastConversationId), + sql`coalesce(${fastAgentMessages.metadata} ->> 'visibleInTranscript', 'true') <> 'false'`, + ), + ) + .orderBy(...orderBy); + if (params.limit) { + query = query.limit(params.limit) as typeof query; + } + + const rows = await query; + return rows.map((row) => { + const sanitized = sanitizeEnvelopeFields( + row.eventType, + row.contentBlocks, + row.metadata, + row.payload, + { maxOutputChars: ACP_UI_TOOL_OUTPUT_MAX_CHARS }, + ); + return { + id: row.id, + taskId: params.sessionId, + ts: Number(row.ts), + eventType: row.eventType, + role: row.role, + text: getTextFromContentBlocks(sanitized.contentBlocks), + images: getImageUrisFromContentBlocks(sanitized.contentBlocks), + metadata: sanitized.metadata, + visibleInTranscript: true, + }; + }); +} + +export async function sendMessageToFastSessionForUser(params: { + sessionId: string; + userId: string; + message: string; + images?: string[]; +}): Promise< + | { success: true; result: { sessionId: string; queued: true } } + | { success: false; status: 404 | 409; error: string } +> { + if (!canonicalFastSessionIdSchema.safeParse(params.sessionId).success) { + return { success: false, status: 404, error: 'Task not found' }; + } + const fastConversationId = params.sessionId; + if ( + !(await canUserAccessFastAgentSession({ + sessionId: fastConversationId, + userId: params.userId, + })) + ) { + return { success: false, status: 404, error: 'Task not found' }; + } + + const queued = await queueFastAgentSurfaceReply({ + sessionId: fastConversationId, + userId: params.userId, + senderDisplayName: null, + question: params.message, + images: params.images, + currentMessageId: `mcp-${randomUUID()}`, + }); + if (!queued) { + return { + success: false, + status: 409, + error: "This Fast session's chat surface is not connected", + }; + } + + return { + success: true, + result: { sessionId: params.sessionId, queued: true }, + }; +} diff --git a/apps/api/src/handlers/tasks/getTaskMessages.ts b/apps/api/src/handlers/tasks/getTaskMessages.ts index fdd0c31ea..05cfc6269 100644 --- a/apps/api/src/handlers/tasks/getTaskMessages.ts +++ b/apps/api/src/handlers/tasks/getTaskMessages.ts @@ -12,12 +12,15 @@ import { import { getTextFromContentBlocks, getImageUrisFromContentBlocks, + ACP_UI_TOOL_OUTPUT_MAX_CHARS, resolveAcpTranscriptVisibility, + sanitizeEnvelopeFields, } from '@roomote/types'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; import { visibleTaskHistoryCondition } from './helpers'; +import { getFastSessionMessagesForUser } from './fastSessionCommunication'; import { logHandlerError } from '../utils'; /** @@ -62,7 +65,7 @@ export async function getTaskMessages( return c.json({ error: 'order must be one of: asc, desc' }, 400); } - const order = orderParam ?? 'asc'; + const order: 'asc' | 'desc' = orderParam === 'desc' ? 'desc' : 'asc'; try { const [task] = await db @@ -72,6 +75,18 @@ export async function getTaskMessages( .limit(1); if (!task) { + const userId = c.get('mcpAuth').userId; + if (userId) { + const messages = await getFastSessionMessagesForUser({ + sessionId: taskId, + userId, + limit, + order, + }); + if (messages) { + return c.json({ messages, returned: messages.length }); + } + } return c.json({ error: 'Task not found' }, 404); } @@ -80,45 +95,70 @@ export async function getTaskMessages( ? [desc(taskMessages.ts), desc(taskMessages.createdAt)] : [asc(taskMessages.ts), asc(taskMessages.createdAt)]; - let query = db - .select({ - id: taskMessages.id, - taskId: taskMessages.taskId, - ts: taskMessages.ts, - eventType: taskMessages.eventType, - role: taskMessages.role, - contentBlocks: taskMessages.contentBlocks, - metadata: taskMessages.metadata, - payload: taskMessages.payload, - createdAt: taskMessages.createdAt, - }) - .from(taskMessages) - .where(eq(taskMessages.taskId, taskId)) - .orderBy(...orderBy); - - if (limit) { - query = query.limit(limit) as typeof query; + const selectRows = () => + db + .select({ + id: taskMessages.id, + taskId: taskMessages.taskId, + ts: taskMessages.ts, + eventType: taskMessages.eventType, + role: taskMessages.role, + contentBlocks: taskMessages.contentBlocks, + metadata: taskMessages.metadata, + payload: taskMessages.payload, + createdAt: taskMessages.createdAt, + }) + .from(taskMessages) + .where(eq(taskMessages.taskId, taskId)) + .orderBy(...orderBy); + + const toVisibleMessages = (rows: Awaited>) => + rows.flatMap((row) => { + const visibleInTranscript = resolveAcpTranscriptVisibility({ + eventType: row.eventType, + contentBlocks: row.contentBlocks, + metadata: row.metadata, + payload: row.payload, + }); + if (!visibleInTranscript) return []; + const sanitized = sanitizeEnvelopeFields( + row.eventType, + row.contentBlocks, + row.metadata, + row.payload, + { maxOutputChars: ACP_UI_TOOL_OUTPUT_MAX_CHARS }, + ); + return [ + { + id: row.id, + taskId: row.taskId, + ts: Number(row.ts), + eventType: row.eventType, + role: row.role, + text: getTextFromContentBlocks(sanitized.contentBlocks), + images: getImageUrisFromContentBlocks(sanitized.contentBlocks), + metadata: sanitized.metadata, + visibleInTranscript: true, + }, + ]; + }); + + let messages; + if (!limit) { + messages = toVisibleMessages(await selectRows()); + } else { + messages = [] as ReturnType; + const batchSize = Math.max(limit, 100); + let offset = 0; + while (messages.length < limit) { + const rows = await selectRows().limit(batchSize).offset(offset); + messages.push(...toVisibleMessages(rows)); + offset += rows.length; + if (rows.length < batchSize) break; + } + messages = messages.slice(0, limit); } - const rows = await query; - - const messages = rows.map((row) => ({ - id: row.id, - taskId: row.taskId, - ts: Number(row.ts), - eventType: row.eventType, - role: row.role, - text: getTextFromContentBlocks(row.contentBlocks), - images: getImageUrisFromContentBlocks(row.contentBlocks), - metadata: row.metadata, - visibleInTranscript: resolveAcpTranscriptVisibility({ - eventType: row.eventType, - contentBlocks: row.contentBlocks, - metadata: row.metadata, - payload: row.payload, - }), - })); - return c.json({ messages, returned: messages.length }); } catch (error) { logHandlerError('getTaskMessages', error); diff --git a/apps/api/src/handlers/tasks/manageSourceControl.ts b/apps/api/src/handlers/tasks/manageSourceControl.ts index f1785241a..78a6d9786 100644 --- a/apps/api/src/handlers/tasks/manageSourceControl.ts +++ b/apps/api/src/handlers/tasks/manageSourceControl.ts @@ -182,6 +182,7 @@ export async function manageSourceControl( case 'create_pull_request_review_comment': case 'resolve_pull_request_thread': case 'submit_pull_request_review': + case 'dismiss_pull_request_review': case 'update_pull_request_comment': { const writeResult = await runWithQuoteRestorationOnFailure(() => writeSourceControlPullRequestForTaskRun({ diff --git a/apps/api/src/handlers/tasks/saveTaskMemory.ts b/apps/api/src/handlers/tasks/saveTaskMemory.ts index b7e59fa16..527db7292 100644 --- a/apps/api/src/handlers/tasks/saveTaskMemory.ts +++ b/apps/api/src/handlers/tasks/saveTaskMemory.ts @@ -1,11 +1,7 @@ import type { Context } from 'hono'; import { z } from 'zod'; -import { - db, - isBrainProviderConfigured, - saveBrainAgentSummary, -} from '@roomote/db/server'; +import { db, isBrainEnabled, saveBrainAgentSummary } from '@roomote/db/server'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; @@ -80,7 +76,7 @@ export async function saveTaskMemory( ); } - if (!(await isBrainProviderConfigured())) { + if (!(await isBrainEnabled())) { return c.json( { saved: false, reason: 'This deployment has no Brain configured.' }, 200, diff --git a/apps/api/src/handlers/tasks/sendMessage.ts b/apps/api/src/handlers/tasks/sendMessage.ts index 6c4a2da97..5aca90aad 100644 --- a/apps/api/src/handlers/tasks/sendMessage.ts +++ b/apps/api/src/handlers/tasks/sendMessage.ts @@ -7,6 +7,7 @@ import { type SendMessageSenderMode, sendMessageToTask, } from './sendMessageToTask'; +import { sendMessageToFastSessionForUser } from './fastSessionCommunication'; type PublicSendMessageSenderMode = Extract< SendMessageSenderMode, @@ -123,7 +124,7 @@ export async function sendMessage( return c.json({ error: 'senderMode is invalid' }, 400); } - const result = await sendMessageToTask({ + let result = await sendMessageToTask({ taskId, userId: auth.userId, authContext: auth.authContext, @@ -134,6 +135,15 @@ export async function sendMessage( senderMode, }); + if (!result.success && result.status === 404) { + result = await sendMessageToFastSessionForUser({ + sessionId: taskId, + userId: auth.userId, + message: body.message, + images: body.images, + }); + } + if (result.success) { return c.json(result); } diff --git a/apps/api/src/handlers/tasks/steerMessage.ts b/apps/api/src/handlers/tasks/steerMessage.ts index f242cc62b..461fc347d 100644 --- a/apps/api/src/handlers/tasks/steerMessage.ts +++ b/apps/api/src/handlers/tasks/steerMessage.ts @@ -3,6 +3,7 @@ import type { Context } from 'hono'; import type { Variables } from '../../types'; import type { McpAuth } from '../mcp/middleware'; import { steerMessageToTask } from './sendMessageToTask'; +import { sendMessageToFastSessionForUser } from './fastSessionCommunication'; /** * POST /api/tasks/:taskId/steer_message @@ -48,7 +49,7 @@ export async function steerMessage( return c.json({ error: 'senderMode is invalid' }, 400); } - const result = await steerMessageToTask({ + let result = await steerMessageToTask({ taskId, userId: auth.userId, message: body.message, @@ -56,6 +57,15 @@ export async function steerMessage( senderMode: body.senderMode, }); + if (!result.success && result.status === 404) { + result = await sendMessageToFastSessionForUser({ + sessionId: taskId, + userId: auth.userId, + message: body.message, + images: body.images, + }); + } + if (result.success) { return c.json(result); } diff --git a/apps/api/src/handlers/tasks/suggestion-launch.test.ts b/apps/api/src/handlers/tasks/suggestion-launch.test.ts new file mode 100644 index 000000000..c0afd8f07 --- /dev/null +++ b/apps/api/src/handlers/tasks/suggestion-launch.test.ts @@ -0,0 +1,228 @@ +const mocks = vi.hoisted(() => ({ + finalize: vi.fn(), + release: vi.fn(), + cancel: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: {}, + finalizeWorkItemLaunched: mocks.finalize, + releaseWorkItemClaim: mocks.release, +})); + +vi.mock('./orphaned-work-item-run.js', () => ({ + cancelOrphanedWorkItemRunBestEffort: mocks.cancel, +})); + +vi.mock('../fast-agent-entry.js', () => ({ + resolveFastAgentEntryMode: ({ + userDefaultEnabled, + fastAvailable, + }: { + userDefaultEnabled: boolean; + fastAvailable?: boolean; + }) => (userDefaultEnabled && fastAvailable !== false ? 'default' : null), +})); + +import { + launchClaimedSuggestedTask, + resolveSuggestedTaskLaunchMode, +} from './suggestion-launch'; + +const claimedAt = new Date('2026-08-28T00:00:00.000Z'); +const suggestion = { id: 'suggestion-1', launchClaimedAt: claimedAt }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.finalize.mockResolvedValue(true); + mocks.release.mockResolvedValue(true); + mocks.cancel.mockResolvedValue('orphaned run canceled'); +}); + +describe('resolveSuggestedTaskLaunchMode', () => { + it('selects Fast for an eligible suggestion when Fast is the default', () => { + expect( + resolveSuggestedTaskLaunchMode({ + fastEligible: true, + userDefaultEnabled: true, + fastAvailable: true, + }), + ).toBe('fast'); + }); + + it('falls back to coding when Fast is unavailable', () => { + expect( + resolveSuggestedTaskLaunchMode({ + fastEligible: true, + userDefaultEnabled: true, + fastAvailable: false, + }), + ).toBe('coding'); + }); + + it('keeps Fast-ineligible suggestions on coding', () => { + expect( + resolveSuggestedTaskLaunchMode({ + fastEligible: false, + userDefaultEnabled: true, + fastAvailable: true, + }), + ).toBe('coding'); + }); +}); + +describe('launchClaimedSuggestedTask', () => { + it('finalizes an accepted launch with its task link', async () => { + await expect( + launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: true, + userDefaultEnabled: false, + fastAvailable: true, + }, + launch: async () => ({ + accepted: true, + runId: 7, + taskId: 'task-1', + }), + }), + ).resolves.toEqual({ + status: 'started', + mode: 'coding', + runId: 7, + taskId: 'task-1', + }); + expect(mocks.finalize).toHaveBeenCalledWith(expect.anything(), { + id: suggestion.id, + taskId: 'task-1', + claimedAt, + }); + }); + + it('releases a rejected launch for retry', async () => { + await expect( + launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: true, + userDefaultEnabled: true, + fastAvailable: true, + }, + launch: async () => ({ accepted: false, reason: 'busy' }), + }), + ).resolves.toEqual({ + status: 'rejected', + mode: 'fast', + reason: 'busy', + }); + expect(mocks.release).toHaveBeenCalledWith(expect.anything(), { + id: suggestion.id, + claimedAt, + }); + expect(mocks.finalize).not.toHaveBeenCalled(); + }); + + it('releases a startup failure and classifies it as failed', async () => { + const error = new Error('startup failed'); + await expect( + launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: true, + userDefaultEnabled: true, + fastAvailable: true, + }, + launch: async () => { + throw error; + }, + }), + ).resolves.toMatchObject({ status: 'failed', mode: 'fast', error }); + expect(mocks.release).toHaveBeenCalled(); + expect(mocks.finalize).not.toHaveBeenCalled(); + }); + + it('cancels an orphaned coding run when finalization loses the fence', async () => { + mocks.finalize.mockResolvedValue(false); + await expect( + launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: true, + userDefaultEnabled: false, + fastAvailable: true, + }, + launch: async () => ({ + accepted: true, + runId: 7, + taskId: 'task-1', + }), + }), + ).resolves.toEqual({ + status: 'finalize_lost', + mode: 'coding', + runId: 7, + taskId: 'task-1', + cancelNote: 'orphaned run canceled', + }); + expect(mocks.cancel).toHaveBeenCalledWith(7); + expect(mocks.release).not.toHaveBeenCalled(); + }); + + it('cancels the run and releases the claim when finalization throws', async () => { + mocks.finalize.mockRejectedValue(new Error('database unavailable')); + await expect( + launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: true, + userDefaultEnabled: false, + fastAvailable: true, + }, + launch: async () => ({ + accepted: true, + runId: 7, + taskId: 'task-1', + }), + }), + ).resolves.toMatchObject({ + status: 'finalize_failed', + mode: 'coding', + runId: 7, + taskId: 'task-1', + }); + expect(mocks.cancel).toHaveBeenCalledWith(7); + expect(mocks.release).toHaveBeenCalledWith(expect.anything(), { + id: suggestion.id, + claimedAt, + }); + }); + + it('aborts an accepted Fast turn before releasing a failed finalization', async () => { + const abort = vi.fn(async () => undefined); + mocks.finalize.mockRejectedValue(new Error('database unavailable')); + + await expect( + launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: true, + userDefaultEnabled: true, + fastAvailable: true, + }, + launch: async () => ({ + accepted: true, + runId: null, + taskId: null, + abort, + }), + }), + ).resolves.toMatchObject({ + status: 'finalize_failed', + mode: 'fast', + cancelNote: 'Fast turn aborted', + }); + expect(abort).toHaveBeenCalledOnce(); + expect(mocks.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/tasks/suggestion-launch.ts b/apps/api/src/handlers/tasks/suggestion-launch.ts new file mode 100644 index 000000000..406b4216c --- /dev/null +++ b/apps/api/src/handlers/tasks/suggestion-launch.ts @@ -0,0 +1,176 @@ +import { + db, + finalizeWorkItemLaunched, + releaseWorkItemClaim, +} from '@roomote/db/server'; +import { isDeploymentReadOnlyError } from '@roomote/types'; + +import { resolveFastAgentEntryMode } from '../fast-agent-entry.js'; +import { cancelOrphanedWorkItemRunBestEffort } from './orphaned-work-item-run.js'; + +type SuggestedTaskLaunchMode = 'fast' | 'coding'; + +type SuggestedTaskLaunchAttempt = + | { + accepted: true; + runId: number | null; + taskId: string | null; + abort?: () => Promise; + } + | { + accepted: false; + reason?: string; + }; + +type SuggestedTaskLaunchResult = + | { + status: 'started'; + mode: SuggestedTaskLaunchMode; + runId: number | null; + taskId: string | null; + } + | { + status: 'rejected'; + mode: SuggestedTaskLaunchMode; + reason?: string; + } + | { + status: 'finalize_lost'; + mode: SuggestedTaskLaunchMode; + runId: number | null; + taskId: string | null; + cancelNote: string; + } + | { + status: 'finalize_failed'; + mode: SuggestedTaskLaunchMode; + runId: number | null; + taskId: string | null; + error: unknown; + cancelNote: string; + } + | { + status: 'failed'; + mode: SuggestedTaskLaunchMode; + error: unknown; + readOnly: boolean; + }; + +export function resolveSuggestedTaskLaunchMode(input: { + fastEligible: boolean; + userDefaultEnabled: boolean; + fastAvailable: boolean; +}): SuggestedTaskLaunchMode { + if (!input.fastEligible) { + return 'coding'; + } + + return resolveFastAgentEntryMode({ + explicitInvocation: false, + userDefaultEnabled: input.userDefaultEnabled, + fastAvailable: input.fastAvailable, + }) + ? 'fast' + : 'coding'; +} + +export async function launchClaimedSuggestedTask(input: { + suggestion: { id: string; launchClaimedAt: Date }; + policy: { + fastEligible: boolean; + userDefaultEnabled: boolean; + fastAvailable: boolean; + }; + launch: ( + mode: SuggestedTaskLaunchMode, + ) => Promise; + finalize?: (taskId: string | null) => Promise; + release?: () => Promise; +}): Promise { + const mode = resolveSuggestedTaskLaunchMode(input.policy); + const finalize = + input.finalize ?? + ((taskId: string | null) => + finalizeWorkItemLaunched(db, { + id: input.suggestion.id, + taskId, + claimedAt: input.suggestion.launchClaimedAt, + })); + const release = + input.release ?? + (() => + releaseWorkItemClaim(db, { + id: input.suggestion.id, + claimedAt: input.suggestion.launchClaimedAt, + })); + + let attempt: SuggestedTaskLaunchAttempt; + try { + attempt = await input.launch(mode); + } catch (error) { + await release().catch(() => undefined); + return { + status: 'failed', + mode, + error, + readOnly: isDeploymentReadOnlyError(error), + }; + } + + if (!attempt.accepted) { + await release(); + return { + status: 'rejected', + mode, + ...(attempt.reason ? { reason: attempt.reason } : {}), + }; + } + + const cancelAcceptedAttempt = async () => { + if (attempt.runId !== null) { + return cancelOrphanedWorkItemRunBestEffort(attempt.runId); + } + if (!attempt.abort) { + return 'no run id to cancel'; + } + try { + await attempt.abort(); + return 'Fast turn aborted'; + } catch { + return 'Fast turn abort failed'; + } + }; + + let finalized: boolean; + try { + finalized = await finalize(attempt.taskId); + } catch (error) { + const cancelNote = await cancelAcceptedAttempt(); + await release().catch(() => undefined); + return { + status: 'finalize_failed', + mode, + runId: attempt.runId, + taskId: attempt.taskId, + error, + cancelNote, + }; + } + if (finalized) { + return { + status: 'started', + mode, + runId: attempt.runId, + taskId: attempt.taskId, + }; + } + + const cancelNote = await cancelAcceptedAttempt(); + return { + status: 'finalize_lost', + mode, + runId: attempt.runId, + taskId: attempt.taskId, + cancelNote, + }; +} diff --git a/apps/api/src/handlers/teams/__tests__/index.test.ts b/apps/api/src/handlers/teams/__tests__/index.test.ts index 62dcf0573..464b24090 100644 --- a/apps/api/src/handlers/teams/__tests__/index.test.ts +++ b/apps/api/src/handlers/teams/__tests__/index.test.ts @@ -40,8 +40,11 @@ const { releaseClaimedOutOfBandMock, callViaEmojiConfigMock, continueFastReplyMock, + queueFastReplyMock, + findFastMessageSessionMock, findFastReplySessionMock, findTeamsConversationRouteMock, + getFastSessionMock, isFastProviderMessageMock, } = vi.hoisted(() => ({ authAccountsFindFirstMock: vi.fn(), @@ -102,8 +105,11 @@ const { releaseClaimedOutOfBandMock: vi.fn(), callViaEmojiConfigMock: vi.fn(), continueFastReplyMock: vi.fn(), + queueFastReplyMock: vi.fn(), + findFastMessageSessionMock: vi.fn(), findFastReplySessionMock: vi.fn(), findTeamsConversationRouteMock: vi.fn(), + getFastSessionMock: vi.fn(), isFastProviderMessageMock: vi.fn(), })); @@ -284,14 +290,21 @@ vi.mock('@roomote/sdk/server', () => ({ } : null, ), + findFastAgentSessionForProviderMessage: findFastMessageSessionMock, findFastAgentSessionForProviderReply: findFastReplySessionMock, findTeamsConversationRoute: findTeamsConversationRouteMock, isFastAgentProviderMessage: isFastProviderMessageMock, + queueFastAgentSurfaceReply: queueFastReplyMock, })); vi.mock('@roomote/cloud-agents/server', () => ({ + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), buildTeamsRoutingContext: buildTeamsRoutingContextMock, enqueueTask: enqueueTaskMock, + getOrCreateFastAgentSession: getFastSessionMock, getTaskUrl: getTaskUrlMock, routeTask: routeTaskMock, })); @@ -367,7 +380,12 @@ describe('Teams webhook handler', () => { beforeEach(() => { vi.clearAllMocks(); continueFastReplyMock.mockResolvedValue(true); + queueFastReplyMock.mockResolvedValue(true); + findFastMessageSessionMock.mockResolvedValue(null); findFastReplySessionMock.mockResolvedValue(null); + getFastSessionMock.mockResolvedValue({ + id: '11111111-1111-4111-8111-111111111111', + }); findTeamsConversationRouteMock.mockResolvedValue({ serviceUrl: 'https://smba.trafficmanager.net/amer/', workspaceId: 'tenant-1', @@ -495,6 +513,7 @@ describe('Teams webhook handler', () => { threadTs: 'activity-root', }), ); + expect(queueFastReplyMock).not.toHaveBeenCalled(); }); it('launches the exact suggested task when a linked user likes its card', async () => { @@ -550,6 +569,139 @@ describe('Teams webhook handler', () => { }), ); expect(callViaEmojiConfigMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + + it('queues a native reaction on the owner’s bound Fast message', async () => { + teamsUserMappingFindFirstMock.mockResolvedValue({ + userId: 'mapped-user-1', + }); + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: '19:conversation@thread.v2:user:mapped-user-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'fast-reaction-1', + text: undefined, + entities: undefined, + replyToId: 'fast-message-1', + reactionsAdded: [{ type: 'heart' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastReactionQueued: true, + }); + expect(findFastMessageSessionMock).toHaveBeenCalledWith({ + provider: 'teams', + workspaceId: 'tenant-1', + channelId: '19:conversation@thread.v2', + messageId: 'fast-message-1', + }); + expect(queueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-1', + userId: 'mapped-user-1', + currentMessageId: 'teams-reaction:fast-reaction-1', + replyToMessageId: 'fast-message-1', + externalInput: expect.objectContaining({ + provider: 'teams', + reactions: [{ name: 'heart' }], + }), + }), + ); + }); + + it('rejects a reaction from a different Fast session owner', async () => { + teamsUserMappingFindFirstMock.mockResolvedValue({ + userId: 'mapped-user-1', + }); + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'another-user', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: '19:conversation@thread.v2:user:another-user', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'fast-reaction-owner-mismatch', + text: undefined, + entities: undefined, + replyToId: 'fast-message-1', + reactionsAdded: [{ type: 'laugh' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + + it('ignores reaction removals', async () => { + const response = await createApp().request('/teams', { + method: 'POST', + headers: { + authorization: 'Bearer valid-token', + 'content-type': 'application/json', + }, + body: JSON.stringify( + createTeamsActivity({ + type: 'messageReaction', + id: 'reaction-removed', + text: undefined, + entities: undefined, + replyToId: 'fast-message-1', + reactionsAdded: [], + reactionsRemoved: [{ type: 'heart' }], + }), + ), + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'reaction_removed', + }); + expect(findFastMessageSessionMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); }); it('does not claim a reaction suggestion when account mapping fails', async () => { @@ -717,6 +869,7 @@ describe('Teams webhook handler', () => { channelId: '19:conversation@thread.v2', threadId: 'activity-root', replyToMessageId: 'fast-report-1', + userId: 'mapped-user-1', }); expect(findTeamsConversationRouteMock).toHaveBeenCalledWith( '19:conversation@thread.v2', @@ -1232,11 +1385,54 @@ describe('Teams webhook handler', () => { expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); }); - it('starts a new Teams task when the bot is mentioned without an active task run', async () => { + it('uses Fast for a linked Teams task entry', async () => { + findFirstMock.mockResolvedValueOnce(null); + teamsUserMappingFindFirstMock.mockResolvedValueOnce({ + userId: 'mapped-user-1', + }); + 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, + fastAnswered: true, + fastDefaulted: true, + }); + expect(getFastSessionMock).toHaveBeenCalledWith({ + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: 'activity-root:user:mapped-user-1', + replyTarget: { + channelId: '19:conversation@thread.v2', + threadId: 'activity-root', + }, + }, + }); + expect(continueFastReplyMock).toHaveBeenCalledWith({ + sessionId: '11111111-1111-4111-8111-111111111111', + userId: 'mapped-user-1', + senderDisplayName: 'Ada Lovelace', + question: 'continue', + currentMessageId: 'activity-2', + }); + expect(enqueueTaskMock).not.toHaveBeenCalled(); + }); + + it('falls back to normal Teams task routing when Fast session setup fails', async () => { findFirstMock.mockResolvedValueOnce(null).mockResolvedValueOnce(null); teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', }); + getFastSessionMock.mockRejectedValueOnce(new Error('session unavailable')); + const response = await createApp().request('/teams', { method: 'POST', headers: { @@ -1251,45 +1447,11 @@ describe('Teams webhook handler', () => { started: true, runId: 88, }); - expect(response.status).toBe(200); - expect(buildTeamsRoutingContextMock).toHaveBeenCalledWith( - expect.objectContaining({ - userId: 'mapped-user-1', - taskDescription: 'continue', - }), - ); - expect(enqueueTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: 'standard', - payload: expect.objectContaining({ - repo: '__all_repositories__', - description: 'continue', - communicationProvider: 'teams', - communicationChannelId: '19:conversation@thread.v2', - communicationThreadId: 'activity-root', - communicationServiceUrl: 'https://smba.trafficmanager.net/amer/', - }), - }), - initiator: { kind: 'user', userId: 'mapped-user-1' }, - workflow: 'standard', - surface: 'teams', - trigger: 'message', - }), - expect.objectContaining({ - launchClass: 'human', - }), - ); - expect(postMessageMock).toHaveBeenCalledWith( - expect.objectContaining({ - channelId: '19:conversation@thread.v2', - replyToMessageId: 'activity-root', - text: expect.stringContaining('Started a task'), - }), - ); + expect(buildTeamsRoutingContextMock).toHaveBeenCalled(); + expect(enqueueTaskMock).toHaveBeenCalled(); }); - it('starts new Teams tasks with image attachments as prompt images', async () => { + it('starts Fast with Teams image attachments', async () => { findFirstMock.mockResolvedValueOnce(null).mockResolvedValueOnce(null); teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', @@ -1315,8 +1477,8 @@ describe('Teams webhook handler', () => { await expect(response.json()).resolves.toEqual({ ok: true, - started: true, - runId: 88, + fastAnswered: true, + fastDefaulted: true, }); expect(processImageAttachmentsMock).toHaveBeenCalledWith( [ @@ -1328,33 +1490,16 @@ describe('Teams webhook handler', () => { ], { serviceUrl: 'https://smba.trafficmanager.net/amer/' }, ); - expect(buildTeamsRoutingContextMock).toHaveBeenCalledWith( + expect(continueFastReplyMock).toHaveBeenCalledWith( expect.objectContaining({ + question: 'continue', images: ['data:image/png;base64,abc123'], }), ); - expect(enqueueTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: 'standard', - payload: expect.objectContaining({ - description: 'continue', - images: ['data:image/png;base64,abc123'], - communicationProvider: 'teams', - }), - }), - initiator: { kind: 'user', userId: 'mapped-user-1' }, - workflow: 'standard', - surface: 'teams', - trigger: 'message', - }), - expect.objectContaining({ - launchClass: 'human', - }), - ); + expect(enqueueTaskMock).not.toHaveBeenCalled(); }); - it('starts new Teams tasks from image-only task-entry activities', async () => { + it('starts Fast from image-only Teams task-entry activities', async () => { findFirstMock.mockResolvedValueOnce(null).mockResolvedValueOnce(null); teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', @@ -1381,28 +1526,16 @@ describe('Teams webhook handler', () => { await expect(response.json()).resolves.toEqual({ ok: true, - started: true, - runId: 88, + fastAnswered: true, + fastDefaulted: true, }); - expect(enqueueTaskMock).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.objectContaining({ - type: 'standard', - payload: expect.objectContaining({ - description: 'Image attachment', - images: ['data:image/png;base64,abc123'], - communicationProvider: 'teams', - }), - }), - initiator: { kind: 'user', userId: 'mapped-user-1' }, - workflow: 'standard', - surface: 'teams', - trigger: 'message', - }), + expect(continueFastReplyMock).toHaveBeenCalledWith( expect.objectContaining({ - launchClass: 'human', + question: 'Image attachment', + images: ['data:image/png;base64,abc123'], }), ); + expect(enqueueTaskMock).not.toHaveBeenCalled(); }); it('prompts task-entry Teams users to link accounts before starting work', async () => { @@ -1606,7 +1739,7 @@ describe('Teams webhook handler', () => { expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); }); - it('starts personal chat tasks without unstable activity thread metadata', async () => { + it('uses a stable personal chat identity for Fast sessions', async () => { findFirstMock.mockResolvedValueOnce(null).mockResolvedValueOnce(null); teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', @@ -1634,25 +1767,19 @@ describe('Teams webhook handler', () => { await expect(response.json()).resolves.toEqual({ ok: true, - started: true, - runId: 88, + fastAnswered: true, + fastDefaulted: true, }); - const { task } = enqueueTaskMock.mock.calls[0]?.[0] as { - task: { payload: Record }; - }; - expect(task.payload).toMatchObject({ - communicationProvider: 'teams', - communicationChannelId: 'a:personal-conversation', - communicationMessageId: 'personal-activity-1', - }); - expect(task.payload).not.toHaveProperty('communicationThreadId'); - expect(task.payload).not.toHaveProperty('teamsThreadId'); - const reply = postMessageMock.mock.calls[0]?.[0] as Record; - expect(reply).toMatchObject({ - channelId: 'a:personal-conversation', - text: expect.stringContaining('Started a task'), - }); - expect(reply).not.toHaveProperty('replyToMessageId'); + expect(getFastSessionMock).toHaveBeenCalledWith({ + userId: 'mapped-user-1', + conversation: { + surface: 'teams', + workspaceId: 'tenant-1', + conversationId: 'a:personal-conversation:user:mapped-user-1', + replyTarget: { channelId: 'a:personal-conversation' }, + }, + }); + expect(enqueueTaskMock).not.toHaveBeenCalled(); }); it('ignores channel messages without a bot mention when no active task run exists', async () => { @@ -1685,7 +1812,7 @@ describe('Teams webhook handler', () => { expect(enqueueTaskMock).not.toHaveBeenCalled(); }); - it('starts a task for an unmentioned thread reply when the unmentioned-reply gate routes it', async () => { + it('starts Fast for an unmentioned thread reply when the gate routes it', async () => { findFirstMock.mockResolvedValueOnce(null).mockResolvedValueOnce(null); teamsUserMappingFindFirstMock.mockResolvedValueOnce({ userId: 'mapped-user-1', @@ -1707,8 +1834,8 @@ describe('Teams webhook handler', () => { await expect(response.json()).resolves.toEqual({ ok: true, - started: true, - runId: 88, + fastAnswered: true, + fastDefaulted: true, }); expect(response.status).toBe(200); expect(shouldRouteUnmentionedReplyMock).toHaveBeenCalledWith( @@ -1722,22 +1849,12 @@ describe('Teams webhook handler', () => { }), }), ); - expect(enqueueTaskMock).toHaveBeenCalledWith( + expect(continueFastReplyMock).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.objectContaining({ - type: 'standard', - payload: expect.objectContaining({ - description: 'sounds good, keep going', - communicationProvider: 'teams', - }), - }), - initiator: { kind: 'user', userId: 'mapped-user-1' }, - workflow: 'standard', - surface: 'teams', - trigger: 'message', + question: 'sounds good, keep going', }), - expect.objectContaining({ launchClass: 'human' }), ); + expect(enqueueTaskMock).not.toHaveBeenCalled(); }); it('resumes a completed Teams task from a snapshot for an unmentioned thread reply the gate routes', async () => { @@ -1841,7 +1958,7 @@ describe('Teams webhook handler', () => { ); }); - it('resumes a completed Teams task from a snapshot when it wins the resume lock', async () => { + it('resumes a completed Teams task before starting a new Fast session', async () => { findFirstMock.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 77, userId: 'user-1', @@ -1892,6 +2009,7 @@ describe('Teams webhook handler', () => { replyToMessageId: 'activity-root', }), ); + expect(getFastSessionMock).not.toHaveBeenCalled(); }); it('queues the follow-up to the leader resume task run when the resume lock is contended', async () => { diff --git a/apps/api/src/handlers/teams/__tests__/suggestion-start.test.ts b/apps/api/src/handlers/teams/__tests__/suggestion-start.test.ts index d5b766612..f0fc17ca1 100644 --- a/apps/api/src/handlers/teams/__tests__/suggestion-start.test.ts +++ b/apps/api/src/handlers/teams/__tests__/suggestion-start.test.ts @@ -151,10 +151,7 @@ describe('launchClaimedTeamsSuggestion', () => { expect(outcome).toEqual({ result: 'started', runId: 7 }); expect(launchTask).toHaveBeenCalledWith( - expect.stringContaining('Start this suggested task: Fix the flaky test'), - ); - expect(launchTask).toHaveBeenCalledWith( - expect.stringContaining('Target repository: acme/app'), + 'Fix the flaky test\n\nThe retry loop never terminates.\n\nTarget repository: acme/app', ); expect(finalizeWorkItemLaunchedMock).toHaveBeenCalledTimes(1); expect(finalizeWorkItemLaunchedMock).toHaveBeenCalledWith( diff --git a/apps/api/src/handlers/teams/__tests__/unmentioned-thread-reply.test.ts b/apps/api/src/handlers/teams/__tests__/unmentioned-thread-reply.test.ts index beae0e65e..9a4a2edbd 100644 --- a/apps/api/src/handlers/teams/__tests__/unmentioned-thread-reply.test.ts +++ b/apps/api/src/handlers/teams/__tests__/unmentioned-thread-reply.test.ts @@ -174,6 +174,25 @@ describe('shouldRouteUnmentionedTeamsThreadReplyToAgent', () => { await expect(routeDecision(threadReplyActivity())).resolves.toBe(true); }); + it('keeps routing after the sender mentions themself', async () => { + fetchThreadMessagesMock.mockResolvedValue([ + humanGraphMessage({ + id: THREAD_ROOT_ID, + userId: 'aad-user-1', + mentions: [botMention()], + }), + botGraphMessage('1700000000100'), + humanGraphMessage({ + id: '1700000000200', + userId: 'aad-user-1', + text: '@Ada note to self', + mentions: [{ userId: 'aad-user-1', name: 'Ada' }], + }), + ]); + + await expect(routeDecision(threadReplyActivity())).resolves.toBe(true); + }); + it('requires a mention when somebody else posted since the bot last spoke', async () => { fetchThreadMessagesMock.mockResolvedValue([ humanGraphMessage({ diff --git a/apps/api/src/handlers/teams/index.ts b/apps/api/src/handlers/teams/index.ts index 6ca0b5295..188429044 100644 --- a/apps/api/src/handlers/teams/index.ts +++ b/apps/api/src/handlers/teams/index.ts @@ -29,9 +29,11 @@ import type { TeamsCommunicationProvider } from '@roomote/communication/teams-pr import { continueFastAgentSurfaceReply, createTeamsCommunicationProviderFromRuntimeCredentials, + findFastAgentSessionForProviderMessage, findFastAgentSessionForProviderReply, findTeamsConversationRoute, isFastAgentProviderMessage, + queueFastAgentSurfaceReply, } from '@roomote/sdk/server'; import { exchangeMicrosoftDelegatedGraphToken, @@ -70,13 +72,16 @@ import { appendAttachmentTextsToPromptText } from '@roomote/cloud-agents'; import { AUDIO_TRANSCRIPTION_MAX_SIZE_BYTES, buildTeamsRoutingContext, + buildFastAgentReactionExternalInputQuestion, enqueueTask, formatAudioAttachmentWarning, formatAudioTranscriptionResult, getTaskUrl, + getOrCreateFastAgentSession, resolveAudioTranscriptionMimeType, routeTask, transcribeAudioAttachment, + type FastAgentReactionExternalInput, type RoutingWorkspace, } from '@roomote/cloud-agents/server'; @@ -1781,6 +1786,12 @@ teams.post('/', async (c) => { } const reactionTargetMessageId = activity.replyToId?.trim(); + if ( + (activity.reactionsAdded?.length ?? 0) === 0 && + (activity.reactionsRemoved?.length ?? 0) > 0 + ) { + return c.json({ ok: true, ignored: 'reaction_removed' }); + } const hasLikeReaction = (activity.reactionsAdded ?? []).some( (reaction) => reaction.type === 'like', ); @@ -1844,15 +1855,101 @@ teams.post('/', async (c) => { } } - if (!configuration && !claimedSuggestionReaction) { - return c.json({ ok: true, ignored: 'reaction_not_configured' }); - } - const targetMessageId = activity.replyToId?.trim(); if (!targetMessageId) { return c.json({ ok: true, ignored: 'reaction_target_missing' }); } + if (!configuration && !claimedSuggestionReaction) { + const addedReactions = (activity.reactionsAdded ?? []) + .map((reaction) => reaction.type.trim().toLowerCase()) + .filter(isTeamsNativeReactionType) + .map((name) => ({ name })); + if (addedReactions.length === 0) { + return c.json({ ok: true, ignored: 'reaction_not_configured' }); + } + + const metadata = getTeamsActivityCommunicationMetadata(activity); + const tenantId = metadata.teamsTenantId; + const fastChannelId = getTeamsBaseConversationId( + metadata.communicationChannelId, + ); + const fastSession = tenantId + ? await findFastAgentSessionForProviderMessage({ + provider: 'teams', + workspaceId: tenantId, + channelId: fastChannelId, + messageId: targetMessageId, + }) + : null; + if (!fastSession) { + return c.json({ ok: true, ignored: 'reaction_not_configured' }); + } + + const mappedUserId = await findMappedTeamsUserId(activity); + if (!mappedUserId) { + await postTeamsAccountLinkPrompt({ activity, metadata }); + return c.json({ + ok: true, + queued: false, + reason: 'account_link_required', + }); + } + if (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 eventId = activity.id ?? randomUUID(); + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'teams', + reactions: addedReactions, + reactor: { + externalUserId: activity.from?.id ?? mappedUserId, + ...(activity.from?.name?.trim() + ? { displayName: activity.from.name.trim() } + : {}), + }, + message: { + workspaceId: tenantId!, + channelId: fastChannelId, + messageId: targetMessageId, + ...(fastSession.conversation.replyTarget.threadId + ? { threadId: fastSession.conversation.replyTarget.threadId } + : {}), + }, + eventId, + }; + const queued = await queueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId: mappedUserId, + senderDisplayName: activity.from?.name?.trim() || null, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + currentMessageId: `teams-reaction:${eventId}`, + replyToMessageId: targetMessageId, + externalInput: reactionInput, + }); + return c.json( + queued + ? { ok: true, fastReactionQueued: true } + : { + ok: true, + ignored: 'teams_fast_reaction_route_unavailable', + }, + ); + } + const mentionName = activity.recipient?.name?.trim() || PRODUCT_NAME; const mentionText = `${mentionName}`; activity = { @@ -1968,6 +2065,7 @@ teams.post('/', async (c) => { ? { threadId: metadata.communicationThreadId } : {}), ...(replyToMessageId ? { replyToMessageId } : {}), + userId: mappedUserId, }) : null; if (!fastSession && replyToMessageId) { @@ -2272,6 +2370,62 @@ teams.post('/', async (c) => { } } + if (tenantId) { + const providerConversationId = + metadata.communicationThreadId ?? + (activity.conversation.conversationType === 'personal' + ? fastChannelId + : queuedMessage.ts); + const conversation = { + surface: 'teams' as const, + workspaceId: tenantId, + conversationId: `${providerConversationId}:user:${mappedUserId}`, + replyTarget: { + channelId: fastChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + }, + }; + + try { + const session = await getOrCreateFastAgentSession({ + userId: mappedUserId, + conversation, + }); + void continueFastAgentSurfaceReply({ + sessionId: session.id, + userId: mappedUserId, + senderDisplayName: activity.from?.name?.trim() || null, + question: queuedMessage.text.trim(), + currentMessageId: queuedMessage.ts, + ...(queuedMessage.images ? { images: queuedMessage.images } : {}), + }) + .then((continued) => { + if (!continued) { + apiLogger.warn( + `[teams] Default Fast session ${session.id} could not resolve an active delivery route`, + ); + } + }) + .catch((error) => { + apiLogger.error( + `[teams] Default Fast response failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + + return c.json({ ok: true, fastAnswered: true, fastDefaulted: true }); + } catch (error) { + apiLogger.warn( + `[teams] Failed to initialize the default Fast session; falling back to task routing: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + let launch: Awaited>; try { launch = await startNewTeamsTask({ diff --git a/apps/api/src/handlers/teams/suggestion-start.ts b/apps/api/src/handlers/teams/suggestion-start.ts index 99741f236..61d7e572e 100644 --- a/apps/api/src/handlers/teams/suggestion-start.ts +++ b/apps/api/src/handlers/teams/suggestion-start.ts @@ -4,21 +4,16 @@ import { claimWorkItem, db, eq, - finalizeWorkItemLaunched, inArray, isNotNull, - releaseWorkItemClaim, sql, trackedMessages, workItems, } from '@roomote/db/server'; -import { - MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE, - isDeploymentReadOnlyError, -} from '@roomote/types'; +import { MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE } from '@roomote/types'; import { apiLogger } from '../../logging.js'; -import { cancelOrphanedWorkItemRunBestEffort } from '../tasks/orphaned-work-item-run.js'; +import { launchClaimedSuggestedTask } from '../tasks/suggestion-launch.js'; import { claimCurrentThreadSuggestionByMessage } from '../tasks/current-thread-suggestion-reaction.js'; import { stripTeamsMessageIdSuffix } from './find-active-teams-run.js'; @@ -73,6 +68,7 @@ export type ClaimedTeamsSuggestion = { investigationContext: string | null; targetRepositoryFullName: string | null; targetEnvironmentId?: string | null; + usesRouterLaunch?: boolean; launchClaimedAt: Date; }; @@ -229,7 +225,7 @@ function buildTeamsSuggestionTaskPromptText( suggestion: ClaimedTeamsSuggestion, ): string { return [ - `Start this suggested task: ${suggestion.title}`, + suggestion.title, '', suggestion.brief ?? '', ...(suggestion.targetRepositoryFullName @@ -278,97 +274,53 @@ export async function launchClaimedTeamsSuggestion(params: { postMessage: (text: string) => Promise; }): Promise { const { suggestion } = params; - const claimedAt = suggestion.launchClaimedAt; - - try { - const launch = await params.launchTask( - buildTeamsSuggestionTaskPromptText(suggestion), - ); - - if (launch.status === 'started') { - // Close the launch state machine: `launching` -> `launched` with the - // task link, so a later "start idea N" can never relaunch this - // suggestion and the task stays linked to its work item. - const finalized = await finalizeWorkItemLaunched(db, { - id: suggestion.id, - taskId: launch.launchResult.taskId, - claimedAt, - }); - - if (!finalized) { - // The task is already enqueued but the fencing guard rejected the - // finalize (our stale claim was reclaimed by another launcher), so - // the run is orphaned from the work item. Best-effort cancel it while - // it is still pre-sandbox; log loudly either way with the outcome. - const cancelNote = await cancelOrphanedWorkItemRunBestEffort( - launch.launchResult.id, - ); - - apiLogger.warn( - `[teams] finalize lost the fencing guard for work item ${suggestion.id}; task ${launch.launchResult.taskId} (run ${launch.launchResult.id}) was orphaned — ${cancelNote}`, - ); - - // startNewTeamsTask already posted its started acknowledgement before - // the finalize, so correct it: the user must not follow the canceled - // orphan. Surface the claim-lose outcome instead of a started one. - await params.postMessage( - `"${suggestion.title}" was already started elsewhere — this duplicate launch was canceled.`, - ); - - return { result: 'already_started' }; - } - - return { result: 'started', runId: launch.launchResult.id }; - } - - // Routing answered inline; no task was launched. Release the claim so the - // suggestion is retryable now instead of dead for the stale window. - await releaseWorkItemClaim(db, { id: suggestion.id, claimedAt }); - - return { result: 'replied_inline' }; - } catch (error) { - if (isDeploymentReadOnlyError(error)) { - await releaseWorkItemClaim(db, { id: suggestion.id, claimedAt }).catch( - (releaseError) => { - apiLogger.warn( - `[teams] Failed to release claim for work item ${suggestion.id} after read-only launch block: ${ - releaseError instanceof Error - ? releaseError.message - : String(releaseError) - }`, - ); - }, + const launchResult = await launchClaimedSuggestedTask({ + suggestion, + policy: { + fastEligible: false, + userDefaultEnabled: false, + fastAvailable: false, + }, + launch: async () => { + const launch = await params.launchTask( + buildTeamsSuggestionTaskPromptText(suggestion), ); + return launch.status === 'started' + ? { + accepted: true, + runId: launch.launchResult.id, + taskId: launch.launchResult.taskId, + } + : { accepted: false }; + }, + }); - await params.postMessage(MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE); - - return { result: 'launch_failed' }; - } - + if (launchResult.status === 'started') { + return { result: 'started', runId: launchResult.runId! }; + } + if (launchResult.status === 'rejected') { + return { result: 'replied_inline' }; + } + if ( + launchResult.status === 'finalize_lost' || + launchResult.status === 'finalize_failed' + ) { apiLogger.warn( - `[teams] Failed to launch suggestion ${suggestion.id} from "start idea" reply: ${ - error instanceof Error ? error.message : String(error) - }`, + `[teams] failed to finalize work item ${suggestion.id}; task ${launchResult.taskId ?? 'null'} (run ${launchResult.runId ?? 'null'}) — ${launchResult.cancelNote}`, ); - - // Release the claim (fenced on our token) so the suggestion becomes - // retryable immediately rather than after the 10-minute stale window. - await releaseWorkItemClaim(db, { id: suggestion.id, claimedAt }).catch( - (releaseError) => { - apiLogger.warn( - `[teams] Failed to release claim for work item ${suggestion.id} after launch failure: ${ - releaseError instanceof Error - ? releaseError.message - : String(releaseError) - }`, - ); - }, - ); - await params.postMessage( - `Could not start "${suggestion.title}" — try describing the task in a message instead.`, + `"${suggestion.title}" was already started elsewhere — this duplicate launch was canceled.`, ); - - return { result: 'launch_failed' }; + return { result: 'already_started' }; } + + apiLogger.warn( + `[teams] Failed to launch suggestion ${suggestion.id}: ${launchResult.error instanceof Error ? launchResult.error.message : String(launchResult.error)}`, + ); + await params.postMessage( + launchResult.readOnly + ? MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE + : `Could not start "${suggestion.title}" — try describing the task in a message instead.`, + ); + return { result: 'launch_failed' }; } diff --git a/apps/api/src/handlers/teams/unmentioned-thread-reply.ts b/apps/api/src/handlers/teams/unmentioned-thread-reply.ts index 75c0dd356..c2fd36bfb 100644 --- a/apps/api/src/handlers/teams/unmentioned-thread-reply.ts +++ b/apps/api/src/handlers/teams/unmentioned-thread-reply.ts @@ -63,7 +63,6 @@ function isHumanAuthoredGraphMessage(message: TeamsGraphMessage): boolean { function toSharedHistoryMessages( threadMessages: TeamsGraphMessage[], normalizedBotAppId: string, - senderAadObjectId: string, ): UnmentionedThreadHistoryMessage[] { return threadMessages.map((message) => { const isBot = isBotAuthoredGraphMessage(message, normalizedBotAppId); @@ -83,7 +82,8 @@ function toSharedHistoryMessages( (mention) => !isBotGraphMention(mention, normalizedBotAppId) && (Boolean(mention.applicationId) || - (Boolean(mention.userId) && mention.userId !== senderAadObjectId)), + (Boolean(mention.userId) && + mention.userId !== message.authorUserId)), ), }; }); @@ -191,11 +191,7 @@ export async function shouldRouteUnmentionedTeamsThreadReplyToAgent(params: { taskBackedThreadRun.userId === params.mappedUserId, isThreadRootAuthor, isAutomationReportThread: Boolean(automationReportRun), - threadMessages: toSharedHistoryMessages( - threadMessages, - normalizedBotAppId, - senderAadObjectId, - ), + threadMessages: toSharedHistoryMessages(threadMessages, normalizedBotAppId), compareMessageIds: compareNumericMessageIds, }); diff --git a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts index f9a432906..559b26d04 100644 --- a/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/callback-actions.test.ts @@ -132,6 +132,7 @@ beforeEach(() => { brief: 'The retry loop never terminates.', investigationContext: null, targetRepositoryFullName: null, + usesRouterLaunch: true, launchClaimedAt: CLAIMED_AT, }); findCurrentThreadSuggestionIdByMessageMock.mockResolvedValue(WORK_ITEM_ID); @@ -143,6 +144,7 @@ beforeEach(() => { brief: 'The retry loop never terminates.', investigationContext: null, targetRepositoryFullName: null, + usesRouterLaunch: true, launchClaimedAt: CLAIMED_AT, }, }); @@ -202,6 +204,7 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { expect.objectContaining({ launchOwnerUserId: 'user-1', queuedMessage: expect.objectContaining({ + text: 'Fix the flaky test\n\nThe retry loop never terminates.', user: 'Matt', userId: 'user-1', }), @@ -227,6 +230,23 @@ describe('handleTelegramCallbackQuery suggestion launch lifecycle', () => { ); }); + it('keeps router-backed suggestions on the coding path when Fast is unavailable', async () => { + startNewTelegramTaskMock.mockResolvedValue({ + status: 'started', + launchResult: { id: 7, taskId: 'task-1' }, + }); + + await handleTelegramCallbackQuery(buildSuggestionQuery()); + + expect(startNewTelegramTaskMock).toHaveBeenCalledWith( + expect.not.objectContaining({ workspaceOverride: expect.anything() }), + ); + expect(finalizeWorkItemLaunchedMock).toHaveBeenCalledWith( + expect.anything(), + { id: WORK_ITEM_ID, taskId: 'task-1', claimedAt: CLAIMED_AT }, + ); + }); + it('launches directly in the environment saved on the suggestion', async () => { claimTelegramSuggestionLaunchMock.mockResolvedValue({ id: WORK_ITEM_ID, diff --git a/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts b/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts index c94d49663..9034d314b 100644 --- a/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/claim-telegram-suggestion-launch.db.test.ts @@ -133,6 +133,7 @@ describe('claimTelegramSuggestionLaunch (work_items launch CAS)', () => { investigationContext: null, targetRepositoryFullName: null, targetEnvironmentId: null, + usesRouterLaunch: true, }); }); diff --git a/apps/api/src/handlers/telegram/__tests__/index.test.ts b/apps/api/src/handlers/telegram/__tests__/index.test.ts index 5c33fb1fa..a202dc1bd 100644 --- a/apps/api/src/handlers/telegram/__tests__/index.test.ts +++ b/apps/api/src/handlers/telegram/__tests__/index.test.ts @@ -40,6 +40,12 @@ const { usersFindFirstMock, telegramMappingsFindFirstMock, appendAccountLinkHelpTextMock, + continueFastReplyMock, + queueFastReplyMock, + findFastMessageSessionMock, + findFastReplySessionMock, + getFastSessionMock, + isFastProviderMessageMock, } = vi.hoisted(() => ({ addReactionMock: vi.fn(), answerCallbackQueryMock: vi.fn(), @@ -84,6 +90,12 @@ const { usersFindFirstMock: vi.fn(), telegramMappingsFindFirstMock: vi.fn(), appendAccountLinkHelpTextMock: vi.fn(async (message: string) => message), + continueFastReplyMock: vi.fn(), + queueFastReplyMock: vi.fn(), + findFastMessageSessionMock: vi.fn(), + findFastReplySessionMock: vi.fn(), + getFastSessionMock: vi.fn(), + isFastProviderMessageMock: vi.fn(), })); vi.mock('@roomote/env', () => ({ @@ -247,6 +259,7 @@ vi.mock('@roomote/communication/messages', () => ({ })); vi.mock('@roomote/sdk/server', () => ({ + continueFastAgentSurfaceReply: continueFastReplyMock, createTelegramCommunicationProviderFromRuntimeCredentials: vi.fn(async () => envMock.R_TELEGRAM_BOT_TOKEN ? { @@ -267,6 +280,10 @@ vi.mock('@roomote/sdk/server', () => ({ isTelegramLinkCode: (value: string) => /^link-[A-Za-z0-9_-]{16,}$/.test(value.trim()), findTelegramPrimaryChatId: vi.fn(async () => null), + findFastAgentSessionForProviderMessage: findFastMessageSessionMock, + findFastAgentSessionForProviderReply: findFastReplySessionMock, + isFastAgentProviderMessage: isFastProviderMessageMock, + queueFastAgentSurfaceReply: queueFastReplyMock, TELEGRAM_PRIMARY_CHAT_ENV_VAR_NAME: 'TELEGRAM_PRIMARY_CHAT_ID', claimPendingPrReviewAction: vi.fn(async () => null), claimPendingPrReviewActionsForThread: vi.fn(async () => []), @@ -294,12 +311,17 @@ vi.mock('../../tasks/task-stop.js', () => ({ })); vi.mock('@roomote/cloud-agents/server', () => ({ + buildFastAgentReactionExternalInputQuestion: vi.fn( + (input: unknown) => + `${JSON.stringify(input)}`, + ), buildTelegramRoutingContext: buildTelegramRoutingContextMock, classifyFollowUp: classifyFollowUpMock, enqueueTask: enqueueTaskMock, getAvailableEnvironments: getAvailableEnvironmentsMock, getRoutingAutoConfirmDelayMs: getRoutingAutoConfirmDelayMsMock, getTaskUrl: getTaskUrlMock, + getOrCreateFastAgentSession: getFastSessionMock, resolveRoutingFollowUp: resolveRoutingFollowUpMock, routeTask: routeTaskMock, })); @@ -381,6 +403,12 @@ describe('Telegram webhook handler', () => { taskRunsFindFirstMock.mockReset(); telegramMappingsFindFirstMock.mockReset(); consumeLinkCodeMock.mockReset(); + continueFastReplyMock.mockResolvedValue(true); + queueFastReplyMock.mockResolvedValue(true); + findFastMessageSessionMock.mockResolvedValue(null); + findFastReplySessionMock.mockResolvedValue(null); + getFastSessionMock.mockRejectedValue(new Error('Fast unavailable')); + isFastProviderMessageMock.mockResolvedValue(false); envMock.R_APP_URL = 'https://app.example.com'; envMock.R_TELEGRAM_BOT_TOKEN = 'bot-token'; @@ -465,6 +493,114 @@ describe('Telegram webhook handler', () => { postMessageMock.mockResolvedValue({ messageId: 'telegram-response' }); }); + it('queues a new reaction on the owner’s bound Fast message', async () => { + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'mapped-user-1', + conversation: { + surface: 'telegram', + workspaceId: '222', + conversationId: '222:user:mapped-user-1', + replyTarget: { channelId: '222' }, + }, + }); + mockTelegramLinkedSender('mapped-user-1'); + + const response = await postTelegramUpdate({ + update_id: 124, + message_reaction: { + chat: { id: 222, type: 'private' }, + message_id: 777, + date: 1_700_000_000, + user: { + id: 111, + first_name: 'Ada', + last_name: 'Lovelace', + username: 'ada', + }, + old_reaction: [], + new_reaction: [{ type: 'emoji', emoji: '❤️' }], + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastReactionQueued: true, + }); + expect(findFastMessageSessionMock).toHaveBeenCalledWith({ + provider: 'telegram', + workspaceId: '222', + channelId: '222', + messageId: '777', + }); + expect(queueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: 'fast-session-1', + userId: 'mapped-user-1', + currentMessageId: 'telegram-reaction:124', + replyToMessageId: '777', + externalInput: expect.objectContaining({ + provider: 'telegram', + reactions: [{ name: '❤️' }], + }), + }), + ); + }); + + it('rejects a reaction from a different Fast session owner', async () => { + findFastMessageSessionMock.mockResolvedValue({ + id: 'fast-session-1', + userId: 'another-user', + conversation: { + surface: 'telegram', + workspaceId: '222', + conversationId: '222:user:another-user', + replyTarget: { channelId: '222' }, + }, + }); + mockTelegramLinkedSender('mapped-user-1'); + + const response = await postTelegramUpdate({ + update_id: 125, + message_reaction: { + chat: { id: 222, type: 'private' }, + message_id: 777, + date: 1_700_000_000, + user: { id: 111, first_name: 'Ada' }, + old_reaction: [], + new_reaction: [{ type: 'emoji', emoji: '🔥' }], + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + + it('ignores reaction removals without starting Fast or a suggestion', async () => { + const response = await postTelegramUpdate({ + update_id: 126, + message_reaction: { + chat: { id: 222, type: 'private' }, + message_id: 777, + date: 1_700_000_000, + user: { id: 111, first_name: 'Ada' }, + old_reaction: [{ type: 'emoji', emoji: '👍' }], + new_reaction: [], + }, + }); + + await expect(response.json()).resolves.toEqual({ + ok: true, + ignored: 'reaction_removed_or_unchanged', + }); + expect(findFastMessageSessionMock).not.toHaveBeenCalled(); + expect(queueFastReplyMock).not.toHaveBeenCalled(); + }); + it('remembers implicit Telegram topics so the task title can replace New Chat', async () => { const response = await postTelegramUpdate( createTelegramUpdate({ @@ -492,6 +628,227 @@ describe('Telegram webhook handler', () => { expect(enqueueTaskMock).not.toHaveBeenCalled(); }); + it('uses Fast for a linked Telegram direct message', async () => { + mockTelegramLinkedSender('mapped-user-1'); + getFastSessionMock.mockResolvedValueOnce({ + id: '11111111-1111-4111-8111-111111111111', + }); + + const response = await postTelegramUpdate(createTelegramUpdate()); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); + expect(getFastSessionMock).toHaveBeenCalledWith({ + userId: 'mapped-user-1', + conversation: { + surface: 'telegram', + workspaceId: '222', + conversationId: '222:user:mapped-user-1', + replyTarget: { channelId: '222' }, + }, + }); + expect(continueFastReplyMock).toHaveBeenCalledWith({ + sessionId: '11111111-1111-4111-8111-111111111111', + userId: 'mapped-user-1', + senderDisplayName: 'Ada Lovelace', + question: 'continue the task', + currentMessageId: '456', + }); + expect(enqueueTaskMock).not.toHaveBeenCalled(); + }); + + it('continues a Telegram Fast reply before ordinary task routing', async () => { + mockTelegramLinkedSender('mapped-user-1'); + findFastReplySessionMock.mockResolvedValueOnce({ + id: '22222222-2222-4222-8222-222222222222', + userId: 'mapped-user-1', + conversation: { + surface: 'telegram', + workspaceId: '222', + conversationId: '222:user:mapped-user-1', + replyTarget: { channelId: '222' }, + }, + }); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + reply_to_message: { + message_id: 400, + date: 1, + text: 'Fast answer', + chat: { id: 222, type: 'private' }, + }, + }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + fastAnswered: true, + fastContinued: true, + }); + expect(findFastReplySessionMock).toHaveBeenCalledWith({ + provider: 'telegram', + workspaceId: '222', + channelId: '222', + replyToMessageId: '400', + userId: 'mapped-user-1', + }); + expect(continueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: '22222222-2222-4222-8222-222222222222', + userId: 'mapped-user-1', + question: 'continue the task', + }), + ); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + expect(enqueueTaskMock).not.toHaveBeenCalled(); + }); + + it('fails closed when a Telegram reply targets a Fast message on another route', async () => { + mockTelegramLinkedSender('mapped-user-1'); + isFastProviderMessageMock.mockResolvedValueOnce(true); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + reply_to_message: { + message_id: 400, + date: 1, + text: 'Fast answer', + chat: { id: 222, type: 'private' }, + }, + }, + }), + ); + + await expect(response.json()).resolves.toEqual({ + ok: true, + queued: false, + reason: 'fast_session_route_mismatch', + }); + expect(isFastProviderMessageMock).toHaveBeenCalledWith({ + provider: 'telegram', + messageId: '400', + workspaceId: '222', + channelId: '222', + }); + expect(queueCommunicationMessageMock).not.toHaveBeenCalled(); + expect(enqueueTaskMock).not.toHaveBeenCalled(); + }); + + it('passes Telegram photos to a new Fast session', async () => { + mockTelegramLinkedSender('mapped-user-1'); + getFastSessionMock.mockResolvedValueOnce({ + id: '33333333-3333-4333-8333-333333333333', + }); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: undefined, + caption: 'Inspect this screenshot', + photo: [ + { + file_id: 'photo-large', + file_unique_id: 'photo-1', + width: 1024, + height: 768, + }, + ], + }, + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + ok: true, + fastAnswered: true, + fastDefaulted: true, + }); + expect(continueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ + question: 'Inspect this screenshot', + images: ['data:image/jpeg;base64,AQID'], + }), + ); + expect(enqueueTaskMock).not.toHaveBeenCalled(); + }); + + it('uses a user-scoped Fast session for a Telegram group topic mention', async () => { + mockTelegramLinkedSender('mapped-user-1'); + getFastSessionMock.mockResolvedValueOnce({ + id: '44444444-4444-4444-8444-444444444444', + }); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: '@roomote_bot inspect this topic', + entities: [{ type: 'mention', offset: 0, length: 12 }], + message_thread_id: 77, + chat: { id: -1007, type: 'supergroup', title: 'Engineering' }, + }, + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + fastAnswered: true, + fastDefaulted: true, + }); + expect(getFastSessionMock).toHaveBeenCalledWith({ + userId: 'mapped-user-1', + conversation: { + surface: 'telegram', + workspaceId: '-1007', + conversationId: '77:user:mapped-user-1', + replyTarget: { channelId: '-1007', threadId: '77' }, + }, + }); + }); + + it('continues a user-owned Telegram Fast topic without another mention', async () => { + mockTelegramLinkedSender('mapped-user-1'); + findFastReplySessionMock.mockResolvedValueOnce({ + id: '55555555-5555-4555-8555-555555555555', + userId: 'mapped-user-1', + conversation: { + surface: 'telegram', + workspaceId: '-1007', + conversationId: '77:user:mapped-user-1', + replyTarget: { channelId: '-1007', threadId: '77' }, + }, + }); + + const response = await postTelegramUpdate( + createTelegramUpdate({ + message: { + text: 'keep going', + message_thread_id: 77, + chat: { id: -1007, type: 'supergroup', title: 'Engineering' }, + }, + }), + ); + + await expect(response.json()).resolves.toMatchObject({ + fastAnswered: true, + fastContinued: true, + }); + expect(findFastReplySessionMock).toHaveBeenCalledWith({ + provider: 'telegram', + workspaceId: '-1007', + channelId: '-1007', + threadId: '77', + userId: 'mapped-user-1', + }); + expect(continueFastReplyMock).toHaveBeenCalledWith( + expect.objectContaining({ question: 'keep going' }), + ); + }); + it('nudges an unlinked sender to link and drops the message', async () => { appendAccountLinkHelpTextMock.mockImplementation( async (message: string) => `${message} Ask an admin for an invite.`, @@ -744,6 +1101,7 @@ describe('Telegram webhook handler', () => { runId: 77, userId: 'launch-owner-1', }); + expect(getFastSessionMock).not.toHaveBeenCalled(); }); it('queues a captioned photo as an active-run follow-up', async () => { @@ -1211,6 +1569,8 @@ describe('Telegram webhook handler', () => { expect.objectContaining({ launchClass: 'human' }), ); expect(routeTaskMock).toHaveBeenCalled(); + expect(findFastReplySessionMock).not.toHaveBeenCalled(); + expect(getFastSessionMock).not.toHaveBeenCalled(); }); it('replies with a usage hint for a bare /new with no description', async () => { diff --git a/apps/api/src/handlers/telegram/callback-actions.ts b/apps/api/src/handlers/telegram/callback-actions.ts index 7812da11d..98877cb03 100644 --- a/apps/api/src/handlers/telegram/callback-actions.ts +++ b/apps/api/src/handlers/telegram/callback-actions.ts @@ -12,7 +12,6 @@ import { and, db, eq, - finalizeWorkItemLaunched, inArray, isNull, releaseWorkItemClaim, @@ -21,7 +20,7 @@ import { } from '@roomote/db/server'; import { apiLogger } from '../../logging.js'; -import { cancelOrphanedWorkItemRunBestEffort } from '../tasks/orphaned-work-item-run.js'; +import { launchClaimedSuggestedTask } from '../tasks/suggestion-launch.js'; import { claimCurrentThreadSuggestionByMessage, findCurrentThreadSuggestionIdByMessage, @@ -226,7 +225,7 @@ async function handleSuggestionLaunchCallback(params: { } const promptText = [ - `Start this suggested task: ${suggestion.title}`, + suggestion.title, '', suggestion.brief, ...(suggestion.targetRepositoryFullName @@ -276,63 +275,59 @@ async function handleSuggestionLaunchCallback(params: { if (suggestion.targetEnvironmentId && !workspaceOverride) { throw new Error('The suggestion target environment is unavailable.'); } - const started = await startNewTelegramTask({ - message, - launchOwnerUserId: senderUserId, - queuedMessage, - metadata: { - communicationProvider: 'telegram', - communicationChannelId: chatId, - ...(threadId ? { communicationThreadId: threadId } : {}), - communicationMessageId: messageId, + const launchResult = await launchClaimedSuggestedTask({ + suggestion: { id: params.suggestionId, launchClaimedAt: claimedAt }, + policy: { + fastEligible: false, + userDefaultEnabled: false, + fastAvailable: false, + }, + launch: async () => { + const started = await startNewTelegramTask({ + message, + launchOwnerUserId: senderUserId, + queuedMessage, + metadata: { + communicationProvider: 'telegram', + communicationChannelId: chatId, + ...(threadId ? { communicationThreadId: threadId } : {}), + communicationMessageId: messageId, + }, + // The button click already is the explicit start signal. + skipRoutingConfirmation: true, + forceNewTopic: true, + ...(workspaceOverride ? { workspaceOverride } : {}), + }); + return started.status === 'started' + ? { + accepted: true, + runId: started.launchResult.id, + taskId: started.launchResult.taskId, + } + : { accepted: false }; }, - // The button click already is the explicit start signal. - skipRoutingConfirmation: true, - forceNewTopic: true, - ...(workspaceOverride ? { workspaceOverride } : {}), }); - if (started.status === 'started') { - // Close the launch state machine: `launching` -> `launched` with the - // task link, so a later click can never relaunch this suggestion and the - // task stays linked to its work item. - const finalized = await finalizeWorkItemLaunched(db, { - id: params.suggestionId, - taskId: started.launchResult.taskId, - claimedAt, + if ( + launchResult.status === 'finalize_lost' || + launchResult.status === 'finalize_failed' + ) { + apiLogger.warn( + `[telegram] failed to finalize work item ${params.suggestionId}; task ${launchResult.taskId ?? 'null'} (run ${launchResult.runId ?? 'null'}) — ${launchResult.cancelNote}`, + ); + await postTelegramMessageBestEffort({ + chatId, + replyToMessageId: messageId, + text: `"${suggestion.title}" was already started elsewhere — this duplicate task was canceled.`, + }); + } else if (launchResult.status === 'failed') { + await postTelegramMessageBestEffort({ + chatId, + replyToMessageId: messageId, + text: launchResult.readOnly + ? MANAGED_DEPLOYMENT_READ_ONLY_MESSAGE + : `Could not start "${suggestion.title}" — try describing the task in a message instead.`, }); - - if (!finalized) { - // The task is already enqueued but the fencing guard rejected the - // finalize (our stale claim was reclaimed by another launcher), so - // the run is orphaned from the work item. Best-effort cancel it while - // it is still pre-sandbox; log loudly either way with the outcome. - const cancelNote = await cancelOrphanedWorkItemRunBestEffort( - started.launchResult.id, - ); - - apiLogger.warn( - `[telegram] finalize lost the fencing guard for work item ${params.suggestionId}; task ${started.launchResult.taskId} (run ${started.launchResult.id}) was orphaned — ${cancelNote}`, - ); - - // The callback was already answered "Starting: ..." and the launch - // path already posted a started message pointing at the orphan, so - // post a corrective reply: the user must follow the winning launch, - // not the canceled duplicate. (Deferring the started post until after - // finalize would change startNewTelegramTask's contract for its other - // callers, so correct instead.) - await postTelegramMessageBestEffort({ - chatId, - replyToMessageId: messageId, - text: `"${suggestion.title}" was already started elsewhere — this duplicate task was canceled.`, - }); - } - } else { - // No task was launched: routing answered inline (`replied_inline`), or — - // defensively, since skipRoutingConfirmation is set — a confirmation was - // requested. Release the claim so the suggestion is retryable now - // instead of dead for the 10-minute stale window. - await releaseWorkItemClaim(db, { id: params.suggestionId, claimedAt }); } } catch (error) { const blockedByReadOnly = isDeploymentReadOnlyError(error); diff --git a/apps/api/src/handlers/telegram/index.ts b/apps/api/src/handlers/telegram/index.ts index 4ac032da0..4e80259f0 100644 --- a/apps/api/src/handlers/telegram/index.ts +++ b/apps/api/src/handlers/telegram/index.ts @@ -17,6 +17,7 @@ import { getTelegramUpdateCommunicationMetadata, getTelegramUpdateMessage, getTelegramUpdateMessageReaction, + getNewTelegramMessageReactions, getTelegramNewTaskCommand, isTelegramImplicitTopicCreatedMessage, isTelegramPrivateChat, @@ -36,10 +37,20 @@ import { } from './task-run-lookup.js'; import { retireTelegramPrReviewOffersBestEffort } from './pr-review-action.js'; import { + continueFastAgentSurfaceReply, consumeTelegramLinkCode, + findFastAgentSessionForProviderMessage, + findFastAgentSessionForProviderReply, isTelegramLinkCode, + isFastAgentProviderMessage, + queueFastAgentSurfaceReply, restoreTelegramLinkCode, } from '@roomote/sdk/server'; +import { + buildFastAgentReactionExternalInputQuestion, + getOrCreateFastAgentSession, + type FastAgentReactionExternalInput, +} from '@roomote/cloud-agents/server'; import { handleTelegramCallbackQuery, @@ -135,15 +146,98 @@ telegram.post('/', async (c) => { if (!claimedReaction) { return c.json({ ok: true, duplicate: true }); } - if (!isNewTelegramThumbsUpReaction(messageReaction)) { - return c.json({ ok: true, ignored: 'unsupported_reaction' }); + const addedReactions = getNewTelegramMessageReactions(messageReaction); + if (addedReactions.length === 0) { + return c.json({ ok: true, ignored: 'reaction_removed_or_unchanged' }); + } + + if (isNewTelegramThumbsUpReaction(messageReaction)) { + const handled = await handleTelegramSuggestionReaction(messageReaction); + if (handled) { + return c.json({ ok: true, suggestionStarted: true }); + } + } + + const chatId = String(messageReaction.chat.id); + const messageId = String(messageReaction.message_id); + const threadId = messageReaction.message_thread_id + ? String(messageReaction.message_thread_id) + : undefined; + const fastSession = await findFastAgentSessionForProviderMessage({ + provider: 'telegram', + workspaceId: chatId, + channelId: chatId, + ...(threadId ? { threadId } : {}), + messageId, + }); + if (!fastSession) { + return c.json({ ok: true, ignored: 'reaction_target_not_fast' }); + } + if (!messageReaction.user) { + return c.json({ ok: true, ignored: 'reaction_user_missing' }); + } + + const senderUserId = await resolveTelegramSenderUserId( + String(messageReaction.user.id), + ); + if (!senderUserId) { + await postTelegramMessageBestEffort({ + chatId, + ...(threadId ? { threadId } : {}), + replyToMessageId: messageId, + text: 'Link your Roomote account to respond to Roomote from Telegram.', + }); + return c.json({ + ok: true, + queued: false, + reason: 'telegram_reactor_not_linked', + }); + } + if (fastSession.userId !== senderUserId) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); } - const handled = await handleTelegramSuggestionReaction(messageReaction); + const senderDisplayName = + [messageReaction.user.first_name, messageReaction.user.last_name] + .filter(Boolean) + .join(' ') + .trim() || + messageReaction.user.username?.trim() || + null; + const eventId = String(update.update_id); + const reactionInput: FastAgentReactionExternalInput = { + type: 'reaction_added', + provider: 'telegram', + reactions: addedReactions, + reactor: { + externalUserId: String(messageReaction.user.id), + ...(senderDisplayName ? { displayName: senderDisplayName } : {}), + }, + message: { + workspaceId: chatId, + channelId: chatId, + messageId, + ...(threadId ? { threadId } : {}), + }, + eventId, + }; + const queued = await queueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId: senderUserId, + senderDisplayName, + question: buildFastAgentReactionExternalInputQuestion(reactionInput), + currentMessageId: `telegram-reaction:${eventId}`, + replyToMessageId: messageId, + externalInput: reactionInput, + }); return c.json( - handled - ? { ok: true, suggestionStarted: true } - : { ok: true, ignored: 'reaction_target_not_suggestion' }, + queued + ? { ok: true, fastReactionQueued: true } + : { ok: true, ignored: 'telegram_fast_reaction_route_unavailable' }, ); } @@ -433,6 +527,101 @@ telegram.post('/', async (c) => { messageId: String(repliedToReportRootId), }) : null; + const replyToMessageId = repliedToReportRootId + ? String(repliedToReportRootId) + : undefined; + const hasMedia = Boolean( + message.photo?.length || message.document || message.audio || message.voice, + ); + const fastSession = !newTaskCommand + ? await findFastAgentSessionForProviderReply({ + provider: 'telegram', + workspaceId: metadata.communicationChannelId, + channelId: metadata.communicationChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + ...(replyToMessageId ? { replyToMessageId } : {}), + userId: senderUserId, + }) + : null; + if (!fastSession && replyToMessageId) { + const isKnownFastMessage = await isFastAgentProviderMessage({ + provider: 'telegram', + messageId: replyToMessageId, + workspaceId: metadata.communicationChannelId, + channelId: metadata.communicationChannelId, + }); + if (isKnownFastMessage) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_route_mismatch', + }); + } + } + if (fastSession) { + if (fastSession.userId !== senderUserId) { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_user_mismatch', + }); + } + if (fastSession.conversation.surface !== 'telegram') { + return c.json({ + ok: true, + queued: false, + reason: 'fast_session_surface_mismatch', + }); + } + + const fastMessage = hasMedia + ? await attachTelegramMediaToQueuedMessage({ + message, + queuedMessage: queuedMessage!, + ...(botToken ? { botToken } : {}), + }) + : queuedMessage!; + const question = fastMessage.text.trim(); + if (!question) { + return c.json({ ok: true, queued: false, reason: 'fast_message_empty' }); + } + await ackTelegramMessageBestEffort({ + chatId: metadata.communicationChannelId, + messageId: metadata.communicationMessageId, + }); + const senderDisplayName = + [message.from?.first_name, message.from?.last_name] + .filter(Boolean) + .join(' ') + .trim() || + message.from?.username?.trim() || + null; + void continueFastAgentSurfaceReply({ + sessionId: fastSession.id, + userId: senderUserId, + senderDisplayName, + question, + currentMessageId: metadata.communicationMessageId ?? fastMessage.ts, + ...(fastMessage.images ? { images: fastMessage.images } : {}), + }) + .then((continued) => { + if (!continued) { + apiLogger.warn( + `[telegram] Fast session ${fastSession.id} could not resolve an active delivery route`, + ); + } + }) + .catch((error) => { + apiLogger.error( + `[telegram] Fast session ${fastSession.id} continuation failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + return c.json({ ok: true, fastAnswered: true, fastContinued: true }); + } const activeRun = repliedToAutomationReport ? activeRunStatuses.some( (status) => status === repliedToAutomationReport.status, @@ -441,9 +630,6 @@ telegram.post('/', async (c) => { : undefined : await findActiveTelegramTaskRun(conversation); - const hasMedia = Boolean( - message.photo?.length || message.document || message.audio || message.voice, - ); const shouldProcessMedia = Boolean( activeRun || newTaskCommand || @@ -651,6 +837,68 @@ telegram.post('/', async (c) => { } } + if (!newTaskCommand) { + const providerConversationId = + metadata.communicationThreadId ?? + (isTelegramPrivateChat(message) + ? metadata.communicationChannelId + : queuedMessage.ts); + const conversation = { + surface: 'telegram' as const, + workspaceId: metadata.communicationChannelId, + conversationId: `${providerConversationId}:user:${senderUserId}`, + replyTarget: { + channelId: metadata.communicationChannelId, + ...(metadata.communicationThreadId + ? { threadId: metadata.communicationThreadId } + : {}), + }, + }; + + try { + const session = await getOrCreateFastAgentSession({ + userId: senderUserId, + conversation, + }); + void continueFastAgentSurfaceReply({ + sessionId: session.id, + userId: senderUserId, + senderDisplayName: + [message.from?.first_name, message.from?.last_name] + .filter(Boolean) + .join(' ') + .trim() || + message.from?.username?.trim() || + null, + question: queuedMessage.text.trim(), + currentMessageId: metadata.communicationMessageId ?? queuedMessage.ts, + ...(queuedMessage.images ? { images: queuedMessage.images } : {}), + }) + .then((continued) => { + if (!continued) { + apiLogger.warn( + `[telegram] Default Fast session ${session.id} could not resolve an active delivery route`, + ); + } + }) + .catch((error) => { + apiLogger.error( + `[telegram] Default Fast response failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + + return c.json({ ok: true, fastAnswered: true, fastDefaulted: true }); + } catch (error) { + apiLogger.warn( + `[telegram] Failed to initialize the default Fast session; falling back to task routing: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + let launch: Awaited>; try { launch = await startNewTelegramTask({ diff --git a/apps/api/src/handlers/telegram/setup-suggestions.ts b/apps/api/src/handlers/telegram/setup-suggestions.ts index 889ff9553..a275f25ba 100644 --- a/apps/api/src/handlers/telegram/setup-suggestions.ts +++ b/apps/api/src/handlers/telegram/setup-suggestions.ts @@ -163,6 +163,7 @@ export async function claimTelegramSuggestionLaunch(input: { investigationContext: string | null; targetRepositoryFullName: string | null; targetEnvironmentId?: string | null; + usesRouterLaunch: boolean; launchClaimedAt: Date; } | null> { // Scope: a suggestion card for this work item must have been posted in this @@ -200,6 +201,7 @@ export async function claimTelegramSuggestionLaunch(input: { investigationContext: routed ? null : claimed.investigationContext, targetRepositoryFullName: routed ? null : claimed.targetRepositoryFullName, targetEnvironmentId: routed ? null : claimed.targetEnvironmentId, + usesRouterLaunch: routed, launchClaimedAt: claimed.launchClaimedAt, }; } diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 2e855bdc8..cde48eee4 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -25,7 +25,8 @@ import { import type { Variables } from './types'; import { resolveApiCorsOrigin } from './cors'; import { createSingleLineWarnLogger } from './logging'; -import { captureApiException } from './monitoring/sentry'; +import { captureApiException, flushApiSentry } from './monitoring/sentry'; +import { installApiGracefulShutdown } from './graceful-shutdown'; import { requestObservabilityMiddleware, routePolicyMiddleware, @@ -280,6 +281,7 @@ export async function startApiServer({ const app = createApiApp(); const server = createAdaptorServer({ fetch: app.fetch }); const address = await listen(server, { port, hostname }); + installApiGracefulShutdown(server, { flushSentry: flushApiSentry }); if (Env.NODE_ENV === 'development') { showRoutes(app); diff --git a/apps/bullmq/src/fast-agent-parent-event-queue.test.ts b/apps/bullmq/src/fast-agent-parent-event-queue.test.ts new file mode 100644 index 000000000..a37f5b08a --- /dev/null +++ b/apps/bullmq/src/fast-agent-parent-event-queue.test.ts @@ -0,0 +1,93 @@ +const mocks = vi.hoisted(() => ({ + upsertJobScheduler: vi.fn(), + queueEventsOn: vi.fn(), + workerOn: vi.fn(), + processor: undefined as ((job: unknown) => Promise) | undefined, + recover: vi.fn(), + drain: vi.fn(), + BusyError: class FastAgentParentBusyError extends Error {}, + DelayedError: class DelayedError extends Error {}, +})); + +vi.mock('bullmq', () => ({ + DelayedError: mocks.DelayedError, + Queue: class Queue { + upsertJobScheduler = mocks.upsertJobScheduler; + }, + Worker: class Worker { + on = mocks.workerOn; + constructor(_name: string, processor: (job: unknown) => Promise) { + mocks.processor = processor; + } + }, + QueueEvents: class QueueEvents { + on = mocks.queueEventsOn; + }, +})); + +vi.mock('@roomote/sdk/server', () => ({ + FAST_AGENT_PARENT_EVENT_QUEUE_NAME: 'fast-agent-parent-events', + recoverPendingFastAgentParentEvents: mocks.recover, + drainFastAgentParentEvents: mocks.drain, + FastAgentParentBusyError: mocks.BusyError, +})); + +vi.mock('./redis', () => ({ getRedis: vi.fn(() => ({})) })); + +import { startFastAgentParentEventQueue } from './fast-agent-parent-event-queue'; + +describe('startFastAgentParentEventQueue', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.processor = undefined; + mocks.upsertJobScheduler.mockResolvedValue(undefined); + mocks.recover.mockResolvedValue(0); + mocks.drain.mockResolvedValue(undefined); + }); + + it('recovers persisted rows and drains ordinary wakeups', async () => { + await startFastAgentParentEventQueue(); + + expect(mocks.upsertJobScheduler).toHaveBeenCalledWith( + 'fast-agent-parent-event-recovery', + { every: 60_000 }, + { name: 'recover-pending', data: { recovery: true } }, + ); + expect(mocks.recover).toHaveBeenCalledOnce(); + + const request = { conversationId: 'conversation-1', eventKey: 'event-1' }; + await mocks.processor?.({ name: 'deliver', data: request }); + expect(mocks.drain).toHaveBeenCalledWith(request); + }); + + it('runs the periodic recovery sweep without invoking a parent drain', async () => { + await startFastAgentParentEventQueue(); + mocks.recover.mockClear(); + + await mocks.processor?.({ + name: 'recover-pending', + data: { recovery: true }, + }); + expect(mocks.recover).toHaveBeenCalledOnce(); + expect(mocks.drain).not.toHaveBeenCalled(); + }); + + it('delays a busy parent without consuming a worker attempt', async () => { + await startFastAgentParentEventQueue(); + mocks.drain.mockRejectedValueOnce(new mocks.BusyError()); + const moveToDelayed = vi.fn().mockResolvedValue(undefined); + + await expect( + mocks.processor?.({ + name: 'deliver', + data: { conversationId: 'conversation-1', eventKey: 'event-1' }, + token: 'worker-token', + moveToDelayed, + }), + ).rejects.toBeInstanceOf(mocks.DelayedError); + expect(moveToDelayed).toHaveBeenCalledWith( + expect.any(Number), + 'worker-token', + ); + }); +}); diff --git a/apps/bullmq/src/fast-agent-parent-event-queue.ts b/apps/bullmq/src/fast-agent-parent-event-queue.ts new file mode 100644 index 000000000..6599b7ef5 --- /dev/null +++ b/apps/bullmq/src/fast-agent-parent-event-queue.ts @@ -0,0 +1,88 @@ +import { DelayedError, Queue, QueueEvents, Worker, type Job } from 'bullmq'; + +import { + drainFastAgentParentEvents, + FastAgentParentBusyError, + FAST_AGENT_PARENT_EVENT_QUEUE_NAME, + recoverPendingFastAgentParentEvents, + type FastAgentParentEventQueueRequest, +} from '@roomote/sdk/server'; + +import { getRedis } from './redis'; + +const RECOVERY_JOB_NAME = 'recover-pending'; +const RECOVERY_SCHEDULER_ID = 'fast-agent-parent-event-recovery'; +const RECOVERY_INTERVAL_MS = 60_000; +const BUSY_PARENT_RETRY_DELAY_MS = 1_000; + +type FastAgentParentEventJob = + | FastAgentParentEventQueueRequest + | { recovery: true }; + +async function processJob(job: Job) { + if (job.name === RECOVERY_JOB_NAME) { + await recoverPendingFastAgentParentEvents(); + return; + } + if ('recovery' in job.data) return; + try { + await drainFastAgentParentEvents(job.data); + } catch (error) { + if (!(error instanceof FastAgentParentBusyError) || !job.token) { + throw error; + } + await job.moveToDelayed(Date.now() + BUSY_PARENT_RETRY_DELAY_MS, job.token); + throw new DelayedError(); + } +} + +export async function startFastAgentParentEventQueue() { + const connection = getRedis(); + const queue = new Queue( + FAST_AGENT_PARENT_EVENT_QUEUE_NAME, + { + connection, + defaultJobOptions: { + attempts: 3, + backoff: { type: 'exponential', delay: 2_000 }, + removeOnComplete: true, + removeOnFail: true, + }, + }, + ); + + await queue.upsertJobScheduler( + RECOVERY_SCHEDULER_ID, + { every: RECOVERY_INTERVAL_MS }, + { name: RECOVERY_JOB_NAME, data: { recovery: true } }, + ); + await recoverPendingFastAgentParentEvents(); + + const worker = new Worker( + FAST_AGENT_PARENT_EVENT_QUEUE_NAME, + processJob, + // Busy parents immediately move back to delayed; no conversation can park + // the worker pool while it owns an active Fast turn. + { connection, concurrency: 20, autorun: true }, + ); + + worker.on('failed', (job, error) => + console.error( + `[FastAgentParentEventQueue] job ${job?.id} failed: ${error.message}`, + ), + ); + worker.on('error', (error) => + console.error('[FastAgentParentEventQueue] worker error:', error), + ); + + const queueEvents = new QueueEvents(FAST_AGENT_PARENT_EVENT_QUEUE_NAME, { + connection, + }); + queueEvents.on('failed', ({ jobId, failedReason }) => + console.error( + `[FastAgentParentEventQueue] job ${jobId} failed: ${failedReason}`, + ), + ); + + return { queue, worker, queueEvents }; +} diff --git a/apps/bullmq/src/index.ts b/apps/bullmq/src/index.ts index 8e5fc9172..dd8fd03bc 100644 --- a/apps/bullmq/src/index.ts +++ b/apps/bullmq/src/index.ts @@ -50,6 +50,7 @@ import { startActivePrReviewFollowUpQueue } from './active-pr-review-follow-up-q import { startPullRequestMergeabilityCheckQueue } from './pull-request-mergeability-check-queue'; import { startTaskSleepQueue } from './task-sleep-queue'; import { startAutomationRecommendationsQueue } from './automation-recommendations-queue'; +import { startFastAgentParentEventQueue } from './fast-agent-parent-event-queue'; // Resolve auto-generated auth keypairs before any queue worker starts so // scheduled jobs that sign tokens observe the resolved keys. @@ -187,6 +188,11 @@ const { worker: pullRequestMergeabilityCheckWorker, queueEvents: pullRequestMergeabilityCheckQueueEvents, } = startPullRequestMergeabilityCheckQueue(); +const { + queue: fastAgentParentEventQueue, + worker: fastAgentParentEventWorker, + queueEvents: fastAgentParentEventQueueEvents, +} = await startFastAgentParentEventQueue(); const serverAdapter = new HonoAdapter(serveStatic); @@ -226,6 +232,7 @@ createBullBoard({ new BullMQAdapter(pullRequestMergeabilityCheckQueue, { readOnlyMode: false, }), + new BullMQAdapter(fastAgentParentEventQueue, { readOnlyMode: false }), ], serverAdapter, }); @@ -399,6 +406,9 @@ async function gracefulShutdown() { await pullRequestMergeabilityCheckWorker.close(); await pullRequestMergeabilityCheckQueueEvents.close(); await pullRequestMergeabilityCheckQueue.close(); + await fastAgentParentEventWorker.close(); + await fastAgentParentEventQueueEvents.close(); + await fastAgentParentEventQueue.close(); await discordGatewaySupervisor.stop(); await closeRedis(); } catch (error) { diff --git a/apps/bullmq/src/jobs/active-pr-review-follow-up.test.ts b/apps/bullmq/src/jobs/active-pr-review-follow-up.test.ts index 8d8114fdf..337bb4f71 100644 --- a/apps/bullmq/src/jobs/active-pr-review-follow-up.test.ts +++ b/apps/bullmq/src/jobs/active-pr-review-follow-up.test.ts @@ -4,31 +4,60 @@ const { mockBuildPrompt, mockEnqueueTask, mockFindFirstRun, + mockFindFallbackRun, + mockFindFirstRepository, mockGetTaskGoalForRun, + mockIsNull, mockSendPrompt, mockUpdateWhere, mockWithSandboxServerRpcClient, + mockAcquireGithubPrReviewLifecycleLock, + mockReleaseGithubPrReviewLifecycleLock, + mockTransferGithubPrReviewCheckToRun, + mockReconcileGithubPrReviewCheckForRun, + MockSnapshotResumeAlreadyExistsError, } = vi.hoisted(() => ({ mockBuildPrompt: vi.fn(), mockEnqueueTask: vi.fn(), mockFindFirstRun: vi.fn(), + mockFindFallbackRun: vi.fn(), + mockFindFirstRepository: vi.fn(), mockGetTaskGoalForRun: vi.fn(), + mockIsNull: vi.fn((...args: unknown[]) => args), mockSendPrompt: vi.fn(), mockUpdateWhere: vi.fn(), mockWithSandboxServerRpcClient: vi.fn(), + mockAcquireGithubPrReviewLifecycleLock: vi.fn(), + mockReleaseGithubPrReviewLifecycleLock: Object.assign(vi.fn(), { + signal: new AbortController().signal, + }), + mockTransferGithubPrReviewCheckToRun: vi.fn(), + mockReconcileGithubPrReviewCheckForRun: vi.fn(), + MockSnapshotResumeAlreadyExistsError: class extends Error { + constructor(public readonly existingRunId: number) { + super(`Snapshot resume run ${existingRunId} already exists.`); + } + }, })); vi.mock('@roomote/cloud-agents/server', () => ({ buildGitHubPrSynchronizeFollowUpMessage: (...args: unknown[]) => mockBuildPrompt(...args), enqueueTask: (...args: unknown[]) => mockEnqueueTask(...args), + SnapshotResumeAlreadyExistsError: MockSnapshotResumeAlreadyExistsError, })); vi.mock('@roomote/db/server', () => ({ db: { query: { taskRuns: { - findFirst: (...args: unknown[]) => mockFindFirstRun(...args), + findFirst: (input: { columns?: Record }) => + input.columns && Object.keys(input.columns).length === 1 + ? mockFindFallbackRun(input) + : mockFindFirstRun(input), + }, + repositories: { + findFirst: (...args: unknown[]) => mockFindFirstRepository(...args), }, }, update: vi.fn(() => ({ @@ -38,13 +67,23 @@ vi.mock('@roomote/db/server', () => ({ })), }, eq: vi.fn((...args: unknown[]) => args), + and: vi.fn((...args: unknown[]) => args), + isNull: (...args: unknown[]) => mockIsNull(...args), + sql: vi.fn((...args: unknown[]) => args), getTaskGoalForRun: (...args: unknown[]) => mockGetTaskGoalForRun(...args), + repositories: { id: 'repositories.id' }, taskPullRequests: { taskId: 'taskPullRequests.taskId' }, - taskRuns: { id: 'taskRuns.id' }, + taskRuns: { + id: 'taskRuns.id', + taskId: 'taskRuns.taskId', + payload: 'taskRuns.payload', + canceledAt: 'taskRuns.canceledAt', + }, })); vi.mock('@roomote/sdk/server', () => ({ activePrReviewFollowUpRequestSchema: z.object({ + installationId: z.number().optional(), runId: z.number(), taskId: z.string(), sandboxServerUrl: z.string(), @@ -54,6 +93,12 @@ vi.mock('@roomote/sdk/server', () => ({ eventHeadSha: z.string(), fallback: z.any(), }), + acquireGithubPrReviewLifecycleLock: (...args: unknown[]) => + mockAcquireGithubPrReviewLifecycleLock(...args), + transferGithubPrReviewCheckToRun: (...args: unknown[]) => + mockTransferGithubPrReviewCheckToRun(...args), + reconcileGithubPrReviewCheckForRun: (...args: unknown[]) => + mockReconcileGithubPrReviewCheckForRun(...args), withSandboxServerRpcClient: (...args: unknown[]) => mockWithSandboxServerRpcClient(...args), })); @@ -65,6 +110,7 @@ import { RunStatus, TaskPayloadKind } from '@roomote/types'; import { activePrReviewFollowUpJob } from './active-pr-review-follow-up'; const data = { + installationId: 1, runId: 100, taskId: 'task-100', sandboxServerUrl: 'https://sandbox.example.test', @@ -88,6 +134,7 @@ const data = { provider: 'github' as const, host: 'github.com', repository: 'owner/repo', + repositoryId: 'repo-id', prNumber: 42, prUrl: 'https://github.com/owner/repo/pull/42', prSha: 'new-head', @@ -95,8 +142,10 @@ const data = { }, }; -function makeJob() { - return { data } as unknown as Job; +function makeJob(overrides: Partial = {}) { + return { + data: { ...data, ...overrides }, + } as unknown as Job; } describe('activePrReviewFollowUpJob', () => { @@ -111,6 +160,16 @@ describe('activePrReviewFollowUpJob', () => { ({ call }: { call: (client: unknown) => Promise }) => call({ commands: { sendPrompt: { mutate: mockSendPrompt } } }), ); + mockAcquireGithubPrReviewLifecycleLock.mockResolvedValue( + mockReleaseGithubPrReviewLifecycleLock, + ); + mockTransferGithubPrReviewCheckToRun.mockResolvedValue(undefined); + mockReconcileGithubPrReviewCheckForRun.mockResolvedValue(undefined); + mockFindFallbackRun.mockResolvedValue(null); + mockFindFirstRepository.mockResolvedValue({ + id: 'repo-id', + githubInstallation: { installationId: 1 }, + }); }); it('sends a hidden follow-up that keeps the active task alive', async () => { @@ -136,6 +195,7 @@ describe('activePrReviewFollowUpJob', () => { }); expect(mockUpdateWhere).toHaveBeenCalledOnce(); expect(mockEnqueueTask).not.toHaveBeenCalled(); + expect(mockTransferGithubPrReviewCheckToRun).not.toHaveBeenCalled(); }); it('includes active goal context in a live review follow-up', async () => { @@ -240,6 +300,23 @@ describe('activePrReviewFollowUpJob', () => { }), ); expect(mockUpdateWhere).toHaveBeenCalledOnce(); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledWith({ + installationId: 1, + repository: 'owner/repo', + prNumber: 42, + taskId: 'task-100', + previousRunId: 100, + newRunId: 200, + signal: mockReleaseGithubPrReviewLifecycleLock.signal, + }); + expect(mockEnqueueTask.mock.invocationCallOrder[0]).toBeLessThan( + mockTransferGithubPrReviewCheckToRun.mock.invocationCallOrder[0]!, + ); + expect( + mockTransferGithubPrReviewCheckToRun.mock.invocationCallOrder[0], + ).toBeLessThan( + mockReleaseGithubPrReviewLifecycleLock.mock.invocationCallOrder[0]!, + ); }); it('resumes the same task when the active review finishes during debounce', async () => { @@ -271,10 +348,16 @@ describe('activePrReviewFollowUpJob', () => { }), ); expect(mockUpdateWhere).toHaveBeenCalledOnce(); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledWith( + expect.objectContaining({ + previousRunId: 100, + newRunId: 200, + }), + ); expect(mockSendPrompt).not.toHaveBeenCalled(); }); - it('starts a sync review when the completed run has no resumable snapshot', async () => { + it('starts a sync review without reusing a canceled fallback attempt', async () => { mockFindFirstRun.mockResolvedValue({ id: 100, taskId: 'task-100', @@ -291,7 +374,14 @@ describe('activePrReviewFollowUpJob', () => { expect(mockEnqueueTask).toHaveBeenCalledWith({ existingTaskId: 'task-100', - task: data.fallback.task, + task: { + ...data.fallback.task, + payload: { + ...data.fallback.task.payload, + launchIdempotencyKey: + 'github-pr-review-fallback:task-100:100:new-head', + }, + }, initiator: { kind: 'automation', key: 'review_code', @@ -302,6 +392,155 @@ describe('activePrReviewFollowUpJob', () => { trigger: 'webhook', prLinkage: data.fallback.prLinkage, }); - expect(mockUpdateWhere).not.toHaveBeenCalled(); + expect(mockUpdateWhere).toHaveBeenCalledOnce(); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledWith({ + installationId: 1, + repository: 'owner/repo', + prNumber: 42, + taskId: 'task-100', + previousRunId: 100, + newRunId: 200, + signal: mockReleaseGithubPrReviewLifecycleLock.signal, + }); + expect(mockIsNull).toHaveBeenCalledWith('taskRuns.canceledAt'); + }); + + it.each([ + ['no snapshot', null], + ['snapshot resume', 'snapshot-100'], + ])( + 'reuses fallback B after transfer fails once (%s)', + async (_label, snapshotId) => { + mockFindFirstRun.mockResolvedValue({ + id: 100, + taskId: 'task-100', + status: RunStatus.Completed, + sandboxServerUrl: null, + snapshotId, + snapshotCreatedAt: snapshotId ? new Date() : null, + port: snapshotId ? 3000 : null, + payload: { repo: 'owner/repo' }, + actingUserId: 'user-1', + }); + mockFindFallbackRun + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 200 }); + mockTransferGithubPrReviewCheckToRun + .mockRejectedValueOnce(new Error('transfer failed')) + .mockResolvedValueOnce(undefined); + + await expect(activePrReviewFollowUpJob(makeJob())).rejects.toThrow( + 'transfer failed', + ); + await activePrReviewFollowUpJob(makeJob()); + + expect(mockEnqueueTask).toHaveBeenCalledOnce(); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledTimes(2); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenLastCalledWith( + expect.objectContaining({ newRunId: 200 }), + ); + expect(mockReconcileGithubPrReviewCheckForRun).toHaveBeenCalledWith( + expect.objectContaining({ runId: 200 }), + ); + expect(mockUpdateWhere).toHaveBeenCalledOnce(); + }, + ); + + it('reuses fallback B when linked-head persistence fails after transfer', async () => { + mockFindFirstRun.mockResolvedValue({ + id: 100, + taskId: 'task-100', + status: RunStatus.Completed, + sandboxServerUrl: null, + snapshotId: null, + snapshotCreatedAt: null, + port: null, + payload: { repo: 'owner/repo' }, + actingUserId: null, + }); + mockFindFallbackRun + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ id: 200 }); + mockUpdateWhere + .mockRejectedValueOnce(new Error('head update failed')) + .mockResolvedValueOnce(undefined); + + await expect(activePrReviewFollowUpJob(makeJob())).rejects.toThrow( + 'head update failed', + ); + await activePrReviewFollowUpJob(makeJob()); + + expect(mockEnqueueTask).toHaveBeenCalledOnce(); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledTimes(2); + expect(mockReconcileGithubPrReviewCheckForRun).toHaveBeenCalledTimes(2); + expect(mockUpdateWhere).toHaveBeenCalledTimes(2); + }); + + it('recovers the existing SnapshotResume run when enqueue reports a duplicate', async () => { + mockFindFirstRun.mockResolvedValue({ + id: 100, + taskId: 'task-100', + status: RunStatus.Completed, + sandboxServerUrl: null, + snapshotId: 'snapshot-100', + snapshotCreatedAt: new Date(), + port: 3000, + payload: { repo: 'owner/repo' }, + actingUserId: 'user-1', + }); + mockEnqueueTask.mockRejectedValueOnce( + new MockSnapshotResumeAlreadyExistsError(200), + ); + + await activePrReviewFollowUpJob(makeJob()); + + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledWith( + expect.objectContaining({ newRunId: 200 }), + ); + }); + + it('resolves installation context for legacy jobs before launching fallback B', async () => { + mockFindFirstRun.mockResolvedValue({ + id: 100, + taskId: 'task-100', + status: RunStatus.Completed, + sandboxServerUrl: null, + snapshotId: null, + snapshotCreatedAt: null, + port: null, + payload: { repo: 'owner/repo' }, + actingUserId: null, + }); + + await activePrReviewFollowUpJob( + makeJob({ installationId: undefined as never }), + ); + + expect(mockFindFirstRepository).toHaveBeenCalledOnce(); + expect(mockTransferGithubPrReviewCheckToRun).toHaveBeenCalledWith( + expect.objectContaining({ installationId: 1, newRunId: 200 }), + ); + }); + + it('retries when fallback ownership cannot acquire the lifecycle lock', async () => { + mockFindFirstRun.mockResolvedValue({ + id: 100, + taskId: 'task-100', + status: RunStatus.Completed, + sandboxServerUrl: null, + snapshotId: null, + snapshotCreatedAt: null, + port: null, + payload: { repo: 'owner/repo' }, + actingUserId: null, + }); + mockAcquireGithubPrReviewLifecycleLock.mockResolvedValueOnce(null); + + await expect(activePrReviewFollowUpJob(makeJob())).rejects.toThrow( + 'Timed out serializing PR review fallback for owner/repo#42', + ); + + expect(mockEnqueueTask).not.toHaveBeenCalled(); + expect(mockTransferGithubPrReviewCheckToRun).not.toHaveBeenCalled(); }); }); diff --git a/apps/bullmq/src/jobs/active-pr-review-follow-up.ts b/apps/bullmq/src/jobs/active-pr-review-follow-up.ts index 3c4ce0d29..d1dedc89e 100644 --- a/apps/bullmq/src/jobs/active-pr-review-follow-up.ts +++ b/apps/bullmq/src/jobs/active-pr-review-follow-up.ts @@ -3,17 +3,25 @@ import { Job } from 'bullmq'; import { buildGitHubPrSynchronizeFollowUpMessage, enqueueTask, + SnapshotResumeAlreadyExistsError, } from '@roomote/cloud-agents/server'; import { + and, db, eq, getTaskGoalForRun, + isNull, + repositories, + sql, taskPullRequests, taskRuns, } from '@roomote/db/server'; import { + acquireGithubPrReviewLifecycleLock, activePrReviewFollowUpRequestSchema, + reconcileGithubPrReviewCheckForRun, type ActivePrReviewFollowUpRequest, + transferGithubPrReviewCheckToRun, withSandboxServerRpcClient, } from '@roomote/sdk/server'; import { @@ -47,6 +55,84 @@ async function updateLinkedHead( .where(eq(taskPullRequests.taskId, taskId)); } +async function launchFallbackWithCheckTransfer( + data: ActivePrReviewFollowUpRequest, + launch: (launchIdempotencyKey: string) => Promise<{ id: number }>, +): Promise { + const releaseLifecycleLock = await acquireGithubPrReviewLifecycleLock( + data.repository, + data.prNumber, + ); + if (!releaseLifecycleLock) { + throw new Error( + `Timed out serializing PR review fallback for ${data.repository}#${data.prNumber}`, + ); + } + + try { + releaseLifecycleLock.signal.throwIfAborted(); + const repositoryId = data.fallback.prLinkage.repositoryId; + const installationId = + data.installationId ?? + (repositoryId + ? ( + await db.query.repositories.findFirst({ + where: eq(repositories.id, repositoryId), + columns: { id: true }, + with: { + githubInstallation: { columns: { installationId: true } }, + }, + }) + )?.githubInstallation?.installationId + : undefined); + if (!installationId) { + throw new Error( + `Could not resolve GitHub installation for PR review fallback ${data.repository}#${data.prNumber}`, + ); + } + + const launchIdempotencyKey = [ + 'github-pr-review-fallback', + data.taskId, + data.runId, + data.eventHeadSha, + ].join(':'); + const existingFallback = await db.query.taskRuns.findFirst({ + where: and( + eq(taskRuns.taskId, data.taskId), + sql`${taskRuns.payload}->>'launchIdempotencyKey' = ${launchIdempotencyKey}`, + isNull(taskRuns.canceledAt), + ), + columns: { id: true }, + }); + const fallbackRun = + existingFallback ?? (await launch(launchIdempotencyKey)); + releaseLifecycleLock.signal.throwIfAborted(); + + await transferGithubPrReviewCheckToRun({ + installationId, + repository: data.repository, + prNumber: data.prNumber, + taskId: data.taskId, + previousRunId: data.runId, + newRunId: fallbackRun.id, + signal: releaseLifecycleLock.signal, + }); + await reconcileGithubPrReviewCheckForRun({ + installationId, + repository: data.repository, + prNumber: data.prNumber, + taskId: data.taskId, + runId: fallbackRun.id, + signal: releaseLifecycleLock.signal, + }); + + await updateLinkedHead(data.taskId, data.eventHeadSha); + } finally { + await releaseLifecycleLock(); + } +} + export const activePrReviewFollowUpJob = async ( job: ActivePrReviewFollowUpJob, ): Promise => { @@ -120,30 +206,49 @@ export const activePrReviewFollowUpJob = async ( resumePromptClientMessageId: buildClientMessageId(data), } satisfies TaskPayload; - await enqueueTask({ - task: { - type: TaskPayloadKind.SnapshotResume, - sourceSnapshotId: run.snapshotId, - sourceRunId: run.id, - payload: resumePayload, + await launchFallbackWithCheckTransfer( + data, + async (launchIdempotencyKey) => { + try { + return await enqueueTask({ + task: { + type: TaskPayloadKind.SnapshotResume, + sourceSnapshotId: run.snapshotId!, + sourceRunId: run.id, + payload: { ...resumePayload, launchIdempotencyKey }, + }, + actingUserId: run.actingUserId, + }); + } catch (error) { + if (error instanceof SnapshotResumeAlreadyExistsError) { + return { id: error.existingRunId }; + } + throw error; + } }, - actingUserId: run.actingUserId, - }); - await updateLinkedHead(run.taskId, data.eventHeadSha); + ); return; } - await enqueueTask({ - existingTaskId: run.taskId, - task: data.fallback.task, - initiator: { - kind: 'automation', - key: 'review_code', - actor: data.fallback.initiatorActor, - }, - workflow: 'pr_review', - surface: 'github', - trigger: 'webhook', - prLinkage: data.fallback.prLinkage, - }); + await launchFallbackWithCheckTransfer(data, (launchIdempotencyKey) => + enqueueTask({ + existingTaskId: run.taskId, + task: { + ...data.fallback.task, + payload: { + ...data.fallback.task.payload, + launchIdempotencyKey, + }, + }, + initiator: { + kind: 'automation', + key: 'review_code', + actor: data.fallback.initiatorActor, + }, + workflow: 'pr_review', + surface: 'github', + trigger: 'webhook', + prLinkage: data.fallback.prLinkage, + }), + ); }; diff --git a/apps/bullmq/src/jobs/pr-review-notification.test.ts b/apps/bullmq/src/jobs/pr-review-notification.test.ts index 19a10881a..0c0995084 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.test.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.test.ts @@ -10,6 +10,9 @@ const { mockPrepareDelivery, mockPrepareCanonical, mockBeginCanonicalPrompt, + mockBeginCanonicalWebPrompt, + mockBeginCanonicalWebAutoDispatch, + mockReleaseCanonicalWebAutoDispatch, mockBeginCanonicalAutoDispatch, mockCompleteCanonicalAutoDispatch, mockRecordDelivery, @@ -40,6 +43,9 @@ const { mockPrepareDelivery: vi.fn(), mockPrepareCanonical: vi.fn(), mockBeginCanonicalPrompt: vi.fn(), + mockBeginCanonicalWebPrompt: vi.fn(), + mockBeginCanonicalWebAutoDispatch: vi.fn(), + mockReleaseCanonicalWebAutoDispatch: vi.fn(), mockBeginCanonicalAutoDispatch: vi.fn(), mockCompleteCanonicalAutoDispatch: vi.fn(), mockRecordDelivery: vi.fn(), @@ -185,6 +191,9 @@ vi.mock('@roomote/sdk/server', () => ({ preparePrReviewNotificationDelivery: mockPrepareDelivery, prepareCanonicalPrReviewNotificationRequest: mockPrepareCanonical, beginCanonicalPrReviewPrompt: mockBeginCanonicalPrompt, + beginCanonicalPrReviewWebPrompt: mockBeginCanonicalWebPrompt, + beginCanonicalPrReviewWebAutoDispatch: mockBeginCanonicalWebAutoDispatch, + releaseCanonicalPrReviewWebAutoDispatch: mockReleaseCanonicalWebAutoDispatch, beginCanonicalPrReviewAutoDispatch: mockBeginCanonicalAutoDispatch, completeCanonicalPrReviewAutoDispatch: mockCompleteCanonicalAutoDispatch, recordPrReviewNotificationDeliveryBestEffort: mockRecordDelivery, @@ -231,6 +240,9 @@ describe('prReviewNotificationJob', () => { mockRenewLease.mockResolvedValue(true); mockPrepareCanonical.mockResolvedValue(true); mockBeginCanonicalPrompt.mockResolvedValue(true); + mockBeginCanonicalWebPrompt.mockResolvedValue(true); + mockBeginCanonicalWebAutoDispatch.mockResolvedValue(true); + mockReleaseCanonicalWebAutoDispatch.mockResolvedValue(true); mockBeginCanonicalAutoDispatch.mockResolvedValue(true); mockCompleteCanonicalAutoDispatch.mockResolvedValue(true); @@ -267,7 +279,7 @@ describe('prReviewNotificationJob', () => { }, text: 'formatted-message', }); - mockRecordDelivery.mockResolvedValue(undefined); + mockRecordDelivery.mockResolvedValue(true); mockNotifyFastAgentParent.mockResolvedValue(false); mockAttachPendingPrReviewActionMessage.mockResolvedValue({ attached: true, @@ -1483,6 +1495,48 @@ describe('prReviewNotificationJob', () => { }); }); + it('publishes an actionable canonical offer for a web-only standard task', async () => { + const deliveryId = '11111111-1111-4111-8111-111111111111'; + const leaseToken = '22222222-2222-4222-8222-222222222222'; + mockPrepareDelivery.mockResolvedValue({ + post: true, + route: null, + text: 'Review feedback remains.', + followUpQuestion: 'Would you like me to resolve these issues?', + followUpPrompt: 'Resolve the review feedback.', + }); + + await prReviewNotificationJob( + makeJob({ + ownershipVersion: 'canonical', + deliveryId, + deliveryState: 'claimed', + deliveryIds: [deliveryId], + leaseToken, + }) as never, + ); + + expect(mockBeginCanonicalWebPrompt).toHaveBeenCalledWith({ + request: expect.objectContaining({ deliveryId }), + followUpPrompt: 'Resolve the review feedback.', + }); + expect(mockRecordDelivery).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-1', + reviewAction: { + deliveryId, + question: 'Would you like me to resolve these issues?', + }, + }), + ); + expect(mockAttachPendingPrReviewActionMessage).toHaveBeenCalledWith( + deliveryId, + deliveryId, + { leaseToken }, + ); + expect(mockFinalize).not.toHaveBeenCalled(); + }); + it('skips without posting when the notification is not worth sending', async () => { mockPrepareDelivery.mockResolvedValue({ post: false, @@ -1584,6 +1638,211 @@ describe('prReviewNotificationJob', () => { expect(mockFinalize).not.toHaveBeenCalled(); }); + it('publishes a canonical action offer for a web Fast parent', async () => { + const deliveryId = '77777777-7777-4777-8777-777777777777'; + const leaseToken = '88888888-8888-4888-8888-888888888888'; + mockFindFirstTaskRun.mockResolvedValue({ + id: 1, + taskId: 'task-1', + payload: { + fastAgentParent: { + sessionId: '99999999-9999-4999-8999-999999999999', + conversation: { + surface: 'web', + workspaceId: 'user-1', + conversationId: 'session-1', + }, + }, + }, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + workerHeartbeatAt: new Date(), + }); + mockPrepareDelivery.mockResolvedValue({ + post: true, + route: null, + text: 'Review feedback remains.', + followUpQuestion: 'Resolve it?', + followUpPrompt: 'Resolve the review feedback.', + }); + mockNotifyFastAgentParent.mockResolvedValue(true); + + await prReviewNotificationJob( + makeJob({ + ownershipVersion: 'canonical', + deliveryId, + notificationUnitId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + deliveryState: 'claimed', + destinationKey: '["web","user-1","session-1"]', + dispatchKey: `pr-review-delivery:${deliveryId}`, + deliveryIds: [deliveryId], + leaseToken, + events, + }) as never, + ); + + expect(mockBeginCanonicalWebPrompt).toHaveBeenCalledWith({ + request: expect.objectContaining({ deliveryId }), + followUpPrompt: 'Resolve the review feedback.', + }); + expect(mockNotifyFastAgentParent).toHaveBeenCalledWith( + expect.objectContaining({ reviewActionDeliveryId: deliveryId }), + ); + expect(mockAttachPendingPrReviewActionMessage).toHaveBeenCalledWith( + deliveryId, + deliveryId, + { leaseToken }, + ); + expect(mockFinalize).not.toHaveBeenCalled(); + }); + + it('auto-dispatches opted-in feedback for a web Fast parent', async () => { + const deliveryId = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + const leaseToken = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; + const destinationKey = '["web","user-1","session-1"]'; + mockFindFirstTaskRun.mockResolvedValue({ + id: 1, + taskId: 'task-1', + payload: { + fastAgentParent: { + sessionId: '99999999-9999-4999-8999-999999999999', + conversation: { + surface: 'web', + workspaceId: 'user-1', + conversationId: 'session-1', + }, + }, + }, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + workerHeartbeatAt: new Date(), + }); + mockPrepareDelivery.mockResolvedValue({ + post: true, + route: null, + text: 'Review feedback remains.', + followUpQuestion: 'Resolve it?', + followUpPrompt: 'Resolve the review feedback.', + }); + mockFindAutoHandlePrReviewFeedbackPreference.mockResolvedValue({ + taskId: 'task-1', + userId: 'user-1', + destinationKey, + }); + mockNotifyFastAgentParent.mockResolvedValue(true); + mockDispatchFollowUp.mockResolvedValue({ outcome: 'resumed', runId: 99 }); + + await prReviewNotificationJob( + makeJob({ + ownershipVersion: 'canonical', + deliveryId, + notificationUnitId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + deliveryState: 'claimed', + destinationKey, + dispatchKey: `pr-review-delivery:${deliveryId}`, + deliveryIds: [deliveryId], + leaseToken, + events, + }) as never, + ); + + expect(mockBeginCanonicalWebAutoDispatch).toHaveBeenCalledWith({ + request: expect.objectContaining({ deliveryId }), + followUpPrompt: 'Resolve the review feedback.', + targetTaskId: 'task-1', + actingUserId: 'user-1', + }); + expect(mockDispatchFollowUp).toHaveBeenCalledWith({ + provider: 'web', + taskId: 'task-1', + followUpPrompt: 'Resolve the review feedback.', + actingUserId: 'user-1', + idempotencyKey: `pr-review-delivery:${deliveryId}`, + }); + expect(mockCompleteCanonicalAutoDispatch).toHaveBeenCalledWith({ + request: expect.objectContaining({ deliveryId }), + runId: 99, + }); + expect(mockBeginCanonicalWebPrompt).not.toHaveBeenCalled(); + expect(mockAttachPendingPrReviewActionMessage).not.toHaveBeenCalled(); + }); + + it('publishes an interactive web fallback after auto-dispatch retries expire', async () => { + const deliveryId = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; + const leaseToken = 'ffffffff-ffff-4fff-8fff-ffffffffffff'; + const destinationKey = '["web","user-1","session-1"]'; + mockFindFirstTaskRun.mockResolvedValue({ + id: 1, + taskId: 'task-1', + payload: { + fastAgentParent: { + sessionId: '99999999-9999-4999-8999-999999999999', + conversation: { + surface: 'web', + workspaceId: 'user-1', + conversationId: 'session-1', + }, + }, + }, + status: RunStatus.Idle, + taskPhase: 'waiting_for_prompt', + workerHeartbeatAt: new Date(), + }); + mockPrepareDelivery.mockResolvedValue({ + post: true, + route: null, + text: 'Review feedback remains.', + followUpQuestion: 'Resolve it?', + followUpPrompt: 'Resolve the review feedback.', + }); + mockFindAutoHandlePrReviewFeedbackPreference.mockResolvedValue({ + taskId: 'task-1', + userId: 'user-1', + destinationKey, + }); + mockNotifyFastAgentParent.mockResolvedValue(true); + mockDispatchFollowUp.mockResolvedValue({ outcome: 'unavailable' }); + + await prReviewNotificationJob( + makeJob({ + ownershipVersion: 'canonical', + deliveryId, + notificationUnitId: '12121212-1212-4212-8212-121212121212', + deliveryState: 'auto_dispatch_pending', + targetTaskId: 'task-1', + actingUserId: 'user-1', + destinationKey, + dispatchKey: `pr-review-delivery:${deliveryId}`, + deliveryIds: [deliveryId], + leaseToken, + deferrals: 3, + events, + }) as never, + ); + + expect(mockReleaseCanonicalWebAutoDispatch).toHaveBeenCalledWith( + expect.objectContaining({ deliveryId }), + ); + expect(mockBeginCanonicalWebPrompt).toHaveBeenCalledWith({ + request: expect.objectContaining({ deliveryId }), + followUpPrompt: 'Resolve the review feedback.', + }); + expect(mockNotifyFastAgentParent).toHaveBeenCalledTimes(2); + expect(mockNotifyFastAgentParent).toHaveBeenLastCalledWith( + expect.objectContaining({ + suggestedActionQuestion: 'Resolve it?', + suggestedActionPrompt: 'Resolve the review feedback.', + reviewActionDeliveryId: deliveryId, + }), + ); + expect(mockAttachPendingPrReviewActionMessage).toHaveBeenCalledWith( + deliveryId, + deliveryId, + { leaseToken }, + ); + expect(mockFinalize).not.toHaveBeenCalled(); + }); + it('reuses the canonical dispatch key for automatic follow-up retries', async () => { const deliveryId = '44444444-4444-4444-8444-444444444444'; const dispatchKey = `pr-review-delivery:${deliveryId}`; diff --git a/apps/bullmq/src/jobs/pr-review-notification.ts b/apps/bullmq/src/jobs/pr-review-notification.ts index be37abd21..308dac42c 100644 --- a/apps/bullmq/src/jobs/pr-review-notification.ts +++ b/apps/bullmq/src/jobs/pr-review-notification.ts @@ -18,6 +18,8 @@ import { attachPendingPrReviewActionMessageWithRetirement, beginCanonicalPrReviewAutoDispatch, beginCanonicalPrReviewPrompt, + beginCanonicalPrReviewWebAutoDispatch, + beginCanonicalPrReviewWebPrompt, buildPrReviewNotificationPostInput, createPrReviewNotificationTelemetry, getCommunicationProviderAdapter, @@ -30,6 +32,7 @@ import { finalizePrReviewNotificationRequest, isDurablePrReviewNotificationRequest, renewPrReviewNotificationRequestLease, + releaseCanonicalPrReviewWebAutoDispatch, retirePrReviewActionMessagesBestEffort, migrateLegacyPrReviewNotificationRequest, notifyFastAgentParentOnPrFeedback, @@ -48,6 +51,7 @@ import { } from '@roomote/slack'; import { buildPrReviewActionCallbackData, + PR_REVIEW_ACTION_LABELS, isTaskExecutingTurn, getFastAgentParentFromPayload, WORKER_HEARTBEAT_STALE_MS, @@ -166,6 +170,9 @@ function getFastParentButtonRoute( const conversation = parent.conversation; if (conversation.surface === 'slack') { + if (!conversation.replyTarget.threadId) { + return null; + } return { provider: 'slack', slackTeamId: conversation.workspaceId, @@ -323,15 +330,15 @@ async function postPrReviewNotification({ postInput.buttons = [ [ { - text: 'Resolve these issues', + text: PR_REVIEW_ACTION_LABELS.yes, callbackData: buildPrReviewActionCallbackData('yes', nonce), }, { - text: 'Auto-resolve on this PR', + text: PR_REVIEW_ACTION_LABELS.auto, callbackData: buildPrReviewActionCallbackData('auto', nonce), }, { - text: 'Dismiss', + text: PR_REVIEW_ACTION_LABELS.dismiss, callbackData: buildPrReviewActionCallbackData('dismiss', nonce), }, ], @@ -593,6 +600,8 @@ export const prReviewNotificationJob = async ( (event) => event.reviewResult, )?.reviewResult; const fallbackAutoHandleRoute = getFastParentButtonRoute(latestJob.payload); + const fastParent = getFastAgentParentFromPayload(latestJob.payload); + const isWebFastParent = fastParent?.conversation.surface === 'web'; const persistedAutoHandleRoute = getPersistedButtonRoute(data); const canonicalPreference = data.ownershipVersion === 'canonical' @@ -645,61 +654,117 @@ export const prReviewNotificationJob = async ( directAutoHandleRoute ?? fallbackAutoHandleRoute) : null; - const autoHandleUserId = autoHandleRoute - ? autoHandlePreference?.userId - : null; + const webAutoDispatchKey = + followUp && + autoHandlePreference && + isWebFastParent && + data.ownershipVersion === 'canonical' + ? (data.dispatchKey ?? null) + : null; + const canAutoHandleWeb = webAutoDispatchKey !== null; + const autoHandleUserId = + autoHandleRoute || canAutoHandleWeb ? autoHandlePreference?.userId : null; // Fast-parent delivery can fail and release this notification for retry. // Complete it before auto-dispatch so a retry cannot enqueue the same // resolve prompt twice. - const deliveredToFastParent = await notifyFastAgentParentOnPrFeedback({ - run: latestJob, - feedbackSourceIds: events.map( - (event) => - event.providerEventId ?? - [ - event.kind, - event.authorLogin, - event.batchId ?? '', - event.reviewHeadSha ?? '', - event.reviewState ?? '', - event.checkName ?? '', - event.inReplyToId ?? '', - event.url ?? '', - String(event.observedAt ?? ''), - event.summary ?? event.body ?? '', - ].join('\0'), - ), - pullRequest: { - provider: - deliveryPrLink?.sourceControlProvider ?? - data.sourceControlProvider ?? - 'github', - host: deliveryPrLink?.host, - repository: deliveryPrLink?.repository ?? data.repository, - number: deliveryPrLink?.prNumber ?? data.prNumber, - title: deliveryPrLink?.prTitle, - url: deliveryPrLink?.prUrl ?? data.prUrl, - status: deliveryPrLink?.status, - }, - summary: delivery.text, - ...(roomoteReviewIdentity?.reviewTaskId && - roomoteReviewIdentity.reviewHeadSha - ? { - reviewTaskId: roomoteReviewIdentity.reviewTaskId, - reviewHeadSha: roomoteReviewIdentity.reviewHeadSha, - } - : {}), - ...(roomoteReviewResult ? { reviewResult: roomoteReviewResult } : {}), - ...(followUp && !autoHandleUserId - ? { - suggestedActionQuestion: followUp.question, - suggestedActionPrompt: followUp.prompt, - } + let webReviewActionDeliveryId: string | null = null; + if ( + followUp && + isWebFastParent && + !autoHandleUserId && + data.ownershipVersion === 'canonical' && + data.deliveryId + ) { + if ( + !(await beginCanonicalPrReviewWebPrompt({ + request: data, + followUpPrompt: followUp.prompt, + })) + ) { + console.log( + `[PrReviewNotification] Canonical Fast web delivery ${data.deliveryId} lost its prompt-posting fence, skipping`, + ); + return; + } + webReviewActionDeliveryId = data.deliveryId; + } + const feedbackSourceIds = events.map( + (event) => + event.providerEventId ?? + [ + event.kind, + event.authorLogin, + event.batchId ?? '', + event.reviewHeadSha ?? '', + event.reviewState ?? '', + event.checkName ?? '', + event.inReplyToId ?? '', + event.url ?? '', + String(event.observedAt ?? ''), + event.summary ?? event.body ?? '', + ].join('\0'), + ); + const notifyFastParent = (options: { + includeSuggestedAction: boolean; + reviewActionDeliveryId?: string; + }) => + notifyFastAgentParentOnPrFeedback({ + run: latestJob, + feedbackSourceIds, + pullRequest: { + provider: + deliveryPrLink?.sourceControlProvider ?? + data.sourceControlProvider ?? + 'github', + host: deliveryPrLink?.host, + repository: deliveryPrLink?.repository ?? data.repository, + number: deliveryPrLink?.prNumber ?? data.prNumber, + title: deliveryPrLink?.prTitle, + url: deliveryPrLink?.prUrl ?? data.prUrl, + status: deliveryPrLink?.status, + }, + summary: delivery.text, + ...(roomoteReviewIdentity?.reviewTaskId && + roomoteReviewIdentity.reviewHeadSha + ? { + reviewTaskId: roomoteReviewIdentity.reviewTaskId, + reviewHeadSha: roomoteReviewIdentity.reviewHeadSha, + } + : {}), + ...(roomoteReviewResult ? { reviewResult: roomoteReviewResult } : {}), + ...(followUp && options.includeSuggestedAction + ? { + suggestedActionQuestion: followUp.question, + suggestedActionPrompt: followUp.prompt, + } + : {}), + canonicalDeliveryOwned: data.ownershipVersion === 'canonical', + ...(options.reviewActionDeliveryId + ? { reviewActionDeliveryId: options.reviewActionDeliveryId } + : {}), + }); + const deliveredToFastParent = await notifyFastParent({ + includeSuggestedAction: Boolean(followUp && !autoHandleUserId), + ...(webReviewActionDeliveryId + ? { reviewActionDeliveryId: webReviewActionDeliveryId } : {}), - canonicalDeliveryOwned: data.ownershipVersion === 'canonical', }); + if (deliveredToFastParent && webReviewActionDeliveryId) { + const { attached } = + await attachPendingPrReviewActionMessageWithRetirement( + webReviewActionDeliveryId, + webReviewActionDeliveryId, + { leaseToken: data.leaseToken }, + ); + if (!attached) { + throw new Error( + 'Canonical Fast web review offer lost its publish fence', + ); + } + } + let autoHandledText: string | null = null; const ownsAutoHandleDispatch = directAutoHandleRoute !== null || deliveredToFastParent; @@ -707,36 +772,54 @@ export const prReviewNotificationJob = async ( followUp && autoHandlePreference && autoHandleUserId && - autoHandleRoute && + (autoHandleRoute || canAutoHandleWeb) && ownsAutoHandleDispatch ) { if ( data.deliveryState !== 'auto_dispatch_pending' && - !(await beginCanonicalPrReviewAutoDispatch({ - request: data, - followUpPrompt: followUp.prompt, - targetTaskId: autoHandlePreference.taskId, - actingUserId: autoHandleUserId, - route: autoHandleRoute, - })) + !(await (canAutoHandleWeb + ? beginCanonicalPrReviewWebAutoDispatch({ + request: data, + followUpPrompt: followUp.prompt, + targetTaskId: autoHandlePreference.taskId, + actingUserId: autoHandleUserId, + }) + : beginCanonicalPrReviewAutoDispatch({ + request: data, + followUpPrompt: followUp.prompt, + targetTaskId: autoHandlePreference.taskId, + actingUserId: autoHandleUserId, + route: autoHandleRoute!, + }))) ) { console.log( `[PrReviewNotification] Canonical delivery ${data.deliveryId} lost its automatic-dispatch fence, skipping`, ); return; } - const dispatched = await dispatchPrReviewFollowUp({ - provider: autoHandleRoute.provider, + const dispatchInput = { taskId: autoHandlePreference.taskId, - ...(autoHandleRoute.provider === 'slack' - ? { slackTeamId: autoHandleRoute.slackTeamId } - : {}), - channelId: autoHandleRoute.channelId, - threadId: autoHandleRoute.threadId ?? null, followUpPrompt: followUp.prompt, actingUserId: autoHandleUserId, ...(data.dispatchKey ? { idempotencyKey: data.dispatchKey } : {}), - }); + }; + const dispatched = await dispatchPrReviewFollowUp( + canAutoHandleWeb + ? { + ...dispatchInput, + provider: 'web', + idempotencyKey: webAutoDispatchKey, + } + : { + ...dispatchInput, + provider: autoHandleRoute!.provider, + ...(autoHandleRoute!.provider === 'slack' + ? { slackTeamId: autoHandleRoute!.slackTeamId } + : {}), + channelId: autoHandleRoute!.channelId, + threadId: autoHandleRoute!.threadId ?? null, + }, + ); if (dispatched.outcome !== 'unavailable') { if ( @@ -773,6 +856,51 @@ ${delivery.text}`; console.warn( `[PrReviewNotification] Auto-handle dispatch remained unavailable for ${data.repository}#${data.prNumber} after ${data.deferrals} deferrals; falling back to the interactive offer`, ); + if ( + canAutoHandleWeb && + data.ownershipVersion === 'canonical' && + data.deliveryId + ) { + if ( + !(await releaseCanonicalPrReviewWebAutoDispatch(data)) || + !(await beginCanonicalPrReviewWebPrompt({ + request: data, + followUpPrompt: followUp.prompt, + })) + ) { + console.log( + `[PrReviewNotification] Canonical Fast web delivery ${data.deliveryId} lost its interactive-fallback fence, skipping`, + ); + return; + } + const fallbackDelivered = await notifyFastParent({ + includeSuggestedAction: true, + reviewActionDeliveryId: data.deliveryId, + }); + if (!fallbackDelivered) { + throw new Error( + 'Canonical Fast web review fallback was not delivered', + ); + } + const { attached } = + await attachPendingPrReviewActionMessageWithRetirement( + data.deliveryId, + data.deliveryId, + { leaseToken: data.leaseToken }, + ); + if (!attached) { + throw new Error( + 'Canonical Fast web review fallback lost its publish fence', + ); + } + await recordPrReviewNotificationDeliveryBestEffort({ + runId: latestJob.id, + taskId: data.taskId, + route: null, + text: textWithQuestion, + }); + return; + } } } @@ -783,7 +911,9 @@ ${delivery.text}`; route: null, text: autoHandledText ?? textWithQuestion, }); - await finalizePrReviewNotificationRequest(data); + if (!webReviewActionDeliveryId) { + await finalizePrReviewNotificationRequest(data); + } return; } @@ -811,6 +941,7 @@ ${delivery.text}`; // Chat delivery is optional (web-only tasks have no route). Task history is // always recorded so the web task view shows the self-review summary. let messageTs: string | null = null; + let taskReviewActionDeliveryId: string | null = null; if (delivery.route) { if ( followUp && @@ -860,22 +991,66 @@ ${delivery.text}`; ); } } else { + if ( + followUp && + !fastParent && + data.ownershipVersion === 'canonical' && + data.deliveryId + ) { + if ( + !(await beginCanonicalPrReviewWebPrompt({ + request: data, + followUpPrompt: followUp.prompt, + })) + ) { + console.log( + `[PrReviewNotification] Canonical web task delivery ${data.deliveryId} lost its prompt-posting fence, skipping`, + ); + return; + } + taskReviewActionDeliveryId = data.deliveryId; + } console.log( `[PrReviewNotification] No conversation routing for task ${data.taskId}; recording review feedback to task history only`, ); } - await recordPrReviewNotificationDeliveryBestEffort({ + const recorded = await recordPrReviewNotificationDeliveryBestEffort({ runId: latestJob.id, taskId: data.taskId, route: delivery.route, text: textWithQuestion, ...(messageTs ? { messageTs } : {}), + ...(taskReviewActionDeliveryId && followUp + ? { + reviewAction: { + deliveryId: taskReviewActionDeliveryId, + question: followUp.question, + }, + } + : {}), }); + if (taskReviewActionDeliveryId) { + if (!recorded) { + throw new Error('Canonical web task review offer was not persisted'); + } + const { attached } = + await attachPendingPrReviewActionMessageWithRetirement( + taskReviewActionDeliveryId, + taskReviewActionDeliveryId, + { leaseToken: data.leaseToken }, + ); + if (!attached) { + throw new Error( + 'Canonical web task review offer lost its publish fence', + ); + } + } if ( data.ownershipVersion !== 'canonical' || !followUp || - !delivery.route || - !isButtonRouteProvider(delivery.route.provider) + (delivery.route + ? !isButtonRouteProvider(delivery.route.provider) + : !taskReviewActionDeliveryId) ) { await finalizePrReviewNotificationRequest(data); } diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts index 95b81fb78..27109cd12 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-collector-engine.test.ts @@ -202,6 +202,43 @@ describe('runBrainCollectors', () => { ); }); + it('reports and continues collectors with pending historical scans', async () => { + const collect = vi + .fn() + .mockResolvedValueOnce({ + pages: makePages(1), + nextSince: null, + historicalPending: true, + }) + .mockResolvedValueOnce({ pages: [], nextSince: null }); + const scanning = makeCollector({ collect }); + const idleCollect = vi + .fn() + .mockResolvedValue({ pages: [], nextSince: null }); + const idle = makeCollector({ collect: idleCollect }); + const sink: BrainSink = vi.fn(async () => {}); + + const first = await runBrainCollectors(connection, { + sink, + collectors: [scanning, idle], + }); + expect(first.historicalPendingCollectorIds).toEqual([scanning.id]); + + // Continuation pass: only the mid-scan collector's collect phase reruns; + // the idle collector is not re-polled in the fast loop. + const second = await runBrainCollectors(connection, { + sink, + collectors: [scanning, idle], + includeIncremental: false, + continueCollectorIds: first.historicalPendingCollectorIds, + }); + + expect(collect).toHaveBeenCalledTimes(2); + expect(idleCollect).toHaveBeenCalledTimes(1); + // The scan settled, so nothing holds the loop open. + expect(second.historicalPendingCollectorIds).toEqual([]); + }); + it('persists partition progress only after every page lands', async () => { const partitionWatermark = new Date('2026-08-13T11:00:00Z'); const partitionCursor = '{"page":2}'; @@ -524,7 +561,11 @@ describe('runBrainCollectors', () => { sink, collectors: [firstCollector, secondCollector], }), - ).resolves.toEqual({ backfillProgressed: false, interrupted: true }); + ).resolves.toEqual({ + backfillProgressed: false, + interrupted: true, + historicalPendingCollectorIds: [], + }); expect(sink).toHaveBeenCalledTimes(1); expect(secondCollect).not.toHaveBeenCalled(); @@ -570,7 +611,11 @@ describe('runBrainCollectors', () => { sink, collectors: [firstCollector, secondCollector], }), - ).resolves.toEqual({ backfillProgressed: false, interrupted: false }); + ).resolves.toEqual({ + backfillProgressed: false, + interrupted: false, + historicalPendingCollectorIds: [], + }); expect(secondCollect).toHaveBeenCalledTimes(1); expect(sink).toHaveBeenCalledTimes(1); @@ -682,7 +727,40 @@ describe('runBrainCollectors deep backfill', () => { expect(result).toEqual({ backfillProgressed: true, interrupted: false, + historicalPendingCollectorIds: [], + }); + }); + + it('persists dependent backfill state only after the step pages land', async () => { + const auxiliaryId = uniqueId('backfill-pending'); + const backfill = vi + .fn>() + .mockImplementation(async ({ cursor }) => + cursor === null + ? { + pages: makePages(1, 'queued'), + nextCursor: 'c1', + done: false, + stateUpdates: [{ collectorId: auxiliaryId, cursor: 'remaining' }], + } + : { pages: [], nextCursor: cursor, done: false }, + ); + const collector = makeCollector({ backfill }); + + await runBrainCollectors(connection, { + sink: vi.fn(async () => { + throw new Error('write failed'); + }), + collectors: [collector], }); + expect(syncStateStore.get(auxiliaryId)).toBeUndefined(); + + await runBrainCollectors(connection, { + sink: vi.fn(async () => {}), + collectors: [collector], + }); + expect(syncStateStore.get(auxiliaryId)?.backfillCursor).toBe('remaining'); + expect(syncStateStore.get(collector.id)?.backfillCursor).toBe('c1'); }); it('keeps the last landed cursor when the sink 429s mid-backfill', async () => { @@ -713,7 +791,11 @@ describe('runBrainCollectors deep backfill', () => { sink, collectors: [collector], }), - ).resolves.toEqual({ backfillProgressed: false, interrupted: false }); + ).resolves.toEqual({ + backfillProgressed: false, + interrupted: false, + historicalPendingCollectorIds: [], + }); // The first step's cursor landed; the failed second step's did not. expect(syncStateStore.get(collector.id)?.backfillCursor).toBe('c1'); @@ -862,6 +944,7 @@ describe('runBrainCollectors deep backfill', () => { expect(result).toEqual({ backfillProgressed: false, interrupted: false, + historicalPendingCollectorIds: [], }); }); diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts new file mode 100644 index 000000000..aa8b75c0b --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-discord-collector.test.ts @@ -0,0 +1,634 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const DISCORD_EPOCH_MS = 1_420_070_400_000n; + +const workspace = vi.hoisted(() => ({ + messages: new Map>>(), + fetchCalls: [] as string[], + tracked: [] as Array<{ + collectorId: string; + itemId: string; + slug: string; + lastSeenAt: Date; + }>, + syncState: new Map< + string, + { + watermark?: Date | null; + backfillCursor?: string | null; + backfillCompletedAt?: Date | null; + } + >(), + available: true, + providerEnabled: true, + guildPageNextAfter: null as string | null, + guilds: [] as Array<{ id: string; name: string; icon: null }>, + installations: [] as Array<{ guildId: string }>, + channels: [] as Array<{ + id: string; + name: string; + type: number; + parentId?: string; + }>, + threads: [] as Array<{ + id: string; + name: string; + type: number; + parentId?: string; + }>, +})); + +function snowflake(iso: string, sequence = 0): string { + return ( + ((BigInt(Date.parse(iso)) - DISCORD_EPOCH_MS) << 22n) | + BigInt(sequence) + ).toString(); +} + +const provider = { + getBotInfo: vi.fn(async () => ({ id: '999' })), + listGuildsPage: vi.fn(async () => ({ + guilds: workspace.guilds, + nextAfter: workspace.guildPageNextAfter, + })), + listPublicReadableGuildChannels: vi.fn(async () => workspace.channels), + listGuildActiveThreads: vi.fn(async () => workspace.threads), + fetchChannelMessages: vi.fn( + async ({ + channelId, + oldest, + latest, + }: { + channelId: string; + oldest?: string; + latest?: string; + }) => { + workspace.fetchCalls.push(channelId); + const messages = (workspace.messages.get(channelId) ?? []).filter( + (message) => + (!oldest || BigInt(message.id as string) >= BigInt(oldest)) && + (!latest || BigInt(message.id as string) <= BigInt(latest)), + ); + return { + provider: 'discord' as const, + channelId, + messageCount: messages.length, + messages, + }; + }, + ), +}; + +vi.mock('@roomote/sdk/server', () => ({ + createDiscordCommunicationProviderFromRuntimeCredentials: vi.fn(async () => + workspace.providerEnabled ? provider : null, + ), + isBrainSourceAvailable: vi.fn(async () => workspace.available), + listDiscordInstallations: vi.fn(async () => workspace.installations), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + discordUserMappings: { + findMany: vi.fn(async () => [ + { + discordUserId: '200', + user: { + id: 'alice-id', + name: 'Alice Example ', + createdAt: new Date('2026-01-01T00:00:00Z'), + deletedAt: null, + }, + }, + ]), + }, + }, + }, + getBrainSyncState: vi.fn( + async (_db: unknown, collectorId: string) => + workspace.syncState.get(collectorId) ?? null, + ), + listBrainCollectorItems: vi.fn(async () => workspace.tracked), + listBrainCollectorItemsBySlugPrefix: vi.fn( + async (_db: unknown, collectorId: string, prefix: string) => + workspace.tracked.filter( + (item) => + item.collectorId === collectorId && item.itemId.startsWith(prefix), + ), + ), +})); + +beforeEach(() => { + workspace.messages.clear(); + workspace.fetchCalls = []; + workspace.tracked = []; + workspace.syncState.clear(); + workspace.available = true; + workspace.providerEnabled = true; + workspace.guildPageNextAfter = null; + workspace.guilds = [{ id: '100', name: 'Community', icon: null }]; + workspace.installations = [{ guildId: '100' }]; + workspace.channels = [{ id: '300', name: 'general', type: 0 }]; + workspace.threads = []; + vi.clearAllMocks(); + vi.resetModules(); +}); + +describe('Discord public-channel Brain collector', () => { + it('formats public channel and active public-thread messages deterministically', async () => { + workspace.threads = [ + { id: '301', name: 'design', type: 11, parentId: '300' }, + { id: '302', name: 'private', type: 12, parentId: '300' }, + { id: '304', name: 'forum-post', type: 11, parentId: '303' }, + ]; + workspace.channels.push({ id: '303', name: 'ideas', type: 15 }); + workspace.messages.set('300', [ + { + provider: 'discord', + id: snowflake('2026-08-28T10:00:00Z'), + user: '200', + username: 'alice', + text: 'A public decision', + channelId: '300', + fileCount: 1, + files: [ + { + id: '1', + name: 'plan.pdf', + mimeType: 'application/pdf', + size: 10, + url: 'https://cdn.discordapp.com/secret', + }, + ], + }, + ]); + workspace.messages.set('301', [ + { + provider: 'discord', + id: snowflake('2026-08-28T11:00:00Z'), + user: '201', + username: 'bob', + text: 'Thread reply', + replyToMessageId: '123', + channelId: '301', + fileCount: 0, + }, + ]); + workspace.messages.set('304', [ + { + provider: 'discord', + id: snowflake('2026-08-28T11:30:00Z'), + user: '201', + username: 'bob', + text: 'Forum context', + channelId: '304', + fileCount: 0, + }, + ]); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + const content = result.pages.map((page) => page.content).join('\n'); + + expect(result.pages.map((page) => page.slug)).toEqual([ + 'discord/100/300/2026-08-28/000', + 'discord/100/threads/300/301/2026-08-28/000', + 'discord/100/threads/303/304/2026-08-28/000', + ]); + expect(content).toContain('[Alice Example](people/roomote-member-'); + expect(content).toContain('[attachments: plan.pdf]'); + expect(content).toContain('(reply to 123)'); + expect(content).not.toContain('alice@example.com'); + expect(content).not.toContain('cdn.discordapp.com/secret'); + expect(workspace.fetchCalls).not.toContain('302'); + }); + + it('retires stale chunks after a complete empty-day read', async () => { + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-28/000', + slug: 'discord/100/300/2026-08-28/000', + lastSeenAt: new Date('2026-08-28T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).toContainEqual({ + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-28/000', + slug: 'discord/100/300/2026-08-28/000', + }); + }); + + it('preserves an archived public thread while its parent remains public', async () => { + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/threads/300/301/2026-08-20/000', + slug: 'discord/100/threads/300/301/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).not.toContainEqual( + expect.objectContaining({ + slug: 'discord/100/threads/300/301/2026-08-20/000', + }), + ); + }); + + it('retires a channel after an authoritative public-permission scan excludes it', async () => { + workspace.channels = []; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).toContainEqual( + expect.objectContaining({ + slug: 'discord/100/300/2026-08-20/000', + }), + ); + }); + + it('requeues deep history when a retired channel becomes public again', async () => { + workspace.channels = []; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + let module = await import('../brain-collectors/discord-public-channels'); + const revoked = await module.discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + const revokedUpdate = revoked.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:revoked-partitions-v1', + ); + expect(JSON.parse(revokedUpdate?.cursor ?? '{}')).toEqual({ + keys: ['100/300'], + }); + + workspace.syncState.set('discord-public-channels:revoked-partitions-v1', { + backfillCursor: revokedUpdate!.cursor!, + }); + workspace.syncState.set(module.discordPublicChannelsCollector.id, { + backfillCompletedAt: new Date('2026-08-20T00:00:00Z'), + backfillCursor: JSON.stringify({ + completed: ['100/300'], + key: null, + day: null, + }), + }); + workspace.tracked = []; + workspace.channels = [{ id: '300', name: 'general', type: 0 }]; + vi.resetModules(); + module = await import('../brain-collectors/discord-public-channels'); + + const restored = await module.discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:15:00Z'), + limit: 100, + }); + const pendingUpdate = restored.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:backfill-pending-v1', + ); + + expect(JSON.parse(pendingUpdate?.cursor ?? '{}')).toMatchObject({ + entries: [expect.objectContaining({ key: '100/300' })], + }); + expect(restored.stateUpdates).toContainEqual({ + collectorId: module.discordPublicChannelsCollector.id, + backfillCompletedAt: null, + }); + }); + + it('persists a day cursor for bounded history backfill', async () => { + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + workspace.syncState.set('discord-public-channels:backfill-pending-v1', { + backfillCursor: JSON.stringify({ + entries: [ + { + key: '100/300', + guildId: '100', + guildName: 'Community', + channelId: '300', + channelName: 'general', + parentChannelId: null, + parentChannelName: null, + isThread: false, + }, + ], + }), + }); + + const result = await discordPublicChannelsCollector.backfill!({ + cursor: null, + limit: 100, + }); + const cursor = JSON.parse(result.nextCursor!) as { + key: string; + day: string; + }; + + expect(result.done).toBe(false); + expect(cursor.key).toBe('100/300'); + expect(cursor.day).toMatch(/^\d{4}-\d{2}-\d{2}$/u); + expect(workspace.fetchCalls).toEqual(['300']); + }); + + it('re-arms a completed backfill when a new public channel appears', async () => { + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + workspace.syncState.set(discordPublicChannelsCollector.id, { + backfillCompletedAt: new Date('2026-08-20T00:00:00Z'), + backfillCursor: JSON.stringify({ + completed: [], + key: null, + day: null, + }), + }); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.stateUpdates).toContainEqual({ + collectorId: discordPublicChannelsCollector.id, + backfillCompletedAt: null, + }); + const pending = result.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:backfill-pending-v1', + ); + expect(JSON.parse(pending?.cursor ?? '{}')).toMatchObject({ + entries: [expect.objectContaining({ key: '100/300' })], + }); + }); + + it('catches up missed days from the per-channel watermark', async () => { + workspace.syncState.set( + 'discord-public-channels:entity-timeline-v1:100/300', + { watermark: new Date('2026-08-24T00:00:00Z') }, + ); + workspace.messages.set('300', [ + { + provider: 'discord', + id: snowflake('2026-08-24T10:00:00Z'), + user: '200', + username: 'alice', + text: 'Missed during an outage', + channelId: '300', + fileCount: 0, + }, + ]); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pages.map((page) => page.slug)).toContain( + 'discord/100/300/2026-08-24/000', + ); + expect(result.stateUpdates).toContainEqual({ + collectorId: `${discordPublicChannelsCollector.id}:100/300`, + watermark: new Date('2026-08-26T00:00:00Z'), + }); + }); + + it('advances a bounded durable guild-discovery cursor', async () => { + workspace.guildPageNextAfter = '100'; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(provider.listGuildsPage).toHaveBeenCalledWith({ limit: 10 }); + expect(result.stateUpdates).toContainEqual({ + collectorId: 'discord-public-channels:guild-discovery', + cursor: JSON.stringify({ after: '100' }), + }); + }); + + it('discovers channels only for active Discord installations', async () => { + workspace.guilds = [ + { id: '100', name: 'Active', icon: null }, + { id: '101', name: 'Inactive', icon: null }, + ]; + workspace.installations = [{ guildId: '100' }]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(provider.listPublicReadableGuildChannels).toHaveBeenCalledTimes(1); + expect(provider.listPublicReadableGuildChannels).toHaveBeenCalledWith({ + guildId: '100', + userId: '999', + }); + expect(provider.listGuildActiveThreads).toHaveBeenCalledWith('100'); + expect(provider.listGuildActiveThreads).not.toHaveBeenCalledWith('101'); + }); + + it('retires indexed pages when a Discord installation is deactivated', async () => { + workspace.guilds = [ + { id: '100', name: 'Active', icon: null }, + { id: '101', name: 'Inactive', icon: null }, + ]; + workspace.installations = [{ guildId: '100' }]; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/101/301/2026-08-20/000', + slug: 'discord/101/301/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(result.pageRetirements).toContainEqual({ + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/101/301/2026-08-20/000', + slug: 'discord/101/301/2026-08-20/000', + }); + const revoked = result.stateUpdates?.find( + (update) => + update.collectorId === 'discord-public-channels:revoked-partitions-v1', + ); + expect(JSON.parse(revoked?.cursor ?? '{}')).toEqual({ + keys: ['101/301'], + }); + }); + + it('keeps cleanup enabled after the final installation is deactivated', async () => { + workspace.available = false; + workspace.installations = []; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await expect(discordPublicChannelsCollector.isEnabled()).resolves.toBe( + true, + ); + const result = await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + expect(result.pageRetirements).toContainEqual( + expect.objectContaining({ + slug: 'discord/100/300/2026-08-20/000', + }), + ); + + workspace.tracked = []; + await expect(discordPublicChannelsCollector.isEnabled()).resolves.toBe( + false, + ); + }); + + it('prunes pending backfill for deactivated installations', async () => { + workspace.installations = [{ guildId: '100' }]; + workspace.syncState.set('discord-public-channels:backfill-pending-v1', { + backfillCursor: JSON.stringify({ + entries: [ + { + key: '101/301', + guildId: '101', + guildName: 'Inactive', + channelId: '301', + channelName: 'general', + parentChannelId: null, + parentChannelName: null, + isThread: false, + }, + ], + }), + }); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + const result = await discordPublicChannelsCollector.backfill!({ + cursor: null, + limit: 100, + }); + + expect(result.done).toBe(true); + expect(result.stateUpdates).toEqual([ + { + collectorId: 'discord-public-channels:backfill-pending-v1', + cursor: JSON.stringify({ entries: [] }), + }, + ]); + expect(workspace.fetchCalls).toEqual([]); + }); + + it('disables collection without credentials and preserves inventory', async () => { + workspace.available = false; + workspace.providerEnabled = false; + workspace.tracked = [ + { + collectorId: 'discord-public-channels:day-pages', + itemId: 'discord/100/300/2026-08-20/000', + slug: 'discord/100/300/2026-08-20/000', + lastSeenAt: new Date('2026-08-20T00:00:00Z'), + }, + ]; + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await expect(discordPublicChannelsCollector.isEnabled()).resolves.toBe( + false, + ); + expect(provider.listGuildsPage).not.toHaveBeenCalled(); + }); + + it('bounds incremental history reads to ten channel partitions', async () => { + workspace.channels = Array.from({ length: 12 }, (_, index) => ({ + id: String(300 + index), + name: `channel-${index}`, + type: 0, + })); + const { discordPublicChannelsCollector } = + await import('../brain-collectors/discord-public-channels'); + + await discordPublicChannelsCollector.collect({ + since: null, + now: new Date('2026-08-28T12:00:00Z'), + limit: 100, + }); + + expect(new Set(workspace.fetchCalls).size).toBe(10); + expect(workspace.fetchCalls).toHaveLength(20); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-maintenance.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-maintenance.test.ts index eb0437fb9..fe1dd4820 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-maintenance.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-maintenance.test.ts @@ -3,14 +3,14 @@ const { mockGetBrainSyncState, mockPostToBrain, mockResolveConnection, - mockResolveProvider, + mockIsBrainEmbeddingAvailable, mockUpsertBrainSyncState, } = vi.hoisted(() => ({ mockEnv: { TRPC_URL: 'http://api.test:3001' }, mockGetBrainSyncState: vi.fn(), mockPostToBrain: vi.fn(), mockResolveConnection: vi.fn(), - mockResolveProvider: vi.fn(), + mockIsBrainEmbeddingAvailable: vi.fn(), mockUpsertBrainSyncState: vi.fn(), })); @@ -20,7 +20,7 @@ vi.mock('@roomote/sdk/server', async (importOriginal) => ({ ...(await importOriginal()), getBrainGatewayToken: () => 'brain-gateway-token', resolveBrainConnection: mockResolveConnection, - resolveBrainInferenceProvider: mockResolveProvider, + isBrainEmbeddingAvailable: mockIsBrainEmbeddingAvailable, })); vi.mock('@roomote/env', () => ({ @@ -48,11 +48,6 @@ import { runBrainWeeklySynthesis, } from '../brain-maintenance'; -const TEST_PROVIDER = { - providerId: 'openrouter' as const, - apiKey: 'brain-provider-key', -}; - function synthesisResponse(input?: { answer?: string; sources?: string[]; @@ -180,7 +175,7 @@ describe('brainMaintenanceJob', () => { mockGetBrainSyncState.mockResolvedValue(null); mockPostToBrain.mockReset(); mockResolveConnection.mockReset(); - mockResolveProvider.mockReset(); + mockIsBrainEmbeddingAvailable.mockReset(); mockUpsertBrainSyncState.mockReset(); }); @@ -189,7 +184,7 @@ describe('brainMaintenanceJob', () => { }); it('does nothing when the Brain provider is disabled', async () => { - mockResolveProvider.mockResolvedValue(null); + mockIsBrainEmbeddingAvailable.mockResolvedValue(false); const fetchSpy = vi.spyOn(globalThis, 'fetch'); await brainMaintenanceJob(); @@ -199,7 +194,7 @@ describe('brainMaintenanceJob', () => { }); it('submits the built-in autopilot cycle with the maintenance credential', async () => { - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockImplementation(async (credential: string) => ({ baseUrl: 'http://gbrain.test/', token: `${credential}-token`, @@ -264,7 +259,7 @@ describe('brainMaintenanceJob', () => { // Synthesis failures are rethrown after the submission so they stay // visible, and the scheduler retries the job; without the day marker // each retry queued another full cycle over the whole corpus. - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockImplementation(async (credential: string) => ({ baseUrl: 'http://gbrain.test', token: `${credential}-token`, @@ -297,7 +292,7 @@ describe('brainMaintenanceJob', () => { }); it('fails the scheduler job when gbrain rejects the submission', async () => { - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockResolvedValue({ baseUrl: 'http://gbrain.test', token: 'maintenance-token', @@ -325,7 +320,7 @@ describe('brainMaintenanceJob', () => { it('keeps the claim on a 5xx, which can follow an accepted submission', async () => { // A gateway can answer 5xx after gbrain already queued the job, so the // claim must stand and the retry must not resubmit. - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockResolvedValue({ baseUrl: 'http://gbrain.test', token: 'maintenance-token', @@ -347,7 +342,7 @@ describe('brainMaintenanceJob', () => { }); it('releases the claim when gbrain answers with a tool error', async () => { - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockResolvedValue({ baseUrl: 'http://gbrain.test', token: 'maintenance-token', @@ -378,7 +373,7 @@ describe('brainMaintenanceJob', () => { it('keeps the claim when the submission fails in transport', async () => { // No HTTP answer means gbrain may already have queued the job; releasing // the claim would let the retry queue a second corpus-wide cycle. - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockResolvedValue({ baseUrl: 'http://gbrain.test', token: 'maintenance-token', @@ -400,7 +395,7 @@ describe('brainMaintenanceJob', () => { // A crash between the submission and a marker written afterwards would // let the retry queue a second corpus-wide cycle; the claim has to land // first. - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockResolvedValue({ baseUrl: 'http://gbrain.test', token: 'maintenance-token', @@ -426,7 +421,7 @@ describe('brainMaintenanceJob', () => { }); it('still submits maintenance when daily synthesis fails', async () => { - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockResolveConnection.mockImplementation(async (credential: string) => ({ baseUrl: 'http://gbrain.test', token: `${credential}-token`, @@ -452,8 +447,8 @@ describe('runBrainDailyDigest', () => { mockEnv.TRPC_URL = 'http://api.test:3001'; mockGetBrainSyncState.mockReset(); mockPostToBrain.mockReset(); - mockResolveProvider.mockReset(); - mockResolveProvider.mockResolvedValue(TEST_PROVIDER); + mockIsBrainEmbeddingAvailable.mockReset(); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockUpsertBrainSyncState.mockReset(); }); diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts index 822ce94d0..3a327ffaf 100644 --- a/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts +++ b/apps/bullmq/src/scheduled-jobs/__tests__/brain-notion.test.ts @@ -83,6 +83,89 @@ describe('Notion page mapping', () => { expect(mapped?.content).toContain('## Decision\n\nShip the collector.'); }); + it('renders database property values into the page body', () => { + const users = buildNotionUserReferences( + [{ id: 'notion-ada', name: 'Ada', email: null }], + new Map(), + ); + const mapped = buildNotionPage( + { + ...page, + properties: { + ...page.properties, + Status: { type: 'status', status: { name: 'In review' } }, + Priority: { type: 'select', select: { name: 'P1' } }, + Tags: { + type: 'multi_select', + multi_select: [{ name: 'infra' }, { name: 'memory' }], + }, + Due: { type: 'date', date: { start: '2026-09-01', end: null } }, + Effort: { type: 'number', number: 3 }, + Done: { type: 'checkbox', checkbox: false }, + Owner: { + type: 'people', + people: [{ object: 'user', id: 'notion-ada' }], + }, + Blocked: { + type: 'relation', + relation: [{ id: '12345678-90AB-CDEF-1234-567890ABCD00' }], + }, + Ticket: { + type: 'unique_id', + unique_id: { prefix: 'ROO', number: 42 }, + }, + Score: { type: 'formula', formula: { type: 'number', number: 8.5 } }, + Empty: { type: 'rich_text', rich_text: [] }, + }, + }, + { markdown: 'Body' }, + { identityContext: { users, identityLookup: new Map() } }, + ); + + expect(mapped?.content).toContain('## Properties'); + expect(mapped?.content).toContain('- **Status**: In review'); + expect(mapped?.content).toContain('- **Priority**: P1'); + expect(mapped?.content).toContain('- **Tags**: infra, memory'); + expect(mapped?.content).toContain('- **Due**: 2026-09-01'); + expect(mapped?.content).toContain('- **Effort**: 3'); + expect(mapped?.content).toContain('- **Done**: No'); + expect(mapped?.content).toContain('- **Owner**: Ada'); + expect(mapped?.content).toContain( + '- **Blocked**: [notion/1234567890abcdef1234567890abcd00](notion/1234567890abcdef1234567890abcd00)', + ); + expect(mapped?.content).toContain('- **Ticket**: ROO-42'); + expect(mapped?.content).toContain('- **Score**: 8.5'); + // Empty values and the title property never render as property lines. + expect(mapped?.content).not.toContain('**Empty**'); + expect(mapped?.content).not.toContain('**Name**'); + }); + + it('marks property lists Notion truncated at the inline cap', () => { + const mapped = buildNotionPage( + { + ...page, + properties: { + ...page.properties, + Blocked: { + type: 'relation', + relation: [{ id: '12345678-90AB-CDEF-1234-567890ABCD00' }], + has_more: true, + }, + }, + }, + { markdown: 'Body' }, + ); + + expect(mapped?.content).toContain( + '- **Blocked**: [notion/1234567890abcdef1234567890abcd00](notion/1234567890abcdef1234567890abcd00) _(Notion truncated this list; open the source page for the rest)_', + ); + }); + + it('omits the properties section for pages with only a title', () => { + const mapped = buildNotionPage(page, { markdown: 'Body' }); + expect(mapped?.content).not.toContain('## Properties'); + }); + it('emits deterministic person and relation references from page metadata', () => { const identities: PersonIdentityRecord[] = [ { @@ -303,6 +386,8 @@ describe('Notion page mapping', () => { scanStartedAt: '2026-08-15T00:00:00.000Z', traverse: { afterItemId: '', pending: [] }, }); + // The handed-off traversal keeps the fast continuation loop open. + expect(result.historicalPending).toBe(true); }); it('refreshes a page that moved during the sweep instead of tombstoning it', async () => { @@ -478,6 +563,8 @@ describe('Notion traversal discovery', () => { expect(cursor.mode).toBe('traverse'); expect(cursor.traverse!.pending.length).toBeGreaterThan(0); expect(cursor.traverse!.afterItemId).toBe(parents[24]!.itemId); + // An unfinished walk asks the engine to continue it in the fast loop. + expect(result.historicalPending).toBe(true); }); it('queries data sources behind child databases', async () => { @@ -925,5 +1012,7 @@ describe('Notion traversal discovery', () => { expect(JSON.parse(result.stateUpdates![0]!.cursor as string)).toMatchObject( { mode: 'idle', lastSweepAt: '2026-08-27T00:00:00.000Z' }, ); + // A settled scan releases the fast continuation loop. + expect(result.historicalPending).toBe(false); }); }); 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 64e506c99..d541deb2d 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 @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockResolveConnection, - mockResolveBrainProvider, + mockIsBrainEmbeddingAvailable, mockBackfillEvents, mockClaimEvents, mockClaimFastEvents, @@ -15,7 +15,7 @@ const { mockRunBrainCollectors, } = vi.hoisted(() => ({ mockResolveConnection: vi.fn(), - mockResolveBrainProvider: vi.fn(), + mockIsBrainEmbeddingAvailable: vi.fn(), mockBackfillEvents: vi.fn(), mockClaimEvents: vi.fn(), mockClaimFastEvents: vi.fn(), @@ -33,7 +33,7 @@ vi.mock('@roomote/sdk/server', async (importOriginal) => ({ // through the mocked global fetch. ...(await importOriginal()), resolveBrainConnection: mockResolveConnection, - resolveBrainInferenceProvider: mockResolveBrainProvider, + isBrainEmbeddingAvailable: mockIsBrainEmbeddingAvailable, })); vi.mock('@roomote/db/server', async (importOriginal) => { @@ -308,10 +308,7 @@ describe('collector continuation orchestration', () => { baseUrl: 'http://brain.test', token: 'ingest-token', }); - mockResolveBrainProvider.mockResolvedValue({ - providerId: 'openrouter', - apiKey: 'sk-or', - }); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); }); it('runs incremental integrations only on the scheduled pass', async () => { @@ -337,12 +334,45 @@ describe('collector continuation orchestration', () => { expect(mockRunBrainCollectors).toHaveBeenNthCalledWith( 1, expect.anything(), - { includeIncremental: true }, + { includeIncremental: true, continueCollectorIds: [] }, ); expect(mockRunBrainCollectors).toHaveBeenNthCalledWith( 2, expect.anything(), - { includeIncremental: false }, + { includeIncremental: false, continueCollectorIds: [] }, + ); + }); + + it('feeds pending historical scans back into continuation passes', async () => { + vi.useFakeTimers(); + mockRunBrainCollectors + .mockResolvedValueOnce({ + backfillProgressed: false, + interrupted: false, + historicalPendingCollectorIds: ['notion-pages'], + }) + .mockResolvedValueOnce({ + backfillProgressed: false, + interrupted: false, + historicalPendingCollectorIds: [], + }); + + try { + const job = brainCollectorsJob(); + await vi.runAllTimersAsync(); + await job; + } finally { + vi.useRealTimers(); + } + + // The mid-scan collector holds the loop open for one more pass and is + // handed back so only its collect phase reruns; a settled second pass + // ends the loop. + expect(mockRunBrainCollectors).toHaveBeenCalledTimes(2); + expect(mockRunBrainCollectors).toHaveBeenNthCalledWith( + 2, + expect.anything(), + { includeIncremental: false, continueCollectorIds: ['notion-pages'] }, ); }); @@ -670,7 +700,7 @@ describe('Brain readiness gate', () => { // cannot embed, burn every memory through its retry budget into a // terminal state, and mark the one-shot history backfill complete before // a single page landed. - mockResolveBrainProvider.mockResolvedValue(null); + mockIsBrainEmbeddingAvailable.mockResolvedValue(false); await brainOutboxDrainJob(); @@ -683,10 +713,7 @@ describe('Brain readiness gate', () => { baseUrl: 'http://brain.test', token: 'ingest-token', }); - mockResolveBrainProvider.mockResolvedValue({ - providerId: 'openrouter', - apiKey: 'sk-or', - }); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockGetSyncState.mockResolvedValue({ backfillCompletedAt: new Date() }); mockClaimEvents.mockResolvedValue([]); @@ -750,10 +777,7 @@ describe('fast conversation memory drain', () => { baseUrl: 'http://brain.test', token: 'ingest-token', }); - mockResolveBrainProvider.mockResolvedValue({ - providerId: 'openrouter', - apiKey: 'sk-or', - }); + mockIsBrainEmbeddingAvailable.mockResolvedValue(true); mockGetSyncState.mockResolvedValue({ backfillCompletedAt: new Date() }); }); diff --git a/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts new file mode 100644 index 000000000..00d4118c6 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/__tests__/sessions-reconcile.test.ts @@ -0,0 +1,279 @@ +import { + db, + eq, + fastAgentConversations, + fastAgentMessages, + inArray, + sessionBackfillState, + sessionFactory, + sessionTasks, + sessions, + taskFactory, + userFactory, +} from '@roomote/db/server'; +import { sessionsReconcileJob } from '../sessions-reconcile'; + +const BACKFILL_KEY = 'unified-sessions-v1'; + +describe('sessionsReconcileJob', () => { + it('backfills Fast conversations and visible tasks idempotently', async () => { + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + }); + + it('adopts orphan Fast conversations during steady-state reconciliation', async () => { + // Complete (or advance) the one-time backfill first so the next run takes + // the steady-state reconciliation path. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await sessionsReconcileJob(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, conversation!.id)), + ).resolves.toHaveLength(1); + }); + + it('resumes a backfill parked in the legacy fast_tasks phase', async () => { + await db + .insert(sessionBackfillState) + .values({ key: BACKFILL_KEY, phase: 'fast_tasks' }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: 'fast_tasks', + cursorCreatedAt: null, + cursorId: null, + completedAt: null, + }, + }); + const user = await userFactory.create(); + const task = await taskFactory.create({ initiatorUserId: user.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + await expect( + db.select().from(sessionTasks).where(eq(sessionTasks.taskId, task.id)), + ).resolves.toHaveLength(1); + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + expect(state?.completedAt).not.toBeNull(); + }); + + it('continues past a poisoned row during steady-state reconciliation', async () => { + // Ensure the backfill is complete so the steady-state path runs. + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + // A surface value the sessions check constraint rejects makes + // ensureSessionForFastConversation throw for this row only. + const [poisoned] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'bogus' as never, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + const [healthy] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + + await expect(sessionsReconcileJob()).resolves.toBeUndefined(); + + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, healthy!.id)), + ).resolves.toHaveLength(1); + await expect( + db + .select() + .from(sessions) + .where(eq(sessions.fastConversationId, poisoned!.id)), + ).resolves.toHaveLength(0); + + // A failed adoption must NOT advance the reconcile watermark, so the + // failed row stays inside the next run's scan window instead of being + // stranded past the cutoff once the failure clears. + const watermarkBefore = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + await db + .delete(fastAgentConversations) + .where(eq(fastAgentConversations.id, poisoned!.id)); + await sessionsReconcileJob(); + const watermarkAfter = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, 'unified-sessions-reconcile-v1'), + }); + expect(watermarkAfter?.cursorCreatedAt?.getTime() ?? 0).toBeGreaterThan( + watermarkBefore?.cursorCreatedAt?.getTime() ?? 0, + ); + }); + + it('drains an over-batch orphan backlog across runs without stranding rows', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + // 101 orphans: one full batch plus one. A full batch must NOT advance + // the watermark, so the next run still sees (and adopts) the remainder. + const user = await userFactory.create(); + const rows = await db + .insert(fastAgentConversations) + .values( + Array.from({ length: 101 }, () => ({ + userId: user.id, + surface: 'web' as const, + workspaceId: user.id, + conversationId: crypto.randomUUID(), + })), + ) + .returning({ id: fastAgentConversations.id }); + + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const ids = rows.map((row) => row.id); + const adopted = await db + .select({ id: sessions.fastConversationId }) + .from(sessions) + .where(inArray(sessions.fastConversationId, ids)); + expect(adopted).toHaveLength(101); + }); + + it('heals sessions wedged active on an expired responding lease', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const wedged = await sessionFactory.create({ + cachedStatus: 'active', + respondingUntil: new Date(Date.now() - 60_000), + // Old activity keeps it clear of the recent-activity refresh window. + activityAt: 100, + }); + + await sessionsReconcileJob(); + + const [healed] = await db + .select({ cachedStatus: sessions.cachedStatus }) + .from(sessions) + .where(eq(sessions.id, wedged.id)); + expect(healed?.cachedStatus).toBe('ready'); + + await db.delete(sessions).where(eq(sessions.id, wedged.id)); + }); + + it('reconciles persisted retry notices after the responding lease expires', async () => { + await sessionsReconcileJob(); + await sessionsReconcileJob(); + + const user = await userFactory.create(); + const [conversation] = await db + .insert(fastAgentConversations) + .values({ + userId: user.id, + surface: 'web', + workspaceId: user.id, + conversationId: crypto.randomUUID(), + }) + .returning(); + await sessionsReconcileJob(); + await db.insert(fastAgentMessages).values({ + conversationId: conversation!.id, + eventId: 'interrupted-turn:retry-notice:0', + turnId: 'interrupted-turn', + turnSeq: 1, + ts: Date.now(), + eventType: 'roomote_runtime.assistant_message', + role: 'assistant', + contentBlocks: [ + { + type: 'text', + text: 'The inference provider returned a temporary error. Retrying in 1s (attempt 1/6).', + }, + ], + metadata: { + visibleInTranscript: true, + purpose: 'progress', + inferenceRetryNotice: true, + inferenceRetryActive: true, + }, + payload: { purpose: 'progress' }, + source: 'web', + }); + await db + .update(sessions) + .set({ + cachedStatus: 'ready', + respondingUntil: new Date(Date.now() - 60_000), + }) + .where(eq(sessions.fastConversationId, conversation!.id)); + + await sessionsReconcileJob(); + + const [notice] = await db + .select({ + contentBlocks: fastAgentMessages.contentBlocks, + metadata: fastAgentMessages.metadata, + }) + .from(fastAgentMessages) + .where(eq(fastAgentMessages.conversationId, conversation!.id)); + expect(notice?.contentBlocks).toEqual([ + { + type: 'text', + text: 'The inference retry was interrupted before it completed. Please send the request again.', + }, + ]); + expect(notice?.metadata).toMatchObject({ + purpose: 'closeout', + inferenceRetryActive: false, + }); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts index 9d322736e..bbad5da51 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors.ts @@ -26,7 +26,9 @@ import { writeCollectorPages, } from './brain-collectors/write-pages'; import { githubIssuesCollector } from './brain-collectors/github-issues'; +import { discordPublicChannelsCollector } from './brain-collectors/discord-public-channels'; import { granolaMeetingsCollector } from './brain-collectors/granola-meetings'; +import { linearIssuesCollector } from './brain-collectors/linear-issues'; import { notionPagesCollector, notionUsersCollector, @@ -41,6 +43,13 @@ const LOG_PREFIX = '[brainCollectors]'; type BrainCollectorRunResult = { backfillProgressed: boolean; interrupted: boolean; + /** + * Collectors that advanced durable historical scan work this pass and have + * more remaining. Feed back as `continueCollectorIds` so the continuation + * loop keeps their scans moving; empty once every scan settles, which is + * what lets the loop end. + */ + historicalPendingCollectorIds: string[]; }; async function persistCollectorItemUpdates( @@ -134,6 +143,12 @@ export async function runBrainCollectors( collectors?: BrainCollector[]; /** Skip upstream incremental polls during fast historical continuation. */ includeIncremental?: boolean; + /** + * Collectors whose previous pass reported historicalPending: their + * collect phase runs again even on a continuation pass, so an unfinished + * sweep/reconcile/discovery scan keeps advancing in the fast loop. + */ + continueCollectorIds?: string[]; } = {}, ): Promise { const sink = options.sink ?? postToBrain; @@ -141,6 +156,8 @@ export async function runBrainCollectors( const retireSink = options.retireSink ?? retireBrainPage; const collectors = options.collectors ?? BRAIN_COLLECTORS; const includeIncremental = options.includeIncremental ?? true; + const continueIds = new Set(options.continueCollectorIds ?? []); + const historicalPendingCollectorIds: string[] = []; let backfillProgressed = false; for (const collector of collectors) { @@ -151,10 +168,12 @@ export async function runBrainCollectors( const state = await getBrainSyncState(db, collector.id); - if (includeIncremental) { + if (includeIncremental || continueIds.has(collector.id)) { // Incremental phase runs once per scheduled tick: new activity stays // fresh without repeating upstream API polls in the one-second - // historical continuation loop. + // historical continuation loop. The exception is a collector whose + // last pass reported unfinished historical scan work — its collect + // phase is productive upstream work, not a wasted poll. const { pages, nextSince, @@ -162,6 +181,7 @@ export async function runBrainCollectors( itemUpdates = [], itemDeletes = [], pageRetirements = [], + historicalPending = false, } = await collector.collect({ since: state?.watermark ?? null, now: new Date(), @@ -224,6 +244,13 @@ export async function runBrainCollectors( } } + // An overshot pass persisted nothing, so re-running it in the fast + // loop would repeat the same over-limit collect; let the next + // scheduled tick retry it instead. + if (historicalPending && !overshot) { + historicalPendingCollectorIds.push(collector.id); + } + if (capped.length > 0) { console.log( `${LOG_PREFIX} ${collector.id} ingested ${capped.length} pages`, @@ -256,7 +283,11 @@ export async function runBrainCollectors( isBrainRateLimited(error) ? 'rate limited' : 'cannot embed' }); ending collector pass until next tick`, ); - return { backfillProgressed, interrupted: true }; + return { + backfillProgressed, + interrupted: true, + historicalPendingCollectorIds, + }; } console.warn( @@ -265,7 +296,11 @@ export async function runBrainCollectors( } } - return { backfillProgressed, interrupted: false }; + return { + backfillProgressed, + interrupted: false, + historicalPendingCollectorIds, + }; } /** @@ -315,6 +350,19 @@ async function drainCollectorBackfill(input: { connection, retireSink, ); + for (const update of step.stateUpdates ?? []) { + await upsertBrainSyncState(db, update.collectorId, { + ...(update.watermark !== undefined + ? { watermark: update.watermark } + : {}), + ...(update.cursor !== undefined + ? { backfillCursor: update.cursor } + : {}), + ...(update.backfillCompletedAt !== undefined + ? { backfillCompletedAt: update.backfillCompletedAt } + : {}), + }); + } budget -= step.pages.length; ingested += step.pages.length; @@ -360,8 +408,10 @@ const BRAIN_COLLECTORS: BrainCollector[] = [ personIdentitiesCollector, ripplingWorkersCollector, slackPublicChannelsCollector, + discordPublicChannelsCollector, notionUsersCollector, notionPagesCollector, granolaMeetingsCollector, githubIssuesCollector, + linearIssuesCollector, ]; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts index 616ddbc44..c794171f6 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/contracts.ts @@ -50,6 +50,16 @@ export type CollectorResult = { itemUpdates?: CollectorItemUpdate[]; itemDeletes?: CollectorItemDelete[]; pageRetirements?: CollectorPageRetirement[]; + /** + * Durable historical scan work (a sweep, reconcile, or discovery walk) + * advanced this call and remains unfinished. The engine re-runs this + * collector's collect phase in the fast historical continuation loop so a + * multi-hour scan completes in one session instead of trickling one + * bounded pass per scheduled tick. Leave unset for ordinary incremental + * polling — the loop must never spin on upstream polls that found + * nothing new. + */ + historicalPending?: boolean; }; export interface BrainCollector { @@ -65,6 +75,7 @@ export interface BrainCollector { pages: CollectorPage[]; nextCursor: string | null; done: boolean; + stateUpdates?: CollectorStateUpdate[]; itemUpdates?: CollectorItemUpdate[]; pageRetirements?: CollectorPageRetirement[]; }>; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts new file mode 100644 index 000000000..cec19f72d --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/discord-public-channels.ts @@ -0,0 +1,945 @@ +import type { DiscordCommunicationProvider } from '@roomote/communication/discord-provider'; +import type { CommunicationMessage } from '@roomote/communication/provider'; +import { + db, + getBrainSyncState, + listBrainCollectorItems, + listBrainCollectorItemsBySlugPrefix, +} from '@roomote/db/server'; +import { + createDiscordCommunicationProviderFromRuntimeCredentials, + isBrainSourceAvailable, + listDiscordInstallations, +} from '@roomote/sdk/server'; +import { + BRAIN_COLLECTOR_IDS, + BRAIN_PAGE_TYPES, + brainNamespacePrefix, + buildDiscordMessagePermalink, + renderBrainFrontmatter, +} from '@roomote/types'; + +import type { + BrainCollector, + CollectorItemUpdate, + CollectorPage, + CollectorPageRetirement, + CollectorResult, + CollectorStateUpdate, +} from './contracts'; +import { + brainSafeIdentityValue, + personIdentitySlug, + type PersonIdentityReference, +} from './identity'; + +const LOG_PREFIX = '[brainCollectors]'; +const DISCORD_EPOCH_MS = 1_420_070_400_000n; +const DAY_MS = 24 * 60 * 60 * 1_000; +const DISCORD_HISTORY_PAGE_SIZE = 100; +const DISCORD_DAY_MAX_REQUESTS = 25; +const DISCORD_PAGE_MESSAGE_LIMIT = 200; +const DISCORD_INCREMENTAL_PARTITIONS_PER_PASS = 10; +const DISCORD_GUILDS_PER_DISCOVERY_PASS = 10; +const DISCORD_BACKFILL_DAYS = 90; +const DISCORD_INVENTORY_LIMIT = 10_000; +const DISCORD_INVENTORY_ID = 'discord-public-channels:day-pages'; +const DISCORD_GUILD_DISCOVERY_STATE_ID = + 'discord-public-channels:guild-discovery'; +const DISCORD_BACKFILL_PENDING_STATE_ID = + 'discord-public-channels:backfill-pending-v1'; +const DISCORD_REVOKED_PARTITIONS_STATE_ID = + 'discord-public-channels:revoked-partitions-v1'; +const DISCORD_DISCOVERY_CACHE_MS = 60_000; + +const DISCORD_TEXT_CHANNEL_TYPES = new Set([0, 5]); +const DISCORD_PUBLIC_THREAD_PARENT_TYPES = new Set([0, 5, 15, 16]); +const DISCORD_PUBLIC_THREAD_TYPES = new Set([10, 11]); + +type DiscordCollectionEntry = { + key: string; + guildId: string; + guildName: string; + channelId: string; + channelName: string; + parentChannelId: string | null; + parentChannelName: string | null; + isThread: boolean; +}; + +type DiscordDiscovery = { + provider: DiscordCommunicationProvider; + entries: DiscordCollectionEntry[]; + activeGuildIds: Set; + scannedGuildIds: Set; + readableChannelKeys: Set; + nextGuildCursor: DiscordGuildDiscoveryCursor; +}; + +type DiscordGuildDiscoveryCursor = { + after: string | null; +}; + +type DiscordBackfillCursor = { + completed: string[]; + key: string | null; + day: string | null; +}; + +type DiscordBackfillPending = { + entries: DiscordCollectionEntry[]; +}; + +type DiscordRevokedPartitions = { + keys: string[]; +}; + +let discoveryCache: { + loadedAt: number; + cursorKey: string; + value: DiscordDiscovery; +} | null = null; + +function discordSnowflakeToDate(id: string): Date | null { + try { + const milliseconds = (BigInt(id) >> 22n) + DISCORD_EPOCH_MS; + const value = Number(milliseconds); + return Number.isSafeInteger(value) ? new Date(value) : null; + } catch { + return null; + } +} + +function dateToDiscordSnowflake(date: Date): string { + const milliseconds = BigInt(date.getTime()) - DISCORD_EPOCH_MS; + return (milliseconds > 0n ? milliseconds << 22n : 0n).toString(); +} + +function utcDay(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function startOfUtcDay(day: string): Date { + return new Date(`${day}T00:00:00.000Z`); +} + +function shiftUtcDay(day: string, days: number): string { + return utcDay(new Date(startOfUtcDay(day).getTime() + days * DAY_MS)); +} + +function normalizeMessageText(text: string): string { + return text.replace(/\s+/gu, ' ').trim(); +} + +function discordDayPrefix(entry: DiscordCollectionEntry, day: string): string { + const channelPath = entry.isThread + ? `threads/${entry.parentChannelId}/${entry.channelId}` + : entry.channelId; + return `${brainNamespacePrefix('discord')}${entry.guildId}/${channelPath}/${day}/`.toLowerCase(); +} + +function groupDiscordMessagesIntoDayPages(input: { + entry: DiscordCollectionEntry; + day: string; + messages: CommunicationMessage[]; + people?: ReadonlyMap; +}): CollectorPage[] { + const messages = input.messages + .filter( + (message) => + discordSnowflakeToDate(message.id) && + (message.text.trim() || message.fileCount > 0), + ) + .sort((left, right) => { + try { + const leftId = BigInt(left.id); + const rightId = BigInt(right.id); + return leftId === rightId ? 0 : leftId < rightId ? -1 : 1; + } catch { + return left.id.localeCompare(right.id); + } + }); + const channelLabel = input.entry.parentChannelName + ? `#${input.entry.parentChannelName} / ${input.entry.channelName}` + : `#${input.entry.channelName}`; + const title = `${input.entry.guildName} / ${channelLabel} — ${input.day}`; + const pages: CollectorPage[] = []; + + for ( + let start = 0; + start < messages.length; + start += DISCORD_PAGE_MESSAGE_LIMIT + ) { + const chunk = messages.slice(start, start + DISCORD_PAGE_MESSAGE_LIMIT); + const people = new Set(); + const lines = chunk.map((message) => { + const at = discordSnowflakeToDate(message.id)!; + const person = input.people?.get(message.user); + if (person) people.add(person.slug); + const author = person + ? `[${person.title}](${person.slug}) (${message.user})` + : `<${message.username ? `${message.username} (${message.user})` : message.user}>`; + const text = normalizeMessageText(message.text); + const attachments = (message.files ?? []).map((file) => file.name).sort(); + const details = [ + text, + attachments.length > 0 + ? `[attachments: ${attachments.join(', ')}]` + : '', + ] + .filter(Boolean) + .join(' '); + const permalink = buildDiscordMessagePermalink({ + guildId: input.entry.guildId, + channelId: input.entry.channelId, + messageId: message.id, + }); + const reply = message.replyToMessageId + ? ` (reply to ${message.replyToMessageId})` + : ''; + return `- [${at.toISOString().slice(11, 16)}] ${author}${reply}: ${details}${permalink ? ` ([source](${permalink}))` : ''}`; + }); + const index = Math.floor(start / DISCORD_PAGE_MESSAGE_LIMIT); + const slug = `${discordDayPrefix(input.entry, input.day)}${String(index).padStart(3, '0')}`; + + pages.push({ + slug, + title, + content: [ + ...renderBrainFrontmatter({ + type: BRAIN_PAGE_TYPES.discordDay, + title, + created: input.day, + fields: [ + `date: ${input.day}`, + `guild_id: ${JSON.stringify(input.entry.guildId)}`, + `channel_id: ${JSON.stringify(input.entry.channelId)}`, + input.entry.isThread && 'thread: true', + ], + }), + '', + `# ${title}`, + '', + `Discord public ${input.entry.isThread ? 'thread' : 'channel'} ${channelLabel} in ${input.entry.guildName}, messages on ${input.day} (times UTC).`, + '', + ...lines, + ].join('\n'), + timelineEvidence: [...people].map((personSlug) => ({ + slug: personSlug, + date: input.day, + summary: 'Participated in a public Discord channel', + source: `discord:channel-day:${input.entry.guildId}/${input.entry.channelId}/${input.day}`, + })), + }); + } + + return pages; +} + +async function loadDiscordAuthorLabels(): Promise< + Map +> { + try { + const mappings = await db.query.discordUserMappings.findMany({ + with: { + user: { + columns: { id: true, name: true, createdAt: true, deletedAt: true }, + }, + }, + }); + return new Map( + mappings.flatMap(({ discordUserId, user }) => { + const title = brainSafeIdentityValue(user.name); + return !user.deletedAt && title + ? [ + [ + discordUserId, + { + slug: personIdentitySlug(user.id), + title, + effectiveDate: user.createdAt, + }, + ] as const, + ] + : []; + }), + ); + } catch (error) { + console.warn( + `${LOG_PREFIX} could not resolve Discord author names: ${error instanceof Error ? error.message : String(error)}`, + ); + return new Map(); + } +} + +function parseGuildDiscoveryCursor( + raw: string | null, +): DiscordGuildDiscoveryCursor { + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + return { + after: typeof parsed.after === 'string' ? parsed.after : null, + }; + } catch { + // Restarting the guild census is safe; it only delays retirement. + } + } + return { after: null }; +} + +async function discoverDiscordEntries( + cursor: DiscordGuildDiscoveryCursor, +): Promise { + const now = Date.now(); + const cursorKey = JSON.stringify(cursor); + if ( + discoveryCache && + discoveryCache.cursorKey === cursorKey && + now - discoveryCache.loadedAt < DISCORD_DISCOVERY_CACHE_MS + ) { + return discoveryCache.value; + } + + const provider = + await createDiscordCommunicationProviderFromRuntimeCredentials(); + if (!provider) return null; + + const [bot, guildPage, installations] = await Promise.all([ + provider.getBotInfo(), + provider.listGuildsPage({ + ...(cursor.after ? { after: cursor.after } : {}), + limit: DISCORD_GUILDS_PER_DISCOVERY_PASS, + }), + listDiscordInstallations(), + ]); + const activeGuildIds = new Set( + installations.map((installation) => installation.guildId), + ); + const guilds = guildPage.guilds.filter((guild) => + activeGuildIds.has(guild.id), + ); + const entries: DiscordCollectionEntry[] = []; + const scannedGuildIds = new Set(); + const readableChannelKeys = new Set(); + + for (const guild of guilds) { + try { + const channels = await provider.listPublicReadableGuildChannels({ + guildId: guild.id, + userId: bot.id, + }); + const parents = new Map( + channels + .filter((channel) => + DISCORD_PUBLIC_THREAD_PARENT_TYPES.has(channel.type), + ) + .map((channel) => [channel.id, channel] as const), + ); + const activeThreads = await provider.listGuildActiveThreads(guild.id); + + for (const channel of parents.values()) { + readableChannelKeys.add(`${guild.id}/${channel.id}`); + if (!DISCORD_TEXT_CHANNEL_TYPES.has(channel.type)) continue; + entries.push({ + key: `${guild.id}/${channel.id}`, + guildId: guild.id, + guildName: guild.name, + channelId: channel.id, + channelName: channel.name, + parentChannelId: null, + parentChannelName: null, + isThread: false, + }); + } + for (const thread of activeThreads) { + const parent = thread.parentId ? parents.get(thread.parentId) : null; + if (!parent || !DISCORD_PUBLIC_THREAD_TYPES.has(thread.type)) continue; + entries.push({ + key: `${guild.id}/${thread.id}`, + guildId: guild.id, + guildName: guild.name, + channelId: thread.id, + channelName: thread.name, + parentChannelId: parent.id, + parentChannelName: parent.name, + isThread: true, + }); + } + scannedGuildIds.add(guild.id); + } catch (error) { + console.warn( + `${LOG_PREFIX} Discord guild ${guild.id} discovery failed; preserving its existing pages: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + entries.sort((left, right) => left.key.localeCompare(right.key)); + const value = { + provider, + entries, + activeGuildIds, + scannedGuildIds, + readableChannelKeys, + nextGuildCursor: guildPage.nextAfter + ? { after: guildPage.nextAfter } + : { after: null }, + }; + discoveryCache = { loadedAt: now, cursorKey, value }; + return value; +} + +async function fetchDiscordDay(input: { + provider: DiscordCommunicationProvider; + channelId: string; + day: string; +}): Promise<{ messages: CommunicationMessage[]; complete: boolean }> { + const start = startOfUtcDay(input.day); + const end = new Date(start.getTime() + DAY_MS); + const oldest = dateToDiscordSnowflake(start); + let latest = (BigInt(dateToDiscordSnowflake(end)) - 1n).toString(); + const messages = new Map(); + + for (let request = 0; request < DISCORD_DAY_MAX_REQUESTS; request++) { + const page = await input.provider.fetchChannelMessages({ + channelId: input.channelId, + oldest, + latest, + }); + for (const message of page.messages) messages.set(message.id, message); + if (page.messages.length < DISCORD_HISTORY_PAGE_SIZE) { + return { messages: [...messages.values()], complete: true }; + } + + const firstId = page.messages[0]?.id; + if (!firstId) return { messages: [...messages.values()], complete: true }; + const next = BigInt(firstId) - 1n; + if (next < BigInt(oldest)) { + return { messages: [...messages.values()], complete: true }; + } + latest = next.toString(); + } + + console.warn( + `${LOG_PREFIX} Discord channel ${input.channelId} exceeded ${DISCORD_DAY_MAX_REQUESTS} history pages on ${input.day}; holding its checkpoint`, + ); + return { messages: [], complete: false }; +} + +async function reconcileDiscordDay(input: { + entry: DiscordCollectionEntry; + day: string; + pages: CollectorPage[]; + now: Date; +}): Promise<{ + itemUpdates: CollectorItemUpdate[]; + pageRetirements: CollectorPageRetirement[]; +}> { + const prefix = discordDayPrefix(input.entry, input.day); + const tracked = await listBrainCollectorItemsBySlugPrefix( + db, + DISCORD_INVENTORY_ID, + prefix, + 1_000, + ); + const emitted = new Set(input.pages.map((page) => page.slug)); + + return { + itemUpdates: input.pages.map((page) => ({ + collectorId: DISCORD_INVENTORY_ID, + itemId: page.slug, + slug: page.slug, + lastSeenAt: input.now, + })), + pageRetirements: tracked.flatMap((item) => + emitted.has(item.itemId) + ? [] + : [ + { + collectorId: DISCORD_INVENTORY_ID, + itemId: item.itemId, + slug: item.slug, + }, + ], + ), + }; +} + +function parseInventoryEntry(slug: string): { + guildId: string; + channelId: string; + parentChannelId: string | null; + isThread: boolean; +} | null { + const thread = slug.match(/^discord\/([^/]+)\/threads\/([^/]+)\/([^/]+)\//u); + if (thread) { + return { + guildId: thread[1]!, + parentChannelId: thread[2]!, + channelId: thread[3]!, + isThread: true, + }; + } + const channel = slug.match(/^discord\/([^/]+)\/([^/]+)\//u); + return channel + ? { + guildId: channel[1]!, + channelId: channel[2]!, + parentChannelId: null, + isThread: false, + } + : null; +} + +async function collectInaccessiblePageRetirements( + discovery: DiscordDiscovery | null, + limit: number, +): Promise<{ + retirements: CollectorPageRetirement[]; + ineligibleKeys: Set; +}> { + const tracked = await listBrainCollectorItems( + db, + DISCORD_INVENTORY_ID, + DISCORD_INVENTORY_LIMIT, + ); + if (tracked.length === DISCORD_INVENTORY_LIMIT) { + console.warn( + `${LOG_PREFIX} Discord day-page inventory reached its ${DISCORD_INVENTORY_LIMIT} row cleanup scan bound`, + ); + } + const active = new Set(discovery?.entries.map((entry) => entry.key) ?? []); + + const ineligibleKeys = new Set(); + const retirements = tracked + .filter((item) => { + const parsed = parseInventoryEntry(item.slug); + if (!parsed) return false; + if (!discovery) return false; + if (!discovery.activeGuildIds.has(parsed.guildId)) { + ineligibleKeys.add(`${parsed.guildId}/${parsed.channelId}`); + return true; + } + if (!discovery.scannedGuildIds.has(parsed.guildId)) return false; + if (active.has(`${parsed.guildId}/${parsed.channelId}`)) return false; + if (parsed.isThread && parsed.parentChannelId) { + // Discord omits archived public threads from this bounded discovery + // pass, but they remain readable. Preserve them while their parent is + // public; losing parent visibility is authoritative removal. + const ineligible = !discovery.readableChannelKeys.has( + `${parsed.guildId}/${parsed.parentChannelId}`, + ); + if (ineligible) { + ineligibleKeys.add(`${parsed.guildId}/${parsed.channelId}`); + } + return ineligible; + } + ineligibleKeys.add(`${parsed.guildId}/${parsed.channelId}`); + return true; + }) + .slice(0, limit) + .map((item) => ({ + collectorId: DISCORD_INVENTORY_ID, + itemId: item.itemId, + slug: item.slug, + })); + return { retirements, ineligibleKeys }; +} + +function parseBackfillCursor(raw: string | null): DiscordBackfillCursor { + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial; + return { + completed: Array.isArray(parsed.completed) + ? parsed.completed.filter( + (entry): entry is string => typeof entry === 'string', + ) + : [], + key: typeof parsed.key === 'string' ? parsed.key : null, + day: typeof parsed.day === 'string' ? parsed.day : null, + }; + } catch { + // Restarting is safe because page slugs are stable upserts. + } + } + return { completed: [], key: null, day: null }; +} + +function parseBackfillPending(raw: string | null): DiscordCollectionEntry[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw) as Partial; + return Array.isArray(parsed.entries) + ? parsed.entries.filter( + (entry): entry is DiscordCollectionEntry => + typeof entry === 'object' && + entry !== null && + typeof entry.key === 'string' && + typeof entry.guildId === 'string' && + typeof entry.guildName === 'string' && + typeof entry.channelId === 'string' && + typeof entry.channelName === 'string' && + (typeof entry.parentChannelId === 'string' || + entry.parentChannelId === null) && + (typeof entry.parentChannelName === 'string' || + entry.parentChannelName === null) && + typeof entry.isThread === 'boolean', + ) + : []; + } catch { + return []; + } +} + +function serializeBackfillPending(entries: DiscordCollectionEntry[]): string { + return JSON.stringify({ + entries: [...entries].sort((left, right) => + left.key.localeCompare(right.key), + ), + } satisfies DiscordBackfillPending); +} + +function parseRevokedPartitions(raw: string | null): Set { + if (!raw) return new Set(); + try { + const parsed = JSON.parse(raw) as Partial; + return new Set( + Array.isArray(parsed.keys) + ? parsed.keys.filter((key): key is string => typeof key === 'string') + : [], + ); + } catch { + return new Set(); + } +} + +function serializeRevokedPartitions(keys: ReadonlySet): string { + return JSON.stringify({ + keys: [...keys].sort(), + } satisfies DiscordRevokedPartitions); +} + +function pendingEntryRemainsEligible( + entry: DiscordCollectionEntry, + discovery: DiscordDiscovery, + activeKeys: ReadonlySet, +): boolean { + if (!discovery.activeGuildIds.has(entry.guildId)) return false; + if (!discovery.scannedGuildIds.has(entry.guildId)) return true; + if (activeKeys.has(entry.key)) return true; + return Boolean( + entry.isThread && + entry.parentChannelId && + discovery.readableChannelKeys.has( + `${entry.guildId}/${entry.parentChannelId}`, + ), + ); +} + +async function collectDiscordIncremental(input: { + now: Date; + limit: number; +}): Promise { + const guildDiscoveryState = await getBrainSyncState( + db, + DISCORD_GUILD_DISCOVERY_STATE_ID, + ); + const discovery = await discoverDiscordEntries( + parseGuildDiscoveryCursor(guildDiscoveryState?.backfillCursor ?? null), + ); + const inaccessible = await collectInaccessiblePageRetirements( + discovery, + input.limit, + ); + const pageRetirements = inaccessible.retirements; + if (!discovery) { + return { pages: [], nextSince: null, pageRetirements }; + } + + const [people, pendingState, backfillState, revokedState] = await Promise.all( + [ + loadDiscordAuthorLabels(), + getBrainSyncState(db, DISCORD_BACKFILL_PENDING_STATE_ID), + getBrainSyncState(db, discordPublicChannelsCollector.id), + getBrainSyncState(db, DISCORD_REVOKED_PARTITIONS_STATE_ID), + ], + ); + const completedBackfills = new Set( + parseBackfillCursor(backfillState?.backfillCursor ?? null).completed, + ); + const activeKeys = new Set(discovery.entries.map((entry) => entry.key)); + const revoked = parseRevokedPartitions(revokedState?.backfillCursor ?? null); + for (const key of inaccessible.ineligibleKeys) revoked.add(key); + const pendingByKey = new Map( + parseBackfillPending(pendingState?.backfillCursor ?? null) + .filter((entry) => + pendingEntryRemainsEligible(entry, discovery, activeKeys), + ) + .map((entry) => [entry.key, entry] as const), + ); + for (const entry of discovery.entries) { + if (!completedBackfills.has(entry.key) || revoked.has(entry.key)) { + pendingByKey.set(entry.key, entry); + revoked.delete(entry.key); + } + } + const pendingCursor = serializeBackfillPending([...pendingByKey.values()]); + const revokedCursor = serializeRevokedPartitions(revoked); + const entries = await Promise.all( + discovery.entries.map(async (entry) => ({ + ...entry, + state: await getBrainSyncState( + db, + `${discordPublicChannelsCollector.id}:${entry.key}`, + ), + })), + ); + entries.sort( + (left, right) => + (left.state?.watermark?.getTime() ?? 0) - + (right.state?.watermark?.getTime() ?? 0) || + left.key.localeCompare(right.key), + ); + + const pages: CollectorPage[] = []; + const itemUpdates: CollectorItemUpdate[] = []; + const stateUpdates: CollectorStateUpdate[] = discovery + ? [ + { + collectorId: DISCORD_GUILD_DISCOVERY_STATE_ID, + cursor: JSON.stringify(discovery.nextGuildCursor), + }, + ] + : []; + if (pendingCursor !== (pendingState?.backfillCursor ?? null)) { + stateUpdates.push({ + collectorId: DISCORD_BACKFILL_PENDING_STATE_ID, + cursor: pendingCursor, + }); + } + if (revokedCursor !== (revokedState?.backfillCursor ?? null)) { + stateUpdates.push({ + collectorId: DISCORD_REVOKED_PARTITIONS_STATE_ID, + cursor: revokedCursor, + }); + } + const today = utcDay(input.now); + const recentStart = shiftUtcDay(today, -1); + + for (const entry of entries.slice( + 0, + DISCORD_INCREMENTAL_PARTITIONS_PER_PASS, + )) { + const entryPages: CollectorPage[] = []; + const entryUpdates: CollectorItemUpdate[] = []; + const entryRetirements: CollectorPageRetirement[] = []; + let complete = true; + const watermarkDay = entry.state?.watermark + ? utcDay(entry.state.watermark) + : recentStart; + const startDay = watermarkDay < recentStart ? watermarkDay : recentStart; + const days = [startDay, shiftUtcDay(startDay, 1)].filter( + (day) => day <= today, + ); + + for (const day of days) { + try { + const fetched = await fetchDiscordDay({ + provider: discovery.provider, + channelId: entry.channelId, + day, + }); + if (!fetched.complete) { + complete = false; + break; + } + const dayPages = groupDiscordMessagesIntoDayPages({ + entry, + day, + messages: fetched.messages, + people, + }); + const reconciled = await reconcileDiscordDay({ + entry, + day, + pages: dayPages, + now: input.now, + }); + entryPages.push(...dayPages); + entryUpdates.push(...reconciled.itemUpdates); + entryRetirements.push(...reconciled.pageRetirements); + } catch (error) { + console.warn( + `${LOG_PREFIX} Discord channel ${entry.channelId} history read failed; preserving its pages and checkpoint: ${error instanceof Error ? error.message : String(error)}`, + ); + complete = false; + break; + } + } + + if (!complete || pages.length + entryPages.length > input.limit) continue; + pages.push(...entryPages); + itemUpdates.push(...entryUpdates); + pageRetirements.push(...entryRetirements); + stateUpdates.push({ + collectorId: `${discordPublicChannelsCollector.id}:${entry.key}`, + watermark: + days.at(-1) === today + ? input.now + : startOfUtcDay(shiftUtcDay(days.at(-1)!, 1)), + }); + } + + if (backfillState?.backfillCompletedAt && pendingByKey.size > 0) { + stateUpdates.push({ + collectorId: discordPublicChannelsCollector.id, + backfillCompletedAt: null, + }); + } + + return { + pages, + nextSince: null, + stateUpdates, + itemUpdates, + pageRetirements, + }; +} + +async function backfillDiscordHistory(rawCursor: string | null): Promise<{ + pages: CollectorPage[]; + nextCursor: string | null; + done: boolean; + stateUpdates?: CollectorStateUpdate[]; + itemUpdates?: CollectorItemUpdate[]; + pageRetirements?: CollectorPageRetirement[]; +}> { + const noProgress = { pages: [], nextCursor: rawCursor, done: false }; + const [provider, pendingState, installations] = await Promise.all([ + discoveryCache?.value.provider ?? + createDiscordCommunicationProviderFromRuntimeCredentials(), + getBrainSyncState(db, DISCORD_BACKFILL_PENDING_STATE_ID), + listDiscordInstallations(), + ]); + if (!provider) return noProgress; + + const state = parseBackfillCursor(rawCursor); + const completed = new Set(state.completed); + const activeGuildIds = new Set( + installations.map((installation) => installation.guildId), + ); + const savedPending = parseBackfillPending( + pendingState?.backfillCursor ?? null, + ); + const pending = savedPending.filter((entry) => + activeGuildIds.has(entry.guildId), + ); + const prunedPendingUpdate = + pending.length === savedPending.length + ? [] + : [ + { + collectorId: DISCORD_BACKFILL_PENDING_STATE_ID, + cursor: serializeBackfillPending(pending), + }, + ]; + const entry = + (state.key + ? pending.find((candidate) => candidate.key === state.key) + : null) ?? pending[0]; + + if (!entry) { + return { + pages: [], + nextCursor: JSON.stringify({ + completed: [...completed].sort(), + key: null, + day: null, + } satisfies DiscordBackfillCursor), + done: true, + stateUpdates: prunedPendingUpdate, + }; + } + + const yesterday = shiftUtcDay(utcDay(new Date()), -1); + const oldest = shiftUtcDay(yesterday, -(DISCORD_BACKFILL_DAYS - 1)); + const day = state.key === entry.key && state.day ? state.day : yesterday; + const fetched = await fetchDiscordDay({ + provider, + channelId: entry.channelId, + day, + }); + if (!fetched.complete) return noProgress; + + const people = await loadDiscordAuthorLabels(); + const pages = groupDiscordMessagesIntoDayPages({ + entry, + day, + messages: fetched.messages, + people, + }); + const reconciled = await reconcileDiscordDay({ + entry, + day, + pages, + now: new Date(), + }); + const nextDay = shiftUtcDay(day, -1); + + if (nextDay >= oldest) { + return { + pages, + nextCursor: JSON.stringify({ + completed: [...completed].sort(), + key: entry.key, + day: nextDay, + } satisfies DiscordBackfillCursor), + done: false, + stateUpdates: prunedPendingUpdate, + ...reconciled, + }; + } + + completed.add(entry.key); + const remainingPending = pending.filter( + (candidate) => candidate.key !== entry.key, + ); + return { + pages, + nextCursor: JSON.stringify({ + completed: [...completed].sort(), + key: null, + day: null, + } satisfies DiscordBackfillCursor), + done: false, + stateUpdates: [ + { + collectorId: DISCORD_BACKFILL_PENDING_STATE_ID, + cursor: serializeBackfillPending(remainingPending), + }, + ], + ...reconciled, + }; +} + +export const discordPublicChannelsCollector: BrainCollector = { + id: BRAIN_COLLECTOR_IDS.discordPublicChannels, + displayName: 'Discord public channels', + async isEnabled() { + const [available, provider, tracked] = await Promise.all([ + isBrainSourceAvailable('discord'), + createDiscordCommunicationProviderFromRuntimeCredentials(), + listBrainCollectorItems(db, DISCORD_INVENTORY_ID, 1), + ]); + // Keep authoritative deactivation cleanup runnable after the final guild + // is disabled, but preserve historical pages when credentials are removed. + return available || (provider !== null && tracked.length > 0); + }, + collect({ now, limit }) { + return collectDiscordIncremental({ now, limit }); + }, + backfill({ cursor }) { + return backfillDiscordHistory(cursor); + }, +}; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts new file mode 100644 index 000000000..4303eb2cf --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/linear-issues.ts @@ -0,0 +1,28 @@ +import { BRAIN_COLLECTOR_IDS } from '@roomote/types'; + +import { + backfillBrainLinearIssuesStep, + collectBrainLinearIssues, + isBrainSourceAvailable, +} from '@roomote/sdk/server'; + +import type { BrainCollector } from './contracts'; + +/** + * Linear issues are durable product context. The SDK collector reuses the + * deployment OAuth connection, keeps comments bounded inside each issue page, + * and performs periodic complete visibility sweeps before retiring pages. + */ +export const linearIssuesCollector: BrainCollector = { + id: BRAIN_COLLECTOR_IDS.linearIssues, + displayName: 'Linear issues', + async isEnabled() { + return isBrainSourceAvailable('linear'); + }, + async collect({ now, limit }) { + return collectBrainLinearIssues({ now, limit }); + }, + async backfill({ cursor, limit }) { + return backfillBrainLinearIssuesStep({ cursor, limit }); + }, +}; diff --git a/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts b/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts index 496e4df41..c6a170591 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-collectors/notion-pages.ts @@ -596,6 +596,144 @@ function notionPageEntityReferences( }; } +function notionDateValue(value: unknown): string | null { + const record = asObject(value); + const start = record ? asString(record.start) : null; + if (!start) return null; + const end = asString(record?.end); + return end ? `${start} → ${end}` : start; +} + +function notionNamedOptions(value: unknown): string | null { + const names = Array.isArray(value) + ? value.flatMap((option) => { + const name = asString(asObject(option)?.name); + return name ? [name] : []; + }) + : []; + return names.length > 0 ? names.join(', ') : null; +} + +/** + * Human-readable value for one database property, or null when the property + * is empty or adds nothing beyond the frontmatter (timestamps and authorship + * already have fields there). People resolve through the stored directory and + * relations render as Brain slug links so property lines stay meaningful + * inside Memory search results. + */ +function renderNotionPropertyValue( + record: Record, + context: NotionPageIdentityContext, +): string | null { + switch (record.type) { + case 'rich_text': + return notionRichTextPlainText(record.rich_text) || null; + case 'number': + return typeof record.number === 'number' ? String(record.number) : null; + case 'select': + case 'status': + return asString(asObject(record[record.type])?.name) ?? null; + case 'multi_select': + return notionNamedOptions(record.multi_select); + case 'date': + return notionDateValue(record.date); + case 'checkbox': + return record.checkbox === true + ? 'Yes' + : record.checkbox === false + ? 'No' + : null; + case 'url': + case 'email': + case 'phone_number': + return asString(record[record.type]) ?? null; + case 'files': + return notionNamedOptions(record.files); + case 'people': { + const names = Array.isArray(record.people) + ? record.people.flatMap((user) => { + const id = notionUserId(user); + const name = + (id ? context.users.get(id)?.title : null) ?? + parseNotionUser(user)?.name ?? + asString(asObject(user)?.name); + return name ? [name] : []; + }) + : []; + return names.length > 0 ? names.join(', ') : null; + } + case 'relation': { + const links = Array.isArray(record.relation) + ? record.relation.flatMap((relation) => { + const id = notionUserId(relation); + const slug = id ? notionPageSlug(id) : null; + return slug ? [`[${slug}](${slug})`] : []; + }) + : []; + return links.length > 0 ? links.join(', ') : null; + } + case 'unique_id': { + const uniqueId = asObject(record.unique_id); + if (typeof uniqueId?.number !== 'number') return null; + const prefix = asString(uniqueId.prefix); + return prefix ? `${prefix}-${uniqueId.number}` : String(uniqueId.number); + } + case 'formula': { + const formula = asObject(record.formula); + return formula ? renderNotionPropertyValue(formula, context) : null; + } + case 'rollup': { + const rollup = asObject(record.rollup); + if (!rollup) return null; + if (rollup.type === 'array' && Array.isArray(rollup.array)) { + const values = rollup.array.flatMap((entry) => { + const item = asObject(entry); + const value = item ? renderNotionPropertyValue(item, context) : null; + return value ? [value] : []; + }); + return values.length > 0 ? values.join('; ') : null; + } + return renderNotionPropertyValue(rollup, context); + } + // Formula results arrive as {type: 'string' | 'boolean', ...}; plain + // strings and dates above cover the other two result shapes. + case 'string': + return asString(record.string) ?? null; + case 'boolean': + return record.boolean === true + ? 'Yes' + : record.boolean === false + ? 'No' + : null; + default: + // Timestamps and authorship live in the frontmatter; buttons, + // verification, and future property types have no stable text value. + return null; + } +} + +function renderNotionPropertyLines( + page: NotionSearchPage, + context: NotionPageIdentityContext, +): string[] { + return Object.entries(page.properties ?? {}) + .flatMap(([name, property]) => { + const record = asObject(property); + if (!record || record.type === 'title') return []; + const value = renderNotionPropertyValue(record, context); + if (!value) return []; + // Notion caps inline multi-value properties (relations above all) at + // 25 entries and signals the rest with has_more; an unmarked subset + // would read as the complete list. + const truncated = + record.has_more === true + ? ' _(Notion truncated this list; open the source page for the rest)_' + : ''; + return [`- **${name}**: ${value}${truncated}`]; + }) + .sort((a, b) => a.localeCompare(b)); +} + export function buildNotionPage( page: NotionSearchPage, markdown: NotionMarkdownResponse, @@ -619,10 +757,11 @@ export function buildNotionPage( const updatedAt = parseDate(page.last_edited_time) ?? createdAt; const sourceUrl = asString(page.url); const body = asString(markdown.markdown); - const references = notionPageEntityReferences( - page, - options.identityContext ?? EMPTY_NOTION_PAGE_IDENTITY_CONTEXT, - ); + const identityContext = + options.identityContext ?? EMPTY_NOTION_PAGE_IDENTITY_CONTEXT; + const references = notionPageEntityReferences(page, identityContext); + const propertyLines = + status === 'active' ? renderNotionPropertyLines(page, identityContext) : []; return { slug, @@ -655,6 +794,12 @@ export function buildNotionPage( '', `# ${title}`, ...(sourceUrl ? ['', `[Open in Notion](${sourceUrl})`] : []), + // Database property values: the page-as-Markdown body carries only the + // page content, so rows would otherwise lose their Status/Owner/date + // columns in Memory. + ...(propertyLines.length > 0 + ? ['', '## Properties', '', ...propertyLines] + : []), ...(status === 'deleted' ? ['', 'This page is in the Notion trash.'] : status === 'unavailable' @@ -1311,6 +1456,7 @@ export async function collectNotionTraversal(input: { pages, nextSince: null, itemUpdates, + historicalPending: !complete, stateUpdates: [ { collectorId: NOTION_INCREMENTAL_STATE_ID, @@ -1382,6 +1528,9 @@ export async function collectNotionReconciliation(input: { pages, nextSince: null, itemUpdates, + // Reconcile always hands off to another historical phase (more stale + // items, or the traversal walk), so the fast loop keeps going. + historicalPending: true, itemDeletes: [ { collectorId: NOTION_PAGES_COLLECTOR_ID, @@ -1427,6 +1576,7 @@ async function collectDisabledNotionPages( return { pages: batch.map(buildUnavailableNotionPage), nextSince: null, + historicalPending: !complete, itemDeletes: [ { collectorId: NOTION_PAGES_COLLECTOR_ID, @@ -1579,6 +1729,11 @@ async function collectNotionPages(input: { pages, nextSince: null, itemUpdates, + // A sweep (finished or not) always leaves historical phases ahead of it; + // ordinary incremental catch-up must NOT hold the fast loop open — it + // re-polls the same newest-first search, and its watermark chase settles + // on the next scheduled tick. + historicalPending: mode === 'sweep', stateUpdates: [ { collectorId: NOTION_INCREMENTAL_STATE_ID, diff --git a/apps/bullmq/src/scheduled-jobs/brain-maintenance.ts b/apps/bullmq/src/scheduled-jobs/brain-maintenance.ts index 84db46041..8e6344e59 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-maintenance.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-maintenance.ts @@ -3,7 +3,7 @@ import { parseBrainToolPayloads as parseToolPayloads, postBrainToolCall, resolveBrainConnection, - resolveBrainInferenceProvider, + isBrainEmbeddingAvailable, } from '@roomote/sdk/server'; import { db, @@ -842,9 +842,13 @@ export async function runBrainDailyDigest( * gbrain owns the maintenance algorithm and its cycle locking. */ export async function brainMaintenanceJob(): Promise { - const provider = await resolveBrainInferenceProvider(); - - if (!provider) { + // Provider-agnostic, like the collector/outbox gate: a Brain with a + // configured embedder is a real Brain, and the digest's synthesis rides the + // deployment's helper model through the inference gateway, so no + // OpenAI/OpenRouter key is required. If a deployment somehow cannot + // synthesize at all, the nightly digest errors for that night (already + // caught below) rather than the whole Brain being dark. + if (!(await isBrainEmbeddingAvailable())) { return; } diff --git a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts index 2b5d7b8a6..5da3e7805 100644 --- a/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts +++ b/apps/bullmq/src/scheduled-jobs/brain-outbox-drain.ts @@ -30,7 +30,7 @@ import { import { parseBrainToolPayloads, postBrainToolCall, - resolveBrainInferenceProvider, + isBrainEmbeddingAvailable, resolveBrainConnection, } from '@roomote/sdk/server'; import { @@ -343,27 +343,31 @@ export async function brainOutboxDrainJob(): Promise { /** * A Brain worth writing to needs both halves: somewhere to put pages, and a - * model provider to embed them with. + * way to embed them. * - * The provider half is not optional caution. Every page written to a Brain + * The embedding half is not optional caution. Every page written to a Brain * that cannot embed fails outright — gbrain retries the embed three times and - * returns an error — so draining ahead of a configured provider would burn - * each memory through its retry budget into a terminal 'failed' state that no - * later claim picks up, and would mark the one-shot history backfill complete - * before a single page landed. Holding here instead is what makes turning the - * Brain on later actually pick up everything that happened before. + * returns an error — so draining ahead of an embedding path would burn each + * memory through its retry budget into a terminal 'failed' state that no later + * claim picks up, and would mark the one-shot history backfill complete before + * a single page landed. Holding here instead is what makes turning the Brain + * on later actually pick up everything that happened before. + * + * The check is provider-agnostic: a configured embedder (the shared Modal + * endpoint for managed tenants, or a local model self-hosted) is enough, and + * synthesis rides the deployment's helper model through the inference gateway, + * so no OpenAI/OpenRouter key is required — an Anthropic-only or trial-key + * tenant is just as ready. See isBrainEmbeddingAvailable. */ async function resolveReadyBrain(): Promise<{ baseUrl: string; token: string; } | null> { - // Provider first, and not in parallel: resolving a connection registers - // scoped OAuth clients against the Brain as a side effect, which is not - // worth doing for a Brain that cannot embed yet. This check is cached, so - // the common unconfigured tick costs nothing. - const provider = await resolveBrainInferenceProvider(); - - if (!provider) { + // Embedding path first, and not in parallel: resolving a connection + // registers scoped OAuth clients against the Brain as a side effect, which + // is not worth doing for a Brain that cannot embed yet. This check is + // cached, so the common unconfigured tick costs nothing. + if (!(await isBrainEmbeddingAvailable())) { return null; } @@ -444,6 +448,7 @@ export async function brainCollectorsJob(): Promise { } let includeIncremental = true; + let continueCollectorIds: string[] = []; await drainBrainHistoricalIngestion({ async runPass() { @@ -457,13 +462,20 @@ export async function brainCollectorsJob(): Promise { const collectorResult = await runBrainCollectors(connection, { includeIncremental, + continueCollectorIds, }); includeIncremental = false; + // Collectors mid-scan (a sweep, reconcile, or discovery walk) keep + // their collect phase running across continuation passes; the list + // empties as scans settle, letting the loop end. + continueCollectorIds = + collectorResult.historicalPendingCollectorIds ?? []; return { progressed: pullRequestFactsResult.progressed || - collectorResult.backfillProgressed, + collectorResult.backfillProgressed || + continueCollectorIds.length > 0, interrupted: collectorResult.interrupted, }; }, diff --git a/apps/bullmq/src/scheduled-jobs/index.ts b/apps/bullmq/src/scheduled-jobs/index.ts index 6be114ec8..a2b2061f0 100644 --- a/apps/bullmq/src/scheduled-jobs/index.ts +++ b/apps/bullmq/src/scheduled-jobs/index.ts @@ -9,3 +9,4 @@ export { standbyRetentionJob } from './standby-retention'; export { prReviewNotificationDispatchJob } from './pr-review-notification-dispatch'; export { brainOutboxDrainJob, brainCollectorsJob } from './brain-outbox-drain'; export { brainMaintenanceJob } from './brain-maintenance'; +export { sessionsReconcileJob } from './sessions-reconcile'; diff --git a/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.test.ts b/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.test.ts new file mode 100644 index 000000000..33f60f661 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.test.ts @@ -0,0 +1,82 @@ +const mocks = vi.hoisted(() => ({ + enrichPullRequestFacts: vi.fn(), + syncGitHubPullRequestFactsForAllOrgs: vi.fn(), + syncSourceControlPullRequestFacts: vi.fn(), + queueAdd: vi.fn(), +})); + +vi.mock('@roomote/sdk/server', () => ({ + enrichPullRequestFacts: mocks.enrichPullRequestFacts, + syncGitHubPullRequestFactsForAllOrgs: + mocks.syncGitHubPullRequestFactsForAllOrgs, + syncSourceControlPullRequestFacts: mocks.syncSourceControlPullRequestFacts, +})); + +vi.mock('../redis', () => ({ + getRedis: () => ({}), +})); + +vi.mock('bullmq', () => ({ + Queue: class MockQueue { + add = (...args: unknown[]) => mocks.queueAdd(...args); + }, +})); + +import { + pullRequestAnalyticsSyncJob, + resetPullRequestAnalyticsFollowUpQueueForTests, +} from './pull-request-analytics-sync'; + +const emptyResult = { + eligibleRepositories: 0, + processedRepositories: 0, + failedRepositories: 0, + cooledDownRepositories: 0, +}; + +describe('pullRequestAnalyticsSyncJob', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPullRequestAnalyticsFollowUpQueueForTests(); + mocks.syncGitHubPullRequestFactsForAllOrgs.mockResolvedValue([]); + mocks.syncSourceControlPullRequestFacts.mockResolvedValue(emptyResult); + mocks.enrichPullRequestFacts.mockResolvedValue(undefined); + mocks.queueAdd.mockResolvedValue(undefined); + }); + + it('chains a BrainCollectors follow-up when asked, before enrichment', async () => { + const order: string[] = []; + mocks.queueAdd.mockImplementation(async () => { + order.push('follow-up'); + }); + mocks.enrichPullRequestFacts.mockImplementation(async () => { + order.push('enrich'); + }); + + await pullRequestAnalyticsSyncJob({ chainBrainCollectors: true }); + + expect(mocks.queueAdd).toHaveBeenCalledWith( + 'BrainCollectors', + { reason: 'pull-request-analytics-sync' }, + expect.objectContaining({ + jobId: expect.stringMatching(/^brain-collectors-post-pr-sync-\d+$/), + }), + ); + expect(order).toEqual(['follow-up', 'enrich']); + }); + + it('does not chain on a plain scheduled run', async () => { + await pullRequestAnalyticsSyncJob(); + + expect(mocks.queueAdd).not.toHaveBeenCalled(); + }); + + it('swallows follow-up enqueue failures', async () => { + mocks.queueAdd.mockRejectedValue(new Error('redis down')); + + await expect( + pullRequestAnalyticsSyncJob({ chainBrainCollectors: true }), + ).resolves.toBeUndefined(); + expect(mocks.enrichPullRequestFacts).toHaveBeenCalled(); + }); +}); diff --git a/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.ts b/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.ts index 65a8b85b9..858fa1368 100644 --- a/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.ts +++ b/apps/bullmq/src/scheduled-jobs/pull-request-analytics-sync.ts @@ -1,13 +1,60 @@ +import { Queue } from 'bullmq'; + import { enrichPullRequestFacts, syncGitHubPullRequestFactsForAllOrgs, syncSourceControlPullRequestFacts, } from '@roomote/sdk/server'; +import { getRedis } from '../redis'; +import { ScheduledJobName } from '../types'; + const LOG_PREFIX = '[pullRequestAnalyticsSync]'; +let followUpQueue: Queue | null = null; + +/** Test seam: drop the memoized queue so a fresh connection is created. */ +export function resetPullRequestAnalyticsFollowUpQueueForTests(): void { + followUpQueue = null; +} + +/** + * The PR-fact → Brain sync lives in BrainCollectors, which reads the local + * `pull_request_facts` table this job populates. A one-off collectors run + * kicked alongside this job (the memory-enable backfill) can race it and see + * an empty table, deferring initial PR ingestion to the next 15-minute tick — + * so a kicked run chains a follow-up collectors run once the table is + * populated. Fire-and-forget: the schedule is the safety net. + */ +async function enqueueBrainCollectorsFollowUp(): Promise { + try { + if (!followUpQueue) { + followUpQueue = new Queue('scheduled-jobs', { + connection: getRedis(), + defaultJobOptions: { + attempts: 1, + removeOnComplete: { age: 3600, count: 10 }, + removeOnFail: { age: 3600 }, + }, + }); + } + + const minuteBucket = Math.floor(Date.now() / 60_000); + await followUpQueue.add( + ScheduledJobName.BrainCollectors, + { reason: 'pull-request-analytics-sync' }, + { jobId: `brain-collectors-post-pr-sync-${minuteBucket}` }, + ); + } catch (error) { + console.warn( + `${LOG_PREFIX} failed to enqueue BrainCollectors follow-up:`, + error instanceof Error ? error.message : error, + ); + } +} + export async function pullRequestAnalyticsSyncJob( - opts: { manualTrigger?: boolean } = {}, + opts: { manualTrigger?: boolean; chainBrainCollectors?: boolean } = {}, ) { console.log( `${LOG_PREFIX} Starting sync${opts.manualTrigger ? ' (manual)' : ''}`, @@ -41,6 +88,12 @@ export async function pullRequestAnalyticsSyncJob( `${LOG_PREFIX} Completed: ${totals.processedRepositories}/${totals.eligibleRepositories} processed, ${totals.failedRepositories} failed, ${totals.cooledDownRepositories} cooled down`, ); + // Before the enrichment pass: the facts rows already exist, and enrichment + // only adds detail the collector's watermark re-reads later. + if (opts.chainBrainCollectors) { + await enqueueBrainCollectorsFollowUp(); + } + // Files touched and reviews need per-PR requests the list sync above // does not make; a bounded batch per pass keeps that traffic predictable. try { diff --git a/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts new file mode 100644 index 000000000..596476189 --- /dev/null +++ b/apps/bullmq/src/scheduled-jobs/sessions-reconcile.ts @@ -0,0 +1,397 @@ +import { reconcileExpiredFastAgentInferenceRetryNotices } from '@roomote/cloud-agents/server'; +import { + and, + db, + desc, + ensureSessionForFastConversation, + ensureSessionForTask, + eq, + fastAgentConversations, + gt, + inArray, + isNull, + lt, + or, + sessionBackfillState, + sessions, + sessionTasks, + sql, + taskRuns, + tasks, + touchSessionActivity, +} from '@roomote/db/server'; +const LOG_PREFIX = '[sessions]'; +const BACKFILL_KEY = 'unified-sessions-v1'; +/** + * Steady-state reconcile watermark, stored as a second state row: its + * cursorCreatedAt marks the scan-start time of the last orphan pass that + * completed with ZERO failures. Advancing only on clean passes means a + * transient outage keeps failed rows inside the scan window until they + * actually converge, instead of stranding them past the cutoff forever. + */ +const RECONCILE_KEY = 'unified-sessions-reconcile-v1'; +const RECONCILE_CURSOR_ID = 'watermark'; +const BATCH_SIZE = 100; +/** Slack subtracted from the last-run watermark when bounding orphan scans. */ +const ORPHAN_SCAN_SLACK_MS = 60 * 60 * 1000; + +type Cursor = { createdAt: Date; id: string } | null; + +function afterCursor( + createdAt: TCreatedAt, + id: TId, + cursor: Cursor, +) { + return cursor + ? or( + gt(createdAt as never, cursor.createdAt), + and( + eq(createdAt as never, cursor.createdAt), + gt(id as never, cursor.id), + ), + ) + : undefined; +} + +async function updateState(input: { + phase: 'fast_conversations' | 'tasks' | 'participants'; + cursor?: Cursor; + completed?: boolean; +}) { + await db + .insert(sessionBackfillState) + .values({ + key: BACKFILL_KEY, + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + phase: input.phase, + cursorCreatedAt: input.cursor?.createdAt ?? null, + cursorId: input.cursor?.id ?? null, + completedAt: input.completed ? new Date() : null, + lastRunAt: new Date(), + updatedAt: new Date(), + }, + }); +} + +async function backfillFastConversations(cursor: Cursor): Promise { + const rows = await db + .select({ + id: fastAgentConversations.id, + createdAt: fastAgentConversations.createdAt, + }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + afterCursor( + fastAgentConversations.createdAt, + fastAgentConversations.id, + cursor, + ), + ), + ) + .orderBy(fastAgentConversations.createdAt, fastAgentConversations.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, row.id), + ); + } catch (error) { + console.error( + `${LOG_PREFIX} backfill failed for fast conversation ${row.id}`, + error, + ); + } + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'fast_conversations' : 'tasks', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill fast conversations`, { + processed: rows.length, + }); + return rows.length < BATCH_SIZE; +} + +async function backfillTasks(cursor: Cursor): Promise { + const rows = await db + .select({ id: tasks.id, createdAt: tasks.createdAt }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + afterCursor(tasks.createdAt, tasks.id, cursor), + ), + ) + .orderBy(tasks.createdAt, tasks.id) + .limit(BATCH_SIZE); + + for (const row of rows) { + try { + const latestFastRun = await db.query.taskRuns.findFirst({ + where: and( + eq(taskRuns.taskId, row.id), + sql`${taskRuns.fastAgentSessionId} IS NOT NULL`, + ), + columns: { fastAgentSessionId: true }, + orderBy: desc(taskRuns.id), + }); + await db.transaction((tx) => + ensureSessionForTask(tx, { + taskId: row.id, + fastConversationId: latestFastRun?.fastAgentSessionId ?? null, + origin: 'backfill', + }), + ); + } catch (error) { + console.error(`${LOG_PREFIX} backfill failed for task ${row.id}`, error); + } + } + + const last = rows.at(-1); + await updateState({ + phase: last && rows.length === BATCH_SIZE ? 'tasks' : 'participants', + cursor: + last && rows.length === BATCH_SIZE + ? { createdAt: last.createdAt, id: last.id } + : null, + }); + console.info(`${LOG_PREFIX} backfill tasks`, { processed: rows.length }); + return rows.length < BATCH_SIZE; +} + +async function backfillParticipants(): Promise { + await db.execute(sql` + INSERT INTO session_participants (session_id, user_id, role) + SELECT DISTINCT s.id, fam.metadata->>'userId', 'member' + FROM sessions s + JOIN fast_agent_messages fam ON fam.conversation_id = s.fast_conversation_id + JOIN users u ON u.id = fam.metadata->>'userId' AND u.deleted_at IS NULL + WHERE fam.metadata->>'userId' IS NOT NULL + ON CONFLICT (session_id, user_id) DO NOTHING + `); + await updateState({ phase: 'participants', completed: true }); + console.info(`${LOG_PREFIX} backfill participants complete`); +} + +async function reconcileRecentSessions(watermark: Date | null): Promise { + // Bound the steady-state orphan scans to rows created since the last + // fully-successful pass (with slack) so they stop scanning entire tables + // every run. A null watermark (first run, or no clean pass yet) scans + // unbounded. + const cutoff = watermark + ? new Date(watermark.getTime() - ORPHAN_SCAN_SLACK_MS) + : null; + const scanStartedAt = new Date(); + let orphanFailures = 0; + const reconciledRetryNotices = + await reconcileExpiredFastAgentInferenceRetryNotices(BATCH_SIZE); + + // Fast conversations without a session row (e.g. created before this + // release finished its backfill) are adopted here so the unified list + // converges without another full backfill. + const orphanConversations = await db + .select({ id: fastAgentConversations.id }) + .from(fastAgentConversations) + .leftJoin( + sessions, + eq(sessions.fastConversationId, fastAgentConversations.id), + ) + .where( + and( + isNull(sessions.id), + cutoff ? gt(fastAgentConversations.createdAt, cutoff) : undefined, + ), + ) + .orderBy(desc(fastAgentConversations.updatedAt)) + .limit(BATCH_SIZE); + + for (const conversation of orphanConversations) { + try { + await db.transaction((tx) => + ensureSessionForFastConversation(tx, conversation.id), + ); + } catch (error) { + orphanFailures += 1; + console.error( + `${LOG_PREFIX} reconcile failed for fast conversation ${conversation.id}`, + error, + ); + } + } + + const orphanTasks = await db + .select({ id: tasks.id }) + .from(tasks) + .leftJoin(sessionTasks, eq(sessionTasks.taskId, tasks.id)) + .where( + and( + eq(tasks.visibility, 'visible'), + isNull(tasks.deletedAt), + isNull(sessionTasks.taskId), + cutoff ? gt(tasks.createdAt, cutoff) : undefined, + ), + ) + .orderBy(desc(tasks.activityAt)) + .limit(BATCH_SIZE); + + for (const task of orphanTasks) { + try { + await db.transaction((tx) => + ensureSessionForTask(tx, { taskId: task.id, origin: 'backfill' }), + ); + } catch (error) { + orphanFailures += 1; + console.error( + `${LOG_PREFIX} reconcile failed for task ${task.id}`, + error, + ); + } + } + + const recent = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where(eq(sessions.visibility, 'visible')) + .orderBy(desc(sessions.activityAt)) + .limit(BATCH_SIZE); + for (const session of recent) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} refresh failed for session ${session.id}`, + error, + ); + } + } + + // Sessions stuck 'active'/'needs_input' on an expired (or missing) lease + // may be older than the top-100-by-activity window; heal them explicitly + // so wedged sessions converge regardless of recency. + const expiredLeases = await db + .select({ id: sessions.id, activityAt: sessions.activityAt }) + .from(sessions) + .where( + and( + eq(sessions.visibility, 'visible'), + inArray(sessions.cachedStatus, ['active', 'needs_input']), + or( + isNull(sessions.respondingUntil), + lt(sessions.respondingUntil, new Date()), + ), + ), + ) + .limit(BATCH_SIZE); + for (const session of expiredLeases) { + try { + await touchSessionActivity(db, session.id, session.activityAt); + } catch (error) { + console.error( + `${LOG_PREFIX} lease heal failed for session ${session.id}`, + error, + ); + } + } + + // Advance the watermark only when this pass definitely drained the + // backlog: zero adoption failures AND neither scan returned a full batch + // (a full batch means older rows may remain beyond the LIMIT). Otherwise + // the next run rescans the same window until it converges. Failures in + // the touch/heal loops don't affect orphan scanning. + const sawFullBatch = + orphanConversations.length === BATCH_SIZE || + orphanTasks.length === BATCH_SIZE; + if (orphanFailures === 0 && !sawFullBatch) { + await db + .insert(sessionBackfillState) + .values({ + key: RECONCILE_KEY, + phase: 'participants', + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + completedAt: null, + lastRunAt: scanStartedAt, + }) + .onConflictDoUpdate({ + target: sessionBackfillState.key, + set: { + cursorCreatedAt: scanStartedAt, + cursorId: RECONCILE_CURSOR_ID, + lastRunAt: scanStartedAt, + updatedAt: new Date(), + }, + }); + } else { + console.warn( + `${LOG_PREFIX} keeping the reconcile watermark: ${orphanFailures} orphan adoption(s) failed, fullBatch=${sawFullBatch}`, + ); + } + + console.info(`${LOG_PREFIX} reconciliation`, { + orphanFastConversations: orphanConversations.length, + orphanVisibleTasks: orphanTasks.length, + refreshedSessions: recent.length, + healedExpiredLeases: expiredLeases.length, + reconciledRetryNotices, + }); +} + +export async function sessionsReconcileJob(): Promise { + const state = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, BACKFILL_KEY), + }); + if (state?.completedAt) { + const reconcileState = await db.query.sessionBackfillState.findFirst({ + where: eq(sessionBackfillState.key, RECONCILE_KEY), + }); + await reconcileRecentSessions(reconcileState?.cursorCreatedAt ?? null); + return; + } + + const phase = state?.phase ?? 'fast_conversations'; + const cursor = + state?.cursorCreatedAt && state.cursorId + ? { createdAt: state.cursorCreatedAt, id: state.cursorId } + : null; + + if (phase === 'fast_conversations') { + const complete = await backfillFastConversations(cursor); + if (!complete) return; + } + // 'fast_tasks' is the pre-rename name of the tasks phase; deployments that + // ran an earlier build of this branch may still be parked there. + if ( + phase === 'fast_conversations' || + phase === 'fast_tasks' || + phase === 'tasks' + ) { + const complete = await backfillTasks( + phase === 'fast_tasks' || phase === 'tasks' ? cursor : null, + ); + if (!complete) return; + } + await backfillParticipants(); +} diff --git a/apps/bullmq/src/scheduler.ts b/apps/bullmq/src/scheduler.ts index 360d37127..44b34dfa5 100644 --- a/apps/bullmq/src/scheduler.ts +++ b/apps/bullmq/src/scheduler.ts @@ -35,6 +35,7 @@ import { brainOutboxDrainJob, brainCollectorsJob, brainMaintenanceJob, + sessionsReconcileJob, } from './scheduled-jobs'; const QUEUE_NAME = 'scheduled-jobs'; @@ -225,6 +226,10 @@ async function createJobs(queue: Queue): Promise { { pattern: '0 7 * * *' }, ); + await queue.upsertJobScheduler(ScheduledJobName.SessionsReconcile, { + every: 60 * 1000, + }); + const schedulers = await queue.getJobSchedulers(); console.log('[createJobs] getJobSchedulers ->', schedulers); } @@ -266,6 +271,8 @@ const runJobs = async (job: ScheduledJob): Promise => { return brainCollectorsJob(); case ScheduledJobName.BrainMaintenance: return brainMaintenanceJob(); + case ScheduledJobName.SessionsReconcile: + return sessionsReconcileJob(); case ScheduledJobName.CustomAutomations: await customAutomationsJob(); return; diff --git a/apps/bullmq/src/types.ts b/apps/bullmq/src/types.ts index 6393c98a6..f657d0f6c 100644 --- a/apps/bullmq/src/types.ts +++ b/apps/bullmq/src/types.ts @@ -18,15 +18,16 @@ export enum ScheduledJobName { BrainOutboxDrain = 'BrainOutboxDrain', BrainCollectors = 'BrainCollectors', BrainMaintenance = 'BrainMaintenance', + SessionsReconcile = 'SessionsReconcile', } /** * Automation scheduler jobs are named by the canonical snake_case automation - * key (ci_failure_triage is webhook/Run-now driven and never scheduled). + * key (webhook-driven automations are never scheduled). */ export type ScheduledAutomationJobName = Exclude< TriggerableBackgroundAutomationKey, - 'ci_failure_triage' | 'issue_fixer' + 'ci_failure_triage' | 'issue_fixer' | 'merge_announcer' >; export type SchedulerJobName = ScheduledJobName | ScheduledAutomationJobName; diff --git a/apps/docs/anonymous-analytics.mdx b/apps/docs/anonymous-analytics.mdx index e1bfbf7d7..b74efec53 100644 --- a/apps/docs/anonymous-analytics.mdx +++ b/apps/docs/anonymous-analytics.mdx @@ -21,7 +21,9 @@ When anonymous telemetry is enabled, your deployment sends: the provider type and, where Roomote can determine it from deployment state, whether it was configured before the wizard. Other events include non-identifying facts like the harness, model, source surface, and sandbox - provider used. + provider used. Fast session events include bounded outcome, retry count, and + phase-duration measurements, plus whether a turn is the session's first human + message, but not conversation or message identifiers. - **A daily instance report** — aggregate deployment metadata and usage: setup timestamps; counts of users, environments, and connected repositories; task, model, token, and cost totals for the past day; pull-request statistics diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx index 233661c11..38a2a7d35 100644 --- a/apps/docs/automations.mdx +++ b/apps/docs/automations.mdx @@ -58,6 +58,7 @@ These automations react to pull requests, issues, and repository state. | **Review Code** | Reviews pull requests automatically or on demand | Add an extra reviewer for regressions, risky changes, and missed tests | | **Triage Issues** | Posts clarifying questions or an implementation plan when an issue is opened/reopened on GitHub, GitLab, or Gitea | Get a grounded plan on the issue without auto-opening a PR | | **Resolve PR Conflicts** | Looks for merge conflicts and helps fix them on open PRs | Keep long-running branches from getting stuck | +| **Merge announcer** | Summarizes commits pushed to active repositories' default branches and names the pusher | Keep the team aware of changes that land outside pull requests | For **Review Code**, decide whether Roomote should review new commits automatically and whether draft pull requests should be included. If your team @@ -85,6 +86,21 @@ conflict resolution, and remove it when a human should handle the conflict instead. Roomote only tries this on labeled PRs that are still active, and it skips PRs older than the age cap you set. +For **Merge announcer**, connect a source-control provider and turn the toggle +on. Choose Slack, Microsoft Teams, Telegram, or Discord, then select a channel +or **DM me**. Choose **Default** to use the shared Manager Channel or normal +primary-conversation fallback. Roomote reacts to provider-deduplicated push +webhooks for each active repository's current default branch, uses the +deployment helper model to write a brief commit summary, and includes the pusher +and commit authors. Feature-branch pushes and branch deletions are ignored. +GitHub, GitLab, Azure DevOps, Bitbucket, and Gitea are supported. +For GitHub merge commits that Roomote can match to a pull request, the summary +uses that pull request's context and **View changes** opens the pull request. +When the pull request body contains a suitable image, the Slack announcement +can also show one representative screenshot after Roomote validates its URL and +image type. Announcements continue without an image when validation fails. +Other announcements link to the provider's compare view. + ## Custom automations Create arbitrary scheduled agent runs with: @@ -129,11 +145,9 @@ when repository or workspace execution is required. 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. +the report links back to that session in the web app. Replies on all four +providers continue the Fast session directly in chat. Roomote verifies the +active provider installation, conversation, and linked user before resuming. Runs with no report destination remain stored Fast sessions and do not post to chat. @@ -220,6 +234,11 @@ Provider support differs slightly: `sad`, and `angry` reactions; choose an equivalent configured emoji such as `:thumbsup:` for Like or `:heart:` for Heart. +Telegram reactions on Roomote Fast replies are supported, but Telegram is not +available for **Call Roomote via emoji** on arbitrary messages. Its Bot API +reaction updates are handled only for user-attributed reactions on Roomote +replies and suggested-task cards. + ## Channel automations The channel section starts with **Auto-respond to channels**. diff --git a/apps/docs/communications.mdx b/apps/docs/communications.mdx index e854be4fa..8c1e481a2 100644 --- a/apps/docs/communications.mdx +++ b/apps/docs/communications.mdx @@ -58,6 +58,31 @@ also needs an environment that can run the suggestion's target repository. Configured **Call Roomote via emoji** automations are separate from these suggestion reactions. +When the suggestion is not pinned to an execution target, Fast-capable chat +surfaces start it in Fast so Roomote can answer or delegate as needed. A pinned +environment, repository-only surface, or provider without Fast support starts +the coding task directly. + +## React to Roomote replies + +When you react to a Roomote reply in an active Fast conversation, Roomote can +use that reaction as context, including as an answer to a question it asked. +The linked user who owns the Fast conversation must add the reaction. Roomote +posts a text reply when the reaction warrants a response and otherwise stays +silent; it does not react to the reaction event itself. + +| Provider | Fast reply reactions | Provider limits | +| --- | --- | --- | +| Slack | Supported | Slack sends standard and workspace custom `reaction_added` events. | +| Discord | Supported | Discord Gateway sends standard and server custom reaction-add events, but the event does not include the reacted-to message text. | +| Microsoft Teams | Supported | Bot Framework sends native `messageReaction` activities only for messages posted by Roomote. Reaction removals are ignored. | +| Telegram | Supported | Roomote uses user-attributed `message_reaction` updates. Anonymous aggregate `message_reaction_count` updates are not supported because they do not identify the reacting user. | + +These reaction paths apply only to Roomote replies that were recorded for the +current Fast conversation. Reactions on older or unrelated messages do not gain +access to that conversation. Suggested-task reactions keep their dedicated +launch behavior described above. + Use the same stable public URL for every provider app setting that requires a callback or webhook. If you change that URL, update those provider settings and restart Roomote with the matching deployment URL. Discord receives messages diff --git a/apps/docs/cost-analytics.mdx b/apps/docs/cost-analytics.mdx index c1e5dc315..0a2a3dc27 100644 --- a/apps/docs/cost-analytics.mdx +++ b/apps/docs/cost-analytics.mdx @@ -1,7 +1,7 @@ --- title: Cost Analytics icon: circle-dollar-sign -description: Review Roomote inference spend by task type, source, environment, provider, model, or user. +description: Review Roomote inference spend by type, source, environment, provider, model, or user. --- Cost Analytics helps deployment teams understand how Roomote uses inference @@ -9,30 +9,30 @@ 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. -Any signed-in deployment user can open **Analytics** from the dashboard, then -select **Costs**. +Any signed-in deployment user can open **Analytics** from the dashboard. The +page opens to **Costs** by default. ## What you can review The Costs view includes summary cards, a chart, and a detailed breakdown of recorded inference usage. Use it to answer questions such as: -- which task types account for the most spend -- how much non-task inference, including Fast mode, contributes to spend +- which task and automation types account for the most spend +- how much Session orchestration and Memory contribute to spend - which product features or runtime sources account for inference spend - whether a particular environment or model is driving costs - how usage differs between people and automations - how a provider's cost changes over time -The view starts grouped by **Task Type**. You can instead group or filter the -data by source, user, environment, provider, or model. **Source** preserves the -usage event that recorded the inference, so it can separate work such as task -routing, title generation, and the main agent runtime even when those requests -use the same model and provider. The source also appears in the detail drilldown. -Choose a time range before comparing periods so the chart and breakdown use the -same window. -Fast-mode inference that runs outside a launched task appears as **Non-task -inference** in the task-type breakdown. +The view starts grouped by **Type**. It distinguishes manual and automated +tasks, **Session** orchestration, **Memories** synthesis, and other **Non-task +inference**. You can instead group or filter the data by source, user, +environment, provider, or model. **Source** preserves the usage event that +recorded the inference, so it can separate work such as task routing, title +generation, and the main agent runtime even when those requests use the same +model and provider. The source also appears in the detail drilldown. Choose a +time range before comparing periods so the chart and breakdown use the same +window. ## Exporting data diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index efb846f87..d77a14dab 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -38,7 +38,10 @@ The exact order depends on the setting. A few important cases: admin selection saved in Roomote wins over `DEFAULT_COMPUTE_PROVIDER` because local and Compose stacks often set a default automatically. - Local development has safe defaults for Postgres, Redis, MinIO, signing keys, - and local URLs. Production does not. + and local URLs. Dev login also stores an intentionally invalid inference + placeholder when no provider is configured, so setup can complete without a + secret; inference fails until you connect a real provider. Production does + not use this behavior. ## UI locking @@ -220,15 +223,13 @@ as per-task auth tokens or workspace paths. | `OPENAI_COMPATIBLE__LABEL` | Optional | Display label stored with a named OpenAI-compatible connection. | | `VLLM_BASE_URL` | vLLM | vLLM OpenAI-compatible endpoint URL, usually including its `/v1` path. | | `VLLM_API_KEY` | Optional | Bearer API key for a vLLM endpoint that requires authentication. | -| `R_BRAIN_OPENROUTER_API_KEY` | Memory provider | OpenRouter key that enables Memory embeddings, reranking, and synthesis. | +| `R_BRAIN_OPENROUTER_API_KEY` | Memory provider | OpenRouter key that enables Memory embeddings and synthesis. | | `R_BRAIN_OPENAI_API_KEY` | Memory provider | OpenAI key that enables Memory embeddings and synthesis. | | `R_BRAIN_MODEL` | Optional | Memory synthesis model in the configured provider's naming. Changes apply immediately. | | `R_BRAIN_EMBEDDING_MODEL` | Before first Memory boot | Embedding model id that sizes Memory's vector storage. Changing it later requires re-embedding. | | `R_BRAIN_EMBEDDING_DIMENSIONS` | Before first Memory boot | Output width for `R_BRAIN_EMBEDDING_MODEL`; it must match the served model. | -| `R_BRAIN_RERANKER_MODEL` | Optional | Memory reranker model. Use OpenRouter's model id for OpenRouter, or the exact bare model id served by a self-run rerank upstream. | | `R_BRAIN_EMBEDDINGS_UPSTREAM_URL` | Optional | OpenAI-compatible embeddings endpoint used instead of the Memory provider. | -| `R_BRAIN_RERANK_UPSTREAM_URL` | Optional | OpenAI-compatible rerank endpoint used instead of OpenRouter. | -| `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` | Optional | Bearer key shared by the self-run embeddings and rerank upstreams; omit it for a trusted private-network service. | +| `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` | Optional | Bearer key for the self-run embeddings upstream; omit it for a trusted private-network service. | ### Sandbox providers diff --git a/apps/docs/fast-sessions.mdx b/apps/docs/fast-sessions.mdx index 9fbfd2b5e..00efb0e8b 100644 --- a/apps/docs/fast-sessions.mdx +++ b/apps/docs/fast-sessions.mdx @@ -1,49 +1,104 @@ --- -title: Fast sessions -icon: zap -description: Chat with the fast orchestrator from the dashboard and review every Fast session transcript. +title: Sessions +icon: messages-square +description: Follow a conversation and every execution it delegates from one continuous Roomote workspace. --- -Fast is Roomote's conversational orchestrator: it answers directly when it can -and delegates execution work into tasks when needed. A Fast session persists -across Slack, Discord, Microsoft Teams, Telegram, an automation, or the web -dashboard. +Sessions are the primary way to follow work in Roomote. A Session keeps the +conversation, delegated executions, review activity, artifacts, pull requests, +cost, and unread state together, whether it started in chat, source control, an +automation, the API, or the web dashboard. Fast is the conversational +orchestrator inside a Session: it answers directly when it can and delegates +execution work when needed across Slack, Discord, Microsoft Teams, Telegram, +automations, and the web dashboard. -## Start a Fast session from the dashboard +## Start a Session from the dashboard -On the home page, open the workspace selector next to the prompt box and choose -**Fast**. Your prompt starts a Fast session instead of a sandbox task, and -Roomote takes you straight to the session view, where the response streams in -as it is produced. +On the home page, leave the workspace selector on **Auto** to start a +conversation. Roomote answers directly when it can and delegates execution +when the request needs a repository workspace. Selecting an environment or +repository starts the execution directly, but Roomote still creates the +owning Session and opens it with that execution selected. -Use Fast when you want an answer, a decision, or a delegation rather than a -full sandbox run. Fast can still launch tasks on your behalf; delegated tasks -appear in the transcript with links to their task pages. +Fast is the default for **Auto** and other unpinned requests across the web +dashboard and supported chat providers. Choose an environment or repository +explicitly when the request should skip conversation-first routing and start a +coding task immediately. + +Select **New Session** in the navigation to open the same launcher from any +dashboard page. + +You do not need to choose a separate conversation mode. The Session grows from +conversation to execution to review without changing identity. ## The session view -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 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. +A Session timeline shows prompts, replies, and delegated execution activity. +Execution cards show their status, workspace, pull requests, artifacts, latest +error, and cost. Select a card to open the lightweight details panel, or choose +**Open full workspace** for terminal, logs, diff, and preview tools. + +When delegated tasks keep working after the conversation becomes idle, the +timeline shows the live task count. Select that activity to open the **Tasks** +panel. The **Artifacts** panel combines the latest uploaded outputs from every +task in the Session, labels which task produced each item, and opens previews +without leaving the Session. Individual task details continue to show their own +artifacts. + +**Session info** shows the total inference cost for the conversation and its +attached tasks. Open the cost breakdown to separate direct Session orchestration +from each task's usage. + +The Sessions page supports list and board views, pins, recent Sessions, and +unread indicators. Common scope, status, and time filters stay visible; turn on +advanced filters for user, environment, pull request, model, and source. Active +advanced filters remain visible until you clear them, and Roomote remembers the +selected list or board view. Search matches Session and task titles, +repositories, and conversation text. Queries of at least three characters +include transcript matches and show a highlighted excerpt. **Ready** is not a +terminal state: you can reply or start another execution in the same Session +later. + +Session rows show who or which automation started the work, its source and +cost, and links to accumulated active pull requests. In the Session workspace, +those pull requests remain available across delegated tasks. Selecting the live +task count opens the only running task directly or shows the task list when +several are active. A Session link with a selected task opens that task's full +workspace while keeping the Session conversation alongside it on desktop. + +The transcript renders prompts, replies, and tool activity in real time with a +generated title that updates as the Session evolves. 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. +sandboxed in the web view. Chat surfaces receive a concise text preview and a +link to open the rendered widget when both are available. ## Reply to a session -Every session has a reply box at the bottom of the transcript; follow-ups -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 -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. +Conversational Sessions have a reply box at the bottom of the transcript; +follow-ups continue the same conversation with full context. For conversations +that live on another surface, Roomote posts the answer 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. + +For substantive requests, Fast sends a brief acknowledgement before it starts +model-invoked work. An eligible Slack message may receive an acknowledgement +reaction instead. Immediate answers and clarifying questions remain single +responses, and delegated tasks keep their existing kickoff message rather than +posting a duplicate acknowledgement. + +Fast replies across Slack, Discord, Microsoft Teams, and Telegram link back to +the Session view and can resume the same Session directly from chat. Roomote +verifies the provider installation, conversation, and linked user before it +accepts the follow-up. + +In shared Slack and Discord conversations, Fast can stay silent when linked +participants are talking to each other. A direct reply to Roomote, a mention, +an explicit Fast command, or a direct message still requires a response. + +## Execution access + +Session participants can see timeline summaries. Full execution details keep +the existing task permissions, so joining a shared channel does not grant +access to logs, terminals, diffs, previews, or private artifacts. diff --git a/apps/docs/file-attachments.mdx b/apps/docs/file-attachments.mdx index c45cff231..69c9947e5 100644 --- a/apps/docs/file-attachments.mdx +++ b/apps/docs/file-attachments.mdx @@ -13,6 +13,10 @@ not Office documents, PDFs, or presentations. Microsoft Teams currently supports image attachments only. Slack can also describe one supported video attachment per message. +Attachments sent to Fast stay available as conversation context. When Fast +delegates work or follows up with a coding task, it can forward the relevant +supported attachments with the instruction. + Use attachments when the file itself is the fastest way to show the problem: a bug report export, a config file, a markdown handoff, a spreadsheet, or a PDF from a customer. diff --git a/apps/docs/integrations/linear.mdx b/apps/docs/integrations/linear.mdx index 237d70b90..878dfbcac 100644 --- a/apps/docs/integrations/linear.mdx +++ b/apps/docs/integrations/linear.mdx @@ -6,7 +6,8 @@ icon: 'https://api.iconify.design/simple-icons:linear.svg?color=currentColor' The Linear integration lets Roomote receive work from Linear, post progress back to the issue or agent session, and keep the full run available in the Roomote -task view. +task view. When the Brain is configured, Roomote also indexes issues from the +connected workspace so agents can recall product context before opening Linear. Linear is optional during onboarding. Connect it when your team wants Roomote to work from issues that already have product context, acceptance criteria, @@ -68,6 +69,20 @@ Roomote can post status, plan updates, and final responses back to Linear. The Roomote task view remains the best place to inspect logs, diffs, artifacts, and previews. +## Brain context + +The Brain keeps one durable page per visible Linear issue, including its current +workflow state, team, project, priority, labels, assignee, description, and a +bounded set of recent comments. When available, issue pages also include cycle, +estimate, start date, parent, and related-issue context. Collection uses the same +workspace connection configured above; it does not require another Linear token. + +Roomote refreshes changed issues incrementally and periodically checks the full +visible issue set. Archived issues remain available as historical context. If an +issue is deleted or the connected app can no longer see it, Roomote removes its +page only after a complete visibility check, avoiding deletion from a partial or +failed API response. + Put acceptance criteria and relevant repository links directly in the Linear issue before starting agent work. diff --git a/apps/docs/integrations/notion.mdx b/apps/docs/integrations/notion.mdx index 09baeb90f..119adc3fa 100644 --- a/apps/docs/integrations/notion.mdx +++ b/apps/docs/integrations/notion.mdx @@ -43,7 +43,9 @@ regular Memory collector ticks, and a daily full sweep discovers older pages that were newly shared without being edited. Because Notion's search index does not reliably surface pages that live inside databases, the sweep also enumerates every shared data source and walks page trees to capture database -rows and other inheritance-shared pages. The same sweep replaces pages +rows and other inheritance-shared pages, running that discovery to +completion in a continuous loop rather than trickling across scheduled +ticks. The same sweep replaces pages that are no longer shared with unavailable tombstones, so their former content is no longer retained in Memory search results. diff --git a/apps/docs/integrations/roomote-mcp.mdx b/apps/docs/integrations/roomote-mcp.mdx index bfc5f64c5..24dd6205a 100644 --- a/apps/docs/integrations/roomote-mcp.mdx +++ b/apps/docs/integrations/roomote-mcp.mdx @@ -1,38 +1,40 @@ --- title: Roomote MCP icon: '/favicon.svg' -description: Run and manage Roomote agent tasks from your MCP client. +description: Start, inspect, and continue Roomote Sessions and tasks from your MCP client. --- Roomote MCP brings your Roomote workspace into the coding tools you already -use. From any OAuth-capable MCP client, you can kick off a new Roomote task, -check on its progress, and follow up without switching back and forth between -tools. +use. From any OAuth-capable MCP client, you can start a Session, inspect its +conversation and delegated tasks, and follow up without switching back and +forth between tools. ## What you can do -### Kick off a new task +### Start a Session -Ask your MCP client to start a Roomote task with a clear prompt. Roomote MCP -finds the available launch environments and uses the right one for the work. -For example, you can delegate an investigation or implementation, keep working -locally, and come back for the result when the task finishes. You can also push -work in progress to a remote branch and hand that branch to a Roomote agent to -continue. +Ask your MCP client to start Roomote with a clear message. The ordinary start +action creates a Session, where Fast can answer directly or delegate repository +work to tasks. You can keep working locally and return to the same Session for +the result. When work must start directly in a known compute environment, your +client can still use the explicit environment-targeted task launch action. ### Check on and steer existing work -Search active or completed tasks by keyword, status, or pull request. Once you -find a task, you can read its summary, conversation, and compute logs to -understand what happened and where the work stands. +Search finds Sessions by default. Once you find one, provide its Session ID to +read the summary or newest conversation messages and to send a follow-up. When +you need one concrete coding task instead, use task search and pass its task ID; +the task ID takes precedence if both identifiers are supplied. Compute logs, +cancellation, environment-targeted launch, and task model controls remain +task-specific operations. This is useful when you want to: -- catch up on a task that is already running +- catch up on a Session that is already active - find an earlier investigation or implementation -- understand why a task failed +- inspect a delegated task when you need its execution details - pull the result of a Roomote task into your current conversation -- send follow-up instructions or cancel work that is no longer needed +- send follow-up instructions or cancel task work that is no longer needed Follow-up instructions normally wait behind the task's current turn. When the new instruction must take effect immediately, ask your MCP client to steer the @@ -53,11 +55,13 @@ only returns conversations your signed-in user is allowed to read. - “Push what I’m working on to a remote branch, then start a Roomote task from that branch to take over.” -- “Start a Roomote task in the web app environment to investigate this bug.” -- “Find my active tasks related to OAuth and summarize their progress.” +- “Start a Roomote Session to investigate this bug.” +- “Find my active Sessions related to OAuth and summarize their progress.” +- “Find the coding task for this pull request and show its compute logs.” - “Show me the messages and compute logs from the task that failed this morning.” - “Tell the running task to add a regression test before it opens the PR.” +- “Read the latest messages from this Fast Session and send a follow-up.” - “Use this Slack message as context and explain what the team decided.” - “Cancel the task working on the old approach.” diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index 806f71c9b..1bdaebf04 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -21,8 +21,12 @@ Roomote fills Memory from what it can already see: its own work: what it decided, why, and what is still open - **pull requests** from your connected source-control provider - **public Slack channels** the Roomote bot has been added to +- **public Discord server channels and active public threads** the Roomote bot + can read - **GitHub issues** in connected repositories - **Notion pages** explicitly shared with the deployment's Notion integration + including readable database property values such as status, dates, people, + labels, relations, and formula or rollup results - **meeting notes** from Granola, when that integration is connected - **employee directory and reporting structure** from Rippling, when that integration is connected; HRIS reporting and membership fields remain @@ -61,6 +65,15 @@ inside what your team has already made visible company-wide. Slack directory cards contain names, handles, and job titles, but never copy profile email, status, timezone, or avatar fields into Memory. +Discord collection follows the server's permission model: Roomote includes only +channels visible to the server's `@everyone` role where the bot also has **View +Channel** and **Read Message History**. Private channels, private threads, group +DMs, and direct messages are never collected. Active public threads and forum +posts inherit the visibility of their public parent channel. Roomote re-reads a +bounded recent window so edits and deletions are reflected, and removes stored +pages when an authoritative permission scan shows that a channel is no longer +publicly accessible. + Notion only returns pages explicitly shared with its integration. Workspace guests and restricted users may omit email addresses, and some integration configurations cannot list users at all. Roomote still keeps stable Notion user @@ -68,7 +81,9 @@ references in page snapshots in those cases, but it does not guess a match from the display name. The workspace user directory is refreshed once a day; people removed from the workspace (or hidden when the integration loses its user-listing capability) have their Notion identity cards marked deleted on -the next refresh. +the next refresh. Notion may truncate long multi-value database properties; +Memory marks those partial values and links back to the source page for the +complete list. ## Turning it on @@ -76,51 +91,54 @@ Memory runs as its own service alongside Roomote, reachable only on your deployment's internal network. On the hosted templates (Railway, Render, Coolify) that service is already there after a deploy, sitting idle. -Setting a Memory provider key, `R_BRAIN_OPENROUTER_API_KEY` or -`R_BRAIN_OPENAI_API_KEY`, is what turns it on. Set either one as an -environment variable or a Settings environment value; the value can be the -same key you already use for tasks, or a separate one to bill Memory -independently. The explicit `R_BRAIN_*` name is the opt-in: the general -provider keys your deployment uses for tasks never activate Memory on -their own, so configuring task models leaves Memory off. +New Roomote Cloud deployments enable Memory when initial setup completes. +Existing deployments keep their current setting. Administrators can change it +with the **Enable Memory** toggle at the top of **Settings → Memory**; no +provider key is required. Synthesis runs through your deployment's helper model +— the same small model that already writes task titles and summaries. +Deployments that enabled Memory before the toggle existed, by setting +`R_BRAIN_OPENROUTER_API_KEY` or `R_BRAIN_OPENAI_API_KEY`, stay enabled without +doing anything; using the toggle stores an explicit choice that wins over the +key from then on. -The Memory service holds no provider key of its own. It asks Roomote for -embeddings and synthesis, and Roomote forwards them under the Memory key. -Changing that key later takes effect on Memory's next request, with no -redeploy. +Semantic recall still needs embeddings. Those come from an OpenRouter or +OpenAI key — a Memory-specific `R_BRAIN_*` key to bill Memory separately, or +the deployment's general provider key once Memory is enabled — or from a +self-run embeddings upstream (below). The Memory service holds no provider +key of its own: it asks Roomote for embeddings and synthesis, and Roomote +forwards them. Changing a key later takes effect on Memory's next request, +with no redeploy. -OpenRouter and OpenAI both support Memory's embedding and synthesis calls. -Search reranking requires OpenRouter unless a self-run rerank upstream is -configured. +OpenRouter and OpenAI both support Memory's embedding calls. -### Run embeddings and reranking locally +### Run embeddings locally -Self-hosted Compose deployments can keep embeddings and reranking on their own -hardware while continuing to send chat synthesis to the configured Memory -provider. Enable both services and point Memory at the bundled inference server: +Self-hosted Compose deployments can keep embeddings on their own hardware +while continuing to send chat synthesis to the configured Memory provider. +Enable both services and point Memory at the bundled inference server: ```sh COMPOSE_PROFILES=brain,local-inference R_BRAIN_EMBEDDINGS_UPSTREAM_URL=http://infinity:7997 -R_BRAIN_RERANK_UPSTREAM_URL=http://infinity:7997 R_BRAIN_EMBEDDING_MODEL=BAAI/bge-m3 R_BRAIN_EMBEDDING_DIMENSIONS=1024 -R_BRAIN_RERANKER_MODEL=BAAI/bge-reranker-v2-m3 ``` The bundled CPU service uses multilingual models so recall can cross languages. +Its anonymous usage reporting is disabled by default in the Roomote Compose +bundle. This is separate from Roomote's own optional anonymous analytics. For a smaller CPU host, `Alibaba-NLP/gte-multilingual-base` with `768` dimensions is a lighter embedding alternative. Choose the embedding model and dimensions before Memory's first boot; changing that pair later requires re-embedding the corpus. -The two upstream URLs can instead target any OpenAI-compatible embedding and -rerank server. Set `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` when that server requires -a bearer key. Roomote forwards model names unchanged to self-run upstreams, so -`R_BRAIN_EMBEDDING_MODEL` and `R_BRAIN_RERANKER_MODEL` must exactly match the -models that server exposes, without a provider prefix. +The upstream URL can instead target any OpenAI-compatible embedding server. +Set `R_BRAIN_INFERENCE_UPSTREAM_API_KEY` when that server requires a bearer +key. Roomote forwards model names unchanged to self-run upstreams, so +`R_BRAIN_EMBEDDING_MODEL` must exactly match a model that server exposes, +without a provider prefix. -Without a Memory key, Memory stays inert. Agents are not told it exists, +While Memory is disabled, it stays inert. Agents are not told it exists, and nothing is ingested. Roomote schedules one maintenance pass each night. It retrieves a bounded, @@ -149,9 +167,10 @@ that upstream feature requires an operator review workflow before proposals become canonical memory. - Self-hosted Compose deployments start Memory from the `brain` - profile, so set a Memory key in your environment file to bring the container - up. Everything after that is the same. + Self-hosted Compose deployments start Memory from the `brain` profile, so + add `brain` to `COMPOSE_PROFILES` in your environment file to bring the + container up, then enable Memory in Settings. Everything after that is the + same. ## Seeing what it knows @@ -218,23 +237,19 @@ in staging is distinguishable from one written against production. ## Choosing models -Three settings pick Memory's models: +Two settings pick Memory's models: | Variable | What it does | Written as | Changeable | | ----------------------------- | ----------------- | --------------------------------- | --------------------- | | `R_BRAIN_MODEL` | Sourced synthesis | your provider's naming | any time | | `R_BRAIN_EMBEDDING_MODEL` | Semantic recall | a plain model id | before the first boot | -| `R_BRAIN_RERANKER_MODEL` | Search precision | provider or upstream naming | after a restart | -Leave the first two unset and Memory uses OpenAI's `gpt-5.6-luna` and +Leave them unset and Memory uses OpenAI's `gpt-5.6-luna` and `text-embedding-3-small` through whichever provider you configured. -The reranker defaults to OpenRouter's `voyageai/rerank-2.5-lite`. Set -`R_BRAIN_RERANKER_MODEL` to choose another model from -OpenRouter's reranker catalog. Reranking requires an OpenRouter key; with only -OpenAI configured, gbrain keeps the unreranked results instead of failing the -search. When `R_BRAIN_RERANK_UPSTREAM_URL` is set, use the exact bare model id -served by that upstream instead. +Memory search does not use a cross-encoder reranker: retrieval is hybrid +(vector + keyword fusion), which keeps search latency flat and provider +requirements minimal. The synthesis model is applied by Roomote when it forwards the call and passed to the provider as written, so use that provider's naming @@ -291,6 +306,6 @@ matching on keywords alone. - **Memory has no public service route.** It is never exposed to the internet, and task sandboxes reach it only through Roomote's API with their run token, which grants read access only. -- **To run with no memory at all**, leave both provider keys unset. Deployments - that want to reclaim the resources entirely can delete the Memory service - from their compose file or template. +- **To run with no memory at all**, leave Memory disabled in Settings. + Deployments that want to reclaim the resources entirely can delete the + Memory service from their compose file or template. diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 9821d9019..1394e4045 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -28,11 +28,13 @@ Some hosting deployments provision **Roomote inference** with a limited, spend-capped credit grant during setup. It is separate from your own provider connections: in particular, you can add an OpenRouter key in **Settings > Models** even when Roomote inference is active. If the hosting deployment does -not offer it, the option is not shown. Trial token usage and estimated model -cost appear in task details and [Cost Analytics](/cost-analytics), while the -remaining credit line reflects the hosted grant's authoritative limit. Its key -is stored with your other provider credentials, so deleting the Roomote -inference provider in **Settings > Models** disables it permanently. +not offer it, the option is not shown. The Roomote Cloud trial includes $5 of +inference, enough to complete several tasks before you connect your own +provider. Trial token usage and estimated model cost appear in task details and +[Cost Analytics](/cost-analytics), while the remaining credit line reflects the +hosted grant's authoritative limit. Its key is stored with your other provider +credentials, so deleting the Roomote inference provider in **Settings > Models** +disables it permanently. For example, a deployment might use: @@ -296,6 +298,11 @@ You can also just ask the agent — for example "switch to Fable 5 with max reasoning for the rest of this task". The agent applies the same change through its task-management tool, subject to the same allowed-models list. +Conversational Sessions expose the same model and reasoning choices in their +composer. A selection is saved before the next message can be sent, persists +across page refreshes, and applies to later Fast turns without changing a turn +that is already running. + ## Fast delegation and consultation Fast mode sees the exact coding models enabled for delegated tasks. When it diff --git a/apps/docs/personal-settings.mdx b/apps/docs/personal-settings.mdx index 02f976dee..8af762abb 100644 --- a/apps/docs/personal-settings.mdx +++ b/apps/docs/personal-settings.mdx @@ -64,11 +64,6 @@ 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 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/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index 473e84c27..feb4f7396 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -59,13 +59,20 @@ automatically. If every tag is moderated, the bot needs Manage Threads to apply one. - Unlike a Slack workspace, a Discord server can be public or shared with +Unlike a Slack workspace, a Discord server can be public or shared with people outside your team. Task threads are visible to everyone who can see the channel, including repository names, task descriptions, and PR links. Only add Roomote to servers whose members you trust with that context, and use private channels for sensitive work. +When deployment Memory is enabled, Roomote also collects message history from +server channels visible to `@everyone` where the bot has **View Channel** and +**Read Message History**. Active public threads and forum posts are included; +private channels, private threads, and DMs are not. Removing public visibility +or the bot's read access removes that channel's collected pages after the next +successful permission scan. + If Roomote cannot see the server or channel, confirm that the bot was added to the server and that its role and channel overrides grant the permissions above. Use **Repair** to register commands again and refresh server discovery. @@ -113,9 +120,11 @@ under **Settings > Automations**, the same way you would pick a Slack channel. current one - use `/goal objective:` to keep working toward an objective across multiple turns in an active task thread or DM; this does not create a new task -- enable **Fast response mode** under **Settings > Personal** to send ordinary - Discord DMs, mentions, and eligible thread replies from your linked account - through the fast orchestrator +- ordinary Discord DMs, mentions, and eligible thread replies from your linked + account are always answered in Fast mode, which can delegate repository work + into tasks +- replies in a Fast-owned DM or thread continue that Fast session; replies in + an existing task-only conversation continue or resume the task - when Roomote asks where to run a task, use a button or reply naturally in the same thread or DM; `yes`, `never mind`, and `use API instead` confirm, cancel, or revise the pending route @@ -135,6 +144,10 @@ or inspect repository contents without delegating the work to a task. When the request spans every repository, Fast can delegate it to the deployment's all-repositories environment. +Fast automation reports can be delivered to configured Discord channels or the +automation owner's DM. Replies continue the report's Fast session in Discord, +and the footer also links to the same session in the web app. + Roomote does not quote every message it answers. In a task thread, the conversation itself supplies the context. When you start a task by tagging Roomote in an existing thread, Roomote reads earlier messages in that thread diff --git a/apps/docs/providers/communications/microsoft-teams.mdx b/apps/docs/providers/communications/microsoft-teams.mdx index 68e7ec448..27d7379a6 100644 --- a/apps/docs/providers/communications/microsoft-teams.mdx +++ b/apps/docs/providers/communications/microsoft-teams.mdx @@ -146,8 +146,28 @@ personally to test DMs, and add it to a team or group chat to test mentions. 1. run Roomote with the public URL you entered in Azure 2. send a direct message to the bot, or mention the bot in a channel or group chat -3. confirm Roomote starts a task and posts a Teams reply with the task link -4. reply in the same Teams conversation to send a follow-up to the task +3. confirm Roomote answers in Fast and includes a web continuation link +4. reply to the Fast answer, or reply in the same Fast-owned thread, and confirm + the same Fast session continues + +Ordinary task-entry messages from linked users start in Fast in personal chats, +group chats, and team channels. Channel and group-chat starts require the same +mention or eligible thread-reply signals used for task entry. Sessions are +isolated by linked Roomote user even when several people share one Teams thread. +Unlinked senders receive the account-link prompt instead of starting work. + +Replies to Fast messages continue the bound Fast session. Existing task-only +conversations keep their active-task and resumable-snapshot behavior, and Fast +can delegate repository work into those normal Roomote tasks. If Fast cannot +initialize a new session, Roomote safely falls back to the existing task router. +Bot Framework and Microsoft Graph images, image-only messages, and transcribed +audio continue through the same attachment handling. + +When a task calls `request_user_input`, Teams accepts a text reply in the same +conversation. Sensitive input remains in the web app; Teams does not currently +render interactive answer buttons. Fast automation reports can target a Teams +channel, chat, or owner direct message. Replies continue the report's Fast +session, and its footer opens the same session in the web app. The first verified Teams message also captures the conversation Roomote uses for proactive output. When Slack and Telegram are not connected, setup diff --git a/apps/docs/providers/communications/slack.mdx b/apps/docs/providers/communications/slack.mdx index 113c832c0..2a398a5a9 100644 --- a/apps/docs/providers/communications/slack.mdx +++ b/apps/docs/providers/communications/slack.mdx @@ -181,9 +181,11 @@ Mention the app and use `!fast ` to ask the fast orchestrator a question or delegate work into a task. For example: `@Roomote !fast summarize this thread` or `@Roomote !fast fix the failing CI job`. -Enable **Fast response mode** under **Settings > Personal** to send ordinary -messages from your linked Slack and Discord accounts through the fast -orchestrator without an explicit command. +**Fast response mode** is always on across Slack, Discord, Microsoft Teams, and +Telegram: ordinary messages from your linked account go through the Fast +orchestrator, which can delegate work into tasks. `!fast` remains available for +an explicit Slack Fast request. Replies in a Fast-owned Slack thread continue +that Fast session; replies in an existing task-only thread continue the task. Fast can read a bounded history from the current Slack channel, use MCP servers and user-scoped integrations that you are allowed to access, and delegate @@ -204,5 +206,6 @@ restart Roomote with the matching URL. 1. sign in with Slack, if Slack sign-in is enabled 2. install the Slack app to the workspace 3. mention the app in a channel or send it a direct message -4. confirm Roomote starts a task and replies with a task link -5. reply in the same thread and confirm the message continues the same task +4. confirm Roomote answers in Fast and includes a web continuation link +5. reply in the same thread and confirm the message continues the same Fast + session; when Fast delegates work, confirm the task card stays in that thread diff --git a/apps/docs/providers/communications/telegram.mdx b/apps/docs/providers/communications/telegram.mdx index 026258335..8aa5b90a7 100644 --- a/apps/docs/providers/communications/telegram.mdx +++ b/apps/docs/providers/communications/telegram.mdx @@ -67,7 +67,11 @@ When you save Telegram credentials in Roomote, the webhook is registered automatically at `/api/webhooks/telegram` with the managed secret token and `allowed_updates` including `message`, `callback_query`, and `message_reaction`. Roomote uses newly added 👍 reactions on suggested-task -messages to launch the selected task. +messages to launch the selected task and user-attributed reactions on Roomote +Fast replies as conversation input. Roomote does not request +`message_reaction_count`: those aggregate updates do not identify the reacting +user, so they cannot satisfy Roomote's account and conversation ownership +checks. If the connection check reports a mismatch, delivery error, or stale update configuration, use **Repair** in Telegram settings to re-register it. @@ -99,15 +103,33 @@ up on tasks on their behalf. ## Verify setup 1. send a direct message to the bot, or mention the bot in a group -2. confirm Roomote starts a task and posts a Telegram reply with the task link -3. confirm a new task opens in its own topic when Threaded Mode is enabled -4. when an environment confirmation appears, answer it with a button or a +2. confirm Roomote answers in Fast and includes a web continuation link +3. reply to the Fast answer, or reply in the same Fast-owned topic, and confirm + the same Fast session continues +4. use `/new ` and confirm a fresh task opens in its own topic when + Threaded Mode is enabled +5. when an environment confirmation appears, answer it with a button or a natural reply -5. reply in the task topic to send a follow-up - -Photos are passed to the task as image input. Supported text documents are -downloaded server-side and their extracted content is added to the request; -the bot token is never included in the task prompt or attachment URL. +6. reply in the task topic to send a follow-up + +Ordinary messages from linked users start in Fast in private chats and from bot +mentions in groups. A Fast-owned topic can continue without another mention, +and a direct reply to a Fast message resumes the bound session even when the +chat has no topics. Sessions are isolated by linked Roomote user. `/new` remains +an explicit fresh-task command: it bypasses Fast and snapshot resume so the +request starts a normal task, creating a topic when Telegram supports it. + +Existing task-only chats and topics keep their active-task, +`request_user_input`, routing-confirmation, and resumable-snapshot behavior. If +Fast cannot initialize a new session, Roomote safely falls back to that existing +task router. Fast automation reports can target a Telegram chat, topic, or owner +direct message; replies continue the report's Fast session, and its footer opens +the same session in the web app. + +Photos are passed to Fast or the task as image input. Supported text documents +are downloaded server-side and their extracted content is added to the request; +voice and audio messages are transcribed when supported. The bot token is never +included in the prompt or attachment URL. ## Local URL changes diff --git a/apps/docs/providers/source-control/github.mdx b/apps/docs/providers/source-control/github.mdx index 38f668e17..93c0c45ee 100644 --- a/apps/docs/providers/source-control/github.mdx +++ b/apps/docs/providers/source-control/github.mdx @@ -160,6 +160,11 @@ R_GITHUB_ADDITIONAL_APP_SLUGS=roomote-community,roomote-reviewer Roomote treats only the primary slug and this comma-separated allowlist as managed bot identities. Do not add unrelated GitHub Apps. +Fast can use the GitHub App's Actions access to inspect workflow runs, jobs, and +logs when it explains a CI failure. That diagnostic path is read-only even +though the app keeps broader Actions and Workflows permissions for normal task +and automation workflows. + Generate a private key from the app's **Private keys** section. GitHub downloads a `.pem` file. Convert it to an escaped single-line value before saving it in an env var: @@ -219,11 +224,11 @@ 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 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. +When a Roomote task opens a pull request, actionable review feedback returns to +the owning Session and appears in both Fast and standard web task transcripts. +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. Failed GitHub checks also return to every Roomote task linked to the pull request and to the conversation that started the work. Roomote consolidates a diff --git a/apps/docs/self-hosting.mdx b/apps/docs/self-hosting.mdx index 568b2f10c..23aac299e 100644 --- a/apps/docs/self-hosting.mdx +++ b/apps/docs/self-hosting.mdx @@ -150,14 +150,17 @@ one. Check the running application version and applied schema migration at any time under **Settings → Deployment → Diagnostics**; both are also recorded in every backup bundle's manifest. -After you upgrade, Roomote can surface the new release in the web app: +Roomote surfaces release history and available updates in the web app: - **Admins** on self-hosted deployments see an update notice in the sidenav when - a newer GitHub release is available, with release notes and a link back to - GitHub. Use `roomote upgrade` (or your image roll-forward process) to install - it. -- **Everyone** sees a short "what's new" notice once after the app is upgraded - to a new version, pulled from the same GitHub release notes as the changelog. + a newer GitHub release is available. The update dialog keeps that target and + its GitHub link visible even when the running image does not yet contain the + newer release's notes. Use `roomote upgrade` (or your image roll-forward + process) to install it. +- **Everyone** can open **About Roomote** and select **See all Roomote releases** + to browse the current and previous release notes bundled in the running + image, with the latest release expanded. After an upgrade, Roomote also shows + the latest "what's new" notice once. ## Deployment modes @@ -187,9 +190,9 @@ After you upgrade, Roomote can surface the new release in the web app: The final setup step offers a small set of preselected starter tasks. Keep the ones you want, clear any you do not want to run yet, then select **Go**. If you -start one task, Roomote opens it directly; if you start several, Roomote opens -the task list. You can also clear every selection and finish setup without -starting a task. +keep any selected, Roomote completes setup and opens one **Set up Roomote** +Session that launches the starter tasks and keeps their progress together. You +can also clear every selection and finish setup without starting a Session. After setup, run a small task that uses the first environment if you did not start one from the starter list. A healthy deployment should let you: diff --git a/apps/docs/skills.mdx b/apps/docs/skills.mdx index ca0fd115a..aa4deda8c 100644 --- a/apps/docs/skills.mdx +++ b/apps/docs/skills.mdx @@ -10,6 +10,16 @@ specialized workflow. In the task prompt, type `/` to search the packaged skills available to Roomote, then select one to insert its slash invocation. +In a Fast conversation, start a message with `$skill-name` to explicitly load +a packaged or Settings skill. Roomote resolves Settings skills only from +environments you can use. If different environments define different versions +with the same name, Roomote asks which environment you mean. + +When you ask Fast which skills are available, its unscoped inventory includes +packaged skills and Settings skills from every environment you can use. +Repository-defined skills remain scoped to a specific environment or +repository. + Use Skills when the same guidance keeps showing up across tasks in an environment: a framework-specific review checklist, a release process, a design-system rule, a data workflow, or a repeatable debugging path. @@ -90,10 +100,12 @@ 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. -- **Fast mode can discover repository-defined skills before starting a task.** - It reads only the checked-in skill Markdown from repositories in configured +- **Fast mode can discover both kinds before starting a task.** It can load + Settings skills only from the selected or otherwise authorized environment, + and it reads repository skill Markdown only 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. + matching environment and the task loads the environment-scoped or 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. @@ -120,6 +132,9 @@ change. - **Roomote does not use the skill.** Make the description more specific and confirm the task is running in an environment where the skill is enabled. +- **Fast cannot find `$skill-name`.** Confirm the skill is enabled in an + environment you can use and that the name after `$` exactly matches the + skill's invocation name. - **The skill applies too broadly.** Narrow the description or limit it to fewer environments. - **Two skills seem to overlap.** Keep the more specific one, or split the diff --git a/apps/docs/tasks.mdx b/apps/docs/tasks.mdx index ce1107bea..9ed349c42 100644 --- a/apps/docs/tasks.mdx +++ b/apps/docs/tasks.mdx @@ -4,9 +4,10 @@ icon: clipboard-check description: Inspect the transcript, logs, diffs, previews, and follow-up path before you trust the result. --- -A task is a single unit of Roomote work. It may start from chat, source -control, Linear, or the web dashboard, but the task view gives your team one -shared place to inspect what happened and decide what should happen next. +A task is one independently controllable execution inside a Session. It may +start from chat, source control, Linear, the API, or the web dashboard. The +task workspace remains the place to inspect operational details such as logs, +terminal output, diffs, previews, retries, and artifacts. Use the task view as the handoff point between Roomote and your normal review process. A task is complete only when the evidence is clear enough for a @@ -24,19 +25,17 @@ Before you dive into details, check the basics: - whether the end state matches the kind of outcome you wanted: answer, plan, patch, branch, or PR -## Task board +## Sessions and the task board -Use the board view on the Tasks page to scan shared work by lifecycle. Roomote -places tasks in **Active**, **Needs input**, **Blocked / failed**, or **Done** +Use the board view on the Sessions page to scan shared work by lifecycle. +Roomote places Sessions in **Active**, **Needs input**, **Blocked**, or **Ready** from their current task, goal, and run state, so your team does not need to maintain a separate status field. -Each card shows who started the task, participant avatars, recent activity, and -available workspace or pull-request context. The Done column keeps the six most -recent completed tasks so finished work does not overwhelm active work. Board -and list choices remain in the URL so views are shareable. Roomote also restores -the most recently selected layout from browser storage when you return; if -browser storage is unavailable, the Tasks page falls back to list view. +Each Session card shows its owner and participants, recent activity, delegated +execution count, workspace or pull-request context, aggregate cost, and unread +state. Use the **Tasks** scope when you only want Sessions containing execution +work. Board and list choices remain in the URL so views are shareable. ## Recover from a failed start @@ -50,6 +49,8 @@ reattach any files the new task needs. The task view gives you the working context for a run: +- a header breadcrumb linking back to the owning Session (when you opened the + workspace from a filtered Sessions view, browser Back returns to that view) - conversation history and Roomote updates - inline widgets for structured tables, status cards, plans, and other presentational results an agent chooses to show @@ -70,6 +71,10 @@ For a Markdown plan artifact, **Build this** starts a new task from the plan. After the task starts, use **View task** in the confirmation message to follow its status and review its transcript. +Generated HTML artifacts open in a sandboxed **Preview** by default. Switch to +**Code** to inspect the saved HTML source without executing it in the page that +runs the Roomote dashboard. + ### Native widget styling Roomote widgets inherit the task view's selected light or dark theme. Agents can diff --git a/apps/web/package.json b/apps/web/package.json index c5521568b..8c94d02be 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,7 @@ "@linear/sdk": "^68.0.0", "@melloware/react-logviewer": "^6.4.1", "@octokit/rest": "^22.0.1", + "@primer/octicons-react": "19.33.0", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -149,9 +150,9 @@ "@vitejs/plugin-react": "^4.7.0", "aws-sdk-client-mock": "^4.1.0", "jsdom": "^26.1.0", - "sharp": "0.35.0", "postcss": "^8.5.6", "postcss-load-config": "^6.0.1", + "sharp": "0.35.0", "storybook": "^10.2.10", "tailwindcss": "^4.3.1", "vite": "7.3.5", diff --git a/apps/web/public/automation-icons/git-commit-vertical.png b/apps/web/public/automation-icons/git-commit-vertical.png new file mode 100644 index 000000000..c0bc75ff9 Binary files /dev/null and b/apps/web/public/automation-icons/git-commit-vertical.png differ diff --git a/apps/web/public/automation-icons/git-merge.png b/apps/web/public/automation-icons/git-merge.png new file mode 100644 index 000000000..dd2dba846 Binary files /dev/null and b/apps/web/public/automation-icons/git-merge.png differ diff --git a/apps/web/scripts/generate-automation-icons.mjs b/apps/web/scripts/generate-automation-icons.mjs index f7e8c87ec..fa5b9e3b4 100644 --- a/apps/web/scripts/generate-automation-icons.mjs +++ b/apps/web/scripts/generate-automation-icons.mjs @@ -5,9 +5,11 @@ import path from 'node:path'; import { createElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; +import { GitMergeIcon } from '@primer/octicons-react'; import { ChartColumnIncreasing, BatteryWarning, + GitCommitVertical, GitMergeConflict, Lightbulb, Megaphone, @@ -27,6 +29,7 @@ const outputDirectory = path.resolve( const lucideIcons = { 'battery-warning': BatteryWarning, 'chart-column-increasing': ChartColumnIncreasing, + 'git-commit-vertical': GitCommitVertical, 'git-merge-conflict': GitMergeConflict, lightbulb: Lightbulb, megaphone: Megaphone, @@ -36,6 +39,10 @@ const lucideIcons = { zap: Zap, }; +const octicons = { + 'git-merge': GitMergeIcon, +}; + const simpleIcons = { dependabot: siDependabot, github: siGithub, @@ -59,6 +66,17 @@ function renderLucideIcon(Icon) { ); } +function renderOcticon(Icon) { + return wrapIcon( + renderToStaticMarkup( + createElement(Icon, { + size: 56, + fill: '#000000', + }), + ), + ); +} + function renderSimpleIcon(icon) { return wrapIcon( ``, @@ -73,6 +91,12 @@ for (const [name, Icon] of Object.entries(lucideIcons)) { .toFile(path.join(outputDirectory, `${name}.png`)); } +for (const [name, Icon] of Object.entries(octicons)) { + await sharp(Buffer.from(renderOcticon(Icon))) + .png() + .toFile(path.join(outputDirectory, `${name}.png`)); +} + for (const [name, icon] of Object.entries(simpleIcons)) { await sharp(Buffer.from(renderSimpleIcon(icon))) .png() diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx index 53677afd4..f9d7a509d 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.client.test.tsx @@ -90,14 +90,14 @@ vi.mock('./AnalyticsShell', () => ({ AnalyticsShellDownloadAction: () => null, getAnalyticsHref: (object: 'tasks' | 'pullRequests' | 'costs') => { if (object === 'costs') { - return '/analytics/costs'; + return '/analytics'; } if (object === 'pullRequests') { return '/analytics?object=pullRequests'; } - return '/analytics'; + return '/analytics?object=tasks'; }, })); @@ -140,22 +140,47 @@ describe('Analytics', () => { }); }); - it('treats unknown analytics objects as invalid on the generic /analytics page', () => { - state.searchParams = new URLSearchParams('object=unknown'); + it.each(['unknown', 'sessions'])( + 'uses Costs for unsupported %s analytics objects on the generic /analytics page', + (object) => { + state.searchParams = new URLSearchParams({ + object, + viewBy: 'status', + status: 'active', + }); + + render(); + + expect(screen.getByTestId('active-item')).toHaveTextContent('costs'); + expect( + screen.getByRole('heading', { name: 'Costs' }), + ).toBeInTheDocument(); + expect(hooks.useAnalyticsOverview).toHaveBeenCalledWith( + expect.objectContaining({ + object: 'costs', + viewBy: 'taskType', + filters: {}, + }), + { enabled: true }, + ); + }, + ); + it('uses Costs as the default /analytics view', () => { render(); - expect(screen.getByTestId('active-item')).toHaveTextContent('tasks'); - expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument(); + expect(screen.getByTestId('active-item')).toHaveTextContent('costs'); + expect(screen.getByRole('heading', { name: 'Costs' })).toBeInTheDocument(); }); - it('opens Costs on its dedicated analytics page from generic analytics', () => { + it('opens the canonical Costs URL from Tasks analytics', () => { + state.searchParams = new URLSearchParams('object=tasks'); render(); fireEvent.click(screen.getByRole('button', { name: 'Costs' })); - expect(state.push).toHaveBeenCalledWith('/analytics/costs'); - expect(state.replace).not.toHaveBeenCalled(); + expect(state.replace).toHaveBeenCalledWith('/analytics', { scroll: false }); + expect(state.push).not.toHaveBeenCalled(); }); it('loads costs through the combined analytics overview query', () => { diff --git a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx index 47013ad37..b570ba4e2 100644 --- a/apps/web/src/app/(authenticated)/analytics/Analytics.tsx +++ b/apps/web/src/app/(authenticated)/analytics/Analytics.tsx @@ -10,7 +10,6 @@ import { type AnalyticsFilters, type AnalyticsGranularity, type AnalyticsMetric, - type AnalyticsObject, type TimePeriodFilter, ANALYTICS_OBJECT_CONFIG, getAnalyticsAxisLabel, @@ -21,7 +20,6 @@ import { isValidAnalyticsGranularity, isValidAnalyticsMetric, isValidAnalyticsViewBy, - analyticsObjects, parseTimePeriodParam, } from '@/types'; import { @@ -39,6 +37,7 @@ import { AnalyticsShell, AnalyticsShellDownloadAction, getAnalyticsHref, + type AnalyticsShellItemId, } from './AnalyticsShell'; import { AnalyticsStackedBarChart } from './AnalyticsStackedBarChart'; import { PullRequestSummaryCards } from './PullRequestSummaryCards'; @@ -56,6 +55,8 @@ const analyticsFilterKeys = [ 'taskType', 'provider', 'model', + 'ownerKind', + 'hasExecution', ] as const; type SelectedAnalyticsSegment = { @@ -65,25 +66,34 @@ type SelectedAnalyticsSegment = { seriesLabel: string; }; -const GENERIC_ANALYTICS_OBJECTS: AnalyticsObject[] = ['tasks', 'pullRequests']; +const GENERIC_ANALYTICS_OBJECTS: AnalyticsShellItemId[] = [ + 'costs', + 'tasks', + 'pullRequests', +]; function parseAnalyticsObject( value: string | null, - allowedObjects: readonly AnalyticsObject[] = analyticsObjects, -): AnalyticsObject { - if (value && allowedObjects.includes(value as AnalyticsObject)) { - return value as AnalyticsObject; + allowedObjects: readonly AnalyticsShellItemId[] = GENERIC_ANALYTICS_OBJECTS, +): AnalyticsShellItemId { + if (value && allowedObjects.includes(value as AnalyticsShellItemId)) { + return value as AnalyticsShellItemId; } - return allowedObjects[0] ?? analyticsObjects[0]; + return allowedObjects[0] ?? 'costs'; } function getFiltersFromSearchParams( searchParams: URLSearchParams, + allowedKeys: readonly AnalyticsDimension[], ): AnalyticsFilters { const filters: AnalyticsFilters = {}; for (const key of analyticsFilterKeys) { + if (!allowedKeys.includes(key)) { + continue; + } + const values = searchParams.getAll(key).filter(Boolean); if (values.length > 0) { filters[key] = values; @@ -95,7 +105,7 @@ function getFiltersFromSearchParams( export function Analytics({ fixedObject, -}: { fixedObject?: AnalyticsObject } = {}) { +}: { fixedObject?: AnalyticsShellItemId } = {}) { const router = useRouter(); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -110,7 +120,11 @@ export function Analytics({ fixedObject ?? parseAnalyticsObject(searchParams.get('object'), GENERIC_ANALYTICS_OBJECTS); const basePath = fixedObject ? getAnalyticsHref(fixedObject) : '/analytics'; - const filters = getFiltersFromSearchParams(searchParams); + const config = ANALYTICS_OBJECT_CONFIG[object]; + const filters = getFiltersFromSearchParams( + searchParams, + config.filterDimensions, + ); const timePeriod = parseTimePeriodParam(searchParams.get('timePeriod'), 7); const requestedViewBy = searchParams.get('viewBy'); const granularityParam = searchParams.get('granularity'); @@ -162,7 +176,6 @@ export function Analytics({ : null, ); - const config = ANALYTICS_OBJECT_CONFIG[object]; const axisLabel = getAnalyticsAxisLabel(object, metric); const chart = object === 'pullRequests' @@ -204,12 +217,12 @@ export function Analytics({ const resetSelection = () => setSelectedSegment(null); - const handleObjectChange = (nextObject: AnalyticsObject) => { + const handleObjectChange = (nextObject: AnalyticsShellItemId) => { if (nextObject === object) { return; } - if (fixedObject || nextObject === 'costs') { + if (fixedObject) { resetSelection(); startParamsTransition(() => { router.push(getAnalyticsHref(nextObject)); @@ -219,12 +232,13 @@ export function Analytics({ resetSelection(); updateParams((params) => { - if (nextObject === 'tasks') { + if (nextObject === 'costs') { params.delete('object'); + params.delete('viewBy'); } else { params.set('object', nextObject); + params.set('viewBy', getDefaultAnalyticsViewBy(nextObject)); } - params.set('viewBy', getDefaultAnalyticsViewBy(nextObject)); params.delete('metric'); for (const key of analyticsFilterKeys) { params.delete(key); diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx index 7f0acd1fd..c0513d34c 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDetailsDialog.tsx @@ -42,12 +42,14 @@ type AnalyticsDetailsDialogProps = { }; const DIALOG_WIDTH_BY_OBJECT: Record = { + sessions: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', tasks: 'md:w-[min(96vw,1160px)] md:max-w-[1160px]', pullRequests: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', costs: 'md:w-[min(96vw,1240px)] md:max-w-[1240px]', }; const TABLE_MIN_WIDTH_BY_OBJECT: Record = { + sessions: 'min-w-[900px] md:min-w-[1040px]', tasks: 'min-w-[980px] md:min-w-[1100px]', pullRequests: 'min-w-[1140px] md:min-w-[1220px]', costs: 'min-w-[1140px] md:min-w-[1220px]', diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts index 0e440a02d..35a5b9c57 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsDimensionIcons.ts @@ -10,6 +10,7 @@ import { GitPullRequest, RadioTower, VectorSquare, + Rows4, } from '@/components/system'; export const ANALYTICS_DIMENSION_ICONS: Record< @@ -25,4 +26,6 @@ export const ANALYTICS_DIMENSION_ICONS: Record< taskType: Bot, provider: Cpu, model: Brain, + ownerKind: Bot, + hasExecution: Rows4, }; diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx index c2dcda2d3..e01eaa501 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsFilterBar.tsx @@ -36,9 +36,11 @@ const ANALYTICS_DIMENSION_PLURAL_LABELS: Record = { status: 'Statuses', repo: 'Repos', author: 'Authors', - taskType: 'Task Types', + taskType: 'Types', provider: 'Providers', model: 'Models', + ownerKind: 'Owner kinds', + hasExecution: 'Execution states', }; type AnalyticsFilterBarProps = { diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.client.test.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.client.test.tsx index 49f31b8e2..11c8f0e1a 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.client.test.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.client.test.tsx @@ -66,22 +66,23 @@ describe('AnalyticsShell', () => { ); const nav = screen.getByRole('navigation'); - const navItems = within(nav).getAllByText(/^(Tasks|Costs)$/); + const navItems = within(nav).getAllByText(/^(Costs|Tasks)$/); expect(screen.getByRole('heading', { name: 'PRs' })).toBeInTheDocument(); expect(within(nav).queryByText('PRs')).not.toBeInTheDocument(); + expect(within(nav).queryByText('Sessions')).not.toBeInTheDocument(); expect(navItems.map((item) => item.textContent)).toEqual([ - 'Tasks', 'Costs', + 'Tasks', ]); }); - it('uses Tasks as the default analytics URL and keeps PRs and Costs addressable', () => { - expect(getAnalyticsHref('tasks')).toBe('/analytics'); + it('uses Costs as the default analytics URL and keeps Tasks and PRs addressable', () => { + expect(getAnalyticsHref('costs')).toBe('/analytics'); + expect(getAnalyticsHref('tasks')).toBe('/analytics?object=tasks'); expect(getAnalyticsHref('pullRequests')).toBe( '/analytics?object=pullRequests', ); - expect(getAnalyticsHref('costs')).toBe('/analytics/costs'); }); it('names the export action in each state', () => { diff --git a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx index 2311c1ac7..337e8614a 100644 --- a/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx +++ b/apps/web/src/app/(authenticated)/analytics/AnalyticsShell.tsx @@ -12,22 +12,22 @@ import { } from '@/components/system'; import { PageNavigationShell } from '@/components/settings/PageNavigationShell'; -type AnalyticsShellItemId = AnalyticsObject; +export type AnalyticsShellItemId = Exclude; export function getAnalyticsHref(itemId: AnalyticsShellItemId) { switch (itemId) { case 'tasks': - return '/analytics'; + return '/analytics?object=tasks'; case 'pullRequests': return '/analytics?object=pullRequests'; case 'costs': - return '/analytics/costs'; + return '/analytics'; } } const ANALYTICS_SHELL_ITEMS = [ - { id: 'tasks', label: 'Tasks', icon: ChartColumnIncreasing }, { id: 'costs', label: 'Costs', icon: CircleDollarSign }, + { id: 'tasks', label: 'Tasks', icon: ChartColumnIncreasing }, ] as const satisfies Array<{ id: AnalyticsObject; label: string; @@ -60,7 +60,7 @@ export function AnalyticsShell({ const resolvedActiveItemId = (items.some((item) => item.id === activeItemId) ? activeItemId - : items[0]?.id) ?? 'tasks'; + : items[0]?.id) ?? 'costs'; return ( | undefined = [ { id: 'env-2', name: 'Secondary Env' }, ]; let currentEnvironmentsPending = false; -let currentCommunicationsFastModeDefault = false; -let currentPersonalPreferencesLoading = false; const { mockPush, @@ -31,8 +28,6 @@ const { mockUseCreateStandardTaskRun, mockCreateStandardTaskRun, mockUseLaunchTaskModels, - mockUseRouteHomeTask, - mockRouteHomeTask, mockPreparePromptAttachments, mockStartFastSession, } = vi.hoisted(() => ({ @@ -44,8 +39,6 @@ const { mockUseCreateStandardTaskRun: vi.fn(), mockCreateStandardTaskRun: vi.fn(), mockUseLaunchTaskModels: vi.fn(), - mockUseRouteHomeTask: vi.fn(), - mockRouteHomeTask: vi.fn(), mockPreparePromptAttachments: vi.fn(), mockStartFastSession: vi.fn(), })); @@ -95,23 +88,8 @@ 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, useStartFastSession: () => ({ isPending: false, mutateAsync: mockStartFastSession, @@ -133,17 +111,6 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: mockUseLaunchTaskModels, })); -vi.mock('@/components/system', async () => { - const actual = await vi.importActual( - '@/components/system', - ); - - return { - ...actual, - Loader2: (props: React.ComponentProps<'svg'>) => , - }; -}); - vi.mock('@/lib', () => ({ processImageFiles: mockProcessImageFiles, })); @@ -161,6 +128,7 @@ vi.mock('./BottomSheetTabs', () => ({ BottomSheetTabs: () =>
Tabs
, })); +import { NewTaskForm } from '@/components/tasks/NewTaskForm'; import { Home } from './Home'; vi.mock('@/components/tasks', async () => { @@ -179,13 +147,11 @@ vi.mock('@/components/tasks', async () => { ...actual, SelectWorkspace: ({ allowAuto, - allowFast, autoSelectDefaultWorkspace, onInvalidWorkspaceReset, allowBranchSelection, }: { allowAuto?: boolean; - allowFast?: boolean; autoSelectDefaultWorkspace?: boolean; onInvalidWorkspaceReset?: () => void; allowBranchSelection?: boolean; @@ -234,18 +200,16 @@ vi.mock('@/components/tasks', async () => { > Use auto workspace - {allowFast && ( - - )} + + + ), +})); + +import { SessionsFilters } from './SessionsFilters'; + +const baseProps = { + userId: null, + timePeriod: 'all' as const, + sourceOptions: ['slack', 'web'], +}; + +describe('SessionsFilters', () => { + beforeEach(() => { + replaceMock.mockReset(); + searchParamsMock.current = new URLSearchParams(); + localStorage.clear(); + }); + + it.each(['repository', 'pullRequest', 'model', 'source'])( + 'shows advanced filters when the URL supplies %s without changing the saved preference', + (param) => { + localStorage.setItem( + 'roomote-sessions-advanced-filters-visible', + 'false', + ); + searchParamsMock.current = new URLSearchParams(`${param}=active`); + + render(); + + expect(screen.getByTestId('advanced-task-filters')).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'Toggle advanced filters' }), + ).toBeDisabled(); + expect( + localStorage.getItem('roomote-sessions-advanced-filters-visible'), + ).toBe('false'); + }, + ); + + it('shows the user and automation filter with the primary controls', () => { + render(); + + expect(screen.getByTestId('time-filter')).toHaveAttribute( + 'data-show-user', + 'true', + ); + fireEvent.click(screen.getByRole('button', { name: 'Choose automation' })); + expect(replaceMock).toHaveBeenCalledWith( + '/sessions?user=automation%3Asentry_triage', + ); + }); + + it('keeps a selected user filter in the primary controls', () => { + searchParamsMock.current = new URLSearchParams('user=user-1'); + + render(); + + expect(screen.getByTestId('time-filter')).toHaveAttribute( + 'data-user-id', + 'user-1', + ); + expect( + screen.queryByTestId('advanced-task-filters'), + ).not.toBeInTheDocument(); + }); + + it('restores the saved hidden preference after URL filters are removed', () => { + localStorage.setItem('roomote-sessions-advanced-filters-visible', 'false'); + searchParamsMock.current = new URLSearchParams('source=slack'); + const { rerender } = render( + , + ); + + expect(screen.getByTestId('advanced-task-filters')).toBeInTheDocument(); + + searchParamsMock.current = new URLSearchParams(); + rerender(); + + expect( + screen.queryByTestId('advanced-task-filters'), + ).not.toBeInTheDocument(); + }); + + it('hides advanced filters by default and persists their visibility', async () => { + const { unmount } = render(); + const advancedFiltersButton = screen.getByRole('button', { + name: 'Toggle advanced filters', + }); + + expect( + screen.queryByTestId('advanced-task-filters'), + ).not.toBeInTheDocument(); + expect(advancedFiltersButton).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(advancedFiltersButton); + + expect(screen.getByTestId('advanced-task-filters')).toBeInTheDocument(); + expect(advancedFiltersButton).toHaveAttribute('aria-pressed', 'true'); + expect( + localStorage.getItem('roomote-sessions-advanced-filters-visible'), + ).toBe('true'); + + unmount(); + render(); + await waitFor(() => + expect(screen.getByTestId('advanced-task-filters')).toBeInTheDocument(), + ); + }); + + it('expands search to the left, shows a submit hint, and switches views', () => { + render(); + const searchButton = screen.getByRole('button', { + name: 'Toggle session search', + }); + + expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument(); + expect(searchButton).toHaveAttribute('aria-pressed', 'false'); + fireEvent.click(searchButton); + + const searchInput = screen.getByPlaceholderText('Search...'); + expect(searchInput).toHaveFocus(); + expect(searchInput.compareDocumentPosition(searchButton)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(searchButton).toHaveAttribute('aria-pressed', 'true'); + expect( + screen.queryByRole('button', { name: 'Submit session search' }), + ).not.toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: 'release notes' } }); + fireEvent.click( + screen.getByRole('button', { name: 'Submit session search' }), + ); + expect(replaceMock).toHaveBeenCalledWith('/sessions?q=release+notes'); + + fireEvent.click(screen.getByRole('button', { name: 'Board view' })); + expect(replaceMock).toHaveBeenCalledWith('/sessions?view=board'); + expect(localStorage.getItem('roomote-sessions-view')).toBe('board'); + }); + + it('clears the search query from the URL when search is closed', () => { + searchParamsMock.current = new URLSearchParams( + 'q=release+notes&view=board', + ); + render( + , + ); + + const searchButton = screen.getByRole('button', { + name: 'Toggle session search', + }); + expect(searchButton).toHaveAttribute('aria-pressed', 'true'); + + fireEvent.click(searchButton); + + expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument(); + expect(searchButton).toHaveAttribute('aria-pressed', 'false'); + expect(replaceMock).toHaveBeenCalledWith('/sessions?view=board'); + }); +}); diff --git a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx index 51b8c9fb2..4fc5b507f 100644 --- a/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx +++ b/apps/web/src/app/(authenticated)/sessions/SessionsFilters.tsx @@ -1,66 +1,418 @@ 'use client'; -import { useCallback } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { getSessionStatusLabel, SESSION_STATUSES } from '@roomote/types'; + import type { TimePeriodFilter } from '@/types'; +import { cn } from '@/lib/utils'; +import { getSessionSurfaceLabel } from '@/components/sessions/session-surfaces'; import { TaskFilters } from '@/components/tasks'; +import { + Activity, + Button, + ChevronDown, + Columns3, + CornerDownLeftIcon, + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, + Input, + List, + MessagesSquare, + Search, + Share2, + SlidersHorizontal, +} from '@/components/system'; + +const ADVANCED_FILTERS_STORAGE_KEY = + 'roomote-sessions-advanced-filters-visible'; +const SESSIONS_VIEW_STORAGE_KEY = 'roomote-sessions-view'; +const ADVANCED_FILTER_PARAMS = [ + 'repository', + 'pullRequest', + 'model', + 'source', +] as const; + +const activeFilterStyle = + 'text-accent-foreground font-medium border-b-2 border-accent-foreground focus-visible:border-accent-foreground rounded-none'; +const defaultFilterStyle = 'text-muted-foreground hover:text-accent-foreground'; + +const scopeOptions = [ + { value: 'all', label: 'All sessions' }, + { value: 'tasks', label: 'Tasks' }, + { value: 'reviews', label: 'Reviews' }, + { value: 'automations', label: 'Automations' }, +]; + +type FilterOption = { value: string; label: string }; + +function SessionFilterDropdown({ + ariaLabel, + icon, + value, + options, + onChange, +}: { + ariaLabel: string; + icon: ReactNode; + value: string; + options: FilterOption[]; + onChange: (value: string) => void; +}) { + const active = value !== 'all'; + const label = options.find((option) => option.value === value)?.label; + + return ( + + + + + + {options.map((option) => ( + onChange(option.value)} + className="cursor-pointer" + > + {option.label} + + ))} + + + ); +} + +function readStoredBoolean(key: string): boolean { + try { + return window.localStorage.getItem(key) === 'true'; + } catch { + return false; + } +} + +function writeStoredPreference(key: string, value: string): void { + try { + window.localStorage.setItem(key, value); + } catch { + // Ignore localStorage failures. + } +} export function SessionsFilters({ userId, timePeriod, + scope = 'all', + status = 'all', + view = 'list', + query = '', + repository = null, + pullRequest = null, + model = null, + source = 'all', + sourceOptions, }: { userId: string | null; timePeriod: TimePeriodFilter; + scope?: string; + status?: string; + view?: string; + query?: string; + repository?: string | null; + pullRequest?: string | null; + model?: string | null; + source?: string; + sourceOptions: string[]; }) { const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); + const searchInputRef = useRef(null); + const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); + const [showSearch, setShowSearch] = useState(Boolean(query)); + const [searchValue, setSearchValue] = useState(query); + const hasAdvancedUrlFilters = ADVANCED_FILTER_PARAMS.some((param) => + searchParams.has(param), + ); + const advancedFiltersVisible = hasAdvancedUrlFilters || showAdvancedFilters; const updateParams = useCallback( (mutate: (params: URLSearchParams) => void) => { const params = new URLSearchParams(searchParams); mutate(params); - // Filter changes restart pagination. params.delete('before'); - const query = params.toString(); - router.replace(query ? `${pathname}?${query}` : pathname); + const nextQuery = params.toString(); + router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname); }, [router, pathname, searchParams], ); - return ( - - updateParams((params) => { - if (id && id !== 'all') { - params.set('user', id); - } else { - params.delete('user'); - } - }) + useEffect(() => { + setShowAdvancedFilters(readStoredBoolean(ADVANCED_FILTERS_STORAGE_KEY)); + }, []); + + useEffect(() => { + setSearchValue(query); + if (query) setShowSearch(true); + }, [query]); + + useEffect(() => { + if (showSearch) searchInputRef.current?.focus(); + }, [showSearch]); + + useEffect(() => { + if (searchParams.has('view')) return; + + try { + if (window.localStorage.getItem(SESSIONS_VIEW_STORAGE_KEY) === 'board') { + updateParams((params) => params.set('view', 'board')); } - onRepositoryChange={() => {}} - onPullRequestChange={() => {}} - onModelChange={() => {}} - onTimePeriodChange={(period) => - updateParams((params) => { - if (period === 'all') { - params.delete('period'); - } else { - params.set('period', String(period)); + } catch { + // Ignore localStorage failures. + } + }, [searchParams, updateParams]); + + const updateNullableParam = (name: string, value: string | null) => + updateParams((params) => { + if (value) params.set(name, value); + else params.delete(name); + }); + + const sessionStatusOptions = [ + { value: 'all', label: 'All statuses' }, + ...SESSION_STATUSES.map((value) => ({ + value, + label: getSessionStatusLabel(value), + })), + ]; + const sessionSourceOptions = [ + { value: 'all', label: 'All sources' }, + ...sourceOptions.map((value) => ({ + value, + label: getSessionSurfaceLabel(value), + })), + ]; + const isSearchActive = showSearch; + + return ( +
+ } + value={scope} + options={scopeOptions} + onChange={(value) => + updateParams((params) => params.set('scope', value)) + } + /> + } + value={status} + options={sessionStatusOptions} + onChange={(value) => + updateParams((params) => { + if (value === 'all') params.delete('status'); + else params.set('status', value); + }) + } + /> + + updateNullableParam('user', value && value !== 'all' ? value : null) + } + onRepositoryChange={() => undefined} + onPullRequestChange={() => undefined} + onModelChange={() => undefined} + onTimePeriodChange={(period) => + updateParams((params) => { + if (period === 'all') params.delete('period'); + else params.set('period', String(period)); + }) + } + showRepository={false} + showPullRequest={false} + showModel={false} + showTaskType={false} + /> + + {advancedFiltersVisible ? ( + <> + + updateNullableParam( + 'user', + value && value !== 'all' ? value : null, + ) + } + onRepositoryChange={(value) => + updateNullableParam('repository', value) + } + onPullRequestChange={(value) => + updateNullableParam('pullRequest', value) + } + onModelChange={(value) => updateNullableParam('model', value)} + onTimePeriodChange={() => undefined} + showUser={false} + showTimePeriod={false} + showTaskType={false} + /> + } + value={source} + options={sessionSourceOptions} + onChange={(value) => + updateParams((params) => { + if (value === 'all') params.delete('source'); + else params.set('source', value); + }) + } + /> + + ) : null} + +
+ + {showSearch ? ( +
{ + event.preventDefault(); + updateNullableParam('q', searchValue.trim() || null); + }} + > + setSearchValue(event.target.value)} + aria-label="Search sessions" + placeholder="Search..." + className="h-8 w-40 pr-9 sm:w-48" + /> + {searchValue ? ( + + ) : null} +
+ ) : null} + +
+ + +
+
+
); } diff --git a/apps/web/src/app/(authenticated)/sessions/page.tsx b/apps/web/src/app/(authenticated)/sessions/page.tsx index 8a794905c..6b8a852e6 100644 --- a/apps/web/src/app/(authenticated)/sessions/page.tsx +++ b/apps/web/src/app/(authenticated)/sessions/page.tsx @@ -1,78 +1,161 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; +import { + getSessionStatusLabel, + SESSION_STATUSES, + type SessionStatus, +} from '@roomote/types'; + import { parseTimePeriodParam } from '@/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessions } from '@/lib/server/fast-sessions'; +import { + getSessions, + getSessionSources, + type SessionScope, +} from '@/lib/server/sessions'; import { Empty, EmptyDescription, EmptyHeader } from '@/components/system'; -import { FastSessionCard } from './FastSessionCard'; import { SessionsFilters } from './SessionsFilters'; +import { SessionCard } from './SessionCard'; export default async function SessionsPage({ searchParams, }: { - searchParams?: Promise<{ before?: string; user?: string; period?: string }>; + searchParams?: Promise<{ + before?: string; + user?: string; + period?: string; + scope?: string; + status?: string; + view?: string; + q?: string; + repository?: string; + pullRequest?: string; + source?: string; + model?: string; + }>; }) { - const [authorizedUser, { before, user, period } = {}] = await Promise.all([ + const [authorizedUser, params = {}] = await Promise.all([ authorize(), searchParams, ]); if (!authorizedUser.success) { notFound(); } + const { before, user, period, q } = params; + const scope = ['all', 'tasks', 'reviews', 'automations'].includes( + params.scope ?? '', + ) + ? (params.scope as SessionScope) + : 'all'; + const status = (SESSION_STATUSES as readonly string[]).includes( + params.status ?? '', + ) + ? (params.status as SessionStatus) + : undefined; + const view = params.view === 'board' ? 'board' : 'list'; const timePeriod = parseTimePeriodParam(period ?? null, 'all'); - const { sessions, nextCursor } = await getFastSessions(authorizedUser, { - before, - filterUserId: user ?? null, - timePeriod, - }); - + const [result, sources] = await Promise.all([ + getSessions(authorizedUser, { + before, + user, + period: timePeriod, + scope, + status, + q, + repository: params.repository, + pullRequest: params.pullRequest, + source: params.source, + model: params.model, + }), + getSessionSources(authorizedUser), + ]); const olderParams = new URLSearchParams(); - if (nextCursor) olderParams.set('before', nextCursor); - if (user) olderParams.set('user', user); - if (timePeriod !== 'all') olderParams.set('period', String(timePeriod)); + Object.entries(params).forEach(([key, value]) => { + if (value && key !== 'before') olderParams.set(key, value); + }); + if (result.nextCursor) olderParams.set('before', result.nextCursor); + const columns = SESSION_STATUSES; return (
-
- -
+
- -
-
- {sessions.length === 0 ? ( - - - No sessions yet. - - - ) : ( -
- {sessions.map((session) => ( - - ))} - {nextCursor ? ( -
- - Show older sessions - +
+ {result.sessions.length === 0 ? ( + + + No sessions found. + + + ) : view === 'board' ? ( +
+ {columns.map((column) => ( +
+

+ {getSessionStatusLabel(column)} +

+
+ {result.sessions + .filter((session) => + column === 'ready' + ? !session.cachedStatus || + session.cachedStatus === column + : session.cachedStatus === column, + ) + .map((session) => ( + + ))}
- ) : null} -
- )} -
-
+ + ))} +
+ ) : ( +
+ {result.sessions.map((session) => ( + + ))} +
+ )} + {result.nextCursor ? ( +
+ + Show older sessions + +
+ ) : null} +
); } diff --git a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx index b7e41924c..645172511 100644 --- a/apps/web/src/app/(authenticated)/tasks/Tasks.tsx +++ b/apps/web/src/app/(authenticated)/tasks/Tasks.tsx @@ -5,8 +5,6 @@ import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; -import { ALL_REPOSITORIES } from '@roomote/types'; - import { type Filter, type TimePeriodFilter, @@ -14,7 +12,11 @@ import { parseTimePeriodParam, } from '@/types'; -import { DEFAULT_VISIBLE_TASK_WORKFLOWS, getTaskCategoryById } from '@/lib'; +import { + DEFAULT_VISIBLE_TASK_WORKFLOWS, + formatRepositoryName, + getTaskCategoryById, +} from '@/lib'; import { cn } from '@/lib/utils'; import { useAuthorizedUser } from '@/hooks/useUser'; @@ -320,7 +322,7 @@ export const Tasks = () => { const pullRequestLabel = pullRequest === HAS_PULL_REQUEST_FILTER_VALUE ? 'Has PR' - : pullRequest.replace(ALL_REPOSITORIES, 'All Repositories'); + : formatRepositoryName(pullRequest); result.push({ type: 'pullRequest', diff --git a/apps/web/src/app/(authenticated)/tasks/page.tsx b/apps/web/src/app/(authenticated)/tasks/page.tsx index 32b5c5f48..cbc48735e 100644 --- a/apps/web/src/app/(authenticated)/tasks/page.tsx +++ b/apps/web/src/app/(authenticated)/tasks/page.tsx @@ -6,6 +6,8 @@ import { toast } from 'sonner'; import { Tasks } from './Tasks'; +// Sessions is the primary workspace; this page is intentionally unlinked from +// the primary nav but stays fully functional for direct URLs and deep links. export default function Page() { const searchParams = useSearchParams(); const error = searchParams.get('error'); diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx index 8f3050695..701ee19a9 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx @@ -98,7 +98,7 @@ describe('StepConfigureInference', () => { screen.getByRole('heading', { name: 'Configure inference' }), ).toBeInTheDocument(); expect(screen.getByText(/Roomote needs a model provider/).textContent).toBe( - 'Roomote needs a model provider for, you know, AI stuff.If you want, we can give you a few credits to try Roomote out or you can configure your provider directly.', + 'Roomote needs a model provider for, you know, AI stuff.Your Roomote Cloud trial includes $5 of inference, enough to complete several tasks and get a practical sense of what Roomote can do. You can also configure your own provider directly.', ); expect( screen.getByRole('button', { diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx index 06c7d88dc..190097fe8 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx @@ -48,8 +48,9 @@ export function StepConfigureInference({

Roomote needs a model provider for, you know, AI stuff.
- If you want, we can give you a few credits to try Roomote out or you - can configure your provider directly. + Your Roomote Cloud trial includes $5 of inference, enough to complete + several tasks and get a practical sense of what Roomote can do. You + can also configure your own provider directly.

diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx index b9d6c5799..58fd446ec 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.client.test.tsx @@ -26,8 +26,7 @@ const userState = vi.hoisted(() => ({ })); const starterResultState = vi.hoisted(() => ({ queue: [] as Array<{ - launched: Array<{ starterTaskId: string; taskId: string }>; - failed: Array<{ starterTaskId: string; error: string }>; + sessionId: string | null; setupCompleted: boolean; completionError: string | null; }>, @@ -59,11 +58,7 @@ vi.mock('@tanstack/react-query', async () => { mutate: async (input: { selectedStarterTaskIds: string[] }) => { starterMutateMock(input); const result = starterResultState.queue.shift() ?? { - launched: input.selectedStarterTaskIds.map((starterTaskId) => ({ - starterTaskId, - taskId: `task-${starterTaskId}`, - })), - failed: [], + sessionId: 'setup-session-1', setupCompleted: true, completionError: null, }; @@ -307,7 +302,7 @@ describe('Setup StepInvoke', () => { } }); - it('launches a single selected task and routes to it', async () => { + it('submits a single selected starter task and routes to the setup session', async () => { render(); for (const title of STARTER_TASK_TITLES.slice(1)) { @@ -325,7 +320,7 @@ describe('Setup StepInvoke', () => { }); await waitFor(() => { - expect(replaceMock).toHaveBeenCalledWith('/task/task-speed-up-ci'); + expect(replaceMock).toHaveBeenCalledWith('/sessions/setup-session-1'); }); expect(setQueryDataMock).toHaveBeenCalledWith( @@ -347,7 +342,7 @@ describe('Setup StepInvoke', () => { expect(mutateMock).not.toHaveBeenCalled(); }); - it('launches every selected task and routes to the tasks list', async () => { + it('submits the full selection and routes to the one setup session', async () => { render(); clickGo(); @@ -367,22 +362,15 @@ describe('Setup StepInvoke', () => { }); await waitFor(() => { - expect(replaceMock).toHaveBeenCalledWith('/tasks'); + expect(replaceMock).toHaveBeenCalledWith('/sessions/setup-session-1'); }); }); - it('keeps failures visible and retries only tasks that have not launched', async () => { + it('surfaces a completion error and retries with the same selection', async () => { starterResultState.queue.push({ - launched: [ - { starterTaskId: 'speed-up-ci', taskId: 'task-ci' }, - { starterTaskId: 'security-scan', taskId: 'task-security' }, - ], - failed: [ - { starterTaskId: 'fix-test-flakes', error: 'No repositories.' }, - { starterTaskId: 'update-dependencies', error: 'No repositories.' }, - ], + sessionId: null, setupCompleted: false, - completionError: null, + completionError: 'settings write failed', }); render(); @@ -390,45 +378,36 @@ describe('Setup StepInvoke', () => { clickGo(); await waitFor(() => { - expect( - screen.getByText( - /Couldn't start: Fix test flakes, Update dependencies/, - ), - ).toBeInTheDocument(); + expect(screen.getByText(/settings write failed/)).toBeInTheDocument(); }); expect(replaceMock).not.toHaveBeenCalled(); - expect( - screen.getByRole('checkbox', { name: 'Speed up CI' }), - ).toBeDisabled(); - expect(screen.getAllByText('Started.')).toHaveLength(2); fireEvent.click(screen.getByRole('button', { name: /retry/i })); await waitFor(() => { expect(starterMutateMock).toHaveBeenLastCalledWith({ launchBatchId: '11111111-1111-4111-8111-111111111111', - selectedStarterTaskIds: ['fix-test-flakes', 'update-dependencies'], + selectedStarterTaskIds: [ + 'speed-up-ci', + 'security-scan', + 'fix-test-flakes', + 'update-dependencies', + ], anonymousAnalyticsEnabled: true, productUpdatesEnabled: true, }); }); await waitFor(() => { - expect(replaceMock).toHaveBeenCalledWith('/tasks'); + expect(replaceMock).toHaveBeenCalledWith('/sessions/setup-session-1'); }); }); it('reuses the launch batch id after an ambiguous result and remount', async () => { starterResultState.queue.push({ - launched: [], - failed: [ - { starterTaskId: 'speed-up-ci', error: 'Request timed out.' }, - { starterTaskId: 'security-scan', error: 'Request timed out.' }, - { starterTaskId: 'fix-test-flakes', error: 'Request timed out.' }, - { starterTaskId: 'update-dependencies', error: 'Request timed out.' }, - ], + sessionId: null, setupCompleted: false, - completionError: null, + completionError: 'Request timed out.', }); const firstRender = render(); @@ -455,44 +434,6 @@ describe('Setup StepInvoke', () => { ); }); - it('keeps setup incomplete and offers retry when completion fails after launches', async () => { - starterResultState.queue.push({ - launched: [{ starterTaskId: 'speed-up-ci', taskId: 'task-ci' }], - failed: [], - setupCompleted: false, - completionError: 'settings write failed', - }); - - render(); - - for (const title of STARTER_TASK_TITLES.slice(1)) { - fireEvent.click(screen.getByRole('checkbox', { name: title })); - } - clickGo(); - - await waitFor(() => { - expect(screen.getByText(/settings write failed/)).toBeInTheDocument(); - }); - expect(replaceMock).not.toHaveBeenCalled(); - - // The remaining selection is empty, so the retry completes setup through - // the starter mutation and routes to the already-launched task. - fireEvent.click(screen.getByRole('button', { name: /retry/i })); - - await waitFor(() => { - expect(starterMutateMock).toHaveBeenLastCalledWith({ - launchBatchId: '11111111-1111-4111-8111-111111111111', - selectedStarterTaskIds: [], - anonymousAnalyticsEnabled: true, - productUpdatesEnabled: true, - }); - }); - - await waitFor(() => { - expect(replaceMock).toHaveBeenCalledWith('/task/task-ci'); - }); - }); - it('optimistically completes setup and onboarding before routing away when nothing is selected', async () => { const onTryItOut = vi.fn(); diff --git a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx index b085d2b96..7295bd374 100644 --- a/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepInvoke.tsx @@ -22,7 +22,6 @@ import { useEnvironments } from '@/hooks/environments/useEnvironments'; import { useUser } from '@/hooks/useUser'; import { SETUP_STARTER_TASKS, - getSetupStarterTask, type SetupStarterTaskId, } from '@/lib/setup-starter-tasks'; import { buildInvokeMethods } from '../invokeMethods'; @@ -285,28 +284,6 @@ function CompletionPreferences({ ); } -type StarterTaskLaunch = { - starterTaskId: SetupStarterTaskId; - taskId: string; -}; - -function buildLaunchErrorMessage(result: { - failed: Array<{ starterTaskId: SetupStarterTaskId; error: string }>; - completionError: string | null; -}) { - const [firstFailure] = result.failed; - - if (firstFailure) { - const failedTitles = result.failed - .map((failure) => getSetupStarterTask(failure.starterTaskId).title) - .join(', '); - - return `Couldn't start: ${failedTitles} (${firstFailure.error}). Press Retry to try again.`; - } - - return `${result.completionError ?? 'Setup could not be completed.'} Press Retry to try again.`; -} - function StarterTasksStepContent({ onTryItOut, linkSuggestedTasks = false, @@ -321,7 +298,6 @@ function StarterTasksStepContent({ const [selectedIds, setSelectedIds] = useState(() => SETUP_STARTER_TASKS.map((starterTask) => starterTask.id), ); - const [launchedTasks, setLaunchedTasks] = useState([]); const [launchError, setLaunchError] = useState(null); const [anonymousAnalyticsEnabled, setAnonymousAnalyticsEnabled] = useState(true); @@ -336,14 +312,9 @@ function StarterTasksStepContent({ const launchStarterTasks = useMutation( trpc.setup.completeWithStarterTasks.mutationOptions({ onSuccess: async (result) => { - const allLaunched = [...launchedTasks, ...(result?.launched ?? [])]; - setLaunchedTasks(allLaunched); - - if (!result || result.failed.length > 0 || !result.setupCompleted) { + if (!result?.setupCompleted) { setLaunchError( - result - ? buildLaunchErrorMessage(result) - : 'The tasks could not be started. Press Retry to try again.', + `${result?.completionError ?? 'Setup could not be completed.'} Press Retry to try again.`, ); return; } @@ -362,13 +333,8 @@ function StarterTasksStepContent({ // Leave before awaiting invalidation so /setup's completed-setup // guard cannot race and flash Home before the destination page. - const [firstLaunched] = allLaunched; router.replace( - allLaunched.length === 0 - ? '/' - : allLaunched.length === 1 && firstLaunched - ? `/task/${firstLaunched.taskId}` - : '/tasks', + result.sessionId ? `/sessions/${result.sessionId}` : '/', ); await Promise.all([ @@ -389,10 +355,6 @@ function StarterTasksStepContent({ }), ); - const launchedIds = new Set( - launchedTasks.map((launch) => launch.starterTaskId), - ); - const remainingIds = selectedIds.filter((id) => !launchedIds.has(id)); const isPending = completeSetup.isPending || launchStarterTasks.isPending; const toggleStarterTask = (id: SetupStarterTaskId, checked: boolean) => { @@ -413,9 +375,9 @@ function StarterTasksStepContent({ ...(!isCloudAdmin ? { productUpdatesEnabled } : {}), }; - if (remainingIds.length === 0 && launchedTasks.length === 0) { - // Nothing selected and nothing launched: plain setup completion with - // the existing Home routing. + if (selectedIds.length === 0) { + // Nothing selected: plain setup completion with the existing Home + // routing. completeSetup.mutate(preferences); return; } @@ -423,7 +385,7 @@ function StarterTasksStepContent({ setLaunchError(null); launchStarterTasks.mutate({ launchBatchId, - selectedStarterTaskIds: remainingIds, + selectedStarterTaskIds: selectedIds, ...preferences, }); }; @@ -440,8 +402,7 @@ function StarterTasksStepContent({ />
{SETUP_STARTER_TASKS.map((starterTask) => { - const isLaunched = launchedIds.has(starterTask.id); - const checked = isLaunched || selectedIds.includes(starterTask.id); + const checked = selectedIds.includes(starterTask.id); const inputId = `setup-starter-task-${starterTask.id}`; return ( @@ -455,7 +416,7 @@ function StarterTasksStepContent({ aria-label={starterTask.title} className="relative top-0.5 shrink-0" checked={checked} - disabled={isLaunched || isPending} + disabled={isPending} onCheckedChange={(nextChecked) => toggleStarterTask(starterTask.id, nextChecked === true) } @@ -463,7 +424,7 @@ function StarterTasksStepContent({ {starterTask.title} - {isLaunched ? 'Started.' : starterTask.description} + {starterTask.description} diff --git a/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx new file mode 100644 index 000000000..967f94001 --- /dev/null +++ b/apps/web/src/app/(sandbox)/SandboxInfoPanel.tsx @@ -0,0 +1,55 @@ +import type { ReactNode } from 'react'; + +import { SandboxSidePanelHeader } from './SandboxSidePanelHeader'; + +export function SandboxInfoPanel({ + title, + onClose, + closeLabel, + header, + children, +}: { + title: string; + onClose: () => void; + closeLabel?: string; + header?: ReactNode; + children: ReactNode; +}) { + return ( + <> + {header ?? ( + + )} +
+
{children}
+
+ + ); +} + +export function SandboxInfoRow({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( + + {label} + {children} + + ); +} + +export function SandboxInfoTable({ children }: { children: ReactNode }) { + return ( + + {children} +
+ ); +} diff --git a/apps/web/src/app/(sandbox)/layout.tsx b/apps/web/src/app/(sandbox)/layout.tsx index b9d519b84..41787bf82 100644 --- a/apps/web/src/app/(sandbox)/layout.tsx +++ b/apps/web/src/app/(sandbox)/layout.tsx @@ -1,5 +1,7 @@ import { CommandPalette } from '@/components/layout/CommandPalette'; import { CommandPaletteProvider } from '@/components/layout/CommandPaletteContext'; +import { TaskLaunchConfigProvider } from '@/components/tasks/TaskLaunchConfig'; +import { resolveTaskLaunchConfig } from '@/lib/server/task-launch-config'; import { SandboxShell } from './SandboxShell'; @@ -7,11 +9,15 @@ interface SandboxLayoutProps { children: React.ReactNode; } -export default function SandboxLayout({ children }: SandboxLayoutProps) { +export default async function SandboxLayout({ children }: SandboxLayoutProps) { + const taskLaunchConfig = await resolveTaskLaunchConfig(); + return ( - - {children} - - + + + {children} + + + ); } 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 5d8930f6d..7a4969f62 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 @@ -7,19 +7,79 @@ import { } from '@testing-library/react'; import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; -import { FastSessionTranscript } from './FastSessionTranscript'; +import { + FastSessionTranscript, + pendingResponseReducer, +} from './FastSessionTranscript'; +import { SessionRunningTaskCountContext } from './session-task-panel-context'; -const { replyMutate, preparePromptAttachments } = vi.hoisted(() => ({ +const { + replyMutate, + reviewActionMutate, + updateModelSelectionMutate, + preparePromptAttachments, + openTaskPanel, + openTasksPanel, + narrationState, +} = vi.hoisted(() => ({ replyMutate: vi.fn(), + reviewActionMutate: vi.fn(), + updateModelSelectionMutate: vi.fn(), preparePromptAttachments: vi.fn(), + openTaskPanel: vi.fn(), + openTasksPanel: vi.fn(), + narrationState: { enabled: false }, +})); + +vi.mock('@/hooks/useNarrationMode', () => ({ + useNarrationMode: () => ({ enabled: narrationState.enabled }), })); vi.mock('@/trpc/client', () => ({ useTRPCClient: () => ({ - fastSessions: { reply: { mutate: replyMutate } }, + fastSessions: { + reply: { mutate: replyMutate }, + reviewAction: { mutate: reviewActionMutate }, + updateModelSelection: { mutate: updateModelSelectionMutate }, + }, }), })); +vi.mock('./SessionModelSwitcher', () => ({ + SessionModelSwitcher: ({ + model, + onModelChange, + reasoningEffort, + onReasoningEffortChange, + disabled, + }: { + model: string; + onModelChange: (model: string) => void; + reasoningEffort: string | null; + onReasoningEffortChange: (effort: 'high') => void; + disabled?: boolean; + }) => ( +
+ {model} + {reasoningEffort} + + +
+ ), +})); + vi.mock('@/lib/prompt-attachments', async () => { const actual = await vi.importActual< typeof import('@/lib/prompt-attachments') @@ -38,6 +98,26 @@ vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ }), })); +vi.mock('./session-task-panel-context', async (importOriginal) => ({ + ...(await importOriginal()), + useOpenSessionTaskPanel: () => openTaskPanel, + useOpenSessionTasksPanel: () => openTasksPanel, +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + onOpen, + }: { + taskId: string; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + class FakeEventSource { static instances: FakeEventSource[] = []; listeners = new Map void>>(); @@ -68,17 +148,750 @@ class FakeEventSource { beforeEach(() => { FakeEventSource.instances = []; replyMutate.mockReset(); + reviewActionMutate.mockReset(); + updateModelSelectionMutate.mockReset(); preparePromptAttachments.mockImplementation(({ text }: { text: string }) => Promise.resolve({ text }), ); + narrationState.enabled = false; + openTaskPanel.mockReset(); + openTasksPanel.mockReset(); vi.stubGlobal('EventSource', FakeEventSource); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); describe('FastSessionTranscript', () => { + const textMessage = ({ + id, + role, + text, + ts, + visible = true, + turnSeq = role === 'user' ? 0 : 1, + }: { + id: string; + role: 'user' | 'assistant'; + text: string; + ts: number; + visible?: boolean; + turnSeq?: number; + }) => ({ + id, + eventId: `${id}:event`, + turnId: `${id}:turn`, + turnSeq, + ts, + eventType: + role === 'user' + ? ACP_ENVELOPE_EVENT_TYPES.UserPrompt + : ACP_ENVELOPE_EVENT_TYPES.AssistantMessage, + role, + contentBlocks: [{ type: 'text' as const, text }], + metadata: { visibleInTranscript: visible }, + payload: {}, + source: 'web', + nativeSessionId: role === 'assistant' ? 'opencode-1' : null, + nativeMessageId: null, + createdAt: new Date(ts), + }); + + describe('pendingResponseReducer', () => { + const emptyState = { + pendingAfter: null, + latestVisibleResponse: null, + optimisticRollback: null, + }; + + it('uses the same ordering and visibility rules for hydration and streamed messages', () => { + const hydrated = pendingResponseReducer(emptyState, { + type: 'hydrate', + messages: [ + textMessage({ + id: 'user-1', + role: 'user', + text: 'Question', + ts: 2, + }), + textMessage({ + id: 'hidden-1', + role: 'assistant', + text: 'Internal activity', + ts: 3, + visible: false, + }), + ], + }); + + const afterStaleOutput = pendingResponseReducer(hydrated, { + type: 'messages', + newEventIds: new Set(), + messages: [ + textMessage({ + id: 'stale-assistant', + role: 'assistant', + text: 'Earlier output', + ts: 2, + turnSeq: -1, + }), + ], + }); + expect(afterStaleOutput.pendingAfter?.id).toBe('user-1'); + + const afterVisibleOutput = pendingResponseReducer(afterStaleOutput, { + type: 'messages', + newEventIds: new Set(['assistant-1:event']), + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Answer', + ts: 3, + }), + ], + }); + expect(afterVisibleOutput.pendingAfter).toBeNull(); + + const afterStaleUserReplay = pendingResponseReducer(afterVisibleOutput, { + type: 'messages', + newEventIds: new Set(), + messages: [ + textMessage({ + id: 'stale-user', + role: 'user', + text: 'Replayed question', + ts: 2, + }), + ], + }); + expect(afterStaleUserReplay.pendingAfter).toBeNull(); + }); + + it('keeps a tied new user pending when the same batch replays the latest response', () => { + const latestResponse = textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Answer', + ts: 2, + }); + const hydrated = pendingResponseReducer(emptyState, { + type: 'hydrate', + messages: [latestResponse], + }); + const nextUser = textMessage({ + id: 'user-2', + role: 'user', + text: 'Follow up', + ts: latestResponse.ts, + }); + + const next = pendingResponseReducer(hydrated, { + type: 'messages', + messages: [nextUser, latestResponse], + newEventIds: new Set([nextUser.eventId]), + }); + + expect(next.pendingAfter?.id).toBe(nextUser.id); + }); + + it('restores an optimistic fallback only while that message owns pending state', () => { + const earlierPending = pendingResponseReducer(emptyState, { + type: 'hydrate', + messages: [ + textMessage({ + id: 'user-1', + role: 'user', + text: 'Earlier question', + ts: 1, + }), + ], + }); + const optimistic = textMessage({ + id: 'optimistic-1', + role: 'user', + text: 'Later question', + ts: 2, + }); + const optimisticPending = pendingResponseReducer(earlierPending, { + type: 'optimistic', + message: optimistic, + }); + + expect( + pendingResponseReducer(optimisticPending, { + type: 'rollbackOptimistic', + optimisticId: optimistic.id, + }).pendingAfter?.id, + ).toBe('user-1'); + + const resolved = pendingResponseReducer(optimisticPending, { + type: 'messages', + newEventIds: new Set(['assistant-1:event']), + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Answer', + ts: 3, + }), + ], + }); + expect( + pendingResponseReducer(resolved, { + type: 'rollbackOptimistic', + optimisticId: optimistic.id, + }).pendingAfter, + ).toBeNull(); + }); + }); + + it.each([ + [1, '1 task running'], + [2, '2 tasks running'], + ])('shows the running task count as %s', (runningTaskCount, label) => { + render( + + + , + ); + + const status = screen.getByRole('status'); + expect(status).toHaveTextContent(label); + expect(status.closest('.chat-reasoning-message')).toHaveClass( + 'is-assistant', + ); + }); + + it('removes the running task indicator when the count returns to zero', () => { + const { rerender } = render( + + + , + ); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + rerender( + + + , + ); + + expect(screen.queryByText('1 task running')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('opens the tasks panel from a keyboard-focusable activity button', () => { + render( + + + , + ); + + const button = screen.getByRole('button', { + name: '1 task running. Open task', + }); + button.focus(); + expect(button).toHaveFocus(); + expect(button).toHaveAttribute('type', 'button'); + fireEvent.click(button); + + expect(openTasksPanel).toHaveBeenCalledOnce(); + }); + + it('shows the task indicator only after the session response finishes', () => { + render( + + + , + ); + + expect(screen.queryByText('1 task running')).not.toBeInTheDocument(); + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Tasks launched', + ts: 2, + }), + ], + }); + }); + + expect(screen.getByText('1 task running')).toBeInTheDocument(); + }); + + it('keeps running task activity visible when a Session is loaded directly', () => { + render( + + + , + ); + + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + conversationResponding: true, + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Tasks launched', + ts: 2, + }), + ], + }); + }); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: true, + }); + }); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('open', null); + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: true, + }); + }); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: false, + }); + }); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: true, + }); + }); + expect(screen.queryByText('1 task running')).not.toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('session', { + conversationResponding: false, + }); + }); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + }); + + it('applies streamed parent activity with visible output atomically', () => { + render( + + + , + ); + expect(screen.getByText('1 task running')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + conversationResponding: true, + messages: [ + textMessage({ + id: 'assistant-2', + role: 'assistant', + text: 'Parent output is streaming', + ts: 3, + }), + ], + }); + }); + + expect(screen.getByText('Parent output is streaming')).toBeInTheDocument(); + expect(screen.queryByText('1 task running')).not.toBeInTheDocument(); + }); + + it('shows Thinking while the initial Fast turn is awaiting output', () => { + render( + , + ); + + expect(screen.getByText('Thinking')).toBeInTheDocument(); + }); + + it('shows Thinking after a follow-up until streamed output arrives', async () => { + vi.spyOn(Date, 'now').mockReturnValue(2); + replyMutate.mockResolvedValue({ success: true }); + render( + , + ); + + expect(screen.queryByText('Thinking')).not.toBeInTheDocument(); + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Follow up' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(await screen.findByText('Thinking')).toBeInTheDocument(); + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'stale-assistant', + role: 'assistant', + text: 'Replayed earlier output', + ts: 2, + turnSeq: -1, + }), + textMessage({ + id: 'lifecycle-2', + role: 'assistant', + text: 'Internal lifecycle update', + ts: Date.now(), + visible: false, + }), + ], + }); + }); + expect(screen.getByText('Thinking')).toBeInTheDocument(); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-2', + role: 'assistant', + text: 'Follow-up answer', + ts: Date.now() + 1, + }), + ], + }); + }); + + expect(screen.queryByText('Thinking')).not.toBeInTheDocument(); + expect(screen.getByText('Follow-up answer')).toBeInTheDocument(); + }); + + it('clears Thinking when a follow-up send fails', async () => { + replyMutate.mockRejectedValue(new Error('turn is busy')); + render( + , + ); + + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Retry this' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(await screen.findByText('turn is busy')).toBeInTheDocument(); + expect(screen.queryByText('Thinking')).not.toBeInTheDocument(); + }); + + it('keeps Thinking for an earlier pending response when a later send fails', async () => { + replyMutate.mockRejectedValue(new Error('turn is busy')); + render( + , + ); + + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Rejected follow-up' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(await screen.findByText('turn is busy')).toBeInTheDocument(); + expect(screen.getByText('Thinking')).toBeInTheDocument(); + expect(screen.getByText('Earlier pending follow-up')).toBeInTheDocument(); + expect(screen.getByRole('log')).not.toHaveTextContent('Rejected follow-up'); + }); + + it('does not restore an earlier pending response that resolves during attachment preparation', async () => { + let finishPreparing: ((value: { text: string }) => void) | undefined; + preparePromptAttachments.mockReturnValueOnce( + new Promise((resolve) => { + finishPreparing = resolve; + }), + ); + replyMutate.mockRejectedValue(new Error('turn is busy')); + render( + , + ); + + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Later follow-up' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + await waitFor(() => expect(preparePromptAttachments).toHaveBeenCalled()); + + act(() => { + FakeEventSource.instances[0]!.emit('messages', { + messages: [ + textMessage({ + id: 'assistant-1', + role: 'assistant', + text: 'Earlier response', + ts: 2, + }), + ], + }); + }); + expect(screen.queryByText('Thinking')).not.toBeInTheDocument(); + + await act(async () => { + finishPreparing?.({ text: 'Later follow-up' }); + }); + + expect(await screen.findByText('turn is busy')).toBeInTheDocument(); + expect(screen.queryByText('Thinking')).not.toBeInTheDocument(); + expect(screen.getByText('Earlier response')).toBeInTheDocument(); + }); + + const reviewOfferMessage = (status = 'pending') => ({ + id: 'offer-1', + eventId: 'turn-offer:assistant:0', + turnId: 'turn-offer', + turnSeq: 1, + ts: 2, + eventType: ACP_ENVELOPE_EVENT_TYPES.AssistantMessage, + role: 'assistant' as const, + contentBlocks: [ + { type: 'text' as const, text: 'Review feedback remains.' }, + ], + metadata: { visibleInTranscript: true }, + payload: { + prReviewAction: { + deliveryId: '11111111-1111-4111-8111-111111111111', + question: 'Would you like me to resolve these issues?', + status, + }, + }, + source: 'web', + nativeSessionId: 'opencode-1', + nativeMessageId: null, + createdAt: new Date('2026-01-01T00:00:01.000Z'), + }); + + it('renders and dispatches a persisted review action offer', async () => { + reviewActionMutate.mockResolvedValue({ status: 'resolved' }); + render( + , + ); + + fireEvent.click( + screen.getByRole('button', { name: 'Resolve these issues' }), + ); + await waitFor(() => + expect(reviewActionMutate).toHaveBeenCalledWith({ + sessionId: '22222222-2222-4222-8222-222222222222', + deliveryId: '11111111-1111-4111-8111-111111111111', + choice: 'yes', + }), + ); + expect( + await screen.findByText('Resolving the current review issues.'), + ).toBeInTheDocument(); + }); + + it('hides dismissed offers and renders late-click states without controls', async () => { + const { rerender } = render( + , + ); + expect( + screen.queryByText('Would you like me to resolve these issues?'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('pr-review-action-offer'), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Resolve these issues' }), + ).not.toBeInTheDocument(); + + rerender( + , + ); + act(() => { + FakeEventSource.instances.at(-1)?.emit('messages', { + messages: [reviewOfferMessage('stale')], + }); + }); + expect( + await screen.findByText('This offer was already handled or has expired.'), + ).toBeInTheDocument(); + }); it('renders persisted user and assistant text with task transcript primitives', () => { render( { />, ); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Starting')).toBeInTheDocument(); + expect(screen.getByText('Coding Task')).toBeInTheDocument(); + expect(screen.getByText('Running')).toBeInTheDocument(); expect(FakeEventSource.instances).toHaveLength(1); expect(FakeEventSource.instances[0]!.url).toBe( '/api/sessions/session-1/stream', @@ -219,7 +1034,8 @@ describe('FastSessionTranscript', () => { }); }); - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect(screen.getByText('Started')).toBeInTheDocument(); + expect(screen.queryByText('Running')).not.toBeInTheDocument(); }); it('renders trusted Fast show_widget results with the shared sandboxed preview', () => { @@ -279,6 +1095,69 @@ describe('FastSessionTranscript', () => { ); }); + it('keeps a launched child task visible in narration mode and opens its panel', () => { + narrationState.enabled = true; + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: /Delegated task/ })); + + expect(openTaskPanel).toHaveBeenCalledWith('child-1'); + }); + it('cold-loads one completed tool row before an intervening kickoff', () => { render( { />, ); - const activityToggle = screen.getByRole('button', { - name: /Worked for/, - }); - expect(screen.queryByText('launch_task')).not.toBeInTheDocument(); - - fireEvent.click(activityToggle); - - expect(screen.getAllByText('launch_task')).toHaveLength(1); + expect( + screen.getByRole('button', { name: /Started Coding Task Completed/ }), + ).toBeInTheDocument(); expect(screen.getByText('I started the checkout fix.')).toBeInTheDocument(); }); @@ -373,6 +1247,91 @@ describe('FastSessionTranscript', () => { }); }); + it('persists model selections immediately and uses them for the next reply', async () => { + updateModelSelectionMutate.mockResolvedValue({ success: true }); + replyMutate.mockResolvedValue({ success: true }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Use GLM 5.2' })); + expect(screen.getByTestId('session-model')).toHaveTextContent( + 'openrouter/z-ai/glm-5.2', + ); + await waitFor(() => { + expect(updateModelSelectionMutate).toHaveBeenCalledWith({ + sessionId: 'session-1', + model: 'openrouter/z-ai/glm-5.2', + }); + }); + + fireEvent.click(screen.getByRole('button', { name: 'Use high reasoning' })); + expect(screen.getByTestId('session-reasoning')).toHaveTextContent('high'); + await waitFor(() => { + expect(updateModelSelectionMutate).toHaveBeenLastCalledWith({ + sessionId: 'session-1', + reasoningEffort: 'high', + }); + }); + + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Use these settings' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(replyMutate).toHaveBeenCalledWith({ + sessionId: 'session-1', + text: 'Use these settings', + model: 'openrouter/z-ai/glm-5.2', + reasoningEffort: 'high', + }); + }); + }); + + it('does not submit with Enter while a model selection is still saving', async () => { + let resolveModelUpdate: ((value: { success: true }) => void) | undefined; + updateModelSelectionMutate.mockReturnValue( + new Promise((resolve) => { + resolveModelUpdate = resolve; + }), + ); + replyMutate.mockResolvedValue({ success: true }); + + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Use GLM 5.2' })); + const input = screen.getByPlaceholderText('Message agent'); + fireEvent.change(input, { target: { value: 'Wait for the model save' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(replyMutate).not.toHaveBeenCalled(); + + await act(async () => { + resolveModelUpdate?.({ success: true }); + }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + await waitFor(() => { + expect(replyMutate).toHaveBeenCalledWith({ + sessionId: 'session-1', + text: 'Wait for the model save', + model: 'openrouter/z-ai/glm-5.2', + reasoningEffort: null, + }); + }); + }); + it('sends an image-only reply', async () => { preparePromptAttachments.mockResolvedValueOnce({ text: '', @@ -463,23 +1422,32 @@ describe('FastSessionTranscript', () => { }); it('updates the header title from the session stream event', () => { + document.title = 'Roomote'; render( , ); - expect(screen.getByText('Session')).toBeInTheDocument(); + expect(screen.getByText('New session')).toBeInTheDocument(); act(() => { FakeEventSource.instances[0]!.emit('session', { - title: 'Rotate the API keys', + title: + 'Rotate the API keys across every production environment without downtime', }); }); - expect(screen.getByText('Rotate the API keys')).toBeInTheDocument(); + expect( + screen.getByText( + 'Rotate the API keys across every production environment without downtime', + ), + ).toBeInTheDocument(); + expect(document.title).toBe( + 'Rotate the API keys across every production environment with... | Roomote', + ); }); it('hides the reply composer for non-web sessions', () => { diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx index 8066694fc..8d6e49352 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/FastSessionTranscript.tsx @@ -1,11 +1,22 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, + type ReactNode, +} from 'react'; +import { useReducedMotion } from 'motion/react'; import { ACP_ENVELOPE_EVENT_TYPES, getImageUrisFromContentBlocks, getTextFromContentBlocks, inferAcpMessageKind, + parsePrReviewActionOffer, + type PrReviewActionChoice, type AcpEventType, type ReasoningEffort, } from '@roomote/types'; @@ -16,7 +27,10 @@ import { Conversation, ConversationContent, ConversationScrollButton, + Message, + MessageContent, MessageUiOptionsProvider, + Shimmer, } from '@/components/ai-elements'; import { WorkspaceHeader } from '@/components/layout'; import { @@ -24,6 +38,15 @@ import { type SessionPromptSubmission, } from './SessionPromptInput'; import { preparePromptAttachments } from '@/lib/prompt-attachments'; +import { + useOpenSessionTaskPanel, + useOpenSessionTasksPanel, + useSessionRunningTaskCount, +} from './session-task-panel-context'; +import { useNarrationMode } from '@/hooks/useNarrationMode'; +import { usePageTitle } from '@/hooks/usePageTitle'; +import { truncatePageTitle } from '@/lib/page-title'; +import { PrReviewActionOffer } from '@/components/ai-elements/pr-review-action-offer'; import { AcpTranscriptBlockList, @@ -37,12 +60,38 @@ type TranscriptMessage = Omit & { createdAt: Date | string; }; -function compareTranscriptMessages(a: TranscriptMessage, b: TranscriptMessage) { +type TranscriptOrder = Pick; + +type PendingResponseState = { + pendingAfter: TranscriptOrder | null; + latestVisibleResponse: TranscriptOrder | null; + optimisticRollback: { + optimisticId: string; + pendingAfter: TranscriptOrder | null; + } | null; +}; + +type PendingResponseAction = + | { type: 'hydrate'; messages: TranscriptMessage[] } + | { + type: 'messages'; + messages: TranscriptMessage[]; + newEventIds: ReadonlySet; + } + | { type: 'optimistic'; message: TranscriptOrder } + | { type: 'commitOptimistic'; optimisticId: string } + | { type: 'rollbackOptimistic'; optimisticId: string }; + +function compareTranscriptOrder(a: TranscriptOrder, b: TranscriptOrder) { if (a.ts !== b.ts) return a.ts - b.ts; if (a.turnSeq !== b.turnSeq) return a.turnSeq - b.turnSeq; return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; } +function compareTranscriptMessages(a: TranscriptMessage, b: TranscriptMessage) { + return compareTranscriptOrder(a, b); +} + function getUserMessageIdentity(message: TranscriptMessage) { return JSON.stringify([ getTextFromContentBlocks(message.contentBlocks)?.trim() ?? '', @@ -50,17 +99,154 @@ function getUserMessageIdentity(message: TranscriptMessage) { ]); } +function isVisibleResponseActivity(message: TranscriptMessage) { + return ( + message.role !== 'user' && message.metadata?.visibleInTranscript !== false + ); +} + +export function pendingResponseReducer( + state: PendingResponseState, + action: PendingResponseAction, +): PendingResponseState { + if (action.type === 'hydrate' || action.type === 'messages') { + let pendingAfter = + action.type === 'hydrate' + ? action.messages.length === 0 + ? { id: '', ts: 0, turnSeq: -1 } + : null + : state.pendingAfter; + let latestVisibleResponse = + action.type === 'hydrate' ? null : state.latestVisibleResponse; + + for (const message of [...action.messages].sort( + compareTranscriptMessages, + )) { + const pendingThreshold = pendingAfter ?? latestVisibleResponse; + const isNewMessage = + action.type === 'hydrate' || action.newEventIds.has(message.eventId); + if ( + message.role === 'user' && + isNewMessage && + (action.type === 'hydrate' || + pendingThreshold === null || + message.ts >= pendingThreshold.ts) + ) { + pendingAfter = message; + } else if (isNewMessage && isVisibleResponseActivity(message)) { + if ( + latestVisibleResponse === null || + compareTranscriptOrder(message, latestVisibleResponse) >= 0 + ) { + latestVisibleResponse = message; + } + if ( + pendingAfter !== null && + compareTranscriptOrder(message, pendingAfter) >= 0 + ) { + pendingAfter = null; + } + } + } + + return { ...state, pendingAfter, latestVisibleResponse }; + } + + if (action.type === 'optimistic') { + return { + pendingAfter: action.message, + latestVisibleResponse: state.latestVisibleResponse, + optimisticRollback: { + optimisticId: action.message.id, + pendingAfter: state.pendingAfter, + }, + }; + } + + if (state.optimisticRollback?.optimisticId !== action.optimisticId) { + return state; + } + + if (action.type === 'commitOptimistic') { + return { ...state, optimisticRollback: null }; + } + + return { + pendingAfter: + state.pendingAfter?.id === action.optimisticId + ? state.optimisticRollback.pendingAfter + : state.pendingAfter, + latestVisibleResponse: state.latestVisibleResponse, + optimisticRollback: null, + }; +} + +function ThinkingMessage() { + return ( + + + + Thinking + + + + ); +} + +function RunningTasksMessage({ + count, + onOpenTasks, +}: { + count: number; + onOpenTasks: () => void; +}) { + const shouldReduceMotion = useReducedMotion(); + const label = `${count} ${count === 1 ? 'task' : 'tasks'} running`; + + return ( + + + + + + + + ); +} + export function FastSessionTranscript({ sessionId, initialMessages, hasOlderMessages, canReply, initialTitle = null, - fallbackTitle = 'Session', + fallbackTitle = 'New session', sessionModel = null, sessionReasoningEffort = null, defaultModelId = null, defaultReasoningEffort = null, + headerExtras, + timelineExtras, }: { sessionId: string; initialMessages: FastSessionMessage[]; @@ -72,32 +258,75 @@ export function FastSessionTranscript({ sessionReasoningEffort?: ReasoningEffort | null; defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; + headerExtras?: ReactNode; + timelineExtras?: ReactNode; }) { const trpcClient = useTRPCClient(); + const openTaskPanel = useOpenSessionTaskPanel(); + const openTasksPanel = useOpenSessionTasksPanel(); + const runningTaskCount = useSessionRunningTaskCount(); + const { enabled: narrationModeEnabled } = useNarrationMode(); + const displayMode = narrationModeEnabled ? 'narration' : 'default'; const [serverMessages, setServerMessages] = useState< Map >( () => new Map(initialMessages.map((message) => [message.eventId, message])), ); const serverMessagesRef = useRef(serverMessages); + const hasReceivedInitialSessionStateRef = useRef(false); const [optimisticMessages, setOptimisticMessages] = useState< TranscriptMessage[] >([]); const [isSending, setIsSending] = useState(false); + const [pendingResponseState, dispatchPendingResponse] = useReducer( + pendingResponseReducer, + initialMessages, + (messages) => + pendingResponseReducer( + { + pendingAfter: null, + latestVisibleResponse: null, + optimisticRollback: null, + }, + { type: 'hydrate', messages }, + ), + ); const [replyError, setReplyError] = useState(null); const [title, setTitle] = useState(initialTitle); + const [conversationResponding, setConversationResponding] = useState< + boolean | null + >(null); + usePageTitle(truncatePageTitle(title ?? fallbackTitle)); useEffect(() => { + hasReceivedInitialSessionStateRef.current = false; const source = new EventSource(`/api/sessions/${sessionId}/stream`); + const onOpen = () => { + hasReceivedInitialSessionStateRef.current = false; + }; const onMessages = (event: MessageEvent) => { try { - const { messages } = JSON.parse(event.data) as { + const { messages, conversationResponding: responding } = JSON.parse( + event.data, + ) as { messages: TranscriptMessage[]; + conversationResponding?: boolean | null; }; const previous = serverMessagesRef.current; - const canonicalUserMessages = messages.filter( - (message) => - message.role === 'user' && !previous.has(message.eventId), + const canonicalMessages = messages.filter( + (message) => !previous.has(message.eventId), + ); + // The stream overlaps the server-rendered transcript on connect. A + // replayed lease can be stale, so only new output proves that the + // parent response should suppress hydrated nested-task activity. + if ( + responding !== undefined && + (responding !== true || canonicalMessages.length > 0) + ) { + setConversationResponding(responding); + } + const canonicalUserMessages = canonicalMessages.filter( + (message) => message.role === 'user', ); const next = new Map(previous); for (const message of messages) { @@ -105,6 +334,13 @@ export function FastSessionTranscript({ } serverMessagesRef.current = next; setServerMessages(next); + dispatchPendingResponse({ + type: 'messages', + messages, + newEventIds: new Set( + canonicalMessages.map((message) => message.eventId), + ), + }); if (canonicalUserMessages.length > 0) { setOptimisticMessages((current) => { @@ -126,17 +362,31 @@ export function FastSessionTranscript({ }; const onSession = (event: MessageEvent) => { try { - const { title: nextTitle } = JSON.parse(event.data) as { - title: string | null; + const update = JSON.parse(event.data) as { + title?: string; + conversationResponding?: boolean | null; }; - setTitle(nextTitle); + if (update.title !== undefined) { + setTitle(update.title); + } + const isInitialSessionState = + !hasReceivedInitialSessionStateRef.current; + hasReceivedInitialSessionStateRef.current = true; + if ( + update.conversationResponding !== undefined && + (!isInitialSessionState || update.conversationResponding !== true) + ) { + setConversationResponding(update.conversationResponding); + } } catch { // Ignore malformed frames. } }; + source.addEventListener('open', onOpen); source.addEventListener('messages', onMessages); source.addEventListener('session', onSession); return () => { + source.removeEventListener('open', onOpen); source.removeEventListener('messages', onMessages); source.removeEventListener('session', onSession); source.close(); @@ -166,14 +416,23 @@ export function FastSessionTranscript({ ), [messages], ); + const reviewOffers = useMemo( + () => + messages.flatMap((message) => { + const offer = parsePrReviewActionOffer(message.payload); + return offer ? [offer] : []; + }), + [messages], + ); const { renderBlocks, suppressMessage } = useAcpTranscriptBlocks({ messages: uiMessages, artifacts: [], - displayMode: 'default', + displayMode, initialPrompt: null, shouldHideFirstMessage: false, showInternalMessages: false, hasLeadingTextBoundary: false, + keepDelegatedTasksVisible: true, resetKey: `${messages.length}:${messages[0]?.eventId ?? ''}:${messages.at(-1)?.eventId ?? ''}`, }); @@ -227,13 +486,21 @@ export function FastSessionTranscript({ createdAt: new Date(), }; setOptimisticMessages((previous) => [...previous, optimistic]); + dispatchPendingResponse({ type: 'optimistic', message: optimistic }); await trpcClient.fastSessions.reply.mutate({ sessionId, text: prepared.text, ...(images.length > 0 ? { images } : {}), + ...(prepared.attachmentTexts?.length + ? { attachmentTexts: prepared.attachmentTexts } + : {}), model: message.model ?? null, reasoningEffort: message.reasoningEffort ?? null, }); + dispatchPendingResponse({ + type: 'commitOptimistic', + optimisticId, + }); return true; } catch (error) { if (optimisticId) { @@ -245,6 +512,12 @@ export function FastSessionTranscript({ setReplyError( error instanceof Error ? error.message : 'Failed to send message', ); + if (optimisticId) { + dispatchPendingResponse({ + type: 'rollbackOptimistic', + optimisticId, + }); + } return false; } finally { setIsSending(false); @@ -253,12 +526,30 @@ export function FastSessionTranscript({ [isSending, sessionId, trpcClient], ); + const handleReviewAction = useCallback( + async (deliveryId: string, choice: PrReviewActionChoice) => { + const result = await trpcClient.fastSessions.reviewAction.mutate({ + sessionId, + deliveryId, + choice, + }); + return result.status; + }, + [sessionId, trpcClient], + ); + return ( - - -

+ + +

{title ?? fallbackTitle}

+ {headerExtras}
@@ -267,17 +558,42 @@ export function FastSessionTranscript({ Older messages in this session are not shown.

) : null} + {timelineExtras} + {pendingResponseState.pendingAfter !== null ? ( + + ) : !isSending && + conversationResponding !== true && + runningTaskCount > 0 && + openTasksPanel ? ( + + ) : null} + {reviewOffers.map((offer) => ( + + handleReviewAction(offer.deliveryId, choice) + } + /> + ))}
{canReply ? (
({ + useTaskSession: (...args: unknown[]) => useTaskSessionMock(...args), +})); + +vi.mock('../../task/[taskId]/hooks/use-task-message-envelopes', () => ({ + useTaskMessageEnvelopes: (...args: unknown[]) => + useTaskMessageEnvelopesMock(...args), +})); + +vi.mock('../../task/[taskId]/hooks/ArtifactLinkProvider', () => ({ + ArtifactLinkProvider: ({ children }: { children: ReactNode }) => children, +})); + +vi.mock('../../task/[taskId]/hooks/HistoricalSandboxProvider', () => ({ + HistoricalSandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/hooks/SandboxProvider', () => ({ + SandboxProvider: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +vi.mock('../../task/[taskId]/Messages', () => ({ + Messages: ({ footer }: { footer?: ReactNode }) => ( +
+ Child transcript + {footer} +
+ ), +})); + +vi.mock('../../task/[taskId]/startup', () => ({ + Startup: ({ + runId, + initialTaskRun, + }: { + runId: number; + initialTaskRun: { status: RunStatus }; + }) => ( +
+ {runId}:{initialTaskRun.status} +
+ ), +})); + +vi.mock('../../task/[taskId]/sidebar-panels/SidePanelHeader', () => ({ + SidePanelHeader: ({ + title, + actions, + }: { + title: string; + actions: ReactNode; + }) => ( +
+ {title} + {actions} +
+ ), +})); + +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; + +const baseSession = { + taskId: 'child-1', + task: { title: 'Fix checkout' }, + taskRun: { + id: 42, + harness: 'opencode-server', + status: RunStatus.Running, + taskPhase: 'running', + sandboxServerUrl: 'http://sandbox.test', + }, + artifacts: [], + prompt: null, + token: 'token', + refreshConnection: vi.fn(), + sessionState: 'interactive', + isSessionLoading: false, +}; + +describe('NestedTaskSidePanel', () => { + beforeEach(() => { + useTaskMessageEnvelopesMock.mockReturnValue({ + data: [], + isPending: false, + isSuccess: true, + isError: false, + }); + useTaskSessionMock.mockReturnValue(baseSession); + }); + + it('renders the focused live transcript and full-task navigation without task chrome', () => { + render(); + + expect(screen.getByText('Fix checkout')).toBeInTheDocument(); + expect(screen.getByTestId('live-provider')).toBeInTheDocument(); + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: /Go to task/ })).toHaveAttribute( + 'href', + '/task/child-1', + ); + expect(screen.queryByText('Task actions')).not.toBeInTheDocument(); + expect(useTaskSessionMock).toHaveBeenCalledWith('child-1', { + refetchInterval: 2_000, + }); + }); + + it.each([ + RunStatus.Pending, + RunStatus.Dequeued, + RunStatus.Processing, + RunStatus.Preparing, + RunStatus.Spawning, + RunStatus.Connecting, + RunStatus.Running, + ])( + 'shows shared startup progress for a %s nested task before its transcript starts', + (status) => { + useTaskSessionMock.mockReturnValue({ + ...baseSession, + taskRun: { + ...baseSession.taskRun, + status, + taskPhase: + status === RunStatus.Running + ? 'running' + : 'waiting_for_sandbox_provider', + }, + prompt: null, + sessionState: 'booting', + }); + + render(); + + expect(screen.getByTestId('startup-progress')).toHaveTextContent( + `42:${status}`, + ); + expect(screen.queryByText('Child transcript')).not.toBeInTheDocument(); + expect(screen.queryByTestId('live-provider')).not.toBeInTheDocument(); + }, + ); + + it('keeps startup progress inline once the child transcript begins, then removes it when initialization finishes', () => { + const bootingSession = { + ...baseSession, + taskRun: { + ...baseSession.taskRun, + status: RunStatus.Connecting, + taskPhase: null, + }, + prompt: { + id: 'prompt-1', + visibleInTranscript: true, + }, + sessionState: 'booting', + }; + useTaskSessionMock.mockReturnValue(bootingSession); + + const { rerender } = render( + , + ); + + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.getByTestId('startup-progress')).toHaveTextContent( + `42:${RunStatus.Connecting}`, + ); + + useTaskSessionMock.mockReturnValue({ + ...bootingSession, + taskRun: { + ...bootingSession.taskRun, + status: RunStatus.Running, + taskPhase: 'running', + }, + sessionState: 'interactive', + }); + rerender(); + + expect(screen.getByText('Child transcript')).toBeInTheDocument(); + expect(screen.queryByTestId('startup-progress')).not.toBeInTheDocument(); + expect(screen.getByTestId('live-provider')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx new file mode 100644 index 000000000..8ee14688d --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/NestedTaskSidePanel.tsx @@ -0,0 +1,153 @@ +'use client'; + +import Link from 'next/link'; + +import { DEFAULT_CODING_HARNESS, type TaskPhase } from '@roomote/types'; + +import { + Button, + ErrorState, + ExternalLink, + Skeleton, +} from '@/components/system'; +import { FramedSurface } from '@/components/layout'; + +import { ArtifactLinkProvider } from '../../task/[taskId]/hooks/ArtifactLinkProvider'; +import { HistoricalSandboxProvider } from '../../task/[taskId]/hooks/HistoricalSandboxProvider'; +import { SandboxProvider } from '../../task/[taskId]/hooks/SandboxProvider'; +import { useTaskMessageEnvelopes } from '../../task/[taskId]/hooks/use-task-message-envelopes'; +import { + useTaskSession, + type TaskSession, +} from '../../task/[taskId]/hooks/use-task-session'; +import { Messages } from '../../task/[taskId]/Messages'; +import { SidePanelHeader } from '../../task/[taskId]/sidebar-panels/SidePanelHeader'; +import { Startup } from '../../task/[taskId]/startup'; + +function NestedTaskTranscript({ session }: { session: TaskSession }) { + const history = useTaskMessageEnvelopes(session.taskId); + + if (session.isSessionLoading) { + return ( +
+ + + +
+ ); + } + + if ( + session.sessionState === 'error' || + session.sessionState === 'not-found' + ) { + return ; + } + + if (!session.taskRun) { + return ; + } + + const hasTranscriptHistory = (history.data?.length ?? 0) > 0; + const hasVisibleSessionPrompt = + session.prompt?.visibleInTranscript !== false && session.prompt != null; + const bootingTaskRun = + session.sessionState === 'booting' ? session.taskRun : null; + + if (bootingTaskRun && !hasTranscriptHistory && !hasVisibleSessionPrompt) { + return ( +
+
+ +
+
+ ); + } + + const transcript = ( + + + ) : null + } + /> + + ); + + if ( + session.sessionState === 'historical' || + session.sessionState === 'resuming' || + session.sessionState === 'boot-failed' + ) { + return ( + + {transcript} + + ); + } + + return ( + + {transcript} + + ); +} + +export function NestedTaskSidePanel({ + taskId, + onClose, +}: { + taskId: string; + onClose: () => void; +}) { + const session = useTaskSession(taskId, { refetchInterval: 2_000 }); + const title = session.task?.title?.trim() || 'Task'; + + return ( + + + + Go to task + + + + } + /> +
+ +
+
+ ); +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx index 2dcd605eb..48e586fe3 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionPromptInput.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState } from 'react'; +import { toast } from 'sonner'; import type { ReasoningEffort } from '@roomote/types'; @@ -22,6 +23,7 @@ import { usePromptInputAttachments, } from '@/components/ai-elements'; import { BasicTooltip } from '@/components/system'; +import { useTRPCClient } from '@/trpc/client'; import { AttachmentsDisplay } from '../../task/[taskId]/prompt-input/AttachmentsDisplay'; import { SessionModelSwitcher } from './SessionModelSwitcher'; @@ -51,6 +53,7 @@ function SessionSubmit({ /** Session reply composer mirroring the task composer's structure: action * menu and model switcher on the left, voice and submit on the right. */ export function SessionPromptInput({ + sessionId, isBusy, onSend, initialModel = null, @@ -58,6 +61,7 @@ export function SessionPromptInput({ defaultModelId = null, defaultReasoningEffort = null, }: { + sessionId: string; isBusy: boolean; onSend: (submission: SessionPromptSubmission) => Promise; initialModel?: string | null; @@ -65,11 +69,14 @@ export function SessionPromptInput({ defaultModelId?: string | null; defaultReasoningEffort?: ReasoningEffort | null; }) { + const trpcClient = useTRPCClient(); const [prompt, setPrompt] = useState(''); const [resetKey, setResetKey] = useState(0); const [model, setModel] = useState(initialModel ?? ''); const [reasoningEffort, setReasoningEffort] = useState(initialReasoningEffort); + const [isUpdatingModelSelection, setIsUpdatingModelSelection] = + useState(false); const voiceDictation = useVoiceDictation({ onTranscript: (text) => setPrompt(text), getPrefix: () => prompt, @@ -77,6 +84,10 @@ export function SessionPromptInput({ }); const handleSubmit = async (message: PromptInputMessage) => { + if (isBusy || isUpdatingModelSelection) { + return; + } + // Always send the current picker state: it round-trips the persisted // choice and clears it when the picker is reset to the default. The // draft and attachments are only cleared once the send succeeds, so a @@ -93,6 +104,48 @@ export function SessionPromptInput({ } }; + const updateModelSelection = async ( + next: { model?: string | null; reasoningEffort?: ReasoningEffort | null }, + rollback: () => void, + ) => { + setIsUpdatingModelSelection(true); + try { + await trpcClient.fastSessions.updateModelSelection.mutate({ + sessionId, + ...next, + }); + } catch (error) { + rollback(); + toast.error( + error instanceof Error + ? error.message + : 'Failed to update the model settings', + ); + } finally { + setIsUpdatingModelSelection(false); + } + }; + + const handleModelChange = (nextModel: string) => { + const previousModel = model; + setModel(nextModel); + void updateModelSelection({ model: nextModel || null }, () => + setModel(previousModel), + ); + }; + + const handleReasoningEffortChange = ( + nextReasoningEffort: ReasoningEffort | null, + ) => { + const previousReasoningEffort = reasoningEffort; + setReasoningEffort(nextReasoningEffort); + void updateModelSelection({ reasoningEffort: nextReasoningEffort }, () => + setReasoningEffort(previousReasoningEffort), + ); + }; + + const controlsDisabled = isBusy || isUpdatingModelSelection; + return (
@@ -141,7 +194,7 @@ export function SessionPromptInput({ onClick={voiceDictation.toggle} disabled={isBusy} /> - +
diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx new file mode 100644 index 000000000..58717a7c4 --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionReadTracker.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useMarkSessionRead } from '@/hooks/useMarkSessionRead'; +import { useRecentSessions } from '@/hooks/useRecentSessions'; +import { useTelemetry } from '@/hooks/useTelemetry'; + +export function SessionReadTracker({ sessionId }: { sessionId: string }) { + const { recordVisit } = useRecentSessions(); + const { capture } = useTelemetry(); + + useMarkSessionRead(sessionId); + + useEffect(() => { + recordVisit(sessionId); + capture('session_opened', { surface: 'web', outcome: 'opened' }); + }, [capture, recordVisit, sessionId]); + + return null; +} diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx index 7d0b0fa84..4bd8c75d8 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.client.test.tsx @@ -1,23 +1,155 @@ import { useState, type ReactNode } from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RunStatus } from '@roomote/types'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { SandboxLayoutContext } from '../../use-sandbox-layout'; -import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { + SessionHeaderExtras, + SessionWorkspace, + type SessionInfo, +} from './SessionWorkspace'; +import { + useOpenSessionTaskPanel, + useOpenSessionTasksPanel, + useSessionRunningTaskCount, +} from './session-task-panel-context'; -const { useMediaQueryMock } = vi.hoisted(() => ({ +const { + useMediaQueryMock, + sessionQueryState, + fastTaskQueryState, + searchParamsState, + routerReplaceMock, + artifactQueryState, + artifactQueryInputs, +} = vi.hoisted(() => ({ useMediaQueryMock: vi.fn(), + sessionQueryState: { data: null as unknown }, + fastTaskQueryState: { data: null as unknown }, + searchParamsState: { value: '' }, + routerReplaceMock: vi.fn(), + artifactQueryState: { dataByPath: {} as Record }, + artifactQueryInputs: [] as Array<{ + taskId: string; + path: string; + version?: number; + }>, })); vi.mock('usehooks-ts', () => ({ useMediaQuery: useMediaQueryMock, })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: routerReplaceMock }), + useSearchParams: () => new URLSearchParams(searchParamsState.value), +})); + vi.mock('@/hooks/task-models/useLaunchTaskModels', () => ({ useLaunchTaskModels: () => ({ data: { models: [{ id: 'model-1', displayName: 'Model One' }] }, }), })); +vi.mock('@/trpc/client', () => ({ + useTRPC: () => ({ + sessions: { + byId: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['sessions', 'byId', input.sessionId], + queryFn: async () => sessionQueryState.data, + ...options, + }), + }, + }, + fastSessions: { + tasks: { + queryOptions: ( + input: { sessionId: string }, + options?: Record, + ) => ({ + queryKey: ['fastSessions', 'tasks', input.sessionId], + queryFn: async () => fastTaskQueryState.data, + ...options, + }), + }, + }, + artifacts: { + byPath: { + queryOptions: ( + input: { taskId: string; path: string; version?: number }, + options?: Record, + ) => ({ + queryKey: ['artifacts', 'byPath', input], + queryFn: async () => { + artifactQueryInputs.push(input); + return ( + artifactQueryState.dataByPath[`${input.taskId}:${input.path}`] ?? + artifactQueryState.dataByPath[input.path] + ); + }, + ...options, + }), + }, + }, + }), +})); + +vi.mock('@/components/tasks/ArtifactViewerContent', () => ({ + ArtifactViewerContent: ({ + artifact, + }: { + artifact: { path: string } | null; + }) =>
Artifact preview: {artifact?.path}
, +})); + +vi.mock('./NestedTaskSidePanel', () => ({ + NestedTaskSidePanel: ({ + taskId, + onClose, + }: { + taskId: string; + onClose: () => void; + }) => ( +
+ Nested panel {taskId} + +
+ ), +})); + +vi.mock('../../task/[taskId]/messages/acp/DelegatedTaskCard', () => ({ + DelegatedTaskCard: ({ + taskId, + prompt, + onOpen, + }: { + taskId: string; + prompt: string | null; + onOpen: (taskId: string) => void; + }) => ( + + ), +})); + const session: SessionInfo = { id: 'session-1', ownerName: 'Test User', @@ -25,8 +157,29 @@ const session: SessionInfo = { ownerImageUrl: null, surface: 'slack', model: 'model-1', + reasoningEffort: null, inferenceCostMicroUsd: 1_000_000, + inferenceCostBreakdown: { + directInferenceCostMicroUsd: 1_000_000, + tasks: [], + }, createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'needs_input', + tasks: [], +}; + +const singleTask: SessionInfo['tasks'][number] = { + taskId: 'task-1', + title: 'Update homepage background', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], }; function SandboxLayoutProvider({ children }: { children: ReactNode }) { @@ -45,48 +198,296 @@ function SandboxLayoutProvider({ children }: { children: ReactNode }) { ); } -function renderWorkspace({ isMobile }: { isMobile: boolean }) { +function renderWorkspace({ + isMobile, + children =
Session transcript
, + sessionOverride, + queriedTasks, + queriedFastTasks, + selectedTaskId, + searchParams, +}: { + isMobile: boolean; + children?: ReactNode; + sessionOverride?: Partial; + queriedTasks?: SessionInfo['tasks']; + queriedFastTasks?: NonNullable; + selectedTaskId?: string; + searchParams?: string; +}) { useMediaQueryMock.mockReturnValue(!isMobile); + searchParamsState.value = + searchParams ?? (selectedTaskId ? `task=${selectedTaskId}` : ''); + let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = + null; + const mediaQuery = { + matches: isMobile, + addEventListener: vi.fn( + (event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + viewportChangeListener = listener; + } + }, + ), + removeEventListener: vi.fn(), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue(mediaQuery), + }); - render( - - -
Session transcript
-
-
, + const initialSession = { ...session, ...sessionOverride }; + sessionQueryState.data = { + ...initialSession, + tasks: queriedTasks ?? initialSession.tasks, + }; + fastTaskQueryState.data = queriedFastTasks ?? initialSession.taskCards ?? []; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const result = render( + + + {children} + + , + ); + + return { + ...result, + queryClient, + resizeToMobile() { + mediaQuery.matches = true; + act(() => + viewportChangeListener?.({ matches: true } as MediaQueryListEvent), + ); + }, + }; +} + +function OpenNestedTask() { + const openTaskPanel = useOpenSessionTaskPanel(); + + return ( + + ); +} + +function RunningTaskCount() { + const count = useSessionRunningTaskCount(); + return {count}; +} + +function OpenTasksPanel() { + const openTasksPanel = useOpenSessionTasksPanel(); + return ( + ); } describe('SessionWorkspace', () => { + beforeEach(() => { + routerReplaceMock.mockClear(); + artifactQueryInputs.length = 0; + artifactQueryState.dataByPath = { + 'tmp/capture-visual-proof/sidebar-alignment.png': { + id: 'artifact-image', + taskId: 'task-1', + path: 'tmp/capture-visual-proof/sidebar-alignment.png', + version: 1, + artifactType: 'visual-proof', + contentType: 'image/png', + size: 1024, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + downloadUrl: '/api/artifacts/artifact-image/download', + }, + 'plans/sidebar.md': { + id: 'artifact-file', + taskId: 'task-1', + path: 'plans/sidebar.md', + version: 1, + artifactType: 'plan', + contentType: 'text/markdown', + size: 512, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + downloadUrl: '/api/artifacts/artifact-file/download', + content: '# Sidebar', + }, + 'recordings/session-walkthrough.webm': { + id: 'artifact-video', + taskId: 'task-1', + path: 'recordings/session-walkthrough.webm', + version: 1, + artifactType: 'visual-proof', + contentType: 'video/webm', + size: 2048, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + downloadUrl: '/api/artifacts/artifact-video/download', + }, + }; + }); + + it('orders panel controls as tasks, artifacts, then session info', () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { tasks: [singleTask] }, + }); + + const tasks = screen.getByRole('button', { name: 'Tasks' }); + const artifacts = screen.getByRole('button', { name: 'Artifacts' }); + const sessionInfo = screen.getByRole('button', { name: 'Session info' }); + + expect(tasks.compareDocumentPosition(artifacts)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(artifacts.compareDocumentPosition(sessionInfo)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + expect(artifacts.querySelector('svg')).toHaveClass('lucide-layout-grid'); + }); + + it('aggregates task pull requests in the header and removes duplicates', async () => { + const firstTask = { + ...singleTask, + pullRequests: [ + { + id: 'pr-1', + url: 'https://github.com/acme/widgets/pull/42', + number: 42, + title: 'First PR', + repository: 'acme/widgets', + status: 'open', + }, + ], + }; + const secondTask = { + ...singleTask, + taskId: 'task-2', + pullRequests: [ + { + id: 'pr-duplicate-number', + url: 'https://github.com/acme/widgets/pull/42?duplicate=1', + number: 42, + title: 'Duplicate PR', + repository: 'acme/widgets', + status: 'open', + }, + { + id: 'pr-duplicate-url', + url: 'https://github.com/acme/widgets/pull/42', + number: 99, + title: 'Duplicate URL', + repository: 'acme/other', + status: 'open', + }, + { + id: 'pr-2', + url: 'https://github.com/acme/api/pull/7', + number: 7, + title: 'Second PR', + repository: 'acme/api', + status: 'open', + }, + { + id: 'pr-closed', + url: 'https://github.com/acme/api/pull/8', + number: 8, + title: 'Closed PR', + repository: 'acme/api', + status: 'closed', + }, + { + id: 'pr-merged', + url: 'https://github.com/acme/api/pull/9', + number: 9, + title: 'Merged PR', + repository: 'acme/api', + status: 'merged', + }, + ], + }; + + renderWorkspace({ + isMobile: false, + children: , + sessionOverride: { tasks: [firstTask, secondTask] }, + }); + + expect(await screen.findByText('active')).toBeVisible(); + expect(screen.getByRole('link', { name: 'widgets#42' })).toHaveAttribute( + 'href', + 'https://github.com/acme/widgets/pull/42', + ); + expect(screen.getByRole('link', { name: 'api#7' })).toHaveAttribute( + 'href', + 'https://github.com/acme/api/pull/7', + ); + expect(screen.getAllByRole('link')).toHaveLength(2); + }); + + it('updates header pull requests from refreshed session tasks', async () => { + const { queryClient } = renderWorkspace({ + isMobile: false, + children: , + sessionOverride: { tasks: [] }, + }); + + expect(screen.queryByRole('link')).toBeNull(); + await waitFor(() => + expect( + queryClient.getQueryState(['sessions', 'byId', session.id])?.status, + ).toBe('success'), + ); + + act(() => { + queryClient.setQueryData(['sessions', 'byId', session.id], { + ...session, + status: 'active', + tasks: [ + { + ...singleTask, + pullRequests: [ + { + id: 'pr-new', + url: 'https://github.com/acme/new/pull/123', + number: 123, + title: 'Newly opened PR', + repository: 'acme/new', + status: 'open', + }, + ], + }, + ], + }); + }); + + expect( + await screen.findByRole('link', { name: 'new#123' }), + ).toHaveAttribute('target', '_blank'); + }); + it('matches the task sidebar replacement behavior and controls on mobile', () => { renderWorkspace({ isMobile: true }); expect(screen.getByText('Session transcript')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Chat' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Session info' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Show sidebar' })); + expect(screen.getByRole('button', { name: 'Chat' })).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect(screen.queryByText('Session transcript')).not.toBeInTheDocument(); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); - const table = screen.getByRole('table'); - const panel = table.parentElement!.parentElement!; - - expect(panel).toHaveClass( - 'flex', - 'min-h-0', - 'min-w-0', - 'flex-1', - 'flex-col', - ); - expect(panel.parentElement).toHaveClass( - 'flex', - 'min-h-0', - 'min-w-0', - 'flex-1', - 'flex-col', - ); + expect(screen.getByText('needs input')).toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Close session info' }), ).toBeNull(); @@ -106,13 +507,14 @@ describe('SessionWorkspace', () => { fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect( - screen.getByRole('heading', { name: 'Session info' }), + screen.getByRole('heading', { name: 'Session Info' }), ).toBeInTheDocument(); }); it('preserves the split panel and close control on desktop', () => { renderWorkspace({ isMobile: false }); + expect(screen.getByRole('button', { name: 'Session info' })).toBeVisible(); fireEvent.click(screen.getByRole('button', { name: 'Session info' })); expect(screen.getByText('Session transcript')).toBeInTheDocument(); @@ -120,4 +522,535 @@ describe('SessionWorkspace', () => { screen.getByRole('button', { name: 'Close session info' }), ).toBeInTheDocument(); }); + + it('shows direct and per-task inference costs, including zero-cost tasks', () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { + inferenceCostMicroUsd: 3_500_000, + inferenceCostBreakdown: { + directInferenceCostMicroUsd: 1_000_000, + tasks: [ + { + taskId: 'task-costly', + title: 'Implement session totals', + inferenceCostMicroUsd: 2_500_000, + }, + { + taskId: 'task-zero', + title: 'Zero-cost audit', + inferenceCostMicroUsd: 0, + }, + ], + }, + }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Session info' })); + + const costTrigger = screen.getByRole('button', { + name: 'Show inference cost breakdown', + }); + expect(costTrigger).toHaveTextContent('3.50'); + fireEvent.click(costTrigger); + + expect(screen.getByText('Inference cost breakdown')).toBeInTheDocument(); + expect(screen.getByText('Direct session')).toBeInTheDocument(); + expect(screen.getByText('Implement session totals')).toBeInTheDocument(); + expect(screen.getByText('Zero-cost audit')).toBeInTheDocument(); + expect(screen.getByText('$1.00')).toBeInTheDocument(); + expect(screen.getByText('$2.50')).toBeInTheDocument(); + expect(screen.getByText('$0.00')).toBeInTheDocument(); + expect(screen.getByText('$3.50')).toBeInTheDocument(); + }); + + it.each([false, true])( + 'lands on the transcript for a normal single-task session URL when isMobile=%s', + (isMobile) => { + renderWorkspace({ + isMobile, + sessionOverride: { tasks: [singleTask] }, + searchParams: + 'utm_source=slack&utm_medium=link&utm_campaign=slack.fast_reply', + }); + + expect(screen.getByText('Session transcript')).toBeInTheDocument(); + expect( + screen.queryByLabelText('Full task task-1'), + ).not.toBeInTheDocument(); + expect(routerReplaceMock).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'keeps the first manually opened panel visible from a normal attributed URL when isMobile=%s', + (isMobile) => { + renderWorkspace({ + isMobile, + sessionOverride: { tasks: [singleTask] }, + searchParams: + 'utm_source=slack&utm_medium=link&utm_campaign=slack.fast_reply', + }); + + if (isMobile) { + fireEvent.click(screen.getByRole('button', { name: 'Show sidebar' })); + } + fireEvent.click(screen.getByRole('button', { name: 'Session info' })); + + expect( + screen.getByRole('heading', { name: 'Session Info' }), + ).toBeInTheDocument(); + expect(routerReplaceMock).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'keeps the full task closed when a sole task arrives without a selector and isMobile=%s', + async (isMobile) => { + renderWorkspace({ + isMobile, + sessionOverride: { tasks: [] }, + queriedTasks: [singleTask], + searchParams: + 'utm_source=slack&utm_medium=link&utm_campaign=slack.fast_reply', + }); + + if (isMobile) { + fireEvent.click(screen.getByRole('button', { name: 'Show sidebar' })); + } + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(); + }); + expect(screen.getByText('Session transcript')).toBeInTheDocument(); + expect( + screen.queryByLabelText('Full task task-1'), + ).not.toBeInTheDocument(); + expect(routerReplaceMock).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'opens an explicitly selected full task in the responsive panel when isMobile=%s', + (isMobile) => { + renderWorkspace({ + isMobile, + sessionOverride: { tasks: [singleTask] }, + searchParams: + 'utm_source=slack&utm_medium=link&utm_campaign=slack.fast_reply&task=task-1', + }); + + expect(screen.getByLabelText('Full task task-1')).toBeInTheDocument(); + if (isMobile) { + expect( + screen.queryByText('Session transcript'), + ).not.toBeInTheDocument(); + } else { + expect(screen.getByText('Session transcript')).toBeInTheDocument(); + } + expect(routerReplaceMock).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Close panel' })); + expect(routerReplaceMock).toHaveBeenCalledWith( + '/sessions/session-1?utm_source=slack&utm_medium=link&utm_campaign=slack.fast_reply', + ); + }, + ); + + it.each([false, true])( + 'opens an explicitly selected task when its details arrive after navigation and isMobile=%s', + async (isMobile) => { + renderWorkspace({ + isMobile, + sessionOverride: { tasks: [] }, + queriedTasks: [singleTask], + selectedTaskId: singleTask.taskId, + }); + + expect( + await screen.findByLabelText('Full task task-1'), + ).toBeInTheDocument(); + expect(routerReplaceMock).not.toHaveBeenCalled(); + }, + ); + + it.each([false, true])( + 'opens a Slack-linked Fast task from task cards when isMobile=%s', + (isMobile) => { + renderWorkspace({ + isMobile, + sessionOverride: { + tasks: [], + taskSource: 'fast', + taskCards: [singleTask], + }, + searchParams: + 'utm_source=slack&utm_medium=link&utm_campaign=fast-delegation&task=task-1', + }); + + expect(screen.getByLabelText('Full task task-1')).toBeInTheDocument(); + if (isMobile) { + expect( + screen.queryByText('Session transcript'), + ).not.toBeInTheDocument(); + } else { + expect(screen.getByText('Session transcript')).toBeInTheDocument(); + } + expect(routerReplaceMock).not.toHaveBeenCalled(); + }, + ); + + it('disables the Tasks panel button until the session has a task', () => { + renderWorkspace({ isMobile: false }); + + expect(screen.getByRole('button', { name: 'Tasks' })).toBeDisabled(); + }); + + it('lists session tasks with delegated task cards', () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { tasks: [singleTask] }, + }); + + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument(); + fireEvent.click( + screen.getByRole('button', { + name: 'View coding task: Update homepage background', + }), + ); + + expect(screen.getByText('Nested panel task-1')).toBeInTheDocument(); + }); + + it('navigates to an empty session Artifacts panel and back', () => { + renderWorkspace({ isMobile: false }); + + fireEvent.click(screen.getByRole('button', { name: 'Artifacts' })); + + expect( + screen.getByRole('heading', { name: 'Artifacts' }), + ).toBeInTheDocument(); + expect(screen.getByText('No artifacts in this session yet.')).toBeVisible(); + + fireEvent.click(screen.getByRole('button', { name: 'Close artifacts' })); + expect(screen.queryByText('No artifacts in this session yet.')).toBeNull(); + }); + + it('aggregates latest artifacts per task and preserves duplicate paths across tasks', async () => { + const sharedPath = 'reports/result.md'; + const firstTask = { + ...singleTask, + title: 'First execution', + artifacts: [ + { + id: 'first-latest', + path: sharedPath, + version: 2, + artifactType: 'plan' as const, + contentType: 'text/markdown', + size: 200, + createdAt: new Date('2026-01-03T00:00:00.000Z'), + }, + { + id: 'first-older', + path: sharedPath, + version: 1, + artifactType: 'plan' as const, + contentType: 'text/markdown', + size: 100, + createdAt: new Date('2026-01-04T00:00:00.000Z'), + }, + ], + }; + const secondTask = { + ...singleTask, + taskId: 'task-2', + title: 'Second execution', + artifacts: [ + { + id: 'second-latest', + path: sharedPath, + version: 1, + artifactType: 'plan' as const, + contentType: 'text/markdown', + size: 300, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ], + }; + artifactQueryState.dataByPath[`task-1:${sharedPath}`] = { + id: 'first-latest', + taskId: 'task-1', + path: sharedPath, + version: 2, + artifactType: 'plan', + contentType: 'text/markdown', + size: 200, + createdAt: new Date('2026-01-03T00:00:00.000Z'), + downloadUrl: '/api/artifacts/first-latest/download', + }; + artifactQueryState.dataByPath[`task-2:${sharedPath}`] = { + id: 'second-latest', + taskId: 'task-2', + path: sharedPath, + version: 1, + artifactType: 'plan', + contentType: 'text/markdown', + size: 300, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + downloadUrl: '/api/artifacts/second-latest/download', + }; + + renderWorkspace({ + isMobile: false, + sessionOverride: { tasks: [firstTask, secondTask] }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Artifacts' })); + + expect( + screen.getByRole('button', { + name: 'Open Result from First execution', + }), + ).toBeVisible(); + expect( + screen.getByRole('button', { + name: 'Open Result from Second execution', + }), + ).toBeVisible(); + expect(screen.getAllByText('Result')).toHaveLength(2); + + fireEvent.click( + screen.getByRole('button', { + name: 'Open Result from First execution', + }), + ); + expect( + await screen.findByText(`Artifact preview: ${sharedPath}`), + ).toBeVisible(); + expect(artifactQueryInputs).toContainEqual({ + taskId: 'task-1', + path: sharedPath, + version: 2, + }); + expect(routerReplaceMock).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Back to artifacts' })); + expect( + screen.getByRole('button', { + name: 'Open Result from Second execution', + }), + ).toBeVisible(); + }); + + it('enables and populates the Tasks panel from refreshed session tasks', async () => { + const delegatedTask = { + taskId: 'task-2', + title: 'Refreshed coding task', + workflow: 'standard', + state: 'active', + repositoryName: null, + latestOutput: null, + inferenceCostMicroUsd: 0, + canAccessDetails: true, + latestRun: null, + artifacts: [], + pullRequests: [], + }; + renderWorkspace({ + isMobile: false, + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [delegatedTask], + }); + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(); + }); + fireEvent.click(screen.getByRole('button', { name: 'Tasks' })); + + expect( + screen.getByRole('button', { + name: 'View coding task: Refreshed coding task', + }), + ).toBeInTheDocument(); + }); + + it('selects the task from transcript context when exactly one task is running', async () => { + renderWorkspace({ + isMobile: false, + children: , + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [ + { + taskId: 'task-2', + title: 'Running coding task', + latestRun: { + status: RunStatus.Running, + taskPhase: 'running', + }, + artifacts: [], + }, + ], + }); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole('button', { name: 'Open tasks' })); + + expect(routerReplaceMock).toHaveBeenCalledWith( + '/sessions/session-1?task=task-2', + ); + expect( + screen.queryByRole('button', { + name: 'View coding task: Running coding task', + }), + ).not.toBeInTheDocument(); + }); + + it('opens the Tasks panel from transcript context when multiple tasks are running', async () => { + renderWorkspace({ + isMobile: false, + children: , + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [ + { + taskId: 'task-2', + title: 'First running task', + latestRun: { + status: RunStatus.Running, + taskPhase: 'running', + }, + artifacts: [], + }, + { + taskId: 'task-3', + title: 'Second running task', + latestRun: { + status: RunStatus.Pending, + taskPhase: null, + }, + artifacts: [], + }, + ], + }); + + await waitFor(() => + expect(screen.getByRole('button', { name: 'Tasks' })).toBeEnabled(), + ); + fireEvent.click(screen.getByRole('button', { name: 'Open tasks' })); + + expect( + screen.getByRole('button', { + name: 'View coding task: First running task', + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: 'View coding task: Second running task', + }), + ).toBeInTheDocument(); + expect(routerReplaceMock).not.toHaveBeenCalled(); + }); + + it('populates the Artifacts panel from refreshed Fast-session tasks', async () => { + renderWorkspace({ + isMobile: false, + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [ + { + taskId: 'fast-task-1', + title: 'Fast execution', + latestRun: null, + artifacts: [ + { + id: 'fast-artifact-1', + path: 'reports/fast-result.md', + version: 1, + artifactType: 'plan', + contentType: 'text/markdown', + size: 200, + createdAt: new Date('2026-01-02T00:00:00.000Z'), + }, + ], + }, + ], + }); + + fireEvent.click(screen.getByRole('button', { name: 'Artifacts' })); + + expect( + await screen.findByRole('button', { + name: 'Open Fast Result from Fast execution', + }), + ).toBeVisible(); + expect(screen.queryByText('No artifacts in this session yet.')).toBeNull(); + }); + + it('counts only canonically running tasks and updates when they finish', async () => { + const task = ( + taskId: string, + status: RunStatus, + taskPhase: string | null, + ): NonNullable[number] => ({ + taskId, + title: taskId, + latestRun: { + status, + taskPhase, + }, + artifacts: [], + }); + const { queryClient } = renderWorkspace({ + isMobile: false, + children: , + sessionOverride: { taskSource: 'fast', taskCards: [] }, + queriedFastTasks: [ + task('booting', RunStatus.Pending, null), + task('working', RunStatus.Running, 'running'), + task('waiting', RunStatus.Running, 'waiting_for_user_input'), + task('finished', RunStatus.Completed, null), + ], + }); + + await waitFor(() => + expect( + screen.getByRole('status', { name: 'Running task count' }), + ).toHaveTextContent('2'), + ); + + act(() => { + queryClient.setQueryData( + ['fastSessions', 'tasks', session.id], + [ + task('booting', RunStatus.Completed, null), + task('working', RunStatus.Idle, 'waiting_for_prompt'), + ], + ); + }); + + await waitFor(() => + expect( + screen.getByRole('status', { name: 'Running task count' }), + ).toHaveTextContent('0'), + ); + }); + + it('opens delegated tasks in the existing session side-panel slot', () => { + renderWorkspace({ isMobile: false, children: }); + + fireEvent.click(screen.getByRole('button', { name: 'Open child' })); + + expect(screen.getByText('Nested panel child-1')).toBeInTheDocument(); + }); + + it('collapses the right rail when the viewport changes from desktop to mobile', () => { + const { resizeToMobile } = renderWorkspace({ isMobile: false }); + + expect(screen.getByRole('button', { name: 'Session info' })).toBeVisible(); + + resizeToMobile(); + + expect(screen.queryByRole('button', { name: 'Session info' })).toBeNull(); + expect(screen.getByRole('button', { name: 'Show sidebar' })).toBeVisible(); + }); }); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx index cbbfd945e..2c8745256 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/SessionWorkspace.tsx @@ -1,27 +1,144 @@ 'use client'; -import { useState, type ReactNode } from 'react'; -import { formatDistanceToNow } from 'date-fns'; +import dynamic from 'next/dynamic'; +import { + createContext, + useCallback, + useContext, + useState, + type ReactNode, +} from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useQuery } from '@tanstack/react-query'; +import { + getReasoningEffortLabel, + isActivelyRunningTask, + type ReasoningEffort, + type RunStatus, +} from '@roomote/types'; -import { formatInferenceCost, getUserDisplayName } from '@/lib'; +import { + formatInferenceCost, + getUserDisplayName, + humanizeFilename, +} from '@/lib'; +import { + getSessionPullRequests, + type SessionPullRequest, +} from '@/lib/session-pull-requests'; +import { SessionStatusBadge } from '@/components/sessions/SessionStatusBadge'; +import { PullRequestBadge } from '@/components/sandbox'; +import { + getSessionSurfaceBrandIcon, + getSessionSurfaceLabel, +} from '@/components/sessions/session-surfaces'; import { useLaunchTaskModels } from '@/hooks/task-models/useLaunchTaskModels'; -import { WorkspaceSurface } from '@/components/layout'; +import { useTRPC } from '@/trpc/client'; +import { FramedSurface, WorkspaceSurface } from '@/components/layout'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; import { ArrowLeftFromLine, + ArrowLeft, Avatar, BasicTooltip, + BrandIcon, + Brain, Button, + Calendar, DollarSign, + FileText, + Globe, + Image, Info, + LayoutGrid, + Loader2Icon, + Popover, + PopoverContent, + PopoverTrigger, + Slack, + VideoIcon, + X, + Rows4, } from '@/components/system'; - import { SandboxSidePanelHeader } from '../../SandboxSidePanelHeader'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../SandboxInfoPanel'; import { ResponsiveWorkspacePanels, SandboxSideActions, } from '../../SandboxWorkspacePanels'; -import { useSandboxLayout } from '../../use-sandbox-layout'; +import { + useResponsiveSandboxSidebar, + useSandboxLayout, +} from '../../use-sandbox-layout'; +import { NestedTaskSidePanel } from './NestedTaskSidePanel'; +import { + OpenSessionTaskPanelContext, + OpenSessionTasksPanelContext, + SessionRunningTaskCountContext, +} from './session-task-panel-context'; +import { DelegatedTaskCard } from '../../task/[taskId]/messages/acp/DelegatedTaskCard'; +import { useArtifactByPath } from '../../task/[taskId]/hooks/use-artifact-by-path'; + +const ArtifactViewerContent = dynamic( + () => + import('@/components/tasks/ArtifactViewerContent').then( + (module) => module.ArtifactViewerContent, + ), + { + ssr: false, + loading: () => ( +
+ +
+ ), + }, +); + +type SessionTaskSummary = { + taskId: string; + title: string; + workflow: string; + state: string; + repositoryName: string | null; + latestOutput: string | null; + inferenceCostMicroUsd: number; + canAccessDetails?: boolean; + latestRun: { + id: number; + status: RunStatus; + taskPhase: string | null; + error: string | null; + result: unknown; + } | null; + artifacts: SessionArtifact[]; + pullRequests: Array<{ + id: string; + url: string; + number: number | null; + title: string | null; + repository: string | null; + status: string | null; + }>; +}; + +type SessionArtifact = { + id: string; + path: string; + version: number; + artifactType: string; + contentType: string; + size: number; + createdAt: Date; + thumbnailUrl?: string; + previewUrl?: string; +}; export type SessionInfo = { id: string; @@ -31,25 +148,352 @@ export type SessionInfo = { surface: string; /** Effective model for the session's turns (stored override or default). */ model: string | null; + reasoningEffort: ReasoningEffort | null; inferenceCostMicroUsd: number; + inferenceCostBreakdown: { + directInferenceCostMicroUsd: number; + tasks: Array< + Pick + >; + }; createdAt: Date; + status: string | null; + tasks: SessionTaskSummary[]; + taskSource?: 'unified' | 'fast'; + taskCards?: Array< + Pick & { + inferenceCostMicroUsd?: number; + latestRun: Pick< + NonNullable, + 'status' | 'taskPhase' + > | null; + } + >; }; -const SURFACE_LABELS: Record = { - slack: 'Slack', - discord: 'Discord', - teams: 'Microsoft Teams', - telegram: 'Telegram', - automation: 'Automation', - web: 'Web', +const SessionPullRequestsContext = createContext([]); + +export function SessionHeaderExtras({ status }: { status: string | null }) { + const pullRequests = useContext(SessionPullRequestsContext); + + if (pullRequests.length === 0 && !status) return null; + + return ( +
+ {pullRequests.map((pullRequest) => ( + + ))} + {status ? : null} +
+ ); +} + +function SessionArtifactCard({ + artifact, + taskTitle, + onOpen, +}: { + artifact: SessionTaskSummary['artifacts'][number]; + taskTitle?: string; + onOpen: () => void; +}) { + const [failedPreviewUrl, setFailedPreviewUrl] = useState(null); + const label = humanizeFilename(artifact.path); + const isImage = artifact.contentType.startsWith('image/'); + const isVideo = artifact.contentType.startsWith('video/'); + const thumbnailUrl = artifact.thumbnailUrl; + const videoPreviewUrl = artifact.previewUrl; + + return ( + + ); +} + +type SessionArtifactEntry = { + taskId: string; + taskTitle: string; + artifact: SessionArtifact; }; -function InfoRow({ label, children }: { label: string; children: ReactNode }) { +type SessionArtifactTask = Pick< + SessionTaskSummary, + 'taskId' | 'title' | 'artifacts' +>; + +function getLatestSessionArtifacts( + tasks: SessionArtifactTask[], +): SessionArtifactEntry[] { + const entries: SessionArtifactEntry[] = []; + + for (const task of tasks) { + const latestByPath = new Map(); + for (const artifact of task.artifacts) { + const current = latestByPath.get(artifact.path); + if (!current || artifact.version > current.version) { + latestByPath.set(artifact.path, artifact); + } + } + for (const artifact of latestByPath.values()) { + entries.push({ taskId: task.taskId, taskTitle: task.title, artifact }); + } + } + + return entries.sort( + (a, b) => + new Date(b.artifact.createdAt).getTime() - + new Date(a.artifact.createdAt).getTime(), + ); +} + +function SessionArtifactViewer({ + entry, + closeLabel, + onBack, + onClose, +}: { + entry: SessionArtifactEntry; + closeLabel: string; + onBack: () => void; + onClose: () => void; +}) { + const { + data: artifact, + isPending, + isError, + } = useArtifactByPath( + entry.taskId, + entry.artifact.path, + entry.artifact.version, + ); + + return ( + <> +
+ + + +

+ {humanizeFilename(entry.artifact.path)} +

+ + + +
+
+ {isPending ? ( +
+ +
+ ) : isError || !artifact ? ( +
+ This artifact is unavailable. +
+ ) : ( + + )} +
+ + ); +} + +function SessionArtifactsPanel({ + tasks, + onClose, +}: { + tasks: SessionArtifactTask[]; + onClose: () => void; +}) { + const [selectedArtifact, setSelectedArtifact] = + useState(null); + const artifacts = getLatestSessionArtifacts(tasks); + const artifactSections = [ + { + label: 'Screenshots', + artifacts: artifacts.filter(({ artifact }) => + artifact.contentType.startsWith('image/'), + ), + }, + { + label: 'Videos', + artifacts: artifacts.filter(({ artifact }) => + artifact.contentType.startsWith('video/'), + ), + }, + { + label: 'Files', + artifacts: artifacts.filter( + ({ artifact }) => + !artifact.contentType.startsWith('image/') && + !artifact.contentType.startsWith('video/'), + ), + }, + ]; + return ( - - {label} - {children} - + + {selectedArtifact ? ( + setSelectedArtifact(null)} + onClose={onClose} + /> + ) : ( + <> + +
+ {artifacts.length === 0 ? ( +
+ No artifacts in this session yet. +
+ ) : ( +
+ {artifactSections.map( + ({ label, artifacts: sectionArtifacts }) => + sectionArtifacts.length ? ( +
+

+ {label} +

+
+ {sectionArtifacts.map((entry) => ( + setSelectedArtifact(entry)} + /> + ))} +
+
+ ) : null, + )} +
+ )} +
+ + )} +
+ ); +} + +function SessionTasksPanel({ + tasks, + onOpenTask, + onClose, +}: { + tasks: Array>; + onOpenTask: (taskId: string) => void; + onClose: () => void; +}) { + return ( + + +
+ {tasks.map((task) => ( + + ))} +
+
); } @@ -70,54 +514,146 @@ function SessionInfoPanel({ ? (modelData?.models.find(({ id }) => id === session.model)?.displayName ?? session.model) : null; + const modelAndReasoningLabel = [ + modelLabel ?? 'Default model', + session.reasoningEffort + ? getReasoningEffortLabel(session.reasoningEffort) + : null, + ] + .filter(Boolean) + .join(' • '); const inferenceCostLabel = formatInferenceCost(session.inferenceCostMicroUsd); + const surfaceLabel = getSessionSurfaceLabel(session.surface); + const surfaceBrandIcon = getSessionSurfaceBrandIcon(session.surface); return ( -
- + -
- - - - - - {ownerDisplayName} - - - {modelLabel ?? 'Default model'} - - - - {inferenceCostLabel} + > + + + + + {ownerDisplayName} + + + + + + {modelAndReasoningLabel} + + + + + + + + +

+ Inference cost breakdown +

+
+
+
Direct session
+
+ $ + {formatInferenceCost( + session.inferenceCostBreakdown + .directInferenceCostMicroUsd, + )} +
+
+ {session.inferenceCostBreakdown.tasks.map((task) => ( +
+
+ {task.title} +
+
+ ${formatInferenceCost(task.inferenceCostMicroUsd)} +
+
+ ))} +
+
Total
+
+ ${inferenceCostLabel} +
+
+
+
+
+
+ + + + + {session.createdAt.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + })} -
- - - - {formatDistanceToNow(session.createdAt, { addSuffix: true })} - - - - - {SURFACE_LABELS[session.surface] ?? session.surface} - - -
-
-
+ + + + + {session.surface === 'slack' ? ( + + ) : surfaceBrandIcon ? ( + + ) : ( + + )} + {surfaceLabel} + + + {session.status ? ( + + + + ) : null} + + + ); } +type WorkspacePanel = + | { kind: 'info' } + | { kind: 'tasks' } + | { kind: 'artifacts' } + | { kind: 'nested'; taskId: string }; + export function SessionWorkspace({ session, children, @@ -125,54 +661,171 @@ export function SessionWorkspace({ session: SessionInfo; children: ReactNode; }) { - const [isInfoOpen, setIsInfoOpen] = useState(false); + // Exactly one side panel can be active: the discriminated union makes an + // impossible combination unrepresentable. The URL's ?task= selection is the + // fourth panel and always wins over `panel` when both are set. + const [panel, setPanel] = useState(null); + const trpc = useTRPC(); + const router = useRouter(); + const searchParams = useSearchParams(); + const isFastTaskSource = session.taskSource === 'fast'; + const { data: currentSession } = useQuery( + trpc.sessions.byId.queryOptions( + { sessionId: session.id }, + { + enabled: !isFastTaskSource, + // Settled sessions poll slowly; only visibly-running work needs the + // fast cadence. TanStack pauses both while the tab is unfocused. + refetchInterval: (query) => + query.state.data?.status === 'active' || + query.state.data?.status === 'needs_input' + ? 2_000 + : 30_000, + }, + ), + ); + const { data: currentFastTasks } = useQuery( + trpc.fastSessions.tasks.queryOptions( + { sessionId: session.id }, + { + enabled: isFastTaskSource, + refetchInterval: 2_000, + }, + ), + ); + const sessionTasks = currentSession?.tasks ?? session.tasks; + const fastTasks = currentFastTasks ?? session.taskCards ?? []; + const taskCards = isFastTaskSource ? fastTasks : sessionTasks; + const artifactTasks = isFastTaskSource ? fastTasks : sessionTasks; + const sessionPullRequests = getSessionPullRequests(sessionTasks); + const runningTasks = taskCards.filter((task) => + isActivelyRunningTask(task.latestRun?.status, task.latestRun?.taskPhase), + ); + const runningTaskCount = runningTasks.length; + const singleRunningTaskId = + runningTaskCount === 1 ? runningTasks[0]?.taskId : null; + const selectedTaskId = searchParams.get('task'); + const selectedTask = taskCards.find((task) => task.taskId === selectedTaskId); + const panelOpen = panel !== null || Boolean(selectedTask); + + const selectTask = useCallback( + (taskId: string | null) => { + if (taskId === selectedTaskId) return; + + const params = new URLSearchParams(searchParams); + if (taskId) params.set('task', taskId); + else params.delete('task'); + const query = params.toString(); + router.replace(`/sessions/${session.id}${query ? `?${query}` : ''}`); + }, + [router, searchParams, selectedTaskId, session.id], + ); + + const openTaskPanel = useCallback( + (taskId: string) => { + setPanel({ kind: 'nested', taskId }); + selectTask(null); + }, + [selectTask], + ); + const openTasksPanel = useCallback(() => { + if (singleRunningTaskId) { + setPanel(null); + selectTask(singleRunningTaskId); + return; + } + + setPanel({ kind: 'tasks' }); + selectTask(null); + }, [selectTask, singleRunningTaskId]); + const closePanel = () => { + setPanel(null); + selectTask(null); + }; + const togglePanel = (kind: 'info' | 'tasks' | 'artifacts') => { + setPanel((previous) => (previous?.kind === kind ? null : { kind })); + selectTask(null); + }; + const panelContent = selectedTask ? ( + + ) : panel?.kind === 'nested' ? ( + + ) : panel?.kind === 'tasks' ? ( + + ) : panel?.kind === 'artifacts' ? ( + + ) : ( + + ); const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); + useResponsiveSandboxSidebar(session.id); return ( - - setIsInfoOpen(false)} - > - setIsInfoOpen((previous) => !previous)} - /> - - {!isSidebarVisible && !isInfoOpen ? ( - - - - ) : null} - - } - > - setIsInfoOpen(false)} - /> + + + + togglePanel('tasks')} + /> + togglePanel('artifacts')} + /> + togglePanel('info')} + /> + + {!isSidebarVisible && !panelOpen ? ( + + + + ) : null} + } - /> - + > + + + + {children} + + + + } + panel={panelContent} + /> + + ); } diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx index 90f947b1c..419f23622 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.test.tsx @@ -1,23 +1,45 @@ import type { ReactNode } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -const { authorizeMock, getFastSessionByIdMock, transcriptMock } = vi.hoisted( - () => ({ - authorizeMock: vi.fn(), - getFastSessionByIdMock: vi.fn(), - transcriptMock: vi.fn( - ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( -
{footer}
- ), +const { + authorizeMock, + getFastSessionByIdMock, + getFastSessionTasksMock, + getSessionByIdCommandMock, + transcriptMock, + sessionWorkspaceMock, +} = vi.hoisted(() => ({ + authorizeMock: vi.fn(), + getFastSessionByIdMock: vi.fn(), + getFastSessionTasksMock: vi.fn(), + getSessionByIdCommandMock: vi.fn(), + transcriptMock: vi.fn( + ({ footer }: { messages: unknown[]; footer?: ReactNode }) => ( +
{footer}
), - }), -); + ), + sessionWorkspaceMock: vi.fn(({ children }: { children: ReactNode }) => ( +
{children}
+ )), +})); vi.mock('@/lib/server/auth-context', () => ({ authorize: authorizeMock })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), + notFound: () => { + throw new Error('NEXT_NOT_FOUND'); + }, +})); vi.mock('@/lib/server/fast-sessions', () => ({ getFastSessionById: getFastSessionByIdMock, + getFastSessionTasks: getFastSessionTasksMock, +})); +vi.mock('@/trpc/commands/sessions', () => ({ + getSessionByIdCommand: getSessionByIdCommandMock, })); vi.mock('../../use-sandbox-layout', () => ({ + useResponsiveSandboxSidebar: vi.fn(), useSandboxLayout: () => ({ isSidebarVisible: true, setSidebarVisible: vi.fn(), @@ -35,10 +57,50 @@ vi.mock('@/components/layout', () => ({ vi.mock('./FastSessionTranscript', () => ({ FastSessionTranscript: transcriptMock, })); +vi.mock('./SessionWorkspace', () => ({ + SessionWorkspace: sessionWorkspaceMock, + SessionHeaderExtras: ({ status }: { status: string | null }) => ( +
{status}
+ ), +})); +vi.mock('./SessionReadTracker', () => ({ + SessionReadTracker: () => null, +})); + +import SessionDetailPage, { generateMetadata } from './page'; + +describe('Session detail page', () => { + beforeEach(() => { + vi.clearAllMocks(); + getSessionByIdCommandMock.mockResolvedValue(null); + getFastSessionTasksMock.mockResolvedValue([]); + }); + + it('uses the Session title in the initial route metadata', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000006', + title: + 'Rotate the API keys across every production environment without downtime', + fastConversationId: null, + }); -import SessionDetailPage from './page'; + await expect( + generateMetadata({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000006', + }), + }), + ).resolves.toEqual({ + title: + 'Rotate the API keys across every production environment with... | Roomote', + }); + }); -describe('Fast session detail page', () => { it('uses the shared task workspace and renders supported session data', async () => { authorizeMock.mockResolvedValue({ success: true, @@ -46,7 +108,7 @@ describe('Fast session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-1', + id: '6a1f8f1e-0000-4000-8000-000000000001', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -98,7 +160,9 @@ describe('Fast session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-1' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', + }), }), ); @@ -108,7 +172,7 @@ describe('Fast session detail page', () => { expect(html).not.toContain('OpenCode workspace details unavailable'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-1', + sessionId: '6a1f8f1e-0000-4000-8000-000000000001', canReply: true, fallbackTitle: 'Question', initialMessages: expect.arrayContaining([ @@ -126,7 +190,7 @@ describe('Fast session detail page', () => { isAdmin: false, }); getFastSessionByIdMock.mockResolvedValue({ - id: 'session-2', + id: '6a1f8f1e-0000-4000-8000-000000000003', userId: 'user-1', ownerName: 'User', ownerEmail: 'user@example.com', @@ -146,17 +210,247 @@ describe('Fast session detail page', () => { const html = renderToStaticMarkup( await SessionDetailPage({ - params: Promise.resolve({ sessionId: 'session-2' }), + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', + }), }), ); expect(html).not.toContain('b3b0a53e-6dab-4bb8-b3a5-111111111111'); expect(transcriptMock).toHaveBeenCalledWith( expect.objectContaining({ - sessionId: 'session-2', + sessionId: '6a1f8f1e-0000-4000-8000-000000000003', canReply: true, initialTitle: 'Rotate the API keys', - fallbackTitle: 'Session', + fallbackTitle: 'New session', + }), + undefined, + ); + }); + + it('hydrates a direct Session route without seeding the response lease', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000002', + title: 'Session title', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'slack', + fastConversationId: '6a1f8f1e-0000-4000-8000-000000000005', + directInferenceCostMicroUsd: 100_000, + inferenceCostMicroUsd: 300_000, + respondingUntil: new Date(Date.now() + 60_000), + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'active', + tasks: [ + { + taskId: 'task-1', + title: 'Delegated task', + inferenceCostMicroUsd: 200_000, + }, + ], + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000002', + }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000002', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionTasksMock).not.toHaveBeenCalled(); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: '6a1f8f1e-0000-4000-8000-000000000002', + status: 'active', + tasks: [expect.objectContaining({ taskId: 'task-1' })], + inferenceCostMicroUsd: 300_000, + inferenceCostBreakdown: { + directInferenceCostMicroUsd: 100_000, + tasks: [ + expect.objectContaining({ + taskId: 'task-1', + inferenceCostMicroUsd: 200_000, + }), + ], + }, + }), + }), + undefined, + ); + expect(transcriptMock).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + canReply: true, + initialTitle: 'Session title', + fallbackTitle: 'Session title', + }), + undefined, + ); + expect(transcriptMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'initialConversationResponding', + ); + expect(transcriptMock.mock.calls[0]?.[0]).not.toHaveProperty( + 'timelineExtras', + ); + }); + + it('renders a task-only workspace for unified sessions without a Fast conversation', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getSessionByIdCommandMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000004', + title: 'Task-only session', + ownerName: 'User', + ownerEmail: 'user@example.com', + ownerImageUrl: null, + sourceSurface: 'web', + fastConversationId: null, + directInferenceCostMicroUsd: 0, + inferenceCostMicroUsd: 0, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + status: 'completed', + tasks: [ + { + taskId: 'task-2', + title: 'Delegated task', + inferenceCostMicroUsd: 0, + }, + ], + }); + + const html = renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000004', + }), + }), + ); + + expect(getFastSessionByIdMock).not.toHaveBeenCalled(); + expect(transcriptMock).not.toHaveBeenCalled(); + expect(html).toContain('Task-only session'); + }); + + it('falls back to the Fast conversation lookup when no session row exists', async () => { + authorizeMock.mockResolvedValue({ + success: true, + userId: 'user-1', + isAdmin: false, + }); + getFastSessionByIdMock.mockResolvedValue({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + userId: 'user-1', + ownerName: 'User', + ownerEmail: 'user@example.com', + surface: 'slack', + model: null, + reasoningEffort: null, + directInferenceCostMicroUsd: 100_000, + inferenceCostMicroUsd: 100_000, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + messages: [], + hasOlderMessages: false, + }); + getFastSessionTasksMock.mockResolvedValue([ + { + taskId: 'task-1', + title: 'Delegated task', + inferenceCostMicroUsd: 200_000, + artifacts: [ + { + id: 'artifact-1', + path: 'reports/result.md', + version: 1, + artifactType: 'plan', + contentType: 'text/markdown', + size: 200, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + }, + { + taskId: 'task-2', + title: 'Zero-cost task', + inferenceCostMicroUsd: 0, + artifacts: [], + }, + ]); + + renderToStaticMarkup( + await SessionDetailPage({ + params: Promise.resolve({ + sessionId: '6a1f8f1e-0000-4000-8000-000000000005', + }), + }), + ); + + expect(getSessionByIdCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionByIdMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(getFastSessionTasksMock).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + '6a1f8f1e-0000-4000-8000-000000000005', + ); + expect(sessionWorkspaceMock).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + id: '6a1f8f1e-0000-4000-8000-000000000005', + taskSource: 'fast', + taskCards: expect.arrayContaining([ + expect.objectContaining({ taskId: 'task-1' }), + expect.objectContaining({ taskId: 'task-2' }), + ]), + inferenceCostMicroUsd: 300_000, + inferenceCostBreakdown: { + directInferenceCostMicroUsd: 100_000, + tasks: [ + expect.objectContaining({ + taskId: 'task-1', + inferenceCostMicroUsd: 200_000, + }), + expect.objectContaining({ + taskId: 'task-2', + inferenceCostMicroUsd: 0, + }), + ], + }, + }), }), undefined, ); diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx index c042a4893..a41f34b9c 100644 --- a/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/page.tsx @@ -1,36 +1,92 @@ +import { cache } from 'react'; +import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; +import { z } from 'zod'; import { resolveEffectiveModelRuntimeEnv } from '@roomote/db/server'; import { getTextFromContentBlocks, + PRODUCT_NAME, REASONING_EFFORT_VALUES, type ReasoningEffort, } from '@roomote/types'; import { authorize } from '@/lib/server/auth-context'; -import { getFastSessionById } from '@/lib/server/fast-sessions'; +import { truncatePageTitle } from '@/lib/page-title'; +import { + getFastSessionById, + getFastSessionTasks, +} from '@/lib/server/fast-sessions'; +import { getSessionByIdCommand } from '@/trpc/commands/sessions'; +import { WorkspaceHeader } from '@/components/layout'; import { FastSessionTranscript } from './FastSessionTranscript'; -import { SessionWorkspace, type SessionInfo } from './SessionWorkspace'; +import { + SessionHeaderExtras, + SessionWorkspace, + type SessionInfo, +} from './SessionWorkspace'; +import { SessionReadTracker } from './SessionReadTracker'; -export default async function SessionDetailPage({ - params, -}: { - params: Promise<{ sessionId: string }>; -}) { - const [{ sessionId }, authorizedUser] = await Promise.all([ - params, - authorize(), - ]); +const getSessionPageData = cache(async (sessionId: string) => { + const authorizedUser = await authorize(); if (!authorizedUser.success) { notFound(); } + // Both lookup columns are uuid; a garbage route param would otherwise throw + // 22P02 in Postgres instead of 404ing. + if (!z.string().uuid().safeParse(sessionId).success) { + notFound(); + } - const session = await getFastSessionById(authorizedUser, sessionId); - if (!session) { + // Old links may carry a fast-conversation id whose session row hasn't been + // backfilled yet; getSessionByIdCommand falls back by fastConversationId, + // and the fast lookup below covers a conversation with no session row. + const unifiedSession = await getSessionByIdCommand(authorizedUser, sessionId); + const session = unifiedSession?.fastConversationId + ? await getFastSessionById( + authorizedUser, + unifiedSession.fastConversationId, + ) + : unifiedSession + ? null + : await getFastSessionById(authorizedUser, sessionId); + + if (!unifiedSession && !session) { notFound(); } + return { authorizedUser, unifiedSession, session }; +}); + +type SessionDetailPageProps = { + params: Promise<{ sessionId: string }>; +}; + +export async function generateMetadata({ + params, +}: SessionDetailPageProps): Promise { + const { sessionId } = await params; + const { unifiedSession, session } = await getSessionPageData(sessionId); + const initialUserMessage = session?.messages.find( + (message) => message.role === 'user', + ); + const fallbackTitle = + getTextFromContentBlocks(initialUserMessage?.contentBlocks ?? [])?.trim() || + 'New session'; + const title = truncatePageTitle( + unifiedSession?.title ?? session?.title ?? fallbackTitle, + ); + + return { title: `${title} | ${PRODUCT_NAME}` }; +} + +export default async function SessionDetailPage({ + params, +}: SessionDetailPageProps) { + const { sessionId } = await params; + const { authorizedUser, unifiedSession, session } = + await getSessionPageData(sessionId); // The chip's "default" must reflect what Fast actually runs with: the // deployment's orchestration model, not the task launch default. const modelEnv: Record = @@ -44,6 +100,74 @@ export default async function SessionDetailPage({ ? (rawDefaultEffort as ReasoningEffort) : null; + if (unifiedSession) { + const sessionInfo: SessionInfo = { + id: unifiedSession.id, + ownerName: unifiedSession.ownerName, + ownerEmail: unifiedSession.ownerEmail, + ownerImageUrl: unifiedSession.ownerImageUrl, + surface: unifiedSession.sourceSurface, + model: session?.model ?? defaultModelId, + reasoningEffort: session?.reasoningEffort ?? defaultReasoningEffort, + inferenceCostMicroUsd: unifiedSession.inferenceCostMicroUsd, + inferenceCostBreakdown: { + directInferenceCostMicroUsd: unifiedSession.directInferenceCostMicroUsd, + tasks: unifiedSession.tasks.map((task) => ({ + taskId: task.taskId, + title: task.title, + inferenceCostMicroUsd: task.inferenceCostMicroUsd, + })), + }, + createdAt: unifiedSession.createdAt, + status: unifiedSession.status, + tasks: unifiedSession.tasks, + }; + return ( + + +
+ {session ? ( + + } + /> + ) : ( + +

+ {unifiedSession.title} +

+ +
+ )} +
+
+ ); + } + if (!session) notFound(); + + const fastTasks = + (await getFastSessionTasks(authorizedUser, session.id)) ?? []; + const directInferenceCostMicroUsd = + session.directInferenceCostMicroUsd ?? session.inferenceCostMicroUsd ?? 0; + const inferenceCostMicroUsd = fastTasks.reduce( + (total, task) => total + task.inferenceCostMicroUsd, + directInferenceCostMicroUsd, + ); + const sessionInfo: SessionInfo = { id: session.id, ownerName: session.ownerName, @@ -51,15 +175,24 @@ export default async function SessionDetailPage({ ownerImageUrl: session.ownerImageUrl, surface: session.surface, model: session.model ?? defaultModelId, - inferenceCostMicroUsd: session.inferenceCostMicroUsd, + reasoningEffort: session.reasoningEffort ?? defaultReasoningEffort, + inferenceCostMicroUsd, + inferenceCostBreakdown: { + directInferenceCostMicroUsd, + tasks: fastTasks, + }, createdAt: session.createdAt, + status: null, + tasks: [], + taskSource: 'fast', + taskCards: fastTasks, }; const initialUserMessage = session.messages.find( (message) => message.role === 'user', ); const fallbackTitle = getTextFromContentBlocks(initialUserMessage?.contentBlocks ?? [])?.trim() || - 'Session'; + 'New session'; return ( diff --git a/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts new file mode 100644 index 000000000..2e9363ccf --- /dev/null +++ b/apps/web/src/app/(sandbox)/sessions/[sessionId]/session-task-panel-context.ts @@ -0,0 +1,23 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export const OpenSessionTaskPanelContext = createContext< + ((taskId: string) => void) | null +>(null); +export const OpenSessionTasksPanelContext = createContext<(() => void) | null>( + null, +); +export const SessionRunningTaskCountContext = createContext(0); + +export function useOpenSessionTaskPanel() { + return useContext(OpenSessionTaskPanelContext); +} + +export function useOpenSessionTasksPanel() { + return useContext(OpenSessionTasksPanelContext); +} + +export function useSessionRunningTaskCount() { + return useContext(SessionRunningTaskCountContext); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx index 8dd0401c4..76c2ee383 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.client.test.tsx @@ -1,12 +1,17 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -const { useSandboxLayoutMock, useTRPCMock, updateTitleMutationMock } = - vi.hoisted(() => ({ - useSandboxLayoutMock: vi.fn(), - useTRPCMock: vi.fn(), - updateTitleMutationMock: vi.fn(async () => undefined), - })); +const { + useSandboxLayoutMock, + useTRPCMock, + updateTitleMutationMock, + parentSessionQueryMock, +} = vi.hoisted(() => ({ + useSandboxLayoutMock: vi.fn(), + useTRPCMock: vi.fn(), + updateTitleMutationMock: vi.fn(async () => undefined), + parentSessionQueryMock: vi.fn(), +})); vi.mock('../../use-sandbox-layout', () => ({ useSandboxLayout: useSandboxLayoutMock, @@ -16,6 +21,10 @@ vi.mock('@/trpc/client', () => ({ useTRPC: useTRPCMock, })); +vi.mock('./TaskSessionReadTracker', () => ({ + TaskSessionReadTracker: () => null, +})); + vi.mock('@/components/sandbox', () => ({ WorkspaceBadge: ({ environmentId, @@ -78,6 +87,10 @@ function renderHeader( describe('Header', () => { beforeEach(() => { vi.clearAllMocks(); + parentSessionQueryMock.mockResolvedValue({ + sessionId: 'session-1', + title: 'Parent Session', + }); useSandboxLayoutMock.mockReturnValue({ isSidebarVisible: true, @@ -93,6 +106,18 @@ describe('Header', () => { ], }, }, + sessions: { + forTask: { + queryOptions: ( + _input: { taskId: string }, + options?: { enabled?: boolean }, + ) => ({ + queryKey: ['sessions.forTask'], + queryFn: parentSessionQueryMock, + enabled: options?.enabled, + }), + }, + }, tasks: { updateTitle: { mutationOptions: () => ({ @@ -142,6 +167,37 @@ describe('Header', () => { expect(screen.queryByText('OpenCode')).not.toBeInTheDocument(); }); + it('always queries the parent session and renders its links', async () => { + renderHeader(); + + expect( + await screen.findByRole('link', { name: 'Parent Session' }), + ).toHaveAttribute('href', '/sessions/session-1?task=task-123'); + expect(screen.getByRole('link', { name: /Go to session/ })).toHaveAttribute( + 'href', + '/sessions/session-1?task=task-123', + ); + expect(parentSessionQueryMock).toHaveBeenCalled(); + }); + + it('links to the Fast session when the task has no unified session', async () => { + parentSessionQueryMock.mockResolvedValue(null); + + renderHeader({ + taskRun: { + payload: { + environmentId: 'env-1', + fastAgentSessionId: '00000000-0000-4000-8000-000000000001', + }, + harness: 'opencode-server', + } as never, + }); + + expect( + await screen.findByRole('link', { name: /Go to session/ }), + ).toHaveAttribute('href', '/sessions/00000000-0000-4000-8000-000000000001'); + }); + it('refreshes task lists after renaming a task', async () => { const { queryClient } = renderHeader(); const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries'); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx index 282df1030..d9d40eedb 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/Header.tsx @@ -1,17 +1,26 @@ 'use client'; import { useEffect, useState, type KeyboardEvent } from 'react'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; import { toast } from 'sonner'; import { ArrowLeftFromLine, Button, + ExternalLink, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, Input, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, } from '@/components/system'; import { PullRequestBadge, WorkspaceBadge } from '@/components/sandbox'; import { WorkspaceHeader } from '@/components/layout'; @@ -20,6 +29,7 @@ import { useTRPC } from '@/trpc/client'; import { useSandboxLayout } from '../../use-sandbox-layout'; import { type TaskSession } from './hooks'; +import { TaskSessionReadTracker } from './TaskSessionReadTracker'; interface HeaderProps { session: TaskSession; @@ -28,15 +38,24 @@ interface HeaderProps { export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { const { isSidebarVisible, toggleSidebar } = useSandboxLayout(); const trpc = useTRPC(); + const searchParams = useSearchParams(); const queryClient = useQueryClient(); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [titleDraft, setTitleDraft] = useState(task?.title ?? ''); + const { data: parentSession } = useQuery( + trpc.sessions.forTask.queryOptions({ taskId }), + ); const environmentId = taskRun?.payload?.environmentId; const repo = taskRun?.payload?.repo; const prRepo = taskRun?.prRepo; const prNumber = taskRun?.prNumber; const pullRequests = taskRun?.pullRequests ?? []; + const sessionHref = parentSession + ? `/sessions/${parentSession.sessionId}?task=${taskId}` + : taskRun?.payload?.fastAgentSessionId + ? `/sessions/${taskRun.payload.fastAgentSessionId}` + : null; const badges = [ (environmentId || repo) && ( @@ -150,21 +169,65 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { }; const title = task?.title || 'Untitled task'; + const returnTo = searchParams?.get('returnTo'); + const safeReturnTo = + returnTo?.startsWith('/sessions') && !returnTo.startsWith('//') + ? returnTo + : '/sessions'; return ( <> - -

- {title} -

+ {parentSession ? ( + + ) : null} + + {parentSession ? ( + + + + + Sessions + + + + + + + {parentSession.title} + + + + + + + {title} + + + + + ) : ( +

+ {title} +

+ )} {badges.length > 0 && (
{badges.map((badge, index) => ( @@ -174,6 +237,14 @@ export const Header = ({ session: { taskRun, task, taskId } }: HeaderProps) => { ))}
)} + {sessionHref ? ( + + ) : null} {!isSidebarVisible && ( + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx index 2edeaacfa..f30360a05 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.anchors.client.test.tsx @@ -142,16 +142,19 @@ function buildGroup(): GroupedToolCallRenderBlock { describe('AcpGroupedToolMessage anchors', () => { it('keeps per-item anchors mounted even when tool content is collapsed', () => { + // The compact grouped layout renders anchors as standalone hidden divs + // (no collapsed ToolContent container anymore); scroll targets must stay + // in the DOM while per-item detail stays unmounted. render(); - const collapsedContent = screen.getByTestId('collapsed-tool-content'); - expect(document.getElementById('msg-101')).toBeTruthy(); expect(document.getElementById('msg-102')).toBeTruthy(); - expect(collapsedContent.querySelector('#msg-101')).toBeNull(); - expect(collapsedContent.querySelector('#msg-102')).toBeNull(); + expect(document.getElementById('msg-101')).toHaveAttribute( + 'aria-hidden', + 'true', + ); - // Subheadings live inside ToolContent and should not be mounted in collapsed mode. + // Subheadings only mount with expanded tool detail. expect(screen.queryByText('file_b.txt')).toBeNull(); expect(screen.queryByText('file_c.txt')).toBeNull(); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx index 6985d2161..ce3ad8e98 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpGroupedToolMessage.client.test.tsx @@ -1,10 +1,13 @@ import { render, screen } from '@testing-library/react'; import type { ReactNode } from 'react'; +import { AlertCircle, FileIcon } from '@/components/system'; + import { AcpGroupedToolMessage } from '../AcpGroupedToolMessage'; import type { GroupedToolCallRenderBlock } from '../render-blocks'; const codeBlockSpy = vi.fn(); +const toolHeaderSpy = vi.fn(); vi.mock('@/components/ai-elements', () => ({ CodeBlock: (props: { code: string }) => { @@ -16,21 +19,21 @@ vi.mock('@/components/ai-elements', () => ({
{children}
), Tool: ({ children }: { children?: ReactNode }) =>
{children}
, - ToolHeader: ({ - action, - object, - }: { + ToolHeader: (props: { action: string; object?: string; icon?: unknown; state?: string; collapsible?: boolean; - }) => ( -
- {action} - {object ? ` ${object}` : ''} -
- ), + }) => { + toolHeaderSpy(props); + return ( +
+ {props.action} + {props.object ? ` ${props.object}` : ''} +
+ ); + }, ToolContent: ({ children }: { children?: ReactNode }) => (
{children}
), @@ -118,17 +121,44 @@ function buildGroup(): GroupedToolCallRenderBlock { describe('AcpGroupedToolMessage', () => { beforeEach(() => { codeBlockSpy.mockClear(); + toolHeaderSpy.mockClear(); }); - it('renders grouped header and per-file sections', () => { + it('keeps grouped read rows compact when no item has expandable details', () => { render(); expect(screen.getByText('Exploring 2 files')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt')).toBeInTheDocument(); - expect(screen.getByText('file_b.txt')).toBeInTheDocument(); - expect(screen.getByText('file_a.txt').className).toContain('truncate'); - expect(screen.getByText('file_b.txt').className).toContain('truncate'); - + expect(screen.queryByText('file_a.txt')).not.toBeInTheDocument(); + expect(screen.queryByText('file_b.txt')).not.toBeInTheDocument(); expect(codeBlockSpy).not.toHaveBeenCalled(); }); + + it('keeps the resolved group icon while the header renders running progress', () => { + const group = buildGroup(); + group.items[0]!.msg.data.status = 'in_progress'; + + render(); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: FileIcon, + state: 'input-available', + }), + ); + }); + + it('keeps group failure presentation ahead of running progress', () => { + const group = buildGroup(); + group.items[0]!.msg.data.status = 'in_progress'; + group.items[1]!.msg.data.status = 'failed'; + + render(); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: AlertCircle, + state: 'output-error', + }), + ); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx index 0983429ec..c33b0bb7d 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpTextMessage.client.test.tsx @@ -1,10 +1,19 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ReactNode } from 'react'; import { ACP_ENVELOPE_EVENT_TYPES } from '@roomote/types'; const transcriptVisibilityState = vi.hoisted(() => ({ enabled: false, })); +const reviewActionMutate = vi.hoisted(() => vi.fn()); + +vi.mock('@/trpc/client', () => ({ + useTRPCClient: () => ({ + sandboxSession: { + handlePrReviewNotificationAction: { mutate: reviewActionMutate }, + }, + }), +})); vi.mock('@/components/ai-elements', () => ({ Attachment: ({ @@ -52,55 +61,62 @@ vi.mock('@/components/ai-elements', () => ({ ), })); -vi.mock('@/components/system', () => ({ - BasicTooltip: ({ - children, - content, - }: { - children: ReactNode; - content: ReactNode; - }) => ( -
- {children} -
- ), - Button: ({ - children, - ...props - }: { children: ReactNode } & Record) => ( - - ), - ChevronDownIcon: () => , - GitCommitVertical: () => , - GitPullRequestCreateArrow: () => ( - - ), - GitPullRequestDraft: () => , - Image: () => , - ListChecks: () => , - MediaViewerDialog: ({ - children, - open, - title, - }: { - children: ReactNode; - open: boolean; - title: string; - }) => - open ? ( -
+vi.mock('@/components/system', async () => { + const actual = await vi.importActual( + '@/components/system', + ); + + return { + ...actual, + BasicTooltip: ({ + children, + content, + }: { + children: ReactNode; + content: ReactNode; + }) => ( +
{children}
- ) : null, - MediaViewerImage: ({ alt }: { alt: string }) => ( -
- ), - ScanFace: () => , - ScanSearch: () => , - Sparkles: () => , -})); + ), + Button: ({ + children, + ...props + }: { children: ReactNode } & Record) => ( + + ), + ChevronDownIcon: () => , + GitCommitVertical: () => , + GitPullRequestCreateArrow: () => ( + + ), + GitPullRequestDraft: () => , + Image: () => , + ListChecks: () => , + MediaViewerDialog: ({ + children, + open, + title, + }: { + children: ReactNode; + open: boolean; + title: string; + }) => + open ? ( +
+ {children} +
+ ) : null, + MediaViewerImage: ({ alt }: { alt: string }) => ( +
+ ), + ScanFace: () => , + ScanSearch: () => , + Sparkles: () => , + }; +}); vi.mock('../../../useInternalTranscriptRowsVisible', () => ({ useInternalTranscriptRowsVisible: () => transcriptVisibilityState.enabled, @@ -111,6 +127,57 @@ import { AcpTextMessage } from '../AcpTextMessage'; describe('AcpTextMessage', () => { beforeEach(() => { transcriptVisibilityState.enabled = false; + reviewActionMutate.mockReset(); + }); + + const reviewOfferMessage = (status = 'pending') => ({ + id: 'review-offer-1', + ts: 123, + role: 'assistant' as const, + kind: 'text' as const, + partial: false, + sessionId: 'session-1', + updateType: ACP_ENVELOPE_EVENT_TYPES.AssistantMessage, + text: 'Review feedback remains.', + data: { + prReviewAction: { + deliveryId: '11111111-1111-4111-8111-111111111111', + question: 'Would you like me to resolve these issues?', + status, + }, + }, + }); + + it('renders and dispatches a canonical review action offer', async () => { + reviewActionMutate.mockResolvedValue({ status: 'resolved' }); + render(); + + fireEvent.click( + screen.getByRole('button', { name: 'Resolve these issues' }), + ); + await waitFor(() => + expect(reviewActionMutate).toHaveBeenCalledWith({ + deliveryId: '11111111-1111-4111-8111-111111111111', + choice: 'yes', + }), + ); + expect( + await screen.findByText('Resolving the current review issues.'), + ).toBeVisible(); + }); + + it('does not render a persisted dismissed offer', () => { + render(); + + expect( + screen.queryByTestId('pr-review-notification-actions'), + ).not.toBeInTheDocument(); + expect( + screen.queryByText('Review action dismissed.'), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Resolve these issues' }), + ).not.toBeInTheDocument(); }); it('shows copy and new task actions for assistant completion text', () => { @@ -718,8 +785,12 @@ describe('AcpTextMessage', () => { />, ); - const avatar = screen.getByRole('img', { name: 'Test User' }); + const avatar = screen.getByLabelText('Test User'); expect(avatar).toBeVisible(); + expect(avatar.querySelector('img')).toHaveAttribute( + 'src', + 'https://example.com/avatar.png', + ); expect(screen.getByTestId('basic-tooltip')).toHaveAttribute( 'data-content', 'Test User', @@ -774,7 +845,7 @@ describe('AcpTextMessage', () => { expect(screen.queryByRole('img')).not.toBeInTheDocument(); }); - it('does not show avatar when userImageUrl is missing', () => { + it('falls back to initials when the user image fails to load', () => { render( { updateType: 'roomote_runtime.assistant_message', text: 'Hello', data: {}, + userName: 'Test User', + userEmail: 'test@example.com', + userImageUrl: 'https://example.com/missing.png', }} />, ); - expect(screen.queryByRole('img')).not.toBeInTheDocument(); + const avatar = screen.getByLabelText('Test User'); + fireEvent.error(avatar.querySelector('img')!); + + expect(avatar.querySelector('img')).not.toBeInTheDocument(); + expect(screen.getByText('TU')).toBeInTheDocument(); + }); + + it('falls back to initials when userImageUrl is missing', () => { + render( + , + ); + + expect(screen.getByLabelText('Test User')).toBeVisible(); + expect(screen.getByText('TU')).toBeInTheDocument(); + expect(document.querySelector('img')).not.toBeInTheDocument(); }); it('shows copy and new task actions for optimistic user text', () => { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx index 78779d639..76d3c0597 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/AcpToolDetails.client.test.tsx @@ -281,7 +281,7 @@ describe('AcpToolDetails', () => { }); it.each(['search', 'query'])( - 'adds the sanitized Hippocampus %s query to the existing result YAML', + 'renders the sanitized Memory %s input before the result YAML', (toolName) => { const result = { matches: [{ title: 'Existing result', score: 0.98 }], @@ -308,20 +308,14 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'matches:', - ' - title: Existing result', - ' score: 0.98', - 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), - ); + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: Find RooCodeInc/Roomote notes with api_key=[redacted]', + ['matches:', ' - title: Existing result', ' score: 0.98'].join( + '\n', + ), + ]); expect(toolInputSpy).not.toHaveBeenCalled(); }, ); @@ -349,18 +343,17 @@ describe('AcpToolDetails', () => { />, ); - expect(codeBlockSpy).toHaveBeenCalledWith( - expect.objectContaining({ - code: [ - 'delivered: true', - 'taskId: task-1', - 'message: Review RooCodeInc/Roomote and use password=[redacted]', - ].join('\n'), - language: 'yaml', - variant: 'compact', - highlight: false, - className: expect.stringContaining('bg-transparent'), - }), + expect(screen.getByText('Input')).toBeInTheDocument(); + expect(screen.getByText('Result')).toBeInTheDocument(); + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'message: Review RooCodeInc/Roomote and use password=[redacted]', + ['delivered: true', 'taskId: task-1'].join('\n'), + ]); + expect(codeBlockSpy.mock.calls[0]?.[0].className).toContain( + '[&_pre]:whitespace-pre-wrap', + ); + expect(codeBlockSpy.mock.calls[0]?.[0].className).toContain( + '[&_pre]:min-w-0', ); expect(toolInputSpy).not.toHaveBeenCalled(); }); @@ -389,6 +382,61 @@ describe('AcpToolDetails', () => { expect(toolInputSpy).not.toHaveBeenCalled(); }); + it('keeps colliding input and result fields separate', () => { + render( + ), + text: JSON.stringify({ query: 'result value', matches: 2 }), + }} + />, + ); + + expect(codeBlockSpy.mock.calls.map(([props]) => props.code)).toEqual([ + 'query: requested value', + ['query: result value', 'matches: 2'].join('\n'), + ]); + }); + + it('keeps input visible when a truncated result is no longer valid JSON', () => { + render( + ), + text: '{"matches":[\n... output truncated ...\n]}', + }} + />, + ); + + expect(codeBlockSpy.mock.calls[0]?.[0].code).toBe('query: large result'); + expect(codeBlockSpy.mock.calls[1]?.[0]).toEqual( + expect.objectContaining({ + language: 'yaml', + code: expect.stringContaining('output truncated'), + }), + ); + }); + it('hides expanded details for Roomote Slack lifecycle tools', () => { const { container } = render( { ); }); + it('keeps the resolved tool icon while the header renders running progress', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: SquarePen, + state: 'input-available', + }), + ); + }); + + it('keeps a known MCP brand icon while the header renders running progress', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: mcpIntegrationIconFor('sentry'), + state: 'input-available', + }), + ); + }); + + it('keeps failure presentation ahead of partial progress', () => { + const msg = buildMessage('edit', { status: 'failed' }); + msg.partial = true; + + render(); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: AlertCircle, + state: 'output-error', + }), + ); + }); + it('uses Bot for subagent tool calls', () => { render(); @@ -349,7 +406,7 @@ describe('AcpToolMessage', () => { expect(toolDetailsSpy).not.toHaveBeenCalled(); }); - it('renders the gbrain MCP server as Hippocampus', () => { + it('renders the gbrain MCP server as Memory', () => { render( { expect.objectContaining({ action: 'Used', object: 'Query', - suffix: 'Hippocampus', + suffix: 'Memory', + }), + ); + }); + + it('uses the known MCP integration’s brand icon', () => { + render( + , + ); + + expect(toolHeaderSpy).toHaveBeenCalledWith( + expect.objectContaining({ + icon: mcpIntegrationIconFor('sentry'), + suffix: 'Sentry', }), ); }); @@ -387,7 +466,7 @@ describe('AcpToolMessage', () => { expect(toolHeaderSpy).toHaveBeenCalledWith( expect.objectContaining({ - icon: Eye, + icon: FileIcon, collapsible: false, }), ); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts index 29b7fd169..fe9a68221 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-call-grouping.client.test.ts @@ -820,7 +820,7 @@ describe('buildAcpRenderBlocks', () => { kind: 'tool_group', action: 'Used', objectSummary: '2 get issue calls', - displayKind: 'tool', + displayKind: 'generic', }); }); @@ -2014,4 +2014,78 @@ describe('buildAcpRenderBlocks', () => { }, }); }); + + it('keeps multiple delegated tasks as standalone cards when requested', () => { + const delegatedTask = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'launch_task', + kind: 'tool', + mcp: false, + payload: { + toolName: 'launch_task', + output: JSON.stringify({ success: true, taskId: id }), + }, + }); + + const entries = buildAcpRenderBlocks( + [delegatedTask('child-1', 1), delegatedTask('child-2', 2)], + { keepDelegatedTasksVisible: true }, + ); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent widget previews standalone', () => { + const widget = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'show_widget', + kind: 'mcp', + toolName: 'show_widget', + text: JSON.stringify({ + success: true, + shown: true, + html: `

${id}

`, + height: 240, + }), + }); + + const entries = buildAcpRenderBlocks([ + widget('widget-1', 1), + widget('widget-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); + + it('keeps adjacent visual-proof uploads standalone', () => { + const proof = (id: string, ts: number) => + explorationToolMessage({ + id, + ts, + title: 'manage_artifacts', + kind: 'mcp', + toolName: 'manage_artifacts', + text: JSON.stringify({ + success: true, + artifactId: id, + artifactType: 'visual-proof', + viewUrl: `https://example.com/task/task-1/artifacts/${id}.png`, + rawUrl: `https://example.com/${id}.png`, + }), + }); + + const entries = buildAcpRenderBlocks([ + proof('proof-1', 1), + proof('proof-2', 2), + ]); + + expect(entries).toHaveLength(2); + expect(entries.every((entry) => entry.kind === 'message')).toBe(true); + }); }); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts new file mode 100644 index 000000000..3df86c181 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/__tests__/tool-presentation.client.test.ts @@ -0,0 +1,217 @@ +import type { AcpToolResultPayload } from '@roomote/types'; + +import { resolveToolPresentation } from '../tool-presentation'; +import { resolveToolPresentationPolicy } from '../tool-presentation-policy'; +import type { AcpToolResultUiMessage } from '../types'; + +function toolData( + overrides: Partial = {}, +): AcpToolResultPayload { + return { + toolCallId: 'call-1', + kind: 'tool', + title: 'custom_tool', + isExecute: false, + isMcp: false, + mcpServerName: null, + mcpToolName: null, + command: null, + exitCode: null, + output: '{}', + status: 'completed', + ...overrides, + }; +} + +function toolMessage( + overrides: Partial = {}, +): AcpToolResultUiMessage { + const data = toolData(overrides); + return { + id: 'message-1', + ts: 1, + role: 'tool', + partial: false, + sessionId: 'session-1', + updateType: 'roomote_runtime.tool_result', + kind: 'tool_result', + text: data.output, + data, + }; +} + +describe('tool presentation resolver', () => { + it.each([ + [{ kind: 'execute', isExecute: true }, 'execute', 'terminal'], + [{ kind: 'read' }, 'read', 'file'], + [{ toolName: 'spill_grep' }, 'search', 'search'], + [{ toolName: 'list_skills' }, 'list', 'folder'], + [{ toolName: 'launch_task' }, 'task', 'task'], + [{ toolName: 'save_memory' }, 'memory', 'memory'], + [{ toolName: 'show_widget' }, 'widget', 'widget'], + ] as const)('classifies %o as %s', (overrides, category, iconKey) => { + expect(resolveToolPresentation(toolData(overrides))).toMatchObject({ + category, + iconKey, + }); + }); + + it.each([ + ['manage_custom_automations', 'task'], + ['get_about_me', 'roomote'], + ['describe_video', 'video'], + ['manage_goal', 'target'], + ['manage_tasks', 'list-checks'], + ['manage_source_control', 'pull-request'], + ['manage_environments', 'environment'], + ['save_task_memory', 'memory'], + ['request_environment_variables', 'terminal'], + ['report_platform_issue', 'alert'], + ['submit_automation_work_items', 'task'], + ['list_chat_channels', 'messages'], + ['get_chat_channel_messages', 'messages'], + ['get_chat_message_context', 'messages'], + ] as const)('uses the %s icon for %s', (toolName, iconKey) => { + expect(resolveToolPresentation(toolData({ toolName }))).toMatchObject({ + iconKey, + }); + }); + + it('uses Memory as the provider label without changing canonical identity', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'gbrain', + mcpToolName: 'query', + serverName: 'gbrain', + toolName: 'query', + }), + ), + ).toMatchObject({ + category: 'memory', + providerLabel: 'Memory', + identity: { serverName: 'gbrain', toolName: 'query' }, + }); + }); + + it('uses a known MCP integration’s catalog label and icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'search_issues', + serverName: 'sentry', + toolName: 'search_issues', + }), + ), + ).toMatchObject({ + integrationIcon: 'sentry', + providerLabel: 'Sentry', + }); + }); + + it('keeps explicit tool icons ahead of an MCP integration icon', () => { + expect( + resolveToolPresentation( + toolData({ + isMcp: true, + mcpServerName: 'sentry', + mcpToolName: 'manage_goal', + serverName: 'sentry', + toolName: 'manage_goal', + }), + ), + ).toMatchObject({ iconKey: 'target', integrationIcon: undefined }); + }); + + it('uses meaningful receipt language for consequential task actions', () => { + expect( + resolveToolPresentation(toolData({ toolName: 'launch_task' })), + ).toMatchObject({ verb: 'Started', object: 'Coding Task' }); + expect( + resolveToolPresentation( + toolData({ toolName: 'launch_task', status: 'failed' }), + ), + ).toMatchObject({ verb: 'Failed to Start', object: 'Coding Task' }); + }); + + it('sanitizes native fallback titles without using them for identity', () => { + expect( + resolveToolPresentation( + toolData({ + title: 'Read /sandbox/repos/RooCodeInc/Roomote/apps/web/package.json', + toolName: null, + }), + ), + ).toMatchObject({ + displayName: 'Read RooCodeInc/Roomote/apps/web/package.json', + object: 'Read RooCodeInc/Roomote/apps/web/package.json', + identity: { toolName: null }, + groupKey: 'kind:tool', + }); + }); +}); + +describe('tool presentation policy', () => { + it('keeps consequential receipts outside collapsed activity', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'save_memory', kind: 'memory' }), + ).activityMode, + ).toBe('keep-visible'); + }); + + it('keeps delegated task cards visible in narration mode only on card-enabled surfaces', () => { + const message = toolMessage({ + toolName: 'launch_task', + kind: 'task', + output: JSON.stringify({ success: true, taskId: 'task-1' }), + }); + + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: true, + displayMode: 'narration', + }), + ).toMatchObject({ + renderAs: 'delegated-task-card', + rowVisibility: 'visible', + activityMode: 'keep-visible', + }); + expect( + resolveToolPresentationPolicy(message, { + delegatedTaskCardsEnabled: false, + }).renderAs, + ).toBe('row'); + }); + + it('keeps ordinary exploration hidden in narration mode', () => { + expect( + resolveToolPresentationPolicy( + toolMessage({ toolName: 'read_file', kind: 'read' }), + { displayMode: 'narration' }, + ).rowVisibility, + ).toBe('hidden'); + }); + + it('hides ignore_event as internal lifecycle handling', () => { + const message = toolMessage({ + title: 'ignore_event', + toolName: 'ignore_event', + kind: 'communication', + }); + + expect( + resolveToolPresentationPolicy(message, { + showInternalMessages: false, + }).rowVisibility, + ).toBe('debug-only'); + expect( + resolveToolPresentationPolicy(message, { + showInternalMessages: true, + }).rowVisibility, + ).toBe('visible'); + }); +}); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts index c899e451e..d3f69f225 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/activity-groups.ts @@ -8,8 +8,7 @@ import type { AcpUiMessage, } from './types'; import type { AcpRenderBlock } from './render-blocks'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; -import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; const COLLAPSIBLE_ACP_MESSAGE_KINDS = [ 'reasoning', @@ -21,10 +20,6 @@ const COLLAPSIBLE_ACP_MESSAGE_KIND_SET = new Set( COLLAPSIBLE_ACP_MESSAGE_KINDS, ); -const MANAGE_ARTIFACTS_TOOL_NAME = 'manage_artifacts'; -const SHOW_WIDGET_TOOL_NAME = 'show_widget'; -const ROOMOTE_MCP_SERVER_NAME = 'roomote'; - export interface AcpActivityGroupRenderBlock { kind: 'activity_group'; id: string; @@ -42,6 +37,7 @@ interface BuildAcpActivityRenderBlocksOptions { displayMode?: 'default' | 'narration'; hasLeadingTextBoundary?: boolean; collapseLeadingActivity?: boolean; + keepDelegatedTasksVisible?: boolean; } function isToolMessage( @@ -92,48 +88,6 @@ function isActivityBoundaryBlock(block: AcpRenderBlock): boolean { return isTextBoundaryBlock(block) || isProgressBoundaryBlock(block); } -function getToolName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.toolName ?? msg.data.mcpToolName; - const normalized = rawName?.trim().toLowerCase(); - - return normalized && normalized.length > 0 ? normalized : null; -} - -function getServerName( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): string | null { - const rawName = msg.data.serverName ?? msg.data.mcpServerName; - const normalized = rawName?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function isArtifactToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - artifacts: readonly TaskArtifact[] | null | undefined, -): boolean { - const toolName = getToolName(msg); - const serverName = getServerName(msg); - - if (toolName === MANAGE_ARTIFACTS_TOOL_NAME) { - return true; - } - - if ( - toolName === SHOW_WIDGET_TOOL_NAME && - serverName === ROOMOTE_MCP_SERVER_NAME - ) { - return true; - } - - if (resolveShowWidgetForToolMessage(msg) !== null) { - return true; - } - - return resolveVisualProofMediaForToolMessage(msg, artifacts).length > 0; -} - function isLivePartialBlock(block: AcpRenderBlock): boolean { if (block.kind === 'tool_group') { return block.items.some( @@ -152,6 +106,7 @@ function isLivePartialBlock(block: AcpRenderBlock): boolean { export function isActivityCollapsibleBlock( block: AcpRenderBlock, artifacts?: readonly TaskArtifact[] | null, + keepDelegatedTasksVisible = false, ): boolean { // Keep in-flight reasoning/tool rows outside default-closed groups so current // activity stays visible without a manual expand. @@ -160,8 +115,12 @@ export function isActivityCollapsibleBlock( } if (block.kind === 'tool_group') { - return !block.items.some((item) => - isArtifactToolMessage(item.msg, artifacts), + return !block.items.some( + (item) => + resolveToolPresentationPolicy(item.msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'keep-visible', ); } @@ -175,8 +134,13 @@ export function isActivityCollapsibleBlock( return false; } - if (isToolMessage(msg) && isArtifactToolMessage(msg, artifacts)) { - return false; + if (isToolMessage(msg)) { + return ( + resolveToolPresentationPolicy(msg, { + artifacts, + delegatedTaskCardsEnabled: keepDelegatedTasksVisible, + }).activityMode === 'collapsible' + ); } return true; @@ -215,7 +179,11 @@ export function buildAcpActivityRenderBlocks( if ( !hasLeftTextBoundary || - !isActivityCollapsibleBlock(current, options.artifacts) + !isActivityCollapsibleBlock( + current, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { groupedBlocks.push(current); hasLeftTextBoundary = false; @@ -228,7 +196,11 @@ export function buildAcpActivityRenderBlocks( while ( activityEnd < blocks.length && - isActivityCollapsibleBlock(blocks[activityEnd]!, options.artifacts) + isActivityCollapsibleBlock( + blocks[activityEnd]!, + options.artifacts, + options.keepDelegatedTasksVisible, + ) ) { activityEnd += 1; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts new file mode 100644 index 000000000..488e97bc8 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/delegated-task.ts @@ -0,0 +1,50 @@ +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface DelegatedTaskDetails { + taskId: string; + prompt: string | null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function getDelegatedTaskDetails( + msg: ToolMessage, +): DelegatedTaskDetails | null { + const toolName = (msg.data.toolName ?? msg.data.mcpToolName) + ?.trim() + .toLowerCase(); + + if (msg.kind !== 'tool_result' || toolName !== 'launch_task') { + return null; + } + + try { + const parsed = asRecord(JSON.parse(msg.data.output)); + const result = asRecord(parsed?.result) ?? asRecord(parsed?.data) ?? parsed; + const taskId = result?.taskId; + + if (typeof taskId !== 'string' || taskId.length === 0) { + return null; + } + + const rawInput = asRecord( + (msg.data as unknown as Record).rawInput, + ); + const args = asRecord(rawInput?.arguments); + const prompt = args?.prompt; + + return { + taskId, + prompt: + typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null, + }; + } catch { + return null; + } +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts index 3cedf9ef0..9c8289e11 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/render-blocks.ts @@ -4,47 +4,24 @@ import { normalizeTranscriptUserText, } from '@roomote/types'; -import { - isInternalDebugToolCallMessage, - shouldHideAcpMessage, -} from '../../message-visibility'; +import { shouldHideAcpMessage } from '../../message-visibility'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage, AcpUiMessage, } from './types'; +import { isSubagentToolMessage, isSubagentToolPayload } from './subagent-tool'; import { - isSubagentSpawnRowMessage, - isSubagentToolMessage, - isSubagentToolPayload, -} from './subagent-tool'; -import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; - -export type ExplorationStepKind = 'list' | 'read' | 'search'; - -export type GroupedToolDisplayKind = - | ExplorationStepKind - | 'execute' - | 'edit' - | 'tool'; - -const EXPLORATION_TOOL_NAMES: Record> = { - search: new Set(['search', 'search_file', 'search_files']), - list: new Set(['glob', 'list', 'list_dir', 'list_directory', 'list_files']), - read: new Set(['read', 'read_file']), -}; + resolveToolPresentation, + summarizeToolGroup, + type ToolPresentationCategory, +} from './tool-presentation'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; -const STEP_KIND_ORDER: ExplorationStepKind[] = ['search', 'list', 'read']; +type ExplorationStepKind = 'list' | 'read' | 'search'; -const STEP_KIND_LABELS: Record< - ExplorationStepKind, - { singular: string; plural: string } -> = { - search: { singular: 'search', plural: 'searches' }, - list: { singular: 'listing', plural: 'listings' }, - read: { singular: 'file', plural: 'files' }, -}; +export type GroupedToolDisplayKind = ToolPresentationCategory; const STEP_KIND_DATA_KEYS: Record = { search: [ @@ -113,6 +90,7 @@ interface BuildAcpRenderBlocksOptions { initialPrompt?: Pick | null; shouldHideFirstMessage?: boolean; showInternalMessages?: boolean; + keepDelegatedTasksVisible?: boolean; suppressedMessageIds?: ReadonlySet; } @@ -287,45 +265,6 @@ function extractLabelFromToolData( return extractStringByKeys(argumentsRecord as Record, keys); } -function isExecuteToolMessage( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): boolean { - const data = msg.data as unknown as Record; - return ( - msg.data.kind === 'execute' || - msg.data.kind === 'execute_command' || - data.isExecute === true - ); -} - -function resolveExplorationStepKind( - msg: AcpToolCallUiMessage | AcpToolResultUiMessage, -): ExplorationStepKind | null { - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - - for (const stepKind of STEP_KIND_ORDER) { - if (toolName && EXPLORATION_TOOL_NAMES[stepKind].has(toolName)) { - return stepKind; - } - } - - if (msg.data.kind === 'search') { - return 'search'; - } - - if (msg.data.kind === 'list') { - return 'list'; - } - - if (msg.data.kind === 'read') { - return 'read'; - } - - return null; -} - /** * Stable identity for consecutive same-type collapsing. Different tools never * share a key, even when both are MCP exploration-style helpers. @@ -338,49 +277,14 @@ function resolveToolGroupKey( return null; } - if (isExecuteToolMessage(msg)) { - return 'execute'; - } - - const toolName = (msg.data.toolName ?? msg.data.mcpToolName ?? '') - .trim() - .toLowerCase(); - const serverName = (msg.data.serverName ?? msg.data.mcpServerName ?? '') - .trim() - .toLowerCase(); - - if (toolName) { - return serverName ? `mcp:${serverName}:${toolName}` : `tool:${toolName}`; - } - - const kind = (msg.data.kind ?? '').trim().toLowerCase(); - - if (kind && kind !== 'mcp') { - return `kind:${kind}`; - } - - return null; + return resolveToolPresentation(msg.data, msg.partial).groupKey; } function resolveGroupedToolDisplayKind( msg: AcpToolCallUiMessage | AcpToolResultUiMessage, - groupKey: string, + _groupKey: string, ): GroupedToolDisplayKind { - if (groupKey === 'execute' || isExecuteToolMessage(msg)) { - return 'execute'; - } - - const explorationStep = resolveExplorationStepKind(msg); - - if (explorationStep) { - return explorationStep; - } - - if (msg.data.kind === 'edit') { - return 'edit'; - } - - return 'tool'; + return resolveToolPresentation(msg.data, msg.partial).category; } function isSettledToolMessage( @@ -393,10 +297,6 @@ function isSettledToolMessage( return msg.data.status === 'completed' || msg.data.status === 'failed'; } -function formatGenericToolLabel(value: string): string { - return value.split(/[_-]+/).filter(Boolean).join(' ').toLowerCase(); -} - const TITLE_PREFIX_RE = /^(?:search|read|list|find|run|using|used|ran|running)\s+(.+)$/i; @@ -435,53 +335,14 @@ function extractObjectLabel( function summarizeSameTypeGroup( items: GroupedToolCallItem[], displayKind: GroupedToolDisplayKind, - groupKey: string, + _groupKey: string, ): { action: string; objectSummary: string } { - const count = items.length; - - if (displayKind === 'execute') { - return { - action: 'Ran', - objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, - }; - } - - if ( - displayKind === 'search' || - displayKind === 'list' || - displayKind === 'read' - ) { - const labels = STEP_KIND_LABELS[displayKind]; - return { - action: 'Exploring', - objectSummary: `${count} ${count === 1 ? labels.singular : labels.plural}`, - }; - } - - if (displayKind === 'edit') { - return { - action: 'Edited', - objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, - }; - } - - const toolNameMatch = /^(?:mcp:[^:]+:|tool:)(.+)$/.exec(groupKey); - const toolLabel = toolNameMatch?.[1] - ? formatGenericToolLabel(toolNameMatch[1]) - : null; - - if (toolLabel) { - return { - action: 'Used', - objectSummary: - count === 1 ? `1 ${toolLabel}` : `${count} ${toolLabel} calls`, - }; - } - - return { - action: 'Used', - objectSummary: `${count} ${count === 1 ? 'tool' : 'tools'}`, - }; + const presentation = resolveToolPresentation(items[0]!.msg.data); + return summarizeToolGroup( + displayKind, + items.length, + presentation.displayName, + ); } function buildGroupedToolItem( @@ -680,12 +541,6 @@ function resolveMessageRenderState( options: BuildAcpRenderBlocksOptions, hideCurrentFirstUserPrompt: boolean, ): MessageRenderState { - const shouldShowInternalMessageInNarration = - options.showInternalMessages === true && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)); - const shouldShowWidgetInNarration = - isToolMessage(msg) && resolveShowWidgetForToolMessage(msg) !== null; - if (options.suppressedMessageIds?.has(msg.id)) { return { visibility: 'hidden', @@ -700,32 +555,18 @@ function resolveMessageRenderState( }; } - if ( - options.showInternalMessages === false && - (isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg)) && - // Spawn rows render inline even without debug UI. Keyed on the stable - // payload shape, never on live-only activity data: activity does not - // survive a transcript rebuild, and a row that vanishes on refresh reads - // as a lost subagent. - !isSubagentSpawnRowMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; - } - - if ( - options.displayMode === 'narration' && - isToolMessage(msg) && - !shouldShowInternalMessageInNarration && - !shouldShowWidgetInNarration && - !isSubagentToolMessage(msg) - ) { - return { - visibility: 'hidden', - behavior: 'boundary', - }; + if (isToolMessage(msg)) { + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + if (policy.rowVisibility !== 'visible') { + return { + visibility: 'hidden', + behavior: policy.hiddenBehavior, + }; + } } if (isEmptyCompletedTextMessage(msg)) { @@ -757,9 +598,16 @@ function resolveMessageRenderState( }; } + const policy = resolveToolPresentationPolicy(msg, { + delegatedTaskCardsEnabled: options.keepDelegatedTasksVisible, + displayMode: options.displayMode, + showInternalMessages: options.showInternalMessages, + }); + return { visibility: 'render', - groupKey: resolveToolGroupKey(msg), + groupKey: + policy.groupingMode === 'standalone' ? null : resolveToolGroupKey(msg), }; } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts index da53510ec..8fffccbd8 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-detail-visibility.ts @@ -1,5 +1,5 @@ -import { isInternalDebugToolCallMessage } from '../../message-visibility'; import { isSubagentToolPayload } from './subagent-tool'; +import { resolveToolPresentationPolicy } from './tool-presentation-policy'; import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; @@ -69,21 +69,9 @@ export function hidesExpandedToolResult( msg: AcpToolUiMessage, options?: ToolDetailVisibilityOptions, ): boolean { - const data = msg.data as unknown as Record; - - if (isSubagentToolPayload(msg.data)) { - if (options?.showSubagentPayload === true) { - return false; - } - - return ( - getSubagentPrompt(msg) === null && getSubagentLastMessage(msg) === null - ); - } - return ( - isInternalDebugToolCallMessage(msg) || - msg.data.kind === 'read' || - data.isRead === true + resolveToolPresentationPolicy(msg, { + showInternalMessages: options?.showSubagentPayload === true, + }).detailMode !== 'expandable' ); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts new file mode 100644 index 000000000..d61e59431 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-icons.ts @@ -0,0 +1,67 @@ +import { createElement, forwardRef } from 'react'; +import type { LucideProps } from 'lucide-react'; + +import { + type LucideIcon, + Brain, + BrandIcon, + Bot, + FileIcon, + FolderIcon, + GalleryVerticalEnd, + GitPullRequest, + HardDriveUpload, + ListChecks, + MessageSquareText, + MessagesSquare, + RoomoteR, + Search, + SquarePen, + Target, + Terminal, + TriangleAlert, + VectorSquare, + Video, + Wrench, + Zap, +} from '@/components/system'; + +import type { ToolIconKey } from './tool-presentation'; + +export function toolIconForKey(key: ToolIconKey): LucideIcon { + if (key === 'terminal') return Terminal; + if (key === 'file') return FileIcon; + if (key === 'folder') return FolderIcon; + if (key === 'search') return Search; + if (key === 'edit') return SquarePen; + if (key === 'bot') return Bot; + if (key === 'task') return Zap; + if (key === 'message') return MessageSquareText; + if (key === 'memory') return Brain; + if (key === 'artifact') return HardDriveUpload; + if (key === 'widget') return GalleryVerticalEnd; + if (key === 'roomote') return RoomoteR; + if (key === 'video') return Video; + if (key === 'target') return Target; + if (key === 'list-checks') return ListChecks; + if (key === 'pull-request') return GitPullRequest; + if (key === 'environment') return VectorSquare; + if (key === 'alert') return TriangleAlert; + if (key === 'messages') return MessagesSquare; + return Wrench; +} + +const mcpIntegrationIconCache = new Map(); + +export function mcpIntegrationIconFor(icon: string): LucideIcon { + const existing = mcpIntegrationIconCache.get(icon); + if (existing) return existing; + + const McpIntegrationIcon = forwardRef( + ({ className }, _ref) => + createElement(BrandIcon, { icon, name: '', className }), + ); + McpIntegrationIcon.displayName = `McpIntegrationIcon(${icon})`; + mcpIntegrationIconCache.set(icon, McpIntegrationIcon); + return McpIntegrationIcon; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts new file mode 100644 index 000000000..699a853f1 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation-policy.ts @@ -0,0 +1,140 @@ +import type { TaskArtifact } from '@/types'; + +import { + isInternalDebugToolCallMessage, + shouldHideAcpMessage, +} from '../../message-visibility'; +import { getDelegatedTaskDetails } from './delegated-task'; +import { + isSubagentSpawnRowMessage, + isSubagentToolMessage, +} from './subagent-tool'; +import { resolveShowWidgetForToolMessage } from './show-widget-tool-result'; +import { resolveToolPresentation } from './tool-presentation'; +import type { AcpToolCallUiMessage, AcpToolResultUiMessage } from './types'; +import { resolveVisualProofMediaForToolMessage } from './visual-proof-tool-result'; + +type ToolMessage = AcpToolCallUiMessage | AcpToolResultUiMessage; + +interface ToolPresentationPolicyOptions { + artifacts?: readonly TaskArtifact[] | null; + delegatedTaskCardsEnabled?: boolean; + displayMode?: 'default' | 'narration'; + showInternalMessages?: boolean; +} + +interface ResolvedToolPolicy { + rowVisibility: 'visible' | 'hidden' | 'debug-only'; + hiddenBehavior: 'boundary' | 'transparent'; + detailMode: 'none' | 'expandable' | 'preview'; + activityMode: 'collapsible' | 'keep-visible'; + renderAs: 'row' | 'delegated-task-card'; + groupingMode: 'groupable' | 'standalone'; +} + +const CONSEQUENTIAL_RECEIPTS = new Set([ + 'launch_task', + 'cancel_task', + 'retry_task_start', + 'send_task_message', + 'save_memory', +]); + +export function resolveToolPresentationPolicy( + msg: ToolMessage, + options: ToolPresentationPolicyOptions = {}, +): ResolvedToolPolicy { + const presentation = resolveToolPresentation(msg.data, msg.partial); + const delegatedTask = getDelegatedTaskDetails(msg); + const renderAs = + options.delegatedTaskCardsEnabled && delegatedTask + ? 'delegated-task-card' + : 'row'; + const isInternal = + isSubagentToolMessage(msg) || isInternalDebugToolCallMessage(msg); + const showWidget = resolveShowWidgetForToolMessage(msg) !== null; + const visualProof = + resolveVisualProofMediaForToolMessage(msg, options.artifacts).length > 0; + const isArtifact = presentation.category === 'artifact'; + const hasPreview = showWidget || visualProof; + const isRunning = msg.partial || msg.data.status === 'in_progress'; + const consequentialReceipt = + presentation.identity.toolName !== null && + CONSEQUENTIAL_RECEIPTS.has(presentation.identity.toolName); + + let rowVisibility: ResolvedToolPolicy['rowVisibility'] = 'visible'; + if (shouldHideAcpMessage(msg)) { + rowVisibility = 'hidden'; + } else if ( + options.showInternalMessages === false && + isInternal && + !isSubagentSpawnRowMessage(msg) + ) { + rowVisibility = 'debug-only'; + } else if ( + options.displayMode === 'narration' && + !hasPreview && + !isSubagentToolMessage(msg) && + renderAs !== 'delegated-task-card' && + !consequentialReceipt && + !(options.showInternalMessages && isInternal) + ) { + rowVisibility = 'hidden'; + } + + const detailMode: ResolvedToolPolicy['detailMode'] = + isSubagentToolMessage(msg) && hasSubagentSummary(msg) + ? 'expandable' + : hasPreview + ? 'preview' + : isInternalDebugToolCallMessage(msg) || + presentation.category === 'read' || + (isSubagentToolMessage(msg) && + !options.showInternalMessages && + !hasSubagentSummary(msg)) + ? 'none' + : 'expandable'; + + return { + rowVisibility, + hiddenBehavior: 'boundary', + detailMode, + activityMode: + isRunning || + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'keep-visible' + : 'collapsible', + renderAs, + groupingMode: + hasPreview || + isArtifact || + renderAs === 'delegated-task-card' || + consequentialReceipt + ? 'standalone' + : 'groupable', + }; +} + +function hasSubagentSummary(msg: ToolMessage): boolean { + const data = msg.data as unknown as Record; + const prompt = data.prompt; + const rawInput = + data.rawInput && + typeof data.rawInput === 'object' && + !Array.isArray(data.rawInput) + ? (data.rawInput as Record) + : null; + const rawPrompt = rawInput?.prompt; + const output = msg.kind === 'tool_result' ? msg.data.output : null; + const activity = data.subagentActivity; + + return Boolean( + (typeof prompt === 'string' && prompt.trim()) || + (typeof rawPrompt === 'string' && rawPrompt.trim()) || + (typeof output === 'string' && output.trim()) || + (activity && typeof activity === 'object'), + ); +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts new file mode 100644 index 000000000..021532e92 --- /dev/null +++ b/apps/web/src/app/(sandbox)/task/[taskId]/messages/acp/tool-presentation.ts @@ -0,0 +1,350 @@ +import { + getMcpIntegration, + type AcpToolCallPayload, + type AcpToolResultPayload, +} from '@roomote/types'; + +// Direct import: the @/lib barrel drags icon-bearing modules into any test +// that mocks @/components/system. +import { sanitizeSandboxPathString } from '@/lib/sandbox-paths'; + +export type ToolPresentationCategory = + | 'execute' + | 'read' + | 'search' + | 'list' + | 'edit' + | 'subagent' + | 'task' + | 'communication' + | 'memory' + | 'artifact' + | 'widget' + | 'generic'; + +export type ToolIconKey = + | 'terminal' + | 'file' + | 'folder' + | 'search' + | 'edit' + | 'bot' + | 'task' + | 'message' + | 'memory' + | 'artifact' + | 'widget' + | 'roomote' + | 'video' + | 'target' + | 'list-checks' + | 'pull-request' + | 'environment' + | 'alert' + | 'messages' + | 'tool'; + +type ToolPresentationPhase = 'running' | 'completed' | 'failed'; + +type ToolData = AcpToolCallPayload | AcpToolResultPayload; + +interface ResolvedToolPresentation { + identity: { + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + }; + category: ToolPresentationCategory; + displayName: string; + iconKey: ToolIconKey; + integrationIcon?: string; + phase: ToolPresentationPhase; + verb: string; + object?: string; + providerLabel?: string; + groupKey: string | null; +} + +const SEARCH_TOOL_NAMES = new Set([ + 'search', + 'search_file', + 'search_files', + 'spill_grep', +]); +const LIST_TOOL_NAMES = new Set([ + 'glob', + 'list', + 'list_dir', + 'list_directory', + 'list_files', + 'list_skills', +]); +const READ_TOOL_NAMES = new Set([ + 'read', + 'read_file', + 'spill_read', + 'load_skill', +]); +const TASK_TOOL_NAMES = new Set([ + 'launch_task', + 'retry_task_start', + 'cancel_task', + 'send_task_message', +]); +const COMMUNICATION_TOOL_NAMES = new Set([ + 'send_chat_reply', + 'send_chat_reaction', + 'send_chat_reaction_emoji', + 'add_reaction_to_slack_message', + 'post_to_channel', + 'ignore_event', +]); +const TOOL_ICON_OVERRIDES: Readonly>> = { + manage_custom_automations: 'task', + get_about_me: 'roomote', + describe_video: 'video', + manage_goal: 'target', + manage_tasks: 'list-checks', + manage_source_control: 'pull-request', + manage_environments: 'environment', + save_task_memory: 'memory', + request_environment_variables: 'terminal', + report_platform_issue: 'alert', + submit_automation_work_items: 'task', + list_chat_channels: 'messages', + get_chat_channel_messages: 'messages', + get_chat_message_context: 'messages', +}; + +function normalized(value: string | null | undefined): string | null { + const result = value?.trim().toLowerCase(); + return result ? result : null; +} + +function formatToolIdentifier(value: string): string { + if (value.toLowerCase() === 'gbrain') return 'Memory'; + + return value + .replace(/[.]/g, ' ') + .replace(/[-_]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/\b\w/g, (character) => character.toUpperCase()) + .trim(); +} + +export function resolveToolPresentation( + data: ToolData, + partial = false, +): ResolvedToolPresentation { + const serverName = normalized(data.serverName ?? data.mcpServerName); + const toolName = normalized(data.toolName ?? data.mcpToolName); + const kind = normalized(data.kind); + const providerKind = data.isMcp ? 'mcp' : 'native'; + const phase: ToolPresentationPhase = + data.status === 'failed' + ? 'failed' + : data.status === 'in_progress' || partial + ? 'running' + : 'completed'; + const category = resolveToolCategory({ + kind, + toolName, + serverName, + isExecute: data.isExecute, + isRead: 'isRead' in data && data.isRead === true, + isSubagentSpawn: data.isSubagentSpawn === true, + }); + const explicitIconKey = toolName ? TOOL_ICON_OVERRIDES[toolName] : undefined; + const integration = + providerKind === 'mcp' && serverName + ? getMcpIntegration(serverName) + : undefined; + const displayName = toolName + ? formatToolIdentifier(toolName) + : sanitizeSandboxPathString(data.title ?? 'Tool'); + const providerLabel = + integration?.name ?? + (serverName ? formatToolIdentifier(serverName) : undefined); + const receipt = resolveReceiptLanguage(toolName, phase); + const verb = receipt?.verb ?? (phase === 'running' ? 'Using' : 'Used'); + const object = receipt?.object ?? displayName; + + return { + identity: { providerKind, serverName, toolName }, + category, + displayName, + iconKey: explicitIconKey ?? categoryIconKey(category), + integrationIcon: explicitIconKey ? undefined : integration?.icon, + phase, + verb, + object, + providerLabel, + groupKey: resolveToolGroupKey({ + category, + providerKind, + serverName, + toolName, + kind, + }), + }; +} + +function resolveToolCategory(input: { + kind: string | null; + toolName: string | null; + serverName: string | null; + isExecute: boolean; + isRead: boolean; + isSubagentSpawn: boolean; +}): ToolPresentationCategory { + if (input.kind === 'subagent' || input.isSubagentSpawn) return 'subagent'; + if ( + input.kind === 'execute' || + input.kind === 'execute_command' || + input.isExecute + ) + return 'execute'; + if ( + input.kind === 'read' || + input.isRead || + (input.toolName && READ_TOOL_NAMES.has(input.toolName)) + ) + return 'read'; + if ( + input.kind === 'search' || + (input.toolName && SEARCH_TOOL_NAMES.has(input.toolName)) + ) + return 'search'; + if ( + input.kind === 'list' || + (input.toolName && LIST_TOOL_NAMES.has(input.toolName)) + ) + return 'list'; + if (input.kind === 'edit') return 'edit'; + if ( + input.kind === 'task' || + (input.toolName && TASK_TOOL_NAMES.has(input.toolName)) + ) + return 'task'; + if ( + input.kind === 'communication' || + (input.toolName && COMMUNICATION_TOOL_NAMES.has(input.toolName)) + ) + return 'communication'; + if ( + input.kind === 'memory' || + input.serverName === 'gbrain' || + input.toolName === 'save_memory' + ) + return 'memory'; + if (input.kind === 'artifact' || input.toolName === 'manage_artifacts') + return 'artifact'; + if (input.kind === 'widget' || input.toolName === 'show_widget') + return 'widget'; + return 'generic'; +} + +function categoryIconKey(category: ToolPresentationCategory): ToolIconKey { + if (category === 'execute') return 'terminal'; + if (category === 'read') return 'file'; + if (category === 'list') return 'folder'; + if (category === 'search') return 'search'; + if (category === 'edit') return 'edit'; + if (category === 'subagent') return 'bot'; + if (category === 'task') return 'task'; + if (category === 'communication') return 'message'; + if (category === 'memory') return 'memory'; + if (category === 'artifact') return 'artifact'; + if (category === 'widget') return 'widget'; + return 'tool'; +} + +function resolveToolGroupKey(input: { + category: ToolPresentationCategory; + providerKind: 'native' | 'mcp'; + serverName: string | null; + toolName: string | null; + kind: string | null; +}): string | null { + if (input.category === 'subagent') return null; + if (input.category === 'execute') return 'execute'; + if (input.toolName) { + return input.providerKind === 'mcp' && input.serverName + ? `mcp:${input.serverName}:${input.toolName}` + : `tool:${input.toolName}`; + } + return input.kind && input.kind !== 'mcp' ? `kind:${input.kind}` : null; +} + +function resolveReceiptLanguage( + toolName: string | null, + phase: ToolPresentationPhase, +): { verb: string; object: string } | null { + const byPhase = (running: string, completed: string, failed: string) => + phase === 'running' ? running : phase === 'failed' ? failed : completed; + + if (toolName === 'launch_task') + return { + verb: byPhase('Starting', 'Started', 'Failed to Start'), + object: 'Coding Task', + }; + if (toolName === 'cancel_task') + return { + verb: byPhase('Cancelling', 'Cancelled', 'Failed to Cancel'), + object: 'Task', + }; + if (toolName === 'retry_task_start') + return { + verb: byPhase('Retrying', 'Retried', 'Failed to Retry'), + object: 'Task', + }; + if (toolName === 'send_task_message') + return { + verb: byPhase('Sending', 'Sent', 'Failed to Send'), + object: 'Task Message', + }; + if (toolName === 'save_memory') + return { + verb: byPhase('Saving', 'Saved', 'Failed to Save'), + object: 'Memory', + }; + return null; +} + +export function summarizeToolGroup( + category: ToolPresentationCategory, + count: number, + displayName: string, +): { action: string; objectSummary: string } { + if (category === 'execute') + return { + action: 'Ran', + objectSummary: `${count} ${count === 1 ? 'command' : 'commands'}`, + }; + if (category === 'search') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'search' : 'searches'}`, + }; + if (category === 'list') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'listing' : 'listings'}`, + }; + if (category === 'read') + return { + action: 'Exploring', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + if (category === 'edit') + return { + action: 'Edited', + objectSummary: `${count} ${count === 1 ? 'file' : 'files'}`, + }; + + const label = displayName.toLowerCase(); + return { + action: 'Used', + objectSummary: count === 1 ? `1 ${label}` : `${count} ${label} calls`, + }; +} diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx index f244c3de9..e63c45dbd 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.client.test.tsx @@ -7,7 +7,7 @@ import { RunStatus, TaskPayloadKind } from '@roomote/types'; const { replaceMock, recordVisitMock, - setSidebarVisibleMock, + useResponsiveSandboxSidebarMock, useTaskSessionMock, usePathnameMock, usePageTitleMock, @@ -20,7 +20,7 @@ const { } = vi.hoisted(() => ({ replaceMock: vi.fn(), recordVisitMock: vi.fn(), - setSidebarVisibleMock: vi.fn(), + useResponsiveSandboxSidebarMock: vi.fn(), useTaskSessionMock: vi.fn(), usePathnameMock: vi.fn(() => '/task/route-task'), usePageTitleMock: vi.fn(), @@ -65,9 +65,7 @@ vi.mock('@/hooks/useRecentTasks', () => ({ })); vi.mock('../../use-sandbox-layout', () => ({ - useSandboxLayout: () => ({ - setSidebarVisible: setSidebarVisibleMock, - }), + useResponsiveSandboxSidebar: useResponsiveSandboxSidebarMock, })); vi.mock('./hooks', () => ({ @@ -189,6 +187,7 @@ describe('SandboxPage', () => { expect(useTaskMessageEnvelopesMock).toHaveBeenCalledWith('route-task', { enabled: true, }); + expect(useResponsiveSandboxSidebarMock).toHaveBeenCalledWith('route-task'); expect(screen.getByTestId('sandbox-provider')).toBeInTheDocument(); expect(screen.getByTestId('live-content')).toBeInTheDocument(); expect(screen.queryByTestId('startup')).not.toBeInTheDocument(); diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx index 870da81ba..f9f252bcd 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/page.tsx @@ -1,12 +1,6 @@ 'use client'; -import { - useCallback, - useEffect, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useParams } from 'next/navigation'; import { useQueryClient } from '@tanstack/react-query'; import { CircleSlash, TriangleAlert } from '@/components/system'; @@ -21,11 +15,12 @@ import { import { useTRPC } from '@/trpc/client'; import { usePageTitle } from '@/hooks/usePageTitle'; import { useRecentTasks } from '@/hooks/useRecentTasks'; +import { truncatePageTitle } from '@/lib/page-title'; import { FramedSurface } from '@/components/layout'; import { EmptyState } from '@/components/system'; -import { useSandboxLayout } from '../../use-sandbox-layout'; +import { useResponsiveSandboxSidebar } from '../../use-sandbox-layout'; import { HistoricalSandboxProvider, @@ -45,7 +40,7 @@ import { TaskWorkspaceSkeleton } from './TaskWorkspaceSkeleton'; export default function SandboxPage() { const { taskId: unresolvedTaskId } = useParams<{ taskId: string }>(); - const { setSidebarVisible } = useSandboxLayout(); + useResponsiveSandboxSidebar(unresolvedTaskId); const trpc = useTRPC(); const queryClient = useQueryClient(); @@ -107,26 +102,6 @@ export default function SandboxPage() { }); }, [queryClient, trpc]); - useLayoutEffect(() => { - const mobileQuery = window.matchMedia?.('(max-width: 767px)'); - - if (!mobileQuery?.matches) { - return; - } - - setSidebarVisible(false); - - const handleViewportChange = (event: MediaQueryListEvent) => - setSidebarVisible(!event.matches); - - mobileQuery.addEventListener('change', handleViewportChange); - - return () => { - mobileQuery.removeEventListener('change', handleViewportChange); - setSidebarVisible(true); - }; - }, [setSidebarVisible, unresolvedTaskId]); - // Track this task as recently visited for command palette ordering. // Record immediately with the URL param so visits are captured even when the // session fails to initialise. If the resolved taskId differs (e.g. alias @@ -144,11 +119,7 @@ export default function SandboxPage() { } }, [taskId, unresolvedTaskId, recordVisit]); - usePageTitle( - task && task.title.length > 60 - ? `${task.title.slice(0, 60)}...` - : task?.title, - ); + usePageTitle(truncatePageTitle(task?.title)); useEffect(() => { if (sessionState !== 'interactive') { diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/ArtifactsButton.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/ArtifactsButton.tsx index f4f11f21f..c074d2304 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/ArtifactsButton.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-actions/ArtifactsButton.tsx @@ -1,8 +1,8 @@ 'use client'; import { memo, useRef, useEffect, useState } from 'react'; -import { LayoutGrid } from 'lucide-react'; import { SideNavItem } from '@/components/layout/side-nav/SideNavItem'; +import { LayoutGrid } from '@/components/system'; import { type TaskArtifact, useTaskSidePanel } from '../hooks'; diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx index 61d3e12ff..d40f13449 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/sidebar-panels/TaskInfoPanel.tsx @@ -52,6 +52,11 @@ import { useTaskSummary, } from '../hooks'; +import { + SandboxInfoPanel, + SandboxInfoRow, + SandboxInfoTable, +} from '../../../SandboxInfoPanel'; import { SidePanelHeader } from './SidePanelHeader'; import { getTaskParticipants } from './task-participants'; @@ -299,296 +304,270 @@ export function TaskInfoPanel({ PRODUCT_NAME; return ( - <> - -
-
- - - - - - - - {participants.length > 0 && ( - - - - - )} - - {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( - - - - - )} - - - - - - - {taskModelLabel && ( - - - - - )} - - - - - - - {showRuntimeRow && ( - - - - - )} - - {(taskRun.pullRequests?.length ?? 0) > 0 ? ( - - - - - ) : taskRun.prRepo && taskRun.prNumber ? ( - - - + + + + + {participants.length > 0 && ( + + + - - ) : null} - - {linkedWorkItems.length > 0 ? ( - - - - - ) : null} - - - - - - - - - - - -
- Creator - - {task.user && task.attributionKind === 'user' ? ( - <> - {task.user.imageUrl ? ( - {taskCreatorDisplayName} - ) : null} - {taskCreatorDisplayName} - - ) : ( - taskCreatorDisplayName - )} -
- Participants - -
- {participants.map((participant) => ( - - - {participant.displayName} - - ))} -
-
- Workspace - - -
- Sandbox Provider - - - - {sandboxProviderLabel} - -
- Model - - - - {taskModelLabel} - {taskRun.payload?.modelRoleOverrides && ( - - Customized - - )} - -
- Inference Cost - - - - {inferenceCostLabel} - -
- Runtime - - - - - {HARNESS_LABELS[effectiveHarness]} - - -
- Pull Requests - -
- {taskRun.pullRequests?.map((pullRequest) => ( - - ))} -
-
- Pull Request - - } + > + +
Creator + {task.user && task.attributionKind === 'user' ? ( + <> + {task.user.imageUrl ? ( + {taskCreatorDisplayName} + ) : null} + {taskCreatorDisplayName} + + ) : ( + taskCreatorDisplayName + )} +
+ Participants + +
+ {participants.map((participant) => ( + + -
- Linked Work - -
- {linkedWorkItems.map((item, index) => ( - - ))} -
-
- Started At - - - - - {formatStartedAt(taskRun.startedAt)} - + {participant.displayName} -
- Started From - - - {startedFrom.brandIcon ? ( - startedFrom.brandIcon === 'slack' ? ( - - ) : ( - - ) - ) : ( - - )} - {startedFrom.label} - -
- - {taskRunError && ( -
-
-

Last Error

- + ))}
-

- {taskRunError} -

-
- )} + + + )} + + {(taskRun.payload?.environmentId || taskRun.payload?.repo) && ( + + Workspace + + + + + )} + + + + Sandbox Provider + + + + + {sandboxProviderLabel} + + + + + {taskModelLabel && ( + + Model + + + + {taskModelLabel} + {taskRun.payload?.modelRoleOverrides && ( + + Customized + + )} + + + + )} + + + + + {inferenceCostLabel} + + + + {showRuntimeRow && ( + + Runtime + + + + + {HARNESS_LABELS[effectiveHarness]} + + + + + )} + + {(taskRun.pullRequests?.length ?? 0) > 0 ? ( + + + Pull Requests + + +
+ {taskRun.pullRequests?.map((pullRequest) => ( + + ))} +
+ + + ) : taskRun.prRepo && taskRun.prNumber ? ( + + + Pull Request + + + + + + ) : null} - {summaryEnabled && ( -
-
-

Summary

+ {linkedWorkItems.length > 0 ? ( + + + Linked Work + + +
+ {linkedWorkItems.map((item, index) => ( + + ))}
+ + + ) : null} - {isLoadingSummary ? ( -
- - Generating... -
- ) : summary ? ( - <> - {isSummaryStale && ( -
- New messages since last summarized. - -
- )} -
- - {summary} - -
- - ) : summaryErrorMessage ? ( -
-

{summaryErrorMessage}

+ + + + + {formatStartedAt(taskRun.startedAt)} + + + + + + + {startedFrom.brandIcon ? ( + startedFrom.brandIcon === 'slack' ? ( + + ) : ( + + ) + ) : ( + + )} + {startedFrom.label} + + + + + {taskRunError && ( +
+
+

Last Error

+ +
+

+ {taskRunError} +

+
+ )} + + {summaryEnabled && ( +
+
+

Summary

+
+ + {isLoadingSummary ? ( +
+ + Generating... +
+ ) : summary ? ( + <> + {isSummaryStale && ( +
+ New messages since last summarized.
- ) : null} + )} +
+ + {summary} + +
+ + ) : summaryErrorMessage ? ( +
+

{summaryErrorMessage}

+
- )} + ) : null}
-
- + )} + ); } diff --git a/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx b/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx index cedc2fbab..06d6661c4 100644 --- a/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx +++ b/apps/web/src/app/(sandbox)/task/[taskId]/startup/Startup.tsx @@ -14,7 +14,7 @@ import { useStartupProgress } from './useStartupProgress'; interface StartupProps { runId: number; initialTaskRun?: TaskRun; - newTaskHref: string; + newTaskHref?: string; onStatusChange?: (status: RunStatusValue) => void; } @@ -49,7 +49,7 @@ export const Startup = ({ interface StartupInnerProps { runId: number; initialTaskRun?: TaskRun; - newTaskHref: string; + newTaskHref?: string; onStatusChange?: (status: RunStatusValue) => void; } diff --git a/apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx b/apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx new file mode 100644 index 000000000..295fe3a56 --- /dev/null +++ b/apps/web/src/app/(sandbox)/use-sandbox-layout.client.test.tsx @@ -0,0 +1,107 @@ +import type { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; + +import { + SandboxLayoutContext, + useResponsiveSandboxSidebar, +} from './use-sandbox-layout'; + +const setSidebarVisible = vi.fn(); + +function wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} + +function mockViewport(isMobile: boolean) { + let viewportChangeListener: ((event: MediaQueryListEvent) => void) | null = + null; + const mediaQuery = { + matches: isMobile, + addEventListener: vi.fn( + (event: string, listener: (event: MediaQueryListEvent) => void) => { + if (event === 'change') { + viewportChangeListener = listener; + } + }, + ), + removeEventListener: vi.fn(), + }; + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockReturnValue(mediaQuery), + }); + + return { + mediaQuery, + resize(isMobile: boolean) { + mediaQuery.matches = isMobile; + act(() => + viewportChangeListener?.({ matches: isMobile } as MediaQueryListEvent), + ); + }, + }; +} + +describe('useResponsiveSandboxSidebar', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('tracks the viewport from an initial desktop state and restores the rail on cleanup', () => { + const viewport = mockViewport(false); + const { unmount } = renderHook( + () => useResponsiveSandboxSidebar('workspace-1'), + { wrapper }, + ); + + expect(setSidebarVisible).toHaveBeenLastCalledWith(true); + + viewport.resize(true); + expect(setSidebarVisible).toHaveBeenLastCalledWith(false); + + viewport.resize(false); + expect(setSidebarVisible).toHaveBeenLastCalledWith(true); + + unmount(); + expect(viewport.mediaQuery.removeEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(setSidebarVisible).toHaveBeenLastCalledWith(true); + }); + + it('starts with the rail collapsed on mobile', () => { + mockViewport(true); + + renderHook(() => useResponsiveSandboxSidebar('workspace-1'), { wrapper }); + + expect(setSidebarVisible).toHaveBeenLastCalledWith(false); + }); + + it('reinitializes the responsive lifecycle when the workspace changes', () => { + const viewport = mockViewport(true); + const { rerender } = renderHook( + ({ scopeKey }) => useResponsiveSandboxSidebar(scopeKey), + { + initialProps: { scopeKey: 'workspace-1' }, + wrapper, + }, + ); + + rerender({ scopeKey: 'workspace-2' }); + + expect(viewport.mediaQuery.removeEventListener).toHaveBeenCalledTimes(1); + expect(viewport.mediaQuery.addEventListener).toHaveBeenCalledTimes(2); + expect(setSidebarVisible).toHaveBeenLastCalledWith(false); + }); +}); diff --git a/apps/web/src/app/(sandbox)/use-sandbox-layout.ts b/apps/web/src/app/(sandbox)/use-sandbox-layout.ts index d3f798f7d..0b50125f5 100644 --- a/apps/web/src/app/(sandbox)/use-sandbox-layout.ts +++ b/apps/web/src/app/(sandbox)/use-sandbox-layout.ts @@ -1,6 +1,6 @@ 'use client'; -import { createContext, useContext } from 'react'; +import { createContext, useContext, useLayoutEffect } from 'react'; interface SandboxLayoutContextValue { isSidebarVisible: boolean; @@ -22,3 +22,27 @@ export function useSandboxLayout() { return ctx; } + +export function useResponsiveSandboxSidebar(scopeKey: string) { + const { setSidebarVisible } = useSandboxLayout(); + + useLayoutEffect(() => { + const mobileQuery = window.matchMedia?.('(max-width: 767px)'); + + if (!mobileQuery) { + return; + } + + setSidebarVisible(!mobileQuery.matches); + + const handleViewportChange = (event: MediaQueryListEvent) => + setSidebarVisible(!event.matches); + + mobileQuery.addEventListener('change', handleViewportChange); + + return () => { + mobileQuery.removeEventListener('change', handleViewportChange); + setSidebarVisible(true); + }; + }, [scopeKey, setSidebarVisible]); +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/stream/route.ts b/apps/web/src/app/api/sessions/[sessionId]/stream/route.ts index d50821066..53cdff268 100644 --- a/apps/web/src/app/api/sessions/[sessionId]/stream/route.ts +++ b/apps/web/src/app/api/sessions/[sessionId]/stream/route.ts @@ -2,12 +2,19 @@ import { NextRequest, NextResponse } from 'next/server'; import { createResponse } from 'better-sse'; import { z } from 'zod'; -import { db, eq, fastAgentConversations } from '@roomote/db/server'; +import { + db, + eq, + fastAgentConversations, + isSessionConversationResponding, + sessions as unifiedSessions, +} from '@roomote/db/server'; import { authorizeUserToken } from '@/lib/server'; import { findAccessibleFastSession, getFastSessionMessagesSince, + getFastSessionDisplayTitle, } from '@/lib/server/fast-sessions'; export const runtime = 'nodejs'; @@ -54,6 +61,7 @@ export async function GET( ? sinceParam.data : Date.now() - INITIAL_CURSOR_OVERLAP_MS; let lastTitle = session.title; + let lastConversationResponding: boolean | null | undefined; return createResponse(request, async (sseSession) => { const startTime = Date.now(); @@ -67,17 +75,49 @@ export async function GET( const { messages, cursor: nextCursor } = await getFastSessionMessagesSince(session.id, cursor); cursor = nextCursor; + + const [conversation] = await db + .select({ + title: fastAgentConversations.title, + unifiedSessionId: unifiedSessions.id, + respondingUntil: unifiedSessions.respondingUntil, + }) + .from(fastAgentConversations) + .leftJoin( + unifiedSessions, + eq(unifiedSessions.fastConversationId, fastAgentConversations.id), + ) + .where(eq(fastAgentConversations.id, session.id)) + .limit(1); + const title = await getFastSessionDisplayTitle( + session.id, + conversation?.title ?? null, + ); + const conversationResponding = conversation?.unifiedSessionId + ? isSessionConversationResponding({ + respondingUntil: conversation.respondingUntil, + }) + : null; if (messages.length > 0) { - await sseSession.push({ messages }, 'messages'); + await sseSession.push( + { messages, conversationResponding }, + 'messages', + ); } - - const conversation = await db.query.fastAgentConversations.findFirst({ - where: eq(fastAgentConversations.id, session.id), - columns: { title: true }, - }); - if (conversation && conversation.title !== lastTitle) { - lastTitle = conversation.title; - await sseSession.push({ title: conversation.title }, 'session'); + const sessionUpdate: { + title?: string; + conversationResponding?: boolean | null; + } = {}; + if (title && title !== lastTitle) { + lastTitle = title; + sessionUpdate.title = title; + } + if (conversationResponding !== lastConversationResponding) { + lastConversationResponding = conversationResponding; + sessionUpdate.conversationResponding = conversationResponding; + } + if (Object.keys(sessionUpdate).length > 0) { + await sseSession.push(sessionUpdate, 'session'); } } catch { break; diff --git a/apps/web/src/app/auth/dev-login/dev-inference.ts b/apps/web/src/app/auth/dev-login/dev-inference.ts new file mode 100644 index 000000000..5e3ece9f8 --- /dev/null +++ b/apps/web/src/app/auth/dev-login/dev-inference.ts @@ -0,0 +1,104 @@ +import { + db, + deploymentSettings, + environmentVariables, + eq, + isChatGptSubscriptionConnected, + isGitHubCopilotSubscriptionConnected, + isXaiSubscriptionConnected, +} from '@roomote/db/server'; +import { + buildRecommendedDeploymentModelConfig, + buildSetupModelStatus, + DEV_LOGIN_INFERENCE_API_KEY_PLACEHOLDER, + getSetupModelProvider, + normalizeDeploymentModelConfig, +} from '@roomote/types'; + +const DEV_LOGIN_PROVIDER_ID = 'openrouter'; +const DEV_LOGIN_PROVIDER_ENV_VAR_NAME = 'OPENROUTER_API_KEY'; + +/** + * Give local dev-login a complete setup state without inventing a usable + * credential. Existing provider credentials or model choices always win. + */ +export async function ensureDevLoginInferenceSetup(userId: string) { + await db.transaction(async (tx) => { + await tx + .insert(deploymentSettings) + .values({ id: 'default' }) + .onConflictDoNothing(); + await tx + .select({ id: deploymentSettings.id }) + .from(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')) + .for('update'); + + const [ + settings, + persistedEnvVars, + chatgptConnected, + githubCopilotConnected, + xaiSubscriptionConnected, + ] = await Promise.all([ + tx.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { runtimeModelConfig: true }, + }), + tx.select({ name: environmentVariables.name }).from(environmentVariables), + isChatGptSubscriptionConnected(tx), + isGitHubCopilotSubscriptionConnected(tx), + isXaiSubscriptionConnected(tx), + ]); + const runtimeModelConfig = normalizeDeploymentModelConfig( + settings?.runtimeModelConfig, + ); + const hasModelChoice = Object.values(runtimeModelConfig).some( + (value) => value !== null, + ); + const modelStatus = buildSetupModelStatus({ + runtimeEnv: process.env, + persistedModelConfig: runtimeModelConfig, + persistedEnvVarNames: persistedEnvVars.map(({ name }) => name), + chatgptConnected, + githubCopilotConnected, + xaiSubscriptionConnected, + }); + const hasConfiguredProvider = modelStatus.providers.some( + (provider) => + provider.runtimeApiKeySatisfied || provider.savedApiKeySatisfied, + ); + + if (hasModelChoice || hasConfiguredProvider) { + return; + } + + const inserted = await tx + .insert(environmentVariables) + .values({ + userId: null, + name: DEV_LOGIN_PROVIDER_ENV_VAR_NAME, + value: DEV_LOGIN_INFERENCE_API_KEY_PLACEHOLDER, + createdByUserId: userId, + lastUpdatedByUserId: userId, + }) + .onConflictDoNothing({ target: environmentVariables.name }) + .returning({ id: environmentVariables.id }); + + // A concurrent provider save won the unique-key race. Never pair its + // credential with model settings chosen by this development-only path. + if (inserted.length === 0) { + return; + } + + await tx + .update(deploymentSettings) + .set({ + runtimeModelConfig: buildRecommendedDeploymentModelConfig( + getSetupModelProvider(DEV_LOGIN_PROVIDER_ID), + ), + updatedAt: new Date(), + }) + .where(eq(deploymentSettings.id, 'default')); + }); +} diff --git a/apps/web/src/app/auth/dev-login/route.test.ts b/apps/web/src/app/auth/dev-login/route.test.ts index 81ccaad4f..a07bef795 100644 --- a/apps/web/src/app/auth/dev-login/route.test.ts +++ b/apps/web/src/app/auth/dev-login/route.test.ts @@ -2,7 +2,24 @@ import { createHmac } from 'node:crypto'; import { NextRequest } from 'next/server'; -import { authSessions, authUsers, db, eq, users } from '@roomote/db/server'; +import { + authSessions, + authUsers, + db, + deploymentSettings, + deploymentSecrets, + environmentVariables, + eq, + inArray, + resolveDeploymentEnvVar, + users, +} from '@roomote/db/server'; +import { encryptJSON } from '@roomote/db/encryption'; +import { + buildSetupModelStatus, + DEV_LOGIN_INFERENCE_API_KEY_PLACEHOLDER, + normalizeDeploymentModelConfig, +} from '@roomote/types'; const { envMock, mockBootstrapWebRuntimeEnv, mockIsWebServerBindExposed } = vi.hoisted(() => ({ @@ -64,15 +81,36 @@ async function deleteDevLoginRows() { await db .delete(authSessions) .where(eq(authSessions.userAgent, 'roomote-dev-login-test')); + await db + .delete(environmentVariables) + .where( + inArray(environmentVariables.name, [ + 'ANTHROPIC_API_KEY', + 'OPENROUTER_API_KEY', + ]), + ); await db .delete(authUsers) .where(eq(authUsers.email, envMock.WEB_DEV_LOGIN_EMAIL)); await db.delete(users).where(eq(users.email, envMock.WEB_DEV_LOGIN_EMAIL)); + await db + .delete(deploymentSettings) + .where(eq(deploymentSettings.id, 'default')); + await db + .delete(deploymentSecrets) + .where( + inArray(deploymentSecrets.name, [ + 'CHATGPT_SUBSCRIPTION_OAUTH', + 'GITHUB_COPILOT_SUBSCRIPTION_OAUTH', + 'XAI_SUBSCRIPTION_OAUTH', + ]), + ); } describe('GET /auth/dev-login', () => { beforeEach(async () => { vi.clearAllMocks(); + vi.unstubAllEnvs(); envMock.APP_ENV = 'development'; envMock.R_APP_URL = 'http://localhost:3000'; envMock.WEB_DEV_LOGIN_EMAIL = 'local@roomote.dev'; @@ -83,6 +121,7 @@ describe('GET /auth/dev-login', () => { }); afterEach(async () => { + vi.unstubAllEnvs(); await deleteDevLoginRows(); }); @@ -131,6 +170,116 @@ describe('GET /auth/dev-login', () => { }); }); + it('satisfies inference setup with an intentionally invalid saved key when configuration is empty', async () => { + const response = await GET( + new NextRequest('http://localhost:3000/auth/dev-login', { + headers: { 'user-agent': 'roomote-dev-login-test' }, + }), + ); + + expect(response.status).toBe(307); + await expect(resolveDeploymentEnvVar('OPENROUTER_API_KEY')).resolves.toBe( + DEV_LOGIN_INFERENCE_API_KEY_PLACEHOLDER, + ); + + const settings = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { runtimeModelConfig: true }, + }); + expect(settings?.runtimeModelConfig?.roomoteModel).toMatch( + /^openrouter\//u, + ); + expect( + buildSetupModelStatus({ + runtimeEnv: {}, + persistedModelConfig: settings?.runtimeModelConfig, + persistedEnvVarNames: ['OPENROUTER_API_KEY'], + }).setupSatisfied, + ).toBe(true); + }); + + it('does not overwrite an existing saved inference configuration', async () => { + await db.insert(environmentVariables).values({ + userId: null, + name: 'ANTHROPIC_API_KEY', + value: 'real-saved-key', + }); + await db.insert(deploymentSettings).values({ + id: 'default', + runtimeModelConfig: normalizeDeploymentModelConfig({ + roomoteModel: 'anthropic/claude-sonnet-5', + }), + }); + + const response = await GET( + new NextRequest('http://localhost:3000/auth/dev-login', { + headers: { 'user-agent': 'roomote-dev-login-test' }, + }), + ); + + expect(response.status).toBe(307); + await expect(resolveDeploymentEnvVar('OPENROUTER_API_KEY')).resolves.toBe( + null, + ); + const settings = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { runtimeModelConfig: true }, + }); + expect(settings?.runtimeModelConfig?.roomoteModel).toBe( + 'anthropic/claude-sonnet-5', + ); + }); + + it('does not add saved inference configuration when runtime configuration exists', async () => { + vi.stubEnv('OPENAI_API_KEY', 'real-runtime-key'); + + const response = await GET( + new NextRequest('http://localhost:3000/auth/dev-login', { + headers: { 'user-agent': 'roomote-dev-login-test' }, + }), + ); + + expect(response.status).toBe(307); + await expect(resolveDeploymentEnvVar('OPENROUTER_API_KEY')).resolves.toBe( + null, + ); + const settings = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { runtimeModelConfig: true }, + }); + expect(settings?.runtimeModelConfig).toBeNull(); + }); + + it.each([ + 'CHATGPT_SUBSCRIPTION_OAUTH', + 'GITHUB_COPILOT_SUBSCRIPTION_OAUTH', + 'XAI_SUBSCRIPTION_OAUTH', + ])( + 'does not add placeholder configuration when %s is connected', + async (secretName) => { + await db.insert(deploymentSecrets).values({ + name: secretName, + value: encryptJSON({ status: 'connected' }), + }); + + const response = await GET( + new NextRequest('http://localhost:3000/auth/dev-login', { + headers: { 'user-agent': 'roomote-dev-login-test' }, + }), + ); + + expect(response.status).toBe(307); + await expect(resolveDeploymentEnvVar('OPENROUTER_API_KEY')).resolves.toBe( + null, + ); + const settings = await db.query.deploymentSettings.findFirst({ + where: eq(deploymentSettings.id, 'default'), + columns: { runtimeModelConfig: true }, + }); + expect(settings?.runtimeModelConfig).toBeNull(); + }, + ); + it('falls back to the root path when the redirect is cross-origin', async () => { const response = await GET( new NextRequest( @@ -314,6 +463,9 @@ describe('GET /auth/dev-login', () => { expect(response.status).toBe(404); expect(response.headers.get('set-cookie')).toBeNull(); + await expect( + resolveDeploymentEnvVar('OPENROUTER_API_KEY'), + ).resolves.toBeNull(); }, ); diff --git a/apps/web/src/app/auth/dev-login/route.ts b/apps/web/src/app/auth/dev-login/route.ts index 0ff45cbb8..71c541da1 100644 --- a/apps/web/src/app/auth/dev-login/route.ts +++ b/apps/web/src/app/auth/dev-login/route.ts @@ -13,6 +13,7 @@ import { isEnvFlagEnabled, isWebServerBindExposed, } from '@/lib/server/env'; +import { ensureDevLoginInferenceSetup } from './dev-inference'; export const runtime = 'nodejs'; @@ -179,6 +180,8 @@ export async function GET(request: NextRequest) { }, }); + await ensureDevLoginInferenceSetup(authUser.id); + await db.insert(authSessions).values({ id: `dev-login-session-${randomUUID()}`, userId: authUser.id, diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 60b6c110b..394c2e116 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -118,6 +118,9 @@ export default async function RootLayout({ return ( + {/* Must run synchronously before first paint: App Router queues + inline beforeInteractive Scripts until client bootstrap, which + flashes the wrong theme. */} '; + + render( + , + ); + + const preview = screen.getByTitle('Preview of reports/preview.html'); + expect(preview).toHaveAttribute('srcdoc', content); + expect(preview).toHaveAttribute('sandbox', ''); + expect(preview).toHaveAttribute('referrerpolicy', 'no-referrer'); + expect(screen.getByText('Preview')).toBeInTheDocument(); + expect(screen.getByText('Code')).toBeInTheDocument(); + }); + + it('switches an HTML artifact between preview and code', () => { + const content = '
HTML source
'; + + render( + , + ); + + fireEvent.click(screen.getByLabelText('Code')); + + expect( + screen.queryByTitle('Preview of reports/preview.htm'), + ).not.toBeInTheDocument(); + expect(screen.getByText(content)).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Preview')); + + expect( + screen.getByTitle('Preview of reports/preview.htm'), + ).toBeInTheDocument(); + }); + + it('resets HTML artifacts to preview when the path or version changes', () => { + const createHtmlArtifact = (path: string, version: number) => ({ + id: 'artifact-html', + taskId: 'task-1', + path, + version, + artifactType: 'general' as const, + contentType: 'text/html', + size: 128, + createdAt: new Date('2026-05-22T00:00:00.000Z'), + downloadUrl: 'https://example.test/preview.html', + content: `
${path} v${version}
`, + }); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByLabelText('Code')); + rerender( + , + ); + + expect( + screen.getByTitle('Preview of reports/second.html'), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Code')); + rerender( + , + ); + + expect( + screen.getByTitle('Preview of reports/second.html'), + ).toBeInTheDocument(); + }); + + it('keeps non-HTML text artifacts in the existing code view', () => { + render( + , + ); + + expect(screen.getByText('Plain text content')).toBeInTheDocument(); + expect(screen.queryByText('Preview')).not.toBeInTheDocument(); + expect(screen.queryByText('Code')).not.toBeInTheDocument(); + expect(screen.queryByTitle(/Preview of/)).not.toBeInTheDocument(); + }); + it('does not render the internal artifact type in the toolbar', () => { render( = { xml: 'xml', html: 'html', htm: 'html', + xhtml: 'html', css: 'css', scss: 'scss', less: 'less', @@ -115,6 +116,20 @@ function getLanguageFromPath(path: string): BundledLanguage { return extensionToLanguage[ext] ?? ('plaintext' as BundledLanguage); } +function isHtmlArtifact(contentType: string, path: string): boolean { + const normalizedContentType = + contentType.split(';', 1)[0]?.trim().toLowerCase() ?? ''; + const extension = path.split('.').pop()?.toLowerCase(); + + return ( + normalizedContentType === 'text/html' || + normalizedContentType === 'application/xhtml+xml' || + extension === 'html' || + extension === 'htm' || + extension === 'xhtml' + ); +} + /** * Build the prompt for a "Build this plan" task that implements a plan artifact. * @@ -205,6 +220,10 @@ export function ArtifactViewerContent({ prevLatestVersionRef.current = undefined; }, [artifact?.path]); + useEffect(() => { + setIsRaw(false); + }, [artifact?.path, artifact?.version]); + const latestVersion = versions[0]?.version; useEffect(() => { if (!artifact || !onVersionChange || !latestVersion) return; @@ -225,17 +244,26 @@ export function ArtifactViewerContent({ ); } + const isHTML = isHtmlArtifact(artifact.contentType, artifact.path); const isMarkdown = - artifact.contentType.includes('markdown') || artifact.path.endsWith('.md'); + !isHTML && + (artifact.contentType.includes('markdown') || + artifact.path.endsWith('.md')); const isImage = artifact.contentType.startsWith('image/'); const isVideo = artifact.contentType.startsWith('video/'); const isPDF = artifact.contentType === 'application/pdf'; const isText = - !isMarkdown && !isImage && !isVideo && !isPDF && !!artifact.content; + !isHTML && + !isMarkdown && + !isImage && + !isVideo && + !isPDF && + !!artifact.content; const language = getLanguageFromPath(artifact.path); const canRender = isText || + (isHTML && artifact.content) || (isMarkdown && artifact.content) || ((isImage || isVideo || isPDF) && artifact.downloadUrl); @@ -411,6 +439,27 @@ export function ArtifactViewerContent({ />
)} + {canRender && isHTML && ( +
+ + + +
+ )}
)} @@ -418,7 +467,7 @@ export function ArtifactViewerContent({
)} - {((isMarkdown && isRaw) || isText) && artifact.content && ( -
- -
+ {isHTML && !isRaw && artifact.content && ( +