Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16b476a205 | |||
| 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 | |||
| d78b985062 | |||
| a4fc6c7f64 | |||
| c252068672 | |||
| 68caf8157a | |||
| cb9c5dda59 | |||
| e431b33bb1 |
@@ -202,6 +202,39 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# Default 900000 (15 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=900000
|
# 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 ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# 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
|
# 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
|
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
||||||
# per rolling day).
|
# per rolling day).
|
||||||
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
# 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
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
|
||||||
jobs:
|
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:
|
test:
|
||||||
uses: ./.github/workflows/test.yml
|
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:
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -57,13 +93,10 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
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:
|
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
# lists them all).
|
||||||
# to the pusher — that red run + email is the intended notification, not a
|
|
||||||
# deploy block.
|
|
||||||
e2e-server:
|
e2e-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
# 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
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
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
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
- name: Run server e2e
|
- name: Run server e2e
|
||||||
run: pnpm --filter ./apps/server test:e2e
|
run: pnpm --filter ./apps/server test:e2e
|
||||||
|
|
||||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
# Gates the publish too — see the comment above e2e-server.
|
||||||
# `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.
|
|
||||||
e2e-mcp:
|
e2e-mcp:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
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
|
contents: read
|
||||||
|
|
||||||
jobs:
|
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:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `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/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`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
| `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` and `git-sync`; there is exactly ONE copy of the converter now |
|
| `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`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -214,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-
|
> 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)**
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
> for the step-by-step and the traps.
|
> 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
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
@@ -224,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")
|
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):
|
**Lint** (per package — there is no root lint script):
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter server lint # eslint --fix on server .ts
|
pnpm --filter server lint # eslint --fix on server .ts
|
||||||
@@ -250,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 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
|
## Architecture — the big picture
|
||||||
|
|
||||||
@@ -279,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/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/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/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
|
### 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:
|
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.
|
- **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. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — 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.
|
- 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`.
|
- 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`.
|
- 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`.
|
||||||
|
|
||||||
@@ -293,7 +321,7 @@ 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.
|
- **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`.
|
- 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.
|
- 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.
|
- **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
|
## 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
|
append/prepend fragments, nor to COMMENT bodies — a comment may legitimately
|
||||||
contain a standalone footnote definition, which canonicalization would drop.
|
contain a standalone footnote definition, which canonicalization would drop.
|
||||||
(#228)
|
(#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
|
- **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
|
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
|
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
|
### 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
|
- **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
|
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
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
|
|||||||
+16
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
|||||||
|
|
||||||
FROM base AS builder
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -57,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
|||||||
|
|
||||||
RUN chown -R node:node /app
|
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
|
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
|
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
||||||
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
`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
|
## Migration from Docmost
|
||||||
|
|
||||||
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
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; где это применимо, заменяйте образ
|
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
||||||
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
`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
|
## Миграция с Docmost
|
||||||
|
|
||||||
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
||||||
|
|||||||
@@ -23,18 +23,33 @@ roles:
|
|||||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||||
may prove useful even if the user did not ask about them directly.
|
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
|
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
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).
|
- Decide which languages it makes sense to search in (see below).
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
WHERE TO WRITE THE RESULT
|
WHERE TO WRITE THE RESULT
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
- If the user explicitly asks to work in the current/already-open document,
|
- Reuse the current/already-open document ONLY if either (a) the user
|
||||||
work in it.
|
explicitly asked to work in it, or (b) it is empty or has very little on
|
||||||
- If this is not specified, create a NEW document for the report.
|
it AND its title matches the topic of the research. In every other case —
|
||||||
- Keep a working draft in the document or in notes: fact → source →
|
a non-empty page, or one whose title is about something else — create a
|
||||||
reliability assessment. Update the structure as you go.
|
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)
|
WORK LOOP (repeat until saturation)
|
||||||
@@ -53,9 +68,19 @@ roles:
|
|||||||
HOW TO SEARCH
|
HOW TO SEARCH
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
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
|
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||||
stop yielding new relevant information (saturation / diminishing returns) —
|
only when further searches stop yielding new relevant information
|
||||||
not when it "seems like enough" or when you get tired.
|
(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
|
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
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.
|
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||||
- Formulate 5–10 search directions, including adjacent perspectives that
|
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||||
may prove useful even if the user did not ask about them directly.
|
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
|
- Fix the "research budget" — how many searches to run. If the USER named a
|
||||||
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
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).
|
- Decide which languages it makes sense to search in (see below).
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
WHERE TO WRITE THE RESULT
|
WHERE TO WRITE THE RESULT
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
- If the user explicitly asks to work in the current/already-open document,
|
- Reuse the current/already-open document ONLY if either (a) the user
|
||||||
work in it.
|
explicitly asked to work in it, or (b) it is empty or has very little on
|
||||||
- If this is not specified, create a NEW document for the report.
|
it AND its title matches the topic of the research. In every other case —
|
||||||
- Keep a working draft in the document or in notes: fact → source →
|
a non-empty page, or one whose title is about something else — create a
|
||||||
reliability assessment. Update the structure as you go.
|
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)
|
WORK LOOP (repeat until saturation)
|
||||||
@@ -53,9 +68,19 @@ roles:
|
|||||||
HOW TO SEARCH
|
HOW TO SEARCH
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
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
|
Do not stop at the first plausible answer. Absent an explicit budget, stop
|
||||||
stop yielding new relevant information (saturation / diminishing returns) —
|
only when further searches stop yielding new relevant information
|
||||||
not when it "seems like enough" or when you get tired.
|
(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
|
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||||
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: researcher
|
- slug: researcher
|
||||||
version: 1
|
version: 4
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 4,
|
||||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
"hash": "9446ec6d2c8a6ec548358537ac392b8bf9b4d2a832ebb105d5514eac2c76da74"
|
||||||
},
|
},
|
||||||
"structural-editor": {
|
"structural-editor": {
|
||||||
"version": 4,
|
"version": 4,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"react-clear-modal": "^2.0.18",
|
"react-clear-modal": "^2.0.18",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-drawio": "1.0.7",
|
"react-drawio": "1.0.7",
|
||||||
|
"web-vitals": "^5.1.0",
|
||||||
"react-error-boundary": "6.1.1",
|
"react-error-boundary": "6.1.1",
|
||||||
"react-helmet-async": "3.0.0",
|
"react-helmet-async": "3.0.0",
|
||||||
"react-i18next": "16.5.8",
|
"react-i18next": "16.5.8",
|
||||||
|
|||||||
@@ -1373,6 +1373,39 @@
|
|||||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||||
"Please try again later.": "Please try again later.",
|
"Please try again later.": "Please try again later.",
|
||||||
"No bundles available": "No bundles available",
|
"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",
|
"Already up to date": "Already up to date",
|
||||||
"Updated to the latest version": "Updated to the latest version",
|
"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",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
|
|||||||
@@ -1235,6 +1235,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 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": "Роли не настроены",
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
IconPlus,
|
IconPlus,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { useAtom, useSetAtom } from "jotai";
|
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||||
import { useLocation, useMatch } from "react-router-dom";
|
import { useLocation, useMatch } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
@@ -37,6 +37,14 @@ import {
|
|||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.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 { extractPageSlugId } from "@/lib";
|
||||||
import {
|
import {
|
||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
@@ -45,9 +53,13 @@ import {
|
|||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
useAiRolesQuery,
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} 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 ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.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 { useChatSession } from "@/features/ai-chat/hooks/use-chat-session.ts";
|
||||||
import {
|
import {
|
||||||
shouldCollapseOnOutsidePointer,
|
shouldCollapseOnOutsidePointer,
|
||||||
@@ -74,6 +86,12 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
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. */
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
@@ -231,8 +249,61 @@ export default function AiChatWindow() {
|
|||||||
[roles],
|
[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 } =
|
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
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// 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 }
|
? { id: openPageData.id, title: openPageData.title }
|
||||||
: null;
|
: 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
|
// 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
|
// 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.
|
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||||
@@ -873,6 +965,9 @@ export default function AiChatWindow() {
|
|||||||
chatId={activeChatId}
|
chatId={activeChatId}
|
||||||
initialRows={activeChatId ? messageRows : []}
|
initialRows={activeChatId ? messageRows : []}
|
||||||
openPage={openPage}
|
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.
|
// Honoured only for a new chat; null = universal assistant.
|
||||||
roleId={activeChatId === null ? selectedRoleId : null}
|
roleId={activeChatId === null ? selectedRoleId : null}
|
||||||
// Role cards are the new-chat empty-state; offered only when this
|
// Role cards are the new-chat empty-state; offered only when this
|
||||||
@@ -882,6 +977,15 @@ export default function AiChatWindow() {
|
|||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
onServerChatId={onServerChatId}
|
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>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
fireEvent,
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
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
|
// 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
|
// 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),
|
onFinish: null as null | ((arg: Record<string, unknown>) => void),
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stop: 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 | {
|
transport: null as null | {
|
||||||
prepareSendMessagesRequest: (arg: {
|
prepareSendMessagesRequest?: (arg: {
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
body: Record<string, unknown>;
|
body: Record<string, 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", () => ({
|
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.onFinish = opts.onFinish ?? null;
|
||||||
|
h.state.seededMessages = opts.messages ?? null;
|
||||||
return {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
sendMessage: h.state.sendMessage,
|
sendMessage: h.state.sendMessage,
|
||||||
status: h.state.status,
|
status: h.state.status,
|
||||||
stop: h.state.stop,
|
stop: h.state.stop,
|
||||||
error: null,
|
error: null,
|
||||||
|
setMessages: h.state.setMessages,
|
||||||
|
resumeStream: h.state.resumeStream,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
// 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", () => {
|
vi.mock("ai", () => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
return {
|
return {
|
||||||
generateId: () => `gid-${counter++}`,
|
generateId: () => `gid-${counter++}`,
|
||||||
DefaultChatTransport: class {
|
DefaultChatTransport: class {
|
||||||
constructor(opts: {
|
constructor(opts: Record<string, unknown>) {
|
||||||
prepareSendMessagesRequest: (arg: {
|
h.state.transport = opts as never;
|
||||||
messages: unknown[];
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
}) => { body: Record<string, unknown> };
|
|
||||||
}) {
|
|
||||||
h.state.transport = opts;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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
|
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||||
// interaction this test needs to populate the queue while "streaming".
|
// 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" />,
|
default: () => <div data-testid="message-list" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||||
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
default: ({
|
||||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
onQueue,
|
||||||
queue
|
onStop,
|
||||||
</button>
|
}: {
|
||||||
|
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 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();
|
const onTurnFinished = vi.fn();
|
||||||
render(
|
const onResumeFallback = vi.fn();
|
||||||
<MantineProvider>
|
const onServerStop = vi.fn();
|
||||||
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
const queryClient = new QueryClient({
|
||||||
</MantineProvider>,
|
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)", () => {
|
describe("ChatThread — send now (#198)", () => {
|
||||||
beforeEach(() => {
|
beforeEach(resetState);
|
||||||
h.state.status = "streaming";
|
|
||||||
h.state.onFinish = null;
|
|
||||||
h.state.sendMessage.mockClear();
|
|
||||||
h.state.stop.mockClear();
|
|
||||||
h.state.transport = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||||
renderThread();
|
renderThread();
|
||||||
|
|
||||||
// Queue a message while the turn is streaming.
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
const sendNowBtn = screen.getByLabelText("Send now");
|
const sendNowBtn = screen.getByLabelText("Send now");
|
||||||
expect(sendNowBtn).toBeTruthy();
|
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);
|
fireEvent.click(sendNowBtn);
|
||||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
|
||||||
act(() => {
|
act(() => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
message: { id: "a", role: "assistant", parts: [] },
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
@@ -119,10 +181,8 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
fireEvent.click(screen.getByLabelText("Send now"));
|
fireEvent.click(screen.getByLabelText("Send now"));
|
||||||
|
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest;
|
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||||
// The send right after "send now" carries interrupted: true...
|
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
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);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -133,42 +193,92 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
fireEvent.click(screen.getByLabelText("Send now"));
|
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.stop).not.toHaveBeenCalled();
|
||||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
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);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
// #388: the editor selection is snapshotted at send time and nested inside
|
||||||
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
||||||
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
// fresh snapshot.
|
||||||
// the queue intact for the user), and which stop notice — if any — to show.
|
describe("ChatThread — editor selection wiring (#388)", () => {
|
||||||
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
beforeEach(resetState);
|
||||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
afterEach(cleanup);
|
||||||
beforeEach(() => {
|
|
||||||
h.state.status = "streaming";
|
function renderWithSelection(props: {
|
||||||
h.state.onFinish = null;
|
openPage?: { id: string; title: string } | null;
|
||||||
h.state.sendMessage.mockClear();
|
getEditorSelection?: () => unknown;
|
||||||
h.state.stop.mockClear();
|
}) {
|
||||||
h.state.transport = null;
|
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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drive a fresh onFinish with the given terminal flags after queueing a
|
it("sends selection: null when the getter returns null", () => {
|
||||||
// message, and report both what the parent was told and whether the queue was
|
renderWithSelection({
|
||||||
// flushed (a resend to the sendMessage spy).
|
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: {
|
function finishWith(flags: {
|
||||||
isAbort?: boolean;
|
isAbort?: boolean;
|
||||||
isDisconnect?: boolean;
|
isDisconnect?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
}) {
|
}) {
|
||||||
// Tear down any prior render so the loop-driven "every outcome" case does
|
|
||||||
// not leave duplicate queue buttons in the DOM.
|
|
||||||
cleanup();
|
cleanup();
|
||||||
h.state.sendMessage.mockClear();
|
h.state.sendMessage.mockClear();
|
||||||
const { onTurnFinished } = renderThread();
|
const { onTurnFinished } = renderThread();
|
||||||
// Populate the queue while the turn is streaming.
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
act(() => {
|
act(() => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
@@ -184,16 +294,12 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
|
|
||||||
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||||
finishWith({});
|
finishWith({});
|
||||||
// Clean finish (no terminal flag): the queued message is auto-sent.
|
|
||||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
// A clean finish shows no stop notice.
|
|
||||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||||
finishWith({ isAbort: true });
|
finishWith({ isAbort: true });
|
||||||
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
|
||||||
// queue is preserved for the user to decide.
|
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -208,15 +314,11 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
|
|
||||||
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||||
finishWith({ isError: true });
|
finishWith({ isError: true });
|
||||||
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
// isError clears the neutral notice (the error banner covers this case).
|
|
||||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("notifies the parent on EVERY terminal outcome", () => {
|
it("notifies the parent on EVERY terminal outcome", () => {
|
||||||
// The chat-list refresh / new-chat id adoption must run on success and on
|
|
||||||
// every failure path alike.
|
|
||||||
for (const flags of [
|
for (const flags of [
|
||||||
{},
|
{},
|
||||||
{ isAbort: true },
|
{ isAbort: true },
|
||||||
@@ -228,3 +330,412 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { generateId } from "ai";
|
import { generateId } from "ai";
|
||||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,15 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.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 {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -60,6 +70,10 @@ interface ChatThreadProps {
|
|||||||
/** The page currently open in the workspace, or null on a non-page route.
|
/** 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. */
|
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||||
openPage?: OpenPageContext | null;
|
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
|
/** 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
|
* 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). */
|
* 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,
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
* which fires only at the terminal outcome. */
|
* which fires only at the terminal outcome. */
|
||||||
onServerChatId?: (serverChatId?: string) => void;
|
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,
|
threadKey,
|
||||||
initialRows,
|
initialRows,
|
||||||
openPage,
|
openPage,
|
||||||
|
getEditorSelection,
|
||||||
roleId,
|
roleId,
|
||||||
roles,
|
roles,
|
||||||
onRolePicked,
|
onRolePicked,
|
||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
onServerChatId,
|
onServerChatId,
|
||||||
|
onResumeFallback,
|
||||||
|
autonomousRunsEnabled,
|
||||||
|
onServerStop,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
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[]>(
|
const initialMessages = useMemo<UIMessage[]>(
|
||||||
() => (initialRows ?? []).map(rowToUiMessage),
|
() =>
|
||||||
|
seedRows(
|
||||||
|
initialRows ?? [],
|
||||||
|
attemptResumeRef.current && stripRef.current,
|
||||||
|
).map(rowToUiMessage),
|
||||||
[initialRows],
|
[initialRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -153,6 +232,14 @@ export default function ChatThread({
|
|||||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||||
openPageRef.current = 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
|
// 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
|
// 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.
|
// 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 flushOnAbortRef = useRef(false);
|
||||||
const interruptNextSendRef = 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).
|
// 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
|
// Returns whether a message was actually sent, so callers can tell an empty
|
||||||
// dequeue (nothing to flush) from a real send.
|
// dequeue (nothing to flush) from a real send.
|
||||||
@@ -223,9 +320,12 @@ export default function ChatThread({
|
|||||||
const { head, rest } = dequeue(queuedRef.current);
|
const { head, rest } = dequeue(queuedRef.current);
|
||||||
if (!head) return false;
|
if (!head) return false;
|
||||||
setQueue(rest);
|
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 });
|
sendMessageRef.current?.({ text: head.text });
|
||||||
return true;
|
return true;
|
||||||
}, [setQueue]);
|
}, [setQueue, setResumedTurnPair]);
|
||||||
|
|
||||||
const enqueue = useCallback(
|
const enqueue = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -245,6 +345,47 @@ export default function ChatThread({
|
|||||||
new DefaultChatTransport<UIMessage>({
|
new DefaultChatTransport<UIMessage>({
|
||||||
api: "/api/ai-chat/stream",
|
api: "/api/ai-chat/stream",
|
||||||
credentials: "include",
|
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
|
// Inject the chat id and the currently-open page alongside the useChat
|
||||||
// messages so the server can resolve an existing chat (or create one
|
// 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
|
// when null) and tell the agent which page "this page" refers to. Both
|
||||||
@@ -261,7 +402,16 @@ export default function ChatThread({
|
|||||||
body: {
|
body: {
|
||||||
...body,
|
...body,
|
||||||
chatId: chatIdRef.current,
|
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 =>
|
// Honoured by the server only when creating a new chat; null =>
|
||||||
// universal assistant.
|
// universal assistant.
|
||||||
roleId: roleIdRef.current,
|
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
|
// 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
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||||
// every render mid-stream (see `chatStoreId` above).
|
// 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
|
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||||
// the user to decide.
|
// the user to decide.
|
||||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
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
|
// Forward the authoritative server chatId (streamed on the assistant
|
||||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
// 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
|
// 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 (isAbort) setStopNotice("manual");
|
||||||
else if (isDisconnect) setStopNotice("disconnect");
|
else if (isDisconnect) setStopNotice("disconnect");
|
||||||
else setStopNotice(null);
|
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
|
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||||
// immediately send the promoted head. Flush it even though the turn was
|
// immediately send the promoted head. Flush it even though the turn was
|
||||||
// aborted (the normal abort path below keeps the queue intact). The
|
// aborted (the normal abort path below keeps the queue intact). The
|
||||||
@@ -365,7 +559,14 @@ export default function ChatThread({
|
|||||||
return;
|
return;
|
||||||
lastForwardedChatIdRef.current = serverChatId;
|
lastForwardedChatIdRef.current = serverChatId;
|
||||||
onServerChatId(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
|
// Live "turn was interrupted" marker for the CURRENT session. The red error
|
||||||
// banner (driven by `error`) covers the error case; this covers an aborted
|
// 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";
|
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 now" on a queued message: interrupt the current turn and immediately
|
||||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||||
// stay queued and flush normally after the new turn. Reuses the existing
|
// 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);
|
const msg = queuedRef.current.find((m) => m.id === id);
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
setQueue(removeQueuedById(queuedRef.current, id));
|
setQueue(removeQueuedById(queuedRef.current, id));
|
||||||
|
// Local send: clear any resume-suppression flag (invariant 8).
|
||||||
|
setResumedTurnPair(false);
|
||||||
sendMessageRef.current?.({ text: msg.text });
|
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
|
// 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
|
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
|
||||||
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
// already consumed synchronously (onFinish + prepareSendMessagesRequest) before
|
||||||
@@ -420,6 +753,11 @@ export default function ChatThread({
|
|||||||
setStopNotice(null);
|
setStopNotice(null);
|
||||||
flushOnAbortRef.current = false;
|
flushOnAbortRef.current = false;
|
||||||
interruptNextSendRef.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]);
|
}, [isStreaming]);
|
||||||
|
|
||||||
@@ -512,17 +850,23 @@ export default function ChatThread({
|
|||||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||||
{m.text}
|
{m.text}
|
||||||
</Text>
|
</Text>
|
||||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
{/* "Send now" (interrupt) is hidden on a RESUMED turn: a local
|
||||||
<ActionIcon
|
stop() does not abort the resumed attach fetch, so the click
|
||||||
size="xs"
|
would be swallowed while flushOnAbortRef would fire minutes
|
||||||
variant="subtle"
|
later on the natural finish. Only the remove affordance stays. */}
|
||||||
color="blue"
|
{!resumedTurn && (
|
||||||
onClick={() => sendNow(m.id)}
|
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||||
aria-label={t("Send now")}
|
<ActionIcon
|
||||||
>
|
size="xs"
|
||||||
<IconPlayerPlayFilled size={12} />
|
variant="subtle"
|
||||||
</ActionIcon>
|
color="blue"
|
||||||
</Tooltip>
|
onClick={() => sendNow(m.id)}
|
||||||
|
aria-label={t("Send now")}
|
||||||
|
>
|
||||||
|
<IconPlayerPlayFilled size={12} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -537,9 +881,13 @@ export default function ChatThread({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={(text) => sendMessage({ text })}
|
onSend={(text) => {
|
||||||
|
// Local send: clear any resume-suppression flag (invariant 8).
|
||||||
|
setResumedTurnPair(false);
|
||||||
|
sendMessage({ text });
|
||||||
|
}}
|
||||||
onQueue={enqueue}
|
onQueue={enqueue}
|
||||||
onStop={stop}
|
onStop={handleStop}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ interface MessageItemProps {
|
|||||||
* Defaults to true (internal chat). The public share passes false.
|
* Defaults to true (internal chat). The public share passes false.
|
||||||
*/
|
*/
|
||||||
showCitations?: boolean;
|
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
|
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||||
* their href so they become inert text). Defaults to false (internal chat,
|
* their href so they become inert text). Defaults to false (internal chat,
|
||||||
@@ -117,6 +124,7 @@ const MarkdownPart = memo(function MarkdownPart({
|
|||||||
function MessageItem({
|
function MessageItem({
|
||||||
message,
|
message,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
|
showInput = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
turnStreaming = false,
|
turnStreaming = false,
|
||||||
@@ -210,6 +218,7 @@ function MessageItem({
|
|||||||
key={index}
|
key={index}
|
||||||
part={part as unknown as ToolUiPart}
|
part={part as unknown as ToolUiPart}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
|
showInput={showInput}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -274,6 +283,7 @@ export function arePropsEqual(
|
|||||||
return (
|
return (
|
||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
|
prev.showInput === next.showInput &&
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName &&
|
prev.assistantName === next.assistantName &&
|
||||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
// 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.
|
* false because an anonymous reader cannot open the linked internal pages.
|
||||||
*/
|
*/
|
||||||
showCitations?: boolean;
|
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
|
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||||
* the rendered answers (drop their href so they render as inert text).
|
* the rendered answers (drop their href so they render as inert text).
|
||||||
@@ -119,6 +126,7 @@ export default function MessageList({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
emptyState,
|
emptyState,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
|
showInput = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
@@ -208,6 +216,7 @@ export default function MessageList({
|
|||||||
message={message}
|
message={message}
|
||||||
signature={messageSignature(message)}
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
|
showInput={showInput}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
getToolName,
|
getToolName,
|
||||||
toolCitations,
|
toolCitations,
|
||||||
|
toolInputSummary,
|
||||||
toolLabelKey,
|
toolLabelKey,
|
||||||
toolRunState,
|
toolRunState,
|
||||||
ToolUiPart,
|
ToolUiPart,
|
||||||
@@ -21,6 +22,14 @@ interface ToolCallCardProps {
|
|||||||
* (the action log itself) while dropping the unusable links.
|
* (the action log itself) while dropping the unusable links.
|
||||||
*/
|
*/
|
||||||
showCitations?: boolean;
|
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({
|
export default function ToolCallCard({
|
||||||
part,
|
part,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
|
showInput = true,
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toolName = getToolName(part);
|
const toolName = getToolName(part);
|
||||||
const state = toolRunState(part.state);
|
const state = toolRunState(part.state);
|
||||||
const { key, values } = toolLabelKey(toolName);
|
const { key, values } = toolLabelKey(toolName);
|
||||||
const citations = showCitations ? toolCitations(part) : [];
|
const citations = showCitations ? toolCitations(part) : [];
|
||||||
|
const inputSummary = showInput ? toolInputSummary(part) : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.toolCard}>
|
<div className={classes.toolCard}>
|
||||||
@@ -57,6 +68,12 @@ export default function ToolCallCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{inputSummary && (
|
||||||
|
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
|
||||||
|
{inputSummary}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
{state === "error" && part.errorText && (
|
{state === "error" && part.errorText && (
|
||||||
<Text size="xs" c="red" mt={2}>
|
<Text size="xs" c="red" mt={2}>
|
||||||
{part.errorText}
|
{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 {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -56,11 +57,12 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
|||||||
export function useAiChatsQuery() {
|
export function useAiChatsQuery() {
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHATS_RQ_KEY,
|
queryKey: AI_CHATS_RQ_KEY,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||||
getAiChats({ cursor: pageParam, limit: 50 }),
|
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = useMemo<IPagination<IAiChat> | 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
|
* Load all persisted messages of a chat (oldest first), flattening the
|
||||||
* paginated server response. Used to seed `useChat` initial messages.
|
* 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({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
getAiChatMessages({ chatId: chatId as string, cursor: pageParam }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
getNextPageParam: (lastPage) =>
|
getNextPageParam: (lastPage) =>
|
||||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
lastPage.meta.hasNextPage
|
||||||
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
|
: undefined,
|
||||||
enabled: !!chatId,
|
enabled: !!chatId,
|
||||||
|
refetchInterval,
|
||||||
});
|
});
|
||||||
|
|
||||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
// 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() {
|
export function useImportAiRolesFromCatalogMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -280,11 +316,14 @@ export function useImportAiRolesFromCatalogMutation() {
|
|||||||
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
mutationFn: (payload) => importAiRolesFromCatalog(payload),
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}", {
|
message: t(
|
||||||
created: result.created,
|
"Imported {{created}}, renamed {{renamed}}, skipped {{skipped}}",
|
||||||
renamed: result.renamed,
|
{
|
||||||
skipped: result.skipped,
|
created: result.created,
|
||||||
}),
|
renamed: result.renamed,
|
||||||
|
skipped: result.skipped,
|
||||||
|
},
|
||||||
|
),
|
||||||
});
|
});
|
||||||
// Surface partial failures (e.g. unique-name races) as a red warning.
|
// Surface partial failures (e.g. unique-name races) as a red warning.
|
||||||
if (result.errors.length > 0) {
|
if (result.errors.length > 0) {
|
||||||
|
|||||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
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).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
message: "Imported 3, renamed 1, skipped 2",
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
{ slug: "a", message: "name taken" },
|
{ slug: "a", message: "name taken" },
|
||||||
{ slug: "b", message: "name taken" },
|
{ slug: "b", message: "name taken" },
|
||||||
],
|
],
|
||||||
|
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||||
|
skippedRoles: [],
|
||||||
});
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
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
|
* 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.
|
* 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";
|
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 {
|
export interface IAiRoleImportResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
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.
|
// renders a "stopped" marker on interrupted turns.
|
||||||
finishReason?: string;
|
finishReason?: string;
|
||||||
} | null;
|
} | 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;
|
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 { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
toolCitations,
|
toolCitations,
|
||||||
|
toolInputSummary,
|
||||||
toolRunState,
|
toolRunState,
|
||||||
type ToolUiPart,
|
type ToolUiPart,
|
||||||
} from "./tool-parts";
|
} 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", () => {
|
describe("toolRunState", () => {
|
||||||
it('maps "output-error" to error', () => {
|
it('maps "output-error" to error', () => {
|
||||||
expect(toolRunState("output-error")).toBe("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;
|
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.
|
* Resolve the page citation(s) a tool part references, from its input/output.
|
||||||
* Only output-available parts (the tool returned) yield citations. Search
|
* 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
@@ -9,10 +9,11 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
const applyMutateAsync = vi.fn();
|
const applyMutateAsync = vi.fn();
|
||||||
const dismissMutateAsync = vi.fn();
|
const dismissMutateAsync = vi.fn();
|
||||||
|
const updateMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||||
useApplySuggestionMutation: () => ({
|
useApplySuggestionMutation: () => ({
|
||||||
mutateAsync: applyMutateAsync,
|
mutateAsync: applyMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
@@ -23,9 +24,51 @@ vi.mock("@/features/comment/queries/comment-query", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 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.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||||
default: () => <div data-testid="comment-editor" />,
|
// 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 CommentListItem from "./comment-list-item";
|
||||||
@@ -286,3 +329,132 @@ describe("canShowDismiss predicate", () => {
|
|||||||
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
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 { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
import React, { useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
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 { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||||
import CommentActions from "@/features/comment/components/comment-actions";
|
import CommentActions from "@/features/comment/components/comment-actions";
|
||||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||||
@@ -50,7 +51,6 @@ function CommentListItem({
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const editor = useAtomValue(pageEditorAtom);
|
const editor = useAtomValue(pageEditorAtom);
|
||||||
const [content, setContent] = useState<string>(comment.content);
|
|
||||||
const editContentRef = useRef<any>(null);
|
const editContentRef = useRef<any>(null);
|
||||||
const updateCommentMutation = useUpdateCommentMutation();
|
const updateCommentMutation = useUpdateCommentMutation();
|
||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
@@ -78,22 +78,16 @@ function CommentListItem({
|
|||||||
const isOwnerOrAdmin =
|
const isOwnerOrAdmin =
|
||||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setContent(comment.content);
|
|
||||||
}, [comment]);
|
|
||||||
|
|
||||||
async function handleUpdateComment() {
|
async function handleUpdateComment() {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const commentToUpdate = {
|
const commentToUpdate = {
|
||||||
commentId: comment.id,
|
commentId: comment.id,
|
||||||
content: JSON.stringify(editContentRef.current ?? content),
|
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||||
};
|
};
|
||||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||||
if (editContentRef.current) {
|
editContentRef.current = null;
|
||||||
setContent(editContentRef.current);
|
|
||||||
editContentRef.current = null;
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update comment:", error);
|
console.error("Failed to update comment:", error);
|
||||||
@@ -350,11 +344,11 @@ function CommentListItem({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<CommentEditor defaultContent={content} editable={false} />
|
<CommentContentView content={comment.content} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
defaultContent={content}
|
defaultContent={comment.content}
|
||||||
editable={true}
|
editable={true}
|
||||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||||
onSave={handleUpdateComment}
|
onSave={handleUpdateComment}
|
||||||
@@ -374,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 { useFocusWithin } from "@mantine/hooks";
|
||||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
|||||||
onClose?: () => void;
|
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) {
|
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
isError,
|
isError,
|
||||||
} = useCommentsQuery({ pageId: page?.id });
|
} = useCommentsQuery({ pageId: page?.id });
|
||||||
const createCommentMutation = useCreateCommentMutation();
|
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 { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||||
|
|
||||||
const canEdit = page?.permissions?.canEdit ?? false;
|
const canEdit = page?.permissions?.canEdit ?? false;
|
||||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
return { activeComments: active, resolvedComments: resolved };
|
return { activeComments: active, resolvedComments: resolved };
|
||||||
}, [comments]);
|
}, [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 [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||||
|
|
||||||
const handleAddPageComment = useCallback(
|
const handleAddPageComment = useCallback(
|
||||||
async (_commentId: string, content: string) => {
|
async (_commentId: string, content: string) => {
|
||||||
try {
|
try {
|
||||||
setIsPageCommentLoading(true);
|
setIsPageCommentLoading(true);
|
||||||
const createdComment = await createCommentMutation.mutateAsync({
|
const createdComment = await createCommentAsync({
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
});
|
});
|
||||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
setIsPageCommentLoading(false);
|
setIsPageCommentLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAddReply = useCallback(
|
const handleAddReply = useCallback(
|
||||||
async (commentId: string, content: string) => {
|
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 {
|
try {
|
||||||
setIsLoading(true);
|
|
||||||
const commentData = {
|
const commentData = {
|
||||||
pageId: page?.id,
|
pageId: page?.id,
|
||||||
parentCommentId: commentId,
|
parentCommentId: commentId,
|
||||||
content: JSON.stringify(content),
|
content: JSON.stringify(content),
|
||||||
};
|
};
|
||||||
|
|
||||||
await createCommentMutation.mutateAsync(commentData);
|
await createCommentAsync(commentData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to post comment:", error);
|
console.error("Failed to post comment:", error);
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[createCommentMutation, page?.id],
|
[createCommentAsync, page?.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderComments = useCallback(
|
const renderComments = useCallback(
|
||||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
userSpaceRole={space?.membership?.role}
|
userSpaceRole={space?.membership?.role}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={comment.id}
|
parentId={comment.id}
|
||||||
pageId={page?.id}
|
pageId={page?.id}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<CommentEditorWithActions
|
<CommentEditorWithActions
|
||||||
commentId={comment.id}
|
commentId={comment.id}
|
||||||
onSave={handleAddReply}
|
onSave={handleAddReply}
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</Paper>
|
||||||
),
|
),
|
||||||
[
|
[
|
||||||
comments,
|
childrenByParent,
|
||||||
handleAddReply,
|
handleAddReply,
|
||||||
isLoading,
|
page?.id,
|
||||||
space?.membership?.role,
|
space?.membership?.role,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
<Tabs
|
<Tabs
|
||||||
defaultValue="open"
|
defaultValue="open"
|
||||||
variant="default"
|
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={{
|
style={{
|
||||||
flex: "1 1 auto",
|
flex: "1 1 auto",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<div style={{ paddingBottom: "8px" }}>
|
<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 ? (
|
{activeComments.length === 0 ? (
|
||||||
<Center py="xl">
|
<Center py="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ChildCommentsProps {
|
interface ChildCommentsProps {
|
||||||
comments: IPagination<IComment>;
|
childrenByParent: Map<string, IComment[]>;
|
||||||
parentId: string;
|
parentId: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
canComment: boolean;
|
canComment: boolean;
|
||||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
|||||||
userSpaceRole?: string;
|
userSpaceRole?: string;
|
||||||
}
|
}
|
||||||
const ChildComments = ({
|
const ChildComments = ({
|
||||||
comments,
|
childrenByParent,
|
||||||
parentId,
|
parentId,
|
||||||
pageId,
|
pageId,
|
||||||
canComment,
|
canComment,
|
||||||
canEdit,
|
canEdit,
|
||||||
userSpaceRole,
|
userSpaceRole,
|
||||||
}: ChildCommentsProps) => {
|
}: ChildCommentsProps) => {
|
||||||
const getChildComments = useCallback(
|
const children = childrenByParent.get(parentId) ?? [];
|
||||||
(parentId: string) =>
|
|
||||||
comments.items.filter(
|
|
||||||
(comment: IComment) => comment.parentCommentId === parentId,
|
|
||||||
),
|
|
||||||
[comments.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{getChildComments(parentId).map((childComment) => (
|
{children.map((childComment) => (
|
||||||
<div key={childComment.id}>
|
<div key={childComment.id}>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={childComment}
|
comment={childComment}
|
||||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
|||||||
userSpaceRole={userSpaceRole}
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
<MemoizedChildComments
|
<MemoizedChildComments
|
||||||
comments={comments}
|
childrenByParent={childrenByParent}
|
||||||
parentId={childComment.id}
|
parentId={childComment.id}
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
canComment={canComment}
|
canComment={canComment}
|
||||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
|||||||
|
|
||||||
const MemoizedChildComments = memo(ChildComments);
|
const MemoizedChildComments = memo(ChildComments);
|
||||||
|
|
||||||
const CommentEditorWithActions = ({
|
export const CommentEditorWithActions = ({
|
||||||
commentId,
|
commentId,
|
||||||
onSave,
|
onSave,
|
||||||
isLoading,
|
|
||||||
placeholder = undefined,
|
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 [content, setContent] = useState("");
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
const { ref, focused } = useFocusWithin();
|
const { ref, focused } = useFocusWithin();
|
||||||
const commentEditorRef = useRef(null);
|
const commentEditorRef = useRef(null);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const activate = useCallback(() => setMounted(true), []);
|
||||||
onSave(commentId, content);
|
|
||||||
setContent("");
|
const handleSave = useCallback(async () => {
|
||||||
commentEditorRef.current?.clearContent();
|
try {
|
||||||
|
setIsSending(true);
|
||||||
|
await onSave(commentId, content);
|
||||||
|
setContent("");
|
||||||
|
commentEditorRef.current?.clearContent();
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
}, [commentId, content, onSave]);
|
}, [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 (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
<CommentEditor
|
<CommentEditor
|
||||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
editable={true}
|
editable={true}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
autofocus={true}
|
||||||
/>
|
/>
|
||||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
data,
|
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,
|
isError: query.isError,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ import {
|
|||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import classes from "./mention.module.css";
|
import classes from "./mention.module.css";
|
||||||
|
|
||||||
export default function MentionView(props: NodeViewProps) {
|
interface MentionAttrs {
|
||||||
const { node } = props;
|
label?: string;
|
||||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
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 isPageMention = entityType === "page";
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
const { shareId } = useParams();
|
const { shareId } = useParams();
|
||||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
<>
|
||||||
{entityType === "user" && (
|
{entityType === "user" && (
|
||||||
<Text className={classes.userMention} component="span">
|
<Text className={classes.userMention} component="span">
|
||||||
@{label}
|
@{label}
|
||||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MentionView(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||||
|
<MentionContent attrs={props.node.attrs} />
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
gitmostUploadFileToEditor,
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
|||||||
pageId: page.id,
|
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 };
|
return { ok: true, pageId: page.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[gitmost] createPageWithRecording failed", err);
|
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;
|
base64: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: 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 {
|
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
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
// a result code.
|
// a result code.
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ import {
|
|||||||
isBodyEditable,
|
isBodyEditable,
|
||||||
isCollabSynced,
|
isCollabSynced,
|
||||||
} from "@/features/editor/editor-sync-state";
|
} from "@/features/editor/editor-sync-state";
|
||||||
|
import {
|
||||||
|
isVitalsActive,
|
||||||
|
measurePageOpen,
|
||||||
|
reportEditorTx,
|
||||||
|
} from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
interface PageEditorProps {
|
interface PageEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -351,6 +356,40 @@ export default function PageEditor({
|
|||||||
editor.storage.pageId = pageId;
|
editor.storage.pageId = pageId;
|
||||||
handleScrollTo(editor);
|
handleScrollTo(editor);
|
||||||
editorRef.current = 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 }) {
|
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;
|
||||||
|
}
|
||||||
@@ -165,6 +165,9 @@ export default function ShareAiWidget({
|
|||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
showCitations={false}
|
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
|
// Anonymous reader: neutralize internal/relative links in the
|
||||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||||
// leak as clickable links (external http(s) links are kept).
|
// 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>(
|
useState<boolean>(
|
||||||
workspace?.settings?.ai?.publicShareAssistant ?? false,
|
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 [chatToggleLoading, setChatToggleLoading] = useState(false);
|
||||||
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
const [searchToggleLoading, setSearchToggleLoading] = useState(false);
|
||||||
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
const [dictationToggleLoading, setDictationToggleLoading] = useState(false);
|
||||||
@@ -403,6 +407,8 @@ export default function AiProviderSettings() {
|
|||||||
publicShareAssistantToggleLoading,
|
publicShareAssistantToggleLoading,
|
||||||
setPublicShareAssistantToggleLoading,
|
setPublicShareAssistantToggleLoading,
|
||||||
] = useState(false);
|
] = useState(false);
|
||||||
|
const [autonomousRunsToggleLoading, setAutonomousRunsToggleLoading] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
// Whether a key is currently stored server-side (drives the placeholder).
|
// Whether a key is currently stored server-side (drives the placeholder).
|
||||||
const [hasApiKey, setHasApiKey] = useState(false);
|
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.
|
// Admins only — match the previous behavior.
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -960,6 +997,31 @@ export default function AiProviderSettings() {
|
|||||||
{...form.getInputProps("publicShareAssistantRoleId")}
|
{...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">
|
<Group mt="md" align="center">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ export interface IWorkspace {
|
|||||||
aiDictation?: boolean;
|
aiDictation?: boolean;
|
||||||
aiDictationStreaming?: boolean;
|
aiDictationStreaming?: boolean;
|
||||||
aiPublicShareAssistant?: boolean;
|
aiPublicShareAssistant?: boolean;
|
||||||
|
// Write-only field for updateWorkspace({ autonomousRuns }). Read state lives at
|
||||||
|
// settings.ai.autonomousRuns.
|
||||||
|
autonomousRuns?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
// Default lifetime (HOURS) for new temporary notes; frozen per-note at creation.
|
||||||
temporaryNoteHours?: number;
|
temporaryNoteHours?: number;
|
||||||
@@ -65,6 +68,9 @@ export interface IWorkspaceAiSettings {
|
|||||||
dictation?: boolean;
|
dictation?: boolean;
|
||||||
dictationStreaming?: boolean;
|
dictationStreaming?: boolean;
|
||||||
publicShareAssistant?: 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 {
|
export interface IWorkspaceSharingSettings {
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ export function isCompactPageTreeEnabled(): boolean {
|
|||||||
return castToBoolean(getConfigValue("COMPACT_PAGE_TREE", "true"));
|
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(
|
export function getAvatarUrl(
|
||||||
avatarUrl: string,
|
avatarUrl: string,
|
||||||
type: AvatarIconType = AvatarIconType.AVATAR,
|
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,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
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 container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"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",
|
"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",
|
"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": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -43,6 +43,7 @@
|
|||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -111,6 +112,7 @@
|
|||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"postmark": "^4.0.7",
|
"postmark": "^4.0.7",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-email": "6.0.8",
|
"react-email": "6.0.8",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
@@ -174,7 +176,7 @@
|
|||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||||
"babel-jest",
|
"babel-jest",
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
@@ -192,7 +194,7 @@
|
|||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"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": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
@@ -203,7 +205,8 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$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 { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
import { AiModule } from './integrations/ai/ai.module';
|
import { AiModule } from './integrations/ai/ai.module';
|
||||||
import { AiChatModule } from './core/ai-chat/ai-chat.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 = [];
|
const enterpriseModules = [];
|
||||||
try {
|
try {
|
||||||
@@ -93,6 +95,10 @@ try {
|
|||||||
SandboxModule,
|
SandboxModule,
|
||||||
AiModule,
|
AiModule,
|
||||||
AiChatModule,
|
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,
|
...enterpriseModules,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
|
||||||
TransclusionSource,
|
TransclusionSource,
|
||||||
TransclusionReference,
|
TransclusionReference,
|
||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not 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 {
|
export function jsonToMarkdown(tiptapJson: any): string {
|
||||||
const html = jsonToHtml(tiptapJson);
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
return htmlToMarkdown(html);
|
// (`@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,
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
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
|
* #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) {
|
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 { documentName, document, context } = data;
|
||||||
|
|
||||||
const pageId = getPageId(documentName);
|
const pageId = getPageId(documentName);
|
||||||
|
|||||||
@@ -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(
|
const controller = new AiChatController(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('AiChatController.export', () => {
|
|||||||
};
|
};
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
aiChatRepo as never,
|
aiChatRepo as never,
|
||||||
aiChatMessageRepo as never,
|
aiChatMessageRepo as never,
|
||||||
{} 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 {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Body,
|
Body,
|
||||||
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
Post,
|
Post,
|
||||||
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
@@ -20,7 +25,13 @@ import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
|||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
import { SkipTransform } from '../../common/decorators/skip-transform.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 { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.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 { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
|
||||||
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
|
||||||
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
|
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 { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import {
|
import {
|
||||||
BoundChatDto,
|
BoundChatDto,
|
||||||
@@ -36,10 +52,18 @@ import {
|
|||||||
ExportChatDto,
|
ExportChatDto,
|
||||||
GeneratePageTitleDto,
|
GeneratePageTitleDto,
|
||||||
GetChatMessagesDto,
|
GetChatMessagesDto,
|
||||||
|
GetRunDto,
|
||||||
RenameChatDto,
|
RenameChatDto,
|
||||||
|
StopRunDto,
|
||||||
} from './dto/ai-chat.dto';
|
} from './dto/ai-chat.dto';
|
||||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||||
import { buildChatMarkdown } from './chat-markdown.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
|
* Per-user AI chat API (§6.1). Routes are POST to match this codebase's
|
||||||
@@ -53,10 +77,16 @@ export class AiChatController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly aiChatService: AiChatService,
|
private readonly aiChatService: AiChatService,
|
||||||
|
private readonly aiChatRunService: AiChatRunService,
|
||||||
private readonly aiChatRepo: AiChatRepo,
|
private readonly aiChatRepo: AiChatRepo,
|
||||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||||
private readonly aiTranscription: AiTranscriptionService,
|
private readonly aiTranscription: AiTranscriptionService,
|
||||||
private readonly pageRepo: PageRepo,
|
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). */
|
/** List the requesting user's chats in this workspace (paginated). */
|
||||||
@@ -149,6 +179,171 @@ export class AiChatController {
|
|||||||
return { markdown };
|
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. */
|
/** Rename a chat. */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('rename')
|
@Post('rename')
|
||||||
@@ -200,11 +395,20 @@ export class AiChatController {
|
|||||||
@AuthWorkspace() workspace: Workspace,
|
@AuthWorkspace() workspace: Workspace,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// A7 gate: the workspace must have AI chat explicitly enabled.
|
// 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) {
|
if (settings.ai?.chat !== true) {
|
||||||
throw new ForbiddenException('AI chat is disabled');
|
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;
|
const sessionId = (req.raw as { sessionId?: string }).sessionId;
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
// The chat requires an interactive session to mint loopback tokens
|
// The chat requires an interactive session to mint loopback tokens
|
||||||
@@ -228,6 +432,70 @@ export class AiChatController {
|
|||||||
// HttpException) instead of breaking mid-stream.
|
// HttpException) instead of breaking mid-stream.
|
||||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
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
|
// Abort the agent loop when the client disconnects. `close` also fires on
|
||||||
// normal completion, so only abort when the response has not finished
|
// normal completion, so only abort when the response has not finished
|
||||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
// 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
|
// A genuine disconnect leaves the response unfinished (unlike a normal
|
||||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
||||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
// 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) {
|
if (!res.raw.writableEnded) {
|
||||||
this.logger.warn(
|
if (autonomousRuns) {
|
||||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
||||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
// the run keeps executing and persisting server-side; the client
|
||||||
);
|
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||||
controller.abort();
|
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);
|
req.raw.once('close', onClose);
|
||||||
res.raw.once('finish', () => req.raw.off('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
|
// 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.
|
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||||
res.hijack();
|
res.hijack();
|
||||||
@@ -268,15 +562,32 @@ export class AiChatController {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
model,
|
model,
|
||||||
role,
|
role,
|
||||||
|
// #184: present only when the flag is on; wraps the turn in a durable run.
|
||||||
|
runHooks,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Any failure AFTER hijack can no longer send a clean JSON error, so emit
|
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||||
// a minimal error on the raw socket if nothing has been written yet.
|
// filter, so emit the error on the raw socket if nothing has been written
|
||||||
this.logger.error('AI chat stream failed', err as Error);
|
// 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) {
|
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.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) {
|
} else if (!res.raw.writableEnded) {
|
||||||
res.raw.end();
|
res.raw.end();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('AiChatController.generatePageTitle', () => {
|
|||||||
const aiChatService = { generatePageTitle: generate };
|
const aiChatService = { generatePageTitle: generate };
|
||||||
const controller = new AiChatController(
|
const controller = new AiChatController(
|
||||||
aiChatService as never,
|
aiChatService as never,
|
||||||
|
{} as never, // aiChatRunService
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { AiModule } from '../../integrations/ai/ai.module';
|
|||||||
import { TokenModule } from '../auth/token.module';
|
import { TokenModule } from '../auth/token.module';
|
||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
import { AiChatService } from './ai-chat.service';
|
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 { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { EmbeddingModule } from './embedding/embedding.module';
|
import { EmbeddingModule } from './embedding/embedding.module';
|
||||||
@@ -42,6 +44,8 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
|||||||
controllers: [AiChatController, PublicShareChatController],
|
controllers: [AiChatController, PublicShareChatController],
|
||||||
providers: [
|
providers: [
|
||||||
AiChatService,
|
AiChatService,
|
||||||
|
AiChatRunService,
|
||||||
|
AiChatStreamRegistryService,
|
||||||
AiTranscriptionService,
|
AiTranscriptionService,
|
||||||
AiChatToolsService,
|
AiChatToolsService,
|
||||||
PublicShareChatService,
|
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';
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,6 +153,41 @@ describe('buildSystemPrompt current-page context', () => {
|
|||||||
expect(prompt).not.toContain('pageId:');
|
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)', () => {
|
it('escapes a malicious opened-page title so it cannot inject tags (F1)', () => {
|
||||||
const prompt = buildSystemPrompt({
|
const prompt = buildSystemPrompt({
|
||||||
workspace,
|
workspace,
|
||||||
@@ -396,3 +435,62 @@ describe('buildSystemPrompt page-changed note (#274)', () => {
|
|||||||
expect(opens).toBe(1);
|
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 { Workspace } from '@docmost/db/types/entity.types';
|
||||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
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
|
* 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" /
|
* 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
|
* "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.
|
* 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
|
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
|
||||||
* server's tools"), built by `McpClientsService.toolsFor` for servers that
|
* 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).
|
* block (unchanged page, page not open, or first turn).
|
||||||
*/
|
*/
|
||||||
pageChanged?: { title: string; diff: string } | null;
|
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,
|
mcpInstructions,
|
||||||
interrupted,
|
interrupted,
|
||||||
pageChanged,
|
pageChanged,
|
||||||
|
deferredToolsEnabled,
|
||||||
|
toolCatalog,
|
||||||
}: BuildSystemPromptInput): string {
|
}: BuildSystemPromptInput): string {
|
||||||
// Persona precedence: role instructions REPLACE the admin persona / default.
|
// Persona precedence: role instructions REPLACE the admin persona / default.
|
||||||
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
// effectivePersona = roleInstructions || adminPrompt || DEFAULT_PROMPT.
|
||||||
@@ -257,6 +314,14 @@ export function buildSystemPrompt({
|
|||||||
? escapeAttr(openedPage.title)
|
? escapeAttr(openedPage.title)
|
||||||
: 'Untitled';
|
: '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.`;
|
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
|
// 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.
|
// Empty when no qualifying server has guidance.
|
||||||
const mcpTooling = buildMcpToolingBlock(mcpInstructions);
|
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
|
// Sandwich the lower-trust persona/role text between two copies of the
|
||||||
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
// immutable SAFETY_FRAMEWORK so any jailbreak inside `base` is both preceded
|
||||||
// and followed by the safety rules. The persona is delimited with explicit
|
// and followed by the safety rules. The persona is delimited with explicit
|
||||||
@@ -316,6 +391,7 @@ export function buildSystemPrompt({
|
|||||||
'</role_persona>',
|
'</role_persona>',
|
||||||
context,
|
context,
|
||||||
mcpTooling,
|
mcpTooling,
|
||||||
|
toolCatalogBlock,
|
||||||
SAFETY_FRAMEWORK,
|
SAFETY_FRAMEWORK,
|
||||||
]
|
]
|
||||||
.filter((part) => part !== '')
|
.filter((part) => part !== '')
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ describe('AiChatService.resolveRoleForRequest', () => {
|
|||||||
aiAgentRoleRepo as never,
|
aiAgentRoleRepo as never,
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatRepo, aiAgentRoleRepo };
|
return { service, aiChatRepo, aiAgentRoleRepo };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
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
|
* Lifecycle unit tests for AiChatService.onModuleInit (#183 crash-recovery
|
||||||
@@ -22,6 +24,7 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
{} as never, // aiAgentRoleRepo
|
{} as never, // aiAgentRoleRepo
|
||||||
{} as never, // pageRepo
|
{} as never, // pageRepo
|
||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
);
|
);
|
||||||
return { service, aiChatMessageRepo };
|
return { service, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
@@ -60,3 +63,99 @@ describe('AiChatService.onModuleInit (startup sweep)', () => {
|
|||||||
expect(String(warnSpy.mock.calls[0][0])).toContain('db unavailable');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Mock the AI SDK: the turn we drive is STOPPED during the pre-streamText setup
|
||||||
|
// phase, so no provider call must ever be made. convertToModelMessages is reached
|
||||||
|
// (before toolsFor) so it is stubbed to an empty transcript.
|
||||||
|
jest.mock('ai', () => ({
|
||||||
|
streamText: jest.fn(),
|
||||||
|
generateText: jest.fn(),
|
||||||
|
convertToModelMessages: jest.fn(async () => []),
|
||||||
|
stepCountIs: jest.fn(() => () => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { streamText } from 'ai';
|
||||||
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D2 — an explicit Stop DURING the external-MCP toolset build (the pre-streamText
|
||||||
|
* setup phase) must:
|
||||||
|
* (a) unwedge the turn (stream() rejects instead of hanging at step 0), and
|
||||||
|
* (b) finalize the run as 'aborted' via the outer catch's onSettled — never leak
|
||||||
|
* the run row as 'running' (which would 409 every later turn in this chat).
|
||||||
|
*
|
||||||
|
* The setup phase does NOT yet observe streamText's terminal callbacks, so before
|
||||||
|
* the fix a hung `toolsFor` ignored the run's abort signal and never finalized.
|
||||||
|
* `raceAgainstAbortAndTimeout(toolsFor, effectiveSignal, ...)` now rejects the
|
||||||
|
* moment the run's signal aborts; the catch re-throws (signal aborted), and the
|
||||||
|
* outer catch settles the run 'aborted'.
|
||||||
|
*/
|
||||||
|
describe('AiChatService.stream — abort during external-MCP setup finalizes the run (D2)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
function makeService(mcpClients: { toolsFor: jest.Mock }) {
|
||||||
|
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 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, tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined as never);
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('stops the hung toolset build, rejects, and settles the run "aborted" — never reaching streamText', async () => {
|
||||||
|
const runController = new AbortController();
|
||||||
|
// The build hangs (never resolves); the run is STOPPED mid-build. Aborting on a
|
||||||
|
// macrotask exercises the abort-listener path (a real user Stop during setup).
|
||||||
|
const toolsFor = jest.fn(() => {
|
||||||
|
setTimeout(() => runController.abort(new Error('user stop')), 0);
|
||||||
|
return new Promise(() => {}); // never settles — models a hung MCP build
|
||||||
|
});
|
||||||
|
const { svc } = makeService({ toolsFor });
|
||||||
|
|
||||||
|
const onSettled = jest.fn();
|
||||||
|
const begin = jest.fn(async () => ({
|
||||||
|
runId: 'run-1',
|
||||||
|
signal: runController.signal,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const promise = svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: body as never,
|
||||||
|
res: { raw: {} } as never,
|
||||||
|
signal: new AbortController().signal, // socket signal (distinct from the run)
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: {
|
||||||
|
begin,
|
||||||
|
onAssistantSeeded: jest.fn(),
|
||||||
|
onStep: jest.fn(),
|
||||||
|
onSettled,
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) The turn is UNWEDGED: it rejects (with the stop reason) instead of hanging.
|
||||||
|
await expect(promise).rejects.toThrow('user stop');
|
||||||
|
|
||||||
|
// (b) The run is finalized as 'aborted' with NO error message (a Stop, not a
|
||||||
|
// failure) — so the run row never leaks 'running'.
|
||||||
|
expect(onSettled).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSettled).toHaveBeenCalledWith('run-1', 'aborted', undefined);
|
||||||
|
|
||||||
|
// The build was reached, but the provider call was NEVER made (stopped at setup).
|
||||||
|
expect(toolsFor).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamTextMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
import {
|
||||||
AiChatService,
|
AiChatService,
|
||||||
compactToolOutput,
|
compactToolOutput,
|
||||||
@@ -217,23 +226,78 @@ describe('rowToUiMessage', () => {
|
|||||||
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
* a text-only synthesis answer (toolChoice 'none') with the FINAL_STEP_INSTRUCTION
|
||||||
* appended onto — not replacing — the original system prompt.
|
* 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', () => {
|
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();
|
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();
|
expect(prepareAgentStep(MAX_AGENT_STEPS - 2, 'SYS')).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forces a text-only synthesis on the final allowed step', () => {
|
it('forces a text-only synthesis on the final allowed step (toggle off)', () => {
|
||||||
const result = prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS');
|
const result = asLockdown(prepareAgentStep(MAX_AGENT_STEPS - 1, 'SYS'));
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
expect(result?.toolChoice).toBe('none');
|
expect(result.toolChoice).toBe('none');
|
||||||
// The original persona is preserved (prefix), not replaced.
|
// 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.
|
// 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', () => {
|
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
|
// finish-step usage is per-step in v6; the caller accumulates and passes the
|
||||||
// running sum, which this just wraps.
|
// running sum, which this just wraps.
|
||||||
@@ -628,6 +698,7 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
selection: unknown;
|
||||||
} | null>;
|
} | null>;
|
||||||
|
|
||||||
it('returns null when no page is open (no id)', async () => {
|
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.
|
// 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' });
|
const result = await call(svc, { id: 'p-1', title: 'Page A' });
|
||||||
// updatedAt (#274 page-change fast path) is carried through from the DB row.
|
// updatedAt (#274 page-change fast path) is carried through from the DB row;
|
||||||
expect(result).toEqual({ id: 'p-1', title: 'Real Title B', updatedAt });
|
// 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 () => {
|
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',
|
id: 'p-1',
|
||||||
title: '',
|
title: '',
|
||||||
updatedAt,
|
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);
|
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;
|
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
|
/** Export a chat to Markdown (#183). `lang` localizes the few fixed
|
||||||
* role/tool-action labels; defaults to English server-side. */
|
* role/tool-action labels; defaults to English server-side. */
|
||||||
export class ExportChatDto {
|
export class ExportChatDto {
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import { McpClientsService } from './mcp-clients.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* D1 — a HUNG MCP handshake must not POISON the per-workspace build cache.
|
||||||
|
*
|
||||||
|
* THE BUG (production hang): `createMCPClient` (inside the private `connect`) is
|
||||||
|
* NOT bounded by a timeout and — like @ai-sdk/mcp's tool calls — its promise does
|
||||||
|
* NOT settle on abort. A transient network blip mid-handshake made connect hang
|
||||||
|
* FOREVER. Because getOrBuildEntry caches the build PROMISE, that never-settling
|
||||||
|
* connect wedged EVERY later turn for the workspace (each awaited the same pending
|
||||||
|
* build) — step_count stuck at 0, run row leaking 'running', chat 409ing forever.
|
||||||
|
*
|
||||||
|
* THE FIX: `connectWithTimeout` races `connect` against a SETTLING timeout
|
||||||
|
* (CONNECT_TIMEOUT_MS). On timeout it REJECTS, so buildEntry catches it, records
|
||||||
|
* the server `ok:false`, and the build COMPLETES with that server skipped — the
|
||||||
|
* cache is never poisoned and a subsequent `toolsFor` returns instead of hanging.
|
||||||
|
*
|
||||||
|
* REACHABILITY NOTE: the smallest network-free path that exercises the fix is to
|
||||||
|
* spy on the private `connect` (the same harness the namespacing spec uses) —
|
||||||
|
* `connectWithTimeout` wraps exactly that call, so a never-resolving `connect`
|
||||||
|
* models a never-settling `createMCPClient` precisely, without DNS/sockets.
|
||||||
|
*
|
||||||
|
* Fake timers prove the timeout fires WITHOUT real waiting.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mirrors the private CONNECT_TIMEOUT_MS constant in mcp-clients.service.ts.
|
||||||
|
const CONNECT_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
interface FakeServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: string;
|
||||||
|
url: string;
|
||||||
|
headersEnc: string | null;
|
||||||
|
toolAllowlist: string[] | null;
|
||||||
|
instructions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function server(
|
||||||
|
over: Partial<FakeServer> & { id: string; name: string },
|
||||||
|
): FakeServer {
|
||||||
|
return {
|
||||||
|
transport: 'http',
|
||||||
|
url: 'https://example.com/mcp',
|
||||||
|
headersEnc: null,
|
||||||
|
toolAllowlist: null,
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildService(servers: FakeServer[]) {
|
||||||
|
const repoStub = { listEnabled: jest.fn().mockResolvedValue(servers) };
|
||||||
|
const service = new McpClientsService(repoStub as never, {} as never);
|
||||||
|
// Silence the expected "server unavailable" warning.
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
|
||||||
|
.logger,
|
||||||
|
'warn',
|
||||||
|
)
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
return service;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spy on the private `connect` with a per-server implementation.
|
||||||
|
function stubConnect(
|
||||||
|
service: McpClientsService,
|
||||||
|
impl: (s: FakeServer) => Promise<unknown>,
|
||||||
|
) {
|
||||||
|
return jest
|
||||||
|
.spyOn(
|
||||||
|
service as unknown as { connect: (s: FakeServer) => Promise<unknown> },
|
||||||
|
'connect',
|
||||||
|
)
|
||||||
|
.mockImplementation(impl);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('McpClientsService.connectWithTimeout — hung connect does not poison the cache (D1)', () => {
|
||||||
|
beforeEach(() => jest.useFakeTimers());
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('buildEntry completes (server recorded ok:false) when connect never settles, and toolsFor does not hang', async () => {
|
||||||
|
const svc = buildService([server({ id: 'id-hung', name: 'hung' })]);
|
||||||
|
// connect NEVER settles — models a wedged createMCPClient handshake.
|
||||||
|
stubConnect(svc, () => new Promise<never>(() => {}));
|
||||||
|
|
||||||
|
const toolsetPromise = svc.toolsFor('ws-1');
|
||||||
|
// Drive fake time past the connect bound so connectWithTimeout rejects and
|
||||||
|
// buildEntry catches it (records ok:false) — flushing the microtasks.
|
||||||
|
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
||||||
|
|
||||||
|
const toolset = await toolsetPromise;
|
||||||
|
// The build COMPLETED with the bad server skipped (no tools, ok:false).
|
||||||
|
expect(Object.keys(toolset.tools)).toHaveLength(0);
|
||||||
|
expect(toolset.outcomes).toEqual([
|
||||||
|
{ name: 'hung', ok: false, reason: 'MCP connect timed out after 5000ms' },
|
||||||
|
]);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
|
||||||
|
// The cache is NOT poisoned: a subsequent turn returns (served from the warm
|
||||||
|
// cached entry) instead of awaiting a never-settling build.
|
||||||
|
const again = await svc.toolsFor('ws-1');
|
||||||
|
expect(Object.keys(again.tools)).toHaveLength(0);
|
||||||
|
await Promise.all(again.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a hung server is skipped but a healthy server in the SAME build still contributes its tools', async () => {
|
||||||
|
const svc = buildService([
|
||||||
|
server({ id: 'id-hung', name: 'hung' }),
|
||||||
|
server({ id: 'id-ok', name: 'ok' }),
|
||||||
|
]);
|
||||||
|
const okClient = {
|
||||||
|
tools: () => Promise.resolve({ search: { description: 'x' } }),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
stubConnect(svc, (s) =>
|
||||||
|
s.id === 'id-hung'
|
||||||
|
? new Promise<never>(() => {})
|
||||||
|
: Promise.resolve(okClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolsetPromise = svc.toolsFor('ws-2');
|
||||||
|
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
||||||
|
|
||||||
|
const toolset = await toolsetPromise;
|
||||||
|
// Healthy server's tool survives (namespaced); hung server recorded ok:false.
|
||||||
|
expect(Object.keys(toolset.tools)).toEqual(['ok_search']);
|
||||||
|
expect(toolset.outcomes).toEqual([
|
||||||
|
{ name: 'hung', ok: false, reason: 'MCP connect timed out after 5000ms' },
|
||||||
|
{ name: 'ok', ok: true },
|
||||||
|
]);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the ORPHANED client when connect resolves LATE (after the timeout)', async () => {
|
||||||
|
const svc = buildService([server({ id: 'id-late', name: 'late' })]);
|
||||||
|
const lateClient = {
|
||||||
|
tools: () => Promise.resolve({}),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
// connect resolves only AFTER the connect bound has already elapsed, so
|
||||||
|
// connectWithTimeout has already rejected and must close this orphan.
|
||||||
|
stubConnect(
|
||||||
|
svc,
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
setTimeout(() => resolve(lateClient), CONNECT_TIMEOUT_MS * 2);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolsetPromise = svc.toolsFor('ws-3');
|
||||||
|
// Fire the timeout: the build completes with the server skipped.
|
||||||
|
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
||||||
|
const toolset = await toolsetPromise;
|
||||||
|
expect(toolset.outcomes[0]?.ok).toBe(false);
|
||||||
|
expect(lateClient.close).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Now let the late connect resolve — the orphan must be closed, not leaked.
|
||||||
|
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS * 2);
|
||||||
|
expect(lateClient.close).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('McpClientsService.buildEntry — closes a connected client whose tools() fails (leak fix)', () => {
|
||||||
|
beforeEach(() => jest.useFakeTimers());
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers();
|
||||||
|
jest.useRealTimers();
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connect succeeds but tools() REJECTS: the client is close()d exactly once and the server is skipped, while a healthy server still contributes', async () => {
|
||||||
|
const svc = buildService([
|
||||||
|
server({ id: 'id-bad', name: 'bad' }),
|
||||||
|
server({ id: 'id-ok', name: 'ok' }),
|
||||||
|
]);
|
||||||
|
// The bad server connects fine, then tools() rejects — the client would leak if
|
||||||
|
// buildEntry did not close it in the per-server catch (it was never registered).
|
||||||
|
const badClient = {
|
||||||
|
tools: () => Promise.reject(new Error('tools listing failed')),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const okClient = {
|
||||||
|
tools: () => Promise.resolve({ search: { description: 'x' } }),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
stubConnect(svc, (s) =>
|
||||||
|
s.id === 'id-bad' ? Promise.resolve(badClient) : Promise.resolve(okClient),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolset = await svc.toolsFor('ws-4');
|
||||||
|
|
||||||
|
// The orphaned (never-registered) client is closed exactly once — no leak.
|
||||||
|
expect(badClient.close).toHaveBeenCalledTimes(1);
|
||||||
|
// Healthy server survives; bad server recorded ok:false and skipped.
|
||||||
|
expect(Object.keys(toolset.tools)).toEqual(['ok_search']);
|
||||||
|
expect(toolset.outcomes).toEqual([
|
||||||
|
{ name: 'bad', ok: false, reason: 'tools listing failed' },
|
||||||
|
{ name: 'ok', ok: true },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The healthy (registered) client is NOT closed by the loop — it is owned by the
|
||||||
|
// cache entry and stays warm (closed only on eviction/teardown, not on lease
|
||||||
|
// release). Releasing the lease keeps it warm since the entry is not evicted.
|
||||||
|
expect(okClient.close).not.toHaveBeenCalled();
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
expect(okClient.close).not.toHaveBeenCalled();
|
||||||
|
// The failed client is never double-closed.
|
||||||
|
expect(badClient.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('connect succeeds but tools() HANGS (times out): the client is close()d once and the server is skipped', async () => {
|
||||||
|
const svc = buildService([server({ id: 'id-slow', name: 'slow' })]);
|
||||||
|
const slowClient = {
|
||||||
|
// tools() never settles -> withTimeout rejects after CONNECT_TIMEOUT_MS.
|
||||||
|
tools: () => new Promise<Record<string, never>>(() => {}),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
stubConnect(svc, () => Promise.resolve(slowClient));
|
||||||
|
|
||||||
|
const toolsetPromise = svc.toolsFor('ws-5');
|
||||||
|
// Drive fake time past the tools() bound so withTimeout rejects.
|
||||||
|
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
||||||
|
const toolset = await toolsetPromise;
|
||||||
|
|
||||||
|
expect(slowClient.close).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Object.keys(toolset.tools)).toHaveLength(0);
|
||||||
|
expect(toolset.outcomes[0]?.ok).toBe(false);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -195,7 +195,7 @@ export class McpClientsService {
|
|||||||
): Promise<{ ok: true; tools: string[] } | { ok: false; error: string }> {
|
): Promise<{ ok: true; tools: string[] } | { ok: false; error: string }> {
|
||||||
let client: McpClient | undefined;
|
let client: McpClient | undefined;
|
||||||
try {
|
try {
|
||||||
client = await this.connect(server);
|
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
||||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||||
return { ok: true, tools: Object.keys(raw) };
|
return { ok: true, tools: Object.keys(raw) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -256,10 +256,18 @@ export class McpClientsService {
|
|||||||
const instructions: McpServerInstruction[] = [];
|
const instructions: McpServerInstruction[] = [];
|
||||||
|
|
||||||
for (const server of servers) {
|
for (const server of servers) {
|
||||||
|
// Track the connected client OUTSIDE the try so the catch can close it when
|
||||||
|
// it was obtained but not yet registered in `clients` (e.g. tools() threw or
|
||||||
|
// timed out after a successful connect). `registered` flips only once the
|
||||||
|
// client is owned by `clients` (closed at entry teardown), so the catch never
|
||||||
|
// double-closes a registered client.
|
||||||
|
let client: McpClient | undefined;
|
||||||
|
let registered = false;
|
||||||
try {
|
try {
|
||||||
const client = await this.connect(server);
|
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
||||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
|
registered = true;
|
||||||
const allow = server.toolAllowlist;
|
const allow = server.toolAllowlist;
|
||||||
const picked =
|
const picked =
|
||||||
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
||||||
@@ -290,9 +298,15 @@ export class McpClientsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A failed server is skipped — the turn proceeds with the rest. Log a
|
// A failed server is skipped — the turn proceeds with the rest. If connect
|
||||||
// short warning (never the URL/headers) so ops can see degradation, and
|
// returned a live client but a later step (tools()) threw, that client was
|
||||||
// record the outcome so the UI can show "tool X unavailable".
|
// never registered in `clients`, so close it here or its transport/socket
|
||||||
|
// leaks (compounding every 60s cache rebuild during a flaky-server outage).
|
||||||
|
if (client && !registered) {
|
||||||
|
void client.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
// Log a short warning (never the URL/headers) so ops can see degradation,
|
||||||
|
// and record the outcome so the UI can show "tool X unavailable".
|
||||||
const reason = shortError(err);
|
const reason = shortError(err);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`External MCP server "${server.name}" unavailable: ${reason}`,
|
`External MCP server "${server.name}" unavailable: ${reason}`,
|
||||||
@@ -383,6 +397,55 @@ export class McpClientsService {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Race {@link connect} against a SETTLING timeout so a hung MCP handshake can
|
||||||
|
* never POISON the per-workspace build cache. `createMCPClient` (inside connect)
|
||||||
|
* is NOT bounded internally, and — exactly like @ai-sdk/mcp's tool calls
|
||||||
|
* (see wrapToolWithCallTimeout) — its promise does NOT settle on abort. So a
|
||||||
|
* transient network blip mid-handshake can make connect hang FOREVER. Because
|
||||||
|
* getOrBuildEntry caches the build PROMISE, a never-settling connect would then
|
||||||
|
* wedge EVERY later turn for the workspace (each awaits the same pending build,
|
||||||
|
* step_count stuck at 0, run row leaks 'running', chat 409s forever). Bounding
|
||||||
|
* connect here guarantees buildEntry always gets a client OR a rejection within
|
||||||
|
* `ms` — so the build completes (bad server skipped) and the cache stays clean.
|
||||||
|
*
|
||||||
|
* If connect resolves LATE (after we already rejected on the timeout), we close
|
||||||
|
* the orphaned client so its transport/socket is not leaked.
|
||||||
|
*/
|
||||||
|
private connectWithTimeout(
|
||||||
|
server: Pick<AiMcpServer, 'transport' | 'url' | 'headersEnc'>,
|
||||||
|
ms: number,
|
||||||
|
): Promise<McpClient> {
|
||||||
|
return new Promise<McpClient>((resolve, reject) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
settled = true;
|
||||||
|
reject(new Error(`MCP connect timed out after ${ms}ms`));
|
||||||
|
}, ms);
|
||||||
|
// Do not keep the process alive just for this connect-timeout timer.
|
||||||
|
timer.unref?.();
|
||||||
|
this.connect(server).then(
|
||||||
|
(client) => {
|
||||||
|
if (settled) {
|
||||||
|
// The race was already lost to the timeout: close the orphaned client
|
||||||
|
// so its socket is not leaked, and drop the late result.
|
||||||
|
void client.close().catch(() => undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
settled = true;
|
||||||
|
resolve(client);
|
||||||
|
},
|
||||||
|
(err: unknown) => {
|
||||||
|
if (settled) return; // late rejection after the timeout — already handled
|
||||||
|
clearTimeout(timer);
|
||||||
|
settled = true;
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt the stored auth headers. Returns undefined when none are set. The
|
* Decrypt the stored auth headers. Returns undefined when none are set. The
|
||||||
* plaintext headers live only in this returned object and are passed straight
|
* plaintext headers live only in this returned object and are passed straight
|
||||||
|
|||||||
@@ -610,6 +610,63 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
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 () => {
|
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||||
const { service, repo } = makeImportService({
|
const { service, repo } = makeImportService({
|
||||||
bundleRoles: [catalogRole()],
|
bundleRoles: [catalogRole()],
|
||||||
@@ -677,6 +734,15 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||||
expect(res.errors).toEqual([]);
|
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).
|
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,6 +305,16 @@ export class AiAgentRolesService {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
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(
|
const { file, versions } = await this.loadBundleById(
|
||||||
dto.bundleId,
|
dto.bundleId,
|
||||||
@@ -312,6 +322,13 @@ export class AiAgentRolesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const errors: { slug: string; message: string }[] = [];
|
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).
|
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||||
let selected = file.roles;
|
let selected = file.roles;
|
||||||
@@ -351,16 +368,27 @@ export class AiAgentRolesService {
|
|||||||
// Already installed from the catalog in THIS language => skip (use
|
// Already installed from the catalog in THIS language => skip (use
|
||||||
// update-from-catalog). A different language of the same slug still imports.
|
// update-from-catalog). A different language of the same slug still imports.
|
||||||
const installKey = `${role.slug}:${dto.language}`;
|
const installKey = `${role.slug}:${dto.language}`;
|
||||||
|
const originalName = role.name.trim();
|
||||||
if (installedKeys.has(installKey)) {
|
if (installedKeys.has(installKey)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = role.name.trim();
|
let name = originalName;
|
||||||
let didRename = false;
|
let didRename = false;
|
||||||
if (takenNames.has(name.toLowerCase())) {
|
if (takenNames.has(name.toLowerCase())) {
|
||||||
if (dto.conflict === 'skip') {
|
if (dto.conflict === 'skip') {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'name-conflict',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// conflict === 'rename': find a free " (N)" suffix.
|
// conflict === 'rename': find a free " (N)" suffix.
|
||||||
@@ -380,6 +408,11 @@ export class AiAgentRolesService {
|
|||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
if (didRename) renamed++;
|
if (didRename) renamed++;
|
||||||
|
createdRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
...(didRename ? { renamedTo: name } : {}),
|
||||||
|
});
|
||||||
takenNames.add(name.toLowerCase());
|
takenNames.add(name.toLowerCase());
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -391,6 +424,11 @@ export class AiAgentRolesService {
|
|||||||
// skipped (already installed) and continue; do NOT abort or error.
|
// skipped (already installed) and continue; do NOT abort or error.
|
||||||
if (isSourceUniqueViolation(err)) {
|
if (isSourceUniqueViolation(err)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
continue;
|
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)');
|
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 DocmostClientLike,
|
||||||
type SharedToolSpec,
|
type SharedToolSpec,
|
||||||
} from './docmost-client.loader';
|
} 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 { parseNodeArg } from './parse-node-arg';
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
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
|
* Per-user, per-request adapter that exposes Docmost READ operations to the
|
||||||
@@ -123,6 +130,18 @@ export class AiChatToolsService {
|
|||||||
return client.exportPageMarkdown(pageId);
|
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(
|
async forUser(
|
||||||
user: User,
|
user: User,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
@@ -137,8 +156,13 @@ export class AiChatToolsService {
|
|||||||
// The page the user currently has open (from the request context), exposed
|
// The page the user currently has open (from the request context), exposed
|
||||||
// to the model via getCurrentPage. Optional and last so existing callers
|
// to the model via getCurrentPage. Optional and last so existing callers
|
||||||
// keep compiling. Kept proxy-robust: the model can CALL for the current
|
// keep compiling. Kept proxy-robust: the model can CALL for the current
|
||||||
// page instead of relying on it surviving in the system prompt text.
|
// page instead of relying on it surviving in the system prompt text. The
|
||||||
openedPage?: { id?: string; title?: string } | null,
|
// `selection` (#388) is already sanitized + nested by resolveOpenPageContext.
|
||||||
|
openedPage?: {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
selection?: SelectionContext | null;
|
||||||
|
} | null,
|
||||||
): Promise<Record<string, Tool>> {
|
): Promise<Record<string, Tool>> {
|
||||||
// Build the per-user loopback client (carrying the access + collab
|
// Build the per-user loopback client (carrying the access + collab
|
||||||
// provenance tokens) and load the shared tool-spec registry. Client
|
// provenance tokens) and load the shared tool-spec registry. Client
|
||||||
@@ -293,57 +317,40 @@ export class AiChatToolsService {
|
|||||||
getCurrentPage: tool({
|
getCurrentPage: tool({
|
||||||
description:
|
description:
|
||||||
'Return the page the user is currently viewing — i.e. what "this page", ' +
|
'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, ' +
|
'"the current page", or "here" refers to — plus the text the user ' +
|
||||||
'or null if the user is not currently on a page. Call this first whenever ' +
|
'currently has SELECTED on that page (what "this", "here", "the selected ' +
|
||||||
'the user refers to the current page without giving an explicit id.',
|
'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({}),
|
inputSchema: modelFriendlyInput({}),
|
||||||
execute: async () => resolveCurrentPageResult(openedPage),
|
execute: async () => resolveCurrentPageResult(openedPage),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's { title, markdown } projection.
|
||||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||||
'in the markdown are comment highlight anchors (also present for ' +
|
const result = await client.getPage(pageId);
|
||||||
'RESOLVED threads) — treat them as markup, not page text.',
|
const data = (result?.data ?? {}) as {
|
||||||
inputSchema: modelFriendlyInput({
|
title?: string;
|
||||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
content?: string;
|
||||||
}),
|
};
|
||||||
execute: async ({ pageId }) => {
|
return {
|
||||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
title: data.title ?? '',
|
||||||
const result = await client.getPage(pageId);
|
markdown: typeof data.content === 'string' ? data.content : '',
|
||||||
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) ---
|
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||||
|
|
||||||
createPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
createPage: sharedTool(
|
||||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
sharedToolSpecs.createPage,
|
||||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
'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 }) => {
|
|
||||||
// createPage(title, content, spaceId, parentPageId?) ->
|
// createPage(title, content, spaceId, parentPageId?) ->
|
||||||
// { data: filterPage(page, markdown), success }.
|
// { data: filterPage(page, markdown), success }.
|
||||||
const result = await client.createPage(
|
const result = await client.createPage(
|
||||||
@@ -359,7 +366,7 @@ export class AiChatToolsService {
|
|||||||
};
|
};
|
||||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
updatePageContent: tool({
|
updatePageContent: tool({
|
||||||
description:
|
description:
|
||||||
@@ -383,115 +390,46 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
renamePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
renamePage: sharedTool(
|
||||||
"Rename a page (change its title only; the body is untouched). " +
|
sharedToolSpecs.renamePage,
|
||||||
'Reversible: rename back at any time.',
|
async ({ pageId, title }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to rename.'),
|
|
||||||
title: z.string().describe('The new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, title }) => {
|
|
||||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||||
await client.renamePage(pageId, title);
|
await client.renamePage(pageId, title);
|
||||||
return { pageId, title };
|
return { pageId, title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
movePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The shared schema adds the optional `position` field this layer lacked
|
||||||
'Move a page under a new parent page, or to the space root when no ' +
|
// before; the execute now forwards it (the client already accepted it).
|
||||||
'parent is given. Reversible: move it back at any time.',
|
movePage: sharedTool(
|
||||||
inputSchema: modelFriendlyInput({
|
sharedToolSpecs.movePage,
|
||||||
pageId: z.string().describe('The id of the page to move.'),
|
async ({ pageId, parentPageId, position }) => {
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Target parent page id. Null/omitted moves the page to the ' +
|
|
||||||
'space root.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, parentPageId }) => {
|
|
||||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
// 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 };
|
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({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// This layer keeps only its own execute-side guards (require a selection
|
||||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
// for a top-level comment; reject suggestedText on a reply / without a
|
||||||
'page can be restored from trash). This NEVER permanently deletes.',
|
// selection) — the schema+description are shared.
|
||||||
inputSchema: modelFriendlyInput({
|
createComment: sharedTool(
|
||||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
sharedToolSpecs.createComment,
|
||||||
}),
|
async ({
|
||||||
// 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 ({
|
|
||||||
pageId,
|
pageId,
|
||||||
content,
|
content,
|
||||||
selection,
|
selection,
|
||||||
@@ -532,26 +470,17 @@ export class AiChatToolsService {
|
|||||||
const data = (result?.data ?? {}) as { id?: string };
|
const data = (result?.data ?? {}) as { id?: string };
|
||||||
return { commentId: data.id, pageId };
|
return { commentId: data.id, pageId };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
resolveComment: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
resolveComment: sharedTool(
|
||||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
sharedToolSpecs.resolveComment,
|
||||||
'the resolved flag). Only top-level comments can be resolved.',
|
async ({ commentId, 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 }) => {
|
|
||||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||||
await client.resolveComment(commentId, resolved);
|
await client.resolveComment(commentId, resolved);
|
||||||
return { commentId, resolved };
|
return { commentId, resolved };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- READ tools (added) ---
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
@@ -569,33 +498,12 @@ export class AiChatToolsService {
|
|||||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
// 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
|
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||||
// its own guidance.
|
// its own guidance.
|
||||||
listPages: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listPages: sharedTool(
|
||||||
'List the most recent pages, optionally scoped to a single space. ' +
|
sharedToolSpecs.listPages,
|
||||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
async ({ spaceId, limit, tree }) =>
|
||||||
"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 }) =>
|
|
||||||
await client.listPages(spaceId, limit, tree),
|
await client.listPages(spaceId, limit, tree),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listSidebarPages: tool({
|
listSidebarPages: tool({
|
||||||
description:
|
description:
|
||||||
@@ -640,41 +548,34 @@ export class AiChatToolsService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 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({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
'matrix so cells can be addressed for rich edits).',
|
'matrix so cells can be addressed for rich edits).',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
tableRef: z
|
table: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
'"#<index>" from the page outline, or a block id of any node ' +
|
||||||
'the table.',
|
'inside the table.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId, tableRef }) =>
|
execute: async ({ pageId, table }) =>
|
||||||
await client.getTable(pageId, tableRef),
|
await client.getTable(pageId, table),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listComments: sharedTool(
|
||||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
sharedToolSpecs.listComments,
|
||||||
'threads are returned; resolved threads (a resolved top-level ' +
|
async ({ pageId, includeResolved }) =>
|
||||||
'comment and all its replies) are hidden and their count reported ' +
|
|
||||||
'as `resolvedThreadsHidden` so you can re-query with ' +
|
|
||||||
'`includeResolved: true` to see everything. Returns ' +
|
|
||||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
includeResolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('default only active threads; true — include resolved'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, includeResolved }) =>
|
|
||||||
await client.listComments(pageId, includeResolved),
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
description: 'Fetch a single comment by id (content as Markdown).',
|
description: 'Fetch a single comment by id (content as Markdown).',
|
||||||
@@ -684,26 +585,12 @@ export class AiChatToolsService {
|
|||||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
checkNewComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
checkNewComments: sharedTool(
|
||||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
sharedToolSpecs.checkNewComments,
|
||||||
'created after a given timestamp.',
|
async ({ spaceId, since, parentPageId }) =>
|
||||||
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 }) =>
|
|
||||||
await client.checkNewComments(spaceId, since, parentPageId),
|
await client.checkNewComments(spaceId, since, parentPageId),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listShares: sharedTool(
|
listShares: sharedTool(
|
||||||
sharedToolSpecs.listShares,
|
sharedToolSpecs.listShares,
|
||||||
@@ -733,19 +620,14 @@ export class AiChatToolsService {
|
|||||||
await client.diffPageVersions(pageId, from, to),
|
await client.diffPageVersions(pageId, from, to),
|
||||||
),
|
),
|
||||||
|
|
||||||
exportPageMarkdown: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
exportPageMarkdown: sharedTool(
|
||||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
sharedToolSpecs.exportPageMarkdown,
|
||||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
async ({ pageId }) => {
|
||||||
'with importPageMarkdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to export.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
const markdown = await client.exportPageMarkdown(pageId);
|
const markdown = await client.exportPageMarkdown(pageId);
|
||||||
return { markdown };
|
return { markdown };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
@@ -795,28 +677,12 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
updatePageJson: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's content normalization (parity with
|
||||||
"Replace a page's body with a full ProseMirror document — a full " +
|
// the standalone MCP server, index.ts update_page_json).
|
||||||
'overwrite — and/or update its title. Minimal example content: ' +
|
updatePageJson: sharedTool(
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
sharedToolSpecs.updatePageJson,
|
||||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
async ({ pageId, content, title }) => {
|
||||||
'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):
|
|
||||||
// undefined/null pass through as undefined (title-only / no-op); any
|
// undefined/null pass through as undefined (title-only / no-op); any
|
||||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||||
// MCP server); an object is passed through unchanged.
|
// MCP server); an object is passed through unchanged.
|
||||||
@@ -829,66 +695,29 @@ export class AiChatToolsService {
|
|||||||
}
|
}
|
||||||
return await client.updatePageJson(pageId, doc, title);
|
return await client.updatePageJson(pageId, doc, title);
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// NOT in the shared registry: this layer names the table argument
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||||
// Sharing one buildShape would rename a model-facing parameter on one
|
tableInsertRow: sharedTool(
|
||||||
// transport, so the table row/cell tools stay per-layer by design.
|
sharedToolSpecs.tableInsertRow,
|
||||||
tableInsertRow: tool({
|
async ({ pageId, table, cells, index }) =>
|
||||||
description:
|
await client.tableInsertRow(pageId, table, cells, index),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableDeleteRow: sharedTool(
|
||||||
tableDeleteRow: tool({
|
sharedToolSpecs.tableDeleteRow,
|
||||||
description:
|
async ({ pageId, table, index }) =>
|
||||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
await client.tableDeleteRow(pageId, table, index),
|
||||||
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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableUpdateCell: sharedTool(
|
||||||
tableUpdateCell: tool({
|
sharedToolSpecs.tableUpdateCell,
|
||||||
description:
|
async ({ pageId, table, row, col, text }) =>
|
||||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||||
'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),
|
|
||||||
}),
|
|
||||||
|
|
||||||
copyPageContent: sharedTool(
|
copyPageContent: sharedTool(
|
||||||
sharedToolSpecs.copyPageContent,
|
sharedToolSpecs.copyPageContent,
|
||||||
@@ -902,25 +731,14 @@ export class AiChatToolsService {
|
|||||||
await client.importPageMarkdown(pageId, markdown),
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
),
|
),
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// confirmation framing ("Only share when the user explicitly asked, since
|
// Both layers already carried the security-confirmation framing, so there
|
||||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
// was no real divergence to preserve — only wording drift.
|
||||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
sharePage: sharedTool(
|
||||||
sharePage: tool({
|
sharedToolSpecs.sharePage,
|
||||||
description:
|
async ({ pageId, searchIndexing }) =>
|
||||||
'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 }) =>
|
|
||||||
await client.sharePage(pageId, searchIndexing),
|
await client.sharePage(pageId, searchIndexing),
|
||||||
}),
|
),
|
||||||
|
|
||||||
unsharePage: sharedTool(
|
unsharePage: sharedTool(
|
||||||
sharedToolSpecs.unsharePage,
|
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
|
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
|
||||||
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
|
* getCurrentPage tool's contract: { page: null, selection: null } when no page
|
||||||
* otherwise { page: { id, title } } with title defaulting to ''.
|
* is open (no id), otherwise { page: { id, title }, selection } with title
|
||||||
|
* defaulting to '' and the selection passed through from the resolved context.
|
||||||
*/
|
*/
|
||||||
describe('resolveCurrentPageResult', () => {
|
describe('resolveCurrentPageResult', () => {
|
||||||
it('returns { page: null } when openedPage is undefined', () => {
|
it('returns { page: null, selection: null } when openedPage is undefined', () => {
|
||||||
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
|
expect(resolveCurrentPageResult(undefined)).toEqual({
|
||||||
|
page: null,
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { page: null } when openedPage is null', () => {
|
it('returns { page: null, selection: null } when openedPage is null', () => {
|
||||||
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
|
expect(resolveCurrentPageResult(null)).toEqual({
|
||||||
|
page: null,
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { page: null } when openedPage has no id', () => {
|
it('returns { page: null, selection: null } when openedPage has no id', () => {
|
||||||
expect(resolveCurrentPageResult({})).toEqual({ page: null });
|
expect(resolveCurrentPageResult({})).toEqual({
|
||||||
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
|
page: null,
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
|
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({
|
||||||
|
page: null,
|
||||||
|
selection: null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { page: null } when id is an empty string', () => {
|
it('returns { page: null, selection: null } when id is an empty string', () => {
|
||||||
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
|
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({
|
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
|
||||||
page: { id: 'p1', title: 'Hello' },
|
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', () => {
|
it('defaults title to "" when it is missing', () => {
|
||||||
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
||||||
page: { id: 'p1', title: '' },
|
page: { id: 'p1', title: '' },
|
||||||
|
selection: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an explicit empty-string title as ""', () => {
|
it('keeps an explicit empty-string title as ""', () => {
|
||||||
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
||||||
page: { id: 'p1', title: '' },
|
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 {
|
export interface CurrentPageInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
title?: 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 {
|
export interface CurrentPageResult {
|
||||||
page: { id: string; title: string } | null;
|
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
|
// 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
|
// context. Returns { page: null, selection: null } when no page is open (no id),
|
||||||
// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage
|
// otherwise the page id + title (title defaults to '' when absent) plus the
|
||||||
// tool's contract so it can be unit-tested without the ESM Docmost client.
|
// 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(
|
export function resolveCurrentPageResult(
|
||||||
openedPage?: CurrentPageInput | null,
|
openedPage?: CurrentPageInput | null,
|
||||||
): CurrentPageResult {
|
): CurrentPageResult {
|
||||||
if (!openedPage?.id) {
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,6 +241,11 @@ export interface SharedToolSpec {
|
|||||||
mcpName: string;
|
mcpName: string;
|
||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
description: 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
|
// 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.
|
// its own zod (v4) and the MCP package its own (v3) into the same builder.
|
||||||
buildShape?: (z: any) => Record<string, unknown>;
|
buildShape?: (z: any) => Record<string, unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import {
|
||||||
|
CORE_TOOL_KEYS,
|
||||||
|
CORE_TOOL_SET,
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
LOAD_TOOLS_DESCRIPTION,
|
||||||
|
INLINE_TOOL_TIERS,
|
||||||
|
buildInAppDeferredCatalog,
|
||||||
|
buildExternalToolCatalog,
|
||||||
|
shortenForCatalog,
|
||||||
|
applyLoadTools,
|
||||||
|
} from './tool-tiers';
|
||||||
|
// The real shared registry, imported from source (same approach as the
|
||||||
|
// SHARED_TOOL_SPECS contract spec) so the tier metadata is checked against
|
||||||
|
// exactly what @docmost/mcp ships.
|
||||||
|
import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs';
|
||||||
|
// For the live-toolset partition test (F3): the REAL adapter, so the catalog is
|
||||||
|
// checked against the tools AiChatToolsService.forUser() actually builds — not a
|
||||||
|
// static list that could drift from it.
|
||||||
|
import { AiChatToolsService } from './ai-chat-tools.service';
|
||||||
|
import * as loader from './docmost-client.loader';
|
||||||
|
import type { DocmostClientLike } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #332 deferred tool loading — tier metadata, catalog assembly, and the
|
||||||
|
* loadTools meta-tool. Pure units; no Nest graph, no @docmost/mcp build (the
|
||||||
|
* registry is imported from TS source).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('tool tier metadata (#332)', () => {
|
||||||
|
it('core set is the documented 13 + searchInPage (14)', () => {
|
||||||
|
expect(CORE_TOOL_KEYS).toHaveLength(14);
|
||||||
|
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||||
|
// loadTools is a meta-tool, not a normal core key.
|
||||||
|
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
||||||
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
|
const isCoreByTier = spec.tier === 'core';
|
||||||
|
const isCoreByList = CORE_TOOL_SET.has(key);
|
||||||
|
expect(isCoreByTier).toBe(isCoreByList);
|
||||||
|
// Every spec carries a non-empty catalogLine (core tools too).
|
||||||
|
expect(typeof spec.catalogLine).toBe('string');
|
||||||
|
expect(spec.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every INLINE tool tier agrees with CORE_TOOL_SET and has a catalogLine', () => {
|
||||||
|
for (const [key, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
expect(meta.tier === 'core').toBe(CORE_TOOL_SET.has(key));
|
||||||
|
expect(meta.catalogLine.trim().length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildInAppDeferredCatalog (#332)', () => {
|
||||||
|
const catalog = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never);
|
||||||
|
const names = catalog.map((e) => e.name);
|
||||||
|
|
||||||
|
it('includes deferred tools from BOTH the inline map and the shared registry', () => {
|
||||||
|
expect(names).toContain('transformPage'); // inline deferred
|
||||||
|
expect(names).toContain('getPageJson'); // shared deferred
|
||||||
|
expect(names).toContain('patchNode'); // shared deferred
|
||||||
|
expect(names).toContain('createPage'); // inline deferred
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NEVER lists a core tool', () => {
|
||||||
|
for (const core of CORE_TOOL_KEYS) {
|
||||||
|
expect(names).not.toContain(core);
|
||||||
|
}
|
||||||
|
// spot-check a couple that are core in each source.
|
||||||
|
expect(names).not.toContain('searchInPage'); // shared core
|
||||||
|
expect(names).not.toContain('searchPages'); // inline core
|
||||||
|
expect(names).not.toContain('editPageText'); // shared core
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders every entry as a "name — purpose" line', () => {
|
||||||
|
// Non-empty catalog (the length is pinned structurally by the live-toolset
|
||||||
|
// partition test below, not by a magic constant that rots on every new tool).
|
||||||
|
expect(catalog.length).toBeGreaterThan(0);
|
||||||
|
for (const entry of catalog) {
|
||||||
|
expect(entry.catalogLine).toMatch(/ — /);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3 — the deferred <tool_catalog> is built from STATIC metadata (INLINE_TOOL_TIERS
|
||||||
|
* + SHARED_TOOL_SPECS), but the loadable-by-name set is derived at RUNTIME from the
|
||||||
|
* actual toolset (`Object.keys(baseTools)` in ai-chat.service.ts). Those two must
|
||||||
|
* agree or a tool becomes loadable-but-invisible (agent thinks it doesn't exist) or
|
||||||
|
* catalogued-but-phantom. INLINE_TOOL_TIERS is a plain hand-maintained Record with
|
||||||
|
* no compile-time link to the tools AiChatToolsService.forUser() builds, so nothing
|
||||||
|
* else catches that drift. This test uses forUser()'s LIVE keys as the source of
|
||||||
|
* truth (mirroring ai-chat-tools.service.spec.ts's loader mock) and asserts a
|
||||||
|
* two-way partition against buildInAppDeferredCatalog — replacing the old magic
|
||||||
|
* toHaveLength(28), so a tool added to forUser() without a catalog line (or a
|
||||||
|
* catalog line without a real tool) fails the suite instead of silently vanishing.
|
||||||
|
*/
|
||||||
|
describe('deferred catalog ↔ live forUser() toolset partition (#332, F3)', () => {
|
||||||
|
let toolKeys: string[];
|
||||||
|
const catalogNames = buildInAppDeferredCatalog(SHARED_TOOL_SPECS as never).map(
|
||||||
|
(e) => e.name,
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
// Intercept the ESM loader so forUser() builds against the TS-source shared
|
||||||
|
// specs (no @docmost/mcp build) and never touches the network.
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue({
|
||||||
|
DocmostClient: function () {
|
||||||
|
return {} as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor,
|
||||||
|
sharedToolSpecs: SHARED_TOOL_SPECS as Record<string, loader.SharedToolSpec>,
|
||||||
|
});
|
||||||
|
const service = new AiChatToolsService(
|
||||||
|
{
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
} as never,
|
||||||
|
{} as never, // aiService — not exercised while merely BUILDING the tools
|
||||||
|
{} as never, // pageEmbeddingRepo
|
||||||
|
{} as never, // spaceMemberRepo
|
||||||
|
{} as never, // pagePermissionRepo
|
||||||
|
// sandboxStore: forUser() eagerly calls asSink() to wire the stash tool.
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
const tools = await service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
toolKeys = Object.keys(tools);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes a non-trivial toolset (sanity: the mock actually built tools)', () => {
|
||||||
|
expect(toolKeys.length).toBeGreaterThan(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every non-core live tool is present in the catalog (no capability silently hidden)', () => {
|
||||||
|
// forUser() does not itself add loadTools (ai-chat.service does), but guard
|
||||||
|
// anyway. Every remaining non-core key MUST have a catalog line.
|
||||||
|
const catalogSet = new Set(catalogNames);
|
||||||
|
const missing = toolKeys.filter(
|
||||||
|
(k) => !CORE_TOOL_SET.has(k) && k !== LOAD_TOOLS_NAME && !catalogSet.has(k),
|
||||||
|
);
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every catalog entry corresponds to a real, non-core live tool (no phantom)', () => {
|
||||||
|
const liveSet = new Set(toolKeys);
|
||||||
|
const phantom = catalogNames.filter(
|
||||||
|
(n) => !liveSet.has(n) || CORE_TOOL_SET.has(n),
|
||||||
|
);
|
||||||
|
expect(phantom).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildExternalToolCatalog + shortenForCatalog (#332)', () => {
|
||||||
|
it('derives a short "name — purpose" line from each external tool description', () => {
|
||||||
|
const catalog = buildExternalToolCatalog({
|
||||||
|
tavily_search: { description: 'Search the web for fresh results. More detail here.' },
|
||||||
|
tavily_extract: { description: '' },
|
||||||
|
});
|
||||||
|
expect(catalog).toEqual([
|
||||||
|
{ name: 'tavily_search', catalogLine: 'tavily_search — Search the web for fresh results.' },
|
||||||
|
{ name: 'tavily_extract', catalogLine: 'tavily_extract — external tool' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps a very long description', () => {
|
||||||
|
const long = 'x'.repeat(500);
|
||||||
|
expect(shortenForCatalog(long).length).toBeLessThanOrEqual(140);
|
||||||
|
expect(shortenForCatalog(long).endsWith('…')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyLoadTools (#332)', () => {
|
||||||
|
const valid = new Set(['createPage', 'transformPage', 'tavily_search']);
|
||||||
|
|
||||||
|
it('adds valid names to the activated set and returns { loaded }', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
const result = applyLoadTools(['createPage', 'tavily_search'], activated, valid);
|
||||||
|
expect(result).toEqual({ loaded: ['createPage', 'tavily_search'] });
|
||||||
|
expect(activated.has('createPage')).toBe(true);
|
||||||
|
expect(activated.has('tavily_search')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown name with an error listing the valid deferred names', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(() => applyLoadTools(['nope'], activated, valid)).toThrow(/unknown tool name/i);
|
||||||
|
try {
|
||||||
|
applyLoadTools(['nope'], activated, valid);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = (e as Error).message;
|
||||||
|
// Lists every valid name (sorted).
|
||||||
|
expect(msg).toContain('createPage');
|
||||||
|
expect(msg).toContain('transformPage');
|
||||||
|
expect(msg).toContain('tavily_search');
|
||||||
|
}
|
||||||
|
// Nothing is activated on a rejected call.
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates a non-array / empty input (loads nothing)', () => {
|
||||||
|
const activated = new Set<string>();
|
||||||
|
expect(applyLoadTools(undefined, activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(applyLoadTools([], activated, valid)).toEqual({ loaded: [] });
|
||||||
|
expect(activated.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadTools description is the verbatim issue text', () => {
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('only ACTIVATES them');
|
||||||
|
expect(LOAD_TOOLS_DESCRIPTION).toContain('callable on your NEXT step');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('editorial "Corrector" scenario is fully served by CORE (#332)', () => {
|
||||||
|
it('read + comment + edit + search need no loadTools', () => {
|
||||||
|
// A Corrector role reads a page, searches within it, edits text, and leaves
|
||||||
|
// inline comments — every tool it needs is core, so it never has to load a
|
||||||
|
// deferred tool.
|
||||||
|
const needed = [
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'searchPages',
|
||||||
|
'searchInPage',
|
||||||
|
'editPageText',
|
||||||
|
'createComment',
|
||||||
|
'listComments',
|
||||||
|
'getComment',
|
||||||
|
'resolveComment',
|
||||||
|
];
|
||||||
|
for (const t of needed) {
|
||||||
|
expect(CORE_TOOL_SET.has(t)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { tool, type Tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { SharedToolSpec } from './docmost-client.loader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deferred tool loading for the in-app AI chat (#332).
|
||||||
|
*
|
||||||
|
* The agent otherwise sends ALL ~41 tool definitions on EVERY model call every
|
||||||
|
* step, bloating context. Instead we split the in-app tools into two tiers:
|
||||||
|
*
|
||||||
|
* - CORE (hot, always active): frequent OR tiny tools whose full schema is
|
||||||
|
* always visible, plus the `loadTools` meta-tool. Deferring a one-line tool is
|
||||||
|
* pure loss, so tiny tools stay core even if rare.
|
||||||
|
* - DEFERRED (loaded on demand): the fat/rare tools + ALL external MCP tools by
|
||||||
|
* default. The model sees only a compact <tool_catalog> (name — purpose) and
|
||||||
|
* calls `loadTools(names)` to ACTIVATE a tool's full schema for the NEXT step
|
||||||
|
* (one extra round-trip on first use).
|
||||||
|
*
|
||||||
|
* This module is the single source of truth for the IN-APP tiering:
|
||||||
|
* - CORE_TOOL_KEYS / CORE_TOOL_SET — the authoritative core list (used by
|
||||||
|
* prepareAgentStep to build per-step `activeTools`).
|
||||||
|
* - INLINE_TOOL_TIERS — tier + catalogLine for the per-layer INLINE tools (the
|
||||||
|
* ones NOT in @docmost/mcp's SHARED_TOOL_SPECS, which carry their own).
|
||||||
|
* - buildInAppDeferredCatalog / buildExternalToolCatalog — assemble the
|
||||||
|
* <tool_catalog> deferred lines.
|
||||||
|
* - applyLoadTools / makeLoadToolsTool — the loadTools meta-tool.
|
||||||
|
*
|
||||||
|
* The tier/catalogLine fields on SHARED_TOOL_SPECS are IN-APP metadata only; the
|
||||||
|
* external /mcp server ignores them and exposes every tool normally.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A single rendered <tool_catalog> line: the tool name + its "name — purpose". */
|
||||||
|
export interface ToolCatalogEntry {
|
||||||
|
/** Exact tool name the model must pass to loadTools. */
|
||||||
|
name: string;
|
||||||
|
/** Hand-written (in-app) or derived (external) "name — purpose" line. */
|
||||||
|
catalogLine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools. `searchInPage`
|
||||||
|
* (#330) is added to core on top of the issue's original tier list: it is
|
||||||
|
* frequent for the editorial roles this feature targets. `loadTools` is active
|
||||||
|
* too but is not a normal tool key (it is added to activeTools separately).
|
||||||
|
*/
|
||||||
|
export const CORE_TOOL_KEYS = [
|
||||||
|
'searchPages',
|
||||||
|
'listPages',
|
||||||
|
'listSpaces',
|
||||||
|
'getWorkspace',
|
||||||
|
'getCurrentPage',
|
||||||
|
'getPage',
|
||||||
|
'getOutline',
|
||||||
|
'getNode',
|
||||||
|
'createComment',
|
||||||
|
'getComment',
|
||||||
|
'listComments',
|
||||||
|
'resolveComment',
|
||||||
|
'editPageText',
|
||||||
|
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
||||||
|
// the issue's tier list.
|
||||||
|
'searchInPage',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** O(1) membership test for the core tier. */
|
||||||
|
export const CORE_TOOL_SET: ReadonlySet<string> = new Set(CORE_TOOL_KEYS);
|
||||||
|
|
||||||
|
/** The meta-tool name (always active alongside the core tools when enabled). */
|
||||||
|
export const LOAD_TOOLS_NAME = 'loadTools';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* loadTools description — VERBATIM from issue #332. Tells the model that the
|
||||||
|
* catalog names EXIST, that loadTools only ACTIVATES them (callable next step),
|
||||||
|
* and to load several at once.
|
||||||
|
*/
|
||||||
|
export const LOAD_TOOLS_DESCRIPTION =
|
||||||
|
'loadTools — Load the full definitions of deferred tools from the <tool_catalog>\n' +
|
||||||
|
'block in your instructions. Pass the EXACT tool names from the catalog; this\n' +
|
||||||
|
'call only ACTIVATES them and returns { loaded: [...] } — the tools become\n' +
|
||||||
|
'callable on your NEXT step. Load several names in one call when the task clearly\n' +
|
||||||
|
'needs them. Unknown names are rejected with the list of valid ones.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tier + catalogLine for the INLINE ai-chat tools — those defined per-layer in
|
||||||
|
* ai-chat-tools.service.ts and NOT present in @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
|
* (which carries its own tier/catalogLine). Together with the shared registry
|
||||||
|
* this describes every in-app tool. catalogLine is present for core tools too
|
||||||
|
* (uniformity), but only DEFERRED tools are rendered into the catalog.
|
||||||
|
*/
|
||||||
|
export const INLINE_TOOL_TIERS: Record<
|
||||||
|
string,
|
||||||
|
{ tier: 'core' | 'deferred'; catalogLine: string }
|
||||||
|
> = {
|
||||||
|
// --- core inline ---
|
||||||
|
searchPages: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'searchPages — hybrid semantic + keyword search across the wiki.',
|
||||||
|
},
|
||||||
|
getCurrentPage: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'getCurrentPage — the page the user is currently viewing and their current text selection on it.',
|
||||||
|
},
|
||||||
|
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
|
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||||
|
// NOTE: createComment, listComments and resolveComment moved to
|
||||||
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||||
|
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||||
|
// n/a — it simply has no shared spec).
|
||||||
|
getComment: {
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- deferred inline ---
|
||||||
|
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
|
||||||
|
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
|
||||||
|
// carry their own deferred tier + catalogLine there.
|
||||||
|
updatePageContent: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
|
},
|
||||||
|
listSidebarPages: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"listSidebarPages — list a space's root pages or a page's direct children.",
|
||||||
|
},
|
||||||
|
getTable: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
|
},
|
||||||
|
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||||
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||||
|
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
||||||
|
// snake_case(inAppKey) convention, so it has no shared spec).
|
||||||
|
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||||
|
// it carries its own deferred tier + catalogLine there.
|
||||||
|
getPageHistory: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
|
},
|
||||||
|
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||||
|
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||||
|
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||||
|
// docmost_transform exposes, a comment-deletion guardrail).
|
||||||
|
transformPage: {
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the <tool_catalog> deferred lines for the IN-APP tools by merging the
|
||||||
|
* two metadata sources: the per-layer INLINE_TOOL_TIERS and the shared registry
|
||||||
|
* (SHARED_TOOL_SPECS, loaded at runtime). Only DEFERRED tools are included; core
|
||||||
|
* tools are always active and never appear in the catalog. Pure — the caller
|
||||||
|
* passes the loaded specs so this stays unit-testable.
|
||||||
|
*/
|
||||||
|
export function buildInAppDeferredCatalog(
|
||||||
|
sharedToolSpecs: Record<string, SharedToolSpec>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
const entries: ToolCatalogEntry[] = [];
|
||||||
|
// Inline deferred tools (hand-written lines).
|
||||||
|
for (const [name, meta] of Object.entries(INLINE_TOOL_TIERS)) {
|
||||||
|
if (meta.tier === 'deferred') {
|
||||||
|
entries.push({ name, catalogLine: meta.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shared deferred tools (line comes from the registry's own catalogLine).
|
||||||
|
for (const [name, spec] of Object.entries(sharedToolSpecs)) {
|
||||||
|
if (spec.tier === 'deferred' && spec.catalogLine) {
|
||||||
|
entries.push({ name, catalogLine: spec.catalogLine });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cap an external tool's (untrusted) description into a short catalog purpose.
|
||||||
|
* External MCP tools have no hand-written catalogLine, so we derive one from the
|
||||||
|
* first sentence of the description, hard-capped. Whitespace is collapsed.
|
||||||
|
*/
|
||||||
|
export function shortenForCatalog(description: string, max = 140): string {
|
||||||
|
const flat = description.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!flat) return 'external tool';
|
||||||
|
// Prefer the first sentence if it is reasonably short.
|
||||||
|
const firstSentence = flat.split(/(?<=[.!?])\s/)[0];
|
||||||
|
const base =
|
||||||
|
firstSentence.length > 0 && firstSentence.length <= max
|
||||||
|
? firstSentence
|
||||||
|
: flat;
|
||||||
|
return base.length > max ? `${base.slice(0, max - 1).trimEnd()}…` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build catalog lines for the EXTERNAL MCP tools (all deferred by default,
|
||||||
|
* #332). Their names are the namespaced tool keys; the purpose is derived from
|
||||||
|
* each tool's own description (no hand-written line exists). Pure.
|
||||||
|
*/
|
||||||
|
export function buildExternalToolCatalog(
|
||||||
|
externalTools: Record<string, { description?: string } | undefined>,
|
||||||
|
): ToolCatalogEntry[] {
|
||||||
|
return Object.entries(externalTools).map(([name, t]) => ({
|
||||||
|
name,
|
||||||
|
catalogLine: `${name} — ${shortenForCatalog(t?.description ?? '')}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure core of the loadTools meta-tool. Validates the requested names against
|
||||||
|
* the per-turn set of valid deferred names, ADDS the valid ones to the caller's
|
||||||
|
* mutable `activatedTools` set (so they become callable next step), and returns
|
||||||
|
* `{ loaded }`. An unknown name throws a clear error listing the valid deferred
|
||||||
|
* names — surfaced to the model as a tool error so it can retry.
|
||||||
|
*/
|
||||||
|
export function applyLoadTools(
|
||||||
|
names: unknown,
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): { loaded: string[] } {
|
||||||
|
const requested = Array.isArray(names)
|
||||||
|
? names.filter((n): n is string => typeof n === 'string')
|
||||||
|
: [];
|
||||||
|
const unknown = requested.filter((n) => !validDeferredNames.has(n));
|
||||||
|
if (unknown.length > 0) {
|
||||||
|
const valid = [...validDeferredNames].sort().join(', ');
|
||||||
|
throw new Error(
|
||||||
|
`loadTools: unknown tool name(s): ${unknown.join(', ')}. ` +
|
||||||
|
`Valid deferred tools are: ${valid || '(none)'}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const n of requested) activatedTools.add(n);
|
||||||
|
return { loaded: requested };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the loadTools AI-SDK tool bound to THIS turn's mutable state: the
|
||||||
|
* `activatedTools` set (grown by execute, read by prepareAgentStep next step)
|
||||||
|
* and the `validDeferredNames` set (every non-core tool in this turn's toolset,
|
||||||
|
* incl. external MCP). Created per streamText call — never module-global.
|
||||||
|
*/
|
||||||
|
export function makeLoadToolsTool(
|
||||||
|
activatedTools: Set<string>,
|
||||||
|
validDeferredNames: ReadonlySet<string>,
|
||||||
|
): Tool {
|
||||||
|
return tool({
|
||||||
|
description: LOAD_TOOLS_DESCRIPTION,
|
||||||
|
inputSchema: z.object({
|
||||||
|
names: z
|
||||||
|
.array(z.string())
|
||||||
|
.describe(
|
||||||
|
'EXACT deferred tool names from the <tool_catalog> to activate for ' +
|
||||||
|
'your next step.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: async ({ names }) =>
|
||||||
|
applyLoadTools(names, activatedTools, validDeferredNames),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
AUTH_THROTTLER,
|
AUTH_THROTTLER,
|
||||||
PAGE_TEMPLATE_THROTTLER,
|
PAGE_TEMPLATE_THROTTLER,
|
||||||
PUBLIC_SHARE_AI_THROTTLER,
|
PUBLIC_SHARE_AI_THROTTLER,
|
||||||
|
VITALS_THROTTLER,
|
||||||
} from '../../integrations/throttle/throttler-names';
|
} from '../../integrations/throttle/throttler-names';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { AuthService } from './services/auth.service';
|
import { AuthService } from './services/auth.service';
|
||||||
@@ -184,16 +185,21 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The global ThrottlerGuard applies ALL named throttlers to every route by
|
// The global ThrottlerGuard applies ALL named throttlers to every route by
|
||||||
// default, so each non-AUTH bucket (AI chat, page template, public-share AI)
|
// default, so each non-AUTH bucket (AI chat, page template, public-share AI,
|
||||||
// is explicitly skipped here. collab-token is auth-guarded (JwtAuthGuard),
|
// client vitals) is explicitly skipped here. collab-token is auth-guarded
|
||||||
// per-user and client-cached, so those feature buckets are irrelevant to it;
|
// (JwtAuthGuard), per-user and client-cached, so those feature buckets are
|
||||||
// skipping them avoids spurious 429s when a user opens many pages in a short
|
// irrelevant to it; skipping them avoids spurious 429s when a user opens many
|
||||||
// window. The AUTH bucket is skipped too for the same per-user, cached reason.
|
// pages in a short window. The VITALS bucket must be skipped too: it is a
|
||||||
|
// process-wide named throttler, so without this skip its per-IP limit would
|
||||||
|
// silently cap collab-token (the one route that opts out of every other
|
||||||
|
// bucket) and break editing behind shared/NAT IPs. The AUTH bucket is skipped
|
||||||
|
// for the same per-user, cached reason.
|
||||||
@SkipThrottle({
|
@SkipThrottle({
|
||||||
[AUTH_THROTTLER]: true,
|
[AUTH_THROTTLER]: true,
|
||||||
[AI_CHAT_THROTTLER]: true,
|
[AI_CHAT_THROTTLER]: true,
|
||||||
[PAGE_TEMPLATE_THROTTLER]: true,
|
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||||
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||||
|
[VITALS_THROTTLER]: true,
|
||||||
})
|
})
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|||||||
@@ -23,8 +23,21 @@ import { hashPassword } from '../../../common/helpers';
|
|||||||
* unthrottled password-guessing oracle.
|
* unthrottled password-guessing oracle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// bcrypt cost-12 hashing/compare takes ~300ms idle but multiple seconds when
|
||||||
|
// parallel jest workers saturate all CPU cores; the 5s default flakes.
|
||||||
|
jest.setTimeout(30_000);
|
||||||
|
|
||||||
const WORKSPACE_ID = 'ws-1';
|
const WORKSPACE_ID = 'ws-1';
|
||||||
|
|
||||||
|
let passwordHash: string;
|
||||||
|
|
||||||
|
// Hoist the expensive work: compute ONE bcrypt cost-12 hash shared by all
|
||||||
|
// tests instead of five. The hash is a read-only string and each test builds
|
||||||
|
// its own user object around it, so sharing is safe.
|
||||||
|
beforeAll(async () => {
|
||||||
|
passwordHash = await hashPassword('correct-horse');
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
// Build an AuthService with the dependencies verifyUserCredentials/login touch
|
// Build an AuthService with the dependencies verifyUserCredentials/login touch
|
||||||
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
|
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
|
||||||
// collaborators actually reached on these paths need real behaviour; the rest
|
// collaborators actually reached on these paths need real behaviour; the rest
|
||||||
@@ -95,7 +108,6 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
|
|||||||
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
|
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
|
||||||
// A deactivated user must be indistinguishable from a wrong password: same
|
// A deactivated user must be indistinguishable from a wrong password: same
|
||||||
// message, before any password comparison.
|
// message, before any password comparison.
|
||||||
const passwordHash = await hashPassword('correct-horse');
|
|
||||||
const disabledUser = {
|
const disabledUser = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'disabled@example.com',
|
email: 'disabled@example.com',
|
||||||
@@ -117,7 +129,6 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||||
const passwordHash = await hashPassword('correct-horse');
|
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -139,7 +150,6 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
|
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
|
||||||
const passwordHash = await hashPassword('correct-horse');
|
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -179,7 +189,6 @@ describe('AuthService.login (live credentials-mismatch contract via verifyUserCr
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||||
const passwordHash = await hashPassword('correct-horse');
|
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -201,7 +210,6 @@ describe('AuthService.login (live credentials-mismatch contract via verifyUserCr
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
|
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
|
||||||
const passwordHash = await hashPassword('correct-horse');
|
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ import {
|
|||||||
INTERNAL_LINK_REGEX,
|
INTERNAL_LINK_REGEX,
|
||||||
extractPageSlugId,
|
extractPageSlugId,
|
||||||
} from '../../../integrations/export/utils';
|
} from '../../../integrations/export/utils';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from '../../../integrations/import/utils/foreign-markdown';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||||
@@ -1301,8 +1303,14 @@ export class PageService {
|
|||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'markdown': {
|
case 'markdown': {
|
||||||
const html = await markdownToHtml(content as string);
|
// Canonical markdown -> ProseMirror JSON directly via
|
||||||
prosemirrorJson = htmlToJson(html as string);
|
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate,
|
||||||
|
// no editor-ext markdown layer. Foreign markdown surfaces the strict
|
||||||
|
// parser rejects (GFM `[^id]` reference footnotes) are normalized to the
|
||||||
|
// canonical inline form first.
|
||||||
|
prosemirrorJson = await markdownToProseMirror(
|
||||||
|
normalizeForeignMarkdown(content as string),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'html': {
|
case 'html': {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/**
|
||||||
|
* Server-side whitelist + limits for POST /api/telemetry/vitals (#355).
|
||||||
|
*
|
||||||
|
* The endpoint is PUBLIC (browsers post it, no auth) so it is a privacy and
|
||||||
|
* abuse surface: everything not on these lists is silently DROPPED and the
|
||||||
|
* request still returns 200 (never 400 — a 400 would make browsers retry).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The only metric names accepted. Anything else is dropped.
|
||||||
|
export const ALLOWED_METRIC_NAMES = new Set<string>([
|
||||||
|
'INP',
|
||||||
|
'LCP',
|
||||||
|
'CLS',
|
||||||
|
'TTFB',
|
||||||
|
'editor_tx_ms',
|
||||||
|
'page_open_ms',
|
||||||
|
'longtask_ms',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// The only rating values accepted (web-vitals). Anything else -> null.
|
||||||
|
export const ALLOWED_RATINGS = new Set<string>([
|
||||||
|
'good',
|
||||||
|
'needs-improvement',
|
||||||
|
'poor',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Max events accepted per batch; the rest are ignored.
|
||||||
|
export const MAX_EVENTS_PER_BATCH = 50;
|
||||||
|
|
||||||
|
// Defence-in-depth body cap (~16KB). Fastify's global bodyLimit is far larger,
|
||||||
|
// so we re-check the parsed payload size here and drop oversized batches.
|
||||||
|
export const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
|
||||||
|
// attr is truncated to this many characters (attribution target only, no PII).
|
||||||
|
export const MAX_ATTR_LENGTH = 120;
|
||||||
|
|
||||||
|
// route label sanity cap (client sends a template like /s/:space/p/:slug).
|
||||||
|
export const MAX_ROUTE_LENGTH = 200;
|
||||||
|
|
||||||
|
// `client_metrics.doc_size` is a Postgres `int` (int4). A garbage/huge docSize
|
||||||
|
// on a single event would overflow int4 and make Postgres reject the WHOLE
|
||||||
|
// batch INSERT, losing every event in it. Values outside this range are DROPPED
|
||||||
|
// to null (the event is still kept) so one bad field never loses the batch.
|
||||||
|
export const DOC_SIZE_MAX = 2147483647; // 2^31 - 1 (int4 max)
|
||||||
|
|
||||||
|
export interface ClientMetricRow {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
rating: string | null;
|
||||||
|
route: string | null;
|
||||||
|
attr: string | null;
|
||||||
|
docSize: number | null;
|
||||||
|
workspaceId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate + normalise a single incoming event into a DB row, or return null to
|
||||||
|
* DROP it. Pure so it is directly unit-testable. Enforces the name whitelist,
|
||||||
|
* numeric value, rating whitelist, attr truncation and doc_size (int) coercion.
|
||||||
|
*/
|
||||||
|
export function sanitizeVitalEvent(
|
||||||
|
raw: unknown,
|
||||||
|
workspaceId: string | null,
|
||||||
|
): ClientMetricRow | null {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const e = raw as Record<string, unknown>;
|
||||||
|
|
||||||
|
const name = e.name;
|
||||||
|
if (typeof name !== 'string' || !ALLOWED_METRIC_NAMES.has(name)) return null;
|
||||||
|
|
||||||
|
const value =
|
||||||
|
typeof e.value === 'number' && Number.isFinite(e.value) ? e.value : null;
|
||||||
|
if (value === null) return null;
|
||||||
|
|
||||||
|
const rating =
|
||||||
|
typeof e.rating === 'string' && ALLOWED_RATINGS.has(e.rating)
|
||||||
|
? e.rating
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let route: string | null = null;
|
||||||
|
if (typeof e.route === 'string' && e.route.length > 0) {
|
||||||
|
route = e.route.slice(0, MAX_ROUTE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let attr: string | null = null;
|
||||||
|
if (typeof e.attr === 'string' && e.attr.length > 0) {
|
||||||
|
attr = e.attr.slice(0, MAX_ATTR_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let docSize: number | null = null;
|
||||||
|
if (typeof e.docSize === 'number' && Number.isFinite(e.docSize)) {
|
||||||
|
docSize = Math.trunc(e.docSize);
|
||||||
|
} else if (typeof e.doc_size === 'number' && Number.isFinite(e.doc_size)) {
|
||||||
|
// Accept snake_case too, in case a client sends the raw column name.
|
||||||
|
docSize = Math.trunc(e.doc_size as number);
|
||||||
|
}
|
||||||
|
// Guard the int4 column: an out-of-range docSize would overflow int4 and make
|
||||||
|
// Postgres reject the whole batch INSERT. Drop the field (keep the event)
|
||||||
|
// rather than lose every other event in the batch.
|
||||||
|
if (docSize !== null && (docSize < 0 || docSize > DOC_SIZE_MAX)) {
|
||||||
|
docSize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, value, rating, route, attr, docSize, workspaceId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { ClientTelemetryModule } from './client-telemetry.module';
|
||||||
|
import { VitalsController } from './vitals.controller';
|
||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
|
||||||
|
// The register() gate is the CORE of the maintainer's E1=B decision: the public,
|
||||||
|
// unauthenticated /api/telemetry/vitals endpoint must be OFF by default, so a
|
||||||
|
// self-host deploy has no anonymous disk-fill surface into `client_metrics`. A
|
||||||
|
// regression that inverts the flag (or a truthiness bug where "" / "false"
|
||||||
|
// registers the route) would silently reopen that surface — pin it here.
|
||||||
|
describe('ClientTelemetryModule.register (E1=B gate)', () => {
|
||||||
|
const original = process.env.CLIENT_TELEMETRY_ENABLED;
|
||||||
|
afterEach(() => {
|
||||||
|
if (original === undefined) delete process.env.CLIENT_TELEMETRY_ENABLED;
|
||||||
|
else process.env.CLIENT_TELEMETRY_ENABLED = original;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('OFF by default (flag unset) — no controller, no provider (endpoint absent)', () => {
|
||||||
|
delete process.env.CLIENT_TELEMETRY_ENABLED;
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toEqual([]);
|
||||||
|
expect(mod.providers).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['false', 'False', '0', '', 'yes', '1'])(
|
||||||
|
'stays OFF for non-"true" value %p (no route)',
|
||||||
|
(val) => {
|
||||||
|
process.env.CLIENT_TELEMETRY_ENABLED = val;
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toEqual([]);
|
||||||
|
expect(mod.providers).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('ON only for "true" — registers VitalsController + VitalsService', () => {
|
||||||
|
process.env.CLIENT_TELEMETRY_ENABLED = 'true';
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toContain(VitalsController);
|
||||||
|
expect(mod.providers).toContain(VitalsService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ON is case-insensitive ("TRUE")', () => {
|
||||||
|
process.env.CLIENT_TELEMETRY_ENABLED = 'TRUE';
|
||||||
|
const mod = ClientTelemetryModule.register();
|
||||||
|
expect(mod.controllers).toContain(VitalsController);
|
||||||
|
expect(mod.providers).toContain(VitalsService);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { DynamicModule, Module } from '@nestjs/common';
|
||||||
|
import { VitalsController } from './vitals.controller';
|
||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client perf-telemetry (#355): the public /api/telemetry/vitals sink that
|
||||||
|
* persists web-vitals + custom client metrics into `client_metrics`.
|
||||||
|
* Named ClientTelemetryModule to avoid confusion with the unrelated
|
||||||
|
* integrations/telemetry (product usage ping) module.
|
||||||
|
*
|
||||||
|
* GATED OFF BY DEFAULT (maintainer decision E1=B). The public, unauthenticated
|
||||||
|
* endpoint is only registered when CLIENT_TELEMETRY_ENABLED=true — otherwise the
|
||||||
|
* route does NOT exist at all (no anonymous disk-fill surface, and no unbounded
|
||||||
|
* `client_metrics` growth on a self-host deploy without an external pruner). The
|
||||||
|
* client is told the same flag via window.CONFIG and skips sending when off.
|
||||||
|
*/
|
||||||
|
@Module({})
|
||||||
|
export class ClientTelemetryModule {
|
||||||
|
static register(): DynamicModule {
|
||||||
|
// Read process.env directly (not EnvironmentService) so the toggle is
|
||||||
|
// resolved at module-registration time, identical to how the metrics
|
||||||
|
// subsystem reads METRICS_PORT. Absent/anything-but-"true" => OFF.
|
||||||
|
const enabled =
|
||||||
|
(process.env.CLIENT_TELEMETRY_ENABLED ?? '').toLowerCase() === 'true';
|
||||||
|
|
||||||
|
return {
|
||||||
|
module: ClientTelemetryModule,
|
||||||
|
controllers: enabled ? [VitalsController] : [],
|
||||||
|
providers: enabled ? [VitalsService] : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SkipThrottle, Throttle, ThrottlerGuard } from '@nestjs/throttler';
|
||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
import { Public } from '../../common/decorators/public.decorator';
|
||||||
|
import {
|
||||||
|
AI_CHAT_THROTTLER,
|
||||||
|
AUTH_THROTTLER,
|
||||||
|
PAGE_TEMPLATE_THROTTLER,
|
||||||
|
PUBLIC_SHARE_AI_THROTTLER,
|
||||||
|
VITALS_THROTTLER,
|
||||||
|
} from '../../integrations/throttle/throttler-names';
|
||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/telemetry/vitals (#355) — public client perf-metrics sink.
|
||||||
|
*
|
||||||
|
* PUBLIC (browsers post via sendBeacon, no session) but IP-throttled. Always
|
||||||
|
* returns 200 with no body of interest: invalid/foreign/oversized payloads are
|
||||||
|
* silently dropped by the service rather than 400'd, so browsers never retry.
|
||||||
|
*/
|
||||||
|
@Controller('telemetry')
|
||||||
|
export class VitalsController {
|
||||||
|
constructor(private readonly vitalsService: VitalsService) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
// The global ThrottlerGuard applies ALL named throttlers to every route, so
|
||||||
|
// every OTHER bucket must be skipped here — otherwise the strictest of them
|
||||||
|
// (public-share AI at 5/min) would override the intended vitals limit and cap
|
||||||
|
// this route at 5/min instead of 120/min. Skip them all so ONLY the VITALS
|
||||||
|
// bucket below applies.
|
||||||
|
@SkipThrottle({
|
||||||
|
[AUTH_THROTTLER]: true,
|
||||||
|
[AI_CHAT_THROTTLER]: true,
|
||||||
|
[PAGE_TEMPLATE_THROTTLER]: true,
|
||||||
|
[PUBLIC_SHARE_AI_THROTTLER]: true,
|
||||||
|
})
|
||||||
|
@Throttle({ [VITALS_THROTTLER]: { limit: 120, ttl: 60_000 } })
|
||||||
|
@Post('vitals')
|
||||||
|
@HttpCode(200)
|
||||||
|
async vitals(
|
||||||
|
@Body() body: unknown,
|
||||||
|
@Req() req: FastifyRequest,
|
||||||
|
): Promise<{ ok: true }> {
|
||||||
|
// workspaceId is resolved by the workspace-host middleware onto req.raw when
|
||||||
|
// the browser posts from a workspace host; null otherwise. No other PII.
|
||||||
|
const workspaceId =
|
||||||
|
((req.raw as unknown as { workspaceId?: string })?.workspaceId ?? null) ||
|
||||||
|
null;
|
||||||
|
try {
|
||||||
|
await this.vitalsService.ingest(body, workspaceId);
|
||||||
|
} catch {
|
||||||
|
// Never surface storage errors to the browser; telemetry is best-effort.
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { VitalsService } from './vitals.service';
|
||||||
|
import { MAX_ATTR_LENGTH } from './client-metrics.constants';
|
||||||
|
|
||||||
|
// buildRows is pure (no DB access), so a null db is fine here.
|
||||||
|
const svc = new VitalsService(null as any);
|
||||||
|
|
||||||
|
describe('VitalsService.buildRows', () => {
|
||||||
|
const WS = 'ws-uuid';
|
||||||
|
|
||||||
|
it('accepts a valid batch and maps whitelisted fields to rows', () => {
|
||||||
|
const body = {
|
||||||
|
events: [
|
||||||
|
{ name: 'INP', value: 123.4, rating: 'good', route: '/s/:space/p/:slug' },
|
||||||
|
{ name: 'editor_tx_ms', value: 12, route: '/s/:space/p/:slug', docSize: 4096 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rows = svc.buildRows(body, WS);
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0]).toEqual({
|
||||||
|
name: 'INP',
|
||||||
|
value: 123.4,
|
||||||
|
rating: 'good',
|
||||||
|
route: '/s/:space/p/:slug',
|
||||||
|
attr: null,
|
||||||
|
docSize: null,
|
||||||
|
workspaceId: WS,
|
||||||
|
});
|
||||||
|
expect(rows[1].name).toBe('editor_tx_ms');
|
||||||
|
expect(rows[1].docSize).toBe(4096);
|
||||||
|
expect(rows[1].workspaceId).toBe(WS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a bare array body', () => {
|
||||||
|
const rows = svc.buildRows([{ name: 'LCP', value: 1 }], WS);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].name).toBe('LCP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops events with foreign metric names', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'evil_metric', value: 1 }, { name: 'LCP', value: 2 }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].name).toBe('LCP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops events with a non-numeric or missing value', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{
|
||||||
|
events: [
|
||||||
|
{ name: 'CLS', value: 'nan' },
|
||||||
|
{ name: 'CLS' },
|
||||||
|
{ name: 'CLS', value: 0.1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].value).toBe(0.1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips foreign fields and only keeps whitelisted columns', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'TTFB', value: 5, secret: 'drop-me', title: 'my page' }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(Object.keys(rows[0]).sort()).toEqual(
|
||||||
|
['attr', 'docSize', 'name', 'rating', 'route', 'value', 'workspaceId'].sort(),
|
||||||
|
);
|
||||||
|
expect((rows[0] as any).secret).toBeUndefined();
|
||||||
|
expect((rows[0] as any).title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a rating outside the allowed set (-> null)', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'INP', value: 1, rating: 'terrible' }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows[0].rating).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates attr to 120 chars', () => {
|
||||||
|
const longAttr = 'a'.repeat(500);
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'INP', value: 1, attr: longAttr }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows[0].attr).toHaveLength(MAX_ATTR_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the batch at 50 events', () => {
|
||||||
|
const events = Array.from({ length: 200 }, () => ({ name: 'CLS', value: 1 }));
|
||||||
|
const rows = svc.buildRows({ events }, WS);
|
||||||
|
expect(rows).toHaveLength(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an oversized (>16KB) payload wholesale', () => {
|
||||||
|
const events = Array.from({ length: 50 }, () => ({
|
||||||
|
name: 'INP',
|
||||||
|
value: 1,
|
||||||
|
attr: 'x'.repeat(400),
|
||||||
|
route: '/s/:space/p/:slug',
|
||||||
|
}));
|
||||||
|
// Serialised body far exceeds 16KB.
|
||||||
|
const rows = svc.buildRows({ events }, WS);
|
||||||
|
expect(rows).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] for malformed bodies', () => {
|
||||||
|
expect(svc.buildRows(null, WS)).toEqual([]);
|
||||||
|
expect(svc.buildRows('nope', WS)).toEqual([]);
|
||||||
|
expect(svc.buildRows({ notEvents: 1 }, WS)).toEqual([]);
|
||||||
|
expect(svc.buildRows(42, WS)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries a null workspaceId through', () => {
|
||||||
|
const rows = svc.buildRows({ events: [{ name: 'LCP', value: 1 }] }, null);
|
||||||
|
expect(rows[0].workspaceId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops an out-of-int4-range docSize to null without losing the batch', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{
|
||||||
|
events: [
|
||||||
|
// Garbage docSize overflowing int4 must NOT reject the whole batch:
|
||||||
|
// the field is dropped to null and the event is kept.
|
||||||
|
{ name: 'editor_tx_ms', value: 10, docSize: 9_999_999_999 },
|
||||||
|
{ name: 'editor_tx_ms', value: 20, docSize: -5 },
|
||||||
|
{ name: 'editor_tx_ms', value: 30, docSize: 4096 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
expect(rows[0].docSize).toBeNull();
|
||||||
|
expect(rows[1].docSize).toBeNull();
|
||||||
|
expect(rows[2].docSize).toBe(4096);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a docSize exactly at the int4 max', () => {
|
||||||
|
const rows = svc.buildRows(
|
||||||
|
{ events: [{ name: 'editor_tx_ms', value: 1, docSize: 2147483647 }] },
|
||||||
|
WS,
|
||||||
|
);
|
||||||
|
expect(rows[0].docSize).toBe(2147483647);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import {
|
||||||
|
ClientMetricRow,
|
||||||
|
MAX_BODY_BYTES,
|
||||||
|
MAX_EVENTS_PER_BATCH,
|
||||||
|
sanitizeVitalEvent,
|
||||||
|
} from './client-metrics.constants';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class VitalsService {
|
||||||
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a raw request body into the (bounded, whitelisted) rows to persist.
|
||||||
|
* Pure/synchronous so it is unit-testable without a DB. Returns [] for any
|
||||||
|
* malformed / oversized / foreign input — the caller still responds 200.
|
||||||
|
*/
|
||||||
|
buildRows(body: unknown, workspaceId: string | null): ClientMetricRow[] {
|
||||||
|
if (!body || typeof body !== 'object') return [];
|
||||||
|
|
||||||
|
// Defence-in-depth body cap (~16KB): drop oversized batches wholesale.
|
||||||
|
try {
|
||||||
|
if (JSON.stringify(body).length > MAX_BODY_BYTES) return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accept either a bare array or `{ events: [...] }`.
|
||||||
|
const events = Array.isArray(body)
|
||||||
|
? body
|
||||||
|
: Array.isArray((body as { events?: unknown }).events)
|
||||||
|
? ((body as { events: unknown[] }).events as unknown[])
|
||||||
|
: null;
|
||||||
|
if (!events) return [];
|
||||||
|
|
||||||
|
const rows: ClientMetricRow[] = [];
|
||||||
|
for (const event of events) {
|
||||||
|
if (rows.length >= MAX_EVENTS_PER_BATCH) break;
|
||||||
|
const row = sanitizeVitalEvent(event, workspaceId);
|
||||||
|
if (row) rows.push(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Batch-insert the sanitised rows in a single statement. No-op on []. */
|
||||||
|
async insertRows(rows: ClientMetricRow[]): Promise<void> {
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
await this.db
|
||||||
|
.insertInto('clientMetrics')
|
||||||
|
.values(
|
||||||
|
rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
value: r.value,
|
||||||
|
rating: r.rating,
|
||||||
|
route: r.route,
|
||||||
|
attr: r.attr,
|
||||||
|
docSize: r.docSize,
|
||||||
|
workspaceId: r.workspaceId,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(body: unknown, workspaceId: string | null): Promise<void> {
|
||||||
|
const rows = this.buildRows(body, workspaceId);
|
||||||
|
await this.insertRows(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user