Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 834684c37a | |||
| 080d1b6051 | |||
| 7f88b0b441 | |||
| 8f7664eb04 | |||
| cb445f0966 | |||
| cafe29b153 | |||
| 35f2c06f42 | |||
| ce9f1a8980 | |||
| 15fe998d3d | |||
| 54f0ba681e | |||
| 27f7791a0e | |||
| 15859e3b9f | |||
| ebf132d5ac | |||
| 05d3456f5c | |||
| d910180772 | |||
| 89a4bf28ce | |||
| da94b1589c | |||
| 40227bbf51 | |||
| a26803a1bc | |||
| 18eee98b7f | |||
| 067fc46170 | |||
| ab1da408e5 | |||
| da7bb95d4f | |||
| 6ab2e989b9 | |||
| 169e34d766 | |||
| 10d5220f5e | |||
| 52ee3c1f3e | |||
| ccee32cb0b | |||
| 79a461f79d | |||
| 24946ad820 | |||
| 84334a1f34 | |||
| 3085ec1b50 | |||
| 05ec9feaf9 | |||
| 8e12579925 | |||
| 20703d06c2 | |||
| dab2660999 | |||
| 751d55e9db | |||
| 02308012a6 | |||
| 0665fcb630 | |||
| 97bd554cb5 | |||
| f77a6b42de | |||
| 134b627806 | |||
| 3267512ed9 | |||
| 48bd27b83c | |||
| 265b81c93d | |||
| ed808876be | |||
| a72ddbbe86 | |||
| d8fc724d90 | |||
| e4bfbcabaa | |||
| 4c1ee50dc9 | |||
| b8cce4f814 | |||
| c5bff2d84a | |||
| a325ddbabd | |||
| 80fc30633b | |||
| e17d5bc060 | |||
| bfcee6dddc | |||
| 2c2d60a5dc | |||
| 1417209915 | |||
| f555fc87da | |||
| d6d1195abd | |||
| 36b940fdb8 | |||
| 0050ad7ebb | |||
| 43b11d92ab | |||
| ce70fab1df | |||
| 7b4617db70 | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| 9b4b38a611 | |||
| eebbe6717c | |||
| e348433a39 | |||
| f759084f41 | |||
| 459d636ffb | |||
| e89ac627dd | |||
| f665f6fdd2 | |||
| 7af85b476e | |||
| 5d8364bb5f | |||
| d3209b5aab | |||
| 68899a2c2e | |||
| b9f3de80f5 | |||
| 5336f06d10 | |||
| 4bd579f7f6 | |||
| 7bf1c91a95 | |||
| 6c82c54470 | |||
| 382e5196da | |||
| 76e0c08cec | |||
| 8978d69f3e | |||
| c192f2a2e1 | |||
| d78b985062 | |||
| 2ce672709a | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 | |||
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| baa41d66ad | |||
| 1a7b817250 | |||
| 52beae85b3 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 | |||
| f5d19f9728 | |||
| 351615e5bc | |||
| 1fda0ec8b0 | |||
| 2637640291 | |||
| aa0428e28b |
@@ -202,6 +202,39 @@ MCP_DOCMOST_PASSWORD=
|
||||
# Default 900000 (15 min).
|
||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||
|
||||
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
||||
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
||||
# active" behavior.
|
||||
# AI_CHAT_DEFERRED_TOOLS=true
|
||||
|
||||
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
||||
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
||||
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
||||
# it, and a client reconnects/live-follows the run.
|
||||
#
|
||||
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
||||
# AbortController that backs it are process-local, so a Stop only aborts a run
|
||||
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
||||
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
||||
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
||||
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
||||
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||
#
|
||||
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
|
||||
# durable run tees its SSE frames into an in-memory registry, and a
|
||||
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
|
||||
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
|
||||
# (default) the registry is never populated and attach always answers 204, so a
|
||||
# reopened tab of an active run silently falls back to degraded 2.5s history
|
||||
# polling — every wire path stays byte-for-byte identical to a build without the
|
||||
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
|
||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
||||
# registry is process-local).
|
||||
# AI_CHAT_RESUMABLE_STREAM=false
|
||||
|
||||
# --- Anonymous public-share AI assistant ---
|
||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||
@@ -235,3 +268,27 @@ MCP_DOCMOST_PASSWORD=
|
||||
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||
# per rolling day).
|
||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
||||
|
||||
# --- Observability / perf metrics (#355) ---
|
||||
#
|
||||
# Two INDEPENDENT toggles, both OFF by default:
|
||||
#
|
||||
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
|
||||
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
|
||||
# collectors, and NOTHING is exposed on the main app port. There is NO
|
||||
# default port — leaving it blank disables it. When set to a port (e.g.
|
||||
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
|
||||
# only (never on the main :3000 app listener), for a scraper such as
|
||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||
# METRICS_PORT=9464
|
||||
#
|
||||
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||
# metrics into the `client_metrics` table (read directly by Grafana, separate
|
||||
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
|
||||
# endpoint is public and the table has NO app-side retention, so enabling it
|
||||
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
|
||||
# infra prunes rows >90d via a maintenance container). When off, the endpoint
|
||||
# does not exist and the client installs no observers.
|
||||
# CLIENT_TELEMETRY_ENABLED=false
|
||||
|
||||
@@ -18,12 +18,48 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
# Run the reusable test suite. Together with the e2e jobs below it gates the
|
||||
# publish job (the image push), not the build itself — build runs in parallel.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
|
||||
# (GHA cache, scope develop-amd64). No push happens here — the publish job
|
||||
# below is the only one that pushes the image.
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build develop image (warm cache, no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: false
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# The gate: rebuilds from the cache the build job just wrote (near-instant on
|
||||
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
|
||||
# old sequential timing) and pushes :develop only when unit tests AND both
|
||||
# e2e suites AND the build are green.
|
||||
publish:
|
||||
needs: [test, e2e-server, e2e-mcp, build]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -57,13 +93,10 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||
# lists them all).
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
||||
@@ -118,15 +151,19 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# @docmost/prosemirror-markdown is an ESM workspace package the server
|
||||
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
|
||||
# hook, so build it before the e2e run (mirrors the test.yml job).
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
- name: Run migrations
|
||||
run: pnpm --filter ./apps/server migration:latest
|
||||
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
# Gates the publish too — see the comment above e2e-server.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
name: Nightly property fuzz
|
||||
|
||||
# The daily heavy property run for the ProseMirror<->Markdown converter
|
||||
# (packages/prosemirror-markdown). The PR/CI test run keeps NUM_RUNS modest to
|
||||
# stay under budget; this cron cranks up total coverage with random seeds to hunt
|
||||
# for deeper round-trip counterexamples than a fixed-seed PR run can reach.
|
||||
#
|
||||
# WHY SHARDING: a single mega-run (~10000 fast-check runs) OOMs the vitest worker
|
||||
# (empirically ~1625 runs -> "JS heap out of memory", ~2GB) because heap
|
||||
# accumulates across the whole property run in one process. Instead this job runs
|
||||
# SHARDS fresh vitest processes, each a MODERATE per-shard count with a DISTINCT
|
||||
# derived seed, so total coverage ~= SHARDS x PER_SHARD_NUM_RUNS across processes
|
||||
# that never accumulate heap. On the first failing shard we stop and keep that
|
||||
# shard's output for triage.
|
||||
#
|
||||
# Counterexample -> fixture workflow: when a shard fails, fast-check prints the
|
||||
# SHRUNK minimal counterexample plus the reproducing seed. This job files a Gitea
|
||||
# issue containing that seed + counterexample ONLY when the output actually holds
|
||||
# a fast-check counterexample; an infra failure (OOM/tsc/install, no
|
||||
# counterexample) is filed under a DISTINCT title so it can never poison the
|
||||
# counterexample dedup. A human then commits the shrunk doc as a PERMANENT fixture
|
||||
# under packages/prosemirror-markdown/test/fixtures/counterexamples/ with a case in
|
||||
# counterexamples.test.ts, and FIXES the converter (never weakens a property to
|
||||
# hide the bug). See packages/prosemirror-markdown/README.md.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:00 UTC daily.
|
||||
- cron: '0 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
num_runs:
|
||||
description: 'fast-check runs PER SHARD (8 shards run in sequence)'
|
||||
required: false
|
||||
default: '600'
|
||||
seed:
|
||||
description: 'base fast-check seed (empty = random); shard i uses base+i'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
property-fuzz:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# No build step: the generative suite imports the converter from src/
|
||||
# directly (e.g. `from '../../src/lib/markdown-converter.js'`), so it runs
|
||||
# against source without the package's build/. Skipping the build also
|
||||
# keeps a tsc build error from masquerading as a property-test failure and
|
||||
# filing a bogus counterexample issue.
|
||||
- name: Resolve base seed and per-shard run count
|
||||
id: params
|
||||
# Dispatch inputs are read via env (NOT interpolated into the shell body)
|
||||
# to avoid script injection through a crafted input value.
|
||||
env:
|
||||
SEED_INPUT: ${{ inputs.seed }}
|
||||
NUM_RUNS_INPUT: ${{ inputs.num_runs }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SEED="${SEED_INPUT:-}"
|
||||
# Empty seed (cron, or a dispatch that left it blank) -> random. Combine
|
||||
# two RANDOMs so the seed spans more than RANDOM's 0..32767 range.
|
||||
[ -z "$SEED" ] && SEED=$(( (RANDOM << 15) | RANDOM ))
|
||||
NUM_RUNS="${NUM_RUNS_INPUT:-}"
|
||||
[ -z "$NUM_RUNS" ] && NUM_RUNS=600
|
||||
echo "seed=$SEED" >> "$GITHUB_OUTPUT"
|
||||
echo "num_runs=$NUM_RUNS" >> "$GITHUB_OUTPUT"
|
||||
echo "Sharded property fuzz: BASE_SEED=$SEED PER_SHARD_NUM_RUNS=$NUM_RUNS SHARDS=8"
|
||||
|
||||
- name: Run generative property suite (sharded)
|
||||
id: fuzz
|
||||
env:
|
||||
BASE_SEED: ${{ steps.params.outputs.seed }}
|
||||
PER_SHARD_NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
SHARDS: '8'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Give each fresh process headroom, but rely on SHARDING (not a big heap)
|
||||
# to avoid OOM: a moderate per-shard count in a process that starts clean.
|
||||
export NODE_OPTIONS=--max-old-space-size=4096
|
||||
: > property-output.txt
|
||||
FAILED=0
|
||||
FAIL_SEED=""
|
||||
i=0
|
||||
while [ "$i" -lt "$SHARDS" ]; do
|
||||
SHARD_SEED=$(( BASE_SEED + i ))
|
||||
echo "=== shard $((i + 1))/$SHARDS: PROPERTY_SEED=$SHARD_SEED PROPERTY_NUM_RUNS=$PER_SHARD_NUM_RUNS ==="
|
||||
# tee OVERWRITES property-output.txt each shard; since we break on the
|
||||
# first failure, the file ends up holding exactly the failing shard's
|
||||
# output (which carries the shrunk counterexample + reproducing seed).
|
||||
if PROPERTY_SEED="$SHARD_SEED" PROPERTY_NUM_RUNS="$PER_SHARD_NUM_RUNS" \
|
||||
pnpm --filter @docmost/prosemirror-markdown exec \
|
||||
vitest run test/generative/ 2>&1 | tee property-output.txt; then
|
||||
echo "shard $((i + 1)) passed"
|
||||
else
|
||||
echo "shard $((i + 1)) FAILED (seed=$SHARD_SEED) — stopping; keeping its output"
|
||||
FAILED=1
|
||||
FAIL_SEED="$SHARD_SEED"
|
||||
break
|
||||
fi
|
||||
i=$(( i + 1 ))
|
||||
done
|
||||
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
|
||||
echo "fail_seed=$FAIL_SEED" >> "$GITHUB_OUTPUT"
|
||||
exit "$FAILED"
|
||||
|
||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
||||
# the next step under a different title) can never poison this dedup.
|
||||
- name: File counterexample issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
# exactly when it must run. always() lets it run on the failure path.
|
||||
if: always() && steps.fuzz.outputs.failed == '1'
|
||||
env:
|
||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TITLE_PREFIX: 'Nightly property counterexample'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Discriminate counterexample vs infra failure by the fast-check
|
||||
# signature. No signature -> leave it to the infra-failure step.
|
||||
if ! grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
|
||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
||||
# prefix already exists. A failure of this check must NOT block creation.
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||
# interpolate the counterexample into JSON).
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
curl -sS -X POST \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @payload.json
|
||||
|
||||
# An INFRA failure (OOM, tsc, install) has NO counterexample signature. File
|
||||
# it under a DISTINCT title so it is visible but keeps the counterexample
|
||||
# dedup (above) uncontaminated — a real counterexample can still file even
|
||||
# while an infra issue is open.
|
||||
- name: File infra failure issue
|
||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||
# exactly when it must run. always() lets it run on the failure path.
|
||||
if: always() && steps.fuzz.outputs.failed == '1'
|
||||
env:
|
||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TITLE_PREFIX: 'Nightly property run infra failure'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Only file when there is NO counterexample signature (else the
|
||||
# counterexample step owns it).
|
||||
if grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
||||
echo "Counterexample present — owned by the counterexample step."
|
||||
exit 0
|
||||
fi
|
||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
||||
|
||||
EXISTING=""
|
||||
if EXISTING=$(curl -sS \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||
if printf '%s' "$EXISTING" \
|
||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed WITHOUT a fast-check counterexample (infra failure: OOM / build / install). This is NOT a converter round-trip bug.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nInvestigate the run log (memory, dependency install, or a tsc/import error). The nightly counterexample dedup is intentionally separate from this issue.\n\nTail of the test output:\n\n```\n%s\n```\n' \
|
||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$(tail -n 120 property-output.txt)")
|
||||
|
||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||
'{title: $title, body: $body}' > payload.json
|
||||
|
||||
curl -sS -X POST \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d @payload.json
|
||||
@@ -13,6 +13,49 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Guard against a long-lived branch adding a migration whose timestamped
|
||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||
# runs for pull_request events (needs a base branch to diff against).
|
||||
migration-order:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout (full history for the base-branch diff)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Added migrations must sort after the newest on the base branch
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MIG_DIR="apps/server/src/database/migrations"
|
||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||
# break the merge-base when the base has moved ahead of the PR merge —
|
||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||
# `set -e` above already aborts on a non-zero diff exit.
|
||||
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||
bad=0
|
||||
for f in $added; do
|
||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||
bad=1
|
||||
fi
|
||||
done
|
||||
if [ "$bad" -eq 0 ]; then
|
||||
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||
fi
|
||||
exit $bad
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@@ -72,6 +115,14 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
run: pnpm --filter @docmost/editor-ext build
|
||||
|
||||
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||
# unless it is built first. Build it before the recursive test run.
|
||||
- name: Build prosemirror-markdown
|
||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
+10
-1
@@ -4,12 +4,21 @@
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
node_modules/
|
||||
node_modules
|
||||
|
||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||
# so src/ and prod can never silently diverge).
|
||||
packages/git-sync/build/
|
||||
|
||||
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||
# never committed, so src/ and prod can never silently diverge).
|
||||
packages/prosemirror-markdown/build/
|
||||
|
||||
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||
# markdown convention; the package is private and rebuilt at deploy.
|
||||
packages/mcp/build/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
||||
|
||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||
|
||||
@@ -213,6 +214,12 @@ Run from the repo root unless noted. The dev workflow needs **Postgres (with the
|
||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||
> for the step-by-step and the traps.
|
||||
>
|
||||
> **Testing the app against a stand** (browser E2E + out-of-band verification) has
|
||||
> its own non-obvious traps — the page has two ProseMirror editors (only the body is
|
||||
> collab-bound), a ~10s store debounce, and API-seeding the thing under test is a
|
||||
> silent no-test. See **[docs/how-to-test.md](docs/how-to-test.md)** before writing
|
||||
> UI tests.
|
||||
|
||||
```bash
|
||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||
@@ -223,6 +230,24 @@ pnpm build # nx run-many -t build (all packages)
|
||||
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
||||
```
|
||||
|
||||
> **Build the shared packages before running a consumer's `tsc`/tests in
|
||||
> isolation.** The `build/` dirs of `@docmost/prosemirror-markdown`,
|
||||
> `@docmost/git-sync`, and `@docmost/mcp` are **gitignored** (not committed), and
|
||||
> a single-package `pnpm --filter <pkg> test` / `tsc` or a bare `pnpm -r test`
|
||||
> does **NOT** honour the Nx `dependsOn: ["^build"]` ordering. So a consumer — the
|
||||
> server's `tsc`, `git-sync`'s vitest typecheck, `mcp`'s `pretest: tsc` — fails
|
||||
> with `error TS2307: Cannot find module '@docmost/…'` until those packages are
|
||||
> built first:
|
||||
> ```bash
|
||||
> pnpm --filter @docmost/prosemirror-markdown build
|
||||
> pnpm --filter @docmost/editor-ext build
|
||||
> pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
||||
> ```
|
||||
> `pnpm build` (nx run-many) does this for you; CI does it explicitly in
|
||||
> `.github/workflows/test.yml` (prosemirror-markdown → git-sync/mcp → server, in
|
||||
> that order). Reach for it whenever you run a consumer package's checks on their
|
||||
> own rather than through the full `pnpm build`.
|
||||
|
||||
**Lint** (per package — there is no root lint script):
|
||||
```bash
|
||||
pnpm --filter server lint # eslint --fix on server .ts
|
||||
@@ -249,7 +274,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
||||
```
|
||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||
|
||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||
|
||||
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||
|
||||
## Architecture — the big picture
|
||||
|
||||
@@ -278,11 +306,12 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||
|
||||
### Client structure
|
||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||
|
||||
@@ -292,7 +321,8 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||
|
||||
## CI / release
|
||||
|
||||
|
||||
@@ -72,6 +72,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||
contain a standalone footnote definition, which canonicalization would drop.
|
||||
(#228)
|
||||
- **Detached, autonomous agent runs that survive a browser disconnect.** When the
|
||||
new `settings.ai.autonomousRuns` workspace flag is on (off by default), an
|
||||
AI-chat turn becomes a first-class, server-side RUN tracked in a new
|
||||
`ai_chat_runs` table instead of a socket-bound stream: closing the tab or
|
||||
losing the connection no longer aborts the turn — it keeps executing and
|
||||
persisting server-side, and only an explicit Stop ends it. A client can
|
||||
reconnect and live-follow (or stop) an in-flight run via `POST /ai-chat/run`
|
||||
(resolve the latest run + its assistant message for a chat) and
|
||||
`POST /ai-chat/stop` (stop by `runId` or `chatId`). A partial unique index
|
||||
enforces one active run per chat, and a startup sweep settles any run left
|
||||
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||
deployment. (#184)
|
||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||
@@ -156,6 +169,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||
strategy was requested (our server never requests one). Unpatched, those
|
||||
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
|
||||
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||
link whose target page name had no file extension (e.g. a bare title) was
|
||||
collapsed to empty text during export, producing an unclickable, label-less
|
||||
|
||||
+24
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||
# This stage is discarded, so the toolchain can stay installed.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
@@ -38,6 +45,14 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||
# revisit at step 6 when #119 lands.)
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||
|
||||
# Copy root package files
|
||||
COPY --from=builder /app/package.json /app/package.json
|
||||
@@ -49,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
USER node
|
||||
|
||||
RUN mkdir -p /app/data/storage
|
||||
|
||||
|
||||
@@ -125,6 +125,32 @@ Gitmost follows the upstream Docmost setup. See the Docmost
|
||||
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
||||
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
||||
|
||||
### Reverse proxy: SSE streaming paths
|
||||
|
||||
The AI agent streams its answers over Server-Sent Events. These endpoints produce a
|
||||
long-lived `text/event-stream` response and **must bypass response buffering AND response
|
||||
compression** at every proxy in front of the app:
|
||||
|
||||
- `POST /api/ai-chat/stream` — the live agent turn stream
|
||||
- `GET /api/ai-chat/runs/<chatId>/stream` — attach/resume of a detached agent run
|
||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||
- `POST /api/shares/ai/stream` — the anonymous public-share assistant
|
||||
|
||||
A buffering or compressing proxy does not break these with an error — it silently ruins them:
|
||||
the request hangs in `pending`, tokens stop streaming and arrive in one burst when the turn
|
||||
ends, or a reloaded tab falls back to coarse polling. The tell in DevTools is a
|
||||
`Content-Encoding: gzip/zstd` response header on a `text/event-stream` response.
|
||||
|
||||
The server already sends `X-Accel-Buffering: no` (honored by nginx unless ignored), but
|
||||
compression middleware is applied by proxy configuration, not headers:
|
||||
|
||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` for these locations, e.g.
|
||||
`location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||
- **Traefik** — route these paths through a dedicated router **without** the `compress`
|
||||
middleware (a `compress` middleware buffers SSE frames until the response closes), e.g.
|
||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Belt-and-braces:
|
||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||
|
||||
## Migration from Docmost
|
||||
|
||||
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
||||
|
||||
@@ -126,6 +126,32 @@ Gitmost повторяет процесс установки upstream-Docmost.
|
||||
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
||||
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
||||
|
||||
### Reverse proxy: SSE-стриминговые пути
|
||||
|
||||
AI-агент стримит ответы через Server-Sent Events. Эти эндпоинты отдают долгоживущий
|
||||
`text/event-stream`-ответ и **обязаны обходить буферизацию И сжатие ответов** на каждом
|
||||
прокси перед приложением:
|
||||
|
||||
- `POST /api/ai-chat/stream` — живой стрим хода агента
|
||||
- `GET /api/ai-chat/runs/<chatId>/stream` — подключение/резюм detached-рана
|
||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
||||
- `POST /api/shares/ai/stream` — анонимный ассистент публичных шар
|
||||
|
||||
Буферизующий или сжимающий прокси не ломает эти пути с ошибкой — он тихо их портит:
|
||||
запрос висит в `pending`, токены не стримятся и вываливаются одним куском в конце хода,
|
||||
а перезагруженная вкладка падает в грубый поллинг. Диагностический признак в DevTools —
|
||||
заголовок `Content-Encoding: gzip/zstd` на ответе с `text/event-stream`.
|
||||
|
||||
Сервер уже шлёт `X-Accel-Buffering: no` (nginx учитывает его по умолчанию), но
|
||||
compression-мидлвари управляются конфигом прокси, а не заголовками:
|
||||
|
||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` для этих location,
|
||||
например `location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
||||
- **Traefik** — вести эти пути через отдельный роутер **без** `compress`-мидлвари
|
||||
(compress буферизует SSE-кадры до закрытия ответа), например
|
||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Для надёжности:
|
||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
||||
|
||||
## Миграция с Docmost
|
||||
|
||||
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
||||
|
||||
@@ -34,7 +34,7 @@ roles:
|
||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||
- [Minor] — an optional improvement to framing or flow.
|
||||
@@ -87,7 +87,7 @@ roles:
|
||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||
- [Minor] — a stylistic improvement to taste.
|
||||
@@ -128,7 +128,7 @@ roles:
|
||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||
@@ -168,8 +168,11 @@ roles:
|
||||
- Don't verify facts — that's the Fact-checker.
|
||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||
|
||||
HOW TO WORK
|
||||
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||
|
||||
HOW TO LEAVE COMMENTS
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
|
||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||
- [Minor] — optional polish.
|
||||
|
||||
@@ -34,7 +34,7 @@ roles:
|
||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||
@@ -87,7 +87,7 @@ roles:
|
||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
- [Критично] — предложение непонятно или искажает смысл.
|
||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||
- [Незначительно] — стилистическое улучшение на вкус.
|
||||
@@ -128,7 +128,7 @@ roles:
|
||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||
@@ -169,8 +169,11 @@ roles:
|
||||
- Не проверяешь достоверность фактов — это фактчекер.
|
||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||
|
||||
КАК РАБОТАТЬ
|
||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||
- [Незначительно] — необязательная шлифовка.
|
||||
|
||||
@@ -23,18 +23,33 @@ roles:
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||
may prove useful even if the user did not ask about them directly.
|
||||
- Set a "research budget" — roughly how many searches the task's complexity
|
||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||
budget (e.g. "budget 100"), that number is BINDING and MUST be spent in
|
||||
full: it defines the volume of the research, so keep searching until it is
|
||||
used up. If the user gave no number, estimate one yourself from the task's
|
||||
complexity (a simple fact: under 5; a medium task: 5–15; a hard task:
|
||||
more).
|
||||
- Decide which languages it makes sense to search in (see below).
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WHERE TO WRITE THE RESULT
|
||||
═══════════════════════════════════════════════
|
||||
- If the user explicitly asks to work in the current/already-open document,
|
||||
work in it.
|
||||
- If this is not specified, create a NEW document for the report.
|
||||
- Keep a working draft in the document or in notes: fact → source →
|
||||
reliability assessment. Update the structure as you go.
|
||||
- Reuse the current/already-open document ONLY if either (a) the user
|
||||
explicitly asked to work in it, or (b) it is empty or has very little on
|
||||
it AND its title matches the topic of the research. In every other case —
|
||||
a non-empty page, or one whose title is about something else — create a
|
||||
NEW document for the report.
|
||||
- Set up this document at the VERY START — right after the plan (STEP 0) and
|
||||
BEFORE running any searches. Seed it immediately with the query, the plan,
|
||||
and a skeleton of the sections you expect to fill.
|
||||
- Fill the document DYNAMICALLY as you work: after every meaningful finding,
|
||||
write it in straight away (fact → source → reliability assessment) and
|
||||
grow or reshape the structure as your understanding evolves.
|
||||
- Do NOT hoard everything in your head or in notes and dump the whole report
|
||||
in one pass at the end. The document is a LIVING artifact: it must exist
|
||||
from the first minute and be updated continuously throughout the run, so
|
||||
that by the finalization stage it is already almost complete and only
|
||||
needs cleanup, ordering, and self-verification.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP (repeat until saturation)
|
||||
@@ -53,9 +68,19 @@ roles:
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||
Do not stop at the first plausible answer. Stop only when further searches
|
||||
stop yielding new relevant information (saturation / diminishing returns) —
|
||||
not when it "seems like enough" or when you get tired.
|
||||
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||
only when further searches stop yielding new relevant information
|
||||
(saturation / diminishing returns) — not when it "seems like enough" or when
|
||||
you get tired.
|
||||
|
||||
MANDATORY BUDGET. A "research budget" set by the user is a floor you MUST
|
||||
reach: spend it in full even past the point where the topic already feels
|
||||
covered. Do not treat apparent saturation as permission to stop early —
|
||||
instead put the remaining searches to real use: broaden the scope, go
|
||||
lateral into adjacent areas, dig deeper into primary sources, and verify key
|
||||
facts from independent angles. Never pad the count with junk or near-
|
||||
duplicate queries; every search must be a genuine attempt to learn something
|
||||
new.
|
||||
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||
|
||||
@@ -23,18 +23,33 @@ roles:
|
||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||
may prove useful even if the user did not ask about them directly.
|
||||
- Set a "research budget" — roughly how many searches the task's complexity
|
||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||
budget (e.g. "budget 100"), that number is BINDING and MUST be spent in
|
||||
full: it defines the volume of the research, so keep searching until it is
|
||||
used up. If the user gave no number, estimate one yourself from the task's
|
||||
complexity (a simple fact: under 5; a medium task: 5–15; a hard task:
|
||||
more).
|
||||
- Decide which languages it makes sense to search in (see below).
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WHERE TO WRITE THE RESULT
|
||||
═══════════════════════════════════════════════
|
||||
- If the user explicitly asks to work in the current/already-open document,
|
||||
work in it.
|
||||
- If this is not specified, create a NEW document for the report.
|
||||
- Keep a working draft in the document or in notes: fact → source →
|
||||
reliability assessment. Update the structure as you go.
|
||||
- Reuse the current/already-open document ONLY if either (a) the user
|
||||
explicitly asked to work in it, or (b) it is empty or has very little on
|
||||
it AND its title matches the topic of the research. In every other case —
|
||||
a non-empty page, or one whose title is about something else — create a
|
||||
NEW document for the report.
|
||||
- Set up this document at the VERY START — right after the plan (STEP 0) and
|
||||
BEFORE running any searches. Seed it immediately with the query, the plan,
|
||||
and a skeleton of the sections you expect to fill.
|
||||
- Fill the document DYNAMICALLY as you work: after every meaningful finding,
|
||||
write it in straight away (fact → source → reliability assessment) and
|
||||
grow or reshape the structure as your understanding evolves.
|
||||
- Do NOT hoard everything in your head or in notes and dump the whole report
|
||||
in one pass at the end. The document is a LIVING artifact: it must exist
|
||||
from the first minute and be updated continuously throughout the run, so
|
||||
that by the finalization stage it is already almost complete and only
|
||||
needs cleanup, ordering, and self-verification.
|
||||
|
||||
═══════════════════════════════════════════════
|
||||
WORK LOOP (repeat until saturation)
|
||||
@@ -53,9 +68,19 @@ roles:
|
||||
HOW TO SEARCH
|
||||
═══════════════════════════════════════════════
|
||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||
Do not stop at the first plausible answer. Stop only when further searches
|
||||
stop yielding new relevant information (saturation / diminishing returns) —
|
||||
not when it "seems like enough" or when you get tired.
|
||||
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||
only when further searches stop yielding new relevant information
|
||||
(saturation / diminishing returns) — not when it "seems like enough" or when
|
||||
you get tired.
|
||||
|
||||
MANDATORY BUDGET. A "research budget" set by the user is a floor you MUST
|
||||
reach: spend it in full even past the point where the topic already feels
|
||||
covered. Do not treat apparent saturation as permission to stop early —
|
||||
instead put the remaining searches to real use: broaden the scope, go
|
||||
lateral into adjacent areas, dig deeper into primary sources, and verify key
|
||||
facts from independent angles. Never pad the count with junk or near-
|
||||
duplicate queries; every search must be a genuine attempt to learn something
|
||||
new.
|
||||
|
||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||
|
||||
@@ -12,13 +12,13 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: structural-editor
|
||||
version: 3
|
||||
- slug: line-editor
|
||||
version: 3
|
||||
- slug: fact-checker
|
||||
version: 4
|
||||
- slug: line-editor
|
||||
version: 4
|
||||
- slug: fact-checker
|
||||
version: 6
|
||||
- slug: proofreader
|
||||
version: 5
|
||||
version: 8
|
||||
- slug: narrator
|
||||
version: 2
|
||||
- id: research
|
||||
@@ -33,4 +33,4 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: researcher
|
||||
version: 1
|
||||
version: 4
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 4,
|
||||
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
|
||||
"version": 6,
|
||||
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 3,
|
||||
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
|
||||
"version": 4,
|
||||
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 2,
|
||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 5,
|
||||
"hash": "40af08c51e03c24b1986ac5cd679434e023afe31a819748966ccb0c6c62f0401"
|
||||
"version": 8,
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
"version": 4,
|
||||
"hash": "9446ec6d2c8a6ec548358537ac392b8bf9b4d2a832ebb105d5514eac2c76da74"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 3,
|
||||
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
|
||||
"version": 4,
|
||||
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
"clsx": "2.1.1",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "3.4.1",
|
||||
"file-saver": "2.0.5",
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
@@ -60,6 +61,7 @@
|
||||
"react-clear-modal": "^2.0.18",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-drawio": "1.0.7",
|
||||
"web-vitals": "^5.1.0",
|
||||
"react-error-boundary": "6.1.1",
|
||||
"react-helmet-async": "3.0.0",
|
||||
"react-i18next": "16.5.8",
|
||||
@@ -81,6 +83,7 @@
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
|
||||
@@ -1373,6 +1373,39 @@
|
||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||
"Please try again later.": "Please try again later.",
|
||||
"No bundles available": "No bundles available",
|
||||
"Content": "Content",
|
||||
"Content language of the roles": "Content language of the roles",
|
||||
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||
"Update all ({{count}})": "Update all ({{count}})",
|
||||
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||
"{{count}} roles": "{{count}} roles",
|
||||
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||
"All installed · up to date": "All installed · up to date",
|
||||
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||
"{{count}} new": "{{count}} new",
|
||||
"{{count}} installed": "{{count}} installed",
|
||||
"{{count}} updates": "{{count}} updates",
|
||||
"Install bundle": "Install bundle",
|
||||
"Install {{count}} selected": "Install {{count}} selected",
|
||||
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||
"Select all": "Select all",
|
||||
"Deselect all": "Deselect all",
|
||||
"Skipped": "Skipped",
|
||||
"v{{version}}": "v{{version}}",
|
||||
"{{count}} roles installed": "{{count}} roles installed",
|
||||
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||
"{{count}} roles updated": "{{count}} roles updated",
|
||||
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||
"Rename & install": "Rename & install",
|
||||
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||
"Retry": "Retry",
|
||||
"The catalog is empty": "The catalog is empty",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||
"Already up to date": "Already up to date",
|
||||
"Updated to the latest version": "Updated to the latest version",
|
||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||
@@ -1382,5 +1415,8 @@
|
||||
"Applied": "Applied",
|
||||
"Suggestion applied": "Suggestion applied",
|
||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||
"Dismiss": "Dismiss",
|
||||
"Suggestion dismissed": "Suggestion dismissed",
|
||||
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||
}
|
||||
|
||||
@@ -1235,6 +1235,39 @@
|
||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||
"Please try again later.": "Попробуйте позже.",
|
||||
"No bundles available": "Наборы недоступны",
|
||||
"Content": "Язык контента",
|
||||
"Content language of the roles": "Язык контента ролей",
|
||||
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||
"{{count}} roles": "ролей: {{count}}",
|
||||
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||
"All installed · up to date": "Все установлены · актуальны",
|
||||
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||
"{{count}} new": "новых: {{count}}",
|
||||
"{{count}} installed": "установлено: {{count}}",
|
||||
"{{count}} updates": "обновлений: {{count}}",
|
||||
"Install bundle": "Установить набор",
|
||||
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||
"Select all": "Выбрать все",
|
||||
"Deselect all": "Снять выбор",
|
||||
"Skipped": "Пропущено",
|
||||
"v{{version}}": "v{{version}}",
|
||||
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||
"Rename & install": "Переименовать и установить",
|
||||
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||
"Retry": "Повторить",
|
||||
"The catalog is empty": "Каталог пуст",
|
||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||
"No roles configured": "Роли не настроены",
|
||||
"Already up to date": "Уже актуальна",
|
||||
"Updated to the latest version": "Обновлено до последней версии",
|
||||
@@ -1245,5 +1278,8 @@
|
||||
"Applied": "Применено",
|
||||
"Suggestion applied": "Предложение применено",
|
||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||
"Dismiss": "Не применять",
|
||||
"Suggestion dismissed": "Предложение отклонено",
|
||||
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
IconPlus,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useLocation, useMatch } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
@@ -37,6 +37,14 @@ import {
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import {
|
||||
getEditorSelectionContext,
|
||||
type EditorSelectionContext,
|
||||
} from "@/features/editor/utils/get-editor-selection.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
@@ -45,9 +53,13 @@ import {
|
||||
useAiChatsQuery,
|
||||
useAiRolesQuery,
|
||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||
import { exportAiChat } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import {
|
||||
exportAiChat,
|
||||
stopRun,
|
||||
} from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||
import {
|
||||
shouldCollapseOnOutsidePointer,
|
||||
@@ -74,6 +86,12 @@ const MIN_HEIGHT = 400;
|
||||
// Margin kept between the window and the viewport edges while dragging.
|
||||
const EDGE_MARGIN = 8;
|
||||
|
||||
// #184 phase 1.5: hard cap on the degraded-poll fallback. The poll is armed when
|
||||
// a resume attempt could not attach to the live run and disarmed by the thread on
|
||||
// settle / local stream; this cap is the ONLY backstop against an endless tick
|
||||
// (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no run).
|
||||
const DEGRADED_POLL_MAX_MS = 10 * 60_000;
|
||||
|
||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
@@ -231,8 +249,61 @@ export default function AiChatWindow() {
|
||||
[roles],
|
||||
);
|
||||
|
||||
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
||||
// ChatThread could not attach to a still-running run it arms this via
|
||||
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
||||
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||
const armedAtRef = useRef(0);
|
||||
const onResumeFallback = useCallback((active: boolean): void => {
|
||||
if (active) armedAtRef.current = Date.now();
|
||||
setDegradedPoll(active);
|
||||
}, []);
|
||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||
// resume attempt of the previously-open chat (invariant 8).
|
||||
useEffect(() => {
|
||||
setDegradedPoll(false);
|
||||
}, [activeChatId]);
|
||||
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
useAiChatMessagesQuery(
|
||||
activeChatId ?? undefined,
|
||||
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
||||
// and under the 10-min cap; otherwise off. NO error checks (TanStack v5
|
||||
// resets fetchFailureCount each fetch, so consecutive errors are not
|
||||
// expressible — and the poll must survive a server restart) and NO tail
|
||||
// checks (the settled/local-stream semantics live in ChatThread, which
|
||||
// disarms via onResumeFallback(false)). The time cap is the only backstop.
|
||||
() =>
|
||||
degradedPoll === true &&
|
||||
Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS
|
||||
? 2500
|
||||
: false,
|
||||
);
|
||||
|
||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||
// this workspace. When the feature is off no runs are ever created, so the
|
||||
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||
// pointless attach round-trip.
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const autonomousRunsEnabled =
|
||||
workspace?.settings?.ai?.autonomousRuns === true;
|
||||
|
||||
// Authoritative stop of the open chat's detached run (the Stop button in
|
||||
// autonomous mode). Request the server stop — the ONLY thing that ends a
|
||||
// detached run; a mere local SSE abort is a client disconnect the server
|
||||
// ignores. On failure surface the error.
|
||||
const handleServerStop = useCallback(
|
||||
(chatId: string): void => {
|
||||
void stopRun(chatId).catch(() => {
|
||||
notifications.show({
|
||||
message: t("Failed to stop the run"),
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
@@ -251,6 +322,27 @@ export default function AiChatWindow() {
|
||||
? { id: openPageData.id, title: openPageData.title }
|
||||
: null;
|
||||
|
||||
// Live editor handles for the selection snapshot (#388). Both are published by
|
||||
// the page editor; the read-only editor is used in read mode. Reading the
|
||||
// selection off `editor.state` stays valid after the editor blurs (ProseMirror
|
||||
// keeps state.selection), mirroring the comment button (comment-dialog.tsx).
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
||||
|
||||
// Snapshot the user's current editor selection at send time. Edit-mode editor
|
||||
// wins; the read-only editor is the fallback (read mode). Null when neither
|
||||
// holds a non-empty selection. Passed to <ChatThread>, which reads it live
|
||||
// from a ref inside prepareSendMessagesRequest — so each turn ships a fresh
|
||||
// snapshot and multi-turn works without recreating the transport.
|
||||
const getEditorSelection = useCallback((): EditorSelectionContext | null => {
|
||||
for (const editor of [pageEditor, readOnlyEditor]) {
|
||||
if (!editor || editor.isDestroyed) continue;
|
||||
const sel = getEditorSelectionContext(editor.state);
|
||||
if (sel) return sel;
|
||||
}
|
||||
return null;
|
||||
}, [pageEditor, readOnlyEditor]);
|
||||
|
||||
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||
@@ -873,6 +965,9 @@ export default function AiChatWindow() {
|
||||
chatId={activeChatId}
|
||||
initialRows={activeChatId ? messageRows : []}
|
||||
openPage={openPage}
|
||||
// #388: live snapshotter for the user's editor selection, read at
|
||||
// send time and nested inside openPage on the wire.
|
||||
getEditorSelection={getEditorSelection}
|
||||
// Honoured only for a new chat; null = universal assistant.
|
||||
roleId={activeChatId === null ? selectedRoleId : null}
|
||||
// Role cards are the new-chat empty-state; offered only when this
|
||||
@@ -882,6 +977,15 @@ export default function AiChatWindow() {
|
||||
assistantName={currentRole?.name}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onServerChatId={onServerChatId}
|
||||
// #184 phase 1.5: arm/disarm the degraded-poll fallback when a
|
||||
// resume attempt could not attach to the live run; the thread
|
||||
// disarms it on settle / local stream.
|
||||
onResumeFallback={onResumeFallback}
|
||||
// #184: in autonomous mode the Stop button must hit the authoritative
|
||||
// server stop (a local SSE abort is a client disconnect the server
|
||||
// ignores).
|
||||
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||
onServerStop={handleServerStop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
act,
|
||||
cleanup,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||
// above the imports) can expose the captured useChat callbacks / transport and
|
||||
@@ -11,48 +18,62 @@ const h = vi.hoisted(() => ({
|
||||
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||
sendMessage: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
setMessages: vi.fn(),
|
||||
resumeStream: vi.fn(),
|
||||
// The messages array useChat was seeded with (to assert strip/seed behavior).
|
||||
seededMessages: null as null | unknown[],
|
||||
transport: null as null | {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
prepareSendMessagesRequest?: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
prepareReconnectToStreamRequest?: () => { api?: string };
|
||||
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||
// Mock useChat: capture onFinish + seeded messages, return the spies and the
|
||||
// controllable status.
|
||||
vi.mock("@ai-sdk/react", () => ({
|
||||
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||
useChat: (opts: {
|
||||
messages?: unknown[];
|
||||
onFinish?: (arg: Record<string, unknown>) => void;
|
||||
}) => {
|
||||
h.state.onFinish = opts.onFinish ?? null;
|
||||
h.state.seededMessages = opts.messages ?? null;
|
||||
return {
|
||||
messages: [],
|
||||
sendMessage: h.state.sendMessage,
|
||||
status: h.state.status,
|
||||
stop: h.state.stop,
|
||||
error: null,
|
||||
setMessages: h.state.setMessages,
|
||||
resumeStream: h.state.resumeStream,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||
// can invoke prepareSendMessagesRequest / prepareReconnectToStreamRequest / fetch.
|
||||
vi.mock("ai", () => {
|
||||
let counter = 0;
|
||||
return {
|
||||
generateId: () => `gid-${counter++}`,
|
||||
DefaultChatTransport: class {
|
||||
constructor(opts: {
|
||||
prepareSendMessagesRequest: (arg: {
|
||||
messages: unknown[];
|
||||
body: Record<string, unknown>;
|
||||
}) => { body: Record<string, unknown> };
|
||||
}) {
|
||||
h.state.transport = opts;
|
||||
constructor(opts: Record<string, unknown>) {
|
||||
h.state.transport = opts as never;
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Keep the ai-chat-query import light: ChatThread only needs the messages RQ key,
|
||||
// so stub the module to avoid pulling axios / i18n transitively.
|
||||
vi.mock("@/features/ai-chat/queries/ai-chat-query.ts", () => ({
|
||||
AI_CHAT_MESSAGES_RQ_KEY: (chatId: string) => ["ai-chat-messages", chatId],
|
||||
}));
|
||||
|
||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||
// interaction this test needs to populate the queue while "streaming".
|
||||
@@ -60,49 +81,90 @@ vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
||||
default: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
default: ({
|
||||
onQueue,
|
||||
onStop,
|
||||
}: {
|
||||
onQueue: (text: string) => void;
|
||||
onStop: () => void;
|
||||
}) => (
|
||||
<>
|
||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||
queue
|
||||
</button>
|
||||
<button aria-label="Stop" onClick={() => onStop()}>
|
||||
stop
|
||||
</button>
|
||||
</>
|
||||
),
|
||||
}));
|
||||
|
||||
import ChatThread from "./chat-thread";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
function renderThread() {
|
||||
function row(
|
||||
id: string,
|
||||
role: string,
|
||||
status?: string,
|
||||
text = "",
|
||||
): IAiChatMessageRow {
|
||||
return { id, role, content: text, status, createdAt: "2026-01-01T00:00:00Z" };
|
||||
}
|
||||
|
||||
function renderThread(props?: {
|
||||
chatId?: string | null;
|
||||
initialRows?: IAiChatMessageRow[];
|
||||
autonomousRunsEnabled?: boolean;
|
||||
}) {
|
||||
const onTurnFinished = vi.fn();
|
||||
render(
|
||||
<MantineProvider>
|
||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||
</MantineProvider>,
|
||||
const onResumeFallback = vi.fn();
|
||||
const onServerStop = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
const { unmount } = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId={props?.chatId === undefined ? "c1" : props.chatId}
|
||||
initialRows={props?.initialRows ?? []}
|
||||
autonomousRunsEnabled={props?.autonomousRunsEnabled}
|
||||
onTurnFinished={onTurnFinished}
|
||||
onResumeFallback={onResumeFallback}
|
||||
onServerStop={onServerStop}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { onTurnFinished };
|
||||
return { onTurnFinished, onResumeFallback, onServerStop, invalidateSpy, unmount };
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.seededMessages = null;
|
||||
h.state.transport = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.setMessages.mockClear();
|
||||
h.state.resumeStream.mockClear();
|
||||
}
|
||||
|
||||
describe("ChatThread — send now (#198)", () => {
|
||||
beforeEach(() => {
|
||||
h.state.status = "streaming";
|
||||
h.state.onFinish = null;
|
||||
h.state.sendMessage.mockClear();
|
||||
h.state.stop.mockClear();
|
||||
h.state.transport = null;
|
||||
});
|
||||
beforeEach(resetState);
|
||||
|
||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||
renderThread();
|
||||
|
||||
// Queue a message while the turn is streaming.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
const sendNowBtn = screen.getByLabelText("Send now");
|
||||
expect(sendNowBtn).toBeTruthy();
|
||||
|
||||
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||
// the resend happens once the abort lands in onFinish.
|
||||
fireEvent.click(sendNowBtn);
|
||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
@@ -119,10 +181,8 @@ describe("ChatThread — send now (#198)", () => {
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
// The send right after "send now" carries interrupted: true...
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||
// ...and only that one (the flag is read-and-cleared).
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
|
||||
@@ -133,10 +193,549 @@ describe("ChatThread — send now (#198)", () => {
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
fireEvent.click(screen.getByLabelText("Send now"));
|
||||
|
||||
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||
expect(h.state.stop).not.toHaveBeenCalled();
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// #388: the editor selection is snapshotted at send time and nested inside
|
||||
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
||||
// fresh snapshot.
|
||||
describe("ChatThread — editor selection wiring (#388)", () => {
|
||||
beforeEach(resetState);
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithSelection(props: {
|
||||
openPage?: { id: string; title: string } | null;
|
||||
getEditorSelection?: () => unknown;
|
||||
}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={[]}
|
||||
openPage={props.openPage as never}
|
||||
getEditorSelection={props.getEditorSelection as never}
|
||||
onTurnFinished={vi.fn()}
|
||||
onResumeFallback={vi.fn()}
|
||||
onServerStop={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("nests the snapshot from the getter into openPage.selection at send time", () => {
|
||||
const selection = { text: "fix this", blockIds: ["b1"], before: "a " };
|
||||
renderWithSelection({
|
||||
openPage: { id: "p1", title: "Doc" },
|
||||
getEditorSelection: () => selection,
|
||||
});
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection });
|
||||
});
|
||||
|
||||
it("sends selection: null when the getter returns null", () => {
|
||||
renderWithSelection({
|
||||
openPage: { id: "p1", title: "Doc" },
|
||||
getEditorSelection: () => null,
|
||||
});
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null });
|
||||
});
|
||||
|
||||
it("does not send selection at all on a non-page route (openPage null)", () => {
|
||||
const getter = vi.fn(() => ({ text: "sel" }));
|
||||
renderWithSelection({ openPage: null, getEditorSelection: getter });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.openPage).toBeNull();
|
||||
// The getter must not even be consulted when there is no page.
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
beforeEach(resetState);
|
||||
|
||||
function finishWith(flags: {
|
||||
isAbort?: boolean;
|
||||
isDisconnect?: boolean;
|
||||
isError?: boolean;
|
||||
}) {
|
||||
cleanup();
|
||||
h.state.sendMessage.mockClear();
|
||||
const { onTurnFinished } = renderThread();
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: { id: "a", role: "assistant", parts: [] },
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
...flags,
|
||||
});
|
||||
});
|
||||
return { onTurnFinished };
|
||||
}
|
||||
|
||||
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||
finishWith({});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||
finishWith({ isAbort: true });
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||
finishWith({ isDisconnect: true });
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText("Connection lost — the answer was interrupted."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||
finishWith({ isError: true });
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||
});
|
||||
|
||||
it("notifies the parent on EVERY terminal outcome", () => {
|
||||
for (const flags of [
|
||||
{},
|
||||
{ isAbort: true },
|
||||
{ isDisconnect: true },
|
||||
{ isError: true },
|
||||
]) {
|
||||
const { onTurnFinished } = finishWith(flags);
|
||||
expect(onTurnFinished).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// #184 phase 1.5: the resumable-SSE client. A reopened tab resumes the live run
|
||||
// via the SDK's reconnect transport (attach: replay + tail) instead of polling.
|
||||
describe("ChatThread — resume (attach) machinery (#184)", () => {
|
||||
const streamingTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "streaming", "partial"),
|
||||
];
|
||||
const settledTail = () => [
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "succeeded", "done"),
|
||||
];
|
||||
const userTail = () => [row("u1", "user", undefined, "hi")];
|
||||
|
||||
const visibleMsg = {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "streamed answer" }],
|
||||
};
|
||||
const emptyMsg = { id: "a1", role: "assistant", parts: [] };
|
||||
|
||||
beforeEach(resetState);
|
||||
// NOTE: do NOT vi.unstubAllGlobals() here — vitest.setup.ts installs
|
||||
// matchMedia/localStorage via vi.stubGlobal and unstubbing wipes them for the
|
||||
// rest of the file. Fetch is re-stubbed per test that needs it.
|
||||
afterEach(cleanup);
|
||||
|
||||
it("resumes on mount only when the flag is on, chatId is set, and the tail is not a settled assistant", () => {
|
||||
// streaming tail -> resume
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
// user tail -> resume (the assistant row may not be seeded yet)
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
||||
|
||||
// settled assistant tail -> NO resume
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
|
||||
// flag off -> NO resume
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({ autonomousRunsEnabled: false, initialRows: streamingTail() });
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
|
||||
// no chatId -> NO resume
|
||||
cleanup();
|
||||
h.state.resumeStream.mockClear();
|
||||
renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
chatId: null,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips the streaming tail from the seed, but keeps a user tail whole", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
// 2 rows in, streaming tail stripped -> 1 seeded message.
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
|
||||
cleanup();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
// user tail is not stripped.
|
||||
expect(h.state.seededMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("builds the attach URL with expect=live&anchor only when the streaming tail was stripped", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
|
||||
);
|
||||
|
||||
cleanup();
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
||||
"/api/ai-chat/runs/c1/stream",
|
||||
);
|
||||
});
|
||||
|
||||
async function fetch204() {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
}
|
||||
|
||||
it("204 on a user tail: no crash, no restore, reconcile+invalidate, onResumeFallback(true)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: userTail(),
|
||||
});
|
||||
await fetch204();
|
||||
// No stripped row -> no restore merge.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
await fetch204();
|
||||
// Stripped row is restored to the store.
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("F7 restart-survival: a 500 attach failure restores the stripped row AND arms the poll (not lost)", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // stripped row restored
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // degraded poll armed
|
||||
});
|
||||
|
||||
it("F7 restart-survival: a network throw restores the stripped row AND arms the poll", async () => {
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new Error("network down")),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state
|
||||
.transport!.fetch!("http://x", { method: "GET" })
|
||||
.catch(() => undefined); // the wrapper rethrows; swallow here
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("unmount during a pending attach aborts the controller and gates late callbacks", async () => {
|
||||
const { onResumeFallback, invalidateSpy, unmount } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
let abortSeen = false;
|
||||
let resolveFetch!: (v: unknown) => void;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
abortSeen = true;
|
||||
});
|
||||
return new Promise((res) => {
|
||||
resolveFetch = res;
|
||||
});
|
||||
}),
|
||||
);
|
||||
// Kick a reconnect GET (stays pending).
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => {
|
||||
pending = h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
// Unmount: the cleanup aborts the in-flight attach.
|
||||
unmount();
|
||||
expect(abortSeen).toBe(true);
|
||||
// A late 204 landing after unmount must NOT arm a poll / invalidate the (now
|
||||
// different) chat.
|
||||
onResumeFallback.mockClear();
|
||||
invalidateSpy.mockClear();
|
||||
await act(async () => {
|
||||
resolveFetch({ status: 204, ok: false });
|
||||
await pending;
|
||||
});
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a resume fetch error clears resumedTurn so the next local turn flushes the queue", async () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
h.state.status = "ready";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
// Queue then clean-finish: suppression was cleared, so the queue flushes.
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||
});
|
||||
|
||||
it("a resumed turn's onFinish does NOT flush the queue", () => {
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a healthy resumed finish (visible content) arms nothing and keeps the store", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
// No restore (would clobber the fuller streamed message), no poll arm.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("isDisconnect WITH visible content arms the poll but does NOT restore", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
invalidateSpy.mockClear();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: visibleMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: true,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: ["ai-chat-messages", "c1"],
|
||||
});
|
||||
// Restore forbidden: the on-screen partial must not roll back.
|
||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("an empty resumed message (starved replay) restores the stripped row AND arms the poll", () => {
|
||||
h.state.status = "ready";
|
||||
const { onResumeFallback } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
act(() => {
|
||||
h.state.onFinish?.({
|
||||
message: emptyMsg,
|
||||
isAbort: false,
|
||||
isDisconnect: false,
|
||||
isError: false,
|
||||
});
|
||||
});
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
||||
});
|
||||
|
||||
it("degraded-merge: merges the tail per initialRows update, and settles disarm the poll", async () => {
|
||||
h.state.status = "ready";
|
||||
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
||||
// Arm reconcile via a 204.
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
||||
);
|
||||
await act(async () => {
|
||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
h.state.setMessages.mockClear();
|
||||
onResumeFallback.mockClear();
|
||||
|
||||
// A streaming-tail update: merge, poll stays armed.
|
||||
rerender([
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "streaming", "step 1\nstep 2"),
|
||||
]);
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(onResumeFallback).not.toHaveBeenCalledWith(false);
|
||||
|
||||
// A settled-tail update: merge + disarm.
|
||||
h.state.setMessages.mockClear();
|
||||
rerender([
|
||||
row("u1", "user", undefined, "hi"),
|
||||
row("a1", "assistant", "succeeded", "final"),
|
||||
]);
|
||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("a local stream disarms both the merge and the poll", () => {
|
||||
h.state.status = "streaming";
|
||||
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
||||
onResumeFallback.mockClear();
|
||||
// A re-render while streaming: the reconciliation effect disarms.
|
||||
rerender(streamingTail());
|
||||
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("Send now is hidden on a resumed turn but visible on a local stream", () => {
|
||||
// Resumed turn: hidden.
|
||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
expect(screen.queryByLabelText("Send now")).toBeNull();
|
||||
|
||||
// Local streaming turn (no resume): visible.
|
||||
cleanup();
|
||||
resetState();
|
||||
renderThread({ initialRows: [] });
|
||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||
expect(screen.getByLabelText("Send now")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("handleStop aborts the attach controller and calls onServerStop", async () => {
|
||||
const { onServerStop } = renderThread({
|
||||
autonomousRunsEnabled: true,
|
||||
initialRows: streamingTail(),
|
||||
});
|
||||
// Establish an attach controller via a (pending) reconnect GET.
|
||||
let abortSeen = false;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
abortSeen = true;
|
||||
});
|
||||
return new Promise(() => undefined); // never resolves
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
void h.state.transport!.fetch!("http://x", { method: "GET" });
|
||||
});
|
||||
fireEvent.click(screen.getByLabelText("Stop"));
|
||||
expect(abortSeen).toBe(true);
|
||||
expect(onServerStop).toHaveBeenCalledWith("c1");
|
||||
});
|
||||
});
|
||||
|
||||
// Helper: render a resumable thread and expose a rerender that only swaps
|
||||
// initialRows (the degraded-merge effect depends on it).
|
||||
function renderResumable(initialRows: IAiChatMessageRow[]) {
|
||||
const onResumeFallback = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const Wrapper = ({ rows }: { rows: IAiChatMessageRow[] }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={rows}
|
||||
autonomousRunsEnabled
|
||||
onTurnFinished={vi.fn()}
|
||||
onResumeFallback={onResumeFallback}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const view = render(<Wrapper rows={initialRows} />);
|
||||
const rerender = (rows: IAiChatMessageRow[]) =>
|
||||
act(() => view.rerender(<Wrapper rows={rows} />));
|
||||
return { rerender, onResumeFallback };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { generateId } from "ai";
|
||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
@@ -24,6 +25,15 @@ import {
|
||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
mergeById,
|
||||
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -60,6 +70,10 @@ interface ChatThreadProps {
|
||||
/** The page currently open in the workspace, or null on a non-page route.
|
||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||
openPage?: OpenPageContext | null;
|
||||
/** #388: snapshot the user's current editor selection at SEND time. Invoked
|
||||
* inside prepareSendMessagesRequest and nested into openPage on the wire, so a
|
||||
* fresh snapshot ships each turn. Null/absent => nothing selected. */
|
||||
getEditorSelection?: () => EditorSelectionContext | null;
|
||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||
* in the request body so the server persists it on chat creation; ignored by
|
||||
* the server for existing chats (the role is read from the chat row). */
|
||||
@@ -86,6 +100,23 @@ interface ChatThreadProps {
|
||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||
* which fires only at the terminal outcome. */
|
||||
onServerChatId?: (serverChatId?: string) => void;
|
||||
/** #184 phase 1.5: arm/disarm the parent's degraded-poll fallback for THIS
|
||||
* chat's window. Called `true` when a resume attempt could not attach to the
|
||||
* live run (attach 204 / starved-or-torn resumed finish), so the window starts
|
||||
* a dumb timed poll of the message history to follow the detached run to settle;
|
||||
* called `false` the moment a local stream starts or the terminal settled row is
|
||||
* merged (invariant 8). The window owns the timer + its 10-min cap. */
|
||||
onResumeFallback?: (active: boolean) => void;
|
||||
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
|
||||
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
|
||||
* (via onServerStop) — aborting only the local SSE is just a client disconnect,
|
||||
* which the server deliberately ignores, so the detached run would keep going. */
|
||||
autonomousRunsEnabled?: boolean;
|
||||
/** #184: request the server-side stop of this chat's active run (the parent owns
|
||||
* the endpoint call + the "stopping" latch that keeps observer-polling from
|
||||
* immediately re-streaming the stopping run's output). Called with the resolved
|
||||
* chat id when the user presses Stop in autonomous mode. */
|
||||
onServerStop?: (chatId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,17 +156,65 @@ export default function ChatThread({
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
getEditorSelection,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
assistantName,
|
||||
onTurnFinished,
|
||||
onServerChatId,
|
||||
onResumeFallback,
|
||||
autonomousRunsEnabled,
|
||||
onServerStop,
|
||||
}: ChatThreadProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// resume machinery refs (#184 phase 1.5)
|
||||
const attachAbortRef = useRef<AbortController | null>(null);
|
||||
const reconcileTailRef = useRef(false);
|
||||
const noStreamHandledRef = useRef(false);
|
||||
const onNoActiveStreamRef = useRef<(() => void) | null>(null);
|
||||
// Live mount flag. The attach GET and the resumed `onFinish` are async and can
|
||||
// land AFTER this thread unmounts (the parent remounts per chat via `key`); with
|
||||
// chatIdRef then pointing at the NEW chat, an ungated late callback would arm a
|
||||
// spurious poll + foreign invalidation on the newly-opened chat. Every parent-
|
||||
// facing resume side-effect is gated on this.
|
||||
const mountedRef = useRef(true);
|
||||
const [resumedTurn, setResumedTurn] = useState(false);
|
||||
const resumedTurnRef = useRef(false);
|
||||
// Identity-stable pair setter (bare useState setter + ref write): it is closed
|
||||
// over by the transport useMemo([]), so it MUST NOT capture state.
|
||||
const setResumedTurnPair = useCallback((v: boolean) => {
|
||||
resumedTurnRef.current = v;
|
||||
setResumedTurn(v);
|
||||
}, []);
|
||||
|
||||
// Mount-time resume gating (in refs — computed once for this mount; the parent
|
||||
// remounts per chat via `key`).
|
||||
//
|
||||
// Attempt resume for any non-settled tail: a streaming tail (strip + expect
|
||||
// live replay) or a user tail (the run may exist but its assistant row is not
|
||||
// seeded yet — attach to the pre-opened registry entry and wait for frames).
|
||||
// A settled assistant tail must NEVER resume: replaying a finished run into a
|
||||
// store that already contains its message duplicates parts (SDK text-start
|
||||
// always pushes a new part).
|
||||
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
|
||||
const attemptResumeRef = useRef(
|
||||
autonomousRunsEnabled === true &&
|
||||
chatId !== null &&
|
||||
!isSettledAssistantTail(initialRows ?? []),
|
||||
);
|
||||
const strippedRowRef = useRef<IAiChatMessageRow | null>(
|
||||
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
|
||||
);
|
||||
|
||||
const initialMessages = useMemo<UIMessage[]>(
|
||||
() => (initialRows ?? []).map(rowToUiMessage),
|
||||
() =>
|
||||
seedRows(
|
||||
initialRows ?? [],
|
||||
attemptResumeRef.current && stripRef.current,
|
||||
).map(rowToUiMessage),
|
||||
[initialRows],
|
||||
);
|
||||
|
||||
@@ -153,6 +232,14 @@ export default function ChatThread({
|
||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||
openPageRef.current = openPage ?? null;
|
||||
|
||||
// Keep the selection snapshotter in a ref, same rationale as openPageRef: the
|
||||
// transport useMemo([]) closes it over, so prop-identity churn must not matter.
|
||||
// Called at send time inside prepareSendMessagesRequest (#388).
|
||||
const getEditorSelectionRef = useRef<
|
||||
(() => EditorSelectionContext | null) | undefined
|
||||
>(getEditorSelection);
|
||||
getEditorSelectionRef.current = getEditorSelection;
|
||||
|
||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||
// ignores it for existing chats), but sending it on every send is harmless.
|
||||
@@ -216,6 +303,16 @@ export default function ChatThread({
|
||||
const flushOnAbortRef = useRef(false);
|
||||
const interruptNextSendRef = useRef(false);
|
||||
|
||||
// #234 F5: the user pressed Stop while streaming a BRAND-NEW chat whose server
|
||||
// chat id has not been adopted yet (the `start` chunk carrying it hadn't landed
|
||||
// when Stop was pressed). A local SSE abort alone does NOT stop the DETACHED
|
||||
// autonomous run — it keeps burning tokens and WRITING TO PAGES — so we cannot
|
||||
// just no-op. We latch the stop as PENDING and fire the authoritative server
|
||||
// stop the moment onServerChatId adopts the id (below). Read-and-cleared there;
|
||||
// also defused on every new turn start so it can never fire against a later,
|
||||
// unrelated turn's run.
|
||||
const stopPendingRef = useRef(false);
|
||||
|
||||
// FIFO dequeue + send the next queued message (no-op when the queue is empty).
|
||||
// Returns whether a message was actually sent, so callers can tell an empty
|
||||
// dequeue (nothing to flush) from a real send.
|
||||
@@ -223,9 +320,12 @@ export default function ChatThread({
|
||||
const { head, rest } = dequeue(queuedRef.current);
|
||||
if (!head) return false;
|
||||
setQueue(rest);
|
||||
// Local send: clear any resume-suppression flag so this genuine local turn's
|
||||
// onFinish flushes normally (invariant 8).
|
||||
setResumedTurnPair(false);
|
||||
sendMessageRef.current?.({ text: head.text });
|
||||
return true;
|
||||
}, [setQueue]);
|
||||
}, [setQueue, setResumedTurnPair]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(text: string) => {
|
||||
@@ -245,6 +345,47 @@ export default function ChatThread({
|
||||
new DefaultChatTransport<UIMessage>({
|
||||
api: "/api/ai-chat/stream",
|
||||
credentials: "include",
|
||||
prepareReconnectToStreamRequest: () => ({
|
||||
// SDK default URL uses the useChat STORE id — always build from the real chat id.
|
||||
// ?expect=live&anchor=<row id> ONLY when we stripped a streaming tail: expect=live
|
||||
// is the only case where a finished-retained replay is safe (the row is stripped,
|
||||
// replay rebuilds it), and the anchor pins the replay to OUR run — a mismatching
|
||||
// (newer) run must 204 into the restore+poll path instead of replaying a foreign
|
||||
// transcript into this store.
|
||||
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
||||
stripRef.current
|
||||
? `?expect=live&anchor=${strippedRowRef.current!.id}`
|
||||
: ""
|
||||
}`,
|
||||
}),
|
||||
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
||||
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
|
||||
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
|
||||
// for observer Stop / unmount abort.
|
||||
const controller = new AbortController();
|
||||
attachAbortRef.current = controller;
|
||||
try {
|
||||
const response = await fetch(input, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
});
|
||||
// No onFinish will come for a 204 (silent no-op) OR any non-2xx
|
||||
// (5xx/502 — a server restart mid-attach). Both run the same
|
||||
// no-active-stream recovery: restore the stripped row, invalidate, and
|
||||
// arm the degraded poll (idempotent via noStreamHandledRef; its part-d
|
||||
// also clears the resumedTurn flag). This is the restart-survival path
|
||||
// the removed F7 latch used to guard — a transient attach failure must
|
||||
// NOT drop the in-progress row or stop tracking the durable run.
|
||||
if (response.status === 204 || !response.ok)
|
||||
onNoActiveStreamRef.current?.();
|
||||
return response;
|
||||
} catch (err) {
|
||||
// Network throw: same no-onFinish recovery, then rethrow so the SDK
|
||||
// still surfaces the error to its own machinery.
|
||||
onNoActiveStreamRef.current?.();
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
// Inject the chat id and the currently-open page alongside the useChat
|
||||
// messages so the server can resolve an existing chat (or create one
|
||||
// when null) and tell the agent which page "this page" refers to. Both
|
||||
@@ -261,7 +402,16 @@ export default function ChatThread({
|
||||
body: {
|
||||
...body,
|
||||
chatId: chatIdRef.current,
|
||||
openPage: openPageRef.current,
|
||||
// Attach the live editor selection to the open-page context at send
|
||||
// time — "this"/"here" in the user's message means THIS selection.
|
||||
// Nested inside openPage so it dies with the page when the server
|
||||
// rejects the page id (#388). Null when nothing is selected.
|
||||
openPage: openPageRef.current
|
||||
? {
|
||||
...openPageRef.current,
|
||||
selection: getEditorSelectionRef.current?.() ?? null,
|
||||
}
|
||||
: null,
|
||||
// Honoured by the server only when creating a new chat; null =>
|
||||
// universal assistant.
|
||||
roleId: roleIdRef.current,
|
||||
@@ -274,7 +424,15 @@ export default function ChatThread({
|
||||
[],
|
||||
);
|
||||
|
||||
const { messages, sendMessage, status, stop, error } = useChat({
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
error,
|
||||
setMessages,
|
||||
resumeStream,
|
||||
} = useChat({
|
||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||
// every render mid-stream (see `chatStoreId` above).
|
||||
@@ -292,6 +450,38 @@ export default function ChatThread({
|
||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||
// the user to decide.
|
||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||
// (1) Capture whether THIS finish belongs to a resumed (attach) turn and
|
||||
// immediately clear the flag so it can never suppress a LATER local turn.
|
||||
const wasResumed = resumedTurnRef.current;
|
||||
setResumedTurnPair(false);
|
||||
// (2) Recovery after a starved/torn resumed finish (invariant 9). The arm
|
||||
// and the stripped-row restore are gated DIFFERENTLY. Skip entirely once
|
||||
// unmounted (an abort-triggered onFinish landing after a chat switch must
|
||||
// not arm a poll / invalidate on the new chat).
|
||||
if (wasResumed && mountedRef.current) {
|
||||
const hasVisibleContent = assistantMessageHasVisibleContent(message);
|
||||
// ARM the reconcile + degraded poll when the resumed message carries no
|
||||
// visible content (starved replay) OR the connection dropped mid-run — in
|
||||
// both cases the poll must drive the row to its real terminal state.
|
||||
if (isDisconnect || !hasVisibleContent) {
|
||||
reconcileTailRef.current = true;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
onResumeFallback?.(true);
|
||||
}
|
||||
// RESTORE the stripped streaming row ONLY when the resumed message has no
|
||||
// visible content. On isDisconnect WITH visible content restore is
|
||||
// FORBIDDEN: the live stream may have advanced far past the mount-time
|
||||
// snapshot, so restoring would clobber on-screen content (invariant 9) —
|
||||
// the arm above suffices, the poll reaches the true terminal.
|
||||
if (!hasVisibleContent && strippedRowRef.current) {
|
||||
setMessages((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
}
|
||||
}
|
||||
// (3) Standard branches.
|
||||
// Forward the authoritative server chatId (streamed on the assistant
|
||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||
@@ -304,6 +494,10 @@ export default function ChatThread({
|
||||
else if (isAbort) setStopNotice("manual");
|
||||
else if (isDisconnect) setStopNotice("disconnect");
|
||||
else setStopNotice(null);
|
||||
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
|
||||
// flush-on-abort branch and the plain flush. The local streamer is the only
|
||||
// tab that owns the queue.
|
||||
if (wasResumed) return;
|
||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||
// immediately send the promoted head. Flush it even though the turn was
|
||||
// aborted (the normal abort path below keeps the queue intact). The
|
||||
@@ -365,7 +559,14 @@ export default function ChatThread({
|
||||
return;
|
||||
lastForwardedChatIdRef.current = serverChatId;
|
||||
onServerChatId(serverChatId);
|
||||
}, [messages, onServerChatId]);
|
||||
// #234 F5: if Stop was pressed before the id was known, the authoritative
|
||||
// server stop was deferred to this adoption point — fire it now with the
|
||||
// just-adopted id. One-shot (read-and-clear) so it can't fire twice.
|
||||
if (stopPendingRef.current) {
|
||||
stopPendingRef.current = false;
|
||||
onServerStop?.(serverChatId);
|
||||
}
|
||||
}, [messages, onServerChatId, onServerStop]);
|
||||
|
||||
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
||||
@@ -378,6 +579,99 @@ export default function ChatThread({
|
||||
|
||||
const isStreaming = status === "submitted" || status === "streaming";
|
||||
|
||||
// 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to
|
||||
// resume (overflow / begin-failure / after retention / anchor-mismatch). One-
|
||||
// shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four
|
||||
// parts. Kept in a ref (read by the transport's fetch closure) and refreshed
|
||||
// each render below.
|
||||
const onNoActiveStream = useCallback(() => {
|
||||
// A late attach outcome after unmount must not arm a poll / invalidate on the
|
||||
// now-different chat this thread's refs were reused for.
|
||||
if (!mountedRef.current) return;
|
||||
if (noStreamHandledRef.current) return;
|
||||
noStreamHandledRef.current = true;
|
||||
// (a) Restore the stripped streaming row to the store — ONLY when we actually
|
||||
// stripped one (a user-tail 204 does NOT reach here with a stripped row, so do
|
||||
// not dereference null).
|
||||
if (strippedRowRef.current) {
|
||||
setMessages((prev) =>
|
||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
||||
);
|
||||
}
|
||||
// (b) Reconcile the tail from the message history + invalidate it so the
|
||||
// degraded poll starts from a fresh fetch.
|
||||
reconcileTailRef.current = true;
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
||||
});
|
||||
// (c) Arm the degraded poll (a dumb timer with a 10-min cap in the window);
|
||||
// the thread disarms it via onResumeFallback(false) on settle / local stream.
|
||||
onResumeFallback?.(true);
|
||||
// (d) 204 means onFinish will NOT fire — clear the suppression flag so it
|
||||
// cannot swallow the NEXT local turn's queue flush.
|
||||
setResumedTurnPair(false);
|
||||
}, [setMessages, queryClient, onResumeFallback, setResumedTurnPair]);
|
||||
onNoActiveStreamRef.current = onNoActiveStream;
|
||||
|
||||
// Mount effect: kick off the resume attempt for a non-settled tail. Marking the
|
||||
// turn as resumed BEFORE resumeStream so onFinish (invariant 7/8) sees it.
|
||||
useEffect(() => {
|
||||
// Re-arm on (re)mount — StrictMode dev-mounts twice, and the cleanup below
|
||||
// flips this false between the two.
|
||||
mountedRef.current = true;
|
||||
if (attemptResumeRef.current) {
|
||||
setResumedTurnPair(true);
|
||||
void resumeStream();
|
||||
}
|
||||
// Unmount: mark unmounted (gates late attach/onFinish side-effects) and abort
|
||||
// the in-flight attach GET so its callbacks don't fire against the next chat.
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
attachAbortRef.current?.abort();
|
||||
};
|
||||
// Mount-only by design; the parent remounts per chat via `key`.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Reconciliation + degraded-merge (invariant 8). Deps are EXACTLY
|
||||
// [initialRows, isStreaming, setMessages].
|
||||
useEffect(() => {
|
||||
// A local stream owns the view: disarm BOTH the merge and the window poll.
|
||||
if (isStreaming) {
|
||||
reconcileTailRef.current = false;
|
||||
onResumeFallback?.(false);
|
||||
return;
|
||||
}
|
||||
if (!reconcileTailRef.current) return;
|
||||
const rows = initialRows ?? [];
|
||||
const tail = rows[rows.length - 1];
|
||||
if (!tail || tail.role !== "assistant") return;
|
||||
// Merge the polled assistant tail on EVERY initialRows update — while the
|
||||
// degraded poll is active this IS the live per-step progress.
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
||||
// Anchor-mismatch coherence: when we restored a stripped streaming row A but a
|
||||
// DIFFERENT run's row B is now the tail (A finished, B replaced the registry
|
||||
// entry, so the attach 204'd), A would otherwise linger forever as an orphan
|
||||
// jumping-dots row over the real run. Settle it from fresh history (where A is
|
||||
// now persisted) so no phantom row survives. No-op in the common case where A
|
||||
// IS the tail (id match).
|
||||
const stripped = strippedRowRef.current;
|
||||
if (stripped && stripped.id !== tail.id) {
|
||||
const historical = rows.find((r) => r.id === stripped.id);
|
||||
if (historical)
|
||||
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
||||
}
|
||||
// Settled: the terminal merge is done — disarm the flag AND the window poll
|
||||
// explicitly (the window only has a time cap, it will not disarm itself).
|
||||
if (tail.status !== "streaming") {
|
||||
reconcileTailRef.current = false;
|
||||
onResumeFallback?.(false);
|
||||
}
|
||||
// onResumeFallback intentionally omitted (parent-stable callback); deps are
|
||||
// fixed by the resume design.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initialRows, isStreaming, setMessages]);
|
||||
|
||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||
// stay queued and flush normally after the new turn. Reuses the existing
|
||||
@@ -403,12 +697,51 @@ export default function ChatThread({
|
||||
const msg = queuedRef.current.find((m) => m.id === id);
|
||||
if (!msg) return;
|
||||
setQueue(removeQueuedById(queuedRef.current, id));
|
||||
// Local send: clear any resume-suppression flag (invariant 8).
|
||||
setResumedTurnPair(false);
|
||||
sendMessageRef.current?.({ text: msg.text });
|
||||
}
|
||||
},
|
||||
[setQueue, stop],
|
||||
[setQueue, stop, setResumedTurnPair],
|
||||
);
|
||||
|
||||
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||
// returns to idle immediately. In AUTONOMOUS mode the turn is a DETACHED run:
|
||||
// aborting the local SSE is only a client disconnect, which the server ignores,
|
||||
// so the run would keep executing — we ADDITIONALLY request the authoritative
|
||||
// server-side stop (the parent owns that call + the "stopping" latch that keeps
|
||||
// observer-polling from re-streaming the stopping run's output). The chat id is
|
||||
// read live from chatIdRef (adopted early at the stream's `start` chunk); if it
|
||||
// is not known yet — a brand-new chat in the first moment of its first turn —
|
||||
// only the local abort happens (there is no server-side run handle to stop yet).
|
||||
const handleStop = useCallback(() => {
|
||||
// Abort the resume/attach GET first: the SDK does not pass it a signal, so an
|
||||
// observer's Stop would otherwise leave the attach fetch running.
|
||||
attachAbortRef.current?.abort();
|
||||
stop();
|
||||
if (!autonomousRunsEnabled) return;
|
||||
if (chatIdRef.current) {
|
||||
onServerStop?.(chatIdRef.current);
|
||||
} else {
|
||||
// #234 F5: no chat id yet (brand-new chat in the first moment of its first
|
||||
// turn, before the `start` chunk adopted the id). Latch the stop as pending;
|
||||
// the onServerChatId adoption effect fires the deferred server stop as soon
|
||||
// as the id appears, so the detached run is still authoritatively stopped
|
||||
// instead of left running by a silent local-only abort.
|
||||
//
|
||||
// KNOWN LIMITATION (#234 F5 review): `stop()` above has already aborted the
|
||||
// local SSE reader. In the rare sub-window where Stop is pressed while still
|
||||
// `submitted` (request sent, not one chunk read yet), that abort can cancel
|
||||
// the reader BEFORE the `start` chunk is applied to `messages`, so the
|
||||
// adoption effect never runs and this pending stop never fires. The detached
|
||||
// run then keeps going for that turn. This is not a regression (the pre-fix
|
||||
// behavior sent no server stop at all); closing it fully would require
|
||||
// deferring the local abort until adoption, which is riskier and out of scope
|
||||
// for this fix. Documented so a future change can address the abort-ordering.
|
||||
stopPendingRef.current = true;
|
||||
}
|
||||
}, [stop, autonomousRunsEnabled, onServerStop]);
|
||||
|
||||
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
|
||||
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||
@@ -420,6 +753,11 @@ export default function ChatThread({
|
||||
setStopNotice(null);
|
||||
flushOnAbortRef.current = false;
|
||||
interruptNextSendRef.current = false;
|
||||
// #234 F5: a new turn is starting — drop any pending deferred-stop from a
|
||||
// previous turn that never adopted an id, so it can never fire against this
|
||||
// (or a later) unrelated turn's run. A deferred stop for the CURRENT turn is
|
||||
// set AFTER this effect (on the Stop click), so this does not clobber it.
|
||||
stopPendingRef.current = false;
|
||||
}
|
||||
}, [isStreaming]);
|
||||
|
||||
@@ -512,17 +850,23 @@ export default function ChatThread({
|
||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{/* "Send now" (interrupt) is hidden on a RESUMED turn: a local
|
||||
stop() does not abort the resumed attach fetch, so the click
|
||||
would be swallowed while flushOnAbortRef would fire minutes
|
||||
later on the natural finish. Only the remove affordance stays. */}
|
||||
{!resumedTurn && (
|
||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => sendNow(m.id)}
|
||||
aria-label={t("Send now")}
|
||||
>
|
||||
<IconPlayerPlayFilled size={12} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
@@ -537,9 +881,13 @@ export default function ChatThread({
|
||||
</Stack>
|
||||
)}
|
||||
<ChatInput
|
||||
onSend={(text) => sendMessage({ text })}
|
||||
onSend={(text) => {
|
||||
// Local send: clear any resume-suppression flag (invariant 8).
|
||||
setResumedTurnPair(false);
|
||||
sendMessage({ text });
|
||||
}}
|
||||
onQueue={enqueue}
|
||||
onStop={stop}
|
||||
onStop={handleStop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -40,6 +40,13 @@ interface MessageItemProps {
|
||||
* Defaults to true (internal chat). The public share passes false.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to ToolCallCard: whether tool cards render the one-line summary of
|
||||
* a call's arguments (e.g. the search query). Defaults to true (internal
|
||||
* chat). The public share passes false so an anonymous reader doesn't see the
|
||||
* agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||
* their href so they become inert text). Defaults to false (internal chat,
|
||||
@@ -117,6 +124,7 @@ const MarkdownPart = memo(function MarkdownPart({
|
||||
function MessageItem({
|
||||
message,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
@@ -210,6 +218,7 @@ function MessageItem({
|
||||
key={index}
|
||||
part={part as unknown as ToolUiPart}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -274,6 +283,7 @@ export function arePropsEqual(
|
||||
return (
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.showInput === next.showInput &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName &&
|
||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||
|
||||
@@ -25,6 +25,13 @@ interface MessageListProps {
|
||||
* false because an anonymous reader cannot open the linked internal pages.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render the
|
||||
* one-line summary of a call's arguments (e.g. the search query). Defaults to
|
||||
* true (internal chat). The public share passes false so an anonymous reader
|
||||
* doesn't see the agent's raw query/argument text.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
/**
|
||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||
* the rendered answers (drop their href so they render as inert text).
|
||||
@@ -119,6 +126,7 @@ export default function MessageList({
|
||||
isStreaming,
|
||||
emptyState,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
}: MessageListProps) {
|
||||
@@ -208,6 +216,7 @@ export default function MessageList({
|
||||
message={message}
|
||||
signature={messageSignature(message)}
|
||||
showCitations={showCitations}
|
||||
showInput={showInput}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getToolName,
|
||||
toolCitations,
|
||||
toolInputSummary,
|
||||
toolLabelKey,
|
||||
toolRunState,
|
||||
ToolUiPart,
|
||||
@@ -21,6 +22,14 @@ interface ToolCallCardProps {
|
||||
* (the action log itself) while dropping the unusable links.
|
||||
*/
|
||||
showCitations?: boolean;
|
||||
/**
|
||||
* Whether to render the one-line summary of the call's arguments (e.g. the
|
||||
* search query) under the label. Defaults to true (the internal chat). The
|
||||
* public share passes false: an anonymous reader should not see the agent's
|
||||
* raw query/argument text. Conservative and reversible — it only suppresses
|
||||
* the extra summary line, leaving the card (the action log) intact.
|
||||
*/
|
||||
showInput?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,12 +40,14 @@ interface ToolCallCardProps {
|
||||
export default function ToolCallCard({
|
||||
part,
|
||||
showCitations = true,
|
||||
showInput = true,
|
||||
}: ToolCallCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const toolName = getToolName(part);
|
||||
const state = toolRunState(part.state);
|
||||
const { key, values } = toolLabelKey(toolName);
|
||||
const citations = showCitations ? toolCitations(part) : [];
|
||||
const inputSummary = showInput ? toolInputSummary(part) : undefined;
|
||||
|
||||
return (
|
||||
<div className={classes.toolCard}>
|
||||
@@ -57,6 +68,12 @@ export default function ToolCallCard({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{inputSummary && (
|
||||
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
|
||||
{inputSummary}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{state === "error" && part.errorText && (
|
||||
<Text size="xs" c="red" mt={2}>
|
||||
{part.errorText}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
// react-i18next / notifications are pulled in transitively by ai-chat-query.ts
|
||||
// (the mutation hooks use them); stub so the module imports cleanly.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Mock the service module; only getAiChatMessages is exercised, but the other
|
||||
// named exports must exist so ai-chat-query.ts imports resolve.
|
||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||
getAiChatMessages: vi.fn(),
|
||||
getAiChats: vi.fn(),
|
||||
getAiRoleCatalog: vi.fn(),
|
||||
getAiRoleCatalogBundle: vi.fn(),
|
||||
getAiRoles: vi.fn(),
|
||||
importAiRolesFromCatalog: vi.fn(),
|
||||
createAiRole: vi.fn(),
|
||||
deleteAiChat: vi.fn(),
|
||||
deleteAiRole: vi.fn(),
|
||||
renameAiChat: vi.fn(),
|
||||
updateAiRole: vi.fn(),
|
||||
updateAiRoleFromCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
import { getAiChatMessages } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||
import { useAiChatMessagesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
|
||||
const emptyPage = { items: [], meta: { hasNextPage: false, nextCursor: null } };
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// The degraded-poll fallback (#184 phase 1.5) is threaded into this query as a
|
||||
// `refetchInterval`; AiChatWindow supplies the deliberately-dumb callback. These
|
||||
// pin the plumbing the window depends on: the interval polls the message history,
|
||||
// and — critically — fetch ERRORS do NOT stop the tick (TanStack v5 resets the
|
||||
// failure count each fetch, so the poll must survive a server restart).
|
||||
describe("useAiChatMessagesQuery — degraded refetchInterval", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("re-polls at the interval while the callback returns a duration", async () => {
|
||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT re-poll when the callback returns false", async () => {
|
||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => false), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
// Give any errant interval a chance to fire, then assert it did not.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps ticking through fetch errors (errors do not gate the poll)", async () => {
|
||||
vi.mocked(getAiChatMessages).mockRejectedValue(new Error("server down"));
|
||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
@@ -56,11 +57,12 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
export function useAiChatsQuery() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) =>
|
||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -83,15 +85,26 @@ export function useAiChatsQuery() {
|
||||
* Load all persisted messages of a chat (oldest first), flattening the
|
||||
* paginated server response. Used to seed `useChat` initial messages.
|
||||
*/
|
||||
export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
export function useAiChatMessagesQuery(
|
||||
chatId: string | undefined,
|
||||
// #184 phase 1.5: the degraded-poll fallback. When a tab could not attach to a
|
||||
// still-running run (the attach returned 204 / the resumed stream ended with no
|
||||
// terminal row), the window arms a dumb timed poll of the message history to
|
||||
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
||||
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
||||
refetchInterval?: number | false | (() => number | false),
|
||||
) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||
queryFn: ({ pageParam }) =>
|
||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled: !!chatId,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
@@ -272,6 +285,29 @@ export function useAiRoleCatalogBundleQuery(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||
* which needs every role's install state up front — so contents can no longer be
|
||||
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||
* cached read per bundle, sharing the same cache keys as
|
||||
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||
* resolved language) so nothing fetches while the modal is closed.
|
||||
*/
|
||||
export function useAiRoleCatalogBundlesQueries(
|
||||
bundleIds: string[],
|
||||
language: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQueries({
|
||||
queries: bundleIds.map((bundleId) => ({
|
||||
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||
enabled: enabled && !!language,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportAiRolesFromCatalogMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
@@ -280,11 +316,14 @@ export function useImportAiRolesFromCatalogMutation() {
|
||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||
onSuccess: (result) => {
|
||||
notifications.show({
|
||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
}),
|
||||
message: t(
|
||||
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||
{
|
||||
created: result.created,
|
||||
renamed: result.renamed,
|
||||
skipped: result.skipped,
|
||||
},
|
||||
),
|
||||
});
|
||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||
if (result.errors.length > 0) {
|
||||
|
||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
||||
});
|
||||
|
||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
||||
await runMutation({
|
||||
created: 3,
|
||||
renamed: 1,
|
||||
skipped: 2,
|
||||
errors: [],
|
||||
createdRoles: [],
|
||||
skippedRoles: [],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||
message: "Imported 3, renamed 1, skipped 2",
|
||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
||||
{ slug: "a", message: "name taken" },
|
||||
{ slug: "b", message: "name taken" },
|
||||
],
|
||||
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||
skippedRoles: [],
|
||||
});
|
||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||
|
||||
@@ -42,6 +42,21 @@ export async function getAiChatMessages(
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||
* is deliberately ignored server-side, so the client must call this to actually
|
||||
* stop an autonomous run. Targeted by `chatId` (the server resolves whatever run
|
||||
* is active on it); owner-gated server-side. Returns `{ stopped }` — false when
|
||||
* there was nothing active to stop.
|
||||
*/
|
||||
export async function stopRun(
|
||||
chatId: string,
|
||||
): Promise<{ stopped: boolean }> {
|
||||
const req = await api.post<{ stopped: boolean }>("/ai-chat/stop", { chatId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||
|
||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
||||
conflict: "skip" | "rename";
|
||||
}
|
||||
|
||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
||||
/**
|
||||
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||
* result plaque — which roles were installed (and any rename) and which were
|
||||
* skipped and why (so the plaque can name the conflicting role and offer
|
||||
* "Rename & install").
|
||||
*/
|
||||
export interface IAiRoleImportResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||
skippedRoles: {
|
||||
slug: string;
|
||||
name: string;
|
||||
reason: "name-conflict" | "already-installed";
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,6 +210,11 @@ export interface IAiChatMessageRow {
|
||||
// renders a "stopped" marker on interrupted turns.
|
||||
finishReason?: string;
|
||||
} | null;
|
||||
// Persisted lifecycle status of the row's turn, carried on the wire by
|
||||
// `baseFields`. 'streaming' marks a still-in-progress assistant row (used by
|
||||
// the resume machinery to decide whether a tail is a live stream to attach to
|
||||
// or a settled row that must not be replayed).
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
bundleCounts,
|
||||
bundlePhase,
|
||||
installedLangForRole,
|
||||
mapBundleRolesToView,
|
||||
mapCatalogRoleToView,
|
||||
nameConflictSlugs,
|
||||
partialOffersRename,
|
||||
type CatalogViewRole,
|
||||
} from "./catalog-bundle-model.ts";
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
function installedRole(
|
||||
source: { slug: string; language: string; version: number },
|
||||
overrides: Partial<IAiRole> = {},
|
||||
): IAiRole {
|
||||
return {
|
||||
id: `role-${source.slug}-${source.language}`,
|
||||
name: source.slug,
|
||||
emoji: null,
|
||||
description: null,
|
||||
enabled: true,
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
source,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogRole(
|
||||
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||
): IAiRoleCatalogRole {
|
||||
return {
|
||||
slug: "writer",
|
||||
emoji: "✍️",
|
||||
name: "Writer",
|
||||
description: "Drafts copy.",
|
||||
instructions: "be a writer",
|
||||
autoStart: true,
|
||||
launchMessage: null,
|
||||
version: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Build a minimal view role for bundlePhase tests.
|
||||
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||
}
|
||||
|
||||
describe("bundlePhase", () => {
|
||||
it("empty bundle -> empty", () => {
|
||||
expect(bundlePhase([])).toBe("empty");
|
||||
});
|
||||
|
||||
it("all importable, none installed -> allNew", () => {
|
||||
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||
"allNew",
|
||||
);
|
||||
});
|
||||
|
||||
it("nothing to import or update -> allInstalled", () => {
|
||||
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||
"allInstalled",
|
||||
);
|
||||
});
|
||||
|
||||
it("updates present, nothing to import -> updates", () => {
|
||||
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||
"updates",
|
||||
);
|
||||
});
|
||||
|
||||
it("import + installed (no updates) -> mixed", () => {
|
||||
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||
"mixed",
|
||||
);
|
||||
});
|
||||
|
||||
it("import + update -> mixed", () => {
|
||||
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||
});
|
||||
|
||||
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||
});
|
||||
|
||||
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||
"mixed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bundleCounts", () => {
|
||||
it("tallies each status once", () => {
|
||||
expect(
|
||||
bundleCounts([
|
||||
viewRole("import"),
|
||||
viewRole("import"),
|
||||
viewRole("installed"),
|
||||
viewRole("update"),
|
||||
viewRole("skipped"),
|
||||
]),
|
||||
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||
const skipped = [
|
||||
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||
];
|
||||
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||
expect(partialOffersRename(skipped)).toBe(true);
|
||||
});
|
||||
|
||||
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||
const skipped = [
|
||||
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||
];
|
||||
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||
expect(partialOffersRename(skipped)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("installedLangForRole", () => {
|
||||
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||
});
|
||||
|
||||
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when no install of the slug exists", () => {
|
||||
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores manually-created roles (no source)", () => {
|
||||
const roles = [
|
||||
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||
source: null,
|
||||
}),
|
||||
];
|
||||
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapCatalogRoleToView", () => {
|
||||
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||
expect(view).toMatchObject({
|
||||
slug: "writer",
|
||||
emoji: "✍️",
|
||||
name: "Writer",
|
||||
description: "Drafts copy.",
|
||||
status: "import",
|
||||
version: 3,
|
||||
});
|
||||
expect(view.installedRoleId).toBeUndefined();
|
||||
expect(view.installedLang).toBeUndefined();
|
||||
});
|
||||
|
||||
it("import with the slug installed in another language -> installedLang set", () => {
|
||||
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||
expect(view.status).toBe("import");
|
||||
expect(view.installedLang).toBe("ru");
|
||||
});
|
||||
|
||||
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 3,
|
||||
});
|
||||
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||
expect(view).toMatchObject({
|
||||
status: "installed",
|
||||
version: 3,
|
||||
installedRoleId: installed.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||
const installed = installedRole({
|
||||
slug: "writer",
|
||||
language: "en",
|
||||
version: 1,
|
||||
});
|
||||
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||
expect(view).toMatchObject({
|
||||
status: "update",
|
||||
version: 1,
|
||||
newVersion: 3,
|
||||
installedRoleId: installed.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||
const view = mapCatalogRoleToView(
|
||||
catalogRole({ emoji: null, description: null }),
|
||||
[],
|
||||
"en",
|
||||
);
|
||||
expect(view.emoji).toBeUndefined();
|
||||
expect(view.description).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapBundleRolesToView", () => {
|
||||
it("maps a bundle's roles preserving order", () => {
|
||||
const roles = [
|
||||
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||
];
|
||||
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||
const view = mapBundleRolesToView(roles, installed, "en");
|
||||
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||
expect(view[0].status).toBe("installed");
|
||||
expect(view[1].status).toBe("import");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import type {
|
||||
IAiRole,
|
||||
IAiRoleCatalogRole,
|
||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||
|
||||
/**
|
||||
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||
* (readable without expanding) and a single primary action. The per-role and
|
||||
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||
* computation are unit-testable without mounting the component (mirrors the
|
||||
* `catalogRoleInstallState` precedent).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A role's status in the catalog view model.
|
||||
* - `import` — not installed in the current content language.
|
||||
* - `installed` — installed and up to date.
|
||||
* - `update` — installed, but the catalog ships a newer version.
|
||||
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||
* (a name collision under `conflict:'skip'`); never from the
|
||||
* backend.
|
||||
*/
|
||||
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||
|
||||
/** A catalog role mapped into the modal's view model. */
|
||||
export interface CatalogViewRole {
|
||||
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||
// `slugs[]` payload for import.
|
||||
slug: string;
|
||||
// Optional in the catalog — the row reserves space and renders nothing when
|
||||
// absent.
|
||||
emoji?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||
version: number;
|
||||
newVersion?: number;
|
||||
status: RoleStatus;
|
||||
// The language a same-slug role is installed under, when it differs from the
|
||||
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||
installedLang?: string;
|
||||
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||
// update-from-catalog mutation.
|
||||
installedRoleId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||
* the collapsed-header summary and the bundle's single primary action.
|
||||
* - `empty` — the bundle has no roles.
|
||||
* - `allNew` — everything is importable, nothing installed.
|
||||
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||
* - `updates` — updates available and nothing left to import.
|
||||
* - `mixed` — any other combination.
|
||||
*/
|
||||
export type BundlePhase =
|
||||
| "empty"
|
||||
| "allNew"
|
||||
| "allInstalled"
|
||||
| "updates"
|
||||
| "mixed";
|
||||
|
||||
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||
export interface BundleCounts {
|
||||
importable: number;
|
||||
installed: number;
|
||||
update: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||
*/
|
||||
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||
const counts: BundleCounts = {
|
||||
importable: 0,
|
||||
installed: 0,
|
||||
update: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
for (const r of roles) {
|
||||
if (r.status === "import") counts.importable += 1;
|
||||
else if (r.status === "installed") counts.installed += 1;
|
||||
else if (r.status === "update") counts.update += 1;
|
||||
else if (r.status === "skipped") counts.skipped += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||
if (roles.length === 0) return "empty";
|
||||
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||
// would make the collapsed green "up to date" header contradict the open
|
||||
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||
return "allNew";
|
||||
return "mixed";
|
||||
}
|
||||
|
||||
/**
|
||||
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||
*/
|
||||
export function nameConflictSlugs(
|
||||
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||
): string[] {
|
||||
return skipped
|
||||
.filter((s) => s.reason === "name-conflict")
|
||||
.map((s) => s.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a partial-import result should offer the "Rename & install" action:
|
||||
* only when at least one skip is a name conflict (renameable). An
|
||||
* `already-installed`-only partial is informational.
|
||||
*/
|
||||
export function partialOffersRename(
|
||||
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||
): boolean {
|
||||
return skipped.some((s) => s.reason === "name-conflict");
|
||||
}
|
||||
|
||||
/**
|
||||
* For a role NOT installed in the current `language`, find a workspace role with
|
||||
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||
* language. Drives the "installed in another language" hint (Р5): a different
|
||||
* language of the same slug is a separate install and appears as `import`.
|
||||
*/
|
||||
export function installedLangForRole(
|
||||
slug: string,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): string | undefined {
|
||||
const other = workspaceRoles.find(
|
||||
(r) =>
|
||||
r.source?.slug === slug &&
|
||||
!!r.source?.language &&
|
||||
r.source.language !== language,
|
||||
);
|
||||
return other?.source?.language;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map one catalog role to the view model, computing its install status against
|
||||
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||
* the other-language hint.
|
||||
*/
|
||||
export function mapCatalogRoleToView(
|
||||
role: IAiRoleCatalogRole,
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogViewRole {
|
||||
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||
const base = {
|
||||
slug: role.slug,
|
||||
emoji: role.emoji ?? undefined,
|
||||
name: role.name,
|
||||
description: role.description ?? "",
|
||||
};
|
||||
if (state.state === "update") {
|
||||
return {
|
||||
...base,
|
||||
status: "update",
|
||||
version: state.fromVersion,
|
||||
newVersion: state.toVersion,
|
||||
installedRoleId: state.installed.id,
|
||||
};
|
||||
}
|
||||
if (state.state === "installed") {
|
||||
return {
|
||||
...base,
|
||||
status: "installed",
|
||||
version: role.version,
|
||||
installedRoleId: state.installed.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
status: "import",
|
||||
version: role.version,
|
||||
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||
*/
|
||||
export function mapBundleRolesToView(
|
||||
roles: IAiRoleCatalogRole[],
|
||||
workspaceRoles: IAiRole[],
|
||||
language: string,
|
||||
): CatalogViewRole[] {
|
||||
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
isStreamingTail,
|
||||
isSettledAssistantTail,
|
||||
seedRows,
|
||||
mergeById,
|
||||
} from "./resume-helpers.ts";
|
||||
|
||||
function row(
|
||||
id: string,
|
||||
role: string,
|
||||
status?: string,
|
||||
): IAiChatMessageRow {
|
||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
||||
}
|
||||
|
||||
function makeMsg(id: string, text: string): UIMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text }],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
describe("isStreamingTail", () => {
|
||||
it("is true when the last row is a streaming assistant row", () => {
|
||||
expect(
|
||||
isStreamingTail([row("u1", "user"), row("a1", "assistant", "streaming")]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a settled assistant tail", () => {
|
||||
expect(isStreamingTail([row("a1", "assistant", "succeeded")])).toBe(false);
|
||||
expect(isStreamingTail([row("a1", "assistant")])).toBe(false);
|
||||
});
|
||||
|
||||
it("is false when the tail is a user row or the list is empty", () => {
|
||||
expect(isStreamingTail([row("u1", "user")])).toBe(false);
|
||||
expect(isStreamingTail([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSettledAssistantTail", () => {
|
||||
it("is true for an assistant tail whose status is not streaming", () => {
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "succeeded")])).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isSettledAssistantTail([row("a1", "assistant")])).toBe(true);
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "aborted")])).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false for a streaming assistant tail", () => {
|
||||
expect(isSettledAssistantTail([row("a1", "assistant", "streaming")])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("is false when the tail is a user row or the list is empty", () => {
|
||||
expect(isSettledAssistantTail([row("u1", "user")])).toBe(false);
|
||||
expect(isSettledAssistantTail([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("seedRows", () => {
|
||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
||||
|
||||
it("returns the rows unchanged when not stripping", () => {
|
||||
expect(seedRows(rows, false)).toBe(rows);
|
||||
});
|
||||
|
||||
it("drops the last row when stripping", () => {
|
||||
const seeded = seedRows(rows, true);
|
||||
expect(seeded).toHaveLength(1);
|
||||
expect(seeded[0].id).toBe("u1");
|
||||
});
|
||||
|
||||
it("returns an empty list when stripping a single-row list", () => {
|
||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
||||
0,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeById", () => {
|
||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||
const incoming = makeMsg("a1", "step 1\nstep 2");
|
||||
const next = mergeById(prev, incoming);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(incoming);
|
||||
expect(next[0]).toBe(prev[0]); // untouched
|
||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||
});
|
||||
|
||||
it("appends when the incoming message is not yet present", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
const incoming = makeMsg("a1", "first token");
|
||||
const next = mergeById(prev, incoming);
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[1]).toBe(incoming);
|
||||
});
|
||||
|
||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||
const prev = [makeMsg("u1", "hi")];
|
||||
expect(mergeById(prev, null)).toBe(prev);
|
||||
expect(mergeById(prev, undefined)).toBe(prev);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Pure decisions for the resumable-SSE resume machinery (#184 phase 1.5). A tab
|
||||
* that reopens a chat whose agent run is still going attaches to the server's
|
||||
* run-stream registry (replay + live tail) instead of polling snapshots; these
|
||||
* small predicates decide WHICH tail is safe to resume and how to seed the store,
|
||||
* extracted so they can be unit-tested in isolation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
||||
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
||||
*/
|
||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
return !!tail && tail.role === "assistant" && tail.status === "streaming";
|
||||
}
|
||||
|
||||
/**
|
||||
* A SETTLED assistant tail: the last row is an assistant row whose status is
|
||||
* anything OTHER than 'streaming'. A settled assistant tail must NEVER resume —
|
||||
* replaying a finished run into a store that already holds its message duplicates
|
||||
* parts (`text-start` always pushes a new part).
|
||||
*/
|
||||
export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
||||
const tail = rows[rows.length - 1];
|
||||
return !!tail && tail.role === "assistant" && tail.status !== "streaming";
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
||||
* without duplicating parts).
|
||||
*/
|
||||
export function seedRows(
|
||||
rows: IAiChatMessageRow[],
|
||||
strip: boolean,
|
||||
): IAiChatMessageRow[] {
|
||||
return strip ? rows.slice(0, -1) : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge an assistant message into the rendered list by id: replace the message
|
||||
* with the same id in place (the in-progress assistant row is already seeded from
|
||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||
* new array; the input is never mutated.
|
||||
*/
|
||||
export function mergeById(
|
||||
messages: UIMessage[],
|
||||
incoming: UIMessage | null | undefined,
|
||||
): UIMessage[] {
|
||||
if (!incoming) return messages;
|
||||
const idx = messages.findIndex((m) => m.id === incoming.id);
|
||||
if (idx === -1) return [...messages, incoming];
|
||||
const next = messages.slice();
|
||||
next[idx] = incoming;
|
||||
return next;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
toolCitations,
|
||||
toolInputSummary,
|
||||
toolRunState,
|
||||
type ToolUiPart,
|
||||
} from "./tool-parts";
|
||||
@@ -77,6 +78,138 @@ describe("toolCitations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInputSummary", () => {
|
||||
it("returns the primary `query` string", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: "hello world" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("summarizes a primary array field with a (+N) suffix", () => {
|
||||
// `urls` is an external MCP read_pages-style list; the first element plus a
|
||||
// count of the rest.
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-read_pages",
|
||||
state: "input-available",
|
||||
input: { urls: ["a", "b", "c"] },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("a (+2)");
|
||||
});
|
||||
|
||||
it("omits the (+N) suffix for a single-element array", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-read_pages",
|
||||
state: "input-available",
|
||||
input: { urls: ["only"] },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("only");
|
||||
});
|
||||
|
||||
it("falls back to `title` for a page op with no query", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-createPage",
|
||||
state: "input-available",
|
||||
input: { pageId: "x", title: "My Page" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("My Page");
|
||||
});
|
||||
|
||||
it("prefers the earlier primary field when several are present", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
// `query` outranks `title` in PRIMARY_INPUT_FIELDS — the ordered list is
|
||||
// the contract, so a reordering must break this test.
|
||||
input: { query: "Q", title: "T" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("Q");
|
||||
});
|
||||
|
||||
it("does not clamp a value exactly at the 140-char limit", () => {
|
||||
const exact = "a".repeat(140);
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: exact },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out).toBe(exact);
|
||||
expect(out.endsWith("…")).toBe(false);
|
||||
expect(out.length).toBe(140);
|
||||
});
|
||||
|
||||
it("clamps one char over the limit (141 -> 140 + ellipsis)", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: "a".repeat(141) },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.length).toBe(141);
|
||||
expect(out).toBe("a".repeat(140) + "…");
|
||||
});
|
||||
|
||||
it("clamps a long value to ~140 chars with an ellipsis", () => {
|
||||
const long = "a".repeat(300);
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: long },
|
||||
};
|
||||
const out = toolInputSummary(part)!;
|
||||
expect(out.endsWith("…")).toBe(true);
|
||||
expect(out.length).toBeLessThanOrEqual(141);
|
||||
});
|
||||
|
||||
it("collapses newlines and repeated spaces to single spaces", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-available",
|
||||
input: { query: " foo\n\n bar baz " },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBe("foo bar baz");
|
||||
});
|
||||
|
||||
it("returns undefined with no input", () => {
|
||||
expect(
|
||||
toolInputSummary({ type: "tool-x", state: "input-available" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for an empty object input", () => {
|
||||
expect(
|
||||
toolInputSummary({
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
input: {},
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for a non-object input", () => {
|
||||
expect(
|
||||
toolInputSummary({
|
||||
type: "tool-x",
|
||||
state: "input-available",
|
||||
input: "just a string",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined while the input is still streaming (even with a full input)", () => {
|
||||
const part: ToolUiPart = {
|
||||
type: "tool-Search_web_search",
|
||||
state: "input-streaming",
|
||||
input: { query: "hello world" },
|
||||
};
|
||||
expect(toolInputSummary(part)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolRunState", () => {
|
||||
it('maps "output-error" to error', () => {
|
||||
expect(toolRunState("output-error")).toBe("error");
|
||||
|
||||
@@ -97,6 +97,69 @@ function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/** Collapse runs of whitespace/newlines to a single space and trim. */
|
||||
function collapse(s: string): string {
|
||||
return s.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
/** Truncate to ~140 chars, appending an ellipsis when it overflows. */
|
||||
function clamp(s: string): string {
|
||||
const MAX = 140;
|
||||
return s.length > MAX ? s.slice(0, MAX).trimEnd() + "…" : s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority "primary" argument fields, in order. The first present one supplies
|
||||
* the summary. `urls` is included (external MCP `read_pages`-style tools take a
|
||||
* list of URLs) and is handled as an array; `url` covers the single-URL form.
|
||||
*/
|
||||
const PRIMARY_INPUT_FIELDS = [
|
||||
"query",
|
||||
"q",
|
||||
"searchQuery",
|
||||
"url",
|
||||
"urls",
|
||||
"title",
|
||||
"name",
|
||||
"text",
|
||||
"prompt",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A short, PLAIN-TEXT one-line summary of a tool call's arguments (e.g. the
|
||||
* search query), or undefined when no recognizable primary field is present.
|
||||
* Rendered under the tool label so tools without a friendly name (external MCP
|
||||
* tools like `Search_web_search`) still show WHAT was requested, not just a
|
||||
* generic "Ran tool {{name}}". The returned string is plain text and MUST be
|
||||
* rendered React-escaped (Mantine `<Text>`), never as markdown/HTML.
|
||||
*
|
||||
* Streaming gate: while `state === "input-streaming"` the `input` object grows
|
||||
* chunk by chunk but `messageSignature` deliberately does NOT track `input`, so
|
||||
* a live summary computed here would freeze at its first captured value and go
|
||||
* stale. We therefore return undefined until the state flips to
|
||||
* `input-available` (input finalized) — that state change IS tracked by the
|
||||
* signature, so the row re-renders and shows the complete summary. Do NOT add
|
||||
* `input` to `message-signature.ts` to work around this.
|
||||
*/
|
||||
export function toolInputSummary(part: ToolUiPart): string | undefined {
|
||||
if (part.state === "input-streaming") return undefined;
|
||||
if (!part.input || typeof part.input !== "object") return undefined;
|
||||
const input = part.input as Record<string, unknown>;
|
||||
|
||||
for (const field of PRIMARY_INPUT_FIELDS) {
|
||||
const value = input[field];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return clamp(collapse(value));
|
||||
}
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
const first = collapse(String(value[0]));
|
||||
if (first.length === 0) continue;
|
||||
return clamp(first + (value.length > 1 ? ` (+${value.length - 1})` : ""));
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the page citation(s) a tool part references, from its input/output.
|
||||
* Only output-available parts (the tool returned) yield citations. Search
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||
// safety valve fired without pulling in the editor stack.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor-fallback" />,
|
||||
}));
|
||||
|
||||
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||
// case renders in isolation.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import { CommentContentView } from "./comment-content-view";
|
||||
|
||||
function renderView(content: string | object) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<CommentContentView content={content} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||
|
||||
describe("CommentContentView", () => {
|
||||
it("renders paragraphs as <p> with text", () => {
|
||||
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||
expect(screen.getByText("Hello world")).toBeDefined();
|
||||
expect(container.querySelector("p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||
const { container } = renderView(doc([para([text("x")])]));
|
||||
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||
expect(globalPm).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the bold mark as <strong>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||
);
|
||||
const el = container.querySelector("strong");
|
||||
expect(el?.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("renders the italic mark as <em>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("it", [{ type: "italic" }])])]),
|
||||
);
|
||||
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||
});
|
||||
|
||||
it("renders the strike mark as <s>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("st", [{ type: "strike" }])])]),
|
||||
);
|
||||
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||
});
|
||||
|
||||
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("un", [{ type: "underline" }])])]),
|
||||
);
|
||||
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the code mark as <code>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("co", [{ type: "code" }])])]),
|
||||
);
|
||||
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||
});
|
||||
|
||||
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "https://example.com" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||
expect(a?.getAttribute("target")).toBe("_blank");
|
||||
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a).not.toBeNull();
|
||||
// No navigable javascript: href — attribute is absent (or empty).
|
||||
expect(a?.getAttribute("href")).toBeFalsy();
|
||||
// The link text is still rendered.
|
||||
expect(a?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("x", [
|
||||
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("neutralizes a data: link href", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("x", [
|
||||
{
|
||||
type: "link",
|
||||
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("mail", [
|
||||
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||
"mailto:a@b.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||
"/some/path",
|
||||
);
|
||||
});
|
||||
|
||||
it("nests multiple marks on one text node", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||
);
|
||||
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||
expect(container.querySelector("strong")).not.toBeNull();
|
||||
expect(container.querySelector("em")).not.toBeNull();
|
||||
expect(screen.getByText("x")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders hardBreak as <br/>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||
);
|
||||
expect(container.querySelector("br")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a user mention as a styled span", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(screen.getByText("@Alice")).toBeDefined();
|
||||
// No fallback to the editor.
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a page mention as a link", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
label: "Some Page",
|
||||
entityType: "page",
|
||||
slugId: "pg1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")).not.toBeNull();
|
||||
expect(screen.getByText("Some Page")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||
renderView("just a legacy string");
|
||||
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to CommentEditor for an unknown node type", () => {
|
||||
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to CommentEditor for malformed JSON", () => {
|
||||
renderView('{"type":"doc","content":[');
|
||||
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
|
||||
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||
//
|
||||
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||
// so we never show a half-rendered comment.
|
||||
|
||||
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||
class UnknownNodeError extends Error {}
|
||||
|
||||
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||
// renderer must apply the SAME sanitization because the backend stores comment
|
||||
// content verbatim and React does not neutralize javascript: in an href.
|
||||
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||
|
||||
function safeHref(href: unknown): string | undefined {
|
||||
if (typeof href !== "string") return undefined;
|
||||
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||
// (e.g. "java\tscript:").
|
||||
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||
}
|
||||
|
||||
interface PMMark {
|
||||
type: string;
|
||||
attrs?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PMNode {
|
||||
type: string;
|
||||
attrs?: Record<string, any>;
|
||||
content?: PMNode[];
|
||||
text?: string;
|
||||
marks?: PMMark[];
|
||||
}
|
||||
|
||||
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||
function renderMarks(
|
||||
text: React.ReactNode,
|
||||
marks: PMMark[] | undefined,
|
||||
keyPrefix: string,
|
||||
): React.ReactNode {
|
||||
if (!marks || marks.length === 0) return text;
|
||||
|
||||
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||
const key = `${keyPrefix}-m${i}`;
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
return <strong key={key}>{acc}</strong>;
|
||||
case "italic":
|
||||
return <em key={key}>{acc}</em>;
|
||||
case "strike":
|
||||
return <s key={key}>{acc}</s>;
|
||||
case "underline":
|
||||
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||
// CommentEditor does not disable it, so real comments can carry this
|
||||
// mark. Render it here rather than degrading the whole comment.
|
||||
return <u key={key}>{acc}</u>;
|
||||
case "code":
|
||||
return <code key={key}>{acc}</code>;
|
||||
case "link": {
|
||||
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||
// safe rel semantics the editor produces. Sanitize the href against the
|
||||
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||
// its text, matching how extension-link blanks a bad href.
|
||||
const href = safeHref(mark.attrs?.href);
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{acc}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||
}
|
||||
}, text);
|
||||
}
|
||||
|
||||
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||
case "text":
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{renderMarks(node.text ?? "", node.marks, key)}
|
||||
</React.Fragment>
|
||||
);
|
||||
case "hardBreak":
|
||||
return <br key={key} />;
|
||||
case "mention":
|
||||
return (
|
||||
<span key={key} style={{ display: "inline" }}>
|
||||
<MentionContent attrs={node.attrs as any} />
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildren(
|
||||
content: PMNode[] | undefined,
|
||||
keyPrefix: string,
|
||||
): React.ReactNode {
|
||||
if (!content) return null;
|
||||
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||
}
|
||||
|
||||
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||
// scoped CSS in comment.module.css (which targets
|
||||
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={classes.commentEditor}>
|
||||
<div className={classes.ProseMirror}>
|
||||
<div className="ProseMirror">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentContentViewProps {
|
||||
content: string | object;
|
||||
}
|
||||
|
||||
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||
// Degrade this single comment to the old editor-based render (safety valve).
|
||||
const fallback = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
"CommentContentView: unsupported comment content, falling back to editor",
|
||||
);
|
||||
}
|
||||
return <CommentEditor defaultContent={content} editable={false} />;
|
||||
};
|
||||
|
||||
let doc: unknown = content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
} catch {
|
||||
const trimmed = content.trim();
|
||||
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return fallback();
|
||||
}
|
||||
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||
return (
|
||||
<Shell>
|
||||
<p>{content}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||
if (typeof doc === "string") {
|
||||
return (
|
||||
<Shell>
|
||||
<p>{doc}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pmDoc = doc as PMNode;
|
||||
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||
}
|
||||
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||
} catch (err) {
|
||||
if (err instanceof UnknownNodeError) {
|
||||
return fallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentContentView;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
@@ -8,23 +8,74 @@ import { IComment } from "@/features/comment/types/comment.types";
|
||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||
const applyMutateAsync = vi.fn();
|
||||
const dismissMutateAsync = vi.fn();
|
||||
const updateMutateAsync = vi.fn();
|
||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||
useApplySuggestionMutation: () => ({
|
||||
mutateAsync: applyMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDismissSuggestionMutation: () => ({
|
||||
mutateAsync: dismissMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||
const EDITED_DOC = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||
],
|
||||
};
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||
// so the edit->save/cancel flow can be driven without a live editor.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||
],
|
||||
};
|
||||
return {
|
||||
default: ({ onUpdate, onSave }: any) => (
|
||||
<div data-testid="comment-editor">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="editor-emit-update"
|
||||
onClick={() => onUpdate?.(doc)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="editor-emit-save"
|
||||
onClick={() => onSave?.()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CommentContentView (used for the read-only body) imports the mention view,
|
||||
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||
// renders in isolation without the app entry side-effect.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import CommentListItem from "./comment-list-item";
|
||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
||||
import {
|
||||
canShowApply,
|
||||
canShowDismiss,
|
||||
} from "@/features/comment/utils/suggestion";
|
||||
|
||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
@@ -38,14 +89,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
function renderItem(comment: IComment, canEdit = true) {
|
||||
function renderItem(
|
||||
comment: IComment,
|
||||
canEdit = true,
|
||||
canComment = true,
|
||||
userSpaceRole?: string,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId="page-1"
|
||||
canComment={true}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -108,10 +165,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
|
||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||
renderItem(suggestion(), true);
|
||||
// Old text appears both as the selection quote and as the struck diff row.
|
||||
const { container } = renderItem(suggestion(), true);
|
||||
// Old text appears as the selection quote (a single unsplit Text node).
|
||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||
expect(screen.getByText("new wording here")).toBeDefined();
|
||||
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||
// so it is no longer a single text node — assert the concatenated content.
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// Apply button is present.
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
// No Applied badge yet.
|
||||
@@ -119,9 +178,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
|
||||
it("hides the Apply button when canEdit is false", () => {
|
||||
renderItem(suggestion(), false);
|
||||
// Diff still renders...
|
||||
expect(screen.getByText("new wording here")).toBeDefined();
|
||||
const { container } = renderItem(suggestion(), false);
|
||||
// Diff still renders (as per-fragment spans, #331)...
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// ...but no Apply button.
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
@@ -157,6 +216,65 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||
baseComment({
|
||||
selection: "old wording here",
|
||||
suggestedText: "new wording here",
|
||||
...over,
|
||||
});
|
||||
|
||||
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||
// regardless of who authored the comment; the tests below use it as the lever
|
||||
// since the currentUser atom is unseeded (null) in this harness.
|
||||
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||
renderItem(suggestion(), true, true, "admin");
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||
renderItem(suggestion(), false, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("hides Dismiss when the viewer cannot comment", () => {
|
||||
renderItem(suggestion(), false, false, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||
// currentUser atom is null while the comment is authored by user-1), so the
|
||||
// server would 403 a dismiss — the button must not be shown at all.
|
||||
renderItem(suggestion(), false, true, "member");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss once the thread is resolved", () => {
|
||||
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||
expect(screen.getByText("Applied")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||
dismissMutateAsync.mockClear();
|
||||
renderItem(suggestion(), true, true, "admin");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
pageId: "page-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("canShowApply predicate", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
@@ -182,3 +300,161 @@ describe("canShowApply predicate", () => {
|
||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canShowDismiss predicate", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
|
||||
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||
});
|
||||
it("false without comment permission", () => {
|
||||
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||
});
|
||||
it("false when not owner and not admin (#338 F5)", () => {
|
||||
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||
});
|
||||
it("false when no suggestion", () => {
|
||||
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||
});
|
||||
it("false when already applied", () => {
|
||||
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("false when resolved", () => {
|
||||
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||
});
|
||||
it("false for a reply comment", () => {
|
||||
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||
const body = (t: string) =>
|
||||
JSON.stringify({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||
});
|
||||
|
||||
// The edit menu item is gated on the viewer owning the comment
|
||||
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||
beforeEach(() => {
|
||||
updateMutateAsync.mockClear();
|
||||
localStorage.setItem(
|
||||
"currentUser",
|
||||
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function openEditor() {
|
||||
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||
fireEvent.click(await screen.findByText("Edit comment"));
|
||||
// Edit form (mocked editor + actions) is now mounted.
|
||||
await screen.findByTestId("comment-editor");
|
||||
}
|
||||
|
||||
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||
const { rerender } = renderItem(
|
||||
baseComment({ content: body("original body") }),
|
||||
);
|
||||
// Static body first.
|
||||
expect(screen.getByText("original body")).toBeDefined();
|
||||
|
||||
await openEditor();
|
||||
|
||||
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// mutateAsync is called with the stringified edited doc.
|
||||
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
content: JSON.stringify(EDITED_DOC),
|
||||
});
|
||||
|
||||
// On success the form closes (isEditing -> false); the static body renders
|
||||
// from the comment.content prop again.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
|
||||
// Simulate the cache invalidation swapping in a new comment object with the
|
||||
// updated content — the static body reflects it.
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<CommentListItem
|
||||
comment={baseComment({ content: body("updated body after save") })}
|
||||
pageId="page-1"
|
||||
canComment={true}
|
||||
canEdit={true}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||
expect(screen.queryByText("original body")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||
renderItem(baseComment({ content: body("original body") }));
|
||||
await openEditor();
|
||||
|
||||
// Type something (editContentRef set), then cancel.
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// Editor unmounts, static body restored, no save happened.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("original body")).toBeDefined();
|
||||
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||
renderItem(baseComment({ content: body("original body") }));
|
||||
|
||||
// Cancel path clears editContentRef...
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
|
||||
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
content: JSON.stringify(body("original body")),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — read-only body renders statically", () => {
|
||||
it("renders the comment body as static text without a TipTap editor", () => {
|
||||
renderItem(
|
||||
baseComment({
|
||||
content: JSON.stringify({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Hello static world" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Body text is present...
|
||||
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
@@ -13,11 +14,16 @@ import { useHover } from "@mantine/hooks";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDeleteCommentMutation,
|
||||
useDismissSuggestionMutation,
|
||||
useResolveCommentMutation,
|
||||
useUpdateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
||||
import {
|
||||
canShowApply,
|
||||
canShowDismiss,
|
||||
computeSuggestionDiff,
|
||||
} from "@/features/comment/utils/suggestion";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,31 +51,43 @@ function CommentListItem({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const editContentRef = useRef<any>(null);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const applySuggestionMutation = useApplySuggestionMutation();
|
||||
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content);
|
||||
}, [comment]);
|
||||
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||
// fragments that actually changed get emphasised inside the red/green block,
|
||||
// instead of striking through / greening the whole line. Memoised on the
|
||||
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||
const suggestionDiff = useMemo(
|
||||
() =>
|
||||
comment.suggestedText != null
|
||||
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||
: null,
|
||||
[comment.selection, comment.suggestedText],
|
||||
);
|
||||
|
||||
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||
// render an action the server will 403.
|
||||
const isOwnerOrAdmin =
|
||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentToUpdate = {
|
||||
commentId: comment.id,
|
||||
content: JSON.stringify(editContentRef.current ?? content),
|
||||
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
if (editContentRef.current) {
|
||||
setContent(editContentRef.current);
|
||||
editContentRef.current = null;
|
||||
}
|
||||
editContentRef.current = null;
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
@@ -115,6 +133,19 @@ function CommentListItem({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDismissSuggestion() {
|
||||
try {
|
||||
await dismissSuggestionMutation.mutateAsync({
|
||||
commentId: comment.id,
|
||||
pageId: comment.pageId,
|
||||
});
|
||||
} catch (error) {
|
||||
// Idempotent races are reconciled to success in the mutation's onError;
|
||||
// anything else surfaces there as a notification.
|
||||
console.error("Failed to dismiss suggestion:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(comment: IComment) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
@@ -190,7 +221,7 @@ function CommentListItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||
{isOwnerOrAdmin && (
|
||||
<CommentMenu
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
@@ -236,12 +267,28 @@ function CommentListItem({
|
||||
{!comment.parentCommentId && comment.suggestedText && (
|
||||
<Box className={classes.suggestionBlock}>
|
||||
{comment.selection && (
|
||||
// Old line: read as removed as a whole (line-through/red); only the
|
||||
// changed fragments carry the extra intraline emphasis.
|
||||
<Text size="xs" className={classes.suggestionOld}>
|
||||
{comment.selection}
|
||||
{suggestionDiff?.old.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" className={classes.suggestionNew}>
|
||||
{comment.suggestedText}
|
||||
{suggestionDiff?.new.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
|
||||
{comment.suggestionAppliedAt ? (
|
||||
@@ -255,29 +302,53 @@ function CommentListItem({
|
||||
{t("Applied")}
|
||||
</Badge>
|
||||
) : (
|
||||
canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
mt={6}
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={applySuggestionMutation.isPending}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
(canShowApply(comment, canEdit) ||
|
||||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||
<Group gap="xs" mt={6}>
|
||||
{canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
)}
|
||||
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||
without changing the page text. Gated on canComment. */}
|
||||
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleDismissSuggestion}
|
||||
loading={dismissSuggestionMutation.isPending}
|
||||
disabled={
|
||||
applySuggestionMutation.isPending ||
|
||||
dismissSuggestionMutation.isPending
|
||||
}
|
||||
>
|
||||
{t("Dismiss")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!isEditing ? (
|
||||
<CommentEditor defaultContent={content} editable={false} />
|
||||
<CommentContentView content={comment.content} />
|
||||
) : (
|
||||
<>
|
||||
<CommentEditor
|
||||
defaultContent={content}
|
||||
defaultContent={comment.content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||
onSave={handleUpdateComment}
|
||||
@@ -297,4 +368,6 @@ function CommentListItem({
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentListItem;
|
||||
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||
export default React.memo(CommentListItem);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||
// the lazy reply editor's mount transition can be observed without the editor.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
}));
|
||||
|
||||
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||
// pulled in transitively so importing the module is side-effect free.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildChildrenByParent,
|
||||
CommentEditorWithActions,
|
||||
} from "./comment-list-with-tabs";
|
||||
|
||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||
({ id, parentCommentId }) as IComment;
|
||||
|
||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
it("returns an empty map for undefined or empty input", () => {
|
||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||
expect(buildChildrenByParent([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||
const map = buildChildrenByParent([c("p1", null)]);
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.has("p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||
const p1 = c("p1", null);
|
||||
const r1 = c("r1", "p1");
|
||||
const r2 = c("r2", "r1"); // a reply to a reply
|
||||
const map = buildChildrenByParent([p1, r1, r2]);
|
||||
expect(map.get("p1")).toEqual([r1]);
|
||||
expect(map.get("r1")).toEqual([r2]);
|
||||
// The top-level comment itself is never a key.
|
||||
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("still groups a reply whose parent is not present in items", () => {
|
||||
const orphan = c("o1", "missing-parent");
|
||||
const map = buildChildrenByParent([orphan]);
|
||||
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||
});
|
||||
|
||||
it("preserves insertion order among sibling replies", () => {
|
||||
const map = buildChildrenByParent([
|
||||
c("a", "p1"),
|
||||
c("b", "p1"),
|
||||
c("d", "p1"),
|
||||
]);
|
||||
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderReplyEditor() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||
renderReplyEditor();
|
||||
expect(screen.getByRole("button")).toBeDefined();
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
// The stub button is replaced by the editor subtree.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the editor when the stub receives focus", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.focus(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mounts the editor on Enter keydown of the stub", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||
// Exported for unit testing.
|
||||
export function buildChildrenByParent(
|
||||
items: IComment[] | undefined,
|
||||
): Map<string, IComment[]> {
|
||||
const m = new Map<string, IComment[]>();
|
||||
for (const c of items ?? []) {
|
||||
if (c.parentCommentId) {
|
||||
const arr = m.get(c.parentCommentId);
|
||||
if (arr) arr.push(c);
|
||||
else m.set(c.parentCommentId, [c]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||
// mutation object) so the reply/comment callbacks stay stable.
|
||||
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
}, [comments]);
|
||||
|
||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||
const childrenByParent = useMemo(
|
||||
() => buildChildrenByParent(comments?.items),
|
||||
[comments?.items],
|
||||
);
|
||||
|
||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||
|
||||
const handleAddPageComment = useCallback(
|
||||
async (_commentId: string, content: string) => {
|
||||
try {
|
||||
setIsPageCommentLoading(true);
|
||||
const createdComment = await createCommentMutation.mutateAsync({
|
||||
const createdComment = await createCommentAsync({
|
||||
pageId: page?.id,
|
||||
content: JSON.stringify(content),
|
||||
});
|
||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
setIsPageCommentLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||
// does not churn renderComments and re-render the whole list.
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
await createCommentAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[
|
||||
comments,
|
||||
childrenByParent,
|
||||
handleAddReply,
|
||||
isLoading,
|
||||
page?.id,
|
||||
space?.membership?.role,
|
||||
canComment,
|
||||
canEdit,
|
||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
<Tabs
|
||||
defaultValue="open"
|
||||
variant="default"
|
||||
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||
// unmounted while Open is shown). The Open panel overrides this with its
|
||||
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||
// Open -> Resolved -> Open switch.
|
||||
keepMounted={false}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
display: "flex",
|
||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "8px" }}>
|
||||
<Tabs.Panel value="open" pt="xs">
|
||||
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||
active, so a lazily-mounted reply editor's draft (and an
|
||||
in-progress edit) is not discarded on tab switch. */}
|
||||
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||
{activeComments.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
}
|
||||
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
childrenByParent: Map<string, IComment[]>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
const ChildComments = ({
|
||||
comments,
|
||||
childrenByParent,
|
||||
parentId,
|
||||
pageId,
|
||||
canComment,
|
||||
canEdit,
|
||||
userSpaceRole,
|
||||
}: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
),
|
||||
[comments.items],
|
||||
);
|
||||
const children = childrenByParent.get(parentId) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
{children.map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem
|
||||
comment={childComment}
|
||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({
|
||||
export const CommentEditorWithActions = ({
|
||||
commentId,
|
||||
onSave,
|
||||
isLoading,
|
||||
placeholder = undefined,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||
// mounted so the draft is preserved.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
const activate = useCallback(() => setMounted(true), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
setIsSending(true);
|
||||
await onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={activate}
|
||||
onFocus={activate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: "6px",
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: 1.4,
|
||||
color: "var(--mantine-color-placeholder)",
|
||||
cursor: "text",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}}
|
||||
>
|
||||
{placeholder || t("Reply...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor
|
||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={placeholder}
|
||||
autofocus={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,6 +53,21 @@
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* Intraline diff (#331): the fragment that actually changed within the
|
||||
red "before" / green "after" block. It inherits the surrounding red/green
|
||||
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||
container's line-through (old) / green (new) still marks the full line. */
|
||||
.suggestionChanged {
|
||||
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||
within the block. `currentColor` follows the parent's red (old) or green
|
||||
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||
inherited line-through must survive on the changed letters too. */
|
||||
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||
border-radius: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentEditor {
|
||||
|
||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider,
|
||||
InfiniteData,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||
*/
|
||||
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||
applySuggestion: vi.fn(),
|
||||
dismissSuggestion: vi.fn(),
|
||||
createComment: vi.fn(),
|
||||
updateComment: vi.fn(),
|
||||
deleteComment: vi.fn(),
|
||||
resolveComment: vi.fn(),
|
||||
getPageComments: vi.fn(),
|
||||
}));
|
||||
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
applySuggestion,
|
||||
dismissSuggestion,
|
||||
} from "@/features/comment/services/comment-service";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDismissSuggestionMutation,
|
||||
RQ_KEY,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
const PAGE_ID = "page-1";
|
||||
|
||||
function seededClient(comment: IComment) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { mutations: { retry: false } },
|
||||
});
|
||||
const seed: InfiniteData<any> = {
|
||||
pageParams: [undefined],
|
||||
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||
};
|
||||
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
return { queryClient, wrapper };
|
||||
}
|
||||
|
||||
function items(queryClient: QueryClient): IComment[] {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||
| InfiniteData<any>
|
||||
| undefined;
|
||||
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||
}
|
||||
|
||||
const comment = (over?: Partial<IComment>): IComment =>
|
||||
({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
content: "{}",
|
||||
creatorId: "u-1",
|
||||
workspaceId: "ws-1",
|
||||
createdAt: new Date(),
|
||||
suggestedText: "new",
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("outcome=deleted → removes the comment from the list", async () => {
|
||||
vi.mocked(applySuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "deleted",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||
const resolvedAt = new Date();
|
||||
vi.mocked(applySuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "resolved",
|
||||
resolvedAt,
|
||||
resolvedById: "u-1",
|
||||
resolvedBy: { id: "u-1", name: "A" },
|
||||
suggestionAppliedAt: resolvedAt,
|
||||
suggestionAppliedById: "u-1",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const list = items(queryClient);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("outcome=deleted → removes the comment from the list", async () => {
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "deleted",
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||
const resolvedAt = new Date();
|
||||
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||
id: "c-1",
|
||||
pageId: PAGE_ID,
|
||||
outcome: "resolved",
|
||||
resolvedAt,
|
||||
resolvedById: "u-1",
|
||||
resolvedBy: { id: "u-1", name: "A" },
|
||||
} as any);
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
|
||||
const list = items(queryClient);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||
});
|
||||
|
||||
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||
// silently drop the comment.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Suggestion dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||
// and keep the comment in the cache.
|
||||
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||
response: { status: 400 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// Comment NOT dropped from the cache.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
// A real (red) error, never the success message.
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ color: "red" }),
|
||||
);
|
||||
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||
message: "Suggestion dismissed",
|
||||
});
|
||||
});
|
||||
|
||||
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||
// error (restores the #315 apply idempotency).
|
||||
vi.mocked(applySuggestion).mockRejectedValue({
|
||||
response: { status: 404 },
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
expect(items(queryClient)).toHaveLength(0);
|
||||
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||
expect(notifications.show).toHaveBeenCalledWith({
|
||||
message: "Suggestion applied",
|
||||
});
|
||||
});
|
||||
|
||||
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||
// real error surfacing the server message, and must NOT drop the live thread.
|
||||
vi.mocked(applySuggestion).mockRejectedValue({
|
||||
response: {
|
||||
status: 400,
|
||||
data: {
|
||||
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||
},
|
||||
},
|
||||
});
|
||||
const { queryClient, wrapper } = seededClient(comment());
|
||||
|
||||
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||
wrapper,
|
||||
});
|
||||
await result.current
|
||||
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||
.catch(() => undefined);
|
||||
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||
|
||||
// The live thread is NOT dropped from the cache.
|
||||
expect(items(queryClient)).toHaveLength(1);
|
||||
// Surfaces the server's specific message as a red error, never a success.
|
||||
expect(notifications.show).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||
color: "red",
|
||||
}),
|
||||
);
|
||||
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||
message: "Suggestion applied",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
applySuggestion,
|
||||
createComment,
|
||||
deleteComment,
|
||||
dismissSuggestion,
|
||||
getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
@@ -51,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: query.isLoading || query.hasNextPage,
|
||||
// Paint the first page as soon as it arrives instead of blocking until every
|
||||
// page has loaded; the background effect above keeps streaming the rest
|
||||
// (tab counts grow as pages arrive).
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
@@ -177,40 +182,121 @@ function updateCommentInCache(
|
||||
};
|
||||
}
|
||||
|
||||
function removeCommentFromCache(
|
||||
cache: InfiniteData<IPagination<IComment>>,
|
||||
commentId: string,
|
||||
): InfiniteData<IPagination<IComment>> {
|
||||
return {
|
||||
...cache,
|
||||
pages: cache.pages.map((page) => ({
|
||||
...page,
|
||||
items: page.items.filter((comment) => comment.id !== commentId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||
// state through (which relocates it to the resolved tab).
|
||||
function applySuggestionOutcomeToCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
pageId: string,
|
||||
commentId: string,
|
||||
data: ISuggestionOutcome,
|
||||
) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (!cache) return;
|
||||
|
||||
if (data.outcome === "deleted") {
|
||||
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||
return;
|
||||
}
|
||||
|
||||
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(pageId),
|
||||
updateCommentInCache(cache, commentId, (comment) => ({
|
||||
...comment,
|
||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||
suggestionAppliedById: data.suggestionAppliedById,
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
export function useApplySuggestionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
||||
return useMutation<
|
||||
ISuggestionOutcome,
|
||||
any,
|
||||
{ commentId: string; pageId: string }
|
||||
>({
|
||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||
// so we only mutate the cache once the server confirms.
|
||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
||||
...comment,
|
||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||
suggestionAppliedById: data.suggestionAppliedById,
|
||||
// The server auto-resolves the thread on apply — carry that through.
|
||||
resolvedAt: data.resolvedAt,
|
||||
resolvedById: data.resolvedById,
|
||||
resolvedBy: data.resolvedBy,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||
applySuggestionOutcomeToCache(
|
||||
queryClient,
|
||||
variables.pageId,
|
||||
variables.commentId,
|
||||
data,
|
||||
);
|
||||
|
||||
notifications.show({ message: t("Suggestion applied") });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
onError: (err: any, variables) => {
|
||||
const status = err?.response?.status;
|
||||
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||
// and report success, the user's intent is already satisfied (restores the
|
||||
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||
//
|
||||
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||
// applied" is a false success that also drops a live thread from the cache.
|
||||
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||
if (status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
notifications.show({ message: t("Suggestion applied") });
|
||||
return;
|
||||
}
|
||||
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||
// the server's specific message when it carries one.
|
||||
if (status === 400) {
|
||||
const serverMsg = err?.response?.data?.message;
|
||||
notifications.show({
|
||||
message:
|
||||
typeof serverMsg === "string" && serverMsg.length > 0
|
||||
? serverMsg
|
||||
: t("Failed to apply suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 409 => the commented text changed since the suggestion was made. Surface
|
||||
// a specific message (with the current text) rather than a generic error.
|
||||
const status = err?.response?.status;
|
||||
const currentText = err?.response?.data?.currentText;
|
||||
if (status === 409 && typeof currentText === "string") {
|
||||
const shortText =
|
||||
@@ -234,6 +320,58 @@ export function useApplySuggestionMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useDismissSuggestionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<
|
||||
ISuggestionOutcome,
|
||||
any,
|
||||
{ commentId: string; pageId: string }
|
||||
>({
|
||||
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||
applySuggestionOutcomeToCache(
|
||||
queryClient,
|
||||
variables.pageId,
|
||||
variables.commentId,
|
||||
data,
|
||||
);
|
||||
|
||||
notifications.show({ message: t("Suggestion dismissed") });
|
||||
},
|
||||
onError: (err: any, variables) => {
|
||||
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||
// report success, the user's intent (make it disappear) is satisfied.
|
||||
//
|
||||
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||
// a live thread from the cache. Show a real error and keep the comment.
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||
| InfiniteData<IPagination<IComment>>
|
||||
| undefined;
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
removeCommentFromCache(cache, variables.commentId),
|
||||
);
|
||||
}
|
||||
notifications.show({ message: t("Suggestion dismissed") });
|
||||
return;
|
||||
}
|
||||
notifications.show({
|
||||
message: t("Failed to dismiss suggestion"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolveCommentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function applySuggestion(commentId: string): Promise<IComment> {
|
||||
export async function applySuggestion(
|
||||
commentId: string,
|
||||
): Promise<ISuggestionOutcome> {
|
||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||
return req.data.data ?? req.data;
|
||||
}
|
||||
|
||||
export async function dismissSuggestion(
|
||||
commentId: string,
|
||||
): Promise<ISuggestionOutcome> {
|
||||
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||
return req.data.data ?? req.data;
|
||||
}
|
||||
|
||||
export async function updateComment(
|
||||
data: Partial<IComment>,
|
||||
): Promise<IComment> {
|
||||
|
||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||
// take (drop the comment vs. move it to the resolved tab).
|
||||
export type ISuggestionOutcome = IComment & {
|
||||
outcome?: "deleted" | "resolved";
|
||||
};
|
||||
|
||||
export interface ICommentParams extends QueryParams {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||
|
||||
// Reconstruct the plain string from a segment stream — the diff must be
|
||||
// lossless (concatenating every fragment yields the original input).
|
||||
const join = (segments: Segment[]): string =>
|
||||
segments.map((s) => s.text).join("");
|
||||
|
||||
// The subset of segments (in order) that the UI would emphasise.
|
||||
const changed = (segments: Segment[]): string[] =>
|
||||
segments.filter((s) => s.changed).map((s) => s.text);
|
||||
|
||||
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||
segments.find((s) => s.text.includes(needle));
|
||||
|
||||
describe("computeSuggestionDiff", () => {
|
||||
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||
|
||||
// Lossless.
|
||||
expect(join(old)).toBe("заведем");
|
||||
expect(join(neu)).toBe("заведём");
|
||||
|
||||
// Old side: exactly the `е` is changed, the rest is common.
|
||||
expect(changed(old)).toEqual(["е"]);
|
||||
expect(old).toEqual([
|
||||
{ text: "завед", changed: false },
|
||||
{ text: "е", changed: true },
|
||||
{ text: "м", changed: false },
|
||||
]);
|
||||
|
||||
// New side: exactly the `ё` is changed.
|
||||
expect(changed(neu)).toEqual(["ё"]);
|
||||
expect(neu).toEqual([
|
||||
{ text: "завед", changed: false },
|
||||
{ text: "ё", changed: true },
|
||||
{ text: "м", changed: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks the differing words changed but keeps the shared word common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff(
|
||||
"привет мир",
|
||||
"здравствуй мир",
|
||||
);
|
||||
|
||||
// Lossless.
|
||||
expect(join(old)).toBe("привет мир");
|
||||
expect(join(neu)).toBe("здравствуй мир");
|
||||
|
||||
// The shared trailing word stays common on both sides (no per-letter noise
|
||||
// leaking across the differing words into `мир`).
|
||||
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||
|
||||
// The differing words are emphasised somewhere on each side.
|
||||
expect(changed(old).length).toBeGreaterThan(0);
|
||||
expect(changed(neu).length).toBeGreaterThan(0);
|
||||
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||
|
||||
// No changed fragment on either side touches the word `мир`.
|
||||
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||
});
|
||||
|
||||
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||
|
||||
expect(join(old)).toBe("a c");
|
||||
expect(join(neu)).toBe("a b c");
|
||||
|
||||
// Old line has no changed fragment (nothing was removed).
|
||||
expect(changed(old)).toEqual([]);
|
||||
// The inserted word is the only changed fragment on the new side.
|
||||
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||
expect(changed(neu)).toEqual(["b "]);
|
||||
});
|
||||
|
||||
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||
|
||||
expect(join(old)).toBe("a b c");
|
||||
expect(join(neu)).toBe("a c");
|
||||
|
||||
// The deleted word is the only changed fragment on the old side.
|
||||
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||
expect(changed(old)).toEqual(["b "]);
|
||||
// New line has no changed fragment (nothing was added).
|
||||
expect(changed(neu)).toEqual([]);
|
||||
});
|
||||
|
||||
it("marks everything common for identical strings", () => {
|
||||
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||
|
||||
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||
expect(changed(old)).toEqual([]);
|
||||
expect(changed(neu)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { diffWordsWithSpace, diffChars } from "diff";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||
@@ -12,3 +13,127 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
||||
!comment.parentCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||
// `changed` marks the fragment that actually differs from the other side, so
|
||||
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||
// the whole line.
|
||||
export interface Segment {
|
||||
text: string;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||
// common vs. removed-and-changed fragments, and the new line split into common
|
||||
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||
// the original strings.
|
||||
export interface SuggestionDiff {
|
||||
old: Segment[];
|
||||
new: Segment[];
|
||||
}
|
||||
|
||||
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||
// so the render emits as few spans as possible and tests stay predictable.
|
||||
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||
if (text === "") return;
|
||||
const last = segments[segments.length - 1];
|
||||
if (last && last.changed === changed) {
|
||||
last.text += text;
|
||||
} else {
|
||||
segments.push({ text, changed });
|
||||
}
|
||||
}
|
||||
|
||||
// Compute an intraline diff between the old `selection` and the new
|
||||
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||
//
|
||||
// Hybrid word + char algorithm (per #331):
|
||||
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||
// `diffChars`: shared characters stay common, differing characters are
|
||||
// marked `changed` on their respective side. This is what keeps a
|
||||
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||
// fragment `changed`.
|
||||
// 4. An unchanged part is `common` on both sides.
|
||||
//
|
||||
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||
export function computeSuggestionDiff(
|
||||
oldStr: string,
|
||||
newStr: string,
|
||||
): SuggestionDiff {
|
||||
const oldSegments: Segment[] = [];
|
||||
const newSegments: Segment[] = [];
|
||||
|
||||
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const next = parts[i + 1];
|
||||
|
||||
// A word replacement: a removed part immediately followed by an added part
|
||||
// (or the reverse). Refine it character-by-character so only the differing
|
||||
// letters are highlighted while shared letters stay common.
|
||||
const isReplacementPair =
|
||||
next &&
|
||||
((part.removed && next.added) || (part.added && next.removed));
|
||||
|
||||
if (isReplacementPair) {
|
||||
const removedPart = part.removed ? part : next;
|
||||
const addedPart = part.added ? part : next;
|
||||
|
||||
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||
for (const cp of charParts) {
|
||||
if (cp.added) {
|
||||
pushSegment(newSegments, cp.value, true);
|
||||
} else if (cp.removed) {
|
||||
pushSegment(oldSegments, cp.value, true);
|
||||
} else {
|
||||
// Shared character: common on both sides.
|
||||
pushSegment(oldSegments, cp.value, false);
|
||||
pushSegment(newSegments, cp.value, false);
|
||||
}
|
||||
}
|
||||
|
||||
i++; // consume the paired part as well
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.added) {
|
||||
// Lone insertion: only present in the new line, wholly changed.
|
||||
pushSegment(newSegments, part.value, true);
|
||||
} else if (part.removed) {
|
||||
// Lone deletion: only present in the old line, wholly changed.
|
||||
pushSegment(oldSegments, part.value, true);
|
||||
} else {
|
||||
// Unchanged: common on both sides.
|
||||
pushSegment(oldSegments, part.value, false);
|
||||
pushSegment(newSegments, part.value, false);
|
||||
}
|
||||
}
|
||||
|
||||
return { old: oldSegments, new: newSegments };
|
||||
}
|
||||
|
||||
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||
export function canShowDismiss(
|
||||
comment: IComment,
|
||||
canComment?: boolean,
|
||||
isOwnerOrAdmin?: boolean,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
canComment &&
|
||||
isOwnerOrAdmin &&
|
||||
comment.suggestedText &&
|
||||
!comment.suggestionAppliedAt &&
|
||||
!comment.resolvedAt &&
|
||||
!comment.parentCommentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,19 @@ import {
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
interface MentionAttrs {
|
||||
label?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
slugId?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||
const { label, entityType, slugId, anchorId } = attrs;
|
||||
const isPageMention = entityType === "page";
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<>
|
||||
{entityType === "user" && (
|
||||
<Text className={classes.userMention} component="span">
|
||||
@{label}
|
||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</span>
|
||||
</Anchor>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<MentionContent attrs={props.node.attrs} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
GitmostListPagesResult,
|
||||
GitmostListSpacesResult,
|
||||
gitmostDecodePayloadToFile,
|
||||
gitmostInsertTranscriptIntoEditor,
|
||||
gitmostUploadFileToEditor,
|
||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||
|
||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
||||
pageId: page.id,
|
||||
};
|
||||
}
|
||||
|
||||
// Best-effort: append the transcript (heading + one paragraph per line)
|
||||
// below the just-inserted audio node. The audio insert already
|
||||
// succeeded, so a transcript failure must NOT turn this into an error —
|
||||
// wrap it and, on any throw, log and still return ok. A missing/empty/
|
||||
// non-string transcript is a no-op inside the helper (audio only).
|
||||
try {
|
||||
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
|
||||
} catch (err) {
|
||||
console.error("[gitmost] transcript insert failed", err);
|
||||
}
|
||||
|
||||
return { ok: true, pageId: page.id };
|
||||
} catch (err: any) {
|
||||
console.error("[gitmost] createPageWithRecording failed", err);
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { Heading } from "@tiptap/extension-heading";
|
||||
import { Bold } from "@tiptap/extension-bold";
|
||||
import { Italic } from "@tiptap/extension-italic";
|
||||
import { Link } from "@tiptap/extension-link";
|
||||
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||
|
||||
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||
|
||||
/**
|
||||
* #377 — the web-side bridge must append the native host's transcript below the
|
||||
* recording. These exercise the pure insert helper through a REAL Tiptap editor
|
||||
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
|
||||
* regression would be caught), asserting the resulting document rather than
|
||||
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||
* paragraphs; absent/empty/non-string -> no-op.
|
||||
*/
|
||||
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||
const makeEditor = () =>
|
||||
new Editor({
|
||||
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||
// text assertions below would fail.
|
||||
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||
// Start from a single empty paragraph (a fresh page's baseline). The
|
||||
// helper appends at the end of the doc, i.e. below existing content.
|
||||
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||
});
|
||||
|
||||
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
|
||||
const editor = makeEditor();
|
||||
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||
editor,
|
||||
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
|
||||
);
|
||||
|
||||
expect(inserted).toBe(true);
|
||||
|
||||
const nodes = (editor.getJSON().content ?? []) as any[];
|
||||
// A level-2 "Transcript" heading is present.
|
||||
const heading = nodes.find((n) => n.type === "heading");
|
||||
expect(heading?.attrs?.level).toBe(2);
|
||||
expect(heading?.content?.[0]?.text).toBe("Transcript");
|
||||
|
||||
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
|
||||
// the blank line between them is dropped.
|
||||
const texts = nodes
|
||||
.filter((n) => n.type === "paragraph")
|
||||
.map((n) => n.content?.[0]?.text)
|
||||
.filter((t) => typeof t === "string");
|
||||
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||
const editor = makeEditor();
|
||||
const line =
|
||||
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||
expect(inserted).toBe(true);
|
||||
|
||||
const paras = (editor.getJSON().content ?? []).filter(
|
||||
(n: any) => n.type === "paragraph",
|
||||
) as any[];
|
||||
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||
const content = paras[paras.length - 1].content;
|
||||
expect(content).toHaveLength(1);
|
||||
expect(content[0].type).toBe("text");
|
||||
expect(content[0].marks ?? []).toEqual([]);
|
||||
expect(content[0].text).toBe(line);
|
||||
// And no bold/italic/link mark exists anywhere in the document.
|
||||
const html = editor.getHTML();
|
||||
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||
// The angle brackets survived as escaped entities (literal text), not a live
|
||||
// <script>/<b> element.
|
||||
expect(html).not.toMatch(/<script/i);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||
const editor = makeEditor();
|
||||
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||
editor,
|
||||
[
|
||||
"- dash",
|
||||
" > quote", // leading indent must be trimmed then neutralized
|
||||
"# hash",
|
||||
"1. one",
|
||||
"> [!info] note",
|
||||
"```js",
|
||||
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||
"***",
|
||||
"___",
|
||||
"You: normal line",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(inserted).toBe(true);
|
||||
|
||||
const texts = (editor.getJSON().content ?? [])
|
||||
.filter((n: any) => n.type === "paragraph")
|
||||
.map((n: any) => n.content?.[0]?.text)
|
||||
.filter((t: any) => typeof t === "string") as string[];
|
||||
|
||||
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||
// trimmed first); the normal `You:` line is left byte-exact.
|
||||
expect(texts).toEqual([
|
||||
ZWSP + "- dash",
|
||||
ZWSP + "> quote",
|
||||
ZWSP + "# hash",
|
||||
ZWSP + "1. one",
|
||||
ZWSP + "> [!info] note",
|
||||
ZWSP + "```js",
|
||||
ZWSP + "---",
|
||||
ZWSP + "***",
|
||||
ZWSP + "___",
|
||||
"You: normal line",
|
||||
]);
|
||||
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
|
||||
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
|
||||
const editor = makeEditor();
|
||||
const before = JSON.stringify(editor.getJSON());
|
||||
|
||||
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
|
||||
|
||||
expect(inserted).toBe(false);
|
||||
// Document is untouched (audio-only behavior preserved).
|
||||
expect(JSON.stringify(editor.getJSON())).toBe(before);
|
||||
editor.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
|
||||
base64: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
// Optional transcript for the recording: plain text, `\n`-separated, each
|
||||
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
|
||||
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
|
||||
// audio only.
|
||||
transcript?: string;
|
||||
}
|
||||
|
||||
export interface GitmostCreatePageResult {
|
||||
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
|
||||
}
|
||||
}
|
||||
|
||||
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||
const GITMOST_ZWSP = "";
|
||||
|
||||
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||
// and never matches, so prefixed lines are left byte-exact.
|
||||
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||
|
||||
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||
// line. The transcript is plain text, `\n`-separated, each line already
|
||||
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||
// serializer's missing block-escape). This is best-effort and meant to run
|
||||
// AFTER the audio has already been inserted; the caller must guard against a
|
||||
// throw so a transcript failure never fails the (already successful) recording.
|
||||
// Returns true when a block was inserted, false when there was nothing to
|
||||
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||
// no-op, not an error.
|
||||
export function gitmostInsertTranscriptIntoEditor(
|
||||
editor: Editor,
|
||||
transcript: unknown,
|
||||
): boolean {
|
||||
if (typeof transcript !== "string") return false;
|
||||
const lines = transcript
|
||||
.split("\n")
|
||||
// Trim each line and drop blank (whitespace-only) ones.
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||
// `Speaker N:`) never match and stay byte-exact.
|
||||
.map((line) =>
|
||||
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||
);
|
||||
if (lines.length === 0) return false;
|
||||
|
||||
const content = [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: "text", text: "Transcript" }],
|
||||
},
|
||||
...lines.map((line) => ({
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: line }],
|
||||
})),
|
||||
];
|
||||
|
||||
// Append at the end of the document. On a freshly-created recording page the
|
||||
// audio node is the last block, so the end position places the transcript
|
||||
// directly below it.
|
||||
const endPos = editor.state.doc.content.size;
|
||||
editor.chain().focus().insertContentAt(endPos, content).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||
// a result code.
|
||||
|
||||
@@ -93,6 +93,11 @@ import {
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
import {
|
||||
isVitalsActive,
|
||||
measurePageOpen,
|
||||
reportEditorTx,
|
||||
} from "@/lib/telemetry/vitals";
|
||||
|
||||
interface PageEditorProps {
|
||||
pageId: string;
|
||||
@@ -351,6 +356,40 @@ export default function PageEditor({
|
||||
editor.storage.pageId = pageId;
|
||||
handleScrollTo(editor);
|
||||
editorRef.current = editor;
|
||||
|
||||
// #355 — perf instrumentation. Skip ALL of it when telemetry is
|
||||
// disabled (F1 flag off) or this session isn't sampled: no page-open
|
||||
// measure, and crucially NO dispatch wrapping, so a non-collecting
|
||||
// session pays zero per-transaction cost.
|
||||
if (isVitalsActive()) {
|
||||
// page_open_ms: this is the first editor-content render, so measure
|
||||
// against any page-open mark set on the tree-row/link click.
|
||||
measurePageOpen();
|
||||
|
||||
// editor_tx_ms: time the SYNCHRONOUS part of applying each
|
||||
// transaction (state.apply + updateState) by wrapping the view's
|
||||
// dispatch. Only slow syncs (>8ms) are reported (see reportEditorTx),
|
||||
// so the common path adds just one performance.now() pair. Passive:
|
||||
// the original dispatch still runs unchanged.
|
||||
try {
|
||||
const view = editor.view as unknown as {
|
||||
dispatch: (tr: unknown) => void;
|
||||
};
|
||||
const originalDispatch = view.dispatch.bind(view);
|
||||
view.dispatch = (tr: unknown) => {
|
||||
const started = performance.now();
|
||||
originalDispatch(tr);
|
||||
const elapsed = performance.now() - started;
|
||||
try {
|
||||
reportEditorTx(elapsed, editor.state.doc.content.size);
|
||||
} catch {
|
||||
// never let telemetry break editing
|
||||
}
|
||||
};
|
||||
} catch {
|
||||
// if the view shape changes, skip editor_tx instrumentation
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Document } from "@tiptap/extension-document";
|
||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||
import { Text } from "@tiptap/extension-text";
|
||||
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { UniqueID } from "@docmost/editor-ext";
|
||||
import { getEditorSelectionContext } from "./get-editor-selection";
|
||||
|
||||
/**
|
||||
* Unit tests for getEditorSelectionContext (#388). Built on a headless
|
||||
* ProseMirror schema (Document + Paragraph + Text + the block-id UniqueID
|
||||
* extension), mirroring the editor-ext test style. We assemble docs with
|
||||
* explicit block ids so the covered-blockIds assertions are deterministic.
|
||||
*/
|
||||
|
||||
// A schema that carries the `id` block attribute (UniqueID) on paragraphs, just
|
||||
// like the real editor.
|
||||
const { schema } = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
UniqueID.configure({ types: ["paragraph"] }),
|
||||
],
|
||||
content: "",
|
||||
});
|
||||
|
||||
function docOf(blocks: { id: string; text: string }[]): PMNode {
|
||||
return schema.node(
|
||||
"doc",
|
||||
null,
|
||||
blocks.map((b) =>
|
||||
schema.node("paragraph", { id: b.id }, b.text ? schema.text(b.text) : []),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function stateWith(doc: PMNode, from: number, to: number): EditorState {
|
||||
const base = EditorState.create({ schema, doc });
|
||||
return base.apply(base.tr.setSelection(TextSelection.create(doc, from, to)));
|
||||
}
|
||||
|
||||
// Select every text position of the doc (pos 1 .. content.size - 1).
|
||||
function selectAll(doc: PMNode): EditorState {
|
||||
return stateWith(doc, 1, doc.content.size - 1);
|
||||
}
|
||||
|
||||
describe("getEditorSelectionContext", () => {
|
||||
it("returns null for an empty (collapsed) selection", () => {
|
||||
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
||||
const state = stateWith(doc, 3, 3); // caret, from === to
|
||||
expect(getEditorSelectionContext(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the default caret-at-start of a fresh editor", () => {
|
||||
const editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
UniqueID.configure({ types: ["paragraph"] }),
|
||||
],
|
||||
content: "<p>fresh</p>",
|
||||
});
|
||||
expect(getEditorSelectionContext(editor.state)).toBeNull();
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("reads a single-paragraph selection with no block-separator artifacts", () => {
|
||||
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
expect(sel.text).toBe("Hello world");
|
||||
expect(sel.blockIds).toEqual(["b1"]);
|
||||
expect(sel.truncated).toBeUndefined();
|
||||
});
|
||||
|
||||
it("joins multiple blocks with a newline and collects all covered blockIds", () => {
|
||||
const doc = docOf([
|
||||
{ id: "b1", text: "First" },
|
||||
{ id: "b2", text: "Second" },
|
||||
]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
expect(sel.text).toBe("First\nSecond");
|
||||
expect(sel.blockIds).toEqual(["b1", "b2"]);
|
||||
});
|
||||
|
||||
it("caps the text at 2000 chars and flags truncated", () => {
|
||||
const doc = docOf([{ id: "b1", text: "x".repeat(2500) }]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
expect(sel.text).toHaveLength(2000);
|
||||
expect(sel.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("computes before/after context and clamps it to the doc bounds", () => {
|
||||
// One paragraph "0123456789abcdefghij"; select the middle "56789".
|
||||
const doc = docOf([{ id: "b1", text: "0123456789abcdefghij" }]);
|
||||
// text char i lives at pos (1 + i); select chars index 5..9 -> pos 6..11.
|
||||
const sel = getEditorSelectionContext(stateWith(doc, 6, 11))!;
|
||||
expect(sel.text).toBe("56789");
|
||||
expect(sel.before).toBe("01234");
|
||||
expect(sel.after).toBe("abcdefghij");
|
||||
});
|
||||
|
||||
it("omits before/after at the document boundaries (never reads past 0/size)", () => {
|
||||
const doc = docOf([{ id: "b1", text: "Edge" }]);
|
||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
||||
// Selection spans the whole single block: nothing before or after it.
|
||||
expect(sel.before).toBeUndefined();
|
||||
expect(sel.after).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
|
||||
export interface EditorSelectionContext {
|
||||
text: string;
|
||||
truncated?: boolean;
|
||||
blockIds?: string[];
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
// Client-side caps. The server re-caps every field independently (defence in
|
||||
// depth — the payload is attacker-controllable), so these only keep the wire
|
||||
// small for the common case.
|
||||
const TEXT_CAP = 2000;
|
||||
const CONTEXT_CHARS = 160;
|
||||
const MAX_BLOCK_IDS = 20;
|
||||
|
||||
// Pure: takes an EditorState so it is unit-testable with a headless editor.
|
||||
// Snapshots the user's current selection into the wire shape carried inside
|
||||
// openPage — plain text + the ids of the blocks it covers + a little surrounding
|
||||
// context. Returns null when nothing meaningful is selected.
|
||||
//
|
||||
// Deliberately does NOT emit the ProseMirror positions (from/to): they rot the
|
||||
// instant the document changes and the server tools address content by block id
|
||||
// + text (getNode / editPageText find-replace), never by position.
|
||||
export function getEditorSelectionContext(
|
||||
state: EditorState,
|
||||
): EditorSelectionContext | null {
|
||||
const { selection, doc } = state;
|
||||
// An empty selection (incl. the default caret-at-start of a fresh editor) is
|
||||
// never a "this"/"here" — bail before reading any text.
|
||||
if (selection.empty) return null;
|
||||
|
||||
const { from, to } = selection;
|
||||
|
||||
let text = doc.textBetween(from, to, "\n");
|
||||
let truncated = false;
|
||||
if (text.length > TEXT_CAP) {
|
||||
text = text.slice(0, TEXT_CAP);
|
||||
truncated = true;
|
||||
}
|
||||
// A selection spanning only non-text nodes (e.g. an image) trims to empty ->
|
||||
// treat as no selection.
|
||||
if (text.trim().length === 0) return null;
|
||||
|
||||
// Ids of every block the selection covers, deduped and capped. These bridge
|
||||
// the plain-text selection to the server tools (getNode / editPageText).
|
||||
const blockIds: string[] = [];
|
||||
doc.nodesBetween(from, to, (node) => {
|
||||
const id = node.isBlock ? node.attrs?.id : undefined;
|
||||
if (typeof id === "string" && id.length > 0 && !blockIds.includes(id)) {
|
||||
blockIds.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
// ~160 chars of plain text on each side, clamped to the document bounds, so
|
||||
// editPageText can disambiguate a duplicate of the selected text.
|
||||
const before = doc.textBetween(Math.max(0, from - CONTEXT_CHARS), from, "\n");
|
||||
const after = doc.textBetween(
|
||||
to,
|
||||
Math.min(doc.content.size, to + CONTEXT_CHARS),
|
||||
"\n",
|
||||
);
|
||||
|
||||
const result: EditorSelectionContext = { text };
|
||||
if (truncated) result.truncated = true;
|
||||
if (blockIds.length > 0) result.blockIds = blockIds.slice(0, MAX_BLOCK_IDS);
|
||||
if (before.length > 0) result.before = before;
|
||||
if (after.length > 0) result.after = after;
|
||||
return result;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useStore } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
createdPage.title,
|
||||
);
|
||||
navigate(pageUrl);
|
||||
// On mobile the create action is triggered from inside the off-canvas
|
||||
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||
// alone leaves that drawer open on top of the freshly created page, so the
|
||||
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||
// No-op on desktop, where the mobile drawer atom is already false.
|
||||
setMobileSidebar(false);
|
||||
},
|
||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||
[
|
||||
spaceId,
|
||||
createPageMutation,
|
||||
setData,
|
||||
store,
|
||||
navigate,
|
||||
spaceSlug,
|
||||
setMobileSidebar,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRename = useCallback(
|
||||
|
||||
@@ -165,6 +165,9 @@ export default function ShareAiWidget({
|
||||
isStreaming={isStreaming}
|
||||
assistantName={assistantName}
|
||||
showCitations={false}
|
||||
// Anonymous reader: suppress the tool-argument summary line so the
|
||||
// agent's raw query/argument text isn't shown on the public share.
|
||||
showInput={false}
|
||||
// Anonymous reader: neutralize internal/relative links in the
|
||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||
// leak as clickable links (external http(s) links are kept).
|
||||
|
||||
+976
-275
File diff suppressed because it is too large
Load Diff
+62
@@ -394,6 +394,10 @@ export default function AiProviderSettings() {
|
||||
useState<boolean>(
|
||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
||||
);
|
||||
// #184: detached/autonomous agent runs (settings.ai.autonomousRuns).
|
||||
const [autonomousRunsEnabled, setAutonomousRunsEnabled] = useState<boolean>(
|
||||
workspace?.settings?.ai?.autonomousRuns ?? false,
|
||||
);
|
||||
const [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||
@@ -403,6 +407,8 @@ export default function AiProviderSettings() {
|
||||
publicShareAssistantToggleLoading,
|
||||
setPublicShareAssistantToggleLoading,
|
||||
] = useState(false);
|
||||
const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] =
|
||||
useState(false);
|
||||
|
||||
// Whether a key is currently stored server-side (drives the placeholder).
|
||||
const [hasApiKey, setHasApiKey] = useState(false);
|
||||
@@ -730,6 +736,37 @@ export default function AiProviderSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic toggle for detached/autonomous agent runs
|
||||
// (settings.ai.autonomousRuns). When on, a chat turn becomes a server-side run
|
||||
// that survives a browser disconnect and can be reconnected to / live-followed;
|
||||
// only an explicit Stop ends it. Off by default; single-instance-only in phase 1.
|
||||
async function handleToggleAutonomousRuns(value: boolean) {
|
||||
setAutonomousRunsToggleLoading(true);
|
||||
const previous = autonomousRunsEnabled;
|
||||
setAutonomousRunsEnabled(value);
|
||||
try {
|
||||
const updated = await updateWorkspace({ autonomousRuns: value });
|
||||
setWorkspace({
|
||||
...updated,
|
||||
settings: {
|
||||
...updated.settings,
|
||||
ai: { ...updated.settings?.ai, autonomousRuns: value },
|
||||
},
|
||||
});
|
||||
notifications.show({ message: t("Updated successfully") });
|
||||
} catch (err) {
|
||||
setAutonomousRunsEnabled(previous);
|
||||
const message = (err as { response?: { data?: { message?: string } } })
|
||||
?.response?.data?.message;
|
||||
notifications.show({
|
||||
message: message ?? t("Failed to update data"),
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
setAutonomousRunsToggleLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Admins only — match the previous behavior.
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
@@ -960,6 +997,31 @@ export default function AiProviderSettings() {
|
||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
||||
/>
|
||||
|
||||
{/* Detached/autonomous agent runs: a chat turn becomes a server-side run
|
||||
that survives a browser disconnect; only an explicit Stop ends it.
|
||||
Single-instance-only in phase 1. */}
|
||||
<Group justify="space-between" align="center" wrap="nowrap" mt="md">
|
||||
<Stack gap={0}>
|
||||
<Text fw={600} size="sm">
|
||||
{t("Autonomous agent runs")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
"Keep an agent turn running server-side even if the browser disconnects; reconnect and follow it on reopen. Single-instance deployments only.",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Switch
|
||||
label={t("Enabled")}
|
||||
labelPosition="left"
|
||||
checked={autonomousRunsEnabled}
|
||||
disabled={autonomousRunsToggleLoading}
|
||||
onChange={(e) =>
|
||||
handleToggleAutonomousRuns(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group mt="md" align="center">
|
||||
<Button
|
||||
variant="default"
|
||||
|
||||
@@ -26,6 +26,9 @@ export interface IWorkspace {
|
||||
aiDictation?: boolean;
|
||||
aiDictationStreaming?: boolean;
|
||||
aiPublicShareAssistant?: boolean;
|
||||
// Write-only field for updateWorkspace({ autonomousRuns }). Read state lives at
|
||||
// settings.ai.autonomousRuns.
|
||||
autonomousRuns?: boolean;
|
||||
trashRetentionDays?: number;
|
||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||
temporaryNoteHours?: number;
|
||||
@@ -65,6 +68,9 @@ export interface IWorkspaceAiSettings {
|
||||
dictation?: boolean;
|
||||
dictationStreaming?: boolean;
|
||||
publicShareAssistant?: boolean;
|
||||
// #184: detached agent runs (a run survives a browser disconnect and can be
|
||||
// reconnected to / live-followed on reopen). Gates the run-reconnect polling.
|
||||
autonomousRuns?: boolean;
|
||||
}
|
||||
|
||||
export interface IWorkspaceSharingSettings {
|
||||
|
||||
@@ -47,6 +47,13 @@ export function isCompactPageTreeEnabled(): boolean {
|
||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
||||
}
|
||||
|
||||
// #355 — operator toggle for client perf-telemetry. DEFAULT OFF: the server
|
||||
// mirrors CLIENT_TELEMETRY_ENABLED into window.CONFIG; when off the client
|
||||
// installs no observers and sends nothing (the sink endpoint doesn't exist).
|
||||
export function isClientTelemetryEnabled(): boolean {
|
||||
return castToBoolean(getConfigValue("CLIENT_TELEMETRY_ENABLED", "false"));
|
||||
}
|
||||
|
||||
export function getAvatarUrl(
|
||||
avatarUrl: string,
|
||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { templateRoute } from "./route-template";
|
||||
|
||||
describe("templateRoute", () => {
|
||||
it("templates a space page path (never leaks slugs)", () => {
|
||||
const t = templateRoute("/s/engineering/p/design-doc-abc123");
|
||||
expect(t).toBe("/s/:space/p/:slug");
|
||||
expect(t).not.toContain("engineering");
|
||||
expect(t).not.toContain("design-doc");
|
||||
});
|
||||
|
||||
it("templates share, redirect and space paths", () => {
|
||||
expect(templateRoute("/share/abc/p/xyz")).toBe("/share/:shareId/p/:slug");
|
||||
expect(templateRoute("/share/p/xyz")).toBe("/share/p/:slug");
|
||||
expect(templateRoute("/p/some-slug")).toBe("/p/:slug");
|
||||
expect(templateRoute("/s/team")).toBe("/s/:space");
|
||||
expect(templateRoute("/s/team/trash")).toBe("/s/:space/trash");
|
||||
expect(templateRoute("/labels/urgent")).toBe("/labels/:label");
|
||||
});
|
||||
|
||||
it("keeps known static routes verbatim", () => {
|
||||
expect(templateRoute("/home")).toBe("/home");
|
||||
expect(templateRoute("/settings/members")).toBe("/settings/members");
|
||||
expect(templateRoute("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("normalises a trailing slash", () => {
|
||||
expect(templateRoute("/s/team/p/slug/")).toBe("/s/:space/p/:slug");
|
||||
});
|
||||
|
||||
it("collapses unknown paths to 'other' (bounded cardinality)", () => {
|
||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Map a raw pathname to a BOUNDED route TEMPLATE (#355).
|
||||
*
|
||||
* Perf metrics must be labelled by route template only — never a raw path with
|
||||
* slugs/ids — so the server-side `route` column and any downstream aggregation
|
||||
* stay low-cardinality and carry NO page slugs/titles (privacy). Anything that
|
||||
* does not match a known pattern collapses to `other`.
|
||||
*
|
||||
* The template vocabulary mirrors the issue's example (`/s/:space/p/:slug`).
|
||||
*/
|
||||
const ROUTE_PATTERNS: { re: RegExp; template: string }[] = [
|
||||
// Share pages (public).
|
||||
{ re: /^\/share\/[^/]+\/p\/[^/]+$/, template: '/share/:shareId/p/:slug' },
|
||||
{ re: /^\/share\/p\/[^/]+$/, template: '/share/p/:slug' },
|
||||
{ re: /^\/share\/[^/]+$/, template: '/share/:shareId' },
|
||||
// Page redirect.
|
||||
{ re: /^\/p\/[^/]+$/, template: '/p/:slug' },
|
||||
// Space + page.
|
||||
{ re: /^\/s\/[^/]+\/p\/[^/]+$/, template: '/s/:space/p/:slug' },
|
||||
{ re: /^\/s\/[^/]+\/trash$/, template: '/s/:space/trash' },
|
||||
{ re: /^\/s\/[^/]+$/, template: '/s/:space' },
|
||||
// Misc dynamic.
|
||||
{ re: /^\/labels\/[^/]+$/, template: '/labels/:label' },
|
||||
{ re: /^\/invites\/[^/]+$/, template: '/invites/:invitationId' },
|
||||
{ re: /^\/settings\/groups\/[^/]+$/, template: '/settings/groups/:groupId' },
|
||||
];
|
||||
|
||||
// Static routes we accept verbatim (finite set).
|
||||
const STATIC_ROUTES = new Set<string>([
|
||||
'/home',
|
||||
'/spaces',
|
||||
'/favorites',
|
||||
'/login',
|
||||
'/forgot-password',
|
||||
'/password-reset',
|
||||
'/setup/register',
|
||||
'/settings/account/profile',
|
||||
'/settings/account/preferences',
|
||||
'/settings/workspace',
|
||||
'/settings/ai',
|
||||
'/settings/members',
|
||||
'/settings/groups',
|
||||
'/settings/spaces',
|
||||
'/settings/sharing',
|
||||
]);
|
||||
|
||||
export function templateRoute(pathname: string): string {
|
||||
// Normalise a trailing slash (except root).
|
||||
const path =
|
||||
pathname.length > 1 && pathname.endsWith('/')
|
||||
? pathname.slice(0, -1)
|
||||
: pathname;
|
||||
|
||||
if (path === '' || path === '/') return '/';
|
||||
if (STATIC_ROUTES.has(path)) return path;
|
||||
|
||||
for (const { re, template } of ROUTE_PATTERNS) {
|
||||
if (re.test(path)) return template;
|
||||
}
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/** Template for the current window location. */
|
||||
export function currentRouteTemplate(): string {
|
||||
try {
|
||||
return templateRoute(window.location.pathname);
|
||||
} catch {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
onCLS,
|
||||
onINP,
|
||||
onLCP,
|
||||
onTTFB,
|
||||
type CLSMetricWithAttribution,
|
||||
type INPMetricWithAttribution,
|
||||
type LCPMetricWithAttribution,
|
||||
type TTFBMetricWithAttribution,
|
||||
} from "web-vitals/attribution";
|
||||
import { isClientTelemetryEnabled } from "@/lib/config";
|
||||
import { currentRouteTemplate } from "./route-template";
|
||||
|
||||
/**
|
||||
* Client perf-telemetry (#355): web-vitals + custom metrics buffered and posted
|
||||
* to POST /api/telemetry/vitals via sendBeacon.
|
||||
*
|
||||
* Design constraints from the issue:
|
||||
* - Sampling is decided ONCE per session (25%), cached in sessionStorage,
|
||||
* BEFORE any observer is subscribed. Non-sampled sessions send nothing.
|
||||
* - Route labels are TEMPLATES only; attr is truncated to 120 chars; no page
|
||||
* titles/slugs/text ever leave the browser.
|
||||
* - Observers are passive and reporting is best-effort — telemetry must not
|
||||
* degrade the perf it measures.
|
||||
*/
|
||||
|
||||
const ENDPOINT = "/api/telemetry/vitals";
|
||||
const SAMPLE_RATE = 0.25;
|
||||
const SAMPLE_KEY = "gm_vitals_sampled";
|
||||
const FLUSH_INTERVAL_MS = 15_000;
|
||||
const MAX_BUFFER = 40; // flush early if the buffer fills between timers
|
||||
const MAX_ATTR_LENGTH = 120;
|
||||
const EDITOR_TX_MIN_MS = 8; // only report editor transactions slower than this
|
||||
|
||||
const ALLOWED_NAMES = new Set([
|
||||
"INP",
|
||||
"LCP",
|
||||
"CLS",
|
||||
"TTFB",
|
||||
"editor_tx_ms",
|
||||
"page_open_ms",
|
||||
"longtask_ms",
|
||||
]);
|
||||
|
||||
interface VitalEvent {
|
||||
name: string;
|
||||
value: number;
|
||||
rating?: string;
|
||||
route?: string;
|
||||
attr?: string;
|
||||
docSize?: number;
|
||||
}
|
||||
|
||||
let sampledCache: boolean | null = null;
|
||||
let initialised = false;
|
||||
let buffer: VitalEvent[] = [];
|
||||
let longtaskSum = 0; // accumulated longtask duration (ms) for the current window
|
||||
|
||||
/**
|
||||
* Decide once per session whether this session is sampled. Cached in
|
||||
* sessionStorage so the choice is stable across reloads within the session and
|
||||
* identical for every observer/custom-metric caller.
|
||||
*/
|
||||
export function isVitalsSampled(): boolean {
|
||||
if (sampledCache !== null) return sampledCache;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(SAMPLE_KEY);
|
||||
if (stored === "1") return (sampledCache = true);
|
||||
if (stored === "0") return (sampledCache = false);
|
||||
const sampled = Math.random() < SAMPLE_RATE;
|
||||
sessionStorage.setItem(SAMPLE_KEY, sampled ? "1" : "0");
|
||||
return (sampledCache = sampled);
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / SSR): default to not sampled.
|
||||
return (sampledCache = false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only when telemetry is BOTH enabled by the operator (F1 flag) AND this
|
||||
* session is sampled. Callers outside initVitals (e.g. the editor dispatch
|
||||
* wrapper) use this to skip ALL instrumentation cost on disabled/non-sampled
|
||||
* sessions — no observers, no per-transaction timing.
|
||||
*/
|
||||
export function isVitalsActive(): boolean {
|
||||
return isClientTelemetryEnabled() && isVitalsSampled();
|
||||
}
|
||||
|
||||
function truncateAttr(value: unknown): string | undefined {
|
||||
if (typeof value !== "string" || value.length === 0) return undefined;
|
||||
return value.slice(0, MAX_ATTR_LENGTH);
|
||||
}
|
||||
|
||||
function enqueue(event: VitalEvent): void {
|
||||
if (!ALLOWED_NAMES.has(event.name)) return;
|
||||
if (!Number.isFinite(event.value)) return;
|
||||
buffer.push(event);
|
||||
if (buffer.length >= MAX_BUFFER) flush();
|
||||
}
|
||||
|
||||
function flush(): void {
|
||||
// Fold any pending longtask total into the batch first.
|
||||
if (longtaskSum > 0) {
|
||||
buffer.push({
|
||||
name: "longtask_ms",
|
||||
value: Math.round(longtaskSum),
|
||||
route: currentRouteTemplate(),
|
||||
});
|
||||
longtaskSum = 0;
|
||||
}
|
||||
if (buffer.length === 0) return;
|
||||
|
||||
const payload = JSON.stringify({ events: buffer });
|
||||
buffer = [];
|
||||
|
||||
try {
|
||||
const blob = new Blob([payload], { type: "application/json" });
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(ENDPOINT, blob)) return;
|
||||
// Fallback for browsers without sendBeacon: keepalive fetch.
|
||||
void fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
} catch {
|
||||
// Best-effort: never throw out of telemetry.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a custom client metric (editor_tx_ms, page_open_ms). No-op unless the
|
||||
* session is sampled. Route is always the current TEMPLATE.
|
||||
*/
|
||||
export function reportClientMetric(
|
||||
name: "editor_tx_ms" | "page_open_ms",
|
||||
value: number,
|
||||
extra?: { docSize?: number },
|
||||
): void {
|
||||
if (!isVitalsActive()) return;
|
||||
if (!Number.isFinite(value)) return;
|
||||
enqueue({
|
||||
name,
|
||||
value,
|
||||
route: currentRouteTemplate(),
|
||||
docSize: extra?.docSize,
|
||||
});
|
||||
}
|
||||
|
||||
/** Threshold-gated editor transaction reporter (only reports slow syncs). */
|
||||
export function reportEditorTx(ms: number, docSize: number): void {
|
||||
if (ms <= EDITOR_TX_MIN_MS) return;
|
||||
reportClientMetric("editor_tx_ms", ms, { docSize });
|
||||
}
|
||||
|
||||
const PAGE_OPEN_MARK = "gm_page_open_start";
|
||||
|
||||
/** Mark the start of a page-open interaction (tree-row / link click). */
|
||||
export function markPageOpenStart(): void {
|
||||
try {
|
||||
performance.clearMarks(PAGE_OPEN_MARK);
|
||||
performance.mark(PAGE_OPEN_MARK);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure page_open_ms at first editor-content render, if a start mark exists.
|
||||
* Consumes the mark so a later render doesn't double-count.
|
||||
*/
|
||||
export function measurePageOpen(): void {
|
||||
try {
|
||||
const marks = performance.getEntriesByName(PAGE_OPEN_MARK, "mark");
|
||||
if (marks.length === 0) return;
|
||||
const started = marks[0].startTime;
|
||||
const elapsed = performance.now() - started;
|
||||
performance.clearMarks(PAGE_OPEN_MARK);
|
||||
if (elapsed > 0 && Number.isFinite(elapsed)) {
|
||||
reportClientMetric("page_open_ms", elapsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function attrTarget(
|
||||
metric:
|
||||
| INPMetricWithAttribution
|
||||
| LCPMetricWithAttribution
|
||||
| CLSMetricWithAttribution,
|
||||
): string | undefined {
|
||||
const a = metric.attribution as Record<string, unknown> | undefined;
|
||||
if (!a) return undefined;
|
||||
// Different vitals expose their culprit element under different keys; only a
|
||||
// CSS-selector-ish target string is taken (no text content / titles).
|
||||
return (
|
||||
truncateAttr(a.interactionTarget) ??
|
||||
truncateAttr(a.element) ??
|
||||
truncateAttr(a.largestShiftTarget) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise client telemetry. Safe to call multiple times (idempotent). Returns
|
||||
* immediately without subscribing when the session is not sampled — so a
|
||||
* non-sampled session subscribes to NO observers and sends nothing.
|
||||
*/
|
||||
export function initVitals(): void {
|
||||
if (initialised) return;
|
||||
initialised = true;
|
||||
|
||||
// Operator flag gate (F1, default OFF): when telemetry is disabled the sink
|
||||
// endpoint does not even exist server-side, so install ZERO observers.
|
||||
if (!isClientTelemetryEnabled()) return;
|
||||
|
||||
// Sampling gate is evaluated BEFORE any observer subscription.
|
||||
if (!isVitalsSampled()) return;
|
||||
|
||||
const report = (
|
||||
metric:
|
||||
| INPMetricWithAttribution
|
||||
| LCPMetricWithAttribution
|
||||
| CLSMetricWithAttribution
|
||||
| TTFBMetricWithAttribution,
|
||||
) => {
|
||||
enqueue({
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
route: currentRouteTemplate(),
|
||||
attr:
|
||||
metric.name === "TTFB"
|
||||
? undefined
|
||||
: attrTarget(
|
||||
metric as
|
||||
| INPMetricWithAttribution
|
||||
| LCPMetricWithAttribution
|
||||
| CLSMetricWithAttribution,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
onINP(report);
|
||||
onLCP(report);
|
||||
onCLS(report);
|
||||
onTTFB(report);
|
||||
|
||||
// Long tasks: aggregate the total blocking time per flush window (a passive
|
||||
// observer; individual entries are summed, never stored/sent individually).
|
||||
try {
|
||||
if (typeof PerformanceObserver !== "undefined") {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
longtaskSum += entry.duration;
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
}
|
||||
} catch {
|
||||
// longtask entry type unsupported: skip silently.
|
||||
}
|
||||
|
||||
// page_open_ms start: mark when the user clicks a page link/tree-row (any
|
||||
// anchor navigating to a page URL). Passive capture listener; the matching
|
||||
// measure fires at first editor-content render (measurePageOpen). No page
|
||||
// titles/slugs are read — only the click timing is marked.
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const target = event.target as Element | null;
|
||||
const anchor = target?.closest?.("a[href]") as HTMLAnchorElement | null;
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute("href") ?? "";
|
||||
// A page link is `/s/:space/p/:slug`, `/p/:slug` or a share page path.
|
||||
if (/\/p\//.test(href)) markPageOpenStart();
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
// Flush on tab hide (most reliable delivery point) and periodically.
|
||||
const onHidden = () => {
|
||||
if (document.visibilityState === "hidden") flush();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onHidden);
|
||||
window.addEventListener("pagehide", flush);
|
||||
|
||||
setInterval(flush, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import posthog from "posthog-js";
|
||||
import { initVitals } from "@/lib/telemetry/vitals";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -43,6 +44,10 @@ if (isCloud() && isPostHogEnabled) {
|
||||
});
|
||||
}
|
||||
|
||||
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||
// subscribing to any observer; non-sampled sessions send nothing.
|
||||
initVitals();
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||
|
||||
|
||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
// Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels
|
||||
// like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are
|
||||
// set a few points below the level measured on develop, scoped to the files
|
||||
// the suite exercises (`all: false`) rather than the whole app, so the gate
|
||||
// passes today but fails on a genuine coverage regression.
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: 'v8',
|
||||
reporter: ['text-summary', 'text'],
|
||||
all: false,
|
||||
thresholds: {
|
||||
statements: 55,
|
||||
branches: 53,
|
||||
functions: 44,
|
||||
lines: 55,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||
"test": "jest",
|
||||
"test:int": "jest --config test/jest-integration.json",
|
||||
"test:watch": "jest --watch",
|
||||
@@ -43,6 +43,7 @@
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@docmost/prosemirror-markdown": "workspace:*",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
@@ -111,6 +112,7 @@
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postgres": "^3.4.8",
|
||||
"postmark": "^4.0.7",
|
||||
"prom-client": "^15.1.3",
|
||||
"react": "^18.3.1",
|
||||
"react-email": "6.0.8",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -174,7 +176,7 @@
|
||||
"/node_modules/"
|
||||
],
|
||||
"transform": {
|
||||
"happy-dom.+\\.js$": [
|
||||
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||
"babel-jest",
|
||||
{
|
||||
"presets": [
|
||||
@@ -192,7 +194,7 @@
|
||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||
],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
@@ -203,7 +205,8 @@
|
||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||
"^src/(.*)$": "<rootDir>/$1"
|
||||
"^src/(.*)$": "<rootDir>/$1",
|
||||
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import { McpModule } from './integrations/mcp/mcp.module';
|
||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||
import { AiModule } from './integrations/ai/ai.module';
|
||||
import { AiChatModule } from './core/ai-chat/ai-chat.module';
|
||||
import { MetricsModule } from './integrations/metrics/metrics.module';
|
||||
import { ClientTelemetryModule } from './core/telemetry/client-telemetry.module';
|
||||
|
||||
const enterpriseModules = [];
|
||||
try {
|
||||
@@ -93,6 +95,10 @@ try {
|
||||
SandboxModule,
|
||||
AiModule,
|
||||
AiChatModule,
|
||||
MetricsModule,
|
||||
// Gated OFF by default: only registers the public vitals sink controller
|
||||
// when CLIENT_TELEMETRY_ENABLED=true (maintainer decision E1=B).
|
||||
ClientTelemetryModule.register(),
|
||||
...enterpriseModules,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -130,3 +130,59 @@ describe('CollaborationHandler.applyCommentSuggestion', () => {
|
||||
expect(value).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CollaborationHandler.deleteCommentMark', () => {
|
||||
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
||||
const doc = buildDocWithComment('Hello world', 'c1');
|
||||
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||
|
||||
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
||||
const xmlText = (
|
||||
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||
).get(0) as Y.XmlText;
|
||||
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
||||
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
||||
const doc = buildDocWithComment('abc', 'c9');
|
||||
const { hocuspocus } = fakeHocuspocus(doc);
|
||||
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
doc.getXmlFragment('default'),
|
||||
'comment',
|
||||
'commentId',
|
||||
'c9',
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('leaves a different comment\'s mark intact', async () => {
|
||||
const doc = buildDocWithComment('keep me', 'other');
|
||||
const { hocuspocus } = fakeHocuspocus(doc);
|
||||
const handler = new CollaborationHandler();
|
||||
const handlers = handler.getHandlers(hocuspocus);
|
||||
|
||||
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||
|
||||
const xmlText = (
|
||||
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||
).get(0) as Y.XmlText;
|
||||
expect(xmlText.toDelta()).toEqual([
|
||||
{
|
||||
insert: 'keep me',
|
||||
attributes: { comment: { commentId: 'other', resolved: false } },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
import {
|
||||
removeYjsMarkByAttribute,
|
||||
replaceYjsMarkedText,
|
||||
setYjsMark,
|
||||
updateYjsMarkAttribute,
|
||||
@@ -78,6 +79,40 @@ export class CollaborationHandler {
|
||||
},
|
||||
);
|
||||
},
|
||||
deleteCommentMark: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
commentId: string;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { commentId, user } = payload;
|
||||
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
||||
// applied one has no replies, the comment is hard-deleted and its inline
|
||||
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
||||
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
||||
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
||||
// the collaborative document.
|
||||
//
|
||||
// Routing this through collaboration.gateway's handleYjsEvent means the
|
||||
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
||||
// no-op) and a missing live instance is a hard error — the same guarantee
|
||||
// applyCommentSuggestion/resolveCommentMark rely on.
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
removeYjsMarkByAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
'commentId',
|
||||
commentId,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
applyCommentSuggestion: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
|
||||
@@ -43,7 +43,6 @@ import {
|
||||
Column,
|
||||
Status,
|
||||
addUniqueIdsToDoc,
|
||||
htmlToMarkdown,
|
||||
TransclusionSource,
|
||||
TransclusionReference,
|
||||
FootnoteReference,
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
FootnoteDefinition,
|
||||
PageEmbed,
|
||||
} from '@docmost/editor-ext';
|
||||
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||
@@ -239,6 +239,10 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
||||
}
|
||||
|
||||
export function jsonToMarkdown(tiptapJson: any): string {
|
||||
const html = jsonToHtml(tiptapJson);
|
||||
return htmlToMarkdown(html);
|
||||
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||
// (`@docmost/prosemirror-markdown`) — no HTML intermediate, no second
|
||||
// editor-ext markdown layer. Same serializer as the page/space export and the
|
||||
// git-sync vault writer, so every server PM->MD path emits identical canonical
|
||||
// markdown (issue #345).
|
||||
return convertProseMirrorToMarkdown(tiptapJson);
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
HISTORY_INTERVAL,
|
||||
} from '../constants';
|
||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
||||
|
||||
/**
|
||||
* #251 — wire format of the client→server stateless message that signals a
|
||||
@@ -192,6 +193,17 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
|
||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||
// #355 — time the full store (persist + post-store side effects) into
|
||||
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
await this.storeDocument(data);
|
||||
} finally {
|
||||
observeCollabStore((performance.now() - startedAt) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
private async storeDocument(data: onStoreDocumentPayload) {
|
||||
const { documentName, document, context } = data;
|
||||
|
||||
const pageId = getPageId(documentName);
|
||||
|
||||
@@ -52,6 +52,7 @@ export const AuditEvent = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
COMMENT_REOPENED: 'comment.reopened',
|
||||
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
|
||||
|
||||
// Page
|
||||
PAGE_CREATED: 'page.created',
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
AiChatRunService,
|
||||
RunAlreadyActiveError,
|
||||
ONE_ACTIVE_RUN_PER_CHAT_INDEX,
|
||||
mapTurnStatusToRun,
|
||||
} from './ai-chat-run.service';
|
||||
|
||||
/** Shape a Postgres unique-violation the way the postgres.js driver surfaces it:
|
||||
* SQLSTATE 23505 + the offending index in `constraint_name`. */
|
||||
function uniqueViolation(constraintName: string): Error & {
|
||||
code: string;
|
||||
constraint_name: string;
|
||||
} {
|
||||
return Object.assign(
|
||||
new Error('duplicate key value violates unique constraint'),
|
||||
{
|
||||
code: '23505',
|
||||
constraint_name: constraintName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit coverage for the #184 phase-1 run lifecycle (AiChatRunService) with a
|
||||
* hand-rolled mock repo — no Nest graph, no DB. The invariant under test is the
|
||||
* one that makes a run "autonomous": a run keeps going when its SUBSCRIBER (the
|
||||
* browser) detaches, and ONLY an explicit stop aborts it. We assert that at the
|
||||
* abort-signal level (the signal the agent loop actually consumes).
|
||||
*/
|
||||
|
||||
/** Minimal EnvironmentService stub. Single-instance (CLOUD unset) by default. */
|
||||
function makeEnv(isCloud = false) {
|
||||
return { isCloud: () => isCloud };
|
||||
}
|
||||
|
||||
function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
||||
return {
|
||||
insert: jest.fn(async (v: any) => ({
|
||||
id: 'run-1',
|
||||
status: v.status ?? 'running',
|
||||
chatId: v.chatId,
|
||||
workspaceId: v.workspaceId,
|
||||
})),
|
||||
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||
findActiveByChat: jest.fn(async () => undefined),
|
||||
findLatestByChat: jest.fn(async () => undefined),
|
||||
findById: jest.fn(async () => undefined),
|
||||
sweepRunning: jest.fn(async () => 0),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('mapTurnStatusToRun', () => {
|
||||
it('maps the turn terminal status to the run terminal status', () => {
|
||||
expect(mapTurnStatusToRun('completed')).toBe('succeeded');
|
||||
expect(mapTurnStatusToRun('error')).toBe('failed');
|
||||
expect(mapTurnStatusToRun('aborted')).toBe('aborted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChatRunService.onModuleInit (startup sweep)', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('calls sweepRunning and resolves; logs when > 0', async () => {
|
||||
const repo = makeRepo({ sweepRunning: jest.fn(async () => 2) });
|
||||
const logSpy = jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||
expect(logSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(logSpy.mock.calls[0][0])).toContain('2');
|
||||
});
|
||||
|
||||
it('a sweep failure is swallowed (never blocks startup)', async () => {
|
||||
const repo = makeRepo({
|
||||
sweepRunning: jest.fn(async () => {
|
||||
throw new Error('db down');
|
||||
}),
|
||||
});
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(svc.onModuleInit()).resolves.toBeUndefined();
|
||||
// The first warn is the sweep failure (the multi-instance warn never fires
|
||||
// single-instance), so the message is the db error.
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db down');
|
||||
});
|
||||
|
||||
it('F1 (DECISION C): the boot sweep is UNCONDITIONAL — sweepRunning is called with NO staleness window, so a fresh running run (updatedAt = now) is settled, not skipped', async () => {
|
||||
// The bug: a fast restart (deploy/OOM within minutes of the last step) left a
|
||||
// run stuck 'running' under the old 10-min window, 409ing every later turn in
|
||||
// the chat. The fix settles ALL pending|running on boot. We assert the service
|
||||
// invokes sweepRunning with no `staleMs` (the unconditional path); the repo's
|
||||
// own spec proves no-window => no updatedAt filter.
|
||||
const repo = makeRepo({ sweepRunning: jest.fn(async () => 1) });
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.onModuleInit();
|
||||
expect(repo.sweepRunning).toHaveBeenCalledTimes(1);
|
||||
const callArgs = repo.sweepRunning.mock.calls[0] as unknown[];
|
||||
const firstArg = callArgs[0] as { staleMs?: number } | undefined;
|
||||
// Either no opts at all, or opts without a staleMs window => unconditional.
|
||||
expect(firstArg?.staleMs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('F2 (DECISION A): warns at startup that autonomousRuns is single-instance-only when a horizontally-scaled deployment (CLOUD) is detected', async () => {
|
||||
const repo = makeRepo();
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv(true) as never);
|
||||
await svc.onModuleInit();
|
||||
const warned = warnSpy.mock.calls.some((c) =>
|
||||
/single-instance-only/i.test(String(c[0])),
|
||||
);
|
||||
expect(warned).toBe(true);
|
||||
});
|
||||
|
||||
it('F2: does NOT warn about multi-instance on a single-instance (CLOUD unset) deployment', async () => {
|
||||
const repo = makeRepo();
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv(false) as never);
|
||||
await svc.onModuleInit();
|
||||
const warned = warnSpy.mock.calls.some((c) =>
|
||||
/single-instance-only/i.test(String(c[0])),
|
||||
);
|
||||
expect(warned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AiChatRunService run lifecycle', () => {
|
||||
it('beginRun inserts a running row and registers a live abort controller', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repo.insert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
createdBy: 'user-1',
|
||||
status: 'running',
|
||||
trigger: 'user',
|
||||
}),
|
||||
);
|
||||
expect(handle.runId).toBe('run-1');
|
||||
expect(handle.signal.aborted).toBe(false);
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('beginRun REJECTS the racer: a 23505 on the one-active-per-chat index throws RunAlreadyActiveError (not swallowed) and registers no controller', async () => {
|
||||
// The race: the controller's cheap pre-check passed for BOTH concurrent
|
||||
// turns, so the loser's INSERT hits the partial unique index. That rejection
|
||||
// is the authoritative gate — it must surface, not be swallowed into an
|
||||
// untracked turn.
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async () => {
|
||||
throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(RunAlreadyActiveError);
|
||||
// No controller leaked for a rejected start.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('beginRun does NOT mask an unrelated unique violation as already-active', async () => {
|
||||
// A 23505 on some OTHER constraint is a real bug, not the race — it must
|
||||
// propagate unchanged so it is never silently treated as "already active".
|
||||
const other = uniqueViolation('ai_chat_runs_pkey');
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async () => {
|
||||
throw other;
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
).rejects.toBe(other);
|
||||
});
|
||||
|
||||
it('beginRun propagates a non-unique insert failure unchanged', async () => {
|
||||
const boom = new Error('connection reset');
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async () => {
|
||||
throw boom;
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
).rejects.toBe(boom);
|
||||
});
|
||||
|
||||
it('two concurrent begins on one chat: exactly one wins, the other is rejected as already-active', async () => {
|
||||
// Integration-style: model the DB partial unique index with a one-shot slot.
|
||||
// The first insert claims it; the second hits a 23505 on the active index.
|
||||
let slotTaken = false;
|
||||
const repo = makeRepo({
|
||||
insert: jest.fn(async (v: any) => {
|
||||
if (slotTaken) throw uniqueViolation(ONE_ACTIVE_RUN_PER_CHAT_INDEX);
|
||||
slotTaken = true;
|
||||
return { id: 'run-win', status: v.status, chatId: v.chatId };
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const results = await Promise.allSettled([
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'user-1' }),
|
||||
]);
|
||||
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||
const rejected = results.filter((r) => r.status === 'rejected');
|
||||
expect(fulfilled).toHaveLength(1);
|
||||
expect(rejected).toHaveLength(1);
|
||||
expect((rejected[0] as PromiseRejectedResult).reason).toBeInstanceOf(
|
||||
RunAlreadyActiveError,
|
||||
);
|
||||
// Exactly the winner is locally active.
|
||||
expect(svc.isLocallyActive('run-win')).toBe(true);
|
||||
});
|
||||
|
||||
it('a SUBSCRIBER detaching does NOT abort the run (only an explicit stop does)', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
// Model a browser disconnect: nothing in the run service is told to stop.
|
||||
// The signal the agent loop consumes must stay un-aborted and the run stays
|
||||
// locally active — i.e. it keeps running server-side.
|
||||
expect(handle.signal.aborted).toBe(false);
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
// markStopRequested was never called by a mere detach.
|
||||
expect(repo.markStopRequested).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requestStop aborts the live controller, marks the row, and reports true', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const aborted = jest.fn();
|
||||
handle.signal.addEventListener('abort', aborted);
|
||||
|
||||
const result = await svc.requestStop('run-1', 'ws-1');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(handle.signal.aborted).toBe(true);
|
||||
expect(aborted).toHaveBeenCalledTimes(1);
|
||||
expect(repo.markStopRequested).toHaveBeenCalledWith('run-1', 'ws-1');
|
||||
});
|
||||
|
||||
it('requestStop on a run this replica does NOT hold still marks the row (true)', async () => {
|
||||
// e.g. after a restart, or a sibling replica owns the controller. The row is
|
||||
// marked so the owning replica/sweep settles it; we report a stop took effect.
|
||||
const repo = makeRepo({
|
||||
markStopRequested: jest.fn(async () => ({ id: 'run-9' })),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const result = await svc.requestStop('run-9', 'ws-1');
|
||||
expect(result).toBe(true);
|
||||
expect(svc.isLocallyActive('run-9')).toBe(false);
|
||||
});
|
||||
|
||||
it('requestStop still aborts the live controller when markStopRequested rejects (transient DB error)', async () => {
|
||||
// F15: the in-memory abort is the ONLY thing that stops a run and must not be
|
||||
// hostage to the audit write of stop_requested_at. A transient failure on
|
||||
// markStopRequested must NOT prevent abort() nor make requestStop throw.
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
const repo = makeRepo({
|
||||
markStopRequested: jest.fn(async () => {
|
||||
throw new Error('pool exhausted');
|
||||
}),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const handle = await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
const aborted = jest.fn();
|
||||
handle.signal.addEventListener('abort', aborted);
|
||||
|
||||
// Does NOT throw despite the DB write rejecting.
|
||||
const result = await svc.requestStop('run-1', 'ws-1');
|
||||
|
||||
// The live turn was aborted even though the audit write failed...
|
||||
expect(handle.signal.aborted).toBe(true);
|
||||
expect(aborted).toHaveBeenCalledTimes(1);
|
||||
expect(repo.markStopRequested).toHaveBeenCalledWith('run-1', 'ws-1');
|
||||
// ...the catch branch logged the swallowed failure...
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
// ...and a stop is reported as having taken effect (the entry existed).
|
||||
expect(result).toBe(true);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('requestStop on an already-settled run (nothing active) reports false', async () => {
|
||||
const repo = makeRepo({
|
||||
markStopRequested: jest.fn(async () => undefined),
|
||||
});
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
const result = await svc.requestStop('run-done', 'ws-1');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('finalizeRun settles the row to the mapped status with finishedAt and drops the in-memory entry', async () => {
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({
|
||||
status: 'failed',
|
||||
error: 'provider blew up',
|
||||
finishedAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('finalizeRun is IDEMPOTENT: a second settle no-ops (single terminal write)', async () => {
|
||||
// The #184 review fix: AiChatService.stream wraps the turn in a safety-net
|
||||
// catch that settles a failed turn AND streamText's terminal callback may
|
||||
// also settle — both routes call finalizeRun. Only the FIRST may write the
|
||||
// terminal row; the second must no-op so a late settle can never clobber the
|
||||
// real terminal status or double-write the row.
|
||||
const repo = makeRepo();
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'first');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||
|
||||
expect(repo.update).toHaveBeenCalledTimes(1);
|
||||
expect(repo.update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('CONCURRENCY: two simultaneous finalizeRun on the same run write the terminal row EXACTLY ONCE (the 2nd caller exits synchronously at the atomic claim)', async () => {
|
||||
// The CRITICAL race: AiChatService.stream's safety-net catch settles the turn
|
||||
// to 'error' while a streamText terminal callback also settles it — both call
|
||||
// finalizeRun for the SAME runId. The once-gate must close ATOMICALLY: a
|
||||
// `settled.has` check alone is read BEFORE the awaited UPDATE, so both callers
|
||||
// would pass it and BOTH write the row (last-write-wins clobber + double
|
||||
// write). The fix claims the run with a SYNCHRONOUS `active.delete` before any
|
||||
// await, so the second caller returns in the same tick, before the UPDATE.
|
||||
//
|
||||
// We force the two calls to overlap by making `update` return a promise we
|
||||
// resolve only AFTER both finalizeRun calls have run their synchronous bodies.
|
||||
let resolveUpdate!: (v: unknown) => void;
|
||||
const updateGate = new Promise((res) => {
|
||||
resolveUpdate = res;
|
||||
});
|
||||
const update = jest.fn(() => updateGate);
|
||||
const repo = makeRepo({ update });
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
// Fire both before the (pending) update resolves. The first synchronously
|
||||
// claims the entry (active.delete) and awaits update; the second, started in
|
||||
// the same macrotask, finds the entry already gone and returns at the claim
|
||||
// WITHOUT ever calling update.
|
||||
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||
|
||||
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Let the single in-flight update land; both calls resolve cleanly.
|
||||
resolveUpdate({ id: 'run-1' });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(update).toHaveBeenCalledTimes(1);
|
||||
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||
// 'error' settle never wrote, so it could not clobber the real status.
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('F6: a TRANSIENT terminal-write failure is ridden out by the bounded retry — the run is settled, not stranded', async () => {
|
||||
// The bug: finalizeRun used to DROP the in-memory entry BEFORE the terminal
|
||||
// UPDATE, then only warn-log a failure. A single transient blip (pool
|
||||
// exhaustion / deadlock / connection hiccup) on that PK UPDATE left the row
|
||||
// 'running' with nothing left to recover it -> every later turn in that chat
|
||||
// 409s until a restart. The fix updates FIRST and retries.
|
||||
let calls = 0;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error('deadlock detected');
|
||||
return { id: 'run-1' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
|
||||
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
||||
// the row carries the real terminal status — NOT stranded at 'running'.
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenCalledTimes(2);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
||||
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
||||
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
||||
let healthy = false;
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
if (!healthy) throw new Error('pool exhausted');
|
||||
return { id: 'run-1' };
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await svc.beginRun({
|
||||
chatId: 'chat-1',
|
||||
workspaceId: 'ws-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
|
||||
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
||||
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
||||
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
||||
// blip — distinct from the per-attempt warns.
|
||||
const gaveUp = errorSpy.mock.calls.some(
|
||||
(c) =>
|
||||
/NON-TERMINAL/.test(String(c[0])) &&
|
||||
/run-1/.test(String(c[0])) &&
|
||||
/chat-1/.test(String(c[0])),
|
||||
);
|
||||
expect(gaveUp).toBe(true);
|
||||
|
||||
// The DB recovers; a later settle now succeeds and frees the slot.
|
||||
healthy = true;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||
expect(repo.update).toHaveBeenLastCalledWith(
|
||||
'run-1',
|
||||
'ws-1',
|
||||
expect.objectContaining({ status: 'succeeded' }),
|
||||
);
|
||||
|
||||
// And it is now idempotent: a further settle no-ops (terminal row already
|
||||
// written), so a double-settle can never clobber the real status.
|
||||
const callsBefore = repo.update.mock.calls.length;
|
||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
||||
});
|
||||
|
||||
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||
const repo = makeRepo({
|
||||
update: jest.fn(async () => {
|
||||
throw new Error('transient');
|
||||
}),
|
||||
});
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||
await expect(svc.recordStep('run-1', 'ws-1', 3)).resolves.toBeUndefined();
|
||||
await expect(
|
||||
svc.linkAssistantMessage('run-1', 'ws-1', 'msg-1'),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,452 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
||||
import { AiChatRun } from '@docmost/db/types/entity.types';
|
||||
import { isUniqueViolation, violatedConstraint } from '@docmost/db/utils';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/** Name of the partial unique index enforcing "one active run per chat" (see the
|
||||
* ai_chat_runs migration). A 23505 on THIS constraint is the race-safe signal
|
||||
* that a concurrent turn already owns the chat — distinct from any other unique
|
||||
* collision, which must NOT be silently treated as "already active". */
|
||||
export const ONE_ACTIVE_RUN_PER_CHAT_INDEX = 'ai_chat_runs_one_active_per_chat';
|
||||
|
||||
/**
|
||||
* Thrown by {@link AiChatRunService.beginRun} when the run-row INSERT loses the
|
||||
* race for a chat's single active slot (the partial unique index rejects it with
|
||||
* a 23505). This is the AUTHORITATIVE concurrency gate: the controller's cheap
|
||||
* pre-check is only a fast-path, and a request that slips past it must NOT run
|
||||
* untracked. The caller (AiChatService.stream) translates this into a 409 and
|
||||
* aborts the turn BEFORE any AI/provider call.
|
||||
*/
|
||||
export class RunAlreadyActiveError extends Error {
|
||||
constructor(public readonly chatId: string) {
|
||||
super(`An agent run is already in progress for chat ${chatId}`);
|
||||
this.name = 'RunAlreadyActiveError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The terminal status of a TURN (the #183 assistant-row lifecycle) maps onto the
|
||||
* terminal status of a RUN (#184). A turn that completed -> the run succeeded; a
|
||||
* turn that errored -> the run failed; a turn aborted (explicit user stop) -> the
|
||||
* run aborted. Pure + unit-testable.
|
||||
*/
|
||||
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||
|
||||
export function mapTurnStatusToRun(
|
||||
status: TurnTerminalStatus,
|
||||
): RunTerminalStatus {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'succeeded';
|
||||
case 'error':
|
||||
return 'failed';
|
||||
case 'aborted':
|
||||
return 'aborted';
|
||||
}
|
||||
}
|
||||
|
||||
/** An in-flight run held in process memory: its AbortController is the ONLY thing
|
||||
* that can stop the turn (an explicit user stop), independent of the browser
|
||||
* socket. A mere disconnect never touches it, so the run keeps going. */
|
||||
interface ActiveRun {
|
||||
controller: AbortController;
|
||||
chatId: string;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/** The live handle the streaming path drives a run through (returned by
|
||||
* {@link AiChatRunService.beginRun}). The `signal` governs the agent loop's
|
||||
* abort — wired to the run, NOT to the HTTP socket. */
|
||||
export interface RunHandle {
|
||||
runId: string;
|
||||
signal: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* AiChatRunService (#184 phase 1) — owns the agent RUN as a first-class,
|
||||
* server-side lifecycle object detached from the HTTP request / browser window.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - create a run row when a turn starts (inserted directly as 'running'; the
|
||||
* 'pending' status is only the column default + a reserved value, never
|
||||
* written by code in phase 1) and register an in-memory AbortController for it
|
||||
* (the explicit-stop lever);
|
||||
* - finalize the run row (succeeded / failed / aborted) and unregister it;
|
||||
* - service an EXPLICIT user stop (`requestStop`) — the ONLY thing that aborts a
|
||||
* run; a browser disconnect deliberately does NOT;
|
||||
* - crash-recovery sweep of dangling runs on startup.
|
||||
*
|
||||
* The agent loop itself still runs in AiChatService.stream (reusing #183's
|
||||
* step-granular durable write path, `consumeStream` already drains it independent
|
||||
* of the socket); this service only wraps it in a durable lifecycle and an
|
||||
* abort handle that outlives the subscriber.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiChatRunService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AiChatRunService.name);
|
||||
|
||||
// runId -> ActiveRun. Process-local on purpose (phase 1 is single-process /
|
||||
// in-memory transport; a cross-process BullMQ runner + Redis stop-signal is
|
||||
// deferred to phase 2). A stop for a runId not in this map (e.g. after a
|
||||
// restart) still records `stop_requested_at` on the row.
|
||||
private readonly active = new Map<string, ActiveRun>();
|
||||
|
||||
// runIds whose TERMINAL row write has SUCCEEDED — the idempotency once-gate
|
||||
// (F6). A finalize must short-circuit only AFTER the terminal write has landed,
|
||||
// NOT merely after the in-memory entry was dropped: a transient UPDATE failure
|
||||
// has to stay retryable, so "already settled" means "row already terminal", not
|
||||
// "entry already gone". Grows by one short UUID per finished run over process
|
||||
// uptime — negligible in phase 1's single process.
|
||||
private readonly settled = new Set<string>();
|
||||
|
||||
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||
// brief connection blip). Riding out that blip in-place matters because the
|
||||
// dominant success path (streamText onFinish) settles exactly ONCE — if that
|
||||
// write is dropped and never retried, the row is stranded 'running' and the
|
||||
// one-active-run gate 409s every future turn in the chat until a restart (no
|
||||
// periodic sweep in phase 1).
|
||||
private static readonly FINALIZE_MAX_ATTEMPTS = 3;
|
||||
private static readonly FINALIZE_RETRY_BASE_MS = 50;
|
||||
|
||||
constructor(
|
||||
private readonly runRepo: AiChatRunRepo,
|
||||
private readonly environment: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Crash-recovery sweep on server start: settle EVERY run still left
|
||||
* pending/running to 'aborted' (F1 / DECISION C). The boot sweep is
|
||||
* UNCONDITIONAL — no staleness window — because phase 1 is single-process: on a
|
||||
* fresh boot any pending|running run is definitionally hung (no live runner owns
|
||||
* it), so even a fast restart (deploy/OOM within minutes of the last step) can
|
||||
* no longer leave a run stuck 'running' forever (which would make the
|
||||
* one-active-run gate 409 every future turn in that chat). The staleness window
|
||||
* is reintroduced only for the phase-2 multi-instance timer sweep, where a
|
||||
* booting replica must not abort a run another replica is actively executing.
|
||||
* Best-effort — a sweep failure is logged but MUST NOT block startup (mirrors
|
||||
* AiChatService.onModuleInit for #183).
|
||||
*/
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.warnIfMultiInstance();
|
||||
try {
|
||||
// No `staleMs`: unconditional boot sweep (F1). See AiChatRunRepo.sweepRunning.
|
||||
const swept = await this.runRepo.sweepRunning();
|
||||
if (swept > 0) {
|
||||
this.logger.log(
|
||||
`Startup sweep: marked ${swept} dangling agent run(s) as 'aborted'.`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Startup sweep of dangling runs failed: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* F2 (DECISION A): autonomous runs are SINGLE-INSTANCE-ONLY in phase 1. An
|
||||
* explicit Stop, and the in-memory AbortController that backs it, are
|
||||
* process-local: a Stop only aborts the live turn if it lands on the SAME
|
||||
* replica that owns the run (it still stamps `stop_requested_at` cross-instance,
|
||||
* but nothing reads that flag during an active run yet). Cross-instance pub/sub
|
||||
* stop is phase 2. So if the deployment is horizontally scaled, warn loudly at
|
||||
* startup that a Stop may not reach a run executing on another replica.
|
||||
*
|
||||
* DETECTION: this codebase always wires the socket.io Redis adapter (REDIS_URL
|
||||
* is mandatory), so the adapter alone is NOT a horizontal-scaling signal. The
|
||||
* authoritative signal the codebase has is `CLOUD=true` (EnvironmentService
|
||||
* .isCloud()), the Docmost-cloud multi-replica deployment. We warn whenever that
|
||||
* is set, because any workspace could enable settings.ai.autonomousRuns. A
|
||||
* self-hosted operator running multiple replicas behind a load balancer is also
|
||||
* multi-instance; the deploy docs (.env.example / AGENTS.md) spell out the
|
||||
* single-instance constraint for that case.
|
||||
*/
|
||||
private warnIfMultiInstance(): void {
|
||||
if (this.environment.isCloud()) {
|
||||
this.logger.warn(
|
||||
'Autonomous agent runs (settings.ai.autonomousRuns) are SINGLE-INSTANCE-ONLY ' +
|
||||
'in phase 1: a horizontally-scaled deployment was detected (CLOUD=true). ' +
|
||||
'An explicit Stop only aborts a run executing on the same replica that owns ' +
|
||||
'it (cross-instance Stop is not yet reliable — phase 2). Run a single ' +
|
||||
'instance if you enable autonomousRuns, or keep the flag off.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a run for a turn: insert the run row (status 'running', startedAt now),
|
||||
* register a fresh AbortController for it, and return a {@link RunHandle} whose
|
||||
* `signal` the agent loop uses. The DB partial unique index guarantees at most
|
||||
* one active run per chat — a second concurrent start on the same chat REJECTS
|
||||
* at the insert (a 23505 on {@link ONE_ACTIVE_RUN_PER_CHAT_INDEX}). That
|
||||
* rejection is the AUTHORITATIVE race gate: it is surfaced as a distinct
|
||||
* {@link RunAlreadyActiveError} (NOT swallowed), so the caller turns it into a
|
||||
* 409 and never streams an untracked turn. The controller is registered AFTER a
|
||||
* successful insert so a rejected start leaks nothing.
|
||||
*/
|
||||
async beginRun(args: {
|
||||
chatId: string;
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
trigger?: string;
|
||||
}): Promise<RunHandle> {
|
||||
let run: AiChatRun;
|
||||
try {
|
||||
run = await this.runRepo.insert({
|
||||
chatId: args.chatId,
|
||||
workspaceId: args.workspaceId,
|
||||
createdBy: args.userId,
|
||||
trigger: args.trigger ?? 'user',
|
||||
status: 'running',
|
||||
startedAt: new Date(),
|
||||
});
|
||||
} catch (err) {
|
||||
// The race backstop: a concurrent turn already holds this chat's single
|
||||
// active slot, so the partial unique index rejected our insert. Surface a
|
||||
// distinct signal — the caller MUST reject this turn (409), not run it
|
||||
// untracked. Any OTHER error propagates unchanged.
|
||||
if (
|
||||
isUniqueViolation(err) &&
|
||||
violatedConstraint(err) === ONE_ACTIVE_RUN_PER_CHAT_INDEX
|
||||
) {
|
||||
throw new RunAlreadyActiveError(args.chatId);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.active.set(run.id, {
|
||||
controller,
|
||||
chatId: args.chatId,
|
||||
workspaceId: args.workspaceId,
|
||||
});
|
||||
return { runId: run.id, signal: controller.signal };
|
||||
}
|
||||
|
||||
/** Link the assistant message (the #183 projection) to its run. Best-effort. */
|
||||
async linkAssistantMessage(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
assistantMessageId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, { assistantMessageId });
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to link assistant message to run ${runId}: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist progress: bump the run's finished-step count. Best-effort (never
|
||||
* blocks or breaks the stream). */
|
||||
async recordStep(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
stepCount: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, { stepCount });
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Failed to record step for run ${runId}: ${
|
||||
err instanceof Error ? err.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
||||
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
||||
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
||||
*
|
||||
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
||||
* so two callers can both see `false` and both write the row (last-write-wins
|
||||
* clobbers the real terminal status, and the bounded retry only widens that
|
||||
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
||||
* check-and-clear with NO await between the gate and the entry removal: the
|
||||
* second concurrent caller finds the entry already gone and returns in the same
|
||||
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
||||
* finalizing" is thus a single atomic step.
|
||||
*
|
||||
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
||||
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
||||
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
||||
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
||||
* would 409 every future turn in the chat until recovery). An in-process retry
|
||||
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
||||
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
||||
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
||||
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
||||
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
||||
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
||||
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
||||
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
||||
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
||||
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
||||
* and harmless meanwhile.
|
||||
*
|
||||
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
||||
* per run. After a successful write the once-gate keys off {@link settled} (the
|
||||
* terminal row already written) so a settle arriving AFTER the entry was already
|
||||
* dropped-and-settled returns early; a settle racing the in-flight write is
|
||||
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
||||
* double-settle collapses to a single write and a late settle can never clobber
|
||||
* the real terminal status or double-write the row.
|
||||
*/
|
||||
async finalizeRun(
|
||||
runId: string,
|
||||
workspaceId: string,
|
||||
turnStatus: TurnTerminalStatus,
|
||||
error?: string,
|
||||
): Promise<void> {
|
||||
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||
// Already terminally written -> idempotent no-op.
|
||||
if (this.settled.has(runId)) return;
|
||||
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
||||
const entry = this.active.get(runId);
|
||||
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||
// the same tick, before any await — so it can never reach the UPDATE.
|
||||
if (!this.active.delete(runId)) return;
|
||||
|
||||
let lastError: unknown;
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= AiChatRunService.FINALIZE_MAX_ATTEMPTS;
|
||||
attempt++
|
||||
) {
|
||||
try {
|
||||
await this.runRepo.update(runId, workspaceId, {
|
||||
status: mapTurnStatusToRun(turnStatus),
|
||||
finishedAt: new Date(),
|
||||
error: error ?? null,
|
||||
});
|
||||
// Terminal write landed: arm the once-gate. The entry is already gone
|
||||
// (claimed above); we do NOT restore it. The slot is now free.
|
||||
this.settled.add(runId);
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
this.logger.warn(
|
||||
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||
);
|
||||
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
||||
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
||||
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
||||
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
||||
this.logger.error(
|
||||
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
||||
`('running'): terminal write failed after ${
|
||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
||||
`boot sweep`,
|
||||
lastError,
|
||||
);
|
||||
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
||||
// that arrives AFTER this restore MAY retry the terminal write — but that
|
||||
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
||||
// window above is consumed at the `active.delete` claim, and the no-streamText
|
||||
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
||||
// is the boot sweep on the next restart; the restored entry is bounded and
|
||||
// cleared on restart.
|
||||
if (entry) this.active.set(runId, entry);
|
||||
}
|
||||
|
||||
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||
* trivial to stub/fake-time in tests. */
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an EXPLICIT stop of a run (the user pressed Stop). This is the ONLY
|
||||
* thing that aborts a run — distinct from a browser disconnect, which leaves
|
||||
* the run going. Aborts the in-process controller FIRST (the only thing that
|
||||
* actually stops the run, if this replica owns it), then makes a best-effort
|
||||
* attempt to stamp `stop_requested_at` — that audit write stamps only while the
|
||||
* row is active and may be skipped on a DB error or lost to the finalize race,
|
||||
* which is acceptable since the row still settles as 'aborted'. Returns true
|
||||
* when a stop took effect (row marked and/or controller aborted), false when
|
||||
* there was nothing active to stop.
|
||||
*/
|
||||
async requestStop(runId: string, workspaceId: string): Promise<boolean> {
|
||||
const entry = this.active.get(runId);
|
||||
if (entry) {
|
||||
// Abort the live turn FIRST -> streamText onAbort fires -> the partial is
|
||||
// persisted (#183) and finalizeRun settles the row as 'aborted'. This is
|
||||
// the ONLY thing that aborts a run, so it MUST NOT be hostage to the audit
|
||||
// write below: a transient failure on `markStopRequested` (pool exhaustion,
|
||||
// deadlock, dropped connection) must never leave the run executing despite
|
||||
// an explicit Stop. At worst only the `stop_requested_at` timestamp is lost.
|
||||
entry.controller.abort();
|
||||
}
|
||||
// Record `stop_requested_at` (best-effort). A transient DB failure here is
|
||||
// logged and treated as `marked = false`; the abort above already took
|
||||
// effect, so we never rethrow and skip stopping the run. Note: because
|
||||
// markStopRequested only stamps while the row is active, aborting first means
|
||||
// even a healthy write can lose the race against the resulting finalize and
|
||||
// skip the stamp — acceptable, as the row still settles as 'aborted' and only
|
||||
// this audit timestamp may be lost.
|
||||
let marked: unknown;
|
||||
try {
|
||||
marked = await this.runRepo.markStopRequested(runId, workspaceId);
|
||||
} catch (err) {
|
||||
marked = undefined;
|
||||
this.logger.warn(
|
||||
`requestStop: markStopRequested failed for run ${runId} ` +
|
||||
`(stop_requested_at not recorded); abort already issued: ` +
|
||||
`${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
return Boolean(marked) || Boolean(entry);
|
||||
}
|
||||
|
||||
/** Latest persisted run for a chat — the reconnect target (an in-flight or
|
||||
* finished run). Pure read-through to the repo. */
|
||||
getLatestForChat(
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiChatRun | undefined> {
|
||||
return this.runRepo.findLatestByChat(chatId, workspaceId);
|
||||
}
|
||||
|
||||
/** Fetch a run by id (workspace-scoped). Used to resolve + ownership-check an
|
||||
* explicit stop targeting a runId. */
|
||||
getRun(runId: string, workspaceId: string): Promise<AiChatRun | undefined> {
|
||||
return this.runRepo.findById(runId, workspaceId);
|
||||
}
|
||||
|
||||
/** The active run on a chat, if any (used to reject a concurrent start with a
|
||||
* clean 409 before committing to the stream). */
|
||||
getActiveForChat(
|
||||
chatId: string,
|
||||
workspaceId: string,
|
||||
): Promise<AiChatRun | undefined> {
|
||||
return this.runRepo.findActiveByChat(chatId, workspaceId);
|
||||
}
|
||||
|
||||
/** Test/diagnostic seam: whether this replica is holding a live controller for
|
||||
* the run. */
|
||||
isLocallyActive(runId: string): boolean {
|
||||
return this.active.has(runId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* In-memory run-stream registry (#184 phase 1.5). A durable agent run tees its
|
||||
* SSE frames here (via `pipeUIMessageStreamToResponse({ consumeSseStream })`)
|
||||
* so a LATE tab — one that reloaded, or opened after the starter dropped — can
|
||||
* attach through `GET /ai-chat/runs/:chatId/stream`, replay the frames buffered
|
||||
* so far, and then follow the live tail as a normal streamer.
|
||||
*
|
||||
* This is deliberately single-process and best-effort: it holds nothing the DB
|
||||
* does not (the run + assistant row are the source of truth), so a process
|
||||
* restart simply drops in-flight entries and the client falls back to its
|
||||
* restore + degraded-poll path. The async `attach` return type is the seam for a
|
||||
* future phase-2 cross-process backend (Redis) — the interface does not change.
|
||||
*/
|
||||
|
||||
/** How long a finished entry is retained for late attach (replay + immediate end). */
|
||||
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
|
||||
|
||||
/** Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204). */
|
||||
export const RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
// 2x the replay cap: a just-written 4MB replay burst alone can never trip the
|
||||
// per-subscriber cap (see controller); only a genuinely stalled socket can.
|
||||
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
|
||||
|
||||
export interface RunStreamCallbacks {
|
||||
onFrame: (frame: string) => void;
|
||||
onEnd: () => void;
|
||||
}
|
||||
|
||||
export interface RunStreamAttachment {
|
||||
replay: string[];
|
||||
finished: boolean;
|
||||
start(): void; // drain pending frames (order preserved) and go live
|
||||
unsubscribe(): void; // safe to call at any point, idempotent
|
||||
}
|
||||
|
||||
interface Subscriber extends RunStreamCallbacks {
|
||||
started: boolean;
|
||||
pending: string[];
|
||||
// Byte size of `pending`, capped at SUBSCRIBER_MAX_BUFFERED_BYTES. `start()` is
|
||||
// called in the SAME tick as `attach()` today (see attach), so `pending` never
|
||||
// holds more than one microtask of frames — but the async `attach` signature is
|
||||
// a phase-2 seam: an await between attach and start would let a stalled paused
|
||||
// subscriber buffer the WHOLE run here. The cap is the structural backstop.
|
||||
pendingBytes: number;
|
||||
overflowed: boolean;
|
||||
pendingEnd: boolean;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
runId: string;
|
||||
// The persisted assistant row id of this run (set at bind; undefined if the
|
||||
// seed failed). Used by the attach anchor check (invariant 6).
|
||||
assistantMessageId?: string;
|
||||
frames: string[];
|
||||
bytes: number;
|
||||
overflowed: boolean;
|
||||
finished: boolean;
|
||||
subscribers: Set<Subscriber>;
|
||||
retainTimer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AiChatStreamRegistryService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(AiChatStreamRegistryService.name);
|
||||
private readonly entries = new Map<string, Entry>(); // key: chatId
|
||||
|
||||
/**
|
||||
* Register a fresh entry at the START of a run (before any frame), so a tab
|
||||
* that attaches in the begin->seed window finds an entry to wait on. If an
|
||||
* entry already exists for this chat (a previous, possibly still-live run whose
|
||||
* tee loop is draining), it is terminated MIRRORING the done-path (invariant 3)
|
||||
* so its subscribers are released and its retention timer is cleared; a late
|
||||
* `done` from that old tee then fires against the closed-over old reference and,
|
||||
* thanks to identity checks, never touches this new entry.
|
||||
*/
|
||||
open(chatId: string, runId: string): void {
|
||||
const existing = this.entries.get(chatId);
|
||||
if (existing) {
|
||||
if (existing.retainTimer) {
|
||||
clearTimeout(existing.retainTimer);
|
||||
existing.retainTimer = undefined;
|
||||
}
|
||||
// Started subscribers get exactly one onEnd() and are removed; paused ones
|
||||
// are marked pendingEnd (their start() will end them). finished=true guards
|
||||
// any later done from the old tee loop from double-notifying.
|
||||
this.terminateSubscribers(existing);
|
||||
}
|
||||
this.entries.set(chatId, {
|
||||
runId,
|
||||
frames: [],
|
||||
bytes: 0,
|
||||
overflowed: false,
|
||||
finished: false,
|
||||
subscribers: new Set<Subscriber>(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Tee a run's SSE frame stream into its entry (called from consumeSseStream).
|
||||
* No-op with a warning when there is no entry or the entry belongs to a
|
||||
* different run (invariant 1). The reader loop is fire-and-forget: the tee
|
||||
* branch outlives the client socket by design.
|
||||
*/
|
||||
bind(
|
||||
chatId: string,
|
||||
runId: string,
|
||||
assistantMessageId: string | undefined,
|
||||
stream: ReadableStream<string>,
|
||||
): void {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.runId !== runId) {
|
||||
// Invariant 1: only the matching run may mutate the entry.
|
||||
this.logger.warn(
|
||||
`bind: no matching run-stream entry for chat=${chatId} run=${runId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
entry.assistantMessageId = assistantMessageId;
|
||||
const reader = stream.getReader();
|
||||
const pump = async (): Promise<void> => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
this.ingestFrame(entry, value);
|
||||
}
|
||||
this.finalizeEntry(chatId, entry);
|
||||
} catch {
|
||||
// A read error is a terminal event too — release subscribers.
|
||||
this.finalizeEntry(chatId, entry);
|
||||
}
|
||||
};
|
||||
void pump();
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate a run's entry from the OUTER catch of the stream method (a failure
|
||||
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
|
||||
* on runId (invariant 1); the shared terminal path is idempotent.
|
||||
*/
|
||||
abortEntry(chatId: string, runId: string): void {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.runId !== runId) return;
|
||||
this.finalizeEntry(chatId, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a run's stream. Async only for the phase-2 Redis seam — the body
|
||||
* runs synchronously so the replay snapshot and the subscriber registration
|
||||
* happen in ONE tick with no await between them (invariant 4): a frame ingested
|
||||
* concurrently cannot slip into the gap and be lost or duplicated.
|
||||
*
|
||||
* Returns null (-> the caller answers 204) when:
|
||||
* - there is no entry, or it overflowed (replay is gone);
|
||||
* - expect=live with an anchor that does not match this run's assistant id
|
||||
* (invariant 6: a stripped tab must never replay a FOREIGN run's transcript);
|
||||
* - the run finished and the caller did not expect a live tail.
|
||||
* A finished run with expect=live yields a replay-only attachment (no
|
||||
* subscriber registered). Otherwise a paused subscriber is registered and the
|
||||
* caller replays `replay`, then calls start() to drain and go live.
|
||||
*/
|
||||
async attach(
|
||||
chatId: string,
|
||||
expectLive: boolean,
|
||||
anchor: string | undefined,
|
||||
cb: RunStreamCallbacks,
|
||||
): Promise<RunStreamAttachment | null> {
|
||||
const entry = this.entries.get(chatId);
|
||||
if (!entry || entry.overflowed) return null;
|
||||
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
|
||||
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
|
||||
if (expectLive && anchor && entry.assistantMessageId !== anchor) return null;
|
||||
if (entry.finished && !expectLive) return null;
|
||||
if (entry.finished && expectLive) {
|
||||
// Replay-only: the run is done, no subscriber is registered.
|
||||
return {
|
||||
replay: entry.frames.slice(),
|
||||
finished: true,
|
||||
start: () => undefined,
|
||||
unsubscribe: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const sub: Subscriber = {
|
||||
onFrame: cb.onFrame,
|
||||
onEnd: cb.onEnd,
|
||||
started: false,
|
||||
pending: [],
|
||||
pendingBytes: 0,
|
||||
overflowed: false,
|
||||
pendingEnd: false,
|
||||
};
|
||||
entry.subscribers.add(sub);
|
||||
// Snapshot in the SAME synchronous block as the registration (invariant 4).
|
||||
const replay = entry.frames.slice();
|
||||
// CONTRACT: the caller MUST call start() in the SAME tick as this attach()
|
||||
// returns — no await between them. While a subscriber is paused, every frame
|
||||
// is buffered in sub.pending; a delayed start() lets a whole run accumulate
|
||||
// there. The pendingBytes cap (see ingestFrame) is the structural backstop if
|
||||
// that contract is ever broken (e.g. the phase-2 Redis await seam).
|
||||
return {
|
||||
replay,
|
||||
finished: false,
|
||||
start: () => {
|
||||
if (sub.overflowed) {
|
||||
// The pending buffer overflowed while paused: end the stream instead of
|
||||
// replaying a partial (a 204-equivalent post-attach degrade).
|
||||
try {
|
||||
sub.onEnd();
|
||||
} catch {
|
||||
// The socket is gone; nothing to end.
|
||||
}
|
||||
entry.subscribers.delete(sub);
|
||||
return;
|
||||
}
|
||||
// Deliver frames buffered while paused, in order, then go live.
|
||||
for (const frame of sub.pending) {
|
||||
try {
|
||||
sub.onFrame(frame);
|
||||
} catch {
|
||||
entry.subscribers.delete(sub);
|
||||
return;
|
||||
}
|
||||
}
|
||||
sub.pending = [];
|
||||
sub.started = true;
|
||||
if (sub.pendingEnd) {
|
||||
try {
|
||||
sub.onEnd();
|
||||
} catch {
|
||||
// The socket is gone; nothing to end.
|
||||
}
|
||||
entry.subscribers.delete(sub);
|
||||
}
|
||||
},
|
||||
unsubscribe: () => {
|
||||
entry.subscribers.delete(sub);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
for (const entry of this.entries.values()) {
|
||||
if (entry.retainTimer) clearTimeout(entry.retainTimer);
|
||||
}
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
/** Buffer + fan-out a single frame. See invariant/overflow semantics inline. */
|
||||
private ingestFrame(entry: Entry, frame: string): void {
|
||||
entry.bytes += Buffer.byteLength(frame);
|
||||
if (!entry.overflowed) {
|
||||
entry.frames.push(frame);
|
||||
if (entry.bytes > RUN_STREAM_MAX_BUFFER_BYTES) {
|
||||
// The crossing frame was already counted AND (below) fanned out; only the
|
||||
// replay buffer is dropped. After overflow no more frames are buffered,
|
||||
// but live fan-out continues.
|
||||
entry.overflowed = true;
|
||||
entry.frames = [];
|
||||
this.logger.warn(
|
||||
`run-stream buffer overflow for run=${entry.runId}; ` +
|
||||
`late attach will 204 until the run ends`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const sub of entry.subscribers) {
|
||||
if (sub.started) {
|
||||
try {
|
||||
sub.onFrame(frame);
|
||||
} catch {
|
||||
entry.subscribers.delete(sub);
|
||||
}
|
||||
} else {
|
||||
sub.pending.push(frame);
|
||||
sub.pendingBytes += Buffer.byteLength(frame);
|
||||
if (sub.pendingBytes > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
||||
// The paused subscriber's buffer overflowed — only possible if start()
|
||||
// was delayed past the same-tick contract (the phase-2 await seam).
|
||||
// Drop it rather than buffer the whole run; on start() it degrades to an
|
||||
// immediate end (a 204-equivalent) instead of replaying a partial.
|
||||
sub.overflowed = true;
|
||||
sub.pending = [];
|
||||
entry.subscribers.delete(sub);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared terminal path for done / read-error / external-abort. Idempotent: a
|
||||
* second call (already finished) is a no-op, so an open()-replaced or
|
||||
* abort-then-done entry is never double-armed or double-ended.
|
||||
*/
|
||||
private finalizeEntry(chatId: string, entry: Entry): void {
|
||||
if (entry.finished) return;
|
||||
this.terminateSubscribers(entry);
|
||||
const timer = setTimeout(() => {
|
||||
// Invariant 2: only delete OUR entry (a replacement may already own the key).
|
||||
if (this.entries.get(chatId) === entry) this.entries.delete(chatId);
|
||||
}, RUN_STREAM_RETAIN_FINISHED_MS);
|
||||
timer.unref?.();
|
||||
entry.retainTimer = timer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the entry finished and release its subscribers, mirroring the done-path:
|
||||
* started subscribers get exactly one onEnd() and are removed; paused ones are
|
||||
* flagged pendingEnd so their start() ends them. Deleting the current element
|
||||
* during Set iteration is safe.
|
||||
*/
|
||||
private terminateSubscribers(entry: Entry): void {
|
||||
entry.finished = true;
|
||||
for (const sub of entry.subscribers) {
|
||||
if (sub.started) {
|
||||
try {
|
||||
sub.onEnd();
|
||||
} catch {
|
||||
// The socket is gone; nothing to end.
|
||||
}
|
||||
entry.subscribers.delete(sub);
|
||||
} else {
|
||||
sub.pendingEnd = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
AiChatStreamRegistryService,
|
||||
RUN_STREAM_MAX_BUFFER_BYTES,
|
||||
RUN_STREAM_RETAIN_FINISHED_MS,
|
||||
RunStreamCallbacks,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
|
||||
/**
|
||||
* Unit tests for the in-memory run-stream registry (#184 phase 1.5). The registry
|
||||
* is the whole of the resumable-transport contract: replay ordering, paused ->
|
||||
* live hand-off, overflow, retention, the anchor check (invariant 6), and the
|
||||
* mirror-the-done-path replace semantics (invariant 3). Every enumerated case in
|
||||
* the issue's task 1.5 has a test here.
|
||||
*/
|
||||
|
||||
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
|
||||
function makePushStream(): {
|
||||
stream: ReadableStream<string>;
|
||||
push: (f: string) => void;
|
||||
close: () => void;
|
||||
error: (e?: unknown) => void;
|
||||
} {
|
||||
let controller!: ReadableStreamDefaultController<string>;
|
||||
const stream = new ReadableStream<string>({
|
||||
start(c) {
|
||||
controller = c;
|
||||
},
|
||||
});
|
||||
return {
|
||||
stream,
|
||||
push: (f) => controller.enqueue(f),
|
||||
close: () => controller.close(),
|
||||
error: (e) => controller.error(e ?? new Error('read error')),
|
||||
};
|
||||
}
|
||||
|
||||
// Let the fire-and-forget pump drain queued frames (reader.read() resolves on a
|
||||
// macrotask boundary for an already-enqueued value).
|
||||
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
function collector(): {
|
||||
cb: RunStreamCallbacks;
|
||||
frames: string[];
|
||||
ended: () => number;
|
||||
} {
|
||||
const frames: string[] = [];
|
||||
let ends = 0;
|
||||
return {
|
||||
frames,
|
||||
ended: () => ends,
|
||||
cb: {
|
||||
onFrame: (f) => frames.push(f),
|
||||
onEnd: () => {
|
||||
ends += 1;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('AiChatStreamRegistryService', () => {
|
||||
const CHAT = 'chat-1';
|
||||
let registry: AiChatStreamRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new AiChatStreamRegistryService();
|
||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registry.onModuleDestroy();
|
||||
});
|
||||
|
||||
it('replays frames in arrival order (live attach)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
src.push('c');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, false, undefined, c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(att!.replay).toEqual(['a', 'b', 'c']);
|
||||
expect(att!.finished).toBe(false);
|
||||
});
|
||||
|
||||
it('late attach gets the full prefix as replay plus the live tail', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
expect(att.replay).toEqual(['a', 'b']);
|
||||
att.start();
|
||||
// Live tail arrives after start().
|
||||
src.push('c');
|
||||
src.push('d');
|
||||
await flush();
|
||||
expect(c.frames).toEqual(['c', 'd']);
|
||||
});
|
||||
|
||||
it('a paused subscriber receives frames buffered during pause in order, then live (no loss/reorder)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
// Attach (paused). Frames that arrive BEFORE start() must queue, not drop.
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
expect(att.replay).toEqual(['a']);
|
||||
src.push('b'); // arrives while paused -> pending
|
||||
src.push('c');
|
||||
await flush();
|
||||
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
|
||||
att.start(); // drains pending in order
|
||||
expect(c.frames).toEqual(['b', 'c']);
|
||||
src.push('d'); // now live
|
||||
await flush();
|
||||
expect(c.frames).toEqual(['b', 'c', 'd']);
|
||||
});
|
||||
|
||||
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
// Terminate the run while the subscriber is still paused.
|
||||
registry.abortEntry(CHAT, 'run-1');
|
||||
expect(c.ended()).toBe(0); // paused: not ended yet
|
||||
att.start();
|
||||
expect(c.ended()).toBe(1); // start() drains + ends
|
||||
});
|
||||
|
||||
it('finished + expect=live returns a replay WITHOUT registering a subscriber', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.push('b');
|
||||
src.close();
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, true, undefined, c.cb))!;
|
||||
expect(att.finished).toBe(true);
|
||||
expect(att.replay).toEqual(['a', 'b']);
|
||||
// No subscriber registered: start()/unsubscribe are no-ops and the entry has
|
||||
// zero subscribers.
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(0);
|
||||
att.start();
|
||||
expect(c.frames).toEqual([]);
|
||||
});
|
||||
|
||||
it('finished WITHOUT expect=live returns null', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
src.close();
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
expect(await registry.attach(CHAT, false, undefined, c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('anchor mismatch with expect=live returns null (and null before bind sets assistantMessageId)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const c = collector();
|
||||
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
|
||||
expect(
|
||||
await registry.attach(CHAT, true, 'assist-1', c.cb),
|
||||
).toBeNull();
|
||||
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
|
||||
expect(await registry.attach(CHAT, true, 'other-id', c.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('matching anchor with expect=live attaches', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = await registry.attach(CHAT, true, 'assist-1', c.cb);
|
||||
expect(att).not.toBeNull();
|
||||
expect(att!.replay).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('overflow: attach returns null, but the LIVE subscriber keeps receiving (incl. the crossing frame)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
// A live (started) subscriber attached before the flood.
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
att.start();
|
||||
|
||||
const oneMb = 'x'.repeat(1024 * 1024);
|
||||
// 5 x 1MB = 5MB > 4MB cap; the 5th frame is the one that crosses.
|
||||
for (let i = 0; i < 5; i++) src.push(oneMb + i);
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.overflowed).toBe(true);
|
||||
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
|
||||
// The live subscriber received ALL 5 frames, including the crossing one.
|
||||
expect(c.frames).toHaveLength(5);
|
||||
expect(c.frames[4]).toBe(oneMb + 4);
|
||||
|
||||
// A NEW attach after overflow gets null (replay buffer is gone).
|
||||
const c2 = collector();
|
||||
expect(await registry.attach(CHAT, false, undefined, c2.cb)).toBeNull();
|
||||
});
|
||||
|
||||
it('a paused subscriber whose pending buffer overflows is dropped and ends on start(); other subscribers keep receiving', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
// A: paused (start() deliberately delayed to simulate the phase-2 await seam).
|
||||
const a = collector();
|
||||
const attA = (await registry.attach(CHAT, false, undefined, a.cb))!;
|
||||
// B: live (started) — its delivery must be unaffected by A's overflow.
|
||||
const b = collector();
|
||||
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
|
||||
attB.start();
|
||||
|
||||
const oneMb = 'x'.repeat(1024 * 1024);
|
||||
// 9 x 1MB = 9MB > 8MB per-subscriber cap; A's pending overflows, B streams live.
|
||||
for (let i = 0; i < 9; i++) src.push(oneMb + i);
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
// A was dropped from the subscriber set on overflow; B (started) remains.
|
||||
expect(entry.subscribers.size).toBe(1);
|
||||
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
|
||||
// B received every frame live (delivery unaffected by A's overflow).
|
||||
expect(b.frames).toHaveLength(9);
|
||||
|
||||
// A's start() (arriving late) degrades to an immediate end, not a partial replay.
|
||||
attA.start();
|
||||
expect(a.frames).toEqual([]);
|
||||
expect(a.ended()).toBe(1);
|
||||
});
|
||||
|
||||
it('open() over a LIVE entry ends started subscribers exactly once and a late done does not touch the new entry (invariant 3)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
|
||||
const c = collector();
|
||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
||||
att.start(); // started subscriber on run-1
|
||||
|
||||
// run-2 starts on the same chat while run-1's tee is still reading.
|
||||
registry.open(CHAT, 'run-2');
|
||||
expect(c.ended()).toBe(1); // exactly one onEnd from the replace
|
||||
|
||||
const newEntry = (registry as any).entries.get(CHAT);
|
||||
expect(newEntry.runId).toBe('run-2');
|
||||
expect(newEntry.finished).toBe(false);
|
||||
|
||||
// The old tee now completes: its late done must NOT double-end nor delete the
|
||||
// new entry.
|
||||
src.push('b');
|
||||
src.close();
|
||||
await flush();
|
||||
expect(c.ended()).toBe(1); // still exactly one
|
||||
const still = (registry as any).entries.get(CHAT);
|
||||
expect(still).toBe(newEntry);
|
||||
expect(still.runId).toBe('run-2');
|
||||
});
|
||||
|
||||
it('bind with a foreign runId is a no-op (invariant 1)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'WRONG-run', 'assist-x', src.stream);
|
||||
src.push('a');
|
||||
await flush();
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
// Frames were NOT ingested (bind bailed), assistantMessageId untouched.
|
||||
expect(entry.frames).toEqual([]);
|
||||
expect(entry.assistantMessageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('abortEntry with a foreign runId is a no-op (invariant 1)', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'WRONG-run');
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.finished).toBe(false);
|
||||
});
|
||||
|
||||
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
const src = makePushStream();
|
||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
||||
|
||||
const bad = collector();
|
||||
const badAtt = (await registry.attach(CHAT, false, undefined, {
|
||||
onFrame: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
onEnd: bad.cb.onEnd,
|
||||
}))!;
|
||||
badAtt.start();
|
||||
|
||||
const good = collector();
|
||||
const goodAtt = (await registry.attach(CHAT, false, undefined, good.cb))!;
|
||||
goodAtt.start();
|
||||
|
||||
src.push('a'); // bad throws on this frame -> ejected
|
||||
src.push('b'); // good still receives both
|
||||
await flush();
|
||||
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry.subscribers.size).toBe(1); // bad ejected, good remains
|
||||
expect(good.frames).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Retention + replace timer behavior. Fake timers, and entries are finalized via
|
||||
* the synchronous abortEntry() path so no stream pump / microtask juggling is
|
||||
* needed.
|
||||
*/
|
||||
describe('AiChatStreamRegistryService retention timers', () => {
|
||||
const CHAT = 'chat-r';
|
||||
let registry: AiChatStreamRegistryService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
registry = new AiChatStreamRegistryService();
|
||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
registry.onModuleDestroy();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('a finished entry is removed after the retention window', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // finalize -> retention armed
|
||||
expect((registry as any).entries.get(CHAT)).toBeDefined();
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
expect((registry as any).entries.get(CHAT)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('retention deletes ONLY its own entry (invariant 2)', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // arm retention for entry A
|
||||
// Simulate the race where the key was replaced without clearing A's timer.
|
||||
const sentinel = { marker: true };
|
||||
(registry as any).entries.set(CHAT, sentinel);
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
// A's timer saw entries.get(CHAT) !== A, so it did NOT delete the successor.
|
||||
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
|
||||
});
|
||||
|
||||
it('open() over a retained entry clears its timer and the successor survives', () => {
|
||||
registry.open(CHAT, 'run-1');
|
||||
registry.abortEntry(CHAT, 'run-1'); // retained, timer armed
|
||||
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
||||
registry.open(CHAT, 'run-2'); // must clear run-1's retain timer
|
||||
expect(clearSpy).toHaveBeenCalled();
|
||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
||||
const entry = (registry as any).entries.get(CHAT);
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry.runId).toBe('run-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type {
|
||||
RunStreamAttachment,
|
||||
RunStreamCallbacks,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
import { SUBSCRIBER_MAX_BUFFERED_BYTES } from './ai-chat-stream-registry.service';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #184 phase 1.5 attach endpoint
|
||||
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
|
||||
* registry is mocked so this exercises ONLY the controller's replay/live/204/
|
||||
* cleanup wiring against a fake raw socket. Constructor order is (aiChatService,
|
||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
|
||||
* streamRegistry, environment).
|
||||
*/
|
||||
describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeRawRes() {
|
||||
const raw: any = {
|
||||
writableEnded: false,
|
||||
writableLength: 0,
|
||||
destroyed: false,
|
||||
written: [] as string[],
|
||||
head: null as any,
|
||||
write: jest.fn((f: string) => {
|
||||
raw.written.push(f);
|
||||
return true;
|
||||
}),
|
||||
writeHead: jest.fn((code: number, headers: any) => {
|
||||
raw.head = { code, headers };
|
||||
return raw;
|
||||
}),
|
||||
flushHeaders: jest.fn(),
|
||||
end: jest.fn(() => {
|
||||
raw.writableEnded = true;
|
||||
}),
|
||||
destroy: jest.fn(() => {
|
||||
raw.destroyed = true;
|
||||
}),
|
||||
on: jest.fn(),
|
||||
once: jest.fn(),
|
||||
};
|
||||
const res: any = {
|
||||
raw,
|
||||
status: jest.fn(() => res),
|
||||
send: jest.fn(),
|
||||
hijack: jest.fn(),
|
||||
};
|
||||
return { res, raw };
|
||||
}
|
||||
|
||||
function makeReq(destroyed = false) {
|
||||
const handlers: Record<string, () => void> = {};
|
||||
const raw: any = {
|
||||
destroyed,
|
||||
once: jest.fn((ev: string, fn: () => void) => {
|
||||
handlers[ev] = fn;
|
||||
}),
|
||||
};
|
||||
return { req: { raw } as any, raw, fireClose: () => handlers['close']?.() };
|
||||
}
|
||||
|
||||
function makeAttachment(
|
||||
over: Partial<RunStreamAttachment> = {},
|
||||
): RunStreamAttachment {
|
||||
return {
|
||||
replay: [],
|
||||
finished: false,
|
||||
start: jest.fn(),
|
||||
unsubscribe: jest.fn(),
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function makeController(opts: {
|
||||
chat?: unknown;
|
||||
attachment?: RunStreamAttachment | null;
|
||||
}) {
|
||||
const aiChatRepo = { findById: jest.fn().mockResolvedValue(opts.chat) };
|
||||
let capturedCb: RunStreamCallbacks | undefined;
|
||||
const streamRegistry = {
|
||||
attach: jest.fn(
|
||||
(
|
||||
_chatId: string,
|
||||
_live: boolean,
|
||||
_anchor: string | undefined,
|
||||
cb: RunStreamCallbacks,
|
||||
) => {
|
||||
capturedCb = cb;
|
||||
return Promise.resolve(
|
||||
opts.attachment === undefined ? makeAttachment() : opts.attachment,
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
const environment = { isAiChatResumableStreamEnabled: () => true };
|
||||
const controller = new AiChatController(
|
||||
{} as never, // aiChatService
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
{} as never, // aiChatMessageRepo
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
streamRegistry as never,
|
||||
environment as never,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
aiChatRepo,
|
||||
streamRegistry,
|
||||
getCb: () => capturedCb!,
|
||||
};
|
||||
}
|
||||
|
||||
const owned = { id: 'c1', creatorId: 'u1' };
|
||||
|
||||
it('owner-gates: a foreign chat throws ForbiddenException and never attaches', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await expect(
|
||||
controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(streamRegistry.attach).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('answers 204 when the registry has nothing to resume (no entry / finished / anchor-mismatch)', async () => {
|
||||
const { controller } = makeController({ chat: owned, attachment: null });
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(204);
|
||||
expect(res.send).toHaveBeenCalled();
|
||||
expect(res.hijack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('threads expect=live and anchor through to the registry', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
});
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
'live',
|
||||
'anchor-1',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
true,
|
||||
'anchor-1',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('passes expect=false when the query is absent', async () => {
|
||||
const { controller, streamRegistry } = makeController({
|
||||
chat: owned,
|
||||
attachment: null,
|
||||
});
|
||||
const { res } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
false,
|
||||
undefined,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('hijacks, writes headers + replay, registers a close cleanup, then goes live', async () => {
|
||||
const start = jest.fn();
|
||||
const attachment = makeAttachment({
|
||||
replay: ['f1', 'f2'],
|
||||
finished: false,
|
||||
start,
|
||||
});
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(res.hijack).toHaveBeenCalled();
|
||||
expect(raw.writeHead).toHaveBeenCalledWith(
|
||||
200,
|
||||
expect.objectContaining({ 'content-type': 'text/event-stream' }),
|
||||
);
|
||||
expect(raw.written).toEqual(['f1', 'f2']); // replay
|
||||
expect(start).toHaveBeenCalled(); // go live after replay
|
||||
expect(req.raw.once).toHaveBeenCalledWith('close', expect.any(Function));
|
||||
});
|
||||
|
||||
it('finished replay ends the response immediately without going live', async () => {
|
||||
const start = jest.fn();
|
||||
const attachment = makeAttachment({
|
||||
replay: ['f1'],
|
||||
finished: true,
|
||||
start,
|
||||
});
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
'live',
|
||||
'a1',
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(raw.written).toEqual(['f1']);
|
||||
expect(raw.end).toHaveBeenCalled();
|
||||
expect(start).not.toHaveBeenCalled(); // finished -> returns before start()
|
||||
});
|
||||
|
||||
it('a close during the awaits (req.raw.destroyed) unsubscribes and writes nothing', async () => {
|
||||
const attachment = makeAttachment({ replay: ['f1'] });
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq(true); // destroyed already at registration time
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(attachment.unsubscribe).toHaveBeenCalled();
|
||||
expect(raw.writeHead).not.toHaveBeenCalled();
|
||||
expect(raw.written).toEqual([]);
|
||||
});
|
||||
|
||||
it('the registered close handler unsubscribes the attachment', async () => {
|
||||
const attachment = makeAttachment({ replay: [] });
|
||||
const { controller } = makeController({ chat: owned, attachment });
|
||||
const { res } = makeRawRes();
|
||||
const { req, fireClose } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
expect(attachment.unsubscribe).not.toHaveBeenCalled();
|
||||
fireClose(); // socket closed
|
||||
expect(attachment.unsubscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onFrame destroys the socket when the buffered length exceeds the cap', async () => {
|
||||
const attachment = makeAttachment({ replay: [] });
|
||||
const { controller, getCb } = makeController({ chat: owned, attachment });
|
||||
const { res, raw } = makeRawRes();
|
||||
const { req } = makeReq();
|
||||
await controller.attachRunStream(
|
||||
'c1',
|
||||
undefined,
|
||||
undefined,
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
workspace,
|
||||
);
|
||||
const cb = getCb();
|
||||
// Normal live frame writes.
|
||||
raw.writableLength = 0;
|
||||
cb.onFrame('live-1');
|
||||
expect(raw.written).toContain('live-1');
|
||||
// A stalled socket over the cap is destroyed instead of buffering.
|
||||
raw.writableLength = SUBSCRIBER_MAX_BUFFERED_BYTES + 1;
|
||||
raw.write.mockClear();
|
||||
cb.onFrame('too-much');
|
||||
expect(raw.destroy).toHaveBeenCalled();
|
||||
expect(raw.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The begin-hook `open()` flag gate (#184 phase 1.5). `open()` lives ONLY in the
|
||||
* stream() begin-hook, gated on the resumable flag. If it regressed, a flag-off
|
||||
* turn would create an EMPTY registry entry (never bound, never finished) and a
|
||||
* later attach would find a non-null paused attachment -> a hung SSE that never
|
||||
* gets a frame and never ends, instead of a clean 204. These drive stream() only
|
||||
* far enough to capture the runHooks it hands to the service, then invoke the
|
||||
* begin-hook and assert whether the registry was opened.
|
||||
*/
|
||||
describe('AiChatController begin-hook open() flag gate (#184 phase 1.5)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = {
|
||||
id: 'ws1',
|
||||
settings: { ai: { chat: true, autonomousRuns: true } },
|
||||
} as unknown as Workspace;
|
||||
|
||||
function makeController(opts: { resumable: boolean }) {
|
||||
let capturedArgs: any;
|
||||
const aiChatService = {
|
||||
resolveRoleForRequest: jest.fn(async () => null),
|
||||
getChatModel: jest.fn(async () => ({})),
|
||||
stream: jest.fn(async (args: any) => {
|
||||
capturedArgs = args;
|
||||
}),
|
||||
};
|
||||
const aiChatRunService = {
|
||||
getActiveForChat: jest.fn(async () => undefined),
|
||||
beginRun: jest.fn(async () => ({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
})),
|
||||
};
|
||||
const streamRegistry = { open: jest.fn(), attach: jest.fn() };
|
||||
const environment = {
|
||||
isAiChatResumableStreamEnabled: () => opts.resumable,
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
aiChatRunService as never,
|
||||
{} as never, // aiChatRepo
|
||||
{} as never, // aiChatMessageRepo
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
streamRegistry as never,
|
||||
environment as never,
|
||||
);
|
||||
return {
|
||||
controller,
|
||||
streamRegistry,
|
||||
aiChatRunService,
|
||||
getRunHooks: () => capturedArgs?.runHooks,
|
||||
};
|
||||
}
|
||||
|
||||
function makeReqRes() {
|
||||
const req: any = {
|
||||
raw: { sessionId: 'sess-1', once: jest.fn() },
|
||||
body: {
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
const res: any = {
|
||||
raw: { once: jest.fn(), on: jest.fn(), headersSent: false, writableEnded: false },
|
||||
hijack: jest.fn(),
|
||||
};
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
it('flag OFF: the begin-hook does NOT open a registry entry (no hung empty entry)', async () => {
|
||||
const { controller, streamRegistry, aiChatRunService, getRunHooks } =
|
||||
makeController({ resumable: false });
|
||||
const { req, res } = makeReqRes();
|
||||
await controller.stream(req, res, user, workspace);
|
||||
|
||||
const runHooks = getRunHooks();
|
||||
expect(runHooks).toBeDefined();
|
||||
const handle = await runHooks.begin('chat-1');
|
||||
// The run still begins (the durable-run feature is independent of resume)...
|
||||
expect(aiChatRunService.beginRun).toHaveBeenCalled();
|
||||
expect(handle).toEqual({ runId: 'run-1', signal: expect.anything() });
|
||||
// ...but with the flag off the registry entry is NEVER opened.
|
||||
expect(streamRegistry.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flag ON: the begin-hook opens the registry entry with (chatId, runId)', async () => {
|
||||
const { controller, streamRegistry, getRunHooks } = makeController({
|
||||
resumable: true,
|
||||
});
|
||||
const { req, res } = makeReqRes();
|
||||
await controller.stream(req, res, user, workspace);
|
||||
|
||||
const runHooks = getRunHooks();
|
||||
await runHooks.begin('chat-1');
|
||||
expect(streamRegistry.open).toHaveBeenCalledWith('chat-1', 'run-1');
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ describe('AiChatController.boundChat', () => {
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('AiChatController.export', () => {
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never,
|
||||
{} as never, // aiChatRunService
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Wiring spec for the #184 run-reconnect / run-stop endpoints
|
||||
* (`POST /ai-chat/run` and `POST /ai-chat/stop`). Both are OWNER-gated via
|
||||
* assertOwnedChat (the requesting user must own the chat) and NOT flag-gated.
|
||||
* Exercised with hand-rolled mocks — no Nest graph, no DB. The controller's
|
||||
* constructor order is (aiChatService, aiChatRunService, aiChatRepo,
|
||||
* aiChatMessageRepo, aiTranscription).
|
||||
*/
|
||||
describe('AiChatController run endpoints (#184)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
function makeController(opts: {
|
||||
chat?: unknown; // what aiChatRepo.findById returns (owner-gate)
|
||||
run?: unknown; // getLatestForChat / getRun result
|
||||
activeRun?: unknown; // getActiveForChat result
|
||||
message?: unknown; // aiChatMessageRepo.findById result
|
||||
stopped?: boolean; // requestStop result
|
||||
}) {
|
||||
const aiChatRunService = {
|
||||
getLatestForChat: jest.fn().mockResolvedValue(opts.run),
|
||||
getRun: jest.fn().mockResolvedValue(opts.run),
|
||||
getActiveForChat: jest.fn().mockResolvedValue(opts.activeRun),
|
||||
requestStop: jest.fn().mockResolvedValue(opts.stopped ?? false),
|
||||
};
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.chat),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
findById: jest.fn().mockResolvedValue(opts.message),
|
||||
};
|
||||
const controller = new AiChatController(
|
||||
{} as never, // aiChatService
|
||||
aiChatRunService as never,
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiTranscription
|
||||
{} as never, // pageRepo
|
||||
);
|
||||
return { controller, aiChatRunService, aiChatRepo, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
describe('POST /ai-chat/run (getRun)', () => {
|
||||
it('owner-gates: a chat the user does not own throws ForbiddenException', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
await expect(
|
||||
controller.getRun({ chatId: 'c1' }, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
// It must NOT reach the run lookup once the owner-gate fails.
|
||||
expect(aiChatRunService.getLatestForChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns { run: null, message: null } when the chat has never had a run', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run: undefined,
|
||||
});
|
||||
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ run: null, message: null });
|
||||
expect(aiChatRunService.getLatestForChat).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the run and its projected assistant message', async () => {
|
||||
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: 'm1' };
|
||||
const message = { id: 'm1', role: 'assistant' };
|
||||
const { controller, aiChatMessageRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run,
|
||||
message,
|
||||
});
|
||||
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ run, message });
|
||||
expect(aiChatMessageRepo.findById).toHaveBeenCalledWith('m1', 'ws1');
|
||||
});
|
||||
|
||||
it('returns message: null when the run has no linked assistant message', async () => {
|
||||
const run = { id: 'run-1', chatId: 'c1', assistantMessageId: null };
|
||||
const { controller, aiChatMessageRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
run,
|
||||
});
|
||||
const res = await controller.getRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ run, message: null });
|
||||
expect(aiChatMessageRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /ai-chat/stop (stopRun)', () => {
|
||||
it('throws BadRequestException when neither runId nor chatId is given', async () => {
|
||||
const { controller } = makeController({});
|
||||
await expect(
|
||||
controller.stopRun({}, user, workspace),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('stops by runId: owner-gates via the run’s chat, then requests the stop', async () => {
|
||||
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||
run: { id: 'run-1', chatId: 'c1' },
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
stopped: true,
|
||||
});
|
||||
const res = await controller.stopRun({ runId: 'run-1' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: true });
|
||||
expect(aiChatRunService.getRun).toHaveBeenCalledWith('run-1', 'ws1');
|
||||
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-1', 'ws1');
|
||||
});
|
||||
|
||||
it('stops by runId: a foreign run’s chat throws ForbiddenException (no stop)', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
run: { id: 'run-1', chatId: 'c1' },
|
||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
||||
});
|
||||
await expect(
|
||||
controller.stopRun({ runId: 'run-1' }, user, workspace),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops by runId: an unknown run reports { stopped: false }', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
run: undefined,
|
||||
});
|
||||
const res = await controller.stopRun({ runId: 'gone' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: false });
|
||||
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops by chatId: owner-gates, resolves the active run, requests the stop', async () => {
|
||||
const { controller, aiChatRunService, aiChatRepo } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
activeRun: { id: 'run-9' },
|
||||
stopped: true,
|
||||
});
|
||||
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: true });
|
||||
expect(aiChatRepo.findById).toHaveBeenCalledWith('c1', 'ws1');
|
||||
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||
'c1',
|
||||
'ws1',
|
||||
);
|
||||
expect(aiChatRunService.requestStop).toHaveBeenCalledWith('run-9', 'ws1');
|
||||
});
|
||||
|
||||
it('stops by chatId: reports { stopped: false } when no run is active', async () => {
|
||||
const { controller, aiChatRunService } = makeController({
|
||||
chat: { id: 'c1', creatorId: 'u1' },
|
||||
activeRun: undefined,
|
||||
});
|
||||
const res = await controller.stopRun({ chatId: 'c1' }, user, workspace);
|
||||
expect(res).toEqual({ stopped: false });
|
||||
expect(aiChatRunService.requestStop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,18 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
ConflictException,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
ServiceUnavailableException,
|
||||
@@ -20,7 +25,13 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { SkipTransform } from '../../common/decorators/skip-transform.decorator';
|
||||
import { AiChat, User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import {
|
||||
AiChat,
|
||||
AiChatMessage,
|
||||
AiChatRun,
|
||||
User,
|
||||
Workspace,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
||||
@@ -28,7 +39,12 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
||||
import { AiChatService, AiChatStreamBody } from './ai-chat.service';
|
||||
import {
|
||||
AiChatRunHooks,
|
||||
AiChatService,
|
||||
AiChatStreamBody,
|
||||
} from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import {
|
||||
BoundChatDto,
|
||||
@@ -36,10 +52,18 @@ import {
|
||||
ExportChatDto,
|
||||
GeneratePageTitleDto,
|
||||
GetChatMessagesDto,
|
||||
GetRunDto,
|
||||
RenameChatDto,
|
||||
StopRunDto,
|
||||
} from './dto/ai-chat.dto';
|
||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||
import { buildChatMarkdown } from './chat-markdown.util';
|
||||
import {
|
||||
AiChatStreamRegistryService,
|
||||
SUBSCRIBER_MAX_BUFFERED_BYTES,
|
||||
} from './ai-chat-stream-registry.service';
|
||||
import { startSseHeartbeat } from './sse-resilience';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
|
||||
/**
|
||||
* Per-user AI chat API (§6.1). Routes are POST to match this codebase's
|
||||
@@ -53,10 +77,16 @@ export class AiChatController {
|
||||
|
||||
constructor(
|
||||
private readonly aiChatService: AiChatService,
|
||||
private readonly aiChatRunService: AiChatRunService,
|
||||
private readonly aiChatRepo: AiChatRepo,
|
||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||
private readonly aiTranscription: AiTranscriptionService,
|
||||
private readonly pageRepo: PageRepo,
|
||||
// #184 phase 1.5. OPTIONAL so existing positional constructions (controller
|
||||
// specs) compile unchanged; Nest always injects the real providers in
|
||||
// production. Only touched on the resumable-stream (flag-on) path.
|
||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
||||
private readonly environment?: EnvironmentService,
|
||||
) {}
|
||||
|
||||
/** List the requesting user's chats in this workspace (paginated). */
|
||||
@@ -149,6 +179,171 @@ export class AiChatController {
|
||||
return { markdown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest run of a chat (#184 phase 1). Returns the run's
|
||||
* persisted lifecycle state ({ status, error, stepCount, timings, ... }) plus
|
||||
* the assistant message it projects (the partial/final output) — the DB is the
|
||||
* source of truth, so this works for an in-flight run (the browser dropped, the
|
||||
* run kept going) and a finished one alike. Owner-gated via assertOwnedChat.
|
||||
* `{ run: null }` when the chat has never had a run.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('run')
|
||||
async getRun(
|
||||
@Body() dto: GetRunDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ run: AiChatRun | null; message: AiChatMessage | null }> {
|
||||
await this.assertOwnedChat(dto.chatId, user, workspace);
|
||||
const run = await this.aiChatRunService.getLatestForChat(
|
||||
dto.chatId,
|
||||
workspace.id,
|
||||
);
|
||||
if (!run) return { run: null, message: null };
|
||||
const message = run.assistantMessageId
|
||||
? await this.aiChatMessageRepo.findById(
|
||||
run.assistantMessageId,
|
||||
workspace.id,
|
||||
)
|
||||
: undefined;
|
||||
return { run, message: message ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP an agent run (#184 phase 1) — the user pressed Stop. This is
|
||||
* the ONLY thing that ends a detached run; a browser disconnect deliberately
|
||||
* does not. Target by `runId` (from the streamed start metadata) or by `chatId`
|
||||
* (stop whatever run is active on it). Owner-gated. Returns
|
||||
* `{ stopped }` — false when there was nothing active to stop.
|
||||
*/
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('stop')
|
||||
async stopRun(
|
||||
@Body() dto: StopRunDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<{ stopped: boolean }> {
|
||||
let runId = dto.runId;
|
||||
if (!runId && !dto.chatId) {
|
||||
throw new BadRequestException('runId or chatId is required');
|
||||
}
|
||||
if (runId) {
|
||||
// Resolve the run to its chat and owner-gate via that chat.
|
||||
const run = await this.aiChatRunService.getRun(runId, workspace.id);
|
||||
if (!run) return { stopped: false };
|
||||
await this.assertOwnedChat(run.chatId, user, workspace);
|
||||
} else {
|
||||
await this.assertOwnedChat(dto.chatId!, user, workspace);
|
||||
const active = await this.aiChatRunService.getActiveForChat(
|
||||
dto.chatId!,
|
||||
workspace.id,
|
||||
);
|
||||
if (!active) return { stopped: false };
|
||||
runId = active.id;
|
||||
}
|
||||
const stopped = await this.aiChatRunService.requestStop(
|
||||
runId,
|
||||
workspace.id,
|
||||
);
|
||||
return { stopped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach to a chat's live run stream (#184 phase 1.5). A late/reloaded tab
|
||||
* replays the frames buffered so far and then follows the live tail as a normal
|
||||
* streamer. Owner-gated via assertOwnedChat (same gate as getRun). When there is
|
||||
* nothing to resume — no entry, a finished run without expect=live, an
|
||||
* overflowed buffer, or an anchor that pins a DIFFERENT run — the endpoint
|
||||
* answers 204, the ONLY "nothing to resume" signal the AI SDK's reconnect
|
||||
* accepts (it maps 204 to a silent no-op). With AI_CHAT_RESUMABLE_STREAM off the
|
||||
* registry is never populated, so attach always 204s.
|
||||
*
|
||||
* `expect=live` opts into replaying a finished-but-retained run (safe only when
|
||||
* the client stripped the streaming tail); `anchor` is the client's assistant
|
||||
* row id, which must match this run's (invariant 6) or a foreign run's
|
||||
* transcript would be replayed into the store.
|
||||
*/
|
||||
@SkipTransform()
|
||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
||||
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 60, ttl: 60000 } })
|
||||
@Get('runs/:chatId/stream')
|
||||
async attachRunStream(
|
||||
@Param('chatId', new ParseUUIDPipe()) chatId: string,
|
||||
@Query('expect') expect: string | undefined,
|
||||
@Query('anchor') anchor: string | undefined,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res() res: FastifyReply,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
await this.assertOwnedChat(chatId, user, workspace); // same gate as getRun
|
||||
let stopHeartbeat: () => void = () => undefined;
|
||||
const attachment = await this.streamRegistry?.attach(
|
||||
chatId,
|
||||
expect === 'live',
|
||||
anchor,
|
||||
{
|
||||
onFrame: (frame) => {
|
||||
// Backpressure guard: 2x the replay cap, so the initial replay burst
|
||||
// alone can never trip it; only a genuinely stalled socket can.
|
||||
try {
|
||||
if (res.raw.writableLength > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
||||
res.raw.destroy(); // 'close' fires -> unsubscribe below
|
||||
return;
|
||||
}
|
||||
if (!res.raw.writableEnded) res.raw.write(frame);
|
||||
} catch {
|
||||
res.raw.destroy();
|
||||
}
|
||||
},
|
||||
onEnd: () => {
|
||||
stopHeartbeat();
|
||||
if (!res.raw.writableEnded) res.raw.end();
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!attachment) {
|
||||
res.status(204).send(); // the ONLY "nothing to resume" signal the SDK accepts
|
||||
return;
|
||||
}
|
||||
res.hijack();
|
||||
// Cleanup BEFORE any write (invariant 5): a torn-down socket must not orphan
|
||||
// a paused subscriber whose pending queue would buffer the whole run.
|
||||
req.raw.once('close', () => {
|
||||
attachment.unsubscribe();
|
||||
stopHeartbeat();
|
||||
});
|
||||
// A close emitted DURING the awaits above was missed by the listener — check.
|
||||
// (Healthy pending GETs have req.raw.destroyed === false, so no false
|
||||
// positives; returning without end() is fine — the socket is gone.)
|
||||
if (req.raw.destroyed) {
|
||||
attachment.unsubscribe();
|
||||
return;
|
||||
}
|
||||
res.raw.on('error', () => undefined);
|
||||
try {
|
||||
res.raw.writeHead(200, {
|
||||
'content-type': 'text/event-stream',
|
||||
'cache-control': 'no-cache',
|
||||
'x-vercel-ai-ui-message-stream': 'v1',
|
||||
'x-accel-buffering': 'no',
|
||||
// deliberately NO Connection/Keep-Alive (hop-by-hop; Safari/HTTP2)
|
||||
});
|
||||
res.raw.flushHeaders?.();
|
||||
for (const frame of attachment.replay) res.raw.write(frame);
|
||||
if (attachment.finished) {
|
||||
res.raw.end();
|
||||
return;
|
||||
}
|
||||
stopHeartbeat = startSseHeartbeat(res.raw, 15_000);
|
||||
attachment.start(); // drain pending accumulated during replay, go live
|
||||
} catch {
|
||||
attachment.unsubscribe();
|
||||
stopHeartbeat();
|
||||
res.raw.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
/** Rename a chat. */
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('rename')
|
||||
@@ -200,11 +395,20 @@ export class AiChatController {
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<void> {
|
||||
// A7 gate: the workspace must have AI chat explicitly enabled.
|
||||
const settings = (workspace.settings ?? {}) as { ai?: { chat?: boolean } };
|
||||
const settings = (workspace.settings ?? {}) as {
|
||||
ai?: { chat?: boolean; autonomousRuns?: boolean };
|
||||
};
|
||||
if (settings.ai?.chat !== true) {
|
||||
throw new ForbiddenException('AI chat is disabled');
|
||||
}
|
||||
|
||||
// #184 phase 1 flag: when ON, the turn becomes a detached, durable RUN — its
|
||||
// lifecycle is tracked in ai_chat_runs, a browser disconnect no longer aborts
|
||||
// it, and only an explicit /ai-chat/stop ends it. When OFF (the default) the
|
||||
// turn is socket-bound exactly as before, so existing deployments are
|
||||
// unaffected.
|
||||
const autonomousRuns = settings.ai?.autonomousRuns === true;
|
||||
|
||||
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
||||
if (!sessionId) {
|
||||
// The chat requires an interactive session to mint loopback tokens
|
||||
@@ -228,6 +432,70 @@ export class AiChatController {
|
||||
// HttpException) instead of breaking mid-stream.
|
||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
||||
|
||||
// #184: one active run per chat. For an EXISTING chat reject a concurrent
|
||||
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
|
||||
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
|
||||
// (no chatId) cannot have a prior run, and the DB partial unique index is the
|
||||
// backstop against any race that slips past this check.
|
||||
if (autonomousRuns && body.chatId) {
|
||||
const active = await this.aiChatRunService.getActiveForChat(
|
||||
body.chatId,
|
||||
workspace.id,
|
||||
);
|
||||
if (active) {
|
||||
throw new ConflictException({
|
||||
message: 'An agent run is already in progress for this chat',
|
||||
code: 'A_RUN_ALREADY_ACTIVE',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
|
||||
// a durable run whose abort is governed by the run (explicit stop), persist
|
||||
// its progress, and settle its terminal status — see AiChatRunService.
|
||||
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
||||
? {
|
||||
begin: async (chatId) => {
|
||||
const handle = await this.aiChatRunService.beginRun({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
trigger: 'user',
|
||||
});
|
||||
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
|
||||
// frame) so a tab that attaches in the begin->seed window finds an
|
||||
// entry to wait on. Gated on AI_CHAT_RESUMABLE_STREAM: with the flag
|
||||
// off nothing is registered and attach always 204s.
|
||||
if (
|
||||
handle?.runId &&
|
||||
this.environment?.isAiChatResumableStreamEnabled?.()
|
||||
) {
|
||||
this.streamRegistry?.open(chatId, handle.runId);
|
||||
}
|
||||
return handle;
|
||||
},
|
||||
onAssistantSeeded: (runId, messageId) =>
|
||||
this.aiChatRunService.linkAssistantMessage(
|
||||
runId,
|
||||
workspace.id,
|
||||
messageId,
|
||||
),
|
||||
onStep: (runId, stepCount) =>
|
||||
void this.aiChatRunService.recordStep(
|
||||
runId,
|
||||
workspace.id,
|
||||
stepCount,
|
||||
),
|
||||
onSettled: (runId, status, error) =>
|
||||
this.aiChatRunService.finalizeRun(
|
||||
runId,
|
||||
workspace.id,
|
||||
status,
|
||||
error,
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||
// normal completion, so only abort when the response has not finished
|
||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
||||
@@ -242,18 +510,44 @@ export class AiChatController {
|
||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
||||
// so log it here before aborting the agent loop.
|
||||
// so log it here.
|
||||
if (!res.raw.writableEnded) {
|
||||
this.logger.warn(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
if (autonomousRuns) {
|
||||
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
||||
// the run keeps executing and persisting server-side; the client
|
||||
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||
this.logger.log(
|
||||
`AI chat stream: client disconnected; run continues server-side ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||
);
|
||||
controller.abort();
|
||||
}
|
||||
}
|
||||
};
|
||||
req.raw.once('close', onClose);
|
||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||
|
||||
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
|
||||
// pipe keeps writing to a socket the client may have dropped — for the rest of
|
||||
// the (continuing) run. A write to the dead socket can emit an 'error' on the
|
||||
// raw response; without a listener that surfaces as an unhandled error event.
|
||||
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
|
||||
// disconnect, so it does not need this and keeps its exact prior behavior.
|
||||
if (autonomousRuns) {
|
||||
res.raw.on('error', (err) => {
|
||||
this.logger.debug(
|
||||
`AI chat detached stream: post-disconnect socket error swallowed: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Commit to streaming: hijack so Fastify stops managing the response and
|
||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||
res.hijack();
|
||||
@@ -268,15 +562,32 @@ export class AiChatController {
|
||||
signal: controller.signal,
|
||||
model,
|
||||
role,
|
||||
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||
runHooks,
|
||||
});
|
||||
} catch (err) {
|
||||
// Any failure AFTER hijack can no longer send a clean JSON error, so emit
|
||||
// a minimal error on the raw socket if nothing has been written yet.
|
||||
this.logger.error('AI chat stream failed', err as Error);
|
||||
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||
// filter, so emit the error on the raw socket if nothing has been written
|
||||
// yet. The lost-the-race 409 (RunAlreadyActiveError -> ConflictException)
|
||||
// is raised by stream() BEFORE it writes a byte, so headers are still
|
||||
// unsent here: honor the HttpException's real status + body (a clean 409),
|
||||
// not a blanket 500. Everything else stays a 500.
|
||||
const isHttp = err instanceof HttpException;
|
||||
if (!isHttp) {
|
||||
this.logger.error('AI chat stream failed', err as Error);
|
||||
}
|
||||
if (!res.raw.headersSent) {
|
||||
res.raw.statusCode = 500;
|
||||
const status = isHttp ? err.getStatus() : 500;
|
||||
const payload = isHttp
|
||||
? err.getResponse()
|
||||
: { error: 'Internal server error' };
|
||||
res.raw.statusCode = status;
|
||||
res.raw.setHeader('Content-Type', 'application/json');
|
||||
res.raw.end(JSON.stringify({ error: 'Internal server error' }));
|
||||
res.raw.end(
|
||||
JSON.stringify(
|
||||
typeof payload === 'string' ? { message: payload } : payload,
|
||||
),
|
||||
);
|
||||
} else if (!res.raw.writableEnded) {
|
||||
res.raw.end();
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('AiChatController.generatePageTitle', () => {
|
||||
const aiChatService = { generatePageTitle: generate };
|
||||
const controller = new AiChatController(
|
||||
aiChatService as never,
|
||||
{} as never, // aiChatRunService
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -3,6 +3,8 @@ import { AiModule } from '../../integrations/ai/ai.module';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
import { AiChatController } from './ai-chat.controller';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import { AiChatStreamRegistryService } from './ai-chat-stream-registry.service';
|
||||
import { AiTranscriptionService } from './ai-transcription.service';
|
||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||
import { EmbeddingModule } from './embedding/embedding.module';
|
||||
@@ -42,6 +44,8 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
||||
controllers: [AiChatController, PublicShareChatController],
|
||||
providers: [
|
||||
AiChatService,
|
||||
AiChatRunService,
|
||||
AiChatStreamRegistryService,
|
||||
AiTranscriptionService,
|
||||
AiChatToolsService,
|
||||
PublicShareChatService,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildSystemPrompt, buildMcpToolingBlock } from './ai-chat.prompt';
|
||||
import {
|
||||
buildSystemPrompt,
|
||||
buildMcpToolingBlock,
|
||||
buildToolCatalogBlock,
|
||||
} from './ai-chat.prompt';
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
@@ -149,6 +153,41 @@ describe('buildSystemPrompt current-page context', () => {
|
||||
expect(prompt).not.toContain('pageId:');
|
||||
});
|
||||
|
||||
// #388: editor-selection flag. Only a FIXED one-liner is added — the selection
|
||||
// TEXT (untrusted page content) must never reach the prompt.
|
||||
const SELECTION_FLAG = 'currently has text SELECTED on this page';
|
||||
|
||||
it('adds the selection flag when a selection is present with a page', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: {
|
||||
id: 'pg-123',
|
||||
title: 'Doc',
|
||||
selection: { text: 'SECRET-SELECTED-TEXT', blockIds: ['b1'] },
|
||||
},
|
||||
});
|
||||
expect(prompt).toContain(SELECTION_FLAG);
|
||||
// The selection TEXT itself is NEVER in the prompt.
|
||||
expect(prompt).not.toContain('SECRET-SELECTED-TEXT');
|
||||
expect(prompt).not.toContain('b1');
|
||||
});
|
||||
|
||||
it('omits the selection flag when there is no selection', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: 'Doc' },
|
||||
});
|
||||
expect(prompt).not.toContain(SELECTION_FLAG);
|
||||
});
|
||||
|
||||
it('omits the selection flag when selection is null', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
openedPage: { id: 'pg-123', title: 'Doc', selection: null },
|
||||
});
|
||||
expect(prompt).not.toContain(SELECTION_FLAG);
|
||||
});
|
||||
|
||||
it('escapes a malicious opened-page title so it cannot inject tags (F1)', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
@@ -396,3 +435,62 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
||||
expect(opens).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #332 deferred tool loading — the <tool_catalog> block builder and its
|
||||
* gating inside buildSystemPrompt.
|
||||
*/
|
||||
describe('buildToolCatalogBlock (#332)', () => {
|
||||
const catalog = [
|
||||
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||
{ name: 'transformPage', catalogLine: 'transformPage — run a JS transform.' },
|
||||
];
|
||||
|
||||
it('renders nothing when the feature is disabled', () => {
|
||||
expect(buildToolCatalogBlock(catalog, false)).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when the catalog is empty', () => {
|
||||
expect(buildToolCatalogBlock([], true)).toBe('');
|
||||
expect(buildToolCatalogBlock(undefined, true)).toBe('');
|
||||
});
|
||||
|
||||
it('renders the verbatim header + each deferred catalogLine when enabled', () => {
|
||||
const block = buildToolCatalogBlock(catalog, true);
|
||||
expect(block).toContain('<tool_catalog note="deferred tools;');
|
||||
expect(block).toContain('NEVER tell the user you lack a capability');
|
||||
expect(block).toContain('Deferred tools (name — purpose):');
|
||||
expect(block).toContain('- createPage — create a new page.');
|
||||
expect(block).toContain('- transformPage — run a JS transform.');
|
||||
expect(block).toContain('</tool_catalog>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemPrompt <tool_catalog> gating (#332)', () => {
|
||||
const workspace = { name: 'Acme' } as unknown as Workspace;
|
||||
const catalog = [
|
||||
{ name: 'createPage', catalogLine: 'createPage — create a new page.' },
|
||||
];
|
||||
|
||||
it('omits the catalog when the toggle is off (unchanged behavior)', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
deferredToolsEnabled: false,
|
||||
toolCatalog: catalog,
|
||||
});
|
||||
expect(prompt).not.toContain('<tool_catalog');
|
||||
expect(prompt).not.toContain('createPage — create a new page.');
|
||||
});
|
||||
|
||||
it('includes the catalog (deferred lines only) when enabled', () => {
|
||||
const prompt = buildSystemPrompt({
|
||||
workspace,
|
||||
deferredToolsEnabled: true,
|
||||
toolCatalog: catalog,
|
||||
});
|
||||
expect(prompt).toContain('<tool_catalog');
|
||||
expect(prompt).toContain('createPage — create a new page.');
|
||||
// A core tool line is never in the catalog (the caller passes deferred only).
|
||||
expect(prompt).not.toContain('searchPages —');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Workspace } from '@docmost/db/types/entity.types';
|
||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
||||
import type { ToolCatalogEntry } from './tools/tool-tiers';
|
||||
|
||||
/**
|
||||
* Default agent persona used when the admin has not configured a custom system
|
||||
@@ -155,8 +156,13 @@ export interface BuildSystemPromptInput {
|
||||
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
|
||||
* "the current page" to that pageId. The page is NOT fetched here — the agent
|
||||
* uses its CASL-enforced read/write page tools with the id when needed.
|
||||
*
|
||||
* `selection` (#388) is present only when the user has a non-empty editor
|
||||
* selection; the prompt adds ONLY a fixed one-line flag from it — the
|
||||
* selection TEXT is untrusted page content and stays out of the prompt (it is
|
||||
* surfaced solely via the getCurrentPage tool result).
|
||||
*/
|
||||
openedPage?: { id?: string; title?: string } | null;
|
||||
openedPage?: { id?: string; title?: string; selection?: object | null } | null;
|
||||
/**
|
||||
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
|
||||
* server's tools"), built by `McpClientsService.toolsFor` for servers that
|
||||
@@ -183,6 +189,55 @@ export interface BuildSystemPromptInput {
|
||||
* block (unchanged page, page not open, or first turn).
|
||||
*/
|
||||
pageChanged?: { title: string; diff: string } | null;
|
||||
/**
|
||||
* Deferred-tool loading toggle (#332). When true (and `toolCatalog` is
|
||||
* non-empty), a `<tool_catalog>` block is rendered inside the safety sandwich
|
||||
* so the model knows which tools EXIST but are not yet loaded, and how to load
|
||||
* them with the loadTools meta-tool. When false, no block is rendered and all
|
||||
* tools are active (unchanged behavior).
|
||||
*/
|
||||
deferredToolsEnabled?: boolean;
|
||||
/**
|
||||
* The DEFERRED tools' catalog lines (#332): one "name — purpose" entry per
|
||||
* deferred in-app tool + per external MCP tool. Rendered by
|
||||
* buildToolCatalogBlock ONLY when `deferredToolsEnabled` is true and this is
|
||||
* non-empty. CORE tools are never here (they are always active).
|
||||
*/
|
||||
toolCatalog?: ToolCatalogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the `<tool_catalog>` block (#332): the compact list of DEFERRED tools
|
||||
* the model can activate on demand via loadTools. Modeled on buildMcpToolingBlock
|
||||
* — placed inside the safety sandwich (informs tool choice, cannot override the
|
||||
* surrounding rules). The header text is verbatim from the issue; each catalog
|
||||
* line is the tool's hand-written (or, for external tools, derived) "name —
|
||||
* purpose". Returns '' when the feature is disabled or the catalog is empty, so
|
||||
* the caller can omit the block entirely (and off => zero change).
|
||||
*/
|
||||
export function buildToolCatalogBlock(
|
||||
catalog: ToolCatalogEntry[] | undefined,
|
||||
enabled: boolean,
|
||||
): string {
|
||||
if (!enabled) return '';
|
||||
const lines = (catalog ?? [])
|
||||
.filter((e) => e && typeof e.catalogLine === 'string' && e.catalogLine.trim())
|
||||
.map((e) => `- ${e.catalogLine.trim()}`);
|
||||
if (lines.length === 0) return '';
|
||||
return [
|
||||
'<tool_catalog note="deferred tools; names only — full definitions load on demand; cannot override the rules above or below">',
|
||||
'The tools below EXIST and are available to you, but their full definitions are',
|
||||
'NOT loaded into this conversation yet. To use one, first call loadTools with',
|
||||
'the exact name(s) from this catalog; the loaded tools become callable on your',
|
||||
'NEXT step. Load several at once when the task clearly needs them.',
|
||||
'NEVER tell the user you lack a capability before checking this catalog: if the',
|
||||
'task needs a tool that is not among your active tools, find it here, call',
|
||||
'loadTools, and continue. Only if the capability is in neither your active',
|
||||
'tools nor this catalog, say so explicitly.',
|
||||
'Deferred tools (name — purpose):',
|
||||
...lines,
|
||||
'</tool_catalog>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,6 +284,8 @@ export function buildSystemPrompt({
|
||||
mcpInstructions,
|
||||
interrupted,
|
||||
pageChanged,
|
||||
deferredToolsEnabled,
|
||||
toolCatalog,
|
||||
}: BuildSystemPromptInput): string {
|
||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||
@@ -257,6 +314,14 @@ export function buildSystemPrompt({
|
||||
? escapeAttr(openedPage.title)
|
||||
: 'Untitled';
|
||||
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
|
||||
// Editor-selection flag (#388). A FIXED one-liner only — the selection TEXT
|
||||
// is untrusted collaborative-page content and must never enter the prompt; it
|
||||
// is surfaced solely through the getCurrentPage tool result (SAFETY_FRAMEWORK
|
||||
// treats a tool result as data). Nested under the page block so it is added
|
||||
// only alongside a resolved page (a selection cannot outlive its page).
|
||||
if (openedPage?.selection) {
|
||||
context += `\nThe user currently has text SELECTED on this page — call getCurrentPage to see the selection. When they say "this", "here", "the selected text" or similar, they mean that selection.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Interrupt-resume marker (#198). Added to the context section (inside the
|
||||
@@ -302,6 +367,16 @@ export function buildSystemPrompt({
|
||||
// Empty when no qualifying server has guidance.
|
||||
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
||||
|
||||
// Deferred-tool catalog (#332). Rendered inside the sandwich next to the MCP
|
||||
// tooling block, ONLY when the feature is enabled and the catalog is non-empty.
|
||||
// Lists the DEFERRED tools (name — purpose) the model can activate via
|
||||
// loadTools; core tools are always active and never here. Empty string when
|
||||
// disabled => the block is omitted and behavior is unchanged.
|
||||
const toolCatalogBlock = buildToolCatalogBlock(
|
||||
toolCatalog,
|
||||
deferredToolsEnabled === true,
|
||||
);
|
||||
|
||||
// Sandwich the lower-trust persona/role text between two copies of the
|
||||
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
||||
// and followed by the safety rules. The persona is delimited with explicit
|
||||
@@ -316,6 +391,7 @@ export function buildSystemPrompt({
|
||||
'</role_persona>',
|
||||
context,
|
||||
mcpTooling,
|
||||
toolCatalogBlock,
|
||||
SAFETY_FRAMEWORK,
|
||||
]
|
||||
.filter((part) => part !== '')
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
||||
aiAgentRoleRepo as never,
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { AiChatService, AiChatRunHooks } from './ai-chat.service';
|
||||
import { AiChatRunService } from './ai-chat-run.service';
|
||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
|
||||
/**
|
||||
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
||||
@@ -22,6 +24,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
return { service, aiChatMessageRepo };
|
||||
}
|
||||
@@ -60,3 +63,99 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #184 CRITICAL run-lifecycle safety net (review fix). A transient failure
|
||||
* AFTER a successful beginRun but BEFORE streamText's terminal callbacks own the
|
||||
* lifecycle must STILL settle the run — otherwise the run row is stuck 'running'
|
||||
* forever (sweepRunning only runs at startup) and the partial unique index + the
|
||||
* controller pre-check 409 every future turn in that chat until a restart. Here
|
||||
* we model the very first bare await after beginRun (the user-message insert)
|
||||
* throwing, wiring the run hooks to a REAL AiChatRunService (mock repo) exactly
|
||||
* as the controller does, and assert the run is settled to 'error' and its
|
||||
* in-memory entry dropped (so a follow-up turn would NOT be 409'd).
|
||||
*/
|
||||
describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
||||
const user = { id: 'u1' } as User;
|
||||
const workspace = { id: 'ws1' } as Workspace;
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('an exception after beginRun settles the run to error and drops the in-memory entry', async () => {
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||
|
||||
// Real run service over a mock repo, so finalizeRun's in-memory bookkeeping
|
||||
// (active.delete) is exercised for real.
|
||||
const runRepo = {
|
||||
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||
};
|
||||
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
||||
|
||||
// The user-message insert (the first bare await after beginRun) throws.
|
||||
const aiChatMessageRepo = {
|
||||
insert: jest.fn().mockRejectedValue(new Error('insert boom')),
|
||||
};
|
||||
const aiChatRepo = {
|
||||
// Existing chat -> chatId stays, no new-chat insert path.
|
||||
findById: jest.fn().mockResolvedValue({ id: 'chat-1', creatorId: 'u1' }),
|
||||
};
|
||||
|
||||
const service = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiChatPageSnapshotRepo
|
||||
{} as never, // aiSettings
|
||||
{} as never, // tools
|
||||
{} as never, // mcpClients
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{} as never, // environment
|
||||
);
|
||||
|
||||
const runHooks: AiChatRunHooks = {
|
||||
begin: (chatId) =>
|
||||
runService.beginRun({
|
||||
chatId,
|
||||
workspaceId: workspace.id,
|
||||
userId: user.id,
|
||||
trigger: 'user',
|
||||
}),
|
||||
onSettled: (runId, status, error) =>
|
||||
runService.finalizeRun(runId, workspace.id, status, error),
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.stream({
|
||||
user,
|
||||
workspace,
|
||||
sessionId: 'sess',
|
||||
body: {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
},
|
||||
res: {} as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks,
|
||||
}),
|
||||
).rejects.toThrow('insert boom');
|
||||
|
||||
// The run was begun...
|
||||
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
||||
// ...then settled to a terminal FAILED status by the safety net...
|
||||
expect(runRepo.update).toHaveBeenCalledTimes(1);
|
||||
expect(runRepo.update).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'ws1',
|
||||
expect.objectContaining({ status: 'failed' }),
|
||||
);
|
||||
// ...and the in-memory entry is gone, so a follow-up turn is NOT 409'd.
|
||||
expect(runService.isLocallyActive('run-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
import { ConflictException, Logger } from '@nestjs/common';
|
||||
|
||||
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
||||
// about to reject. The race rejection happens at runHooks.begin(), long before
|
||||
// any streamText/generateText, so these never resolve a real model.
|
||||
jest.mock('ai', () => ({
|
||||
streamText: jest.fn(),
|
||||
generateText: jest.fn(),
|
||||
convertToModelMessages: jest.fn(() => []),
|
||||
stepCountIs: jest.fn(() => () => false),
|
||||
}));
|
||||
|
||||
import { streamText, generateText } from 'ai';
|
||||
import { AiChatService } from './ai-chat.service';
|
||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||
|
||||
/**
|
||||
* Race-closure coverage for the "one active run per chat" guard (#184).
|
||||
*
|
||||
* THE BUG: two simultaneous POST /ai-chat/stream on the same chat both pass the
|
||||
* controller's cheap pre-check (TOCTOU), so the loser's run-row INSERT hits the
|
||||
* partial unique index. Previously that 23505 was SWALLOWED and the second turn
|
||||
* streamed UNTRACKED (no runId, not stoppable). THE FIX: beginRun surfaces a
|
||||
* RunAlreadyActiveError and stream() turns it into a 409 BEFORE any AI call —
|
||||
* the second turn never runs.
|
||||
*/
|
||||
describe('AiChatService.stream — concurrent-run race rejection (#184)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
const generateTextMock = generateText as unknown as jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
generateTextMock.mockReset();
|
||||
});
|
||||
|
||||
// Minimal service whose only reachable deps before begin() are aiChatRepo
|
||||
// (resolve the existing chat) — everything past begin must remain untouched.
|
||||
function makeService(beginImpl: () => Promise<unknown>) {
|
||||
const aiChatMessageRepo = { insert: jest.fn() };
|
||||
const aiChatRepo = {
|
||||
// An existing chat: stream keeps the supplied chatId and skips creation.
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiChatPageSnapshotRepo
|
||||
{} as never, // aiSettings
|
||||
{} as never, // tools
|
||||
{} as never, // mcpClients
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
||||
);
|
||||
const begin = jest.fn(beginImpl);
|
||||
return { svc, begin, aiChatRepo, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
const baseArgs = (begin: jest.Mock) => ({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: { chatId: 'chat-1', messages: [] } as never,
|
||||
res: { raw: {} } as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: {
|
||||
begin,
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
it('rejects the racer with a 409 ConflictException BEFORE any AI call, and never persists an untracked turn', async () => {
|
||||
// begin loses the unique-index race -> RunAlreadyActiveError.
|
||||
const { svc, begin, aiChatMessageRepo } = makeService(() => {
|
||||
throw new RunAlreadyActiveError('chat-1');
|
||||
});
|
||||
|
||||
const promise = svc.stream(baseArgs(begin));
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(ConflictException);
|
||||
await promise.catch((err: ConflictException) => {
|
||||
expect(err.getStatus()).toBe(409);
|
||||
expect((err.getResponse() as { code?: string }).code).toBe(
|
||||
'A_RUN_ALREADY_ACTIVE',
|
||||
);
|
||||
});
|
||||
|
||||
// The decisive assertions: the rejected racer spent NO tokens and left NO
|
||||
// untracked turn behind.
|
||||
expect(begin).toHaveBeenCalledTimes(1);
|
||||
expect(streamTextMock).not.toHaveBeenCalled();
|
||||
expect(generateTextMock).not.toHaveBeenCalled();
|
||||
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* F3 — the LOAD-BEARING run-detach wiring: `effectiveSignal = handle.signal`
|
||||
* after runHooks.begin, then `abortSignal: effectiveSignal` passed to streamText.
|
||||
* That single line is what makes a run survive a browser disconnect (the agent
|
||||
* loop's abort is governed by the RUN's signal, not the socket): a regression to
|
||||
* the socket-bound signal would still pass every other test green while silently
|
||||
* breaking Stop + durability. These two tests pin the exact signal streamText
|
||||
* consumes on both paths.
|
||||
*/
|
||||
describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
|
||||
// A streamText result stub: the post-call drain + pipe are no-ops here; we only
|
||||
// care WHICH abortSignal streamText was handed.
|
||||
function makeStreamResult() {
|
||||
return {
|
||||
consumeStream: jest.fn(),
|
||||
pipeUIMessageStreamToResponse: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// A raw-response stub sufficient for the post-streamText wiring
|
||||
// (stripStreamingHopByHopHeaders binds writeHead; startSseHeartbeat registers
|
||||
// close/finish listeners; flushHeaders is belt-and-braces).
|
||||
function makeRes() {
|
||||
return {
|
||||
raw: {
|
||||
writeHead: jest.fn(),
|
||||
write: jest.fn(),
|
||||
once: jest.fn(),
|
||||
on: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Wire only the deps reached on the way to streamText: resolve the existing
|
||||
// chat, persist the user + seed the assistant row, load (empty) history, the
|
||||
// admin settings, an empty external toolset + Docmost toolset.
|
||||
function makeService() {
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
const mcpClients = {
|
||||
toolsFor: jest.fn(async () => ({
|
||||
tools: {},
|
||||
clients: [],
|
||||
outcomes: [],
|
||||
instructions: [],
|
||||
})),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiChatPageSnapshotRepo
|
||||
aiSettings as never,
|
||||
tools as never,
|
||||
mcpClients as never,
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo (openPage undefined -> never touched)
|
||||
{} as never, // pageAccess
|
||||
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
||||
);
|
||||
return { svc };
|
||||
}
|
||||
|
||||
const body = {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
streamTextMock.mockImplementation(() => makeStreamResult());
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('happy path (run-wrapped): streamText is driven with abortSignal === handle.signal (the RUN signal, NOT the socket)', async () => {
|
||||
const { svc } = makeService();
|
||||
const runController = new AbortController();
|
||||
const runSignal = runController.signal;
|
||||
const socketSignal = new AbortController().signal;
|
||||
|
||||
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: socketSignal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: {
|
||||
begin,
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(begin).toHaveBeenCalledTimes(1);
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
// THE assertion: the agent loop's abort is wired to the RUN, so a browser
|
||||
// disconnect (which aborts only `socketSignal`) cannot end the turn.
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(runSignal);
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).not.toBe(socketSignal);
|
||||
});
|
||||
|
||||
it('legacy path (no runHooks): streamText is driven with the SOCKET signal', async () => {
|
||||
const { svc } = makeService();
|
||||
const socketSignal = new AbortController().signal;
|
||||
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: socketSignal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
// No runHooks -> the turn stays socket-bound (flag off / default).
|
||||
});
|
||||
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
|
||||
});
|
||||
|
||||
/**
|
||||
* F9 — streamText's TERMINAL callbacks carry the #184 run lifecycle:
|
||||
* onStepFinish -> runHooks.onStep(runId, stepCount)
|
||||
* onFinish -> runHooks.onSettled(runId, 'completed') (dominant path)
|
||||
* onAbort -> runHooks.onSettled(runId, 'aborted')
|
||||
* onError -> runHooks.onSettled(runId, 'error', cause)
|
||||
* makeStreamResult() ignores the streamText options, so these callbacks never
|
||||
* fire on their own — a regression in this wiring (esp. the success path) would
|
||||
* strand the run with NO test catching it. Here we CAPTURE the options streamText
|
||||
* was handed and invoke each callback with the real wiring, asserting the run
|
||||
* hooks fire with the right args.
|
||||
*/
|
||||
// Drive stream() to the point streamText is called, capturing the options object
|
||||
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
||||
async function captureStreamCallbacks() {
|
||||
const { svc } = makeService();
|
||||
let capturedOpts: any;
|
||||
streamTextMock.mockImplementation((opts: any) => {
|
||||
capturedOpts = opts;
|
||||
return makeStreamResult();
|
||||
});
|
||||
const runHooks = {
|
||||
begin: jest.fn(async () => ({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
})),
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
};
|
||||
await svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: runHooks as never,
|
||||
});
|
||||
expect(capturedOpts).toBeDefined();
|
||||
return { capturedOpts, runHooks };
|
||||
}
|
||||
|
||||
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
||||
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||
|
||||
// A finished step -> onStep(runId, finishedStepCount).
|
||||
capturedOpts.onStepFinish({ text: 'step one', toolCalls: [], content: [] });
|
||||
expect(runHooks.onStep).toHaveBeenCalledWith('run-1', 1);
|
||||
capturedOpts.onStepFinish({ text: 'step two', toolCalls: [], content: [] });
|
||||
expect(runHooks.onStep).toHaveBeenLastCalledWith('run-1', 2);
|
||||
|
||||
// The success terminal callback settles the run.
|
||||
await capturedOpts.onFinish({
|
||||
text: 'done',
|
||||
finishReason: 'stop',
|
||||
totalUsage: {},
|
||||
usage: {},
|
||||
steps: [],
|
||||
});
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
|
||||
});
|
||||
|
||||
it('F9: onAbort settles the run "aborted"', async () => {
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||
|
||||
await capturedOpts.onAbort({ steps: [] });
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'aborted');
|
||||
});
|
||||
|
||||
it('F9: onError settles the run "error" carrying the provider cause', async () => {
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as never);
|
||||
const { capturedOpts, runHooks } = await captureStreamCallbacks();
|
||||
|
||||
await capturedOpts.onError({ error: new Error('provider exploded') });
|
||||
expect(runHooks.onSettled).toHaveBeenCalledWith(
|
||||
'run-1',
|
||||
'error',
|
||||
expect.stringContaining('provider exploded'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* F14 — the begin-failure RESILIENCE branch (the `else` of the run-race guard).
|
||||
*
|
||||
* stream() wraps runHooks.begin in try/catch with TWO branches:
|
||||
* - RunAlreadyActiveError -> 409 ConflictException (pinned above).
|
||||
* - ANY OTHER begin failure -> SWALLOW + continue UNTRACKED on the socket signal
|
||||
* (legacy fallback): it logs "...streaming without run tracking", leaves
|
||||
* `effectiveSignal = signal` (runId undefined) and serves the turn anyway.
|
||||
*
|
||||
* The contract: a transient beginRun failure (e.g. a non-unique DB error inserting
|
||||
* the run row) must STILL serve the user's turn — it must NOT re-throw and must NOT
|
||||
* be misclassified as a 409. A regression that re-threw here would break EVERY turn
|
||||
* on a begin failure with nothing to catch it. This branch is otherwise undriven by
|
||||
* any spec, so it is pinned here SEPARATELY from the 409 path: a plain begin error
|
||||
* proceeds to streamText with the SOCKET signal and still persists the user turn.
|
||||
*/
|
||||
describe('AiChatService.stream — begin-failure resilience / legacy fallback (#184 F14)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
|
||||
function makeStreamResult() {
|
||||
return {
|
||||
consumeStream: jest.fn(),
|
||||
pipeUIMessageStreamToResponse: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRes() {
|
||||
return {
|
||||
raw: {
|
||||
writeHead: jest.fn(),
|
||||
write: jest.fn(),
|
||||
once: jest.fn(),
|
||||
on: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Same harness as the F3 abortSignal block, but it also exposes
|
||||
// aiChatMessageRepo so we can assert the user turn IS persisted (the turn really
|
||||
// streamed) despite begin() blowing up.
|
||||
function makeService() {
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
const mcpClients = {
|
||||
toolsFor: jest.fn(async () => ({
|
||||
tools: {},
|
||||
clients: [],
|
||||
outcomes: [],
|
||||
instructions: [],
|
||||
})),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiChatPageSnapshotRepo
|
||||
aiSettings as never,
|
||||
tools as never,
|
||||
mcpClients as never,
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo
|
||||
{} as never, // pageAccess
|
||||
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
||||
);
|
||||
return { svc, aiChatMessageRepo };
|
||||
}
|
||||
|
||||
const body = {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
streamTextMock.mockImplementation(() => makeStreamResult());
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'log')
|
||||
.mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) does NOT 409 — it swallows, logs, and streams the turn UNTRACKED on the socket signal', async () => {
|
||||
const errorSpy = jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
|
||||
const { svc, aiChatMessageRepo } = makeService();
|
||||
const socketSignal = new AbortController().signal;
|
||||
|
||||
// A transient, NON-race begin failure (e.g. a non-unique DB error inserting
|
||||
// the run row). This is the `else` branch of the begin try/catch.
|
||||
const begin = jest.fn(async () => {
|
||||
throw new Error('insert failed');
|
||||
});
|
||||
|
||||
const promise = svc.stream({
|
||||
user: { id: 'user-1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 'sess-1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: socketSignal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: {
|
||||
begin,
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// The turn proceeds: NO throw at all (in particular NOT a 409).
|
||||
await expect(promise).resolves.toBeUndefined();
|
||||
|
||||
expect(begin).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The resilience branch logged the legacy-fallback warning.
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('streaming without run tracking'),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// The turn really streamed: the user message was persisted and streamText ran.
|
||||
expect(aiChatMessageRepo.insert).toHaveBeenCalled();
|
||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The decisive wiring: with no run handle, the fallback uses the SOCKET signal
|
||||
// (effectiveSignal = signal, runId undefined) — not a run-bound signal.
|
||||
expect(streamTextMock.mock.calls[0][0].abortSignal).toBe(socketSignal);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { ForbiddenException, Logger } from '@nestjs/common';
|
||||
// Mock ONLY streamText so a driven stream() call can capture the pipe-options
|
||||
// object (consumeSseStream / generateMessageId). Everything else in the AI SDK
|
||||
// stays REAL (requireActual), so the pure-helper suites in this file are
|
||||
// unaffected — none of them call stream()/streamText.
|
||||
jest.mock('ai', () => ({
|
||||
...jest.requireActual('ai'),
|
||||
streamText: jest.fn(),
|
||||
}));
|
||||
import { streamText } from 'ai';
|
||||
import {
|
||||
AiChatService,
|
||||
compactToolOutput,
|
||||
@@ -217,23 +226,78 @@ describe('rowToUiMessage', () => {
|
||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||
* appended onto — not replacing — the original system prompt.
|
||||
*/
|
||||
// Narrowing helpers for the prepareAgentStep union return type.
|
||||
const asLockdown = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||
r as { toolChoice: 'none'; system: string };
|
||||
const asActive = (r: ReturnType<typeof prepareAgentStep>) =>
|
||||
r as { activeTools: string[] };
|
||||
|
||||
describe('prepareAgentStep', () => {
|
||||
it('returns undefined for the first step', () => {
|
||||
// --- toggle OFF (default): unchanged behavior ---
|
||||
it('returns undefined for the first step (toggle off)', () => {
|
||||
expect(prepareAgentStep(0, 'SYS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for a non-final step (just before the last)', () => {
|
||||
it('returns undefined for a non-final step (toggle off)', () => {
|
||||
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('forces a text-only synthesis on the final allowed step', () => {
|
||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
||||
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
|
||||
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.toolChoice).toBe('none');
|
||||
expect(result.toolChoice).toBe('none');
|
||||
// The original persona is preserved (prefix), not replaced.
|
||||
expect(result?.system.startsWith('SYS')).toBe(true);
|
||||
expect(result.system.startsWith('SYS')).toBe(true);
|
||||
// The synthesis instruction is appended.
|
||||
expect(result?.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
});
|
||||
|
||||
it('does NOT narrow activeTools when the toggle is off', () => {
|
||||
const result = prepareAgentStep(0, 'SYS', new Set(['createPage']), false);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- toggle ON (#332): deferred tool visibility ---
|
||||
it('a non-final step exposes CORE + loadTools + activatedTools', () => {
|
||||
const activated = new Set<string>();
|
||||
const result = asActive(prepareAgentStep(0, 'SYS', activated, true));
|
||||
expect(result.activeTools).toContain('searchPages'); // core
|
||||
expect(result.activeTools).toContain('searchInPage'); // #330, core
|
||||
expect(result.activeTools).toContain('editPageText'); // core
|
||||
expect(result.activeTools).toContain('loadTools'); // meta-tool
|
||||
// No deferred tool is active before it is loaded.
|
||||
expect(result.activeTools).not.toContain('createPage');
|
||||
expect(result.activeTools).not.toContain('transformPage');
|
||||
});
|
||||
|
||||
it('adding a name to activatedTools makes it appear on the next step', () => {
|
||||
const activated = new Set<string>();
|
||||
// Before loading: createPage is not active.
|
||||
expect(
|
||||
asActive(prepareAgentStep(1, 'SYS', activated, true)).activeTools,
|
||||
).not.toContain('createPage');
|
||||
// loadTools grows the SAME set…
|
||||
activated.add('createPage');
|
||||
// …so the next step sees it.
|
||||
const next = asActive(prepareAgentStep(2, 'SYS', activated, true));
|
||||
expect(next.activeTools).toContain('createPage');
|
||||
expect(next.activeTools).toContain('loadTools');
|
||||
});
|
||||
|
||||
it('accepts an array for activatedTools too', () => {
|
||||
const result = asActive(prepareAgentStep(0, 'SYS', ['transformPage'], true));
|
||||
expect(result.activeTools).toContain('transformPage');
|
||||
expect(result.activeTools).toContain('loadTools');
|
||||
});
|
||||
|
||||
it('final-step lockdown WINS even when the toggle is on', () => {
|
||||
const result = asLockdown(
|
||||
prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS', new Set(['createPage']), true),
|
||||
);
|
||||
// The lockdown shape (toolChoice none + synthesis) — not the activeTools shape.
|
||||
expect(result.toolChoice).toBe('none');
|
||||
expect(result.system).toContain(FINAL_STEP_INSTRUCTION);
|
||||
expect((result as unknown as { activeTools?: string[] }).activeTools).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -398,6 +462,12 @@ describe('chatStreamMetadata', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('attaches the runId on the start part when a run wraps the turn (#184)', () => {
|
||||
expect(
|
||||
chatStreamMetadata({ type: 'start' }, 'chat-1', undefined, 'run-1'),
|
||||
).toEqual({ chatId: 'chat-1', runId: 'run-1' });
|
||||
});
|
||||
|
||||
it('returns the CUMULATIVE step usage passed in for the finish-step part', () => {
|
||||
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||
// running sum, which this just wraps.
|
||||
@@ -628,6 +698,7 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: Date;
|
||||
selection: unknown;
|
||||
} | null>;
|
||||
|
||||
it('returns null when no page is open (no id)', async () => {
|
||||
@@ -673,8 +744,14 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
||||
});
|
||||
// The client claims it is on "Page A" but the id points at page B.
|
||||
const result = await call(svc, { id: 'p-1', title: 'Page A' });
|
||||
// updatedAt (#274 page-change fast path) is carried through from the DB row.
|
||||
expect(result).toEqual({ id: 'p-1', title: 'Real Title B', updatedAt });
|
||||
// updatedAt (#274 page-change fast path) is carried through from the DB row;
|
||||
// selection is null when the client sent none (#388).
|
||||
expect(result).toEqual({
|
||||
id: 'p-1',
|
||||
title: 'Real Title B',
|
||||
updatedAt,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('coerces a null DB title to an empty string', async () => {
|
||||
@@ -687,8 +764,55 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
||||
id: 'p-1',
|
||||
title: '',
|
||||
updatedAt,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
// #388: the selection rides ONLY on a successful page resolve, and is
|
||||
// sanitized on the way through.
|
||||
it('attaches the SANITIZED selection on a successful resolve', async () => {
|
||||
const updatedAt = new Date('2026-07-02T10:00:00Z');
|
||||
const svc = makeService({
|
||||
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Doc', updatedAt },
|
||||
canView: true,
|
||||
});
|
||||
const result = await call(svc, {
|
||||
id: 'p-1',
|
||||
selection: {
|
||||
text: 'fix this',
|
||||
blockIds: ['b1', 123, 'y'.repeat(65)], // garbage stripped by sanitize
|
||||
before: 'please ',
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({
|
||||
id: 'p-1',
|
||||
title: 'Doc',
|
||||
updatedAt,
|
||||
selection: { text: 'fix this', blockIds: ['b1'], before: 'please ' },
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a blank/garbage selection to null on a successful resolve', async () => {
|
||||
const updatedAt = new Date('2026-07-02T10:00:00Z');
|
||||
const svc = makeService({
|
||||
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Doc', updatedAt },
|
||||
canView: true,
|
||||
});
|
||||
expect(
|
||||
await call(svc, { id: 'p-1', selection: { text: ' ' } }),
|
||||
).toEqual({ id: 'p-1', title: 'Doc', updatedAt, selection: null });
|
||||
});
|
||||
|
||||
it('selection does NOT survive a foreign/inaccessible page (dies with the page)', async () => {
|
||||
// Forbidden page => the WHOLE context is null, so the selection is gone too.
|
||||
const svc = makeService({
|
||||
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Restricted' },
|
||||
canView: false,
|
||||
});
|
||||
expect(
|
||||
await call(svc, { id: 'p-1', selection: { text: 'secret sel' } }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -998,3 +1122,181 @@ describe('isInterruptResume', () => {
|
||||
expect(isInterruptResume(withPrev(null), true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #184 phase 1.5 — the run-wrapped pipe options (unit). Drives stream() to the
|
||||
* pipe call with streamText mocked, capturing the options object, and asserts:
|
||||
* - flag OFF while a runId IS present -> the LEGACY option shape (no
|
||||
* consumeSseStream, no generateMessageId), and the registry is never touched.
|
||||
* This is the exact dormancy guarantee this PR rests on.
|
||||
* - flag ON + runId -> consumeSseStream tees into the registry and
|
||||
* generateMessageId returns the seeded assistant DB row id.
|
||||
* - flag ON but no runHooks (runId undefined) -> legacy (the runId gate).
|
||||
* - flag ON + runId -> the outer catch releases the entry via abortEntry.
|
||||
*/
|
||||
describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', () => {
|
||||
const streamTextMock = streamText as unknown as jest.Mock;
|
||||
let pipeMock: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
streamTextMock.mockReset();
|
||||
pipeMock = jest.fn();
|
||||
streamTextMock.mockReturnValue({
|
||||
consumeStream: jest.fn(),
|
||||
pipeUIMessageStreamToResponse: pipeMock,
|
||||
});
|
||||
// Silence the service's diagnostic logging for a clean test run.
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'error')
|
||||
.mockImplementation(() => undefined as never);
|
||||
jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
// A raw-response stub sufficient for the post-streamText wiring.
|
||||
function makeRes() {
|
||||
return {
|
||||
raw: {
|
||||
writeHead: jest.fn(),
|
||||
write: jest.fn(),
|
||||
once: jest.fn(),
|
||||
on: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
writableEnded: false,
|
||||
destroyed: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Wire only the deps reached on the way to the pipe call, plus a spy registry.
|
||||
function makeService(opts: { resumable: boolean }) {
|
||||
const aiChatRepo = {
|
||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||
insert: jest.fn(),
|
||||
};
|
||||
const aiChatMessageRepo = {
|
||||
// Both the user insert and the assistant seed return the same row id.
|
||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
findAllByChat: jest.fn(async () => []),
|
||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||
};
|
||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||
const tools = { forUser: jest.fn(async () => ({})) };
|
||||
const mcpClients = {
|
||||
toolsFor: jest.fn(async () => ({
|
||||
tools: {},
|
||||
clients: [],
|
||||
outcomes: [],
|
||||
instructions: [],
|
||||
})),
|
||||
};
|
||||
const streamRegistry = {
|
||||
open: jest.fn(),
|
||||
bind: jest.fn(),
|
||||
abortEntry: jest.fn(),
|
||||
};
|
||||
const svc = new AiChatService(
|
||||
{} as never, // ai (model is injected)
|
||||
aiChatRepo as never,
|
||||
aiChatMessageRepo as never,
|
||||
{} as never, // aiChatPageSnapshotRepo
|
||||
aiSettings as never,
|
||||
tools as never,
|
||||
mcpClients as never,
|
||||
{} as never, // aiAgentRoleRepo
|
||||
{} as never, // pageRepo (openPage undefined -> never touched)
|
||||
{} as never, // pageAccess
|
||||
{
|
||||
isAiChatDeferredToolsEnabled: () => false,
|
||||
isAiChatResumableStreamEnabled: () => opts.resumable,
|
||||
} as never,
|
||||
streamRegistry as never,
|
||||
);
|
||||
return { svc, streamRegistry };
|
||||
}
|
||||
|
||||
const body = {
|
||||
chatId: 'chat-1',
|
||||
messages: [
|
||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const makeRunHooks = () => ({
|
||||
begin: jest.fn(async () => ({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
})),
|
||||
onAssistantSeeded: jest.fn(),
|
||||
onStep: jest.fn(),
|
||||
onSettled: jest.fn(),
|
||||
});
|
||||
|
||||
async function drive(svc: AiChatService, hooks: unknown): Promise<void> {
|
||||
await svc.stream({
|
||||
user: { id: 'u1' } as never,
|
||||
workspace: { id: 'ws-1' } as never,
|
||||
sessionId: 's1',
|
||||
body: body as never,
|
||||
res: makeRes() as never,
|
||||
signal: new AbortController().signal,
|
||||
model: {} as never,
|
||||
role: null,
|
||||
runHooks: hooks as never,
|
||||
});
|
||||
}
|
||||
|
||||
it('flag OFF + runId present: LEGACY option shape (no consumeSseStream / generateMessageId); registry untouched', async () => {
|
||||
const { svc, streamRegistry } = makeService({ resumable: false });
|
||||
await drive(svc, makeRunHooks());
|
||||
expect(pipeMock).toHaveBeenCalledTimes(1);
|
||||
const options = pipeMock.mock.calls[0][1];
|
||||
// The dormancy guarantee: a live run with the flag off tees NOTHING and does
|
||||
// not stamp a message id — byte-for-byte the pre-1.5 wire.
|
||||
expect(options.consumeSseStream).toBeUndefined();
|
||||
expect(options.generateMessageId).toBeUndefined();
|
||||
expect(streamRegistry.bind).not.toHaveBeenCalled();
|
||||
expect(streamRegistry.abortEntry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flag ON + runId: consumeSseStream tees into the registry; generateMessageId returns the seeded row id', async () => {
|
||||
const { svc, streamRegistry } = makeService({ resumable: true });
|
||||
await drive(svc, makeRunHooks());
|
||||
const options = pipeMock.mock.calls[0][1];
|
||||
expect(typeof options.consumeSseStream).toBe('function');
|
||||
expect(typeof options.generateMessageId).toBe('function');
|
||||
// generateMessageId stamps the seeded assistant DB row id.
|
||||
expect(options.generateMessageId()).toBe('msg-1');
|
||||
// consumeSseStream binds the tee: (chatId, runId, assistantId, stream).
|
||||
const fakeStream = {} as ReadableStream<string>;
|
||||
options.consumeSseStream({ stream: fakeStream });
|
||||
expect(streamRegistry.bind).toHaveBeenCalledWith(
|
||||
'chat-1',
|
||||
'run-1',
|
||||
'msg-1',
|
||||
fakeStream,
|
||||
);
|
||||
});
|
||||
|
||||
it('flag ON but NO runHooks (runId undefined): pipe options stay legacy (the runId gate)', async () => {
|
||||
const { svc, streamRegistry } = makeService({ resumable: true });
|
||||
await drive(svc, undefined);
|
||||
const options = pipeMock.mock.calls[0][1];
|
||||
expect(options.consumeSseStream).toBeUndefined();
|
||||
expect(options.generateMessageId).toBeUndefined();
|
||||
expect(streamRegistry.bind).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flag ON + runId: the outer catch calls abortEntry when the stream throws', async () => {
|
||||
const { svc, streamRegistry } = makeService({ resumable: true });
|
||||
streamTextMock.mockImplementation(() => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
await expect(drive(svc, makeRunHooks())).rejects.toThrow('boom');
|
||||
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,30 @@ export class BoundChatDto {
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect to the latest run of a chat (#184): fetch its persisted lifecycle
|
||||
* state (and the assistant message it projects) for an in-flight or finished run.
|
||||
*/
|
||||
export class GetRunDto {
|
||||
@IsString()
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicitly STOP an agent run (#184): the user pressed Stop — distinct from a
|
||||
* browser disconnect, which never stops a run. Either the run id (preferred, from
|
||||
* the streamed start metadata) or the chat id (stop whatever run is active on it).
|
||||
*/
|
||||
export class StopRunDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
runId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
chatId?: string;
|
||||
}
|
||||
|
||||
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||
* role/tool-action labels; defaults to English server-side. */
|
||||
export class ExportChatDto {
|
||||
|
||||
@@ -610,6 +610,63 @@ describe('AiAgentRolesService guards', () => {
|
||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
||||
});
|
||||
|
||||
it('createdRoles lists the installed role (no renamedTo when not renamed)', async () => {
|
||||
const { service } = makeImportService({});
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res.createdRoles).toEqual([
|
||||
{ slug: 'researcher', name: 'Researcher' },
|
||||
]);
|
||||
expect(res.skippedRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('createdRoles carries renamedTo on a rename', async () => {
|
||||
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||
const { service } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ conflict: 'rename' }),
|
||||
);
|
||||
expect(res.createdRoles).toEqual([
|
||||
{ slug: 'researcher', name: 'Researcher', renamedTo: 'Researcher (2)' },
|
||||
]);
|
||||
expect(res.skippedRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('skippedRoles: already-installed slug carries reason "already-installed"', async () => {
|
||||
const existing = [
|
||||
makeRow({
|
||||
id: 'r-existing',
|
||||
name: 'Old researcher',
|
||||
source: { slug: 'researcher', language: 'en', version: 1 } as never,
|
||||
}),
|
||||
];
|
||||
const { service } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||
expect(res.skippedRoles).toEqual([
|
||||
{
|
||||
slug: 'researcher',
|
||||
name: 'Researcher',
|
||||
reason: 'already-installed',
|
||||
},
|
||||
]);
|
||||
expect(res.createdRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('skippedRoles: a name collision under conflict:skip carries reason "name-conflict"', async () => {
|
||||
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||
const { service } = makeImportService({ existing });
|
||||
const res = await service.importFromCatalog(
|
||||
'ws-1',
|
||||
'u1',
|
||||
dto({ conflict: 'skip' }),
|
||||
);
|
||||
expect(res.skippedRoles).toEqual([
|
||||
{ slug: 'researcher', name: 'Researcher', reason: 'name-conflict' },
|
||||
]);
|
||||
expect(res.createdRoles).toEqual([]);
|
||||
});
|
||||
|
||||
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||
const { service, repo } = makeImportService({
|
||||
bundleRoles: [catalogRole()],
|
||||
@@ -677,6 +734,15 @@ describe('AiAgentRolesService guards', () => {
|
||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||
expect(res.errors).toEqual([]);
|
||||
// The per-role list records 'a' as an already-installed skip (the UI reads
|
||||
// skippedRoles, not the counter, to render its plaque — assert the array,
|
||||
// not just the count).
|
||||
expect(res.skippedRoles).toContainEqual({
|
||||
slug: 'a',
|
||||
name: 'A',
|
||||
reason: 'already-installed',
|
||||
});
|
||||
expect(res.createdRoles.map((r) => r.slug)).toEqual(['b']);
|
||||
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
@@ -305,6 +305,16 @@ export class AiAgentRolesService {
|
||||
skipped: number;
|
||||
renamed: number;
|
||||
errors: { slug: string; message: string }[];
|
||||
// Per-role lists alongside the counters (kept for back-compat). The redesigned
|
||||
// catalog UI needs the actual roles — which were created (and any rename) and
|
||||
// which were skipped and why — to render an inline result plaque with the
|
||||
// conflicting role's name and a "Rename & install" affordance.
|
||||
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||
skippedRoles: {
|
||||
slug: string;
|
||||
name: string;
|
||||
reason: 'name-conflict' | 'already-installed';
|
||||
}[];
|
||||
}> {
|
||||
const { file, versions } = await this.loadBundleById(
|
||||
dto.bundleId,
|
||||
@@ -312,6 +322,13 @@ export class AiAgentRolesService {
|
||||
);
|
||||
|
||||
const errors: { slug: string; message: string }[] = [];
|
||||
const createdRoles: { slug: string; name: string; renamedTo?: string }[] =
|
||||
[];
|
||||
const skippedRoles: {
|
||||
slug: string;
|
||||
name: string;
|
||||
reason: 'name-conflict' | 'already-installed';
|
||||
}[] = [];
|
||||
|
||||
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||
let selected = file.roles;
|
||||
@@ -351,16 +368,27 @@ export class AiAgentRolesService {
|
||||
// Already installed from the catalog in THIS language => skip (use
|
||||
// update-from-catalog). A different language of the same slug still imports.
|
||||
const installKey = `${role.slug}:${dto.language}`;
|
||||
const originalName = role.name.trim();
|
||||
if (installedKeys.has(installKey)) {
|
||||
skipped++;
|
||||
skippedRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
reason: 'already-installed',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = role.name.trim();
|
||||
let name = originalName;
|
||||
let didRename = false;
|
||||
if (takenNames.has(name.toLowerCase())) {
|
||||
if (dto.conflict === 'skip') {
|
||||
skipped++;
|
||||
skippedRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
reason: 'name-conflict',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// conflict === 'rename': find a free " (N)" suffix.
|
||||
@@ -380,6 +408,11 @@ export class AiAgentRolesService {
|
||||
});
|
||||
created++;
|
||||
if (didRename) renamed++;
|
||||
createdRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
...(didRename ? { renamedTo: name } : {}),
|
||||
});
|
||||
takenNames.add(name.toLowerCase());
|
||||
installedKeys.add(installKey);
|
||||
} catch (err) {
|
||||
@@ -391,6 +424,11 @@ export class AiAgentRolesService {
|
||||
// skipped (already installed) and continue; do NOT abort or error.
|
||||
if (isSourceUniqueViolation(err)) {
|
||||
skipped++;
|
||||
skippedRoles.push({
|
||||
slug: role.slug,
|
||||
name: originalName,
|
||||
reason: 'already-installed',
|
||||
});
|
||||
installedKeys.add(installKey);
|
||||
continue;
|
||||
}
|
||||
@@ -407,7 +445,7 @@ export class AiAgentRolesService {
|
||||
}
|
||||
}
|
||||
|
||||
return { created, skipped, renamed, errors };
|
||||
return { created, skipped, renamed, errors, createdRoles, skippedRoles };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -539,3 +539,181 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
|
||||
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
|
||||
* but two wirings actually CHANGED in the migration and are otherwise untested:
|
||||
* - movePage now forwards the newly-added optional `position` field to the
|
||||
* client (client.movePage(pageId, parentPageId, position));
|
||||
* - the table trio unified its `tableRef` param to `table` and must forward it
|
||||
* positionally. A field destructured under the wrong name would silently pass
|
||||
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
|
||||
*/
|
||||
describe('AiChatToolsService #294 changed execute wirings', () => {
|
||||
const calls: Record<string, unknown[][]> = {
|
||||
movePage: [],
|
||||
tableInsertRow: [],
|
||||
tableDeleteRow: [],
|
||||
tableUpdateCell: [],
|
||||
};
|
||||
const fakeClient: Partial<DocmostClientLike> = {
|
||||
movePage: (...args: unknown[]) => {
|
||||
calls.movePage.push(args);
|
||||
return Promise.resolve({ success: true });
|
||||
},
|
||||
tableInsertRow: (...args: unknown[]) => {
|
||||
calls.tableInsertRow.push(args);
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
tableDeleteRow: (...args: unknown[]) => {
|
||||
calls.tableDeleteRow.push(args);
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
tableUpdateCell: (...args: unknown[]) => {
|
||||
calls.tableUpdateCell.push(args);
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
};
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
let service: AiChatToolsService;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(calls)) calls[k].length = 0;
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const buildTools = () =>
|
||||
service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
);
|
||||
|
||||
it('movePage forwards the optional position to the client', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.movePage.execute(
|
||||
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
|
||||
});
|
||||
|
||||
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
|
||||
expect(calls.movePage).toEqual([['p2', null, undefined]]);
|
||||
});
|
||||
|
||||
it('tableInsertRow forwards the unified `table` param positionally', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.tableInsertRow.execute(
|
||||
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
|
||||
});
|
||||
|
||||
it('tableDeleteRow forwards `table` positionally', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.tableDeleteRow.execute(
|
||||
{ pageId: 'p1', table: '#0', index: 1 } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
|
||||
});
|
||||
|
||||
it('tableUpdateCell forwards `table` positionally', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.tableUpdateCell.execute(
|
||||
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* getCurrentPage selection contract (#388): the tool surfaces the selection that
|
||||
* was sanitized + nested onto the resolved open-page context (last forUser arg).
|
||||
* No page => selection is null. The tool never fetches or verifies anything — it
|
||||
* just projects the resolved context.
|
||||
*/
|
||||
describe('AiChatToolsService getCurrentPage selection (#388)', () => {
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
|
||||
let service: AiChatToolsService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return {} as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const buildTools = (openedPage: unknown) =>
|
||||
service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
openedPage as never,
|
||||
);
|
||||
|
||||
it('returns the nested selection from the resolved context', async () => {
|
||||
const selection = { text: 'fix this', blockIds: ['b1'], before: 'a ' };
|
||||
const tools = await buildTools({ id: 'p1', title: 'Doc', selection });
|
||||
expect(await tools.getCurrentPage.execute({} as never, {} as never)).toEqual(
|
||||
{ page: { id: 'p1', title: 'Doc' }, selection },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns selection: null when the context has no selection', async () => {
|
||||
const tools = await buildTools({ id: 'p1', title: 'Doc' });
|
||||
expect(await tools.getCurrentPage.execute({} as never, {} as never)).toEqual(
|
||||
{ page: { id: 'p1', title: 'Doc' }, selection: null },
|
||||
);
|
||||
});
|
||||
|
||||
it('returns { page: null, selection: null } when no page is open', async () => {
|
||||
const tools = await buildTools(null);
|
||||
expect(await tools.getCurrentPage.execute({} as never, {} as never)).toEqual(
|
||||
{ page: null, selection: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,17 @@ import {
|
||||
type DocmostClientLike,
|
||||
type SharedToolSpec,
|
||||
} from './docmost-client.loader';
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
import {
|
||||
resolveCurrentPageResult,
|
||||
type SelectionContext,
|
||||
} from './current-page.util';
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
import { modelFriendlyInput } from './model-friendly-input';
|
||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||
import {
|
||||
buildInAppDeferredCatalog,
|
||||
type ToolCatalogEntry,
|
||||
} from './tool-tiers';
|
||||
|
||||
/**
|
||||
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||
@@ -123,6 +130,18 @@ export class AiChatToolsService {
|
||||
return client.exportPageMarkdown(pageId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the IN-APP deferred <tool_catalog> entries (#332): one "name — purpose"
|
||||
* line per DEFERRED tool, merging the per-layer INLINE_TOOL_TIERS with the
|
||||
* shared registry's own catalogLine. Loads @docmost/mcp for the shared specs
|
||||
* (memoized). Core tools are always active and are NOT listed here. External
|
||||
* MCP tools are catalogued separately by the caller (they are runtime-scoped).
|
||||
*/
|
||||
async getInAppDeferredCatalog(): Promise<ToolCatalogEntry[]> {
|
||||
const { sharedToolSpecs } = await loadDocmostMcp();
|
||||
return buildInAppDeferredCatalog(sharedToolSpecs);
|
||||
}
|
||||
|
||||
async forUser(
|
||||
user: User,
|
||||
sessionId: string,
|
||||
@@ -137,8 +156,13 @@ export class AiChatToolsService {
|
||||
// The page the user currently has open (from the request context), exposed
|
||||
// to the model via getCurrentPage. Optional and last so existing callers
|
||||
// keep compiling. Kept proxy-robust: the model can CALL for the current
|
||||
// page instead of relying on it surviving in the system prompt text.
|
||||
openedPage?: { id?: string; title?: string } | null,
|
||||
// page instead of relying on it surviving in the system prompt text. The
|
||||
// `selection` (#388) is already sanitized + nested by resolveOpenPageContext.
|
||||
openedPage?: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
selection?: SelectionContext | null;
|
||||
} | null,
|
||||
): Promise<Record<string, Tool>> {
|
||||
// Build the per-user loopback client (carrying the access + collab
|
||||
// provenance tokens) and load the shared tool-spec registry. Client
|
||||
@@ -293,55 +317,40 @@ export class AiChatToolsService {
|
||||
getCurrentPage: tool({
|
||||
description:
|
||||
'Return the page the user is currently viewing — i.e. what "this page", ' +
|
||||
'"the current page", or "here" refers to. Returns the page id and title, ' +
|
||||
'or null if the user is not currently on a page. Call this first whenever ' +
|
||||
'the user refers to the current page without giving an explicit id.',
|
||||
'"the current page", or "here" refers to — plus the text the user ' +
|
||||
'currently has SELECTED on that page (what "this", "here", "the selected ' +
|
||||
'fragment" refers to), or selection: null when nothing is selected. The ' +
|
||||
'selection is a client-side snapshot taken when the user sent the message ' +
|
||||
'and includes the ids of the blocks it covers plus surrounding context; ' +
|
||||
'it is NOT verified server-side — locate it in the page (searchInPage / ' +
|
||||
'getNode) before editing. Returns page: null if the user is not currently ' +
|
||||
'on a page. Call this first whenever the user refers to the current page ' +
|
||||
'or a selected fragment without giving an explicit id.',
|
||||
inputSchema: modelFriendlyInput({}),
|
||||
execute: async () => resolveCurrentPageResult(openedPage),
|
||||
}),
|
||||
|
||||
getPage: tool({
|
||||
description:
|
||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||
'title and its Markdown content.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||
const result = await client.getPage(pageId);
|
||||
const data = (result?.data ?? {}) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
return {
|
||||
title: data.title ?? '',
|
||||
markdown: typeof data.content === 'string' ? data.content : '',
|
||||
};
|
||||
},
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The execute body keeps this layer's { title, markdown } projection.
|
||||
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||
const result = await client.getPage(pageId);
|
||||
const data = (result?.data ?? {}) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
return {
|
||||
title: data.title ?? '',
|
||||
markdown: typeof data.content === 'string' ? data.content : '',
|
||||
};
|
||||
}),
|
||||
|
||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||
|
||||
createPage: tool({
|
||||
description:
|
||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
||||
'can be moved to trash later.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
title: z.string().describe('The title of the new page.'),
|
||||
content: z
|
||||
.string()
|
||||
.describe('The page body as Markdown (may be empty).'),
|
||||
spaceId: z
|
||||
.string()
|
||||
.describe('The id of the space to create the page in.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional parent page id to nest the new page under.'),
|
||||
}),
|
||||
execute: async ({ title, content, spaceId, parentPageId }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
createPage: sharedTool(
|
||||
sharedToolSpecs.createPage,
|
||||
async ({ title, content, spaceId, parentPageId }) => {
|
||||
// createPage(title, content, spaceId, parentPageId?) ->
|
||||
// { data: filterPage(page, markdown), success }.
|
||||
const result = await client.createPage(
|
||||
@@ -357,7 +366,7 @@ export class AiChatToolsService {
|
||||
};
|
||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
updatePageContent: tool({
|
||||
description:
|
||||
@@ -381,115 +390,46 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
renamePage: tool({
|
||||
description:
|
||||
"Rename a page (change its title only; the body is untouched). " +
|
||||
'Reversible: rename back at any time.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to rename.'),
|
||||
title: z.string().describe('The new title.'),
|
||||
}),
|
||||
execute: async ({ pageId, title }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
renamePage: sharedTool(
|
||||
sharedToolSpecs.renamePage,
|
||||
async ({ pageId, title }) => {
|
||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||
await client.renamePage(pageId, title);
|
||||
return { pageId, title };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
movePage: tool({
|
||||
description:
|
||||
'Move a page under a new parent page, or to the space root when no ' +
|
||||
'parent is given. Reversible: move it back at any time.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to move.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Target parent page id. Null/omitted moves the page to the ' +
|
||||
'space root.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, parentPageId }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The shared schema adds the optional `position` field this layer lacked
|
||||
// before; the execute now forwards it (the client already accepted it).
|
||||
movePage: sharedTool(
|
||||
sharedToolSpecs.movePage,
|
||||
async ({ pageId, parentPageId, position }) => {
|
||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
||||
await client.movePage(pageId, parentPageId ?? null);
|
||||
await client.movePage(pageId, parentPageId ?? null, position);
|
||||
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
||||
},
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
|
||||
// permanentlyDelete/forceDelete are never part of the input and can never
|
||||
// be forwarded — the agent physically cannot permanently delete a page.
|
||||
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
|
||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||
// which is the soft-delete (trash) path on the server.
|
||||
await client.deletePage(pageId);
|
||||
return { pageId, trashed: true };
|
||||
}),
|
||||
|
||||
deletePage: tool({
|
||||
description:
|
||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
||||
'page can be restored from trash). This NEVER permanently deletes.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
||||
}),
|
||||
// GUARDRAIL (§14 H4): the only field ever passed to the client is
|
||||
// pageId. permanentlyDelete/forceDelete are not part of the schema and
|
||||
// are never forwarded, so the agent physically cannot permanently
|
||||
// delete a page through this tool.
|
||||
execute: async ({ pageId }) => {
|
||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||
// which is the soft-delete (trash) path on the server.
|
||||
await client.deletePage(pageId);
|
||||
return { pageId, trashed: true };
|
||||
},
|
||||
}),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): the description is
|
||||
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
||||
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
|
||||
// keeps its own wording. Kept per-layer.
|
||||
createComment: tool({
|
||||
description:
|
||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
||||
'comment (one level only — the backend rejects replies to replies). ' +
|
||||
'The comment is anchored inline to the given exact `selection` text ' +
|
||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
||||
"parent's anchor and take no selection. If the call fails with a " +
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||
'once in the page. Reversible via the comment UI.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().describe('The comment body as Markdown.'),
|
||||
selection: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.optional()
|
||||
.describe(
|
||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
||||
'formatting boundaries). Required for a new top-level comment; ' +
|
||||
'omit only when replying via parentCommentId.',
|
||||
),
|
||||
parentCommentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
||||
'of replies only).',
|
||||
),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
||||
'refused.',
|
||||
),
|
||||
}),
|
||||
execute: async ({
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// This layer keeps only its own execute-side guards (require a selection
|
||||
// for a top-level comment; reject suggestedText on a reply / without a
|
||||
// selection) — the schema+description are shared.
|
||||
createComment: sharedTool(
|
||||
sharedToolSpecs.createComment,
|
||||
async ({
|
||||
pageId,
|
||||
content,
|
||||
selection,
|
||||
@@ -530,26 +470,17 @@ export class AiChatToolsService {
|
||||
const data = (result?.data ?? {}) as { id?: string };
|
||||
return { commentId: data.id, pageId };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
resolveComment: tool({
|
||||
description:
|
||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
||||
'the resolved flag). Only top-level comments can be resolved.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
commentId: z
|
||||
.string()
|
||||
.describe('The id of the top-level comment to resolve/reopen.'),
|
||||
resolved: z
|
||||
.boolean()
|
||||
.describe('true to resolve the thread, false to reopen it.'),
|
||||
}),
|
||||
execute: async ({ commentId, resolved }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
resolveComment: sharedTool(
|
||||
sharedToolSpecs.resolveComment,
|
||||
async ({ commentId, resolved }) => {
|
||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||
await client.resolveComment(commentId, resolved);
|
||||
return { commentId, resolved };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// --- READ tools (added) ---
|
||||
|
||||
@@ -567,33 +498,12 @@ export class AiChatToolsService {
|
||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
||||
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||
// its own guidance.
|
||||
listPages: tool({
|
||||
description:
|
||||
'List the most recent pages, optionally scoped to a single space. ' +
|
||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
||||
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional space id to scope the listing to.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum number of pages (1-100).'),
|
||||
tree: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ spaceId, limit, tree }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listPages: sharedTool(
|
||||
sharedToolSpecs.listPages,
|
||||
async ({ spaceId, limit, tree }) =>
|
||||
await client.listPages(spaceId, limit, tree),
|
||||
}),
|
||||
),
|
||||
|
||||
listSidebarPages: tool({
|
||||
description:
|
||||
@@ -628,31 +538,44 @@ export class AiChatToolsService {
|
||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
searchInPage: sharedTool(
|
||||
sharedToolSpecs.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||
await client.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
|
||||
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
||||
// while this key is `getTable` (verb-first), breaking the
|
||||
// snake_case(inAppKey) convention the shared registry enforces. Its
|
||||
// reference parameter is still named `table` (was `tableRef`) so it matches
|
||||
// the migrated table row/cell tools below.
|
||||
getTable: tool({
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
'matrix so cells can be addressed for rich edits).',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
table: z
|
||||
.string()
|
||||
.describe(
|
||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
||||
'the table.',
|
||||
'"#<index>" from the page outline, or a block id of any node ' +
|
||||
'inside the table.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef }) =>
|
||||
await client.getTable(pageId, tableRef),
|
||||
execute: async ({ pageId, table }) =>
|
||||
await client.getTable(pageId, table),
|
||||
}),
|
||||
|
||||
listComments: tool({
|
||||
description:
|
||||
'List all comments on a page (content as Markdown).',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listComments: sharedTool(
|
||||
sharedToolSpecs.listComments,
|
||||
async ({ pageId, includeResolved }) =>
|
||||
await client.listComments(pageId, includeResolved),
|
||||
),
|
||||
|
||||
getComment: tool({
|
||||
description: 'Fetch a single comment by id (content as Markdown).',
|
||||
@@ -662,26 +585,12 @@ export class AiChatToolsService {
|
||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||
}),
|
||||
|
||||
checkNewComments: tool({
|
||||
description:
|
||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
||||
'created after a given timestamp.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z.string().describe('The id of the space to scan.'),
|
||||
since: z
|
||||
.string()
|
||||
.describe('An ISO-8601 timestamp; only comments created after it.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional page id to scope the scan to that page and its ' +
|
||||
'descendants.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ spaceId, since, parentPageId }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
checkNewComments: sharedTool(
|
||||
sharedToolSpecs.checkNewComments,
|
||||
async ({ spaceId, since, parentPageId }) =>
|
||||
await client.checkNewComments(spaceId, since, parentPageId),
|
||||
}),
|
||||
),
|
||||
|
||||
listShares: sharedTool(
|
||||
sharedToolSpecs.listShares,
|
||||
@@ -711,19 +620,14 @@ export class AiChatToolsService {
|
||||
await client.diffPageVersions(pageId, from, to),
|
||||
),
|
||||
|
||||
exportPageMarkdown: tool({
|
||||
description:
|
||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
||||
'with importPageMarkdown.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to export.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
exportPageMarkdown: sharedTool(
|
||||
sharedToolSpecs.exportPageMarkdown,
|
||||
async ({ pageId }) => {
|
||||
const markdown = await client.exportPageMarkdown(pageId);
|
||||
return { markdown };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||
|
||||
@@ -773,28 +677,12 @@ export class AiChatToolsService {
|
||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
updatePageJson: tool({
|
||||
description:
|
||||
"Replace a page's body with a full ProseMirror document — a full " +
|
||||
'overwrite — and/or update its title. Minimal example content: ' +
|
||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
||||
'object or a JSON string (both accepted). Omit content for a ' +
|
||||
'title-only update. Reversible: the previous version is kept in page ' +
|
||||
'history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to update.'),
|
||||
content: z
|
||||
.any()
|
||||
.optional()
|
||||
.describe(
|
||||
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
|
||||
'object or JSON string); omit for a title-only update.',
|
||||
),
|
||||
title: z.string().optional().describe('Optional new title.'),
|
||||
}),
|
||||
execute: async ({ pageId, content, title }) => {
|
||||
// Parity with the standalone MCP server (index.ts update_page_json):
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The execute body keeps this layer's content normalization (parity with
|
||||
// the standalone MCP server, index.ts update_page_json).
|
||||
updatePageJson: sharedTool(
|
||||
sharedToolSpecs.updatePageJson,
|
||||
async ({ pageId, content, title }) => {
|
||||
// undefined/null pass through as undefined (title-only / no-op); any
|
||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||
// MCP server); an object is passed through unchanged.
|
||||
@@ -807,66 +695,29 @@ export class AiChatToolsService {
|
||||
}
|
||||
return await client.updatePageJson(pageId, doc, title);
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// NOT in the shared registry: this layer names the table argument
|
||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
||||
// Sharing one buildShape would rename a model-facing parameter on one
|
||||
// transport, so the table row/cell tools stay per-layer by design.
|
||||
tableInsertRow: tool({
|
||||
description:
|
||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
||||
'page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
cells: z.array(z.string()).describe('The cell texts for the row.'),
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe('0-based insert position (omit/out-of-range to append).'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, cells, index }) =>
|
||||
await client.tableInsertRow(pageId, tableRef, cells, index),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||
tableInsertRow: sharedTool(
|
||||
sharedToolSpecs.tableInsertRow,
|
||||
async ({ pageId, table, cells, index }) =>
|
||||
await client.tableInsertRow(pageId, table, cells, index),
|
||||
),
|
||||
|
||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
||||
// divergence as tableInsertRow.
|
||||
tableDeleteRow: tool({
|
||||
description:
|
||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
index: z.number().int().describe('0-based row index to delete.'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, index }) =>
|
||||
await client.tableDeleteRow(pageId, tableRef, index),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
tableDeleteRow: sharedTool(
|
||||
sharedToolSpecs.tableDeleteRow,
|
||||
async ({ pageId, table, index }) =>
|
||||
await client.tableDeleteRow(pageId, table, index),
|
||||
),
|
||||
|
||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
||||
// divergence as tableInsertRow.
|
||||
tableUpdateCell: tool({
|
||||
description:
|
||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
||||
'Reversible via page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
row: z.number().int().describe('0-based row index.'),
|
||||
col: z.number().int().describe('0-based column index.'),
|
||||
text: z.string().describe('The new cell text.'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, row, col, text }) =>
|
||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
tableUpdateCell: sharedTool(
|
||||
sharedToolSpecs.tableUpdateCell,
|
||||
async ({ pageId, table, row, col, text }) =>
|
||||
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||
),
|
||||
|
||||
copyPageContent: sharedTool(
|
||||
sharedToolSpecs.copyPageContent,
|
||||
@@ -880,25 +731,14 @@ export class AiChatToolsService {
|
||||
await client.importPageMarkdown(pageId, markdown),
|
||||
),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
||||
// confirmation framing ("Only share when the user explicitly asked, since
|
||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
||||
sharePage: tool({
|
||||
description:
|
||||
'Make a page PUBLICLY accessible and return its public URL. ' +
|
||||
'Reversible via unsharePage. Only share when the user explicitly ' +
|
||||
'asked, since this exposes the page to anyone with the link.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to share.'),
|
||||
searchIndexing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Allow public search engines to index it (default true).'),
|
||||
}),
|
||||
execute: async ({ pageId, searchIndexing }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// Both layers already carried the security-confirmation framing, so there
|
||||
// was no real divergence to preserve — only wording drift.
|
||||
sharePage: sharedTool(
|
||||
sharedToolSpecs.sharePage,
|
||||
async ({ pageId, searchIndexing }) =>
|
||||
await client.sharePage(pageId, searchIndexing),
|
||||
}),
|
||||
),
|
||||
|
||||
unsharePage: sharedTool(
|
||||
sharedToolSpecs.unsharePage,
|
||||
|
||||
@@ -1,43 +1,180 @@
|
||||
import { resolveCurrentPageResult } from './current-page.util';
|
||||
import {
|
||||
resolveCurrentPageResult,
|
||||
sanitizeSelection,
|
||||
} from './current-page.util';
|
||||
|
||||
/**
|
||||
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
|
||||
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
|
||||
* otherwise { page: { id, title } } with title defaulting to ''.
|
||||
* getCurrentPage tool's contract: { page: null, selection: null } when no page
|
||||
* is open (no id), otherwise { page: { id, title }, selection } with title
|
||||
* defaulting to '' and the selection passed through from the resolved context.
|
||||
*/
|
||||
describe('resolveCurrentPageResult', () => {
|
||||
it('returns { page: null } when openedPage is undefined', () => {
|
||||
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
|
||||
it('returns { page: null, selection: null } when openedPage is undefined', () => {
|
||||
expect(resolveCurrentPageResult(undefined)).toEqual({
|
||||
page: null,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns { page: null } when openedPage is null', () => {
|
||||
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
|
||||
it('returns { page: null, selection: null } when openedPage is null', () => {
|
||||
expect(resolveCurrentPageResult(null)).toEqual({
|
||||
page: null,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns { page: null } when openedPage has no id', () => {
|
||||
expect(resolveCurrentPageResult({})).toEqual({ page: null });
|
||||
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
|
||||
it('returns { page: null, selection: null } when openedPage has no id', () => {
|
||||
expect(resolveCurrentPageResult({})).toEqual({
|
||||
page: null,
|
||||
selection: null,
|
||||
});
|
||||
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({
|
||||
page: null,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns { page: null } when id is an empty string', () => {
|
||||
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
|
||||
it('returns { page: null, selection: null } when id is an empty string', () => {
|
||||
expect(resolveCurrentPageResult({ id: '' })).toEqual({
|
||||
page: null,
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the page id and title when both are present', () => {
|
||||
it('drops the selection when there is no page (selection dies with the page)', () => {
|
||||
// Even if a selection somehow rode along without a page id, a null page
|
||||
// always yields a null selection.
|
||||
expect(
|
||||
resolveCurrentPageResult({ selection: { text: 'orphan' } }),
|
||||
).toEqual({ page: null, selection: null });
|
||||
});
|
||||
|
||||
it('returns the page id and title with a null selection by default', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
|
||||
page: { id: 'p1', title: 'Hello' },
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the nested selection through verbatim', () => {
|
||||
const selection = {
|
||||
text: 'fix this',
|
||||
blockIds: ['b1'],
|
||||
before: 'please ',
|
||||
after: ' now',
|
||||
};
|
||||
expect(
|
||||
resolveCurrentPageResult({ id: 'p1', title: 'Hello', selection }),
|
||||
).toEqual({
|
||||
page: { id: 'p1', title: 'Hello' },
|
||||
selection,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults title to "" when it is missing', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
||||
page: { id: 'p1', title: '' },
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps an explicit empty-string title as ""', () => {
|
||||
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
||||
page: { id: 'p1', title: '' },
|
||||
selection: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Unit tests for sanitizeSelection (#388). The selection is an attacker-
|
||||
* controllable client snapshot: every field is type-checked and capped, and
|
||||
* anything that is not a real selection collapses to null. It is NEVER verified
|
||||
* against the page content (decision 5 — a hint, not ground truth).
|
||||
*/
|
||||
describe('sanitizeSelection', () => {
|
||||
it('accepts a well-formed payload unchanged', () => {
|
||||
const raw = {
|
||||
text: 'the selected fragment',
|
||||
truncated: true,
|
||||
blockIds: ['b1', 'b2'],
|
||||
before: 'context before ',
|
||||
after: ' context after',
|
||||
};
|
||||
expect(sanitizeSelection(raw)).toEqual(raw);
|
||||
});
|
||||
|
||||
it('returns null for non-objects', () => {
|
||||
expect(sanitizeSelection(null)).toBeNull();
|
||||
expect(sanitizeSelection(undefined)).toBeNull();
|
||||
expect(sanitizeSelection('text')).toBeNull();
|
||||
expect(sanitizeSelection(42)).toBeNull();
|
||||
expect(sanitizeSelection([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when text is missing, non-string or blank-after-trim', () => {
|
||||
expect(sanitizeSelection({})).toBeNull();
|
||||
expect(sanitizeSelection({ text: 123 })).toBeNull();
|
||||
expect(sanitizeSelection({ text: '' })).toBeNull();
|
||||
expect(sanitizeSelection({ text: ' \n ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps only text when the other fields are garbage', () => {
|
||||
expect(
|
||||
sanitizeSelection({
|
||||
text: 'hello',
|
||||
truncated: 'yes',
|
||||
blockIds: 'nope',
|
||||
before: 5,
|
||||
after: {},
|
||||
}),
|
||||
).toEqual({ text: 'hello' });
|
||||
});
|
||||
|
||||
it('caps text at 4000 and forces truncated', () => {
|
||||
const raw = { text: 'a'.repeat(5000) };
|
||||
const out = sanitizeSelection(raw)!;
|
||||
expect(out.text).toHaveLength(4000);
|
||||
expect(out.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it('does not set truncated for text under the cap', () => {
|
||||
expect(sanitizeSelection({ text: 'short' })).toEqual({ text: 'short' });
|
||||
});
|
||||
|
||||
it('slices blockIds to 20 and drops non-string / oversized ids', () => {
|
||||
const ids = Array.from({ length: 30 }, (_, i) => `b${i}`);
|
||||
const out = sanitizeSelection({
|
||||
text: 'x',
|
||||
blockIds: [...ids, 123, '', 'y'.repeat(65)],
|
||||
})!;
|
||||
// The 30 valid ids cap to the first 20; the number, empty string and the
|
||||
// 65-char id are dropped before the slice.
|
||||
expect(out.blockIds).toHaveLength(20);
|
||||
expect(out.blockIds).toEqual(ids.slice(0, 20));
|
||||
});
|
||||
|
||||
it('keeps a 64-char id but drops a 65-char one (boundary)', () => {
|
||||
const ok = 'z'.repeat(64);
|
||||
const tooLong = 'z'.repeat(65);
|
||||
expect(
|
||||
sanitizeSelection({ text: 'x', blockIds: [ok, tooLong] })!.blockIds,
|
||||
).toEqual([ok]);
|
||||
});
|
||||
|
||||
it('omits blockIds entirely when none survive', () => {
|
||||
const out = sanitizeSelection({ text: 'x', blockIds: [123, ''] })!;
|
||||
expect(out.blockIds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('caps before/after at 200 chars and drops empty ones', () => {
|
||||
const out = sanitizeSelection({
|
||||
text: 'x',
|
||||
before: 'b'.repeat(300),
|
||||
after: '',
|
||||
})!;
|
||||
expect(out.before).toHaveLength(200);
|
||||
expect(out.after).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,91 @@
|
||||
export interface SelectionContext {
|
||||
text: string;
|
||||
truncated?: boolean;
|
||||
blockIds?: string[];
|
||||
before?: string;
|
||||
after?: string;
|
||||
}
|
||||
|
||||
// Server-side caps for the client-reported selection. Intentionally >= the
|
||||
// client caps: the client pre-trims for a small wire, but this layer re-checks
|
||||
// everything because the payload is attacker-controllable.
|
||||
const TEXT_CAP = 4000;
|
||||
const CONTEXT_CAP = 200;
|
||||
const MAX_BLOCK_IDS = 20;
|
||||
const BLOCK_ID_CAP = 64;
|
||||
|
||||
// Sanitize the client-reported selection: type-check every field, cap sizes
|
||||
// (text 4000, before/after 200, blockIds 20 x 64 chars), drop garbage to null.
|
||||
// The selection is a CLIENT-side snapshot — never verified against the page
|
||||
// content (#159 lesson: treat as a hint, not ground truth). The agent is told
|
||||
// (getCurrentPage's description) to localize it before editing.
|
||||
export function sanitizeSelection(raw: unknown): SelectionContext | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const r = raw as Record<string, unknown>;
|
||||
|
||||
// text is the only required field; anything else is a non-selection.
|
||||
if (typeof r.text !== 'string' || r.text.trim().length === 0) return null;
|
||||
let text = r.text;
|
||||
let truncated = r.truncated === true;
|
||||
if (text.length > TEXT_CAP) {
|
||||
text = text.slice(0, TEXT_CAP);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const result: SelectionContext = { text };
|
||||
if (truncated) result.truncated = true;
|
||||
|
||||
if (Array.isArray(r.blockIds)) {
|
||||
// Keep only well-formed, in-range ids (an oversize id is DROPPED, not
|
||||
// truncated — a mangled id is worse than a missing one), then cap the count.
|
||||
const ids = r.blockIds
|
||||
.filter(
|
||||
(x): x is string =>
|
||||
typeof x === 'string' && x.length > 0 && x.length <= BLOCK_ID_CAP,
|
||||
)
|
||||
.slice(0, MAX_BLOCK_IDS);
|
||||
if (ids.length > 0) result.blockIds = ids;
|
||||
}
|
||||
|
||||
if (typeof r.before === 'string' && r.before.length > 0) {
|
||||
result.before = r.before.slice(0, CONTEXT_CAP);
|
||||
}
|
||||
if (typeof r.after === 'string' && r.after.length > 0) {
|
||||
result.after = r.after.slice(0, CONTEXT_CAP);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface CurrentPageInput {
|
||||
id?: string;
|
||||
title?: string;
|
||||
// The already-sanitized selection nested onto the resolved open-page context
|
||||
// by resolveOpenPageContext (never the raw client value). Passed through to
|
||||
// the tool result verbatim; null when nothing is selected.
|
||||
selection?: SelectionContext | null;
|
||||
}
|
||||
|
||||
export interface CurrentPageResult {
|
||||
page: { id: string; title: string } | null;
|
||||
selection: SelectionContext | null; // null when nothing is selected or no page
|
||||
}
|
||||
|
||||
// Resolve the "current page" tool result from the client-supplied open-page
|
||||
// context. Returns { page: null } when no page is open (no id), otherwise the
|
||||
// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage
|
||||
// tool's contract so it can be unit-tested without the ESM Docmost client.
|
||||
// context. Returns { page: null, selection: null } when no page is open (no id),
|
||||
// otherwise the page id + title (title defaults to '' when absent) plus the
|
||||
// selection already sanitized+nested by resolveOpenPageContext. A null page
|
||||
// always yields a null selection (the selection dies with the page). Mirrors the
|
||||
// getCurrentPage tool's contract so it can be unit-tested without the ESM
|
||||
// Docmost client.
|
||||
export function resolveCurrentPageResult(
|
||||
openedPage?: CurrentPageInput | null,
|
||||
): CurrentPageResult {
|
||||
if (!openedPage?.id) {
|
||||
return { page: null };
|
||||
return { page: null, selection: null };
|
||||
}
|
||||
return { page: { id: openedPage.id, title: openedPage.title ?? '' } };
|
||||
return {
|
||||
page: { id: openedPage.id, title: openedPage.title ?? '' },
|
||||
selection: openedPage.selection ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,8 +55,18 @@ export interface DocmostClientLike {
|
||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||
searchInPage(
|
||||
pageId: string,
|
||||
query: string,
|
||||
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
|
||||
): Promise<Record<string, unknown>>;
|
||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||
listComments(pageId: string): Promise<unknown[]>;
|
||||
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
||||
// false) hides resolved threads wholesale; pass true for the full feed.
|
||||
listComments(
|
||||
pageId: string,
|
||||
includeResolved?: boolean,
|
||||
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
|
||||
getComment(
|
||||
commentId: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
@@ -231,6 +241,11 @@ export interface SharedToolSpec {
|
||||
mcpName: string;
|
||||
inAppKey: string;
|
||||
description: string;
|
||||
// Deferred-tool metadata (#332). Optional in this mirror so an older/stale
|
||||
// @docmost/mcp build (pre-#332) still type-checks; the in-app catalog builder
|
||||
// reads them defensively. The external /mcp server ignores both fields.
|
||||
tier?: 'core' | 'deferred';
|
||||
catalogLine?: string;
|
||||
// Loose `z` on purpose: the registry is zod-agnostic so the server can pass
|
||||
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
||||
buildShape?: (z: any) => Record<string, unknown>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user