Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder babc42c2ff fix(#346 review F1-F4): no 206-compress + Vary + precompress VAD + cache test
- F1 [HIGH — data corruption]: @fastify/compress was compressing 206/Range
  attachment responses while Content-Range still described the RAW offsets, so a
  resuming client (curl -C -, download managers) appended encoded bytes as raw →
  corrupted file. sendFileResponse now sets the request header `x-no-compression`
  (the documented @fastify/compress opt-out — its onSend skips when the request
  carries it; the reviewer's `Content-Encoding: identity` does NOT work because
  compress explicitly excludes `identity` and overwrites it). This opts the whole
  download route (both 200 full-file and 206 range) out of on-the-fly compression
  — correct, since attachment bytes are final and mostly binary.
- F2: static responses now emit `Vary: Accept-Encoding` (the preCompressed
  content-negotiated /assets/* were `immutable` without Vary → shared-cache could
  serve a brotli variant to an identity/gzip-only client).
- F3: vite compression `include` extended to .wasm/.onnx so the VAD binaries
  (~26MB .wasm, ~2.3MB .onnx under public/vad) are precompressed at build (.br
  emitted) instead of runtime-brotli'd on every request. (include REPLACES the
  plugin default, so the default js/css/json/html set is re-listed.)
- F4: extracted the cache classification into a pure `resolveStaticAssetHeaders`
  + static.module.spec.ts (3 tests: /assets/* immutable+Vary, index.html
  no-store, non-hashed not-immutable).

Gate: server tsc 0 (deps present), static.module.spec 3/3, client build emits
.wasm.br/.onnx.br, frozen install 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:13:20 +03:00
agent_coder 6ee814b7f3 perf(delivery): pre-compress static + cache headers + compress API responses (#346)
Cold load served ALL static + API responses uncompressed and without cache
headers (~3.7MB over the wire). Delivery only — feature behavior unchanged; no
DB/API-contract/MCP changes.

- apps/client/vite.config.ts: vite-plugin-compression2 emits .br + .gz next to
  each built asset (excludes index.html, which the server rewrites at boot with
  window.CONFIG — a precompressed copy would go stale). Build emits 187 .br /
  175 .gz under dist/assets.
- static.module.ts: @fastify/static `preCompressed: true` serves the .br/.gz
  neighbour; `setHeaders` sets `immutable` ONLY for content-hashed /assets/*,
  `no-cache` for index.html, and leaves non-hashed files (locales, vad, icons,
  manifest) on default etag/last-modified revalidation.
- main.ts: @fastify/compress (threshold 1024) compresses dynamic API JSON + the
  rewritten share-SEO HTML. SSE is safe on two counts: `text/event-stream` is not
  mime-db-compressible (allowlist skips it) AND the AI-chat stream hijacks the raw
  socket (pipeUIMessageStreamToResponse -> res.raw), bypassing the Fastify onSend
  lifecycle entirely. No double-compression with preCompressed static (compress
  skips already-Content-Encoding'd responses).
- docker-compose.yml: comment recommending an optional HTTP/2 + brotli reverse
  proxy (not required).

Deps: apps/client vite-plugin-compression2 2.5.3 (dev), apps/server
@fastify/compress 9.0.0 (matches fastify 5.8.5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 04:13:20 +03:00
126 changed files with 2340 additions and 14638 deletions
+5 -25
View File
@@ -191,24 +191,16 @@ MCP_DOCMOST_PASSWORD=
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
# ~1 min instead of 15. Note it also cuts a legitimately long but byte-silent
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
# single tool call (a slow crawl that emits nothing until done) and an SSE
# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min).
# AI_MCP_STREAM_TIMEOUT_MS=60000
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
# AI_MCP_STREAM_TIMEOUT_MS=300000
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
# but never returns a result — which the silence timeout above never breaks.
# Default 120000 (2 min).
# AI_MCP_CALL_TIMEOUT_MS=120000
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
# small for a long AI-chat research turn: the client resends the FULL message
# history (every tool call + search result) on each turn, so a deep conversation's
# POST to /api/ai-chat/stream can be several MB and would otherwise be rejected
# with FST_ERR_CTP_BODY_TOO_LARGE (413). Does NOT affect multipart file uploads
# (see FILE_UPLOAD_SIZE_LIMIT). Default 26214400 (25 MiB).
# HTTP_JSON_BODY_LIMIT=26214400
# Default 900000 (15 min).
# AI_MCP_CALL_TIMEOUT_MS=900000
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
@@ -230,18 +222,6 @@ MCP_DOCMOST_PASSWORD=
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
# visible, and a startup sweep settles any run left dangling by a restart.
#
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
# durable run tees its SSE frames into an in-memory registry, and a
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
# (default) the registry is never populated and attach always answers 204, so a
# reopened tab of an active run silently falls back to degraded 2.5s history
# polling — every wire path stays byte-for-byte identical to a build without the
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
# enabled for a workspace, and the same single-instance constraint applies (the
# registry is process-local).
# AI_CHAT_RESUMABLE_STREAM=false
# --- Anonymous public-share AI assistant ---
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
-224
View File
@@ -1,224 +0,0 @@
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
+2 -2
View File
@@ -3,9 +3,9 @@
"version": "2.0.0",
"tasks": [
{
"label": "git sync (pull gitea -> push github + gitea)",
"label": "git push (github + gitea)",
"type": "shell",
"command": "git fetch gitea && git merge --no-edit gitea/develop && git push github develop && git push gitea develop",
"command": "git push github develop && git push gitea develop",
"options": { "cwd": "${workspaceFolder}" },
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
"problemMatcher": []
+1 -19
View File
@@ -230,24 +230,6 @@ pnpm build # nx run-many -t build (all packages)
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
```
> **Build the shared packages before running a consumer's `tsc`/tests in
> isolation.** The `build/` dirs of `@docmost/prosemirror-markdown`,
> `@docmost/git-sync`, and `@docmost/mcp` are **gitignored** (not committed), and
> a single-package `pnpm --filter <pkg> test` / `tsc` or a bare `pnpm -r test`
> does **NOT** honour the Nx `dependsOn: ["^build"]` ordering. So a consumer — the
> server's `tsc`, `git-sync`'s vitest typecheck, `mcp`'s `pretest: tsc` — fails
> with `error TS2307: Cannot find module '@docmost/…'` until those packages are
> built first:
> ```bash
> pnpm --filter @docmost/prosemirror-markdown build
> pnpm --filter @docmost/editor-ext build
> pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
> ```
> `pnpm build` (nx run-many) does this for you; CI does it explicitly in
> `.github/workflows/test.yml` (prosemirror-markdown → git-sync/mcp → server, in
> that order). Reach for it whenever you run a consumer package's checks on their
> own rather than through the full `pnpm build`.
**Lint** (per package — there is no root lint script):
```bash
pnpm --filter server lint # eslint --fix on server .ts
@@ -311,7 +293,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
### Client structure
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, 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`.
- 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.
- 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`.
-26
View File
@@ -125,32 +125,6 @@ Gitmost follows the upstream Docmost setup. See the Docmost
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
### Reverse proxy: SSE streaming paths
The AI agent streams its answers over Server-Sent Events. These endpoints produce a
long-lived `text/event-stream` response and **must bypass response buffering AND response
compression** at every proxy in front of the app:
- `POST /api/ai-chat/stream` — the live agent turn stream
- `GET /api/ai-chat/runs/<chatId>/stream` — attach/resume of a detached agent run
(`AI_CHAT_RESUMABLE_STREAM`)
- `POST /api/shares/ai/stream` — the anonymous public-share assistant
A buffering or compressing proxy does not break these with an error — it silently ruins them:
the request hangs in `pending`, tokens stop streaming and arrive in one burst when the turn
ends, or a reloaded tab falls back to coarse polling. The tell in DevTools is a
`Content-Encoding: gzip/zstd` response header on a `text/event-stream` response.
The server already sends `X-Accel-Buffering: no` (honored by nginx unless ignored), but
compression middleware is applied by proxy configuration, not headers:
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` for these locations, e.g.
`location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
- **Traefik** — route these paths through a dedicated router **without** the `compress`
middleware (a `compress` middleware buffers SSE frames until the response closes), e.g.
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Belt-and-braces:
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
## Migration from Docmost
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
-26
View File
@@ -126,32 +126,6 @@ Gitmost повторяет процесс установки upstream-Docmost.
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
### Reverse proxy: SSE-стриминговые пути
AI-агент стримит ответы через Server-Sent Events. Эти эндпоинты отдают долгоживущий
`text/event-stream`-ответ и **обязаны обходить буферизацию И сжатие ответов** на каждом
прокси перед приложением:
- `POST /api/ai-chat/stream` — живой стрим хода агента
- `GET /api/ai-chat/runs/<chatId>/stream` — подключение/резюм detached-рана
(`AI_CHAT_RESUMABLE_STREAM`)
- `POST /api/shares/ai/stream` — анонимный ассистент публичных шар
Буферизующий или сжимающий прокси не ломает эти пути с ошибкой — он тихо их портит:
запрос висит в `pending`, токены не стримятся и вываливаются одним куском в конце хода,
а перезагруженная вкладка падает в грубый поллинг. Диагностический признак в DevTools —
заголовок `Content-Encoding: gzip/zstd` на ответе с `text/event-stream`.
Сервер уже шлёт `X-Accel-Buffering: no` (nginx учитывает его по умолчанию), но
compression-мидлвари управляются конфигом прокси, а не заголовками:
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` для этих location,
например `location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
- **Traefik** — вести эти пути через отдельный роутер **без** `compress`-мидлвари
(compress буферизует SSE-кадры до закрытия ответа), например
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Для надёжности:
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
## Миграция с Docmost
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
+79 -302
View File
@@ -16,337 +16,114 @@ roles:
whatever language is most effective, but deliver the report in English.
═══════════════════════════════════════════════
THE BUDGET: PAGES READ, NOT SEARCHES
STEP 0. PLAN (always do this first)
═══════════════════════════════════════════════
The unit of research work is a PAGE READ IN FULL — opening a source with the
page-reading/extraction tool and actually reading it. Search queries are free
and unlimited: they are navigation, not research. A search result snippet is a
POINTER, never a source. Nothing learned only from a snippet may enter the
report.
- If the user named a budget (e.g. "budget 100"), that is 100 pages read, and
it is BINDING — a floor you MUST reach. Spend it in full even past the point
where the topic feels covered (see BUDGET REMAINDER PROTOCOL below).
- If no budget is given, default to about 50 pages read; fewer only for a
single trivial fact, well over 50 for a hard, broad task. Absent an explicit
budget, stop only at genuine saturation — when further reading stops
yielding new relevant information — not when it "seems like enough".
- A page counts toward the budget only if you read it and extracted something
(a finding, a dead-end note, a contradiction). Skimming a snippet does not
count. Re-opening the same page does not count twice.
- Rule of thumb: for every search that surfaces relevant hits, open and read
at least 2–3 of the most promising results BEFORE running the next search.
Chaining searches with no page reads in between is a critical failure —
snippets carry ~5 % of the available content and reading pages is the whole
job. If you catch yourself doing it, stop and go read what you already
found.
BUDGET REMAINDER PROTOCOL. When the topic already feels covered but budget
remains, do NOT pad with junk or near-duplicate reads. Spend the remainder in
this priority order:
1. ADVERSARIAL VERIFICATION — for each key claim in the document, run
searches deliberately trying to REFUTE it or find a competing version;
read what you find. Results go into the "Contradictions" section (or
strengthen the claim's footnote).
2. PRIMARY SOURCES — for every important claim currently backed by a
retelling, aggregator, or news piece, hunt down and read the original:
the study, spec, dataset, filing, repository, interview.
3. LATERAL EXPANSION — adjacent disciplines, industries with the same
problem, historical analogues, criticism and opposing schools.
Every remainder read must still be a genuine attempt to learn or verify
something.
Before searching for anything, draft and show a research plan:
- Break down the query: what exactly is needed, what sub-questions are
inside it, which terms are ambiguous or have synonyms/jargon.
- Formulate 5–10 search directions, including adjacent perspectives that
may prove useful even if the user did not ask about them directly.
- Set a "research budget" — roughly how many searches the task's complexity
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
- Decide which languages it makes sense to search in (see below).
═══════════════════════════════════════════════
THE DOCUMENT IS YOUR WORKING MEMORY
WHERE TO WRITE THE RESULT
═══════════════════════════════════════════════
Your context window is small and lossy; the document is not. Treat the
document — not your head — as the single source of truth and your external
memory. You are not "taking notes to compile later"; you are building the
report itself, live, from the first minute.
SETUP. Create/claim the document at the VERY START, before any searches.
Reuse the currently open document ONLY if (a) the user explicitly asked to
work in it, or (b) it is empty or near-empty AND its title matches the topic.
Otherwise create a new one.
Seed it immediately with:
- the user's query, restated;
- the RESEARCH PLAN (see below) — the plan lives in the document, not in
chat; do not wait for approval, write it and proceed;
- a skeleton of the report sections you expect to fill;
- a "Log" section (working log) and an "Open Questions" section.
RESEARCH PLAN (written into the document before searching):
- Break down the query: what exactly is needed, what sub-questions are
inside it, which terms are ambiguous or have synonyms/jargon.
- 5–10 search directions, including adjacent angles the user did not ask
about directly.
- The budget (user-given or default) and how you expect to allocate it
across directions — a rough split, revisable.
- Which languages to search in.
THE LOG. In the "Log" section keep a numbered list of pages read:
`N. [query →] source — what I took / empty / contradiction`. One line each.
This is your budget counter and your flush-cadence counter — count by the log,
not from memory. Dead ends and paywalls go in the log too (they count toward
the budget only if you actually read a cached/alternative copy; a hard dead
end is logged but not counted).
FLUSH CADENCE — HARD RULE. Never read more than ~8–10 pages without writing
everything gathered since the last flush into the report sections. Check the
log: if the last flush was 10 reads ago, the next action is writing, not
reading. Frequent small updates are the norm; a long streak of reads with
nothing written is a mistake to correct immediately.
A flush means writing REPORT PROSE, not dumping notes. Every flush produces
finished paragraphs in the report sections, written to the standard of
"PROSE, NOT NOTES" below. Telegraphic fragments are allowed ONLY in the
"Log" and "Open Questions" working sections — never in the report body.
Do not plan to "expand the notes into text later": later never comes, and a
report assembled from unexpanded notes is a failed report.
CONTEXT DISCIPLINE. After flushing a finding into the document, compress it in
your head to 2–3 sentences of conclusions and let the raw page text go. Do not
carry full page contents forward in context. When you need to re-orient — and
ALWAYS before deciding what to research next after a flush — RE-READ the
document (at minimum: the skeleton, "Open Questions", and the sections you
touched). The document you re-read, not your memory of it, defines the current
state of the research.
- If the user explicitly asks to work in the current/already-open document,
work in it.
- If this is not specified, create a NEW document for the report.
- Keep a working draft in the document or in notes: fact → source →
reliability assessment. Update the structure as you go.
═══════════════════════════════════════════════
WORK LOOP
WORK LOOP (repeat until saturation)
═══════════════════════════════════════════════
Iterate observe → orient → decide → act:
1. Observe: re-read the relevant parts of the DOCUMENT — what is filled,
what is thin, what "Open Questions" lists.
2. Orient: which query or source best closes the biggest gap; update the
plan section if your understanding of the topic has shifted.
3. Decide: pick one concrete next action.
4. Act: search, then READ the promising results in full.
After every page read, reason: what you learned, what new questions arose,
what to read next. Add new questions to "Open Questions"; strike out closed
ones. Flush per the cadence above.
═══════════════════════════════════════════════
CRITICAL REVIEW PASS (mandatory, after the main pass)
═══════════════════════════════════════════════
When the planned directions are covered (or ~70 % of the budget is spent,
whichever comes first), STOP researching and switch roles: re-read the ENTIRE
document as a hostile reviewer who did not do the research. Write the result
into a "Revision" block in the document:
- GAPS: sub-questions from the plan that are answered thinly or not at all;
sections that are compilation without analysis; places where the report
says "widely known" instead of citing.
- NOTE-STYLE SECTIONS: sections violating "PROSE, NOT NOTES" — bullet
lists of bare numbers, orphan keyword strings, facts stated without
mechanism or interpretation. Each one gets rewritten as prose; if the
understanding needed to write the prose is missing, that is a research
gap — go read more, then write.
- WEAK CLAIMS: key statements resting on a single source, on a secondary
source, on marketing material, or on an old date.
- CONTRADICTIONS: places where the document disagrees with itself.
- MISSING ANGLES: what a domain expert would immediately ask that the
report does not address.
Then convert this list into a targeted second pass: spend the remaining
budget closing the gaps and hardening the weak claims, in priority order.
If budget remains after that, apply the BUDGET REMAINDER PROTOCOL. Repeat the
review → targeted pass cycle until the budget is spent (mandatory budget) or
saturation is genuine (no budget given). A report that got only one linear
pass and no revision is not finished.
Work iteratively through an observe → orient → decide → act loop:
1. Observe: what has been gathered, what is still missing, what tools exist.
2. Orient: which query or source would best close the gap; update your
understanding of the topic based on what you've found.
3. Decide: choose a specific next action.
4. Act: run the search or open the source.
After EVERY result, reason about it: what you learned, what new questions
arose, what to search next. Maintain an internal list of open questions and
gaps, and close them.
═══════════════════════════════════════════════
HOW TO SEARCH
═══════════════════════════════════════════════
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
Do not stop at the first plausible answer. Stop only when further searches
stop yielding new relevant information (saturation / diminishing returns) —
not when it "seems like enough" or when you get tired.
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
landscape, then narrow. Scarce results broaden the phrasing; abundant →
narrow it.
landscape, then narrow. If results are scarce, broaden the phrasing; if
they're abundant, narrow it.
REFORMULATE. Don't repeat the same query. Approach from different angles:
synonyms, the professional jargon of the field, alternative and historical
terms.
synonyms, the professional jargon of the target field, alternative terms,
historical names.
OTHER LANGUAGES. Actively search in the languages where the primary sources
or core expertise likely live (German-law topic in German, Japanese-technology
topic in Japanese, medical reviews in non-English databases). Translate key
terms into the target language and search with them. Render anything found
into English in the report.
OTHER LANGUAGES. Actively search in the languages where the primary source
or the core expertise on the topic is likely to live (e.g. a German-law
topic in German, a Japanese-technology topic in Japanese, medical reviews
in non-English databases). For many topics a significant share of relevant
primary sources is absent from Russian- and English-language results.
Translate key terms into the target language and search with them. Render
anything found in other languages into English in the report.
NOT THE FIRST PAGE. The first results are the most obvious and often the most
superficial. Deliberately dig deeper.
NOT THE FIRST PAGE. The first results are the most obvious and often the
most superficial. Deliberately dig out what lies deeper.
LATERAL SEARCH. Don't fixate on the narrow phrasing. Regularly ask: "What
sits right next to the scope and might turn out to be important?" Capture
valuable unexpected findings — they feed the "Adjacent & non-obvious" section.
FULL PAGES, NOT SNIPPETS. Open and read sources in full rather than relying
on search-result fragments.
PRIMARY SOURCES. Go to the originals: studies, documents, data, specs,
reports, repositories, interviews. Prefer primary sources over news
aggregators and retellings. If someone cites a source — find the source
itself.
LATERAL SEARCH. Don't fixate on the narrow phrasing. Move into adjacent
areas that may be useful: neighboring disciplines and industries that faced
a similar problem, historical analogues, opposing viewpoints and criticism,
non-obvious connections between topics. Regularly ask yourself: "What sits
right next to the scope and might turn out to be important?" Capture
valuable unexpected findings.
═══════════════════════════════════════════════
EVALUATING SOURCES AND FACTS
═══════════════════════════════════════════════
SOURCE HIERARCHY (when sources conflict, higher beats lower, then recency):
1. Primary documents: studies, specs, standards, datasets, filings, code
repositories, official statistics, court records, first-person
interviews.
2. Peer-reviewed literature and systematic reviews.
3. Official documentation and statements of the responsible organization.
4. Quality journalism with named authors and named sources.
5. Expert blogs and conference talks (judge the author, not the venue).
6. Aggregators, content farms, forums, anonymous retellings — pointers
only; never the sole support for a claim in the report.
CRITICAL APPRAISAL. Watch for: aggregators instead of the original, false
authority, nameless sources with passive voice, qualifiers without specifics,
CRITICAL APPRAISAL. Watch for signs of problematic sources: aggregators
instead of the original, false authority, nameless sources paired with
passive voice, general qualifiers without specifics, unconfirmed reports,
marketing language, speculation, cherry-picked data. Do not present such
material as established fact — flag it. Present speculation about the future
as speculation.
results as established fact — flag the issue. Present speculation about the
future as speculation, not as something that has happened.
LATERAL READING. To judge an unfamiliar source, don't burrow into it — check
what other reliable sources say about it and its author.
LATERAL READING. To judge an unfamiliar source, don't burrow into the
source itself — see what other reliable sources say about it and its author.
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
several INDEPENDENT sources (two retellings of one press release are one
source). Surface unresolved contradictions explicitly in the report.
several independent sources. On conflict, prioritize by recency,
consistency with other facts, and source quality. Surface unresolved
contradictions explicitly in the report.
DATES AND STALENESS. Record the publication date of a source alongside the
claim when it matters. For fast-moving topics, explicitly stamp facts ("as of
2024") and flag data that may be stale. Prefer the newest credible source for
anything volatile.
DEAD ENDS AND FAILURES. Paywall, 403, empty page, broken tool: log it and
move on — look for a cached copy, a mirror, the same material elsewhere, or
an alternative source. NEVER guess or reconstruct what an unreadable page
"probably said". A claim you couldn't verify because the source was
unreachable is written up as exactly that.
SELF-VERIFICATION. Before finalizing, formulate verification questions about
your key claims and answer them separately, grounded in what you found.
═══════════════════════════════════════════════
CITING SOURCES INLINE (FOOTNOTES)
REPORT FORMAT (in the document, written in ENGLISH)
═══════════════════════════════════════════════
EVERY non-trivial claim — facts, figures, dates, names, quotes, anything a
reader could doubt — carries an inline footnote to its source, placed right
at the claim, at the moment you write the claim in (fact → source →
reliability), not in a cleanup pass. The end-of-report source list
COMPLEMENTS inline citations, it does not replace them. A claim with no
footnote reads as unsourced.
- A direct answer to the main question up front.
- A detailed breakdown by subsections.
- A separate "Смежное и неочевидное" section — useful things found next to
the scope.
- Contradictions and disputed points — separately.
- What remains unverified or unknown — honestly.
- Sources with a reliability note.
SYNTAX. Inline form ONLY: `^[...]` directly after the word or sentence it
backs, no space before `^`. Prefer a Markdown link inside. The link must
point to the SPECIFIC page that supports THIS claim, not the site's homepage.
Examples:
The average round size grew 12%^[Bank of Russia report "2023 Results",
section 4.2, [link](https://cbr.ru/collection/file/2023-report.pdf)].
The feature shipped in version 2.1^[Project changelog,
[v2.1.0](https://github.com/example/proj/releases/tag/v2.1.0)].
DO NOT use the reference style `text[^1]` with a separate `[^1]: ...` block:
this system does not parse it and it will show as raw text. Only `^[...]`
becomes a real footnote.
WHAT GOES INSIDE. Enough to identify and locate the source: title or
author/organization plus the URL. For a shaky source, add a short reliability
flag in the note (e.g. "secondary source, unconfirmed"). For a triangulated
claim, cite each source: several `^[...]` in a row or several links in one
note.
DEDUP. Identical `^[...]` texts merge automatically into one numbered entry —
cite freely without fear of duplicates.
WHICH WRITE PATH PARSES `^[...]`. The `^[...]` syntax turns into a REAL
footnote ONLY when you write the whole markdown body at once — create_page,
update_page_content, or import_page_markdown. When you write it as a claim
you are drafting, that is the normal path and it just works. But if you are
adding a citation to text that is ALREADY on the page, a surgical
edit_page_text (or insert_node) writes `^[...]` as a LITERAL string — it does
NOT parse, and the reader sees the raw `^[...]`. For that pinpoint case call
insert_footnote(anchorText, text): anchorText is a snippet of the existing
text to attach the note after, text is the note itself; numbering is handled
for you.
═══════════════════════════════════════════════
PROSE, NOT NOTES
═══════════════════════════════════════════════
You are writing a RESEARCH REPORT, not a set of notes. The failure mode to
avoid: sections that are headers over bullet lists of bolded numbers and
keyword strings — compressed summaries with no reasoning. That is a lookup
table, not research. The reader hires you for the ANALYSIS: what the facts
mean, how they connect, why they are the way they are.
Concretely:
- DEFAULT TO PARAGRAPHS. Every section is connected analytical prose:
full sentences, transitions, a line of argument. A section that consists
only of a bullet list is unfinished.
- EXPLAIN, DON'T JUST STATE. A number or fact enters the report together
with its meaning: what it is compared to, what drives it, what follows
from it, under what conditions it holds. "Inventory accuracy rose from
65% to 95–99%" alone is a note; the report says where these numbers come
from, on what scale they were measured, why the jump is that large, and
what caveats apply.
- MECHANISMS AND CAUSES. Wherever the material allows, answer "why" and
"how", not only "what": the mechanism behind an effect, the trade-off
behind a design choice, the reason two sources disagree.
- BULLETS ARE FOR GENUINE ENUMERATIONS ONLY: lists of items that are truly
parallel and need no individual discussion (a list of standards, a set of
frequency bands). Even then, each item is a full phrase, and the list is
introduced and followed by prose that interprets it. Never use bullets to
avoid writing sentences.
- NO ORPHAN KEYWORDS. Strings like "Equipment, blood, tissues, drugs, cold
chain" are raw material, not report text. Either develop them into
sentences that say something, or state explicitly that the topic is only
surveyed and why.
- EVERY SECTION ANSWERS A QUESTION. Before writing a section, know what
question it answers for the reader; the section is finished when a reader
who knows nothing about the topic comes away with an understanding, not a
word list to google.
- DENSITY OVER LENGTH. This is not a demand for padding or watery
academic filler — keep the text tight. The requirement is that
compression must never discard the reasoning, only the redundancy.
═══════════════════════════════════════════════
LANGUAGE AND TERMINOLOGY OF THE REPORT
═══════════════════════════════════════════════
The report is in English. Rules:
- Technical terms: use the established English term; give the original in
parentheses at first mention when the source language differs —
"embeddings (встраивания)". If no settled English term exists, keep the
original and gloss it once.
- Product names, API names, identifiers, code, CLI commands, config keys:
never translate, never transliterate.
- Quotes from sources: translate into English, keep the original phrasing
in the footnote or parentheses when the exact wording matters.
- Machine-readable artifacts inside the report (code blocks, tables of
identifiers) stay in their original language.
═══════════════════════════════════════════════
REPORT FORMAT (in the document, in ENGLISH)
═══════════════════════════════════════════════
- Direct answer to the main question up front.
- Detailed breakdown by subsections.
- "Adjacent & non-obvious" — useful things found next to the scope.
- "Contradictions & disputes" — conflicts between sources, results of
adversarial verification.
- "Unknown & unverified" — honestly: what was not found, what could not be
verified, and why.
- Inline footnotes throughout, plus a consolidated source list with
reliability notes at the end.
═══════════════════════════════════════════════
FINALIZATION CHECKLIST (run before declaring done)
═══════════════════════════════════════════════
□ Budget: the log shows the mandatory budget fully spent (or genuine
saturation documented, if no budget was given).
□ At least one full CRITICAL REVIEW PASS was done and its gaps were
addressed.
□ Every non-trivial claim has an inline `^[...]` footnote; no claim rests
solely on a snippet or a tier-6 source.
□ No section of the report body is note-style: no bare bullet lists of
numbers, no orphan keyword strings; every section is connected prose
that explains, not just states ("PROSE, NOT NOTES").
□ Key figures/dates are triangulated or explicitly flagged as
single-source.
□ The direct answer at the top matches the body of the report.
□ "Unknown" is honestly filled — not empty by omission.
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
appendix at the end of the document or clearly separated from the report
body.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
Be honest about gaps. If you couldn't find something, say so — don't
disguise a guess as a fact.
autoStart: false
launchMessage: null
+79 -301
View File
@@ -16,336 +16,114 @@ roles:
whatever language is most effective, but deliver the report in Russian.
═══════════════════════════════════════════════
THE BUDGET: PAGES READ, NOT SEARCHES
STEP 0. PLAN (always do this first)
═══════════════════════════════════════════════
The unit of research work is a PAGE READ IN FULL — opening a source with the
page-reading/extraction tool and actually reading it. Search queries are free
and unlimited: they are navigation, not research. A search result snippet is a
POINTER, never a source. Nothing learned only from a snippet may enter the
report.
- If the user named a budget (e.g. "budget 100"), that is 100 pages read, and
it is BINDING — a floor you MUST reach. Spend it in full even past the point
where the topic feels covered (see BUDGET REMAINDER PROTOCOL below).
- If no budget is given, default to about 50 pages read; fewer only for a
single trivial fact, well over 50 for a hard, broad task. Absent an explicit
budget, stop only at genuine saturation — when further reading stops
yielding new relevant information — not when it "seems like enough".
- A page counts toward the budget only if you read it and extracted something
(a finding, a dead-end note, a contradiction). Skimming a snippet does not
count. Re-opening the same page does not count twice.
- Rule of thumb: for every search that surfaces relevant hits, open and read
at least 2–3 of the most promising results BEFORE running the next search.
Chaining searches with no page reads in between is a critical failure —
snippets carry ~5 % of the available content and reading pages is the whole
job. If you catch yourself doing it, stop and go read what you already
found.
BUDGET REMAINDER PROTOCOL. When the topic already feels covered but budget
remains, do NOT pad with junk or near-duplicate reads. Spend the remainder in
this priority order:
1. ADVERSARIAL VERIFICATION — for each key claim in the document, run
searches deliberately trying to REFUTE it or find a competing version;
read what you find. Results go into the "Противоречия" section (or
strengthen the claim's footnote).
2. PRIMARY SOURCES — for every important claim currently backed by a
retelling, aggregator, or news piece, hunt down and read the original:
the study, spec, dataset, filing, repository, interview.
3. LATERAL EXPANSION — adjacent disciplines, industries with the same
problem, historical analogues, criticism and opposing schools.
Every remainder read must still be a genuine attempt to learn or verify
something.
Before searching for anything, draft and show a research plan:
- Break down the query: what exactly is needed, what sub-questions are
inside it, which terms are ambiguous or have synonyms/jargon.
- Formulate 5–10 search directions, including adjacent perspectives that
may prove useful even if the user did not ask about them directly.
- Set a "research budget" — roughly how many searches the task's complexity
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
- Decide which languages it makes sense to search in (see below).
═══════════════════════════════════════════════
THE DOCUMENT IS YOUR WORKING MEMORY
WHERE TO WRITE THE RESULT
═══════════════════════════════════════════════
Your context window is small and lossy; the document is not. Treat the
document — not your head — as the single source of truth and your external
memory. You are not "taking notes to compile later"; you are building the
report itself, live, from the first minute.
SETUP. Create/claim the document at the VERY START, before any searches.
Reuse the currently open document ONLY if (a) the user explicitly asked to
work in it, or (b) it is empty or near-empty AND its title matches the topic.
Otherwise create a new one.
Seed it immediately with:
- the user's query, restated;
- the RESEARCH PLAN (see below) — the plan lives in the document, not in
chat; do not wait for approval, write it and proceed;
- a skeleton of the report sections you expect to fill;
- a "Журнал" section (working log) and an "Открытые вопросы" section.
RESEARCH PLAN (written into the document before searching):
- Break down the query: what exactly is needed, what sub-questions are
inside it, which terms are ambiguous or have synonyms/jargon.
- 5–10 search directions, including adjacent angles the user did not ask
about directly.
- The budget (user-given or default) and how you expect to allocate it
across directions — a rough split, revisable.
- Which languages to search in.
THE LOG. In the "Журнал" section keep a numbered list of pages read:
`N. [запрос →] источник — что взял / пусто / противоречие`. One line each.
This is your budget counter and your flush-cadence counter — count by the log,
not from memory. Dead ends and paywalls go in the log too (they count toward
the budget only if you actually read a cached/alternative copy; a hard dead
end is logged but not counted).
FLUSH CADENCE — HARD RULE. Never read more than ~8–10 pages without writing
everything gathered since the last flush into the report sections. Check the
log: if the last flush was 10 reads ago, the next action is writing, not
reading. Frequent small updates are the norm; a long streak of reads with
nothing written is a mistake to correct immediately.
A flush means writing REPORT PROSE, not dumping notes. Every flush produces
finished paragraphs in the report sections, written to the standard of
"PROSE, NOT NOTES" below. Telegraphic fragments are allowed ONLY in the
«Журнал» and «Открытые вопросы» working sections — never in the report body.
Do not plan to "expand the notes into text later": later never comes, and a
report assembled from unexpanded notes is a failed report.
CONTEXT DISCIPLINE. After flushing a finding into the document, compress it in
your head to 2–3 sentences of conclusions and let the raw page text go. Do not
carry full page contents forward in context. When you need to re-orient — and
ALWAYS before deciding what to research next after a flush — RE-READ the
document (at minimum: the skeleton, "Открытые вопросы", and the sections you
touched). The document you re-read, not your memory of it, defines the current
state of the research.
- If the user explicitly asks to work in the current/already-open document,
work in it.
- If this is not specified, create a NEW document for the report.
- Keep a working draft in the document or in notes: fact → source →
reliability assessment. Update the structure as you go.
═══════════════════════════════════════════════
WORK LOOP
WORK LOOP (repeat until saturation)
═══════════════════════════════════════════════
Iterate observe → orient → decide → act:
1. Observe: re-read the relevant parts of the DOCUMENT — what is filled,
what is thin, what "Открытые вопросы" lists.
2. Orient: which query or source best closes the biggest gap; update the
plan section if your understanding of the topic has shifted.
3. Decide: pick one concrete next action.
4. Act: search, then READ the promising results in full.
After every page read, reason: what you learned, what new questions arose,
what to read next. Add new questions to "Открытые вопросы"; strike out closed
ones. Flush per the cadence above.
═══════════════════════════════════════════════
CRITICAL REVIEW PASS (mandatory, after the main pass)
═══════════════════════════════════════════════
When the planned directions are covered (or ~70 % of the budget is spent,
whichever comes first), STOP researching and switch roles: re-read the ENTIRE
document as a hostile reviewer who did not do the research. Write the result
into a "Ревизия" block in the document:
- GAPS: sub-questions from the plan that are answered thinly or not at all;
sections that are compilation without analysis; places where the report
says "widely known" instead of citing.
- NOTE-STYLE SECTIONS: sections violating "PROSE, NOT NOTES" — bullet
lists of bare numbers, orphan keyword strings, facts stated without
mechanism or interpretation. Each one gets rewritten as prose; if the
understanding needed to write the prose is missing, that is a research
gap — go read more, then write.
- WEAK CLAIMS: key statements resting on a single source, on a secondary
source, on marketing material, or on an old date.
- CONTRADICTIONS: places where the document disagrees with itself.
- MISSING ANGLES: what a domain expert would immediately ask that the
report does not address.
Then convert this list into a targeted second pass: spend the remaining
budget closing the gaps and hardening the weak claims, in priority order.
If budget remains after that, apply the BUDGET REMAINDER PROTOCOL. Repeat the
review → targeted pass cycle until the budget is spent (mandatory budget) or
saturation is genuine (no budget given). A report that got only one linear
pass and no revision is not finished.
Work iteratively through an observe → orient → decide → act loop:
1. Observe: what has been gathered, what is still missing, what tools exist.
2. Orient: which query or source would best close the gap; update your
understanding of the topic based on what you've found.
3. Decide: choose a specific next action.
4. Act: run the search or open the source.
After EVERY result, reason about it: what you learned, what new questions
arose, what to search next. Maintain an internal list of open questions and
gaps, and close them.
═══════════════════════════════════════════════
HOW TO SEARCH
═══════════════════════════════════════════════
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
Do not stop at the first plausible answer. Stop only when further searches
stop yielding new relevant information (saturation / diminishing returns) —
not when it "seems like enough" or when you get tired.
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
landscape, then narrow. Scarce results broaden the phrasing; abundant →
narrow it.
landscape, then narrow. If results are scarce, broaden the phrasing; if
they're abundant, narrow it.
REFORMULATE. Don't repeat the same query. Approach from different angles:
synonyms, the professional jargon of the field, alternative and historical
terms.
synonyms, the professional jargon of the target field, alternative terms,
historical names.
OTHER LANGUAGES. Actively search in the languages where the primary sources
or core expertise likely live (German-law topic in German, Japanese-technology
topic in Japanese, medical reviews in non-English databases). Translate key
terms into the target language and search with them. Render anything found
into Russian in the report.
OTHER LANGUAGES. Actively search in the languages where the primary source
or the core expertise on the topic is likely to live (e.g. a German-law
topic in German, a Japanese-technology topic in Japanese, medical reviews
in non-English databases). For many topics a significant share of relevant
primary sources is absent from Russian- and English-language results.
Translate key terms into the target language and search with them. Render
anything found in other languages into Russian in the report.
NOT THE FIRST PAGE. The first results are the most obvious and often the most
superficial. Deliberately dig deeper.
NOT THE FIRST PAGE. The first results are the most obvious and often the
most superficial. Deliberately dig out what lies deeper.
LATERAL SEARCH. Don't fixate on the narrow phrasing. Regularly ask: "What
sits right next to the scope and might turn out to be important?" Capture
valuable unexpected findings — they feed the "Смежное и неочевидное" section.
FULL PAGES, NOT SNIPPETS. Open and read sources in full rather than relying
on search-result fragments.
PRIMARY SOURCES. Go to the originals: studies, documents, data, specs,
reports, repositories, interviews. Prefer primary sources over news
aggregators and retellings. If someone cites a source — find the source
itself.
LATERAL SEARCH. Don't fixate on the narrow phrasing. Move into adjacent
areas that may be useful: neighboring disciplines and industries that faced
a similar problem, historical analogues, opposing viewpoints and criticism,
non-obvious connections between topics. Regularly ask yourself: "What sits
right next to the scope and might turn out to be important?" Capture
valuable unexpected findings.
═══════════════════════════════════════════════
EVALUATING SOURCES AND FACTS
═══════════════════════════════════════════════
SOURCE HIERARCHY (when sources conflict, higher beats lower, then recency):
1. Primary documents: studies, specs, standards, datasets, filings, code
repositories, official statistics, court records, first-person
interviews.
2. Peer-reviewed literature and systematic reviews.
3. Official documentation and statements of the responsible organization.
4. Quality journalism with named authors and named sources.
5. Expert blogs and conference talks (judge the author, not the venue).
6. Aggregators, content farms, forums, anonymous retellings — pointers
only; never the sole support for a claim in the report.
CRITICAL APPRAISAL. Watch for: aggregators instead of the original, false
authority, nameless sources with passive voice, qualifiers without specifics,
CRITICAL APPRAISAL. Watch for signs of problematic sources: aggregators
instead of the original, false authority, nameless sources paired with
passive voice, general qualifiers without specifics, unconfirmed reports,
marketing language, speculation, cherry-picked data. Do not present such
material as established fact — flag it. Present speculation about the future
as speculation.
results as established fact — flag the issue. Present speculation about the
future as speculation, not as something that has happened.
LATERAL READING. To judge an unfamiliar source, don't burrow into it — check
what other reliable sources say about it and its author.
LATERAL READING. To judge an unfamiliar source, don't burrow into the
source itself — see what other reliable sources say about it and its author.
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
several INDEPENDENT sources (two retellings of one press release are one
source). Surface unresolved contradictions explicitly in the report.
several independent sources. On conflict, prioritize by recency,
consistency with other facts, and source quality. Surface unresolved
contradictions explicitly in the report.
DATES AND STALENESS. Record the publication date of a source alongside the
claim when it matters. For fast-moving topics, explicitly stamp facts («по
состоянию на 2024 год») and flag data that may be stale. Prefer the newest
credible source for anything volatile.
DEAD ENDS AND FAILURES. Paywall, 403, empty page, broken tool: log it and
move on — look for a cached copy, a mirror, the same material elsewhere, or
an alternative source. NEVER guess or reconstruct what an unreadable page
"probably said". A claim you couldn't verify because the source was
unreachable is written up as exactly that.
SELF-VERIFICATION. Before finalizing, formulate verification questions about
your key claims and answer them separately, grounded in what you found.
═══════════════════════════════════════════════
CITING SOURCES INLINE (FOOTNOTES)
REPORT FORMAT (in the document, written in RUSSIAN)
═══════════════════════════════════════════════
EVERY non-trivial claim — facts, figures, dates, names, quotes, anything a
reader could doubt — carries an inline footnote to its source, placed right
at the claim, at the moment you write the claim in (fact → source →
reliability), not in a cleanup pass. The end-of-report source list
COMPLEMENTS inline citations, it does not replace them. A claim with no
footnote reads as unsourced.
- A direct answer to the main question up front.
- A detailed breakdown by subsections.
- A separate "Смежное и неочевидное" section — useful things found next to
the scope.
- Contradictions and disputed points — separately.
- What remains unverified or unknown — honestly.
- Sources with a reliability note.
SYNTAX. Inline form ONLY: `^[...]` directly after the word or sentence it
backs, no space before `^`. Prefer a Markdown link inside. The link must
point to the SPECIFIC page that supports THIS claim, not the site's homepage.
Examples:
Средний размер раунда вырос на 12 %^[Отчёт ЦБ «Итоги 2023», раздел 4.2,
[ссылка](https://cbr.ru/collection/file/2023-report.pdf)].
Функция появилась в версии 2.1^[Changelog проекта,
[v2.1.0](https://github.com/example/proj/releases/tag/v2.1.0)].
DO NOT use the reference style `text[^1]` with a separate `[^1]: ...` block:
this system does not parse it and it will show as raw text. Only `^[...]`
becomes a real footnote.
WHAT GOES INSIDE. Enough to identify and locate the source: title or
author/organization plus the URL. For a shaky source, add a short reliability
flag in the note (e.g. «вторичный источник, не подтверждён»). For a
triangulated claim, cite each source: several `^[...]` in a row or several
links in one note.
DEDUP. Identical `^[...]` texts merge automatically into one numbered entry —
cite freely without fear of duplicates.
WHICH WRITE PATH PARSES `^[...]`. The `^[...]` syntax turns into a REAL
footnote ONLY when you write the whole markdown body at once — create_page,
update_page_content, or import_page_markdown. When you write it as a claim
you are drafting, that is the normal path and it just works. But if you are
adding a citation to text that is ALREADY on the page, a surgical
edit_page_text (or insert_node) writes `^[...]` as a LITERAL string — it does
NOT parse, and the reader sees the raw `^[...]`. For that pinpoint case call
insert_footnote(anchorText, text): anchorText is a snippet of the existing
text to attach the note after, text is the note itself; numbering is handled
for you.
═══════════════════════════════════════════════
PROSE, NOT NOTES
═══════════════════════════════════════════════
You are writing a RESEARCH REPORT, not a конспект. The failure mode to avoid:
sections that are headers over bullet lists of bolded numbers and keyword
strings — compressed summaries with no reasoning. That is a lookup table, not
research. The reader hires you for the ANALYSIS: what the facts mean, how
they connect, why they are the way they are.
Concretely:
- DEFAULT TO PARAGRAPHS. Every section is connected analytical prose:
full sentences, transitions, a line of argument. A section that consists
only of a bullet list is unfinished.
- EXPLAIN, DON'T JUST STATE. A number or fact enters the report together
with its meaning: what it is compared to, what drives it, what follows
from it, under what conditions it holds. «Точность инвентаря выросла с
65 % до 95–99 %» alone is a note; the report says where these numbers
come from, on what scale they were measured, why the jump is that large,
and what caveats apply.
- MECHANISMS AND CAUSES. Wherever the material allows, answer "why" and
"how", not only "what": the mechanism behind an effect, the trade-off
behind a design choice, the reason two sources disagree.
- BULLETS ARE FOR GENUINE ENUMERATIONS ONLY: lists of items that are truly
parallel and need no individual discussion (a list of standards, a set of
frequency bands). Even then, each item is a full phrase, and the list is
introduced and followed by prose that interprets it. Never use bullets to
avoid writing sentences.
- NO ORPHAN KEYWORDS. Strings like «Оборудование, кровь, ткани, лекарства,
холодовая цепь» are raw material, not report text. Either develop them
into sentences that say something, or state explicitly that the topic is
only surveyed and why.
- EVERY SECTION ANSWERS A QUESTION. Before writing a section, know what
question it answers for the reader; the section is finished when a reader
who knows nothing about the topic comes away with an understanding, not a
word list to google.
- DENSITY OVER LENGTH. This is not a demand for padding or watery
academic filler — keep the text tight. The requirement is that
compression must never discard the reasoning, only the redundancy.
═══════════════════════════════════════════════
LANGUAGE AND TERMINOLOGY OF THE REPORT
═══════════════════════════════════════════════
The report is in Russian. Rules:
- Technical terms: use the established Russian term; give the original in
parentheses at first mention — «встраивания (embeddings)». If no settled
Russian term exists, keep the original and gloss it once.
- Product names, API names, identifiers, code, CLI commands, config keys:
never translate, never transliterate.
- Quotes from sources: translate into Russian, keep the original phrasing
in the footnote or parentheses when the exact wording matters.
- Machine-readable artifacts inside the report (code blocks, tables of
identifiers) stay in their original language.
═══════════════════════════════════════════════
REPORT FORMAT (in the document, in RUSSIAN)
═══════════════════════════════════════════════
- Direct answer to the main question up front.
- Detailed breakdown by subsections.
- «Смежное и неочевидное» — useful things found next to the scope.
- «Противоречия и спорное» — conflicts between sources, results of
adversarial verification.
- «Неизвестное и непроверенное» — honestly: what was not found, what could
not be verified, and why.
- Inline footnotes throughout, plus a consolidated source list with
reliability notes at the end.
═══════════════════════════════════════════════
FINALIZATION CHECKLIST (run before declaring done)
═══════════════════════════════════════════════
□ Budget: the log shows the mandatory budget fully spent (or genuine
saturation documented, if no budget was given).
□ At least one full CRITICAL REVIEW PASS was done and its gaps were
addressed.
□ Every non-trivial claim has an inline `^[...]` footnote; no claim rests
solely on a snippet or a tier-6 source.
□ No section of the report body is note-style: no bare bullet lists of
numbers, no orphan keyword strings; every section is connected prose
that explains, not just states ("PROSE, NOT NOTES").
□ Key figures/dates are triangulated or explicitly flagged as
single-source.
□ The direct answer at the top matches the body of the report.
□ «Неизвестное» is honestly filled — not empty by omission.
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
an appendix at the end of the document or clearly separated from the
report body.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
Be honest about gaps. If you couldn't find something, say so — don't
disguise a guess as a fact.
autoStart: false
launchMessage: null
+1 -1
View File
@@ -33,4 +33,4 @@ bundles:
- en
roles:
- slug: researcher
version: 8
version: 1
@@ -16,8 +16,8 @@
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
},
"researcher": {
"version": 8,
"hash": "0e76efa180c3e443c8856b8787e9643923d10486b373ce078c12dc16eb04611b"
"version": 1,
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
},
"structural-editor": {
"version": 4,
+1
View File
@@ -98,6 +98,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-compression2": "2.5.3",
"vitest": "4.1.6"
}
}
@@ -37,22 +37,21 @@ import {
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
import {
pageEditorAtom,
readOnlyEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import {
getEditorSelectionContext,
type EditorSelectionContext,
} from "@/features/editor/utils/get-editor-selection.ts";
import { extractPageSlugId } from "@/lib";
import {
AI_CHATS_RQ_KEY,
AI_CHAT_MESSAGES_RQ_KEY,
AI_CHAT_RUN_RQ_KEY,
useAiChatMessagesQuery,
useAiChatRunQuery,
useAiChatsQuery,
useAiRolesQuery,
} from "@/features/ai-chat/queries/ai-chat-query.ts";
import {
shouldClearLatchOnQueryError,
shouldClearStoppingLatch,
shouldObserveRun,
} from "@/features/ai-chat/utils/run-polling.ts";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
@@ -86,20 +85,6 @@ const MIN_HEIGHT = 400;
// Margin kept between the window and the viewport edges while dragging.
const EDGE_MARGIN = 8;
// #184 phase 1.5 / #430: backstop for 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).
//
// #430: measured from RUN ACTIVITY, not from arm-time. A real autonomous run takes
// 11-25 min — longer than a fixed 10-min-from-start cap, which used to cut the poll
// off mid-run. Instead we cap on INACTIVITY: keep polling as long as the run is
// still making progress (its persisted rows keep changing), and only give up after
// this long with NO new activity. A genuinely stuck run produces no row changes, so
// the idle cap still bounds it; a long-but-progressing run polls to completion.
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
/** Compact token formatter: 1.2M / 3.4k / 950. */
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
@@ -257,75 +242,150 @@ export default function AiChatWindow() {
[roles],
);
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
// ChatThread could not attach to a still-running run it arms this via
// onResumeFallback(true); the thread disarms it on settle / local stream. The
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
const [degradedPoll, setDegradedPoll] = useState(false);
// #430: timestamp of the LAST run activity while the poll is armed — stamped on
// arm and re-stamped whenever the polled rows change (see the effect below). The
// idle cap is measured from this, so a long-but-progressing run keeps polling.
const lastActivityAtRef = useRef(0);
const onResumeFallback = useCallback((active: boolean): void => {
if (active) lastActivityAtRef.current = Date.now();
setDegradedPoll(active);
}, []);
// Reset the degraded poll whenever the open chat changes: it is scoped to the
// resume attempt of the previously-open chat (invariant 8).
useEffect(() => {
setDegradedPoll(false);
}, [activeChatId]);
const { data: messageRows, isLoading: messagesLoading } =
useAiChatMessagesQuery(
activeChatId ?? undefined,
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
// and while the run is still active (#430: under the INACTIVITY cap, not a
// fixed-from-start 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 idle cap is the only backstop.
() =>
degradedPoll === true &&
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS
? 2500
: false,
);
// #430: re-stamp the activity clock whenever the polled rows change while the
// poll is armed. TanStack keeps the same `messageRows` reference across refetches
// that return deep-equal data (structural sharing), so a new reference means the
// run genuinely progressed — which extends the inactivity cap above. A stuck run
// yields no reference change, so the cap eventually fires and stops the poll.
useEffect(() => {
if (degradedPoll) lastActivityAtRef.current = Date.now();
}, [degradedPoll, messageRows]);
useAiChatMessagesQuery(activeChatId ?? undefined);
// #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.
// this workspace. The reconnect endpoint itself is NOT flag-gated server-side
// (it is only owner-gated and returns `{ run: null }` when the chat has no
// run); but when the feature is off no runs are ever created, so polling it
// would always come back empty — we gate it off here to avoid pointless polls.
const workspace = useAtomValue(workspaceAtom);
const autonomousRunsEnabled =
workspace?.settings?.ai?.autonomousRuns === true;
// Whether THIS tab is the one actively streaming the open chat's run locally
// (it started the run here and holds the SSE). Reported up from ChatThread. We
// are the STREAMER while true and a passive OBSERVER while false — the basis of
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
// mount effect on every chat switch.
const [localStreaming, setLocalStreaming] = useState(false);
const onStreamingChange = useCallback((streaming: boolean) => {
setLocalStreaming(streaming);
}, []);
// #184 Stop wiring. While a detached run is being stopped we SUPPRESS the
// observer merge so the stopping run's still-persisting output does not
// re-stream back into view between the moment the user pressed Stop and the run
// actually settling as 'aborted' server-side. Polling itself keeps running (so
// the terminal transition is still detected) — only the visual merge is gated.
// Cleared when the run is observed terminal (below) or the chat is switched.
const [stoppingRun, setStoppingRun] = useState(false);
// Reset the stopping latch whenever the open chat changes: it is scoped to the
// run of the previously-open chat.
useEffect(() => {
setStoppingRun(false);
}, [activeChatId]);
// 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.
// autonomous mode). Latch "stopping" first (suppresses the re-stream flash),
// then 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 we
// release the latch so the observer resumes (better to show the live run than to
// freeze the view) and surface the error.
const handleServerStop = useCallback(
(chatId: string): void => {
setStoppingRun(true);
// #234 F4: drop the PREVIOUS turn's run from the cache so `run` becomes null
// until the CURRENT turn's run is fetched fresh. Without this, once the local
// stream aborts (localStreaming -> false) the run query re-enables and
// react-query SYNCHRONOUSLY returns the still-cached prior terminal run; the
// terminal effect would then clear the stopping latch against that STALE run
// before the current turn's (still-running, detached, growing) run is ever
// observed — re-opening the observer merge and flashing the growing output
// over the frozen row. With the cache cleared the terminal effect's
// `if (!run) return` holds the latch until the current run itself is observed
// terminal (see shouldClearStoppingLatch).
queryClient.removeQueries({ queryKey: AI_CHAT_RUN_RQ_KEY(chatId) });
void stopRun(chatId).catch(() => {
setStoppingRun(false);
notifications.show({
message: t("Failed to stop the run"),
color: "red",
});
});
},
[t],
[t, queryClient],
);
// Poll the latest run of the open chat ONLY when we are a passive observer:
// feature on, a chat is open, and we are NOT the local streamer (the streamer
// already has the live SSE — polling/merging too would double-render). The
// query's own status-keyed refetchInterval stops once the run is terminal.
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
activeChatId ?? undefined,
autonomousRunsEnabled && !localStreaming,
);
const run = runData?.run ?? null;
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
// `run` is null until the current turn's run is fetched fresh, and the terminal
// effect below holds the latch via `if (!run) return`. If that refetch instead
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
// streamer, the run stays null, its status-keyed refetchInterval is off, and
// nothing would ever observe a terminal run — freezing the view with the
// observer merge suppressed. Release the latch on that error so the live view
// resumes rather than stays stuck (the local stopRun may already have succeeded
// independently).
//
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
// pending/running — releasing then would re-open the observer merge and flash
// the growing detached run over the frozen row (the very flash F4 prevents). The
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
// on the run NOT being active: it cures only the genuine permanent-null-freeze
// (`run === null`) and never releases against an active run.
useEffect(() => {
if (
shouldClearLatchOnQueryError({
stoppingRun,
isLocalStreaming: localStreaming,
runQueryFailed,
run,
})
)
setStoppingRun(false);
}, [stoppingRun, localStreaming, runQueryFailed, run]);
// The run's incrementally-persisted assistant message to merge into the thread,
// but only while we are an observer (never when we are the streamer — guards
// against a stale poll fighting the live stream). Includes a terminal run so the
// final persisted output is shown on reopen.
const observedRow =
shouldObserveRun(run, localStreaming) && !stoppingRun
? (runData?.message ?? null)
: null;
// When the observed run reaches a terminal status, do a final messages refetch
// so the persisted final state (token/context badge, export source) is shown,
// then the query's refetchInterval has already stopped polling. Deduped per run
// id so it fires exactly once per run, not on every subsequent poll-less render.
const finalizedRunIdRef = useRef<string | null>(null);
useEffect(() => {
if (!run || !activeChatId) return;
if (run.status === "pending" || run.status === "running") {
// Active again (a new run) — re-arm so its terminal transition fires once.
finalizedRunIdRef.current = null;
return;
}
// Terminal: a stop we requested has landed (or the run finished on its own),
// so release the stopping latch — the observer merge can now show the final
// persisted (aborted/finished) output without any live re-stream. The decision
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
// `run` null (this branch's `if (!run) return` above holds) until the current
// turn's run is fetched fresh, so the latch can never clear against a stale
// cached run.
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
setStoppingRun(false);
if (finalizedRunIdRef.current === run.id) return;
finalizedRunIdRef.current = run.id;
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
});
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
// The page the user is currently viewing. AiChatWindow lives in a pathless
// parent layout route, so useParams() can't see :pageSlug. Match the full
// pathname against the authenticated page route instead so "the current page"
@@ -343,27 +403,6 @@ export default function AiChatWindow() {
? { id: openPageData.id, title: openPageData.title }
: null;
// Live editor handles for the selection snapshot (#388). Both are published by
// the page editor; the read-only editor is used in read mode. Reading the
// selection off `editor.state` stays valid after the editor blurs (ProseMirror
// keeps state.selection), mirroring the comment button (comment-dialog.tsx).
const pageEditor = useAtomValue(pageEditorAtom);
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
// Snapshot the user's current editor selection at send time. Edit-mode editor
// wins; the read-only editor is the fallback (read mode). Null when neither
// holds a non-empty selection. Passed to <ChatThread>, which reads it live
// from a ref inside prepareSendMessagesRequest — so each turn ships a fresh
// snapshot and multi-turn works without recreating the transport.
const getEditorSelection = useCallback((): EditorSelectionContext | null => {
for (const editor of [pageEditor, readOnlyEditor]) {
if (!editor || editor.isDestroyed) continue;
const sel = getEditorSelectionContext(editor.state);
if (sel) return sel;
}
return null;
}, [pageEditor, readOnlyEditor]);
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
// paths, the history-loaded latch, the render-phase reconciler) lives in this
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
@@ -986,9 +1025,6 @@ export default function AiChatWindow() {
chatId={activeChatId}
initialRows={activeChatId ? messageRows : []}
openPage={openPage}
// #388: live snapshotter for the user's editor selection, read at
// send time and nested inside openPage on the wire.
getEditorSelection={getEditorSelection}
// Honoured only for a new chat; null = universal assistant.
roleId={activeChatId === null ? selectedRoleId : null}
// Role cards are the new-chat empty-state; offered only when this
@@ -998,13 +1034,16 @@ export default function AiChatWindow() {
assistantName={currentRole?.name}
onTurnFinished={onTurnFinished}
onServerChatId={onServerChatId}
// #184 phase 1.5: arm/disarm the degraded-poll fallback when a
// resume attempt could not attach to the live run; the thread
// disarms it on settle / local stream.
onResumeFallback={onResumeFallback}
// #184: live-follow a still-running run when we reopened the chat as
// a passive observer; null when there is nothing to observe or this
// tab is the streamer. onStreamingChange lets the window stop polling
// while we are the streamer.
observedRow={observedRow}
onStreamingChange={onStreamingChange}
// #184: in autonomous mode the Stop button must hit the authoritative
// server stop (a local SSE abort is a client disconnect the server
// ignores).
// ignores). onServerStop also arms the "stopping" latch above so the
// stopped run's output does not re-stream via the observer merge.
autonomousRunsEnabled={autonomousRunsEnabled}
onServerStop={handleServerStop}
/>
@@ -1,13 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
render,
screen,
fireEvent,
act,
cleanup,
} from "@testing-library/react";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
// above the imports) can expose the captured useChat callbacks / transport and
@@ -19,61 +12,50 @@ const h = vi.hoisted(() => ({
sendMessage: vi.fn(),
stop: vi.fn(),
setMessages: vi.fn(),
resumeStream: vi.fn(),
// The messages array useChat was seeded with (to assert strip/seed behavior).
seededMessages: null as null | unknown[],
transport: null as null | {
prepareSendMessagesRequest?: (arg: {
prepareSendMessagesRequest: (arg: {
messages: unknown[];
body: Record<string, unknown>;
}) => { body: Record<string, unknown> };
prepareReconnectToStreamRequest?: () => { api?: string };
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
},
},
}));
// Mock useChat: capture onFinish + seeded messages, return the spies and the
// controllable status.
// Mock useChat: capture onFinish, return the spies and the controllable status.
vi.mock("@ai-sdk/react", () => ({
useChat: (opts: {
messages?: unknown[];
onFinish?: (arg: Record<string, unknown>) => void;
}) => {
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
h.state.onFinish = opts.onFinish ?? null;
h.state.seededMessages = opts.messages ?? null;
return {
messages: [],
sendMessage: h.state.sendMessage,
status: h.state.status,
stop: h.state.stop,
error: null,
// #184: ChatThread reads setMessages to merge a polled observer run.
setMessages: h.state.setMessages,
resumeStream: h.state.resumeStream,
};
},
}));
// Mock "ai": deterministic ids + a transport that records its options so the test
// can invoke prepareSendMessagesRequest / prepareReconnectToStreamRequest / fetch.
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
vi.mock("ai", () => {
let counter = 0;
return {
generateId: () => `gid-${counter++}`,
DefaultChatTransport: class {
constructor(opts: Record<string, unknown>) {
h.state.transport = opts as never;
constructor(opts: {
prepareSendMessagesRequest: (arg: {
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
// composer). The ChatInput stub exposes a button that queues a message, the only
// interaction this test needs to populate the queue while "streaming".
@@ -81,90 +63,49 @@ vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
default: () => <div data-testid="message-list" />,
}));
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
default: ({
onQueue,
onStop,
}: {
onQueue: (text: string) => void;
onStop: () => void;
}) => (
<>
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
queue
</button>
<button aria-label="Stop" onClick={() => onStop()}>
stop
</button>
</>
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
queue
</button>
),
}));
import ChatThread from "./chat-thread";
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
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;
}) {
function renderThread() {
const onTurnFinished = vi.fn();
const onResumeFallback = vi.fn();
const onServerStop = vi.fn();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { unmount } = render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<ChatThread
chatId={props?.chatId === undefined ? "c1" : props.chatId}
initialRows={props?.initialRows ?? []}
autonomousRunsEnabled={props?.autonomousRunsEnabled}
onTurnFinished={onTurnFinished}
onResumeFallback={onResumeFallback}
onServerStop={onServerStop}
/>
</MantineProvider>
</QueryClientProvider>,
render(
<MantineProvider>
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
</MantineProvider>,
);
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();
return { onTurnFinished };
}
describe("ChatThread — send now (#198)", () => {
beforeEach(resetState);
beforeEach(() => {
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", () => {
renderThread();
// Queue a message while the turn is streaming.
fireEvent.click(screen.getByTestId("queue-btn"));
const sendNowBtn = screen.getByLabelText("Send now");
expect(sendNowBtn).toBeTruthy();
// "Send now" interrupts the current turn (stop), but does NOT send yet —
// the resend happens once the abort lands in onFinish.
fireEvent.click(sendNowBtn);
expect(h.state.stop).toHaveBeenCalledTimes(1);
expect(h.state.sendMessage).not.toHaveBeenCalled();
// The abort we triggered reaches onFinish: the promoted head is flushed.
act(() => {
h.state.onFinish?.({
message: { id: "a", role: "assistant", parts: [] },
@@ -181,8 +122,10 @@ describe("ChatThread — send now (#198)", () => {
fireEvent.click(screen.getByTestId("queue-btn"));
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);
// ...and only that one (the flag is read-and-cleared).
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
@@ -193,92 +136,42 @@ describe("ChatThread — send now (#198)", () => {
fireEvent.click(screen.getByTestId("queue-btn"));
fireEvent.click(screen.getByLabelText("Send now"));
// No turn to interrupt: sent straight away, no abort, not flagged.
expect(h.state.stop).not.toHaveBeenCalled();
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
const prep = h.state.transport!.prepareSendMessagesRequest!;
const prep = h.state.transport!.prepareSendMessagesRequest;
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
});
});
// #388: the editor selection is snapshotted at send time and nested inside
// openPage on the wire. The getter is read live from a ref, so each send ships a
// fresh snapshot.
describe("ChatThread — editor selection wiring (#388)", () => {
beforeEach(resetState);
afterEach(cleanup);
function renderWithSelection(props: {
openPage?: { id: string; title: string } | null;
getEditorSelection?: () => unknown;
}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<MantineProvider>
<ChatThread
chatId="c1"
initialRows={[]}
openPage={props.openPage as never}
getEditorSelection={props.getEditorSelection as never}
onTurnFinished={vi.fn()}
onResumeFallback={vi.fn()}
onServerStop={vi.fn()}
/>
</MantineProvider>
</QueryClientProvider>,
);
}
it("nests the snapshot from the getter into openPage.selection at send time", () => {
const selection = { text: "fix this", blockIds: ["b1"], before: "a " };
renderWithSelection({
openPage: { id: "p1", title: "Doc" },
getEditorSelection: () => selection,
});
const prep = h.state.transport!.prepareSendMessagesRequest!;
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
string,
unknown
>;
expect(openPage).toEqual({ id: "p1", title: "Doc", selection });
});
it("sends selection: null when the getter returns null", () => {
renderWithSelection({
openPage: { id: "p1", title: "Doc" },
getEditorSelection: () => null,
});
const prep = h.state.transport!.prepareSendMessagesRequest!;
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
string,
unknown
>;
expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null });
});
it("does not send selection at all on a non-page route (openPage null)", () => {
const getter = vi.fn(() => ({ text: "sel" }));
renderWithSelection({ openPage: null, getEditorSelection: getter });
const prep = h.state.transport!.prepareSendMessagesRequest!;
expect(prep({ messages: [], body: {} }).body.openPage).toBeNull();
// The getter must not even be consulted when there is no page.
expect(getter).not.toHaveBeenCalled();
});
});
// The turn-end decision lives in the `onFinish` handler: given the terminal
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
// it decides whether to CONTINUE (flush the next queued message) or END (leave
// the queue intact for the user), and which stop notice — if any — to show.
// `sendNow` is exercised above; these tests pin down the plain outcomes.
describe("ChatThread — turn-end decision (onFinish)", () => {
beforeEach(resetState);
beforeEach(() => {
h.state.status = "streaming";
h.state.onFinish = null;
h.state.sendMessage.mockClear();
h.state.stop.mockClear();
h.state.transport = null;
});
// Drive a fresh onFinish with the given terminal flags after queueing a
// message, and report both what the parent was told and whether the queue was
// flushed (a resend to the sendMessage spy).
function finishWith(flags: {
isAbort?: boolean;
isDisconnect?: 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();
h.state.sendMessage.mockClear();
const { onTurnFinished } = renderThread();
// Populate the queue while the turn is streaming.
fireEvent.click(screen.getByTestId("queue-btn"));
act(() => {
h.state.onFinish?.({
@@ -294,12 +187,16 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
it("CONTINUES — flushes the next queued message on a clean finish", () => {
finishWith({});
// Clean finish (no terminal flag): the queued message is auto-sent.
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
// A clean finish shows no stop notice.
expect(screen.queryByText("Response stopped.")).toBeNull();
});
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
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(screen.getByText("Response stopped.")).toBeTruthy();
});
@@ -314,11 +211,15 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
finishWith({ isError: true });
// Blindly retrying after a failure would be wrong; the queue is left alone.
expect(h.state.sendMessage).not.toHaveBeenCalled();
// isError clears the neutral notice (the error banner covers this case).
expect(screen.queryByText("Response stopped.")).toBeNull();
});
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 [
{},
{ isAbort: true },
@@ -331,578 +232,55 @@ 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")];
// #184 passive-observer merge: when reconnecting to a still-running run, the
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
// SSE owns the view, so a stale observedRow must never overwrite it).
describe("ChatThread — observer run merge (#184)", () => {
beforeEach(() => {
h.state.onFinish = null;
h.state.setMessages.mockReset();
});
const visibleMsg = {
id: "a1",
const observedRow = {
id: "a-run",
role: "assistant",
parts: [{ type: "text", text: "streamed answer" }],
};
const emptyMsg = { id: "a1", role: "assistant", parts: [] };
content: "step 1\nstep 2",
metadata: {
parts: [{ type: "text", text: "step 1\nstep 2" }],
},
createdAt: "2026-01-01T00:00:00Z",
} as const;
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}>
function renderObserver(status: string) {
h.state.status = status;
render(
<MantineProvider>
<ChatThread
chatId="c1"
initialRows={rows}
autonomousRunsEnabled
initialRows={[]}
onTurnFinished={vi.fn()}
onResumeFallback={onResumeFallback}
observedRow={observedRow as never}
/>
</MantineProvider>
</QueryClientProvider>
);
const view = render(<Wrapper rows={initialRows} />);
const rerender = (rows: IAiChatMessageRow[]) =>
act(() => view.rerender(<Wrapper rows={rows} />));
return { rerender, onResumeFallback };
}
// #430: auto-reconnect to a DETACHED run after a LIVE SSE disconnect. The mount
// path only resumes on mount/reload; these cover the missing trigger — a live
// `isDisconnect` on onFinish must (backoff-)re-attach WITHOUT a reload, pin+strip
// the live row to avoid duplicates, fall back to the degraded poll on a 204, and
// exhaust to a manual Retry.
describe("ChatThread — live reconnect after isDisconnect (#430)", () => {
// A LIVE local turn that just dropped: the settled tail existed before, and the
// partial assistant row lives only in `messages` (not persisted as a tail).
const settledTail = () => [
row("u1", "user", undefined, "hi"),
row("a1", "assistant", "succeeded", "done"),
];
// The partial assistant message onFinish hands us for the dropped LIVE turn.
const liveMsg = {
id: "a2",
role: "assistant",
parts: [{ type: "text", text: "partial live answer" }],
};
beforeEach(() => {
resetState();
// status "ready": with a live disconnect the mock is not streaming, so the
// status==="streaming" auto-clear effect stays out of the way.
h.state.status = "ready";
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
cleanup();
});
// Render a NON-resuming mount (settled tail -> no mount resume) with autonomous
// runs on, then simulate a live disconnect via onFinish.
function renderLiveThenDisconnect() {
const view = renderThread({
autonomousRunsEnabled: true,
initialRows: settledTail(),
});
// The settled tail must NOT have triggered a mount resume.
expect(h.state.resumeStream).not.toHaveBeenCalled();
act(() => {
h.state.onFinish?.({
message: liveMsg,
isAbort: false,
isDisconnect: true,
isError: false,
});
});
return view;
}
// Fire the pending (scheduled) attempt for `attempt` (backoff = 1s,2s,4s,...).
function advanceToAttempt(attempt: number) {
act(() => {
vi.advanceTimersByTime(1000 * 2 ** (attempt - 1));
});
}
// Simulate the reconnect GET returning 204 (nothing live) so the transport's
// no-active-stream recovery runs.
async function reconnect204() {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ status: 204, ok: false }),
</MantineProvider>,
);
await act(async () => {
await h.state.transport!.fetch!("http://x", { method: "GET" });
});
}
// Simulate the reconnect GET returning a live 2xx stream.
async function reconnect200() {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({ status: 200, ok: true }),
);
await act(async () => {
await h.state.transport!.fetch!("http://x", { method: "GET" });
});
}
it("calls resumeStream POST-mount (a live disconnect triggers a backoff reconnect)", () => {
renderLiveThenDisconnect();
// The banner shows immediately; the attach itself fires after the first backoff.
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
expect(h.state.resumeStream).not.toHaveBeenCalled();
advanceToAttempt(1);
// resumeStream is now called AFTER mount — the bug was it only ever fired once
// on mount. The reconnect URL pins expect=live&anchor to OUR run.
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a2",
);
it("merges the polled run message when this tab is a passive observer", () => {
renderObserver("ready");
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
// The updater replaces/append the observed assistant row by id.
const updater = h.state.setMessages.mock.calls[0][0] as (
prev: { id: string; parts: { text: string }[] }[],
) => { id: string; parts: { text: string }[] }[];
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
expect(merged).toHaveLength(2);
expect(merged[1].id).toBe("a-run");
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
});
it("strips the pinned live row before replay so content is NOT duplicated", () => {
renderLiveThenDisconnect();
advanceToAttempt(1);
// The attempt strips the anchor row from the store (the live replay rebuilds
// it). Apply the setMessages updater to prove it removes exactly the anchor.
const updater = h.state.setMessages.mock.calls.at(-1)![0] as (
prev: { id: string }[],
) => { id: string }[];
expect(updater([{ id: "u1" }, { id: "a2" }])).toEqual([{ id: "u1" }]);
});
it("a live re-attach (2xx) clears the reconnect banner", async () => {
renderLiveThenDisconnect();
advanceToAttempt(1);
await reconnect200();
expect(screen.queryByText(/reconnecting/i)).toBeNull();
});
it("a 204 arms the degraded poll and backs off to the next attempt", async () => {
const { onResumeFallback } = renderLiveThenDisconnect();
advanceToAttempt(1);
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
await reconnect204();
// Fallback engaged: the degraded poll is armed (204 -> onNoActiveStream).
expect(onResumeFallback).toHaveBeenCalledWith(true);
// Still reconnecting — the banner advanced to attempt 2/5.
expect(screen.getByText(/reconnecting.*2\/5/i)).toBeTruthy();
// The next backoff fires attempt 2 (another resumeStream).
advanceToAttempt(2);
expect(h.state.resumeStream).toHaveBeenCalledTimes(2);
});
it("exhausts the attempt limit into a manual Retry, which restarts the sequence", async () => {
renderLiveThenDisconnect();
// Drive all 5 attempts, each failing with a 204.
for (let n = 1; n <= 5; n++) {
advanceToAttempt(n);
expect(h.state.resumeStream).toHaveBeenCalledTimes(n);
await reconnect204();
}
// The 5th 204 exhausted the cap -> the manual Retry replaces the banner.
expect(screen.queryByText(/reconnecting/i)).toBeNull();
const retry = screen.getByText("Retry");
expect(retry).toBeTruthy();
// Retry fires attempt 1 immediately (no backoff) — a 6th resumeStream.
act(() => {
fireEvent.click(retry);
});
expect(h.state.resumeStream).toHaveBeenCalledTimes(6);
expect(screen.getByText(/reconnecting/i)).toBeTruthy();
});
it("does NOT reconnect when autonomous runs are disabled", () => {
renderThread({ autonomousRunsEnabled: false, initialRows: settledTail() });
act(() => {
h.state.onFinish?.({
message: liveMsg,
isAbort: false,
isDisconnect: true,
isError: false,
});
});
expect(screen.queryByText(/reconnecting/i)).toBeNull();
// The terminal "connection lost" notice is shown instead (unchanged behavior).
expect(
screen.getByText("Connection lost — the answer was interrupted."),
).toBeTruthy();
advanceToAttempt(1);
expect(h.state.resumeStream).not.toHaveBeenCalled();
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
renderObserver("streaming");
expect(h.state.setMessages).not.toHaveBeenCalled();
});
});
@@ -1,17 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { generateId } from "ai";
import {
ActionIcon,
Alert,
Box,
Button,
Group,
Loader,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
import {
IconClockHour4,
IconPlayerPlayFilled,
@@ -35,15 +24,7 @@ import {
} from "@/features/ai-chat/utils/role-launch.ts";
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
import {
isStreamingTail,
isSettledAssistantTail,
seedRows,
mergeById,
} from "@/features/ai-chat/utils/resume-helpers.ts";
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
import {
dequeue,
enqueueMessage,
@@ -61,15 +42,6 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css";
// from the token rate.
const STREAM_THROTTLE_MS = 50;
// #430: auto-reconnect after a LIVE SSE disconnect of a DETACHED (autonomous) run.
// The run keeps executing server-side, so instead of a dead "Lost connection"
// banner we re-attach to the live tail through the SAME resumable machinery the
// mount path uses. Attempts back off exponentially and are capped; on exhaustion
// the user gets a manual Retry (the degraded poll keeps catching up underneath).
const RECONNECT_MAX_ATTEMPTS = 5;
// Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s.
const RECONNECT_BASE_DELAY_MS = 1000;
/** The page the user is currently viewing, sent as chat context. */
export interface OpenPageContext {
id: string;
@@ -89,10 +61,6 @@ interface ChatThreadProps {
/** The page currently open in the workspace, or null on a non-page route.
* Sent with each turn so the agent knows what "this page" refers to. */
openPage?: OpenPageContext | null;
/** #388: snapshot the user's current editor selection at SEND time. Invoked
* inside prepareSendMessagesRequest and nested into openPage on the wire, so a
* fresh snapshot ships each turn. Null/absent => nothing selected. */
getEditorSelection?: () => EditorSelectionContext | null;
/** The agent role selected for a NEW chat (null = universal assistant). Sent
* in the request body so the server persists it on chat creation; ignored by
* the server for existing chats (the role is read from the chat row). */
@@ -119,13 +87,19 @@ interface ChatThreadProps {
* Copy/export button available mid-stream). Distinct from onTurnFinished,
* which fires only at the terminal outcome. */
onServerChatId?: (serverChatId?: string) => void;
/** #184 phase 1.5: arm/disarm the parent's degraded-poll fallback for THIS
* chat's window. Called `true` when a resume attempt could not attach to the
* live run (attach 204 / starved-or-torn resumed finish), so the window starts
* a dumb timed poll of the message history to follow the detached run to settle;
* called `false` the moment a local stream starts or the terminal settled row is
* merged (invariant 8). The window owns the timer + its 10-min cap. */
onResumeFallback?: (active: boolean) => void;
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
* the parent polls the reconnect endpoint and feeds the run's incrementally-
* persisted assistant message here; we merge it into the live list so new
* steps/tool-calls appear as they are persisted. Null when there is nothing to
* observe (no run, feature off, or this tab IS the streamer). The merge is
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
* fight the local stream when we are the streamer. */
observedRow?: IAiChatMessageRow | null;
/** Report this tab's live streaming status up to the parent, so it can stop
* polling the run while WE are the active streamer (the SSE owns the view) and
* resume once we go idle. Called from an effect on every transition. */
onStreamingChange?: (streaming: 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,
@@ -175,69 +149,21 @@ export default function ChatThread({
threadKey,
initialRows,
openPage,
getEditorSelection,
roleId,
roles,
onRolePicked,
assistantName,
onTurnFinished,
onServerChatId,
onResumeFallback,
observedRow,
onStreamingChange,
autonomousRunsEnabled,
onServerStop,
}: ChatThreadProps) {
const { t } = useTranslation();
const queryClient = useQueryClient();
// resume machinery refs (#184 phase 1.5)
const attachAbortRef = useRef<AbortController | null>(null);
const reconcileTailRef = useRef(false);
const noStreamHandledRef = useRef(false);
const onNoActiveStreamRef = useRef<(() => void) | null>(null);
// #430: called from the transport's reconnect-GET success branch when a live
// stream re-attached (2xx, not 204) — clears the reconnect banner. Kept in a ref
// because the transport's fetch closure (useMemo([])) reads it live.
const onReconnectAttachedRef = 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[]>(
() =>
seedRows(
initialRows ?? [],
attemptResumeRef.current && stripRef.current,
).map(rowToUiMessage),
() => (initialRows ?? []).map(rowToUiMessage),
[initialRows],
);
@@ -255,14 +181,6 @@ export default function ChatThread({
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
openPageRef.current = openPage ?? null;
// Keep the selection snapshotter in a ref, same rationale as openPageRef: the
// transport useMemo([]) closes it over, so prop-identity churn must not matter.
// Called at send time inside prepareSendMessagesRequest (#388).
const getEditorSelectionRef = useRef<
(() => EditorSelectionContext | null) | undefined
>(getEditorSelection);
getEditorSelectionRef.current = getEditorSelection;
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
// FIRST request of a brand-new chat uses it (the server persists it then and
// ignores it for existing chats), but sending it on every send is harmless.
@@ -343,12 +261,9 @@ export default function ChatThread({
const { head, rest } = dequeue(queuedRef.current);
if (!head) return false;
setQueue(rest);
// Local send: clear any resume-suppression flag so this genuine local turn's
// onFinish flushes normally (invariant 8).
setResumedTurnPair(false);
sendMessageRef.current?.({ text: head.text });
return true;
}, [setQueue, setResumedTurnPair]);
}, [setQueue]);
const enqueue = useCallback(
(text: string) => {
@@ -368,51 +283,6 @@ export default function ChatThread({
new DefaultChatTransport<UIMessage>({
api: "/api/ai-chat/stream",
credentials: "include",
prepareReconnectToStreamRequest: () => ({
// SDK default URL uses the useChat STORE id — always build from the real chat id.
// ?expect=live&anchor=<row id> ONLY when we stripped a streaming tail: expect=live
// is the only case where a finished-retained replay is safe (the row is stripped,
// replay rebuilds it), and the anchor pins the replay to OUR run — a mismatching
// (newer) run must 204 into the restore+poll path instead of replaying a foreign
// transcript into this store.
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
stripRef.current
? `?expect=live&anchor=${strippedRowRef.current!.id}`
: ""
}`,
}),
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
// for observer Stop / unmount abort.
const controller = new AbortController();
attachAbortRef.current = controller;
try {
const response = await fetch(input, {
...init,
signal: controller.signal,
});
// No onFinish will come for a 204 (silent no-op) OR any non-2xx
// (5xx/502 — a server restart mid-attach). Both run the same
// no-active-stream recovery: restore the stripped row, invalidate, and
// arm the degraded poll (idempotent via noStreamHandledRef; its part-d
// also clears the resumedTurn flag). This is the restart-survival path
// the removed F7 latch used to guard — a transient attach failure must
// NOT drop the in-progress row or stop tracking the durable run.
if (response.status === 204 || !response.ok)
onNoActiveStreamRef.current?.();
// #430: a 2xx stream re-attached (live tail or finished-replay). Signal
// the reconnect controller to clear its banner. No-op outside an active
// reconnect sequence (e.g. the mount attach), so it is safe here.
else onReconnectAttachedRef.current?.();
return response;
} catch (err) {
// Network throw: same no-onFinish recovery, then rethrow so the SDK
// still surfaces the error to its own machinery.
onNoActiveStreamRef.current?.();
throw err;
}
},
// Inject the chat id and the currently-open page alongside the useChat
// messages so the server can resolve an existing chat (or create one
// when null) and tell the agent which page "this page" refers to. Both
@@ -429,16 +299,7 @@ export default function ChatThread({
body: {
...body,
chatId: chatIdRef.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,
openPage: openPageRef.current,
// Honoured by the server only when creating a new chat; null =>
// universal assistant.
roleId: roleIdRef.current,
@@ -451,15 +312,7 @@ export default function ChatThread({
[],
);
const {
messages,
sendMessage,
status,
stop,
error,
setMessages,
resumeStream,
} = useChat({
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
// Stable per-mount key. Existing chats use their real id; new chats use a
// generated client id (never `undefined`) so the store is NOT re-created on
// every render mid-stream (see `chatStoreId` above).
@@ -477,63 +330,6 @@ export default function ChatThread({
// would be wrong, so on Stop/disconnect/error the queue is left intact for
// the user to decide.
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
// (1) Capture whether THIS finish belongs to a resumed (attach) turn and
// immediately clear the flag so it can never suppress a LATER local turn.
const wasResumed = resumedTurnRef.current;
setResumedTurnPair(false);
// (2) Recovery after a starved/torn resumed finish (invariant 9). The arm
// and the stripped-row restore are gated DIFFERENTLY. Skip entirely once
// unmounted (an abort-triggered onFinish landing after a chat switch must
// not arm a poll / invalidate on the new chat).
if (wasResumed && mountedRef.current) {
const hasVisibleContent = assistantMessageHasVisibleContent(message);
// ARM the reconcile + degraded poll when the resumed message carries no
// visible content (starved replay) OR the connection dropped mid-run — in
// both cases the poll must drive the row to its real terminal state.
if (isDisconnect || !hasVisibleContent) {
reconcileTailRef.current = true;
queryClient.invalidateQueries({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
});
onResumeFallback?.(true);
}
// RESTORE the stripped streaming row ONLY when the resumed message has no
// visible content. On isDisconnect WITH visible content restore is
// FORBIDDEN: the live stream may have advanced far past the mount-time
// snapshot, so restoring would clobber on-screen content (invariant 9) —
// the arm above suffices, the poll reaches the true terminal.
if (!hasVisibleContent && strippedRowRef.current) {
setMessages((prev) =>
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
);
}
}
// (2b) #430: a LIVE (non-resumed) detached run whose SSE just dropped. The
// server run keeps executing, so instead of a dead "Lost connection" banner
// start a reconnect sequence: pin the CURRENT streaming assistant row as the
// strip/anchor (the live tail is the already-shown partial in `messages`, not
// a persistent row) and re-attach to the live tail via the resumable machinery.
const startedReconnect =
isDisconnect &&
!wasResumed &&
autonomousRunsEnabled === true &&
mountedRef.current &&
message?.role === "assistant" &&
typeof message.id === "string";
if (startedReconnect) {
beginReconnect({
id: message.id,
role: "assistant",
content: "",
status: "streaming",
createdAt: new Date().toISOString(),
// Preserve the partial parts so a 204 restore (onNoActiveStream) re-shows
// what was on screen while the degraded poll catches the run up to
// terminal (rowToUiMessage prefers metadata.parts).
metadata: { parts: message.parts },
});
}
// (3) Standard branches.
// Forward the authoritative server chatId (streamed on the assistant
// message metadata) so the parent adopts the REAL created chat id for a new
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
@@ -542,16 +338,10 @@ export default function ChatThread({
onTurnFinished(extractServerChatId(message), threadKey);
// Show a neutral "stopped" marker for an aborted turn; the red error banner
// (via `error`) already covers isError, and a clean finish clears any marker.
// On a live disconnect that STARTED a reconnect, suppress the terminal
// "connection lost" notice — the reconnect banner takes over (#430).
if (isError) setStopNotice(null);
else if (isAbort) setStopNotice("manual");
else if (isDisconnect) setStopNotice(startedReconnect ? null : "disconnect");
else if (isDisconnect) setStopNotice("disconnect");
else setStopNotice(null);
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
// flush-on-abort branch and the plain flush. The local streamer is the only
// tab that owns the queue.
if (wasResumed) return;
// "Send now": WE triggered this abort to interrupt the current turn and
// immediately send the promoted head. Flush it even though the turn was
// aborted (the normal abort path below keeps the queue intact). The
@@ -633,226 +423,26 @@ export default function ChatThread({
const isStreaming = status === "submitted" || status === "streaming";
// #430: live-disconnect reconnect controller. `null` = idle; `{ trying, attempt }`
// = a backoff sequence is running (drives the "reconnecting… (N/max)" banner);
// `{ failed }` = attempts exhausted (drives the manual Retry). Mirrored into a ref
// so the transport/onNoActiveStream closures branch on the LIVE value.
type ReconnectState =
| null
| { phase: "trying"; attempt: number }
| { phase: "failed" };
const [reconnectState, setReconnectState] = useState<ReconnectState>(null);
const reconnectStateRef = useRef<ReconnectState>(null);
const setReconnectStatePair = useCallback((s: ReconnectState) => {
reconnectStateRef.current = s;
setReconnectState(s);
}, []);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearReconnectTimer = useCallback(() => {
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
}, []);
// One reconnect attempt — MIRRORS the mount strip/anchor path for the LIVE case.
// beginReconnect pinned strippedRowRef/stripRef to the run's assistant row, so:
// - remove that row from the store (the mount path strips it from the SEED; here
// it is already shown, so filter it out) — the live replay's `text-start` then
// rebuilds it without DUPLICATING parts (the main dedup risk, #430);
// - reset the one-shot 204 guard so onNoActiveStream can fire for THIS attempt;
// - mark the turn resumed (invariant 7/8) so onFinish runs the recovery block and
// never flushes the queue;
// - resumeStream() -> prepareReconnectToStreamRequest builds
// ?expect=live&anchor=<pinned id>, pinning the replay to OUR run (invariant 6).
const attemptReconnectOnce = useCallback(
(attempt: number) => {
if (!mountedRef.current) return;
const anchor = strippedRowRef.current;
if (anchor) {
setMessages((prev) => prev.filter((m) => m.id !== anchor.id));
}
noStreamHandledRef.current = false;
setResumedTurnPair(true);
setReconnectStatePair({ phase: "trying", attempt });
void resumeStream();
},
[setMessages, setResumedTurnPair, setReconnectStatePair, resumeStream],
);
// Schedule attempt `attempt` after an exponential backoff.
const scheduleReconnectAttempt = useCallback(
(attempt: number) => {
clearReconnectTimer();
setReconnectStatePair({ phase: "trying", attempt });
reconnectTimerRef.current = setTimeout(
() => attemptReconnectOnce(attempt),
RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1),
);
},
[clearReconnectTimer, setReconnectStatePair, attemptReconnectOnce],
);
// Start a fresh reconnect sequence, pinning `anchorRow` (the live run's assistant
// row) as the strip/anchor reused by every attempt.
const beginReconnect = useCallback(
(anchorRow: IAiChatMessageRow) => {
if (!autonomousRunsEnabled || !mountedRef.current) return;
strippedRowRef.current = anchorRow;
stripRef.current = true;
scheduleReconnectAttempt(1);
},
[autonomousRunsEnabled, scheduleReconnectAttempt],
);
// Manual Retry (shown once attempts are exhausted): restart at attempt 1 and fire
// immediately (the user asked for it now — no backoff).
const retryReconnect = useCallback(() => {
clearReconnectTimer();
attemptReconnectOnce(1);
}, [clearReconnectTimer, attemptReconnectOnce]);
// Live SSE re-attached (the reconnect GET returned a 2xx stream): clear the
// banner + any pending backoff. No-op outside a sequence (e.g. the mount attach).
const onReconnectAttached = useCallback(() => {
if (!mountedRef.current || !reconnectStateRef.current) return;
clearReconnectTimer();
setReconnectStatePair(null);
}, [clearReconnectTimer, setReconnectStatePair]);
onReconnectAttachedRef.current = onReconnectAttached;
// The reconnect GET could not attach (204 / error). onNoActiveStream has already
// armed the degraded poll (the robust fallback that drives the row to terminal
// from the DB), so this only decides the LIVE-attach retry: back off and try
// again up to the cap, else surface the manual Retry.
const onReconnectNoStream = useCallback(() => {
const s = reconnectStateRef.current;
if (s?.phase !== "trying") return;
if (s.attempt < RECONNECT_MAX_ATTEMPTS)
scheduleReconnectAttempt(s.attempt + 1);
else setReconnectStatePair({ phase: "failed" });
}, [scheduleReconnectAttempt, setReconnectStatePair]);
// 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);
// (e) #430: if this 204/error landed during a live-disconnect reconnect
// sequence, back off and retry the live attach (or give up to the manual
// Retry). The degraded poll armed in (c) is the fallback either way.
onReconnectNoStream();
}, [
setMessages,
queryClient,
onResumeFallback,
setResumedTurnPair,
onReconnectNoStream,
]);
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.
// #184: report our live streaming status up so the parent stops polling the run
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
// Effect (not render) so it never updates parent state during our own render;
// fires on mount with `false`, which also re-syncs the parent after a chat
// switch remounts this thread (a fresh mount is idle until the user sends).
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();
// #430: drop any pending reconnect backoff so it can't fire against the next
// chat this thread's refs are reused for.
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
};
// Mount-only by design; the parent remounts per chat via `key`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
onStreamingChange?.(isStreaming);
}, [isStreaming, onStreamingChange]);
// Reconciliation + degraded-merge (invariant 8). Deps are EXACTLY
// [initialRows, isStreaming, setMessages].
// #184 passive-observer merge: when the parent feeds a polled run message (we
// reopened a chat whose run is still going and did NOT start it here), merge it
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
// owns the view and a stale observedRow must never overwrite it. `observedRow`
// is a stable per-poll object, so this runs once per poll, not per render.
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);
// #430: the run reached its terminal state via the degraded poll — there is
// no live tail left to reconnect to, so drop any reconnect banner / Retry.
clearReconnectTimer();
setReconnectStatePair(null);
}
// onResumeFallback intentionally omitted (parent-stable callback); deps are
// fixed by the resume design.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialRows, isStreaming, setMessages]);
// #430: a real stream is live again — the reconnect re-attached to the live tail
// (status -> "streaming") OR the user started a new local turn. Either way clear
// the reconnect banner + any pending backoff. Gated on "streaming" (not the
// broader "submitted") so a still-pending attach GET does not clear prematurely.
useEffect(() => {
if (status === "streaming") {
clearReconnectTimer();
setReconnectStatePair(null);
}
}, [status, clearReconnectTimer, setReconnectStatePair]);
if (isStreaming || !observedRow) return;
const observed = rowToUiMessage(observedRow);
setMessages((prev) => mergeObservedMessage(prev, observed));
}, [observedRow, isStreaming, setMessages]);
// "Send now" on a queued message: interrupt the current turn and immediately
// send THIS message, keeping the agent's partial output. Other queued messages
@@ -879,12 +469,10 @@ export default function ChatThread({
const msg = queuedRef.current.find((m) => m.id === id);
if (!msg) return;
setQueue(removeQueuedById(queuedRef.current, id));
// Local send: clear any resume-suppression flag (invariant 8).
setResumedTurnPair(false);
sendMessageRef.current?.({ text: msg.text });
}
},
[setQueue, stop, setResumedTurnPair],
[setQueue, stop],
);
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
@@ -897,13 +485,7 @@ export default function ChatThread({
// 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();
// #430: pressing Stop also cancels an in-progress reconnect sequence.
clearReconnectTimer();
setReconnectStatePair(null);
if (!autonomousRunsEnabled) return;
if (chatIdRef.current) {
onServerStop?.(chatIdRef.current);
@@ -925,13 +507,7 @@ export default function ChatThread({
// for this fix. Documented so a future change can address the abort-ordering.
stopPendingRef.current = true;
}
}, [
stop,
autonomousRunsEnabled,
onServerStop,
clearReconnectTimer,
setReconnectStatePair,
]);
}, [stop, autonomousRunsEnabled, onServerStop]);
// Clear the stopped marker as soon as a new turn begins streaming, and drop any
// stale "Send now" interrupt flags. On the legit interrupt path both refs are
@@ -1016,43 +592,6 @@ export default function ChatThread({
detail={errorView.detail}
mb="xs"
/>
) : reconnectState ? (
// #430: while auto-reconnecting to a detached run's live tail, show progress
// instead of a dead "Lost connection" banner; once attempts are exhausted,
// offer a manual Retry (the degraded poll keeps catching up underneath).
<Alert
variant="light"
color="gray"
p="xs"
mb="xs"
style={{ flexShrink: 0 }}
>
<Group gap={8} wrap="nowrap" align="center">
{reconnectState.phase === "trying" ? (
<>
<Loader size={14} color="gray" style={{ flex: "none" }} />
<Text size="sm" lh={1.3} c="dimmed">
{t("Connection lost — reconnecting…")}
{` (${reconnectState.attempt}/${RECONNECT_MAX_ATTEMPTS})`}
</Text>
</>
) : (
<>
<Text size="sm" lh={1.3} c="dimmed" style={{ flex: 1 }}>
{t("Couldn't reconnect to the answer.")}
</Text>
<Button
size="compact-xs"
variant="light"
color="gray"
onClick={retryReconnect}
>
{t("Retry")}
</Button>
</>
)}
</Group>
</Alert>
) : stopNotice ? (
<ChatStoppedNotice
text={
@@ -1078,23 +617,17 @@ export default function ChatThread({
<Text size="xs" lineClamp={2} className={classes.queuedText}>
{m.text}
</Text>
{/* "Send now" (interrupt) is hidden on a RESUMED turn: a local
stop() does not abort the resumed attach fetch, so the click
would be swallowed while flushOnAbortRef would fire minutes
later on the natural finish. Only the remove affordance stays. */}
{!resumedTurn && (
<Tooltip label={t("Interrupt and send now")} withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="blue"
onClick={() => sendNow(m.id)}
aria-label={t("Send now")}
>
<IconPlayerPlayFilled size={12} />
</ActionIcon>
</Tooltip>
)}
<Tooltip label={t("Interrupt and send now")} withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="blue"
onClick={() => sendNow(m.id)}
aria-label={t("Send now")}
>
<IconPlayerPlayFilled size={12} />
</ActionIcon>
</Tooltip>
<ActionIcon
size="xs"
variant="subtle"
@@ -1109,11 +642,7 @@ export default function ChatThread({
</Stack>
)}
<ChatInput
onSend={(text) => {
// Local send: clear any resume-suppression flag (invariant 8).
setResumedTurnPair(false);
sendMessage({ text });
}}
onSend={(text) => sendMessage({ text })}
onQueue={enqueue}
onStop={handleStop}
isStreaming={isStreaming}
@@ -40,13 +40,6 @@ interface MessageItemProps {
* Defaults to true (internal chat). The public share passes false.
*/
showCitations?: boolean;
/**
* Forwarded to ToolCallCard: whether tool cards render the one-line summary of
* a call's arguments (e.g. the search query). Defaults to true (internal
* chat). The public share passes false so an anonymous reader doesn't see the
* agent's raw query/argument text.
*/
showInput?: boolean;
/**
* Neutralize internal/relative markdown links in the rendered answer (drop
* their href so they become inert text). Defaults to false (internal chat,
@@ -124,7 +117,6 @@ const MarkdownPart = memo(function MarkdownPart({
function MessageItem({
message,
showCitations = true,
showInput = true,
neutralizeInternalLinks = false,
assistantName,
turnStreaming = false,
@@ -218,7 +210,6 @@ function MessageItem({
key={index}
part={part as unknown as ToolUiPart}
showCitations={showCitations}
showInput={showInput}
/>
);
}
@@ -283,7 +274,6 @@ export function arePropsEqual(
return (
prev.signature === next.signature &&
prev.showCitations === next.showCitations &&
prev.showInput === next.showInput &&
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
prev.assistantName === next.assistantName &&
// The turn-end flip re-renders every row once (cheap, terminal event) —
@@ -25,13 +25,6 @@ interface MessageListProps {
* false because an anonymous reader cannot open the linked internal pages.
*/
showCitations?: boolean;
/**
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render the
* one-line summary of a call's arguments (e.g. the search query). Defaults to
* true (internal chat). The public share passes false so an anonymous reader
* doesn't see the agent's raw query/argument text.
*/
showInput?: boolean;
/**
* Forwarded to MessageItem: neutralize internal/relative markdown links in
* the rendered answers (drop their href so they render as inert text).
@@ -126,7 +119,6 @@ export default function MessageList({
isStreaming,
emptyState,
showCitations = true,
showInput = true,
neutralizeInternalLinks = false,
assistantName,
}: MessageListProps) {
@@ -216,7 +208,6 @@ export default function MessageList({
message={message}
signature={messageSignature(message)}
showCitations={showCitations}
showInput={showInput}
neutralizeInternalLinks={neutralizeInternalLinks}
assistantName={assistantName}
// Turn-level liveness, gated to the TAIL row: only the tail message
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
import {
getToolName,
toolCitations,
toolInputSummary,
toolLabelKey,
toolRunState,
ToolUiPart,
@@ -22,14 +21,6 @@ interface ToolCallCardProps {
* (the action log itself) while dropping the unusable links.
*/
showCitations?: boolean;
/**
* Whether to render the one-line summary of the call's arguments (e.g. the
* search query) under the label. Defaults to true (the internal chat). The
* public share passes false: an anonymous reader should not see the agent's
* raw query/argument text. Conservative and reversible — it only suppresses
* the extra summary line, leaving the card (the action log) intact.
*/
showInput?: boolean;
}
/**
@@ -40,14 +31,12 @@ interface ToolCallCardProps {
export default function ToolCallCard({
part,
showCitations = true,
showInput = true,
}: ToolCallCardProps) {
const { t } = useTranslation();
const toolName = getToolName(part);
const state = toolRunState(part.state);
const { key, values } = toolLabelKey(toolName);
const citations = showCitations ? toolCitations(part) : [];
const inputSummary = showInput ? toolInputSummary(part) : undefined;
return (
<div className={classes.toolCard}>
@@ -68,12 +57,6 @@ export default function ToolCallCard({
</Text>
</Group>
{inputSummary && (
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
{inputSummary}
</Text>
)}
{state === "error" && part.errorText && (
<Text size="xs" c="red" mt={2}>
{part.errorText}
@@ -1,90 +0,0 @@
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),
);
});
});
@@ -13,6 +13,7 @@ import {
deleteAiChat,
deleteAiRole,
getAiChatMessages,
getAiChatRun,
getAiChats,
getAiRoleCatalog,
getAiRoleCatalogBundle,
@@ -25,6 +26,7 @@ import {
import {
IAiChat,
IAiChatMessageRow,
IAiChatRunResponse,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
@@ -35,6 +37,7 @@ import {
IAiRoleUpdateFromCatalogResult,
} from "@/features/ai-chat/types/ai-chat.types.ts";
import { IPagination } from "@/lib/types.ts";
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
export const AI_CHATS_RQ_KEY = ["ai-chats"];
export const AI_ROLES_RQ_KEY = ["ai-roles"];
@@ -52,6 +55,7 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
"ai-chat-messages",
chatId,
];
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
/** Paginated list of the current user's chats (auto-loads further pages). */
export function useAiChatsQuery() {
@@ -85,15 +89,7 @@ export function useAiChatsQuery() {
* Load all persisted messages of a chat (oldest first), flattening the
* paginated server response. Used to seed `useChat` initial messages.
*/
export function useAiChatMessagesQuery(
chatId: string | undefined,
// #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),
) {
export function useAiChatMessagesQuery(chatId: string | undefined) {
const query = useInfiniteQuery({
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
queryFn: ({ pageParam }) =>
@@ -104,7 +100,6 @@ export function useAiChatMessagesQuery(
? (lastPage.meta.nextCursor ?? undefined)
: undefined,
enabled: !!chatId,
refetchInterval,
});
// useInfiniteQuery only fetches the first page on its own. The hook's contract
@@ -144,6 +139,34 @@ export function useAiChatMessagesQuery(
};
}
/**
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
* is active the query re-polls every {@link runPollInterval} ms (driven off the
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
* embeddings reindex polling); once the run reaches a terminal status — or there
* is no run — the interval returns `false` and polling stops on its own. Polling
* is thus naturally bounded by the run terminating; no separate timeout cap.
*
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
* so we must not also poll/merge). The global `retry: false` means a failed fetch
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
* failed fetch can never spin a tight loop.
*/
export function useAiChatRunQuery(
chatId: string | undefined,
enabled: boolean,
) {
return useQuery<IAiChatRunResponse, Error>({
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
queryFn: () => getAiChatRun(chatId as string),
enabled: !!chatId && enabled,
refetchInterval: (query) => runPollInterval(query.state.data?.run),
});
}
export function useRenameAiChatMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -0,0 +1,92 @@
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";
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
// use it); stub it so the module imports cleanly in this hook test.
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@mantine/notifications", () => ({
notifications: { show: vi.fn() },
}));
// Mock the whole service module; only getAiChatRun is exercised here, but the
// other named exports must exist so ai-chat-query.ts imports resolve.
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
getAiChatRun: vi.fn(),
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 { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
const runningResponse: IAiChatRunResponse = {
run: { id: "run-1", chatId: "c1", status: "running" },
message: {
id: "a1",
role: "assistant",
content: "working...",
createdAt: "2026-01-01T00:00:00Z",
},
};
describe("useAiChatRunQuery — enable gating", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("fetches the run when enabled (passive observer, feature on)", async () => {
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(getAiChatRun).toHaveBeenCalledWith("c1");
expect(result.current.data?.run?.status).toBe("running");
});
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
renderHook(() => useAiChatRunQuery("c1", false), {
wrapper: createWrapper(),
});
// Give any errant fetch a chance to fire, then assert none did.
await new Promise((r) => setTimeout(r, 20));
expect(getAiChatRun).not.toHaveBeenCalled();
});
it("does NOT fetch when there is no chat id", async () => {
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
renderHook(() => useAiChatRunQuery(undefined, true), {
wrapper: createWrapper(),
});
await new Promise((r) => setTimeout(r, 20));
expect(getAiChatRun).not.toHaveBeenCalled();
});
});
@@ -5,6 +5,7 @@ import {
IAiChatListParams,
IAiChatMessageRow,
IAiChatMessagesParams,
IAiChatRunResponse,
IAiRole,
IAiRoleCatalog,
IAiRoleCatalogBundle,
@@ -42,6 +43,23 @@ export async function getAiChatMessages(
return req.data;
}
/**
* Reconnect to the latest agent run of a chat (#184). Returns the run's
* persisted lifecycle state and the assistant message it materializes (the
* partial output while the run is in-flight, the final output once it finished).
* 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; `{ run: null }` when the
* chat has never had a run. Owner-gated server-side (the requesting user must own
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
* runs, so the endpoint returns `{ run: null }`.
*/
export async function getAiChatRun(
chatId: string,
): Promise<IAiChatRunResponse> {
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
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)
@@ -210,14 +210,41 @@ export interface IAiChatMessageRow {
// renders a "stopped" marker on interrupted turns.
finishReason?: string;
} | null;
// Persisted lifecycle status of the row's turn, carried on the wire by
// `baseFields`. 'streaming' marks a still-in-progress assistant row (used by
// the resume machinery to decide whether a tail is a live stream to attach to
// or a settled row that must not be replayed).
status?: string;
createdAt: string;
}
/**
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
* for display/diagnostics. The DB is the source of truth, so this resolves for an
* in-flight run (the browser dropped, the run kept going) and a finished one.
*/
export interface IAiChatRun {
id: string;
chatId: string;
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
error?: string | null;
stepCount?: number;
assistantMessageId?: string | null;
startedAt?: string | null;
finishedAt?: string | null;
createdAt?: string;
updatedAt?: string;
}
/**
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
* assistant message it materializes (the partial/final output, projected from the
* persisted rows). Both are `null` when the chat has never had a run.
*/
export interface IAiChatRunResponse {
run: IAiChatRun | null;
message: IAiChatMessageRow | null;
}
export interface IAiChatListParams extends QueryParams {}
export interface IAiChatMessagesParams {
@@ -1,112 +0,0 @@
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);
});
});
@@ -1,62 +0,0 @@
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;
}
@@ -0,0 +1,303 @@
import { describe, it, expect } from "vitest";
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
import {
RUN_POLL_INTERVAL_MS,
isRunActive,
runPollInterval,
shouldObserveRun,
shouldClearStoppingLatch,
shouldClearLatchOnQueryError,
mergeObservedMessage,
} from "./run-polling.ts";
function makeRun(status: string): IAiChatRun {
return { id: "run-1", chatId: "c1", status };
}
function makeMsg(id: string, text: string): UIMessage {
return {
id,
role: "assistant",
parts: [{ type: "text", text }],
} as UIMessage;
}
describe("isRunActive", () => {
it("treats pending and running as active", () => {
expect(isRunActive(makeRun("pending"))).toBe(true);
expect(isRunActive(makeRun("running"))).toBe(true);
});
it("treats terminal / unknown / nullish as not active", () => {
expect(isRunActive(makeRun("succeeded"))).toBe(false);
expect(isRunActive(makeRun("failed"))).toBe(false);
expect(isRunActive(makeRun("aborted"))).toBe(false);
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
expect(isRunActive(null)).toBe(false);
expect(isRunActive(undefined)).toBe(false);
});
});
describe("runPollInterval (the refetchInterval helper)", () => {
it("returns 2000ms while the run is pending/running", () => {
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
});
it("returns false (stop polling) once the run is terminal", () => {
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
expect(runPollInterval(makeRun("failed"))).toBe(false);
expect(runPollInterval(makeRun("aborted"))).toBe(false);
});
it("returns false (no polling) when there is no run", () => {
expect(runPollInterval(null)).toBe(false);
expect(runPollInterval(undefined)).toBe(false);
});
});
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
it("observes an active run when this tab is NOT the local streamer", () => {
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
});
it("observes a terminal run too (so the final output shows on reopen)", () => {
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
});
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
});
it("does NOT observe when there is no run", () => {
expect(shouldObserveRun(null, false)).toBe(false);
expect(shouldObserveRun(undefined, false)).toBe(false);
});
});
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
// The one case the latch SHOULD clear: we requested a stop, we are the passive
// observer (not streaming), and the CURRENT run is terminal.
it("clears only when stopping, observing, and the run is terminal", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("aborted"),
isLocalStreaming: false,
}),
).toBe(true);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("succeeded"),
isLocalStreaming: false,
}),
).toBe(true);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("failed"),
isLocalStreaming: false,
}),
).toBe(true);
});
// Round-3 regression: clearing while THIS tab is still the local streamer would
// re-open the flash for the current turn the moment we switch to observer role.
// A predicate lacking the streaming gate would (wrongly) return true here.
it("does NOT clear while this tab is the local streamer", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("aborted"),
isLocalStreaming: true,
}),
).toBe(false);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("succeeded"),
isLocalStreaming: true,
}),
).toBe(false);
});
// The detached run keeps growing after a local abort — while it is still
// active the latch MUST hold so the observer merge stays suppressed.
it("does NOT clear while the run is still active", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("running"),
isLocalStreaming: false,
}),
).toBe(false);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: makeRun("pending"),
isLocalStreaming: false,
}),
).toBe(false);
});
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
// observed `run` is null until the current turn's run is fetched fresh. A null
// run HOLDS the latch — it can never clear against the just-removed stale run,
// only against the current turn's own terminal run once observed.
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: null,
isLocalStreaming: false,
}),
).toBe(false);
expect(
shouldClearStoppingLatch({
stoppingRun: true,
run: undefined,
isLocalStreaming: false,
}),
).toBe(false);
});
it("does NOT clear when no stop was requested", () => {
expect(
shouldClearStoppingLatch({
stoppingRun: false,
run: makeRun("aborted"),
isLocalStreaming: false,
}),
).toBe(false);
});
});
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
// This guards the REAL anti-flash decision the component's run-query-error
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
// not a copy — so the test is non-vacuous vs the live code).
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
// runQueryFailed can be true while the held run is still pending/running.
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
// true — so this assertion fails on the buggy code (non-vacuous).
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: makeRun("running"),
}),
).toBe(false);
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: makeRun("pending"),
}),
).toBe(false);
});
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
// safety-net exists to cure — it MUST clear so the frozen view resumes.
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: null,
}),
).toBe(true);
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: undefined,
}),
).toBe(true);
});
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: true,
run: makeRun("aborted"),
}),
).toBe(true);
});
it("does NOT clear without an actual query error", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: false,
runQueryFailed: false,
run: null,
}),
).toBe(false);
});
it("does NOT clear while this tab is the local streamer", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: true,
isLocalStreaming: true,
runQueryFailed: true,
run: null,
}),
).toBe(false);
});
it("does NOT clear when no stop was requested", () => {
expect(
shouldClearLatchOnQueryError({
stoppingRun: false,
isLocalStreaming: false,
runQueryFailed: true,
run: null,
}),
).toBe(false);
});
});
describe("mergeObservedMessage", () => {
it("replaces the message with the same id in place (per-step growth)", () => {
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
const observed = makeMsg("a1", "step 1\nstep 2");
const next = mergeObservedMessage(prev, observed);
expect(next).toHaveLength(2);
expect(next[1]).toBe(observed);
expect(next[0]).toBe(prev[0]); // untouched
expect(next).not.toBe(prev); // new array (never mutates input)
});
it("appends when the observed message is not yet present", () => {
const prev = [makeMsg("u1", "hi")];
const observed = makeMsg("a1", "first token");
const next = mergeObservedMessage(prev, observed);
expect(next).toHaveLength(2);
expect(next[1]).toBe(observed);
});
it("returns the original list unchanged when there is nothing to merge", () => {
const prev = [makeMsg("u1", "hi")];
expect(mergeObservedMessage(prev, null)).toBe(prev);
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
});
});
@@ -0,0 +1,151 @@
import type { UIMessage } from "@ai-sdk/react";
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
/**
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
* run here (no local SSE stream), so it catches up by POLLING the reconnect
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
* assistant message into the rendered thread. These are the small pure decisions
* that machinery hangs off, extracted so they can be unit-tested in isolation
* (mirrors how reindex polling / editor-sync-state are tested).
*/
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
export const RUN_POLL_INTERVAL_MS = 2000;
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
// so a stale/odd value never polls forever).
const ACTIVE_STATUSES = new Set(["pending", "running"]);
/** Whether a run is still going (worth polling / merging live updates from). */
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
return !!run && ACTIVE_STATUSES.has(run.status);
}
/**
* The TanStack Query `refetchInterval` value for the run query: poll every
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
* it is terminal or there is no run. Polling is thus naturally bounded by the run
* reaching a terminal status — no separate timeout cap is needed.
*/
export function runPollInterval(
run: IAiChatRun | null | undefined,
): number | false {
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
}
/**
* Observer-vs-streamer decision. We render the polled run message (catch up +
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
* it here). When this tab is the streamer, the live SSE stream owns the view, so
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
* merge (so the final persisted output is shown on reopen); the poll itself is
* stopped separately by {@link runPollInterval}.
*/
export function shouldObserveRun(
run: IAiChatRun | null | undefined,
localStreaming: boolean,
): boolean {
return !!run && !localStreaming;
}
/**
* Should the "stopping" latch — which suppresses the observer re-stream flash
* after the user pressed Stop — be RELEASED now? All three must hold:
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
* streamer the run query is disabled, so the observed `run` is not the run we
* are following — releasing the latch then would re-open the flash for the
* current turn the instant we switch to observer role;
* - the observed `run` EXISTS and has reached a TERMINAL status.
*
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
* therefore HOLDS the latch, so it can only ever clear against the current turn's
* OWN terminal run — never a stale cached one. (The cache removal itself is
* integration-level in AiChatWindow; this predicate encodes the decision given
* whatever run is currently observed, and a stale terminal run is
* indistinguishable from a current terminal run at the predicate level — hence
* the cache removal is what guarantees only the current run is ever passed here.)
*/
export function shouldClearStoppingLatch(args: {
stoppingRun: boolean;
run: IAiChatRun | null | undefined;
isLocalStreaming: boolean;
}): boolean {
const { stoppingRun, run, isLocalStreaming } = args;
if (!stoppingRun || isLocalStreaming) return false;
return !!run && !isRunActive(run);
}
/**
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
* (#234 F7 — a NEW path of the same re-stream flash the F4 latch exists to
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
* its status-keyed refetchInterval is off, and nothing would ever observe a
* terminal run — freezing the view with the observer merge suppressed. This
* safety-net cures ONLY that genuine permanent-null-freeze.
*
* All four must hold:
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
* {@link shouldClearStoppingLatch});
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
* retry:false — isError);
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
* ACTIVE run (a single transient GET-run failure in the window between Stop and
* settle). Without this gate a transient error would release the latch early —
* re-opening the observer merge and flashing the growing detached run over the
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
* only ever cure the permanent-null-freeze (`run === null`, so
* `isRunActive(null)` is false), never release against an active run.
*
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
* — the terminal effect's {@link shouldClearStoppingLatch} already clears the
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
*
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
* polling when the data is empty — so after we clear on null+error there is no
* subsequent auto-poll that could return a still-active detached run and re-open the
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
* error, this null-branch clear would re-open the F7 flash through the null path.
* Do not change the run query's refetchInterval without re-checking this path.
*/
export function shouldClearLatchOnQueryError(args: {
stoppingRun: boolean;
isLocalStreaming: boolean;
runQueryFailed: boolean;
run: IAiChatRun | null | undefined;
}): boolean {
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
return (
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
);
}
/**
* Merge an observed assistant message into the rendered list: 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 mergeObservedMessage(
messages: UIMessage[],
observed: UIMessage | null | undefined,
): UIMessage[] {
if (!observed) return messages;
const idx = messages.findIndex((m) => m.id === observed.id);
if (idx === -1) return [...messages, observed];
const next = messages.slice();
next[idx] = observed;
return next;
}
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
toolCitations,
toolInputSummary,
toolRunState,
type ToolUiPart,
} from "./tool-parts";
@@ -78,138 +77,6 @@ describe("toolCitations", () => {
});
});
describe("toolInputSummary", () => {
it("returns the primary `query` string", () => {
const part: ToolUiPart = {
type: "tool-Search_web_search",
state: "input-available",
input: { query: "hello world" },
};
expect(toolInputSummary(part)).toBe("hello world");
});
it("summarizes a primary array field with a (+N) suffix", () => {
// `urls` is an external MCP read_pages-style list; the first element plus a
// count of the rest.
const part: ToolUiPart = {
type: "tool-read_pages",
state: "input-available",
input: { urls: ["a", "b", "c"] },
};
expect(toolInputSummary(part)).toBe("a (+2)");
});
it("omits the (+N) suffix for a single-element array", () => {
const part: ToolUiPart = {
type: "tool-read_pages",
state: "input-available",
input: { urls: ["only"] },
};
expect(toolInputSummary(part)).toBe("only");
});
it("falls back to `title` for a page op with no query", () => {
const part: ToolUiPart = {
type: "tool-createPage",
state: "input-available",
input: { pageId: "x", title: "My Page" },
};
expect(toolInputSummary(part)).toBe("My Page");
});
it("prefers the earlier primary field when several are present", () => {
const part: ToolUiPart = {
type: "tool-x",
state: "input-available",
// `query` outranks `title` in PRIMARY_INPUT_FIELDS — the ordered list is
// the contract, so a reordering must break this test.
input: { query: "Q", title: "T" },
};
expect(toolInputSummary(part)).toBe("Q");
});
it("does not clamp a value exactly at the 140-char limit", () => {
const exact = "a".repeat(140);
const part: ToolUiPart = {
type: "tool-Search_web_search",
state: "input-available",
input: { query: exact },
};
const out = toolInputSummary(part)!;
expect(out).toBe(exact);
expect(out.endsWith("…")).toBe(false);
expect(out.length).toBe(140);
});
it("clamps one char over the limit (141 -> 140 + ellipsis)", () => {
const part: ToolUiPart = {
type: "tool-Search_web_search",
state: "input-available",
input: { query: "a".repeat(141) },
};
const out = toolInputSummary(part)!;
expect(out.endsWith("…")).toBe(true);
expect(out.length).toBe(141);
expect(out).toBe("a".repeat(140) + "…");
});
it("clamps a long value to ~140 chars with an ellipsis", () => {
const long = "a".repeat(300);
const part: ToolUiPart = {
type: "tool-Search_web_search",
state: "input-available",
input: { query: long },
};
const out = toolInputSummary(part)!;
expect(out.endsWith("…")).toBe(true);
expect(out.length).toBeLessThanOrEqual(141);
});
it("collapses newlines and repeated spaces to single spaces", () => {
const part: ToolUiPart = {
type: "tool-Search_web_search",
state: "input-available",
input: { query: " foo\n\n bar baz " },
};
expect(toolInputSummary(part)).toBe("foo bar baz");
});
it("returns undefined with no input", () => {
expect(
toolInputSummary({ type: "tool-x", state: "input-available" }),
).toBeUndefined();
});
it("returns undefined for an empty object input", () => {
expect(
toolInputSummary({
type: "tool-x",
state: "input-available",
input: {},
}),
).toBeUndefined();
});
it("returns undefined for a non-object input", () => {
expect(
toolInputSummary({
type: "tool-x",
state: "input-available",
input: "just a string",
}),
).toBeUndefined();
});
it("returns undefined while the input is still streaming (even with a full input)", () => {
const part: ToolUiPart = {
type: "tool-Search_web_search",
state: "input-streaming",
input: { query: "hello world" },
};
expect(toolInputSummary(part)).toBeUndefined();
});
});
describe("toolRunState", () => {
it('maps "output-error" to error', () => {
expect(toolRunState("output-error")).toBe("error");
@@ -97,69 +97,6 @@ function asString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
/** Collapse runs of whitespace/newlines to a single space and trim. */
function collapse(s: string): string {
return s.replace(/\s+/g, " ").trim();
}
/** Truncate to ~140 chars, appending an ellipsis when it overflows. */
function clamp(s: string): string {
const MAX = 140;
return s.length > MAX ? s.slice(0, MAX).trimEnd() + "…" : s;
}
/**
* Priority "primary" argument fields, in order. The first present one supplies
* the summary. `urls` is included (external MCP `read_pages`-style tools take a
* list of URLs) and is handled as an array; `url` covers the single-URL form.
*/
const PRIMARY_INPUT_FIELDS = [
"query",
"q",
"searchQuery",
"url",
"urls",
"title",
"name",
"text",
"prompt",
] as const;
/**
* A short, PLAIN-TEXT one-line summary of a tool call's arguments (e.g. the
* search query), or undefined when no recognizable primary field is present.
* Rendered under the tool label so tools without a friendly name (external MCP
* tools like `Search_web_search`) still show WHAT was requested, not just a
* generic "Ran tool {{name}}". The returned string is plain text and MUST be
* rendered React-escaped (Mantine `<Text>`), never as markdown/HTML.
*
* Streaming gate: while `state === "input-streaming"` the `input` object grows
* chunk by chunk but `messageSignature` deliberately does NOT track `input`, so
* a live summary computed here would freeze at its first captured value and go
* stale. We therefore return undefined until the state flips to
* `input-available` (input finalized) that state change IS tracked by the
* signature, so the row re-renders and shows the complete summary. Do NOT add
* `input` to `message-signature.ts` to work around this.
*/
export function toolInputSummary(part: ToolUiPart): string | undefined {
if (part.state === "input-streaming") return undefined;
if (!part.input || typeof part.input !== "object") return undefined;
const input = part.input as Record<string, unknown>;
for (const field of PRIMARY_INPUT_FIELDS) {
const value = input[field];
if (typeof value === "string" && value.length > 0) {
return clamp(collapse(value));
}
if (Array.isArray(value) && value.length > 0) {
const first = collapse(String(value[0]));
if (first.length === 0) continue;
return clamp(first + (value.length > 1 ? ` (+${value.length - 1})` : ""));
}
}
return undefined;
}
/**
* Resolve the page citation(s) a tool part references, from its input/output.
* Only output-available parts (the tool returned) yield citations. Search
@@ -49,14 +49,19 @@ export default function FootnoteDefinitionView(props: NodeViewProps) {
className={classes.definition}
style={{ ["--footnote-number" as any]: `"${number}"` }}
>
{/* #146: contentDOM MUST be the first child non-editable chrome before
{/* #146: contentDOM MUST be the first child a non-editable marker before
it makes click hit-testing snap the caret above. Content first; the
back-link follows in DOM and is placed on the right via CSS flex. The
decorative "N." number is rendered inline via the .definitionContent
::before rule (from the --footnote-number var), so no marker element
precedes the content. The second #146 mitigation lives in
marker + back-link follow in DOM and are placed left/right via CSS
flex `order`. The second #146 mitigation lives in
editor-paste-handler.tsx (reflowAfterPaste). */}
<NodeViewContent className={classes.definitionContent} />
<span
className={classes.definitionMarker}
contentEditable={false}
aria-hidden="true"
>
{number}.
</span>
{refCount > 1 ? (
// Multiple references -> ↩ followed by one lettered link per occurrence.
<span
@@ -81,34 +81,34 @@
.definition {
display: flex;
align-items: flex-start;
/* Tight spacing between the content and the trailing ↩ back-link. */
gap: 0.3em;
/* Tight numbertext spacing (~one space) so it reads like "1. text"
instead of leaving a wide gap after the period. */
gap: 0.4em;
padding: 2px 0;
/* Footnotes read smaller than body text (16px). Matches .listHeading. */
font-size: var(--mantine-font-size-sm);
}
/* The "N." number is decorative (from the --footnote-number CSS var on the
wrapper, never in the document model) and is rendered inline at the start of
the first content line via ::before. This keeps text and wrapped lines flush
to the left margin no hanging indent while the editable contentDOM stays
the FIRST DOM child (#146). */
.definitionMarker {
order: -1; /* keep the "N." marker on the LEFT though it follows content in DOM (#146) */
flex: 0 0 auto;
min-width: 1.5em;
/* Right-align within the narrow column so the period sits next to the text
and multi-digit numbers (10, 11, ) stay aligned on their right edge. */
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--mantine-color-dimmed);
user-select: none;
}
.definitionContent {
flex: 1 1 auto;
min-width: 0;
}
.definitionContent > :first-child::before {
content: var(--footnote-number, "?") ". ";
color: var(--mantine-color-dimmed);
font-variant-numeric: tabular-nums;
user-select: none;
}
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
Drop the outer margins so the definition sits tight to the heading above and
the ::before number aligns with the top of the row same approach used for
callouts in core.css. */
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`,
which pushes the first text line ~0.5em below the "N." marker (aligned to
flex-start), making the number float above the text. Drop the outer margins
so the marker and the first line share the same top edge same approach
used for callouts in core.css. */
.definitionContent > :first-child {
margin-top: 0;
}
@@ -1,113 +0,0 @@
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();
});
});
@@ -1,71 +0,0 @@
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,9 +165,6 @@ export default function ShareAiWidget({
isStreaming={isStreaming}
assistantName={assistantName}
showCitations={false}
// Anonymous reader: suppress the tool-argument summary line so the
// agent's raw query/argument text isn't shown on the public share.
showInput={false}
// Anonymous reader: neutralize internal/relative links in the
// assistant's markdown so internal UUIDs/auth-gated routes don't
// leak as clickable links (external http(s) links are kept).
+20 -1
View File
@@ -1,5 +1,6 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
import { compression } from "vite-plugin-compression2";
import * as path from "path";
import { execSync } from "node:child_process";
@@ -53,7 +54,25 @@ export default defineConfig(({ mode }) => {
},
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
},
plugins: [react()],
plugins: [
react(),
// Emit .br and .gz next to every built asset so the server can serve the
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
compression({
algorithms: ["brotliCompress", "gzip"],
// vite-plugin-compression2's default `include` only covers text-ish
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
// they are brotli/gzip'd once at build time and served via
// @fastify/static preCompressed — otherwise @fastify/compress would
// re-brotli them on EVERY request. The default types are repeated here
// because setting `include` replaces (does not extend) the default.
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
// index.html is rewritten at server boot (window.CONFIG injection); a
// precompressed copy would go stale — NEVER precompress it.
exclude: [/index\.html$/],
}),
],
build: {
rolldownOptions: {
output: {
+1
View File
@@ -44,6 +44,7 @@
"@docmost/mcp": "workspace:*",
"@docmost/pdf-inspector": "1.9.6",
"@docmost/prosemirror-markdown": "workspace:*",
"@fastify/compress": "^9.0.0",
"@fastify/cookie": "^11.0.2",
"@fastify/multipart": "^10.0.0",
"@fastify/static": "^9.1.3",
@@ -1,9 +1,4 @@
import {
connectedPayload,
Extension,
Hocuspocus,
onConnectPayload,
} from '@hocuspocus/server';
import { Hocuspocus } from '@hocuspocus/server';
import { IncomingMessage } from 'http';
import WebSocket from 'ws';
import { AuthenticationExtension } from './extensions/authentication.extension';
@@ -30,56 +25,6 @@ import {
CollaborationHandler,
CollabEventHandlers,
} from './collaboration.handler';
import {
incDocLoad,
incDocUnload,
isMetricsEnabled,
observeCollabConnect,
registerDocsOpenSource,
} from '../integrations/metrics/metrics.registry';
/**
* #402 collab lifecycle metrics as a lightweight hocuspocus extension.
*
* - afterLoadDocument / afterUnloadDocument (fire once PER DOCUMENT) drive the
* doc load/unload counters.
* - collab_connect_duration_seconds: I time the onConnectconnected hook pair,
* i.e. connection ACCEPTANCE (which includes the auth handshake). This is the
* cleanest per-connection correlation hocuspocus exposes: both payloads carry
* the SAME `request` IncomingMessage object, so a WeakMap keyed on it gives a
* per-connection start with NO leak (the entry is GC'd with the request if a
* connection is rejected in onAuthenticate and `connected` never fires).
* I deliberately do NOT observe at afterLoadDocument: that hook fires per
* DOCUMENT, not per connection, so a second client joining an already-open
* doc would be missed. auth/load latencies are their own separate metrics.
*
* All helpers are no-ops when METRICS_PORT is unset; these hooks are per
* connect/load/unload (never per message), so there is no hot-path cost.
*/
class CollabMetricsExtension implements Extension {
// Keyed by the per-connection request object → connect start time (ms).
private readonly connectStarts = new WeakMap<object, number>();
async onConnect(data: onConnectPayload) {
this.connectStarts.set(data.request, performance.now());
}
async connected(data: connectedPayload) {
const start = this.connectStarts.get(data.request);
if (start !== undefined) {
observeCollabConnect((performance.now() - start) / 1000);
this.connectStarts.delete(data.request);
}
}
async afterLoadDocument() {
incDocLoad();
}
async afterUnloadDocument() {
incDocUnload();
}
}
@Injectable()
export class CollaborationGateway {
@@ -113,18 +58,9 @@ export class CollaborationGateway {
this.authenticationExtension,
this.persistenceExtension,
this.loggerExtension,
// #402 collab lifecycle + connect-duration metrics (no-op when off).
new CollabMetricsExtension(),
],
});
// #402 — read-on-scrape source for collab_docs_open. Wire ONCE, gated, so
// nothing runs when metrics are disabled. The gauge's collect() pulls the
// live count from the hocuspocus instance on each scrape (no inc/dec drift).
if (isMetricsEnabled()) {
registerDocsOpenSource(() => this.hocuspocus.getDocumentsCount());
}
if (this.withRedis) {
this.redisClient = new RedisClient({
host: this.redisConfig.host,
@@ -16,7 +16,6 @@ import { isUserDisabled } from '../../common/helpers';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
@Injectable()
export class AuthenticationExtension implements Extension {
@@ -31,18 +30,6 @@ export class AuthenticationExtension implements Extension {
) {}
async onAuthenticate(data: onAuthenticatePayload) {
// #402 — time the whole auth (verify + user/page/permission lookups) into
// collab_auth_duration_seconds. finally so failed auths are timed too.
// No-op when METRICS_PORT is unset. Behavior unchanged.
const start = performance.now();
try {
return await this.doAuthenticate(data);
} finally {
observeCollabAuth((performance.now() - start) / 1000);
}
}
private async doAuthenticate(data: onAuthenticatePayload) {
const { documentName, token } = data;
const pageId = getPageId(documentName);
@@ -1,140 +0,0 @@
/**
* gitmost #401 regression test for the connect-vs-unload race in
* @hocuspocus/server 3.4.4 (patched via patches/@hocuspocus__server@3.4.4.patch).
*
* The race (unpatched): when the last client disconnects, storeDocumentHooks'
* `finally` schedules an async `unloadDocument`. That unload runs its
* `beforeUnloadDocument` hooks asynchronously and, meanwhile, records an
* in-flight promise in `this.unloadingDocuments`. In the original 3.4.4
* `createDocument`, a NEW connection arriving in that window falls straight
* through to the `loadingDocuments`/`documents` checks it never consults
* `unloadingDocuments`. So the new connection can start loading (or reuse) a
* document while the old instance is still being torn down; the re-check inside
* unload (`shouldUnloadDocument`, which sees 0 connections because async auth
* hooks have not registered the new connection yet) then deletes/destroys the
* doc out from under the freshly-connected client orphaned Document later
* redis-sync takes the "doc not loaded" path sync never completes the
* provider hangs until its ~25s timeout.
*
* The patch: `createDocument` first awaits any in-flight
* `unloadingDocuments.get(name)` before proceeding. Once that settles, the
* decision is deterministic either the doc was fully unloaded (gone from
* `documents`, so a clean fresh load) or the unload aborted (healthy doc still
* in `documents`, reused). The new connection can never hand-shake onto an
* about-to-be-destroyed Document.
*
* These tests exercise the REAL patched `Hocuspocus.createDocument` (the class
* is directly constructible) by seeding `unloadingDocuments` with a controllable
* in-flight unload and observing that createDocument waits for it.
*/
import { Hocuspocus } from '@hocuspocus/server';
// A promise we can resolve on demand, to model an unload that is mid-flight.
function deferred<T = void>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe('gitmost #401 — hocuspocus createDocument awaits in-flight unload', () => {
it('does NOT start loading a new doc until the in-flight unload settles, then loads fresh', async () => {
const hp = new Hocuspocus();
const name = 'page.race';
// Observe loadDocument: on the unpatched code it is invoked synchronously
// within createDocument (before the unload settles); on the patched code it
// must be deferred until unloadingDocuments resolves.
const freshDoc = { name, __fresh: true } as any;
const loadSpy = jest
.spyOn(hp as any, 'loadDocument')
.mockResolvedValue(freshDoc);
// Model an unload in progress: an entry sits in unloadingDocuments and, when
// it completes, it removes the doc from `documents` (a real full unload).
const unload = deferred();
(hp as any).documents.set(name, { name, __dying: true });
(hp as any).unloadingDocuments.set(
name,
unload.promise.then(() => {
(hp as any).documents.delete(name);
}),
);
// Kick off a new connection's createDocument but do not await it yet.
const createPromise = (hp as any).createDocument(
name,
{},
'socket-1',
{ isAuthenticated: true, readOnly: false },
{},
);
// Let all currently-schedulable microtasks run. The patched createDocument is
// now parked on `await unloadingDocuments.get(name)`, so loadDocument must
// NOT have been called yet, and it must NOT have returned the dying doc.
await Promise.resolve();
await Promise.resolve();
expect(loadSpy).not.toHaveBeenCalled();
// The unload completes (doc removed from `documents`).
unload.resolve();
// createDocument now proceeds: sees no existing doc → fresh load.
const doc = await createPromise;
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(doc).toBe(freshDoc);
// The freshly-loaded doc is the one registered — never the dying instance.
expect((hp as any).documents.get(name)).toBe(freshDoc);
});
it('reuses the live doc when the in-flight unload aborts (doc left in documents)', async () => {
const hp = new Hocuspocus();
const name = 'page.abort';
const loadSpy = jest.spyOn(hp as any, 'loadDocument');
// Model an unload that ABORTS (e.g. a new connection reappeared before the
// sync re-check): it settles WITHOUT deleting the doc from `documents`.
const unload = deferred();
const liveDoc = { name, __live: true } as any;
(hp as any).documents.set(name, liveDoc);
(hp as any).unloadingDocuments.set(name, unload.promise); // no-op unload
const createPromise = (hp as any).createDocument(
name,
{},
'socket-2',
{ isAuthenticated: true, readOnly: false },
{},
);
unload.resolve();
const doc = await createPromise;
// The still-present live doc is reused; no fresh load happened.
expect(doc).toBe(liveDoc);
expect(loadSpy).not.toHaveBeenCalled();
});
it('no in-flight unload → behaves normally (fresh load)', async () => {
const hp = new Hocuspocus();
const name = 'page.normal';
const freshDoc = { name } as any;
const loadSpy = jest
.spyOn(hp as any, 'loadDocument')
.mockResolvedValue(freshDoc);
const doc = await (hp as any).createDocument(
name,
{},
'socket-3',
{ isAuthenticated: true, readOnly: false },
{},
);
expect(loadSpy).toHaveBeenCalledTimes(1);
expect(doc).toBe(freshDoc);
});
});
@@ -1,130 +0,0 @@
/**
* gitmost #401 fix 2 onLoadDocument applies the DB state directly into the
* hook's target document and returns undefined (instead of building a NEW Y.Doc
* and returning it, which made hocuspocus re-encode+apply the whole state a
* SECOND time on every cold load).
*
* These tests assert:
* - the hook mutates `data.document` in place so its content equals the DB doc,
* - onLoadDocument returns undefined (so hocuspocus keeps the mutated doc and
* does NOT run its own applyUpdate(encodeStateAsUpdate(...)) merge),
* - both the raw-ydoc branch and the jsonydoc conversion branch behave so.
*
* Returning undefined is the observable signal that the double-encode is gone
* (the old code returned a new Y.Doc, which made hocuspocus re-encode+apply the
* state a second time); we assert that contract rather than counting internal
* encode calls, which is brittle given the encodes inside toYdoc and the test's
* own `expected` fixtures.
*/
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import { PersistenceExtension } from './persistence.extension';
import { tiptapExtensions } from '../collaboration.util';
// A fresh hocuspocus Document (extends Y.Doc, adds isEmpty()) as hocuspocus
// hands to onLoadDocument on a cold load.
const freshDoc = () => new Document(`page.${PAGE_ID}`, {});
const PAGE_ID = '550e8400-e29b-41d4-a716-446655440000';
const doc = (text: string) => ({
type: 'doc',
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
});
const jsonOf = (ydoc: Y.Doc) =>
TiptapTransformer.fromYdoc(ydoc, 'default');
describe('PersistenceExtension.onLoadDocument — #401 fix 2 (apply-into-hook-doc)', () => {
let ext: PersistenceExtension;
let pageRepo: { findById: jest.Mock };
beforeEach(() => {
pageRepo = { findById: jest.fn() };
ext = new PersistenceExtension(
pageRepo as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
{} as any,
);
jest.spyOn(ext['logger'], 'debug').mockImplementation(() => undefined);
jest.spyOn(ext['logger'], 'warn').mockImplementation(() => undefined);
});
const load = (document: Document) =>
ext.onLoadDocument({ documentName: `page.${PAGE_ID}`, document } as any);
it('raw ydoc branch: mutates the hook doc to the DB state and returns undefined', async () => {
// Source doc representing the persisted ydoc state.
const source = TiptapTransformer.toYdoc(
doc('DB CONTENT'),
'default',
tiptapExtensions,
);
const dbState = Buffer.from(Y.encodeStateAsUpdate(source));
pageRepo.findById.mockResolvedValue({ id: PAGE_ID, ydoc: dbState });
// The hook target is a fresh empty doc (as hocuspocus supplies on cold load).
const target = freshDoc();
const result = await load(target);
// Return undefined so hocuspocus keeps `target` as-is (no second merge).
expect(result).toBeUndefined();
// The hook document now carries the DB content.
expect(jsonOf(target)).toEqual(jsonOf(source));
});
it('json→ydoc branch: converts page.content into the hook doc and returns undefined', async () => {
pageRepo.findById.mockResolvedValue({
id: PAGE_ID,
ydoc: null,
content: doc('JSON CONTENT'),
});
const target = freshDoc();
const result = await load(target);
// Returning undefined is what keeps hocuspocus from re-encoding+applying the
// state a second time (the old code returned the doc, forcing that extra
// encode). We assert the observable contract here — the return value and the
// resulting content — rather than counting internal encode calls, which is
// brittle: toYdoc and the `expected` build below both encode too.
expect(result).toBeUndefined();
// The converted content landed in the hook document.
const expected = TiptapTransformer.toYdoc(
doc('JSON CONTENT'),
'default',
tiptapExtensions,
);
expect(jsonOf(target)).toEqual(jsonOf(expected));
});
it('live doc already non-empty: early return, no DB read', async () => {
// A hocuspocus Document carrying live content (isEmpty('default') === false).
const target = freshDoc();
const live = TiptapTransformer.toYdoc(
doc('LIVE'),
'default',
tiptapExtensions,
);
Y.applyUpdate(target, Y.encodeStateAsUpdate(live));
const result = await load(target);
expect(result).toBeUndefined();
expect(pageRepo.findById).not.toHaveBeenCalled();
});
it('no persisted state: leaves the fresh empty doc untouched, returns undefined', async () => {
pageRepo.findById.mockResolvedValue({ id: PAGE_ID, ydoc: null, content: null });
const target = freshDoc();
const result = await load(target);
expect(result).toBeUndefined();
expect(target.isEmpty('default')).toBe(true);
});
});
@@ -41,10 +41,7 @@ import {
HISTORY_INTERVAL,
} from '../constants';
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
import {
observeCollabLoad,
observeCollabStore,
} from '../../integrations/metrics/metrics.registry';
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
/**
* #251 wire format of the clientserver stateless message that signals a
@@ -153,14 +150,10 @@ export class PersistenceExtension implements Extension {
const { documentName, document } = data;
const pageId = getPageId(documentName);
// #402 — the early return below (live doc already non-empty) does NOT touch
// the DB, so it is deliberately NOT timed. We only observe the real DB-load
// work, and only on each real-load return, tagged by the loaded doc size.
if (!document.isEmpty('default')) {
return;
}
const startedAt = performance.now();
const page = await this.pageRepo.findById(pageId, {
includeContent: true,
includeYdoc: true,
@@ -171,21 +164,14 @@ export class PersistenceExtension implements Extension {
return;
}
// #401 fix 2 — apply the DB state DIRECTLY into the hook's target document
// (`document` === `data.document`) and return undefined. When onLoadDocument
// returns undefined, hocuspocus keeps the mutated hook document as-is; only
// when the hook RETURNS a Y.Doc does hocuspocus re-`applyUpdate(document,
// encodeStateAsUpdate(returned))` — a second full encode+apply of the whole
// (e.g. 315KB) state on every cold load. Mutating in place performs a single
// apply and avoids the throwaway `new Y.Doc()` allocation.
if (page.ydoc) {
this.logger.debug(`ydoc loaded from db: ${pageId}`);
const doc = new Y.Doc();
const dbState = new Uint8Array(page.ydoc);
Y.applyUpdate(document, dbState);
observeCollabLoad(dbState.length, (performance.now() - startedAt) / 1000);
return;
Y.applyUpdate(doc, dbState);
return doc;
}
// if no ydoc state in db convert json in page.content to Ydoc.
@@ -198,47 +184,26 @@ export class PersistenceExtension implements Extension {
tiptapExtensions,
);
// Encode the converted doc ONCE, reuse the bytes for both the size label
// and the single apply into the hook document (previously this encode's
// result was returned and hocuspocus re-encoded+applied it a second time).
const encoded = Y.encodeStateAsUpdate(ydoc);
Y.applyUpdate(document, encoded);
observeCollabLoad(
encoded.byteLength,
(performance.now() - startedAt) / 1000,
);
return;
Y.encodeStateAsUpdate(ydoc);
return ydoc;
}
// No persisted state: the hook document is already a fresh empty Y.Doc, so
// leave it untouched and return undefined (no re-encode of an empty doc).
this.logger.debug(`creating fresh ydoc: ${pageId}`);
observeCollabLoad(0, (performance.now() - startedAt) / 1000);
return;
return new Y.Doc();
}
async onStoreDocument(data: onStoreDocumentPayload) {
// #355 — time the full store (persist + post-store side effects) into
// collab_store_duration_seconds. #402 — also tag by document size bucket.
// No-op when METRICS_PORT is unset.
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
const startedAt = performance.now();
// Default 0 so a throw before storeDocument returns still records a
// (smallest-bucket) observation rather than dropping the timing entirely.
let bytes = 0;
try {
bytes = await this.storeDocument(data);
await this.storeDocument(data);
} finally {
observeCollabStore(bytes, (performance.now() - startedAt) / 1000);
observeCollabStore((performance.now() - startedAt) / 1000);
}
}
/**
* Persist the document. Returns the serialized ydoc byte size (used as the
* store histogram's size_bucket). The single Y.encodeStateAsUpdate below is
* the ONLY serialization its byteLength is reused for the label (no second
* encode).
*/
private async storeDocument(data: onStoreDocumentPayload): Promise<number> {
private async storeDocument(data: onStoreDocumentPayload) {
const { documentName, document, context } = data;
const pageId = getPageId(documentName);
@@ -490,11 +455,6 @@ export class PersistenceExtension implements Extension {
await this.enqueuePageHistory(page, lastUpdatedSource);
}
// #402 — report the serialized size for the store histogram's size_bucket.
// ydocState is always computed above (there is no earlier no-write return in
// this method), so this reflects the doc that was serialized this store.
return ydocState.byteLength;
}
/**
@@ -1,343 +0,0 @@
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, and
* the client falls back to its restore + degraded-poll path, #430).
*
* Raised from 4MB to 32MB (#430): marathon autonomous runs (11-25 min observed)
* stream far more than 4MB of SSE frames, so a live disconnect mid-run would find
* an already-overflowed buffer and could only degrade-poll instead of re-attaching
* to the live tail. 32MB comfortably covers those runs while staying bounded.
*
* Memory cost: this is the WORST-CASE retained size PER ACTIVE run (the buffer is
* freed on finish + retention, or dropped immediately on overflow). With the small
* number of concurrent autonomous runs a single workspace realistically has, 32MB
* each is an acceptable ceiling; the overflow->204->degraded-poll fallback remains
* the backstop for anything larger, so correctness never depends on this bound.
*/
export const RUN_STREAM_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
// 2x the replay cap: a just-written full-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;
}
}
}
}
@@ -1,392 +0,0 @@
import {
AiChatStreamRegistryService,
RUN_STREAM_MAX_BUFFER_BYTES,
RUN_STREAM_RETAIN_FINISHED_MS,
SUBSCRIBER_MAX_BUFFERED_BYTES,
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();
// Cap-relative so it survives a buffer-cap change (#430): a quarter-cap frame
// means 5 frames comfortably exceed the replay cap; the last one crosses.
const chunk = 'x'.repeat(Math.floor(RUN_STREAM_MAX_BUFFER_BYTES / 4));
for (let i = 0; i < 5; i++) src.push(chunk + 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(chunk + 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();
// Cap-relative so it survives a buffer-cap change (#430): a quarter-of-the-
// per-subscriber-cap frame means 5 frames exceed A's paused-pending cap while
// B streams every frame live.
const chunk = 'x'.repeat(Math.floor(SUBSCRIBER_MAX_BUFFERED_BYTES / 4));
for (let i = 0; i < 5; i++) src.push(chunk + 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(5);
// 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');
});
});
@@ -1,423 +0,0 @@
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');
});
});
@@ -4,15 +4,11 @@ import {
ConflictException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpException,
HttpStatus,
Logger,
Param,
ParseUUIDPipe,
Post,
Query,
Req,
Res,
ServiceUnavailableException,
@@ -58,12 +54,6 @@ import {
} from './dto/ai-chat.dto';
import { describeProviderError } from '../../integrations/ai/ai-error.util';
import { buildChatMarkdown } from './chat-markdown.util';
import {
AiChatStreamRegistryService,
SUBSCRIBER_MAX_BUFFERED_BYTES,
} from './ai-chat-stream-registry.service';
import { startSseHeartbeat } from './sse-resilience';
import { EnvironmentService } from '../../integrations/environment/environment.service';
/**
* Per-user AI chat API (§6.1). Routes are POST to match this codebase's
@@ -82,11 +72,6 @@ export class AiChatController {
private readonly aiChatMessageRepo: AiChatMessageRepo,
private readonly aiTranscription: AiTranscriptionService,
private readonly pageRepo: PageRepo,
// #184 phase 1.5. OPTIONAL so existing positional constructions (controller
// specs) compile unchanged; Nest always injects the real providers in
// production. Only touched on the resumable-stream (flag-on) path.
private readonly streamRegistry?: AiChatStreamRegistryService,
private readonly environment?: EnvironmentService,
) {}
/** List the requesting user's chats in this workspace (paginated). */
@@ -248,102 +233,6 @@ export class AiChatController {
return { stopped };
}
/**
* Attach to a chat's live run stream (#184 phase 1.5). A late/reloaded tab
* replays the frames buffered so far and then follows the live tail as a normal
* streamer. Owner-gated via assertOwnedChat (same gate as getRun). When there is
* nothing to resume no entry, a finished run without expect=live, an
* overflowed buffer, or an anchor that pins a DIFFERENT run the endpoint
* answers 204, the ONLY "nothing to resume" signal the AI SDK's reconnect
* accepts (it maps 204 to a silent no-op). With AI_CHAT_RESUMABLE_STREAM off the
* registry is never populated, so attach always 204s.
*
* `expect=live` opts into replaying a finished-but-retained run (safe only when
* the client stripped the streaming tail); `anchor` is the client's assistant
* row id, which must match this run's (invariant 6) or a foreign run's
* transcript would be replayed into the store.
*/
@SkipTransform()
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 60, ttl: 60000 } })
@Get('runs/:chatId/stream')
async attachRunStream(
@Param('chatId', new ParseUUIDPipe()) chatId: string,
@Query('expect') expect: string | undefined,
@Query('anchor') anchor: string | undefined,
@Req() req: FastifyRequest,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
): Promise<void> {
await this.assertOwnedChat(chatId, user, workspace); // same gate as getRun
let stopHeartbeat: () => void = () => undefined;
const attachment = await this.streamRegistry?.attach(
chatId,
expect === 'live',
anchor,
{
onFrame: (frame) => {
// Backpressure guard: 2x the replay cap, so the initial replay burst
// alone can never trip it; only a genuinely stalled socket can.
try {
if (res.raw.writableLength > SUBSCRIBER_MAX_BUFFERED_BYTES) {
res.raw.destroy(); // 'close' fires -> unsubscribe below
return;
}
if (!res.raw.writableEnded) res.raw.write(frame);
} catch {
res.raw.destroy();
}
},
onEnd: () => {
stopHeartbeat();
if (!res.raw.writableEnded) res.raw.end();
},
},
);
if (!attachment) {
res.status(204).send(); // the ONLY "nothing to resume" signal the SDK accepts
return;
}
res.hijack();
// Cleanup BEFORE any write (invariant 5): a torn-down socket must not orphan
// a paused subscriber whose pending queue would buffer the whole run.
req.raw.once('close', () => {
attachment.unsubscribe();
stopHeartbeat();
});
// A close emitted DURING the awaits above was missed by the listener — check.
// (Healthy pending GETs have req.raw.destroyed === false, so no false
// positives; returning without end() is fine — the socket is gone.)
if (req.raw.destroyed) {
attachment.unsubscribe();
return;
}
res.raw.on('error', () => undefined);
try {
res.raw.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
'x-vercel-ai-ui-message-stream': 'v1',
'x-accel-buffering': 'no',
// deliberately NO Connection/Keep-Alive (hop-by-hop; Safari/HTTP2)
});
res.raw.flushHeaders?.();
for (const frame of attachment.replay) res.raw.write(frame);
if (attachment.finished) {
res.raw.end();
return;
}
stopHeartbeat = startSseHeartbeat(res.raw, 15_000);
attachment.start(); // drain pending accumulated during replay, go live
} catch {
attachment.unsubscribe();
stopHeartbeat();
res.raw.destroy();
}
}
/** Rename a chat. */
@HttpCode(HttpStatus.OK)
@Post('rename')
@@ -455,25 +344,13 @@ export class AiChatController {
// its progress, and settle its terminal status — see AiChatRunService.
const runHooks: AiChatRunHooks | undefined = autonomousRuns
? {
begin: async (chatId) => {
const handle = await this.aiChatRunService.beginRun({
begin: (chatId) =>
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,
@@ -4,7 +4,6 @@ import { TokenModule } from '../auth/token.module';
import { AiChatController } from './ai-chat.controller';
import { AiChatService } from './ai-chat.service';
import { AiChatRunService } from './ai-chat-run.service';
import { AiChatStreamRegistryService } from './ai-chat-stream-registry.service';
import { AiTranscriptionService } from './ai-transcription.service';
import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { EmbeddingModule } from './embedding/embedding.module';
@@ -45,7 +44,6 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
providers: [
AiChatService,
AiChatRunService,
AiChatStreamRegistryService,
AiTranscriptionService,
AiChatToolsService,
PublicShareChatService,
@@ -153,41 +153,6 @@ describe('buildSystemPrompt current-page context', () => {
expect(prompt).not.toContain('pageId:');
});
// #388: editor-selection flag. Only a FIXED one-liner is added — the selection
// TEXT (untrusted page content) must never reach the prompt.
const SELECTION_FLAG = 'currently has text SELECTED on this page';
it('adds the selection flag when a selection is present with a page', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: {
id: 'pg-123',
title: 'Doc',
selection: { text: 'SECRET-SELECTED-TEXT', blockIds: ['b1'] },
},
});
expect(prompt).toContain(SELECTION_FLAG);
// The selection TEXT itself is NEVER in the prompt.
expect(prompt).not.toContain('SECRET-SELECTED-TEXT');
expect(prompt).not.toContain('b1');
});
it('omits the selection flag when there is no selection', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: 'Doc' },
});
expect(prompt).not.toContain(SELECTION_FLAG);
});
it('omits the selection flag when selection is null', () => {
const prompt = buildSystemPrompt({
workspace,
openedPage: { id: 'pg-123', title: 'Doc', selection: null },
});
expect(prompt).not.toContain(SELECTION_FLAG);
});
it('escapes a malicious opened-page title so it cannot inject tags (F1)', () => {
const prompt = buildSystemPrompt({
workspace,
+1 -14
View File
@@ -156,13 +156,8 @@ export interface BuildSystemPromptInput {
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
* "the current page" to that pageId. The page is NOT fetched here the agent
* uses its CASL-enforced read/write page tools with the id when needed.
*
* `selection` (#388) is present only when the user has a non-empty editor
* selection; the prompt adds ONLY a fixed one-line flag from it the
* selection TEXT is untrusted page content and stays out of the prompt (it is
* surfaced solely via the getCurrentPage tool result).
*/
openedPage?: { id?: string; title?: string; selection?: object | null } | null;
openedPage?: { id?: string; title?: string } | null;
/**
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
* server's tools"), built by `McpClientsService.toolsFor` for servers that
@@ -314,14 +309,6 @@ export function buildSystemPrompt({
? escapeAttr(openedPage.title)
: 'Untitled';
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
// Editor-selection flag (#388). A FIXED one-liner only — the selection TEXT
// is untrusted collaborative-page content and must never enter the prompt; it
// is surfaced solely through the getCurrentPage tool result (SAFETY_FRAMEWORK
// treats a tool result as data). Nested under the page block so it is added
// only alongside a resolved page (a selection cannot outlive its page).
if (openedPage?.selection) {
context += `\nThe user currently has text SELECTED on this page — call getCurrentPage to see the selection. When they say "this", "here", "the selected text" or similar, they mean that selection.`;
}
}
// Interrupt-resume marker (#198). Added to the context section (inside the
@@ -1,295 +0,0 @@
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' }] },
],
};
// A minimal raw ServerResponse stand-in for the turns that PROCEED past setup
// and reach streamText (the deadline + legacy paths). The setup-only abort test
// never wires the stream, so it keeps using `{ raw: {} }`.
function makeRawRes() {
return {
raw: {
writeHead: jest.fn(function writeHead(this: unknown) {
return this;
}),
write: jest.fn(),
once: jest.fn(),
flushHeaders: jest.fn(),
},
};
}
// A fake streamText result: the service only calls consumeStream() and
// pipeUIMessageStreamToResponse() on it (both fire-and-forget). Its terminal
// callbacks are never invoked, so the run is not finalized through them.
function makeStreamResult() {
return {
consumeStream: jest.fn(),
pipeUIMessageStreamToResponse: jest.fn(),
};
}
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();
jest.useRealTimers();
});
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();
});
// Item 1 — the onLateResolve leg of raceAgainstAbortAndTimeout. When `toolsFor`
// loses the race (abort) but RESOLVES LATER with a leased toolset, the setup site
// must release that abandoned toolset's leases (call close() on its client
// handles) so their lease refcount is not pinned forever by a toolset nobody
// consumes. Nothing else exercises this path.
it('releases the leases of a toolset that resolves AFTER the race was already lost (onLateResolve)', async () => {
const runController = new AbortController();
// A controllable build: it hangs until we resolve it by hand, and the run is
// stopped mid-build so the race rejects BEFORE the build settles.
let resolveTools: (v: unknown) => void = () => undefined;
const toolsForPromise = new Promise((resolve) => {
resolveTools = resolve;
});
const toolsFor = jest.fn(() => {
setTimeout(() => runController.abort(new Error('user stop')), 0);
return toolsForPromise;
});
const { svc } = makeService({ toolsFor });
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,
model: {} as never,
role: null,
runHooks: {
begin,
onAssistantSeeded: jest.fn(),
onStep: jest.fn(),
onSettled: jest.fn(),
} as never,
});
// The race is lost to the abort: the turn rejects with the stop reason.
await expect(promise).rejects.toThrow('user stop');
// NOW the abandoned build resolves late with a leased client. onLateResolve must
// release it (call close on the lease handle).
const close = jest.fn().mockResolvedValue(undefined);
resolveTools({
tools: {},
clients: [{ close }],
outcomes: [],
instructions: [],
});
// Flush the microtasks so work.then -> onLateResolve -> Promise.all(close) runs.
await new Promise((r) => setImmediate(r));
expect(close).toHaveBeenCalledTimes(1);
});
// Item 2 — the PURE DEADLINE branch (MCP_TOOLSET_BUILD_DEADLINE_MS). `toolsFor`
// never settles and the run's signal is NOT aborted: the race rejects with a
// "setup timed out" error, the catch does NOT re-throw (runId set but signal not
// aborted), and the turn PROCEEDS Docmost-only. It must reach streamText (the turn
// continues, not wedged) and must NOT be finalized 'aborted'.
it('proceeds Docmost-only (reaches streamText) when the build hits the deadline without an abort', async () => {
jest.useFakeTimers();
// The build hangs forever; the run's signal is never aborted.
const toolsFor = jest.fn(() => new Promise(() => {}));
const { svc } = makeService({ toolsFor });
streamTextMock.mockReturnValue(makeStreamResult() as never);
const onSettled = jest.fn();
const runSignal = new AbortController().signal; // never aborts
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
const promise = svc.stream({
user: { id: 'user-1' } as never,
workspace: { id: 'ws-1' } as never,
sessionId: 'sess-1',
body: body as never,
res: makeRawRes() as never,
signal: new AbortController().signal,
model: {} as never,
role: null,
runHooks: {
begin,
onAssistantSeeded: jest.fn(),
onStep: jest.fn(),
onSettled,
} as never,
});
// Advance past the 60s build deadline; advanceTimersByTimeAsync flushes the
// promise microtasks between timer fires so the whole setup chain runs.
await jest.advanceTimersByTimeAsync(60_001);
// The turn does not throw out of setup — it continues to stream.
await expect(promise).resolves.toBeUndefined();
// The turn CONTINUED: streamText was reached (Docmost-only), not wedged.
expect(toolsFor).toHaveBeenCalledTimes(1);
expect(streamTextMock).toHaveBeenCalledTimes(1);
// The run was NOT finalized as aborted (the deadline is not a Stop) — the setup
// catch settle path never ran, so onSettled is left to streamText's callbacks.
expect(onSettled).not.toHaveBeenCalled();
});
// Item 3 — the LEGACY no-runId path. The catch's re-throw is gated on
// `runId && effectiveSignal.aborted`. With NO runId (no runHooks) an abort during
// setup must NOT re-throw (runId falsy) — the turn warns + proceeds Docmost-only
// and streams, and is never finalized 'aborted' via the re-throw. Locks the
// `runId &&` half of the guard.
it('does NOT re-throw on a setup abort when there is no runId (legacy path proceeds Docmost-only)', async () => {
const socketController = new AbortController();
// The build hangs; the SOCKET signal (legacy effectiveSignal) aborts mid-build.
const toolsFor = jest.fn(() => {
setTimeout(() => socketController.abort(new Error('socket closed')), 0);
return new Promise(() => {});
});
const { svc } = makeService({ toolsFor });
streamTextMock.mockReturnValue(makeStreamResult() as never);
// No runHooks => runId undefined, effectiveSignal === the socket 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: makeRawRes() as never,
signal: socketController.signal,
model: {} as never,
role: null,
});
// The turn does NOT reject out of setup (no re-throw on the legacy path).
await expect(promise).resolves.toBeUndefined();
// It proceeded Docmost-only and reached streamText — streamText then observes
// the already-aborted socket signal via its own abortSignal.
expect(toolsFor).toHaveBeenCalledTimes(1);
expect(streamTextMock).toHaveBeenCalledTimes(1);
});
});
@@ -1,13 +1,4 @@
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 { ForbiddenException } from '@nestjs/common';
import {
AiChatService,
compactToolOutput,
@@ -16,7 +7,6 @@ import {
rowToUiMessage,
prepareAgentStep,
flushAssistant,
stripNulChars,
chatStreamMetadata,
accumulateStepUsage,
isInterruptResume,
@@ -29,13 +19,11 @@ import { buildSystemPrompt } from './ai-chat.prompt';
import type { McpClientsService } from './external-mcp/mcp-clients.service';
/**
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
* before they are persisted (and re-sent to the provider on later turns). The
* contract is: small and normal outputs including whole page reads (tens of
* KB) pass through unchanged (by identity); only an output above the high
* safety cap (> 200 KB) is compacted, and even then it keeps its shape and
* small scalar fields (id/title/pageId the client reads these to render
* citations) while the big payloads are reduced.
* Unit tests for compactToolOutput: the pure helper that shrinks LARGE tool
* outputs before they are persisted (and re-sent to the provider on later
* turns). The contract is: small outputs pass through unchanged (by identity);
* large outputs keep their shape and small scalar fields (id/title/pageId the
* client reads these to render citations) while big payloads are truncated.
*/
describe('compactToolOutput', () => {
it('returns a small object unchanged (by identity)', () => {
@@ -44,7 +32,7 @@ describe('compactToolOutput', () => {
});
it('truncates a large getPage-shaped markdown body but keeps the title', () => {
const big = 'x'.repeat(300000);
const big = 'x'.repeat(20000);
const result = compactToolOutput({ title: 'T', markdown: big }) as {
title: string;
markdown: string;
@@ -52,16 +40,15 @@ describe('compactToolOutput', () => {
// Shallow scalar field is preserved (citations depend on it).
expect(result.title).toBe('T');
// The big payload is shrunk far below the original size.
expect(result.markdown.length).toBeLessThan(300000);
expect(result.markdown).toContain('omitted from stored chat history');
expect(result.markdown.length).toBeLessThan(20000);
expect(result.markdown).toContain('[truncated');
});
it('caps a long array and appends a single truncation marker', () => {
// 200 objects, each padded so the total serialized size
// > 200000 bytes (the new safety cap).
// 200 small objects, each padded so the total serialized size > 4000 bytes.
const long = Array.from({ length: 200 }, (_, i) => ({
id: 'n' + i,
pad: 'y'.repeat(1200),
pad: 'y'.repeat(40),
}));
const result = compactToolOutput(long) as Array<Record<string, unknown>>;
// 50 kept + 1 marker.
@@ -79,8 +66,8 @@ describe('compactToolOutput', () => {
it('replaces a subtree beyond the depth cap with a marker', () => {
// Build a deeply nested object (> TOOL_OUTPUT_MAX_DEPTH levels) with a big
// string at the bottom so the total serialized size exceeds the 200 KB cap.
let nested: Record<string, unknown> = { leaf: 'z'.repeat(250000) };
// string at the bottom so the total serialized size exceeds the threshold.
let nested: Record<string, unknown> = { leaf: 'z'.repeat(8000) };
for (let i = 0; i < 20; i++) {
nested = { child: nested };
}
@@ -89,7 +76,7 @@ describe('compactToolOutput', () => {
});
it('produces a much smaller JSON than the original for a large input', () => {
const big = 'x'.repeat(300000);
const big = 'x'.repeat(20000);
const original = { title: 'T', markdown: big };
const result = compactToolOutput(original);
const originalBytes = Buffer.byteLength(JSON.stringify(original), 'utf8');
@@ -148,53 +135,6 @@ describe('assistantParts', () => {
expect(toolPart).not.toHaveProperty('output');
});
it('replays the REAL error text for a THROWN tool (tool-error part)', () => {
const steps = [
{
text: '',
toolCalls: [
{ toolCallId: 'c7', toolName: 'editPageText', input: { id: 'p1' } },
],
// A thrown tool is a `tool-error` content part; toolResults holds only
// successes and stays empty for this call.
toolResults: [],
content: [
{
type: 'tool-error',
toolCallId: 'c7',
toolName: 'editPageText',
input: { id: 'p1' },
error: new Error('page is locked'),
},
],
},
];
const parts = assistantParts(steps, '') as AnyPart[];
const toolPart = parts.find((p) => p.type === 'tool-editPageText');
expect(toolPart).toBeDefined();
expect(toolPart!.state).toBe('output-error');
// The REAL error is replayed, NOT the 'Tool call did not complete.' placeholder.
expect(toolPart!.errorText).toBe('page is locked');
expect(toolPart).not.toHaveProperty('output');
});
it('keeps the placeholder ONLY for a call with neither result nor tool-error', () => {
const steps = [
{
text: '',
toolCalls: [
{ toolCallId: 'c8', toolName: 'insertNode', input: { node: {} } },
],
toolResults: [],
content: [], // aborted mid-step: no result AND no tool-error
},
];
const parts = assistantParts(steps, '') as AnyPart[];
const toolPart = parts.find((p) => p.type === 'tool-insertNode');
expect(toolPart!.state).toBe('output-error');
expect(toolPart!.errorText).toBe('Tool call did not complete.');
});
it('skips malformed tool-calls (missing toolName or toolCallId)', () => {
const steps = [
{
@@ -242,45 +182,6 @@ describe('serializeSteps', () => {
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } });
});
it('records a THROWN tool failure (tool-error part) with its error message', () => {
const trace = serializeSteps([
{
toolCalls: [{ toolName: 'editPageText', input: { id: 'p1' } }],
toolResults: [],
content: [
{
type: 'tool-error',
toolName: 'editPageText',
error: new Error('page is locked'),
},
],
},
]) as Array<Record<string, unknown>>;
// The call element is followed by a paired error element (mirroring how a
// successful result is appended), so the failure survives in the trace.
expect(trace).toHaveLength(2);
expect(trace[0]).toEqual({ toolName: 'editPageText', input: { id: 'p1' } });
expect(trace[1]).toEqual({
toolName: 'editPageText',
error: 'page is locked',
});
});
it('truncates a very long tool-error message to the tool-output limit', () => {
const long = 'x'.repeat(5000);
const trace = serializeSteps([
{
toolCalls: [{ toolName: 'editPageText', input: {} }],
toolResults: [],
content: [{ type: 'tool-error', toolName: 'editPageText', error: long }],
},
]) as Array<Record<string, unknown>>;
const errorText = trace[1].error as string;
// Truncated (not the full 5000 chars) and carries the omission marker.
expect(errorText.length).toBeLessThan(long.length);
expect(errorText).toContain('chars omitted');
});
});
describe('rowToUiMessage', () => {
@@ -538,45 +439,6 @@ describe('flushAssistant', () => {
});
});
/**
* stripNulChars: a NUL (U+0000) is rejected by Postgres in BOTH the `content`
* (text) and `toolCalls`/`metadata` (jsonb) columns, so it must be stripped from
* every persisted string. String.fromCharCode(0) avoids embedding a raw NUL byte
* in this source file.
*/
describe('stripNulChars', () => {
const NUL = String.fromCharCode(0);
it('deep-strips NUL from strings in nested objects/arrays', () => {
const out = stripNulChars({
content: `a${NUL}b`,
parts: [{ type: 'text', text: `x${NUL}${NUL}y` }],
nested: [`p${NUL}q`, 42, null],
});
expect(out.content).toBe('ab');
expect((out.parts[0] as { text: string }).text).toBe('xy');
expect(out.nested[0]).toBe('pq');
expect(out.nested[1]).toBe(42);
expect(out.nested[2]).toBeNull();
expect(JSON.stringify(out).includes(NUL)).toBe(false);
});
it('returns the SAME reference when there is no NUL (no needless clone)', () => {
const input = { a: 'clean', b: [1, 2, { c: 'ok' }] };
expect(stripNulChars(input)).toBe(input);
});
it('flushAssistant produces a NUL-free row even when the turn text carries one', () => {
const f = flushAssistant([], `partial${NUL}answer`, 'error', {
error: `bo${NUL}om`,
});
expect(f.content).toBe('partialanswer');
const serialized =
f.content + JSON.stringify(f.toolCalls) + JSON.stringify(f.metadata);
expect(serialized.includes(NUL)).toBe(false);
});
});
/**
* chatStreamMetadata: attach metadata to the streamed assistant UI message per
* part type `chatId` on `start` (so the client adopts the real created chat id
@@ -827,7 +689,6 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
id: string;
title: string;
updatedAt: Date;
selection: unknown;
} | null>;
it('returns null when no page is open (no id)', async () => {
@@ -873,14 +734,8 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
});
// The client claims it is on "Page A" but the id points at page B.
const result = await call(svc, { id: 'p-1', title: 'Page A' });
// updatedAt (#274 page-change fast path) is carried through from the DB row;
// selection is null when the client sent none (#388).
expect(result).toEqual({
id: 'p-1',
title: 'Real Title B',
updatedAt,
selection: null,
});
// updatedAt (#274 page-change fast path) is carried through from the DB row.
expect(result).toEqual({ id: 'p-1', title: 'Real Title B', updatedAt });
});
it('coerces a null DB title to an empty string', async () => {
@@ -893,55 +748,8 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
id: 'p-1',
title: '',
updatedAt,
selection: null,
});
});
// #388: the selection rides ONLY on a successful page resolve, and is
// sanitized on the way through.
it('attaches the SANITIZED selection on a successful resolve', async () => {
const updatedAt = new Date('2026-07-02T10:00:00Z');
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Doc', updatedAt },
canView: true,
});
const result = await call(svc, {
id: 'p-1',
selection: {
text: 'fix this',
blockIds: ['b1', 123, 'y'.repeat(65)], // garbage stripped by sanitize
before: 'please ',
},
});
expect(result).toEqual({
id: 'p-1',
title: 'Doc',
updatedAt,
selection: { text: 'fix this', blockIds: ['b1'], before: 'please ' },
});
});
it('drops a blank/garbage selection to null on a successful resolve', async () => {
const updatedAt = new Date('2026-07-02T10:00:00Z');
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Doc', updatedAt },
canView: true,
});
expect(
await call(svc, { id: 'p-1', selection: { text: ' ' } }),
).toEqual({ id: 'p-1', title: 'Doc', updatedAt, selection: null });
});
it('selection does NOT survive a foreign/inaccessible page (dies with the page)', async () => {
// Forbidden page => the WHOLE context is null, so the selection is gone too.
const svc = makeService({
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Restricted' },
canView: false,
});
expect(
await call(svc, { id: 'p-1', selection: { text: 'secret sel' } }),
).toBeNull();
});
});
/**
@@ -1251,181 +1059,3 @@ describe('isInterruptResume', () => {
expect(isInterruptResume(withPrev(null), true)).toBe(false);
});
});
/**
* #184 phase 1.5 the run-wrapped pipe options (unit). Drives stream() to the
* pipe call with streamText mocked, capturing the options object, and asserts:
* - flag OFF while a runId IS present -> the LEGACY option shape (no
* consumeSseStream, no generateMessageId), and the registry is never touched.
* This is the exact dormancy guarantee this PR rests on.
* - flag ON + runId -> consumeSseStream tees into the registry and
* generateMessageId returns the seeded assistant DB row id.
* - flag ON but no runHooks (runId undefined) -> legacy (the runId gate).
* - flag ON + runId -> the outer catch releases the entry via abortEntry.
*/
describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', () => {
const streamTextMock = streamText as unknown as jest.Mock;
let pipeMock: jest.Mock;
beforeEach(() => {
streamTextMock.mockReset();
pipeMock = jest.fn();
streamTextMock.mockReturnValue({
consumeStream: jest.fn(),
pipeUIMessageStreamToResponse: pipeMock,
});
// Silence the service's diagnostic logging for a clean test run.
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as never);
});
afterEach(() => jest.restoreAllMocks());
// A raw-response stub sufficient for the post-streamText wiring.
function makeRes() {
return {
raw: {
writeHead: jest.fn(),
write: jest.fn(),
once: jest.fn(),
on: jest.fn(),
flushHeaders: jest.fn(),
writableEnded: false,
destroyed: false,
},
};
}
// Wire only the deps reached on the way to the pipe call, plus a spy registry.
function makeService(opts: { resumable: boolean }) {
const aiChatRepo = {
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
insert: jest.fn(),
};
const aiChatMessageRepo = {
// Both the user insert and the assistant seed return the same row id.
insert: jest.fn(async () => ({ id: 'msg-1' })),
findAllByChat: jest.fn(async () => []),
update: jest.fn(async () => ({ id: 'msg-1' })),
};
const aiSettings = { resolve: jest.fn(async () => ({})) };
const tools = { forUser: jest.fn(async () => ({})) };
const mcpClients = {
toolsFor: jest.fn(async () => ({
tools: {},
clients: [],
outcomes: [],
instructions: [],
})),
};
const streamRegistry = {
open: jest.fn(),
bind: jest.fn(),
abortEntry: jest.fn(),
};
const svc = new AiChatService(
{} as never, // ai (model is injected)
aiChatRepo as never,
aiChatMessageRepo as never,
{} as never, // aiChatPageSnapshotRepo
aiSettings as never,
tools as never,
mcpClients as never,
{} as never, // aiAgentRoleRepo
{} as never, // pageRepo (openPage undefined -> never touched)
{} as never, // pageAccess
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => opts.resumable,
} as never,
streamRegistry as never,
);
return { svc, streamRegistry };
}
const body = {
chatId: 'chat-1',
messages: [
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
],
};
const makeRunHooks = () => ({
begin: jest.fn(async () => ({
runId: 'run-1',
signal: new AbortController().signal,
})),
onAssistantSeeded: jest.fn(),
onStep: jest.fn(),
onSettled: jest.fn(),
});
async function drive(svc: AiChatService, hooks: unknown): Promise<void> {
await svc.stream({
user: { id: 'u1' } as never,
workspace: { id: 'ws-1' } as never,
sessionId: 's1',
body: body as never,
res: makeRes() as never,
signal: new AbortController().signal,
model: {} as never,
role: null,
runHooks: hooks as never,
});
}
it('flag OFF + runId present: LEGACY option shape (no consumeSseStream / generateMessageId); registry untouched', async () => {
const { svc, streamRegistry } = makeService({ resumable: false });
await drive(svc, makeRunHooks());
expect(pipeMock).toHaveBeenCalledTimes(1);
const options = pipeMock.mock.calls[0][1];
// The dormancy guarantee: a live run with the flag off tees NOTHING and does
// not stamp a message id — byte-for-byte the pre-1.5 wire.
expect(options.consumeSseStream).toBeUndefined();
expect(options.generateMessageId).toBeUndefined();
expect(streamRegistry.bind).not.toHaveBeenCalled();
expect(streamRegistry.abortEntry).not.toHaveBeenCalled();
});
it('flag ON + runId: consumeSseStream tees into the registry; generateMessageId returns the seeded row id', async () => {
const { svc, streamRegistry } = makeService({ resumable: true });
await drive(svc, makeRunHooks());
const options = pipeMock.mock.calls[0][1];
expect(typeof options.consumeSseStream).toBe('function');
expect(typeof options.generateMessageId).toBe('function');
// generateMessageId stamps the seeded assistant DB row id.
expect(options.generateMessageId()).toBe('msg-1');
// consumeSseStream binds the tee: (chatId, runId, assistantId, stream).
const fakeStream = {} as ReadableStream<string>;
options.consumeSseStream({ stream: fakeStream });
expect(streamRegistry.bind).toHaveBeenCalledWith(
'chat-1',
'run-1',
'msg-1',
fakeStream,
);
});
it('flag ON but NO runHooks (runId undefined): pipe options stay legacy (the runId gate)', async () => {
const { svc, streamRegistry } = makeService({ resumable: true });
await drive(svc, undefined);
const options = pipeMock.mock.calls[0][1];
expect(options.consumeSseStream).toBeUndefined();
expect(options.generateMessageId).toBeUndefined();
expect(streamRegistry.bind).not.toHaveBeenCalled();
});
it('flag ON + runId: the outer catch calls abortEntry when the stream throws', async () => {
const { svc, streamRegistry } = makeService({ resumable: true });
streamTextMock.mockImplementation(() => {
throw new Error('boom');
});
await expect(drive(svc, makeRunHooks())).rejects.toThrow('boom');
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
});
});
+28 -345
View File
@@ -32,7 +32,6 @@ import {
import { AiChatToolsService } from './tools/ai-chat-tools.service';
import { McpClientsService } from './external-mcp/mcp-clients.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { AiChatStreamRegistryService } from './ai-chat-stream-registry.service';
import { buildSystemPrompt } from './ai-chat.prompt';
import {
CORE_TOOL_KEYS,
@@ -43,10 +42,6 @@ import {
} from './tools/tool-tiers';
import { RunAlreadyActiveError } from './ai-chat-run.service';
import { computePageChange } from './page-change/page-change.util';
import {
sanitizeSelection,
type SelectionContext,
} from './tools/current-page.util';
import { roleModelOverride } from './roles/role-model-config';
import {
startSseHeartbeat,
@@ -58,19 +53,6 @@ import {
// multi-search research questions are not cut off mid-investigation.
const MAX_AGENT_STEPS = 20;
// Wall-clock ceiling for building the external MCP toolset during the per-turn
// setup phase (before streamText owns the lifecycle). Defense-in-depth ABOVE the
// per-server connect bound in mcp-clients.service (CONNECT_TIMEOUT_MS): even if
// that per-server timeout regressed, this outer deadline — together with the run's
// abort signal — guarantees the setup phase can never wedge a turn at step 0 (the
// production hang) and the run always finalizes. It stays a TRUE backstop because
// buildEntry connects to the servers CONCURRENTLY, so the total build time is
// bounded by the SLOWEST single server (~2×CONNECT_TIMEOUT_MS), not the SUM across
// them — the per-server bound fires first no matter how many servers are enabled,
// and this outer deadline only catches a total build stall the per-server bound
// somehow missed.
const MCP_TOOLSET_BUILD_DEADLINE_MS = 60_000;
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
// It forbids further tool calls and tells the model to synthesize the best
// answer it can from what it already gathered, so a tool-heavy turn never ends
@@ -189,78 +171,6 @@ export function sameInstant(
return ta === tb;
}
/**
* Race `work` against an abort signal AND a wall-clock deadline, so a hung
* external-MCP toolset build during the pre-streamText setup phase can NEITHER
* wedge the turn NOR make it un-stoppable, and the run always finalizes. It
* - resolves with `work`'s value when it settles first;
* - REJECTS EARLY if `signal` aborts (with `signal.reason` when that is an Error,
* else a generic `Error('aborted')`) so an explicit Stop is honored mid-setup;
* - REJECTS EARLY if `deadlineMs` elapses (defense-in-depth backstop);
* - invokes `onLateResolve(value)` when `work` settles AFTER the race was already
* lost, so the caller can release any resources that abandoned value owns
* (e.g. close leased MCP clients that would otherwise leak their sockets).
*
* A rejection handler is attached to `work` so a late rejection is never an
* unhandledRejection; the timer is unref'd and cleared once the race settles.
*/
export function raceAgainstAbortAndTimeout<T>(
work: Promise<T>,
signal: AbortSignal,
deadlineMs: number,
onLateResolve?: (value: T) => void,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const cleanup = () => {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
};
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(
signal.reason instanceof Error ? signal.reason : new Error('aborted'),
);
};
const timer = setTimeout(() => {
if (settled) return;
settled = true;
cleanup();
reject(new Error(`setup timed out after ${deadlineMs}ms`));
}, deadlineMs);
// Do not keep the process alive just for this setup-deadline timer.
timer.unref?.();
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener('abort', onAbort, { once: true });
}
work.then(
(value) => {
if (settled) {
// The race was already lost (abort/deadline): hand the abandoned value to
// the caller so it can release the resources that value owns.
onLateResolve?.(value);
return;
}
settled = true;
cleanup();
resolve(value);
},
(err: unknown) => {
// A late rejection after the race is already handled — swallow so it is
// never an unhandledRejection.
if (settled) return;
settled = true;
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
},
);
});
}
/**
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
@@ -278,12 +188,7 @@ export interface AiChatStreamBody {
// page" refers to; the page itself is never fetched server-side here. The id
// is attacker-controllable but harmless: the agent reads/writes via its
// CASL-enforced page tools, which 403 on a page the user cannot access.
//
// `selection` is the user's editor selection snapshotted client-side at send
// time (#388). It is CLIENT-controlled and UNTRUSTED — a loose `unknown` here
// (the body is parsed off req.body without a DTO) that is type-checked and
// capped by `sanitizeSelection` before it is ever surfaced to the model.
openPage?: { id?: string; title?: string; selection?: unknown } | null;
openPage?: { id?: string; title?: string } | null;
// Set by the client "send now" action (#198): this turn immediately follows a
// user interruption of the previous turn. A hint only — the server re-confirms
// it against persisted history (`isInterruptResume`) before injecting the
@@ -371,10 +276,6 @@ export class AiChatService implements OnModuleInit {
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
// positional constructor callers (tests) only append one stub.
private readonly environment: EnvironmentService,
// #184 phase 1.5 run-stream registry. OPTIONAL so existing positional
// constructions (int-specs) compile unchanged; Nest always injects the real
// provider in production. Only ever touched on the run-wrapped + flag-on path.
private readonly streamRegistry?: AiChatStreamRegistryService,
) {}
/**
@@ -459,18 +360,10 @@ export class AiChatService implements OnModuleInit {
* page, or any non-Forbidden access-check fault, returns null.
*/
private async resolveOpenPageContext(
openPage:
| { id?: string; title?: string; selection?: unknown }
| null
| undefined,
openPage: { id?: string; title?: string } | null | undefined,
workspace: Workspace,
user: User,
): Promise<{
id: string;
title: string;
updatedAt: Date;
selection: SelectionContext | null;
} | null> {
): Promise<{ id: string; title: string; updatedAt: Date } | null> {
const candidatePageId = openPage?.id;
if (!candidatePageId) return null;
const page = await this.pageRepo.findById(candidatePageId);
@@ -492,18 +385,7 @@ export class AiChatService implements OnModuleInit {
// updatedAt is the page's last-modified instant, used by the #274 per-turn
// page-change detection as a cheap fast path (unchanged instant => skip the
// render + diff). The system-prompt / tool consumers ignore the extra field.
//
// The sanitized editor selection (#388) is attached ONLY here, on a
// successful page resolve: the fail-closed branches above return null for the
// WHOLE context, so a selection can never outlive a foreign/missing/deleted
// page (decision 3). Downstream consumers that don't care (detectPageChange,
// snapshotOpenPage) ignore the extra field, same as updatedAt.
return {
id: page.id,
title: page.title ?? '',
updatedAt: page.updatedAt,
selection: sanitizeSelection(openPage?.selection),
};
return { id: page.id, title: page.title ?? '', updatedAt: page.updatedAt };
}
/**
@@ -789,43 +671,10 @@ export class AiChatService implements OnModuleInit {
instructions: [],
};
try {
// Bound the external-MCP toolset build by BOTH the run's abort signal and
// a generous wall-clock deadline. This is the pre-streamText setup phase,
// which streamText's terminal callbacks do NOT yet govern — so without this
// a hung build would hang the turn at step 0 forever (the production hang),
// unobservant of an explicit Stop. The deadline is defense-in-depth ABOVE
// the per-server connect bound in mcp-clients.service. On a LATE resolve
// (the race was already lost) RELEASE the abandoned toolset's leases —
// c.close() here is the lease handle, so it decrements the cache entry's
// refcount; it does NOT force-close the transports (the cache OWNS the
// clients and closes them on TTL/evict). This just prevents the lease
// refcount from being pinned >=1 forever by a toolset nobody will consume.
external = await raceAgainstAbortAndTimeout(
this.mcpClients.toolsFor(workspace.id),
effectiveSignal,
MCP_TOOLSET_BUILD_DEADLINE_MS,
(late) => {
void Promise.all(
late.clients.map((c) => c.close().catch(() => undefined)),
);
},
);
external = await this.mcpClients.toolsFor(workspace.id);
} catch (err) {
// An explicit Stop reached the RUN's signal DURING setup: re-throw so the
// outer catch finalizes the run as aborted — never swallow a Stop. Gated on
// `runId`: the re-throw exists ONLY to finalize the run, which exists only
// in autonomous mode. On the legacy path (no runId) `effectiveSignal` is the
// SOCKET signal (it aborts on a client disconnect); re-throwing there would
// change prior behavior and make the controller write JSON to an already-
// closed socket (it only attaches res.raw.on('error') in autonomous mode).
// So legacy keeps its prior behavior — warn + proceed, and streamText then
// observes the aborted socket signal.
if (runId && effectiveSignal.aborted) {
throw err;
}
// Otherwise a down/slow server (build timeout or other fault) must never
// break the turn: proceed with Docmost-only tools. Never log URLs/headers —
// short message only.
// Building the external toolset must never break the turn; proceed with
// Docmost-only tools. Never log URLs/headers — short message only.
this.logger.warn(
`External MCP toolset unavailable: ${
err instanceof Error ? err.message : 'unknown error'
@@ -1325,35 +1174,6 @@ export class AiChatService implements OnModuleInit {
// as the cumulative authoritative usage so the client never jumps DOWN.
let cumulativeStepUsage: ChatStreamUsage | undefined;
result.pipeUIMessageStreamToResponse(res.raw, {
// #184 phase 1.5: run-wrapped mode only — the legacy path (flag off) stays
// byte-for-byte identical, including the absence of start.messageId. Both
// fields are gated on `runId` (present only for a durable run) AND the
// AI_CHAT_RESUMABLE_STREAM flag; the seed `assistantId` is unconditional,
// so gating on `assistantId` alone would change the legacy wire.
...(runId && this.environment?.isAiChatResumableStreamEnabled?.()
? {
// Tee the SSE frames into the run-stream registry so late tabs can
// attach (replay + live tail).
consumeSseStream: ({
stream,
}: {
stream: ReadableStream<string>;
}) =>
this.streamRegistry?.bind(
chatId,
runId!,
assistantId,
stream,
),
// Stamp the persisted assistant row's DB id onto the streamed
// message so every tab renders the SAME id as the DB row (id-based
// reconciliation). Seeding is best-effort: when it failed, let the
// client generate the id.
...(assistantId
? { generateMessageId: () => assistantId }
: {}),
}
: {}),
headers: { 'X-Accel-Buffering': 'no' },
// Surface the authoritative chatId on the streamed assistant UI message so
// the client adopts the REAL id of the row we created, instead of guessing
@@ -1419,25 +1239,12 @@ export class AiChatService implements OnModuleInit {
// finalizeRun (onSettled) is idempotent — a settle here and a settle from a
// streamText callback collapse to a single terminal write.
if (runId) {
// #184 phase 1.5: a failure here means the tee `done` will never arrive,
// so release the registry entry's subscribers explicitly — otherwise an
// attached tab hangs forever. Same flag gate as the tee wiring above.
if (this.environment?.isAiChatResumableStreamEnabled?.()) {
this.streamRegistry?.abortEntry(chatId, runId);
}
// Distinguish an explicit Stop (the run's signal aborted during setup) from
// a real failure, so the run settles with the correct terminal status
// instead of always 'error'. onSettled/finalizeRun is idempotent, so this
// is safe even if a streamText callback also settles the run.
const settleStatus = effectiveSignal.aborted ? 'aborted' : 'error';
await runHooks?.onSettled?.(
runId,
settleStatus,
settleStatus === 'aborted'
? undefined
: err instanceof Error
? err.message
: 'Agent run failed before streaming started',
'error',
err instanceof Error
? err.message
: 'Agent run failed before streaming started',
);
}
throw err;
@@ -1637,41 +1444,21 @@ type StepLike = {
toolName?: string;
output?: unknown;
}>;
// ai@6.0.134: a tool that THREW surfaces as a `tool-error` content part
// ({ type:'tool-error', toolCallId, toolName, input, error }), NOT as a
// `toolResults` entry (which holds only successes). Read from here so failed
// calls are persisted with their real error instead of being dropped.
content?: ReadonlyArray<{
type?: string;
toolCallId?: string;
toolName?: string;
input?: unknown;
error?: unknown;
}>;
};
/**
* Compaction tunables for persisted tool OUTPUTS. Read tools (getPage,
* getPageJson, getNode, diffPageVersions, exportPageMarkdown, ...) return whole
* pages. Their outputs are stored in `metadata.parts` and RE-SENT to the
* provider on every later turn via convertToModelMessages. We deliberately keep
* these outputs FULL up to a high safety cap (MAX_TOOL_OUTPUT_BYTES) so the
* model never sees a shortened copy of content it already fetched: an earlier
* 4000-byte cap shrank normal page reads (often tens of KB) to a tiny preview,
* and the model seeing a truncation marker in its OWN history re-read the
* same page, wasting tokens. Only a single output LARGER than the cap is
* compacted at all, purely as a backstop against a pathological payload; even
* then we preserve the object's shape and its small scalar fields
* (id/title/pageId) that the client reads to render citations.
* pages with no size cap. Their outputs are stored in `metadata.parts` and
* RE-SENT to the provider on every later turn via convertToModelMessages, so an
* uncompacted large body grows token cost, latency, and DB row size on every
* turn. We shrink the big payloads while preserving the object's shape and its
* small scalar fields (id/title/pageId) the client reads to render citations.
*/
// HIGH safety backstop: only an output whose JSON serialization EXCEEDS this is
// compacted at all. Normal reads (whole pages, tens of KB) stay well under it
// and are stored + replayed VERBATIM (fast path: returned unchanged, by
// identity). Only a single pathologically huge output (> 200 KB) is compacted.
const MAX_TOOL_OUTPUT_BYTES = 200_000;
// Inside the backstop path only (i.e. once the whole output already exceeded
// MAX_TOOL_OUTPUT_BYTES), a string longer than this is reduced to a leading
// preview; normal outputs never reach this branch.
// Only outputs whose JSON serialization exceeds this are compacted at all
// (fast path: smaller outputs are returned unchanged, by identity).
const MAX_TOOL_OUTPUT_BYTES = 4000;
// A string longer than this is truncated to a leading preview.
const TOOL_OUTPUT_STRING_LIMIT = 600;
// Number of leading characters kept from a truncated string.
const TOOL_OUTPUT_STRING_PREVIEW = 500;
@@ -1714,9 +1501,9 @@ export function compactToolOutput(output: unknown): unknown {
function compactValue(value: unknown, depth: number): unknown {
if (typeof value === 'string') {
if (value.length > TOOL_OUTPUT_STRING_LIMIT) {
return `${value.slice(0, TOOL_OUTPUT_STRING_PREVIEW)}…[${
return `${value.slice(0, TOOL_OUTPUT_STRING_PREVIEW)}…[truncated ${
value.length - TOOL_OUTPUT_STRING_PREVIEW
} chars omitted from stored chat history to bound replay size call the tool again to read the full output]`;
} chars]`;
}
return value;
}
@@ -1750,26 +1537,6 @@ function compactValue(value: unknown, depth: number): unknown {
return value;
}
/**
* Extract a bounded string message from a `tool-error` part's `error` field for
* persistence and history replay. The field may be an `Error`, a string, or an
* arbitrary object, so pull a message robustly. The result is passed through
* `compactValue` so a very long error honors the SAME truncation limits the file
* already applies to tool outputs (no new limit is introduced here).
*/
function normalizeToolError(error: unknown): string {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: error != null &&
typeof (error as { message?: unknown }).message === 'string'
? (error as { message: string }).message
: String(error);
return compactValue(message, 0) as string;
}
/**
* Rebuild the FULL UIMessage `parts` for an assistant turn from the SDK steps,
* so multi-turn history replays prior tool-calls/results to the model (not just
@@ -1802,14 +1569,6 @@ export function assistantParts(
for (const r of step.toolResults ?? []) {
if (r.toolCallId) resultsById.set(r.toolCallId, r.output);
}
// Index this step's THROWN tool failures (ai@6 `tool-error` content parts)
// by tool call id, so a call that failed replays with its real error text.
const errorsById = new Map<string, unknown>();
for (const part of step.content ?? []) {
if (part.type === 'tool-error' && part.toolCallId) {
errorsById.set(part.toolCallId, part.error);
}
}
for (const call of step.toolCalls ?? []) {
if (!call.toolName || !call.toolCallId) continue;
const hasResult = resultsById.has(call.toolCallId);
@@ -1822,21 +1581,9 @@ export function assistantParts(
input: call.input,
output: compactToolOutput(resultsById.get(call.toolCallId)),
});
} else if (errorsById.has(call.toolCallId)) {
// The tool THREW: replay the REAL error so the model on the next turn
// knows WHY the call failed (and does not blindly repeat it). An
// output-error round-trips through convertToModelMessages as a balanced
// tool-call + tool-result, keeping the rebuilt history valid.
parts.push({
type: `tool-${call.toolName}`,
toolCallId: call.toolCallId,
state: 'output-error',
input: call.input,
errorText: normalizeToolError(errorsById.get(call.toolCallId)),
});
} else {
// No paired result AND no tool-error (e.g. aborted mid-step). Persisting
// a bare tool-call (input-available) would replay as an unpaired call and
// No paired result (e.g. aborted mid-step). Persisting a bare
// tool-call (input-available) would replay as an unpaired call and
// throw MissingToolResultsError on the next turn (convertToModelMessages
// emits no tool-result for it). Emit a SYNTHETIC paired result instead:
// an output-error round-trips through convertToModelMessages as a
@@ -1943,45 +1690,6 @@ export async function applyFinalize(
});
}
/**
* Deep-strip NUL characters (`\u0000`) from every string in a value, returning
* the SAME reference when nothing changed (so the no-NUL common case allocates
* nothing). Postgres rejects a NUL in BOTH `text` and `jsonb` columns ("invalid
* input syntax for type json" / "unsupported Unicode escape sequence"), so a
* stray NUL in model output or a tool result e.g. a truncated multibyte read
* of a web page otherwise fails EVERY persist of the assistant row, silently
* dropping that turn's content from the DB while the live stream still shows it.
* Applied at the flushAssistant choke point so content + toolCalls + metadata are
* all covered. Exported for the unit test.
*/
export function stripNulChars<T>(value: T): T {
if (typeof value === 'string') {
return (value.includes('\u0000')
? value.replace(/\u0000/g, '')
: value) as T;
}
if (Array.isArray(value)) {
let changed = false;
const out = value.map((v) => {
const s = stripNulChars(v);
if (s !== v) changed = true;
return s;
});
return (changed ? out : value) as T;
}
if (value && typeof value === 'object') {
let changed = false;
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const s = stripNulChars(v);
if (s !== v) changed = true;
out[k] = s;
}
return (changed ? out : value) as T;
}
return value;
}
/**
* PURE assistant-row builder (#183 step-granular durability). Given the turn's
* accumulated steps + the in-progress (not-yet-finished) text + the lifecycle
@@ -2051,16 +1759,12 @@ export function flushAssistant(
};
}
// Strip NUL chars from the whole row before persisting: Postgres rejects a NUL
// in both the `content` (text) and `toolCalls`/`metadata` (jsonb) columns, and a
// single stray NUL in model/tool output would otherwise fail EVERY write of this
// row and silently drop the turn's content from the DB (see stripNulChars).
return stripNulChars({
return {
content: stepsText + trailing,
toolCalls: serializeSteps(finished),
metadata,
status,
});
};
}
/**
@@ -2072,19 +1776,10 @@ export function serializeSteps(
steps: ReadonlyArray<{
toolCalls?: ReadonlyArray<{ toolName?: string; input?: unknown }>;
toolResults?: ReadonlyArray<{ toolName?: string; output?: unknown }>;
content?: ReadonlyArray<{
type?: string;
toolName?: string;
error?: unknown;
}>;
}>,
): unknown {
const calls: Array<{
toolName?: string;
input?: unknown;
output?: unknown;
error?: string;
}> = [];
const calls: Array<{ toolName?: string; input?: unknown; output?: unknown }> =
[];
for (const step of steps ?? []) {
for (const call of step.toolCalls ?? []) {
calls.push({ toolName: call.toolName, input: call.input });
@@ -2092,18 +1787,6 @@ export function serializeSteps(
for (const r of step.toolResults ?? []) {
calls.push({ toolName: r.toolName, output: compactToolOutput(r.output) });
}
// ai@6 surfaces a THROWN tool failure as a `tool-error` content part, NOT as
// a `toolResults` entry. Record it as its own paired element (mirroring how a
// successful result is appended) so the failure and its reason survive in the
// trace instead of leaving an orphaned call with no result.
for (const part of step.content ?? []) {
if (part.type === 'tool-error') {
calls.push({
toolName: part.toolName,
error: normalizeToolError(part.error),
});
}
}
}
return calls.length > 0 ? calls : null;
}
@@ -181,25 +181,25 @@ describe('mcp timeout env helpers', () => {
else process.env.AI_MCP_CALL_TIMEOUT_MS = ORIG_CALL;
});
it('mcpStreamTimeoutMs defaults to 1 min and honors a positive override', () => {
it('mcpStreamTimeoutMs defaults to 5 min and honors a positive override', () => {
delete process.env.AI_MCP_STREAM_TIMEOUT_MS;
expect(mcpStreamTimeoutMs()).toBe(300_000);
process.env.AI_MCP_STREAM_TIMEOUT_MS = '60000';
expect(mcpStreamTimeoutMs()).toBe(60_000);
process.env.AI_MCP_STREAM_TIMEOUT_MS = '90000';
expect(mcpStreamTimeoutMs()).toBe(90_000);
for (const bad of ['0', '-1', 'x', '']) {
process.env.AI_MCP_STREAM_TIMEOUT_MS = bad;
expect(mcpStreamTimeoutMs()).toBe(60_000);
expect(mcpStreamTimeoutMs()).toBe(300_000);
}
});
it('mcpCallTimeoutMs defaults to 2 min and honors a positive override', () => {
it('mcpCallTimeoutMs defaults to 15 min and honors a positive override', () => {
delete process.env.AI_MCP_CALL_TIMEOUT_MS;
expect(mcpCallTimeoutMs()).toBe(900_000);
process.env.AI_MCP_CALL_TIMEOUT_MS = '120000';
expect(mcpCallTimeoutMs()).toBe(120_000);
process.env.AI_MCP_CALL_TIMEOUT_MS = '180000';
expect(mcpCallTimeoutMs()).toBe(180_000);
for (const bad of ['0', '-1', 'x', '']) {
process.env.AI_MCP_CALL_TIMEOUT_MS = bad;
expect(mcpCallTimeoutMs()).toBe(120_000);
expect(mcpCallTimeoutMs()).toBe(900_000);
}
});
});
@@ -1,237 +0,0 @@
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 }> {
let client: McpClient | undefined;
try {
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
client = await this.connect(server);
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
return { ok: true, tools: Object.keys(raw) };
} catch (err) {
@@ -255,96 +255,49 @@ export class McpClientsService {
const callTimeoutMs = mcpCallTimeoutMs();
const instructions: McpServerInstruction[] = [];
// Per-server connect+tools result, still tagged with its server so the merge
// below can be applied in the SAME order as `servers` (see the parallel note).
type PerServerResult =
| { ok: true; client: McpClient; guarded: Record<string, Tool> }
| { ok: false; reason: string };
// Connect to (and list tools for) every enabled server CONCURRENTLY, so the
// total build time is bounded by the SLOWEST single server (~2×
// CONNECT_TIMEOUT_MS: connect + tools), NOT the SUM across servers. The
// sequential loop this replaced summed those bounds, so with enough all-timing-
// out servers the outer MCP_TOOLSET_BUILD_DEADLINE_MS could fire before the
// per-server bounds, dropping ALL external tools and inverting the "per-server
// bound is primary, outer is a backstop" invariant. Each server keeps its OWN
// try/catch + connectWithTimeout/withTimeout bound + close-on-failure logic; a
// failed server is skipped, never fatal. Nothing here mutates the shared
// arrays — every result is merged IN SERVER ORDER after Promise.all, so tool-
// key precedence/disambiguation, `outcomes`, `instructions` and `clients`
// ordering all match the previous sequential behavior exactly.
const perServer = async (
server: (typeof servers)[number],
): Promise<PerServerResult> => {
// Track the connected client so the catch can close it when it was obtained
// but tools() then threw/timed out (connectWithTimeout closes its OWN orphan
// on a connect timeout, so `client` stays undefined on that path). On success
// the client is handed back and registered by the merge below (owned by the
// entry, closed at teardown) — so it is never double-closed.
let client: McpClient | undefined;
for (const server of servers) {
try {
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
const client = await this.connect(server);
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
clients.push(client);
const allow = server.toolAllowlist;
const picked =
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
// Bound each tool's execute with a per-call total-timeout guard before
// merging, so a single chatty-but-stuck call is aborted after the cap.
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
return { ok: true, client, guarded };
} catch (err) {
// A failed server is skipped — the turn proceeds with the rest. If connect
// returned a live client but a later step (tools()) threw, that client was
// 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) {
void client.close().catch(() => undefined);
// Namespace each tool with the sanitized server name AND disambiguate
// against names already merged from earlier servers, so no external
// tool is silently overwritten on collision. The returned count drives
// whether this server's prompt guidance is included (≥1 tool merged).
const merged = this.mergeNamespaced(
tools,
guarded,
server.name,
server.id,
);
outcomes.push({ name: server.name, ok: true });
// Include this server's guidance ONLY when it actually contributed at
// least one tool the agent can call (allowlist may have filtered all of
// them out) AND the admin authored non-blank instructions. The header
// prefix is the sanitized server name (= the tool namespace prefix).
const guide = server.instructions?.trim();
if (merged.count > 0 && guide) {
instructions.push({
serverName: server.name,
toolPrefix: merged.prefix,
instructions: guide,
});
}
// 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".
} catch (err) {
// A failed server is skipped — the turn proceeds with the rest. 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);
this.logger.warn(
`External MCP server "${server.name}" unavailable: ${reason}`,
);
return { ok: false, reason };
}
};
// Promise.all preserves array order regardless of settle order, so `results[i]`
// is `servers[i]`'s outcome — the merge below stays deterministic and matches
// the old sequential order (later servers still override/disambiguate against
// earlier ones on a tool-key clash).
const results = await Promise.all(servers.map(perServer));
for (let i = 0; i < servers.length; i += 1) {
const server = servers[i];
const result = results[i];
if (result.ok !== true) {
outcomes.push({ name: server.name, ok: false, reason: result.reason });
continue;
}
clients.push(result.client);
// Namespace each tool with the sanitized server name AND disambiguate
// against names already merged from earlier servers, so no external
// tool is silently overwritten on collision. The returned count drives
// whether this server's prompt guidance is included (≥1 tool merged).
const merged = this.mergeNamespaced(
tools,
result.guarded,
server.name,
server.id,
);
outcomes.push({ name: server.name, ok: true });
// Include this server's guidance ONLY when it actually contributed at
// least one tool the agent can call (allowlist may have filtered all of
// them out) AND the admin authored non-blank instructions. The header
// prefix is the sanitized server name (= the tool namespace prefix).
const guide = server.instructions?.trim();
if (merged.count > 0 && guide) {
instructions.push({
serverName: server.name,
toolPrefix: merged.prefix,
instructions: guide,
});
outcomes.push({ name: server.name, ok: false, reason });
}
}
@@ -430,55 +383,6 @@ export class McpClientsService {
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
* plaintext headers live only in this returned object and are passed straight
@@ -556,12 +460,12 @@ export function validateResolvedAddresses(addrs: readonly LookupAddress[]): {
*/
function buildPinnedDispatcher(): Agent {
// External-MCP traffic uses a DEDICATED, shorter silence timeout
// (`AI_MCP_STREAM_TIMEOUT_MS`, default 1 min) — deliberately tighter than the
// (`AI_MCP_STREAM_TIMEOUT_MS`, default 5 min) — deliberately tighter than the
// chat provider's 15-min `streamTimeoutMs()` — so a byte-silent/hung MCP
// upstream is broken in ~1 min instead of 15. We keep the keep-alive options
// upstream is broken in ~5 min instead of 15. We keep the keep-alive options
// from `streamingDispatcherOptions()` but OVERRIDE headers/body timeouts.
// Accepted trade-off: a legitimately long but byte-silent single tool call,
// and an SSE transport idling >1 min BETWEEN tool calls, are also cut here; the
// and an SSE transport idling >5 min BETWEEN tool calls, are also cut here; the
// per-call total cap (wrapToolsWithCallTimeout, `AI_MCP_CALL_TIMEOUT_MS`) is the
// complementary guard for chatty-but-stuck calls that keep the socket warm yet
// never return.
@@ -52,7 +52,7 @@ export class CreateAgentRoleDto {
description?: string;
@IsString()
@MaxLength(100000)
@MaxLength(20000)
instructions: string;
// null/omitted => use the workspace default model.
@@ -102,7 +102,7 @@ export class UpdateAgentRoleDto {
@IsOptional()
@IsString()
@MaxLength(100000)
@MaxLength(20000)
instructions?: string;
@IsOptional()
@@ -651,188 +651,3 @@ describe('AiChatToolsService #294 changed execute wirings', () => {
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
});
});
/**
* #410 the footnote + image tools were promoted from MCP-only into the shared
* registry and are now wired in-app. Assert they are REGISTERED in the in-app
* toolset and forward their args to the client with the correct arg->method
* mapping (the schema fields `imageUrl`/`attachmentId` map onto the client's
* positional `url`/`oldAttachmentId`). A field destructured under the wrong name
* would silently pass `undefined` (execute is `any`-cast, so tsc won't catch it).
*/
describe('AiChatToolsService #410 footnote + image tools', () => {
const calls: Record<string, unknown[][]> = {
insertFootnote: [],
insertImage: [],
replaceImage: [],
};
const fakeClient: Partial<DocmostClientLike> = {
insertFootnote: (...args: unknown[]) => {
calls.insertFootnote.push(args);
return Promise.resolve({ success: true, footnoteId: 'fn1', reused: false });
},
insertImage: (...args: unknown[]) => {
calls.insertImage.push(args);
return Promise.resolve({ success: true, attachmentId: 'att1' });
},
replaceImage: (...args: unknown[]) => {
calls.replaceImage.push(args);
return Promise.resolve({ success: true, replaced: 1 });
},
};
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('registers all three tools in the in-app toolset', async () => {
const tools = await buildTools();
expect(tools.insertFootnote).toBeDefined();
expect(tools.insertImage).toBeDefined();
expect(tools.replaceImage).toBeDefined();
});
it('insertFootnote forwards (pageId, anchorText, text) positionally', async () => {
const tools = await buildTools();
const r = await tools.insertFootnote.execute(
{ pageId: 'p1', anchorText: 'the claim', text: 'See source.' } as never,
{} as never,
);
expect(calls.insertFootnote).toEqual([['p1', 'the claim', 'See source.']]);
expect(r).toMatchObject({ footnoteId: 'fn1' });
});
it('insertImage maps imageUrl->url and packs the option fields', async () => {
const tools = await buildTools();
await tools.insertImage.execute(
{
pageId: 'p1',
imageUrl: 'https://x/img.png',
align: 'center',
alt: 'A',
replaceText: '[img]',
afterText: undefined,
} as never,
{} as never,
);
expect(calls.insertImage).toEqual([
[
'p1',
'https://x/img.png',
{ align: 'center', alt: 'A', replaceText: '[img]', afterText: undefined },
],
]);
});
it('replaceImage maps attachmentId->oldAttachmentId and imageUrl->url', async () => {
const tools = await buildTools();
await tools.replaceImage.execute(
{
pageId: 'p1',
attachmentId: 'att-old',
imageUrl: 'https://x/new.png',
align: 'right',
alt: 'B',
} as never,
{} as never,
);
expect(calls.replaceImage).toEqual([
['p1', 'att-old', 'https://x/new.png', { align: 'right', alt: 'B' }],
]);
});
});
/**
* 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,7 @@ import {
type DocmostClientLike,
type SharedToolSpec,
} from './docmost-client.loader';
import {
resolveCurrentPageResult,
type SelectionContext,
} from './current-page.util';
import { resolveCurrentPageResult } from './current-page.util';
import { parseNodeArg } from './parse-node-arg';
import { modelFriendlyInput } from './model-friendly-input';
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
@@ -156,13 +153,8 @@ export class AiChatToolsService {
// The page the user currently has open (from the request context), exposed
// to the model via getCurrentPage. Optional and last so existing callers
// keep compiling. Kept proxy-robust: the model can CALL for the current
// page instead of relying on it surviving in the system prompt text. The
// `selection` (#388) is already sanitized + nested by resolveOpenPageContext.
openedPage?: {
id?: string;
title?: string;
selection?: SelectionContext | null;
} | null,
// page instead of relying on it surviving in the system prompt text.
openedPage?: { id?: string; title?: string } | null,
): Promise<Record<string, Tool>> {
// Build the per-user loopback client (carrying the access + collab
// provenance tokens) and load the shared tool-spec registry. Client
@@ -317,15 +309,9 @@ export class AiChatToolsService {
getCurrentPage: tool({
description:
'Return the page the user is currently viewing — i.e. what "this page", ' +
'"the current page", or "here" refers to — plus the text the user ' +
'currently has SELECTED on that page (what "this", "here", "the selected ' +
'fragment" refers to), or selection: null when nothing is selected. The ' +
'selection is a client-side snapshot taken when the user sent the message ' +
'and includes the ids of the blocks it covers plus surrounding context; ' +
'it is NOT verified server-side — locate it in the page (searchInPage / ' +
'getNode) before editing. Returns page: null if the user is not currently ' +
'on a page. Call this first whenever the user refers to the current page ' +
'or a selected fragment without giving an explicit id.',
'"the current page", or "here" refers to. Returns the page id and title, ' +
'or null if the user is not currently on a page. Call this first whenever ' +
'the user refers to the current page without giving an explicit id.',
inputSchema: modelFriendlyInput({}),
execute: async () => resolveCurrentPageResult(openedPage),
}),
@@ -697,67 +683,6 @@ export class AiChatToolsService {
},
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
// Promoted from MCP-only so the in-app agent can attach a REAL footnote to
// already-written text instead of leaving a literal `^[...]` string.
insertFootnote: sharedTool(
sharedToolSpecs.insertFootnote,
async ({ pageId, anchorText, text }) =>
await client.insertFootnote(pageId, anchorText, text),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
// The schema field is `imageUrl`; the client method takes it positionally.
insertImage: sharedTool(
sharedToolSpecs.insertImage,
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) =>
await client.insertImage(pageId, imageUrl, {
align,
alt,
replaceText,
afterText,
}),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
replaceImage: sharedTool(
sharedToolSpecs.replaceImage,
async ({ pageId, attachmentId, imageUrl, align, alt }) =>
await client.replaceImage(pageId, attachmentId, imageUrl, {
align,
alt,
}),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// meta.hash in the result is the baseHash drawioUpdate requires.
drawioGet: sharedTool(
sharedToolSpecs.drawioGet,
async ({ pageId, node, format }) =>
await client.drawioGet(pageId, node, format ?? 'xml'),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// The flat schema fields are regrouped into the client's `where` object.
drawioCreate: sharedTool(
sharedToolSpecs.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
await client.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
),
),
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
// baseHash is the optimistic lock: mismatch => structured conflict error.
drawioUpdate: sharedTool(
sharedToolSpecs.drawioUpdate,
async ({ pageId, node, xml, baseHash }) =>
await client.drawioUpdate(pageId, node, xml, baseHash),
),
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
// The table reference parameter was unified to `table` (was `tableRef`).
tableInsertRow: sharedTool(
@@ -1,180 +1,43 @@
import {
resolveCurrentPageResult,
sanitizeSelection,
} from './current-page.util';
import { resolveCurrentPageResult } from './current-page.util';
/**
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
* getCurrentPage tool's contract: { page: null, selection: null } when no page
* is open (no id), otherwise { page: { id, title }, selection } with title
* defaulting to '' and the selection passed through from the resolved context.
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
* otherwise { page: { id, title } } with title defaulting to ''.
*/
describe('resolveCurrentPageResult', () => {
it('returns { page: null, selection: null } when openedPage is undefined', () => {
expect(resolveCurrentPageResult(undefined)).toEqual({
page: null,
selection: null,
});
it('returns { page: null } when openedPage is undefined', () => {
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
});
it('returns { page: null, selection: null } when openedPage is null', () => {
expect(resolveCurrentPageResult(null)).toEqual({
page: null,
selection: null,
});
it('returns { page: null } when openedPage is null', () => {
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
});
it('returns { page: null, selection: null } when openedPage has no id', () => {
expect(resolveCurrentPageResult({})).toEqual({
page: null,
selection: null,
});
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({
page: null,
selection: null,
});
it('returns { page: null } when openedPage has no id', () => {
expect(resolveCurrentPageResult({})).toEqual({ page: null });
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
});
it('returns { page: null, selection: null } when id is an empty string', () => {
expect(resolveCurrentPageResult({ id: '' })).toEqual({
page: null,
selection: null,
});
it('returns { page: null } when id is an empty string', () => {
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
});
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', () => {
it('returns the page id and title when both are present', () => {
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
page: { id: 'p1', title: 'Hello' },
selection: null,
});
});
it('passes the nested selection through verbatim', () => {
const selection = {
text: 'fix this',
blockIds: ['b1'],
before: 'please ',
after: ' now',
};
expect(
resolveCurrentPageResult({ id: 'p1', title: 'Hello', selection }),
).toEqual({
page: { id: 'p1', title: 'Hello' },
selection,
});
});
it('defaults title to "" when it is missing', () => {
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
page: { id: 'p1', title: '' },
selection: null,
});
});
it('keeps an explicit empty-string title as ""', () => {
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
page: { id: 'p1', title: '' },
selection: null,
});
});
});
/**
* Unit tests for sanitizeSelection (#388). The selection is an attacker-
* controllable client snapshot: every field is type-checked and capped, and
* anything that is not a real selection collapses to null. It is NEVER verified
* against the page content (decision 5 a hint, not ground truth).
*/
describe('sanitizeSelection', () => {
it('accepts a well-formed payload unchanged', () => {
const raw = {
text: 'the selected fragment',
truncated: true,
blockIds: ['b1', 'b2'],
before: 'context before ',
after: ' context after',
};
expect(sanitizeSelection(raw)).toEqual(raw);
});
it('returns null for non-objects', () => {
expect(sanitizeSelection(null)).toBeNull();
expect(sanitizeSelection(undefined)).toBeNull();
expect(sanitizeSelection('text')).toBeNull();
expect(sanitizeSelection(42)).toBeNull();
expect(sanitizeSelection([])).toBeNull();
});
it('returns null when text is missing, non-string or blank-after-trim', () => {
expect(sanitizeSelection({})).toBeNull();
expect(sanitizeSelection({ text: 123 })).toBeNull();
expect(sanitizeSelection({ text: '' })).toBeNull();
expect(sanitizeSelection({ text: ' \n ' })).toBeNull();
});
it('keeps only text when the other fields are garbage', () => {
expect(
sanitizeSelection({
text: 'hello',
truncated: 'yes',
blockIds: 'nope',
before: 5,
after: {},
}),
).toEqual({ text: 'hello' });
});
it('caps text at 4000 and forces truncated', () => {
const raw = { text: 'a'.repeat(5000) };
const out = sanitizeSelection(raw)!;
expect(out.text).toHaveLength(4000);
expect(out.truncated).toBe(true);
});
it('does not set truncated for text under the cap', () => {
expect(sanitizeSelection({ text: 'short' })).toEqual({ text: 'short' });
});
it('slices blockIds to 20 and drops non-string / oversized ids', () => {
const ids = Array.from({ length: 30 }, (_, i) => `b${i}`);
const out = sanitizeSelection({
text: 'x',
blockIds: [...ids, 123, '', 'y'.repeat(65)],
})!;
// The 30 valid ids cap to the first 20; the number, empty string and the
// 65-char id are dropped before the slice.
expect(out.blockIds).toHaveLength(20);
expect(out.blockIds).toEqual(ids.slice(0, 20));
});
it('keeps a 64-char id but drops a 65-char one (boundary)', () => {
const ok = 'z'.repeat(64);
const tooLong = 'z'.repeat(65);
expect(
sanitizeSelection({ text: 'x', blockIds: [ok, tooLong] })!.blockIds,
).toEqual([ok]);
});
it('omits blockIds entirely when none survive', () => {
const out = sanitizeSelection({ text: 'x', blockIds: [123, ''] })!;
expect(out.blockIds).toBeUndefined();
});
it('caps before/after at 200 chars and drops empty ones', () => {
const out = sanitizeSelection({
text: 'x',
before: 'b'.repeat(300),
after: '',
})!;
expect(out.before).toHaveLength(200);
expect(out.after).toBeUndefined();
});
});
@@ -1,91 +1,21 @@
export interface SelectionContext {
text: string;
truncated?: boolean;
blockIds?: string[];
before?: string;
after?: string;
}
// Server-side caps for the client-reported selection. Intentionally >= the
// client caps: the client pre-trims for a small wire, but this layer re-checks
// everything because the payload is attacker-controllable.
const TEXT_CAP = 4000;
const CONTEXT_CAP = 200;
const MAX_BLOCK_IDS = 20;
const BLOCK_ID_CAP = 64;
// Sanitize the client-reported selection: type-check every field, cap sizes
// (text 4000, before/after 200, blockIds 20 x 64 chars), drop garbage to null.
// The selection is a CLIENT-side snapshot — never verified against the page
// content (#159 lesson: treat as a hint, not ground truth). The agent is told
// (getCurrentPage's description) to localize it before editing.
export function sanitizeSelection(raw: unknown): SelectionContext | null {
if (!raw || typeof raw !== 'object') return null;
const r = raw as Record<string, unknown>;
// text is the only required field; anything else is a non-selection.
if (typeof r.text !== 'string' || r.text.trim().length === 0) return null;
let text = r.text;
let truncated = r.truncated === true;
if (text.length > TEXT_CAP) {
text = text.slice(0, TEXT_CAP);
truncated = true;
}
const result: SelectionContext = { text };
if (truncated) result.truncated = true;
if (Array.isArray(r.blockIds)) {
// Keep only well-formed, in-range ids (an oversize id is DROPPED, not
// truncated — a mangled id is worse than a missing one), then cap the count.
const ids = r.blockIds
.filter(
(x): x is string =>
typeof x === 'string' && x.length > 0 && x.length <= BLOCK_ID_CAP,
)
.slice(0, MAX_BLOCK_IDS);
if (ids.length > 0) result.blockIds = ids;
}
if (typeof r.before === 'string' && r.before.length > 0) {
result.before = r.before.slice(0, CONTEXT_CAP);
}
if (typeof r.after === 'string' && r.after.length > 0) {
result.after = r.after.slice(0, CONTEXT_CAP);
}
return result;
}
export interface CurrentPageInput {
id?: string;
title?: string;
// The already-sanitized selection nested onto the resolved open-page context
// by resolveOpenPageContext (never the raw client value). Passed through to
// the tool result verbatim; null when nothing is selected.
selection?: SelectionContext | null;
}
export interface CurrentPageResult {
page: { id: string; title: string } | null;
selection: SelectionContext | null; // null when nothing is selected or no page
}
// Resolve the "current page" tool result from the client-supplied open-page
// context. Returns { page: null, selection: null } when no page is open (no id),
// otherwise the page id + title (title defaults to '' when absent) plus the
// selection already sanitized+nested by resolveOpenPageContext. A null page
// always yields a null selection (the selection dies with the page). Mirrors the
// getCurrentPage tool's contract so it can be unit-tested without the ESM
// Docmost client.
// context. Returns { page: null } when no page is open (no id), otherwise the
// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage
// tool's contract so it can be unit-tested without the ESM Docmost client.
export function resolveCurrentPageResult(
openedPage?: CurrentPageInput | null,
): CurrentPageResult {
if (!openedPage?.id) {
return { page: null, selection: null };
return { page: null };
}
return {
page: { id: openedPage.id, title: openedPage.title ?? '' },
selection: openedPage.selection ?? null,
};
return { page: { id: openedPage.id, title: openedPage.title ?? '' } };
}
@@ -141,59 +141,6 @@ export interface DocmostClientLike {
doc?: unknown,
title?: string,
): Promise<Record<string, unknown>>;
// Attach an author-inline footnote after the first occurrence of anchorText;
// numbering + the footnotes list are derived server-side.
insertFootnote(
pageId: string,
anchorText: string,
text: string,
): Promise<Record<string, unknown>>;
// Download a web image and insert it into the page (append, or replace/after a
// text anchor). `url` is the image http(s) URL.
insertImage(
pageId: string,
url: string,
opts?: {
align?: 'left' | 'center' | 'right';
alt?: string;
replaceText?: string;
afterText?: string;
},
): Promise<Record<string, unknown>>;
// Swap an existing image (by its attachmentId) for a new one fetched from a web
// URL, repointing every reference in the live document.
replaceImage(
pageId: string,
oldAttachmentId: string,
url: string,
opts?: { align?: 'left' | 'center' | 'right'; alt?: string },
): Promise<Record<string, unknown>>;
// --- draw.io diagrams (#423, stage 1) ---
// Read a diagram as decoded mxGraph XML (default) or the raw .drawio.svg.
// meta.hash is the optimistic-lock key drawioUpdate expects as baseHash.
drawioGet(
pageId: string,
node: string,
format?: 'xml' | 'svg',
): Promise<Record<string, unknown>>;
// Lint mxGraph XML, build the .drawio.svg attachment and insert a drawio node.
drawioCreate(
pageId: string,
where: {
position: 'before' | 'after' | 'append';
anchorNodeId?: string;
anchorText?: string;
},
xml: string,
title?: string,
): Promise<Record<string, unknown>>;
// Optimistic-locked full replacement of a diagram (baseHash from drawioGet).
drawioUpdate(
pageId: string,
node: string,
xml: string,
baseHash: string,
): Promise<Record<string, unknown>>;
tableInsertRow(
pageId: string,
tableRef: string,
@@ -27,26 +27,13 @@ import type { DocmostClientLike } from './docmost-client.loader';
*/
describe('tool tier metadata (#332)', () => {
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => {
expect(CORE_TOOL_KEYS).toHaveLength(15);
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
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
// loadTools is a meta-tool, not a normal core key.
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
});
it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
// insert_footnote is core (symmetric with editPageText); the image tools stay
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true);
expect(SHARED_TOOL_SPECS.insertImage.tier).toBe('deferred');
expect(CORE_TOOL_SET.has('insertImage')).toBe(false);
expect(SHARED_TOOL_SPECS.replaceImage.tier).toBe('deferred');
expect(CORE_TOOL_SET.has('replaceImage')).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';
@@ -38,13 +38,10 @@ export interface ToolCatalogEntry {
}
/**
* CORE (always-active) in-app tool keys 13 frequent/tiny tools + `searchInPage`
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent
* for the editorial roles this feature targets; `insertFootnote` is core so the
* footnote tool is NOT hidden while its natural sibling `editPageText` is always
* active (that asymmetry is exactly what pushed the agent to write literal
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added
* to activeTools separately).
* 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',
@@ -63,9 +60,6 @@ export const CORE_TOOL_KEYS = [
// #330 search_in_page — frequent for editorial sweeps; core despite predating
// the issue's tier list.
'searchInPage',
// #410 insert_footnote — core so pinpoint citations to already-written text
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
'insertFootnote',
] as const;
/** O(1) membership test for the core tier. */
@@ -104,8 +98,7 @@ export const INLINE_TOOL_TIERS: Record<
},
getCurrentPage: {
tier: 'core',
catalogLine:
'getCurrentPage — the page the user is currently viewing and their current text selection on it.',
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
},
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
// (#294); they carry their own tier ('core') + catalogLine there.
@@ -474,6 +474,19 @@ export class AttachmentController {
const fileSize = Number(attachment.fileSize);
const rangeHeader = req.headers.range;
// Opt this download route out of the global @fastify/compress hook.
// Attachment bytes are final and mostly binary, so on-the-fly compression
// only burns CPU — and on the 206/Range branch it is actively corrupting:
// compress decides purely by Content-Type, so for a compressible mime
// (application/octet-stream fallback, image/svg+xml, text/*) it would gzip
// the byte slice and drop Content-Length while Content-Range still
// describes the RAW offsets and the status stays 206. A resuming client
// (`curl -C -`, download managers) then appends the encoded bytes as if
// raw and ends up with a broken file. @fastify/compress skips whenever the
// request carries `x-no-compression` (see its onSend hook), so setting it
// here covers both the 200 (full file) and 206 (range) responses.
req.headers['x-no-compression'] = 'true';
res.header('Accept-Ranges', 'bytes');
res.header(
'Content-Security-Policy',
@@ -23,21 +23,8 @@ import { hashPassword } from '../../../common/helpers';
* 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';
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
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
// collaborators actually reached on these paths need real behaviour; the rest
@@ -108,6 +95,7 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
// A deactivated user must be indistinguishable from a wrong password: same
// message, before any password comparison.
const passwordHash = await hashPassword('correct-horse');
const disabledUser = {
id: 'u-1',
email: 'disabled@example.com',
@@ -129,6 +117,7 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
});
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
@@ -150,6 +139,7 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
});
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
@@ -189,6 +179,7 @@ describe('AuthService.login (live credentials-mismatch contract via verifyUserCr
});
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
@@ -210,6 +201,7 @@ describe('AuthService.login (live credentials-mismatch contract via verifyUserCr
});
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
const passwordHash = await hashPassword('correct-horse');
const user = {
id: 'u-1',
email: 'user@example.com',
@@ -123,19 +123,19 @@ export function streamKeepAliveMs(): number {
return positiveEnv('AI_STREAM_KEEPALIVE_MS', DEFAULT_STREAM_KEEPALIVE_MS);
}
/** Default SILENCE timeout for EXTERNAL-MCP transport (1 min). */
const DEFAULT_MCP_STREAM_TIMEOUT_MS = 60_000;
/** Default SILENCE timeout for EXTERNAL-MCP transport (5 min). */
const DEFAULT_MCP_STREAM_TIMEOUT_MS = 300_000;
/** Default total wall-clock cap for ONE external MCP tool call (2 min). */
const DEFAULT_MCP_CALL_TIMEOUT_MS = 120_000;
/** Default total wall-clock cap for ONE external MCP tool call (15 min). */
const DEFAULT_MCP_CALL_TIMEOUT_MS = 900_000;
/**
* SILENCE timeout (ms) for EXTERNAL-MCP transport ONLY. Override with
* `AI_MCP_STREAM_TIMEOUT_MS`; a missing/invalid/non-positive value falls back to
* {@link DEFAULT_MCP_STREAM_TIMEOUT_MS} (1 min).
* {@link DEFAULT_MCP_STREAM_TIMEOUT_MS} (5 min).
*
* Deliberately tighter than the chat provider's {@link streamTimeoutMs} (15 min)
* so a byte-silent/hung MCP upstream is broken in ~1 min instead of 15. This is
* so a byte-silent/hung MCP upstream is broken in ~5 min instead of 15. This is
* the undici `headersTimeout`/`bodyTimeout` for the external-MCP dispatcher only
* it must NOT change the chat provider, which legitimately needs 15 min between
* reasoning chunks (#175).
@@ -153,7 +153,7 @@ export function mcpStreamTimeoutMs(): number {
/**
* Total wall-clock cap (ms) for ONE external MCP tool call APP-LEVEL, not
* transport. Override with `AI_MCP_CALL_TIMEOUT_MS`; a missing/invalid/
* non-positive value falls back to {@link DEFAULT_MCP_CALL_TIMEOUT_MS} (2 min).
* non-positive value falls back to {@link DEFAULT_MCP_CALL_TIMEOUT_MS} (15 min).
*
* Catches a tool that keeps the connection warm (SSE heartbeats / trickle) but
* never returns a result which the transport silence timeout
@@ -292,23 +292,6 @@ export class EnvironmentService {
return enabled === 'true';
}
/**
* Resumable SSE transport for durable agent runs (#184 phase 1.5). When
* enabled, a run tees its SSE frames into the in-memory run-stream registry so
* a late/reloaded tab can attach (replay + live tail) via
* `GET /ai-chat/runs/:chatId/stream`. Defaults to DISABLED: PR 1 ships the
* server code dormant with the flag off, `open`/`bind`/`generateMessageId`
* are never called and attach always answers 204, so the legacy and #184
* phase-1 wire paths stay byte-for-byte identical. Set
* AI_CHAT_RESUMABLE_STREAM=true to activate it (paired with the PR 2 client).
*/
isAiChatResumableStreamEnabled(): boolean {
const enabled = this.configService
.get<string>('AI_CHAT_RESUMABLE_STREAM', 'false')
.toLowerCase();
return enabled === 'true';
}
getPostHogHost(): string {
return this.configService.get<string>('POSTHOG_HOST');
}
@@ -149,15 +149,6 @@ export type DocmostMcpConfig = (
has?: (uri: string) => boolean;
evict?: (uri: string) => void;
};
// Dependency-neutral metrics sink injected by McpService (mirror of the
// package's onMetric). The package emits generic (name, value, labels)
// samples; McpService maps them onto the prom-client registry. Undefined
// when metrics are disabled → the package no-ops.
onMetric?: (
name: string,
value: number,
labels?: Record<string, string>,
) => void;
};
export interface ResolvedMcpAuth {
@@ -30,11 +30,6 @@ import {
ResolvedMcpAuth,
} from './mcp-auth.helpers';
import { SandboxStore } from '../sandbox/sandbox.store';
import {
isMetricsEnabled,
observeMcpTool,
incConnectTimeout,
} from '../metrics/metrics.registry';
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
interface McpHttpHandler {
@@ -336,31 +331,7 @@ export class McpService implements OnModuleDestroy {
// can store blobs in the shared in-RAM store regardless of which
// credential variant resolved. The sink (put/has/evict + uri↔id
// mapping) is owned by SandboxStore.asSink().
// Route the package's dependency-neutral metric samples onto the
// prom-client registry. When metrics are disabled, onMetric is
// undefined → the package's tool-timer/timeout hooks are a
// negligible-overhead no-op: the registerTool wrapper still runs a
// performance.now() + async try/finally per tool call, but the
// `onMetric?.()` short-circuits so no label/object is built. (Cost
// is immaterial at LLM tool-call rate.) labels?.tool is guarded
// defensively (the tool wrapper always sets it).
return {
...resolved.config,
sandbox: this.sandboxStore.asSink(),
onMetric: isMetricsEnabled()
? (
name: string,
value: number,
labels?: Record<string, string>,
) => {
if (name === 'mcp_tool_duration_seconds') {
observeMcpTool(labels?.tool ?? 'other', value);
} else if (name === 'collab_connect_timeouts_total') {
incConnectTimeout();
}
}
: undefined,
};
return { ...resolved.config, sandbox: this.sandboxStore.asSink() };
},
{
identify: (req: IncomingMessage) => {
@@ -2,11 +2,6 @@
* Perf-metrics contract (#355). These names/labels are FIXED by the already
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
* Grafana dashboards, alerts). Do NOT rename them.
*
* #402 extends the #355 table with the collab-lifecycle + MCP-tool families
* (grouped below). These are the fixed contract from #402; same "do not rename"
* rule applies. Server-side families land in Pass 1; the MCP-tool histogram is
* fed by the MCP callback in a later pass but its NAME is fixed here now.
*/
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
@@ -14,17 +9,6 @@ export const METRIC_BULLMQ_QUEUE_DEPTH = 'bullmq_queue_depth';
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
// #402 additions — collaboration lifecycle + MCP tool timing.
export const METRIC_COLLAB_LOAD_DURATION = 'collab_doc_load_duration_seconds';
export const METRIC_COLLAB_DOCS_OPEN = 'collab_docs_open';
export const METRIC_COLLAB_DOC_LOADS_TOTAL = 'collab_doc_loads_total';
export const METRIC_COLLAB_DOC_UNLOADS_TOTAL = 'collab_doc_unloads_total';
export const METRIC_COLLAB_CONNECT_DURATION = 'collab_connect_duration_seconds';
export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
'collab_connect_timeouts_total';
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
// for typical web/DB latencies without exploding series cardinality.
export const HTTP_BUCKETS = [
@@ -39,12 +23,6 @@ export const COLLAB_BUCKETS = [
export const JOB_BUCKETS = [
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
];
// #402 — MCP tool-call latency. Same shape as COLLAB_BUCKETS but stretched to
// 10s at the top: an MCP tool round-trip (LLM-driven doc ops) can be slower
// than a single collab store, so keep resolution out to 10s.
export const MCP_TOOL_BUCKETS = [
0.005, 0.025, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
];
/**
* Extract the first SQL token (select/insert/update/delete/...) from a query,
@@ -80,30 +58,6 @@ export function firstSqlToken(sql: string | undefined): string {
return KNOWN_SQL_TOKENS.has(token) ? token : 'other';
}
/**
* #402 bucket a document byte size into ONE of four fixed labels for the
* collab load/store histograms' `size_bucket` label. Using the raw byte count
* would be a continuous, unbounded label; four coarse buckets keep series
* cardinality bounded (each of collab_doc_load / collab_store gets ×4 series).
*
* The SAME function is shared by both the load and store paths so their buckets
* can never drift apart. Non-finite / negative sizes collapse to the smallest
* bucket ('lt64k') as a safe default (they can't be legitimately huge and we
* must never throw here this runs on every store/load when metrics are on).
*/
// Module-const thresholds, built once (not per observe).
const SIZE_THRESHOLDS = { lt64k: 65536, lt256k: 262144, lt1m: 1048576 } as const;
export function sizeBucket(
bytes: number | undefined | null,
): 'lt64k' | 'lt256k' | 'lt1m' | 'ge1m' {
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return 'lt64k';
if (bytes < SIZE_THRESHOLDS.lt64k) return 'lt64k';
if (bytes < SIZE_THRESHOLDS.lt256k) return 'lt256k';
if (bytes < SIZE_THRESHOLDS.lt1m) return 'lt1m';
return 'ge1m';
}
/**
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
*
@@ -1,6 +1,5 @@
import {
collectDefaultMetrics,
Counter,
Histogram,
Gauge,
Registry,
@@ -10,21 +9,11 @@ import {
DB_BUCKETS,
HTTP_BUCKETS,
JOB_BUCKETS,
MCP_TOOL_BUCKETS,
METRIC_BULLMQ_JOB_DURATION,
METRIC_BULLMQ_QUEUE_DEPTH,
METRIC_COLLAB_AUTH_DURATION,
METRIC_COLLAB_CONNECT_DURATION,
METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL,
METRIC_COLLAB_DOC_LOADS_TOTAL,
METRIC_COLLAB_DOC_UNLOADS_TOTAL,
METRIC_COLLAB_DOCS_OPEN,
METRIC_COLLAB_LOAD_DURATION,
METRIC_COLLAB_STORE_DURATION,
METRIC_DB_QUERY_DURATION,
METRIC_HTTP_REQUEST_DURATION,
METRIC_MCP_TOOL_DURATION,
sizeBucket,
} from './metrics.constants';
/**
@@ -50,24 +39,7 @@ let httpHist: Histogram<'method' | 'route' | 'status'> | null = null;
let dbHist: Histogram<'op'> | null = null;
let queueDepthGauge: Gauge<'queue'> | null = null;
let jobHist: Histogram<'queue'> | null = null;
// #402 — collab store now carries a size_bucket label (see sizeBucket()).
let collabHist: Histogram<'size_bucket'> | null = null;
// #402 collab-lifecycle + MCP instruments.
let collabLoadHist: Histogram<'size_bucket'> | null = null;
let docsOpenGauge: Gauge | null = null;
let docLoadsCounter: Counter | null = null;
let docUnloadsCounter: Counter | null = null;
let connectTimeoutsCounter: Counter | null = null;
let collabConnectHist: Histogram | null = null;
let collabAuthHist: Histogram | null = null;
let mcpToolHist: Histogram<'tool'> | null = null;
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
// pulls the authoritative live count from here on every scrape. Registered once,
// gated, by the collaboration gateway via registerDocsOpenSource(). Null until
// then → the collect() is a no-op.
let docsOpenSource: (() => number) | null = null;
let collabHist: Histogram | null = null;
function init(): void {
if (registry || !enabled) return;
@@ -110,71 +82,10 @@ function init(): void {
collabHist = new Histogram({
name: METRIC_COLLAB_STORE_DURATION,
help: 'Collaboration onStoreDocument duration in seconds, by document size bucket',
labelNames: ['size_bucket'],
help: 'Collaboration onStoreDocument duration in seconds',
buckets: COLLAB_BUCKETS,
registers: [registry],
});
collabLoadHist = new Histogram({
name: METRIC_COLLAB_LOAD_DURATION,
help: 'Collaboration onLoadDocument DB-load duration in seconds, by document size bucket',
labelNames: ['size_bucket'],
buckets: COLLAB_BUCKETS,
registers: [registry],
});
docsOpenGauge = new Gauge({
name: METRIC_COLLAB_DOCS_OPEN,
help: 'Number of collaboration documents currently open in memory',
registers: [registry],
// Read-on-scrape: pull the live count from the registered source (the
// hocuspocus instance) so the value can never drift. No-op until a source
// is registered.
collect() {
if (docsOpenSource) this.set(docsOpenSource());
},
});
docLoadsCounter = new Counter({
name: METRIC_COLLAB_DOC_LOADS_TOTAL,
help: 'Total collaboration documents loaded into memory',
registers: [registry],
});
docUnloadsCounter = new Counter({
name: METRIC_COLLAB_DOC_UNLOADS_TOTAL,
help: 'Total collaboration documents unloaded from memory',
registers: [registry],
});
connectTimeoutsCounter = new Counter({
name: METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL,
help: 'Total collaboration connection setup timeouts',
registers: [registry],
});
collabConnectHist = new Histogram({
name: METRIC_COLLAB_CONNECT_DURATION,
help: 'Collaboration connection acceptance duration in seconds (onConnect→connected)',
buckets: COLLAB_BUCKETS,
registers: [registry],
});
collabAuthHist = new Histogram({
name: METRIC_COLLAB_AUTH_DURATION,
help: 'Collaboration onAuthenticate duration in seconds',
buckets: COLLAB_BUCKETS,
registers: [registry],
});
mcpToolHist = new Histogram({
name: METRIC_MCP_TOOL_DURATION,
help: 'MCP tool-call duration in seconds, by tool name',
labelNames: ['tool'],
buckets: MCP_TOOL_BUCKETS,
registers: [registry],
});
}
// Runs once when this module is first imported. Safe to call again (idempotent).
@@ -210,46 +121,6 @@ export function observeJobDuration(queue: string, seconds: number): void {
jobHist?.observe({ queue }, seconds);
}
export function observeCollabStore(bytes: number, seconds: number): void {
collabHist?.observe({ size_bucket: sizeBucket(bytes) }, seconds);
}
export function observeCollabLoad(bytes: number, seconds: number): void {
collabLoadHist?.observe({ size_bucket: sizeBucket(bytes) }, seconds);
}
/**
* Register the live open-document count source for the collab_docs_open gauge.
* Called ONCE, gated by isMetricsEnabled(), by the collaboration gateway. The
* gauge reads this on every scrape (collect()); nothing inc/dec's it.
*/
export function registerDocsOpenSource(fn: () => number): void {
docsOpenSource = fn;
}
export function incDocLoad(): void {
docLoadsCounter?.inc();
}
export function incDocUnload(): void {
docUnloadsCounter?.inc();
}
export function incConnectTimeout(): void {
connectTimeoutsCounter?.inc();
}
export function observeCollabConnect(seconds: number): void {
collabConnectHist?.observe(seconds);
}
export function observeCollabAuth(seconds: number): void {
collabAuthHist?.observe(seconds);
}
export function observeMcpTool(tool: string, seconds: number): void {
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
// guarantees it comes from the registered-tool set) — never free-form input —
// so this label stays low-cardinality with no 'other' bucketing needed.
mcpToolHist?.observe({ tool }, seconds);
export function observeCollabStore(seconds: number): void {
collabHist?.observe(seconds);
}
@@ -1,23 +1,6 @@
import { FastifyRequest } from 'fastify';
import { resolveRouteLabel } from './http-metrics.hook';
import {
firstSqlToken,
isStreamingResponse,
sizeBucket,
} from './metrics.constants';
import {
getMetricsRegistry,
incConnectTimeout,
incDocLoad,
incDocUnload,
isMetricsEnabled,
observeCollabAuth,
observeCollabConnect,
observeCollabLoad,
observeCollabStore,
observeMcpTool,
registerDocsOpenSource,
} from './metrics.registry';
import { firstSqlToken, isStreamingResponse } from './metrics.constants';
describe('resolveRouteLabel (histogram route label)', () => {
it('uses the ROUTE TEMPLATE, never the raw URL', () => {
@@ -140,67 +123,3 @@ describe('firstSqlToken (bounded db label)', () => {
expect(firstSqlToken('vacuum analyze')).toBe('other');
});
});
describe('sizeBucket (#402 bounded size label)', () => {
it('maps sizes to the four fixed buckets at their boundaries', () => {
// Boundaries are exclusive-upper: <65536 → lt64k, etc.
expect(sizeBucket(0)).toBe('lt64k');
expect(sizeBucket(65535)).toBe('lt64k');
expect(sizeBucket(65536)).toBe('lt256k');
expect(sizeBucket(262143)).toBe('lt256k');
expect(sizeBucket(262144)).toBe('lt1m');
expect(sizeBucket(1048575)).toBe('lt1m');
expect(sizeBucket(1048576)).toBe('ge1m');
expect(sizeBucket(5_000_000)).toBe('ge1m');
});
it('falls back to the smallest bucket for invalid sizes', () => {
// Non-finite / negative / nullish collapse to the smallest, safe default.
expect(sizeBucket(-1)).toBe('lt64k');
expect(sizeBucket(NaN)).toBe('lt64k');
expect(sizeBucket(Infinity)).toBe('lt64k');
expect(sizeBucket(undefined)).toBe('lt64k');
expect(sizeBucket(null)).toBe('lt64k');
});
it('only ever returns one of the four fixed labels (bounded cardinality)', () => {
const labels = new Set(
[0, 65536, 262144, 1048576, -5, NaN].map((b) => sizeBucket(b)),
);
for (const l of labels) {
expect(['lt64k', 'lt256k', 'lt1m', 'ge1m']).toContain(l);
}
});
});
describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
// These specs run without METRICS_PORT, so the registry is never created and
// every observe/inc/set helper must be a cheap `?.` no-op that never throws.
beforeAll(() => {
// Guard the contract this suite depends on: if a CI env set METRICS_PORT,
// the assertions below would be meaningless, so fail loudly instead.
expect(process.env.METRICS_PORT).toBeUndefined();
});
it('reports metrics disabled and a null registry', () => {
expect(isMetricsEnabled()).toBe(false);
expect(getMetricsRegistry()).toBeNull();
});
it('does not throw from any #402 collab/MCP helper', () => {
expect(() => {
observeCollabLoad(123456, 0.01);
observeCollabStore(123456, 0.02);
observeCollabConnect(0.03);
observeCollabAuth(0.04);
observeMcpTool('some-tool', 0.05);
incDocLoad();
incDocUnload();
incConnectTimeout();
// Registering a source must not create the gauge or invoke the fn.
registerDocsOpenSource(() => {
throw new Error('docsOpenSource must NOT be called when disabled');
});
}).not.toThrow();
});
});
@@ -0,0 +1,35 @@
import { resolveStaticAssetHeaders } from './static.module';
// Unit tests for the static-asset cache classifier extracted from the
// @fastify/static setHeaders callback (precedent: sandbox.controller.spec.ts).
describe('resolveStaticAssetHeaders', () => {
it('marks a content-hashed /assets/ file immutable and sets Vary', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/assets/index-a1b2c3.js',
);
expect(headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
);
expect(headers['vary']).toBe('Accept-Encoding');
});
it('makes index.html always revalidate (never immutable)', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/index.html',
);
expect(headers['cache-control']).toBe(
'no-cache, no-store, must-revalidate',
);
expect(headers['vary']).toBe('Accept-Encoding');
});
it('does NOT mark a non-hashed asset immutable but still sets Vary', () => {
const headers = resolveStaticAssetHeaders(
'/app/apps/client/dist/locales/en.json',
);
// No immutable cache-control — this path keeps @fastify/static's default
// etag/last-modified revalidation.
expect(headers['cache-control']).toBeUndefined();
expect(headers['vary']).toBe('Accept-Encoding');
});
});
@@ -5,6 +5,46 @@ import * as fs from 'node:fs';
import fastifyStatic from '@fastify/static';
import { EnvironmentService } from '../environment/environment.service';
/**
* Resolve the response headers for a statically served client asset.
*
* Extracted from the @fastify/static `setHeaders` callback so the cache
* classification stays a pure, unit-testable function (see
* static.module.spec.ts).
*
* `Vary: Accept-Encoding` is emitted for every static response because
* @fastify/static negotiates a precompressed .br/.gz neighbour by the client's
* Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache
* keyed on the URL alone could store the brotli variant and later serve it to a
* client that only sent `Accept-Encoding: identity`/gzip an undecodable body.
* This matters most for the immutable /assets/ files, which proxies may keep
* for a year.
*/
export function resolveStaticAssetHeaders(
filePath: string,
): Record<string, string> {
const headers: Record<string, string> = { vary: 'Accept-Encoding' };
// Content-hashed files under /assets/ never change for a given URL, so they
// can be cached forever and skip revalidation entirely.
if (filePath.includes('/assets/')) {
headers['cache-control'] = 'public, max-age=31536000, immutable';
return headers;
}
// index.html is rewritten at boot (window.CONFIG injection) and on every
// deploy — it must be revalidated on every load.
if (filePath.endsWith('index.html')) {
headers['cache-control'] = 'no-cache, no-store, must-revalidate';
return headers;
}
// Everything else (locales, vad, icons, manifest) is NOT content-hashed and
// changes between deploys, so it keeps @fastify/static's default
// etag/last-modified revalidation — do NOT mark it immutable.
return headers;
}
@Module({})
export class StaticModule implements OnModuleInit {
constructor(
@@ -72,6 +112,16 @@ export class StaticModule implements OnModuleInit {
await app.register(fastifyStatic, {
root: clientDistPath,
wildcard: false,
// Serve the build-time .br/.gz neighbour when the client accepts it
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
preCompressed: true,
setHeaders: (res, filePath) => {
for (const [name, value] of Object.entries(
resolveStaticAssetHeaders(filePath),
)) {
res.setHeader(name, value);
}
},
});
app.get(RENDER_PATH, (req: any, res: any) => {
+12 -14
View File
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
import fastifyCompress from '@fastify/compress';
import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
import { EnvironmentService } from './integrations/environment/environment.service';
@@ -21,24 +22,10 @@ import { recordHttpResponse } from './integrations/metrics/http-metrics.hook';
import { startMetricsServer } from './integrations/metrics/metrics.server';
async function bootstrap() {
// Fastify JSON body cap. Fastify defaults to 1 MiB, which a long AI-chat
// research turn exceeds: the client resends the FULL message history (every
// tool call + search result) on each turn, so a deep conversation's POST to
// /api/ai-chat/stream can be several MB and would otherwise be rejected with
// FST_ERR_CTP_BODY_TOO_LARGE (413). Raise the cap; override with
// HTTP_JSON_BODY_LIMIT (bytes). A missing/invalid/non-positive value keeps the
// 25 MiB default. Multipart uploads are unaffected (their own @fastify/multipart
// limits apply); this only bounds JSON/urlencoded request bodies.
const bodyLimitEnv = Number(process.env.HTTP_JSON_BODY_LIMIT);
const bodyLimit =
Number.isFinite(bodyLimitEnv) && bodyLimitEnv > 0
? bodyLimitEnv
: 25 * 1024 * 1024;
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
bodyLimit,
routerOptions: {
maxParamLength: 1000,
ignoreTrailingSlash: true,
@@ -77,6 +64,17 @@ async function bootstrap() {
await app.register(fastifyIp);
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
// Compress dynamic responses (API JSON, the rewritten share-SEO HTML) when the
// client accepts br/gzip. @fastify/compress only compresses content-types that
// mime-db flags `compressible` (application/json, text/html, …); `text/event-stream`
// is not in mime-db, so SSE is never compressed by the allowlist. The AI-chat
// stream additionally hijacks the raw socket (pipeUIMessageStreamToResponse ->
// res.raw in ai-chat.service.ts), bypassing Fastify's reply/onSend lifecycle
// entirely, so this hook can never buffer that stream.
await app.register(fastifyCompress, {
// Skip tiny payloads where compression overhead outweighs the savings.
threshold: 1024,
});
const environmentService = app.get(EnvironmentService);
const frameHeader = resolveFrameHeader(
@@ -1,564 +0,0 @@
import * as http from 'node:http';
import { Kysely } from 'kysely';
import {
MockLanguageModelV3,
convertArrayToReadableStream,
} from 'ai/test';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
import { AiChatService } from 'src/core/ai-chat/ai-chat.service';
import { AiChatRunService } from 'src/core/ai-chat/ai-chat-run.service';
import {
AiChatStreamRegistryService,
RunStreamCallbacks,
} from 'src/core/ai-chat/ai-chat-stream-registry.service';
import {
getTestDb,
destroyTestDb,
createWorkspace,
createUser,
createChat,
} from './db';
/**
* #184 phase 1.5 the resumable transport end to end against REAL Postgres,
* the REAL `streamText` (seeded via MockLanguageModelV3) and a REAL Node
* ServerResponse, driving the REAL `AiChatService.stream` run-wrapped path with a
* REAL `AiChatStreamRegistryService`. The run-hooks mirror the controller: they
* begin a durable run and `open()` the registry entry at begin, and the service
* tees the SSE frames into it via `consumeSseStream` while stamping the DB row id
* via `generateMessageId` (both gated on runId + the resumable flag).
*
* Proven here: a finished run's replay is the full frame sequence incl `[DONE]`
* with `start.messageId` == the seeded DB row id; the anchor check (invariant 6);
* an attach opened BEFORE the first frame follows the live stream from frame 0; an
* explicit stop surfaces `{"type":"abort"}` + `[DONE]` + end to the subscriber;
* and the legacy (non-run) path tees nothing.
*/
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function waitFor(
cond: () => Promise<boolean> | boolean,
{ timeoutMs = 15_000, stepMs = 25 } = {},
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (await cond()) return;
await sleep(stepMs);
}
throw new Error('waitFor: condition not met within timeout');
}
// A real Node ServerResponse wired to a live socket (as in the stream int-spec).
function makeRealResponse(): Promise<{
res: http.ServerResponse;
cleanup: () => Promise<void>;
}> {
return new Promise((resolve) => {
const server = http.createServer((_req, res) => {
resolve({
res,
cleanup: () =>
new Promise<void>((done) => {
try {
if (!res.writableEnded) res.end();
} catch {
/* socket already gone */
}
server.close(() => done());
}),
});
});
server.listen(0, () => {
const port = (server.address() as any).port;
const creq = http.request({ port, method: 'GET' }, (cres) => {
cres.resume();
});
creq.on('error', () => undefined);
creq.end();
});
});
}
// A full, successful single-step turn.
function successStream() {
return convertArrayToReadableStream([
{ type: 'stream-start', warnings: [] },
{ type: 'text-start', id: 't1' },
{ type: 'text-delta', id: 't1', delta: 'Hello' },
{ type: 'text-delta', id: 't1', delta: ' there' },
{ type: 'text-end', id: 't1' },
{
type: 'finish',
finishReason: 'stop',
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
},
] as any);
}
// A stream the test feeds chunk by chunk (to attach mid-flight / drive a stop).
function makeControlledStream() {
let controller!: ReadableStreamDefaultController<any>;
const stream = new ReadableStream<any>({
start(c) {
controller = c;
},
});
return {
stream,
emit: (chunk: any) => controller.enqueue(chunk),
close: () => controller.close(),
};
}
// Collect replay + live frames from an attachment.
function liveSink(): {
cb: RunStreamCallbacks;
frames: string[];
ended: () => boolean;
} {
const frames: string[] = [];
let ended = false;
return {
frames,
ended: () => ended,
cb: {
onFrame: (f) => frames.push(f),
onEnd: () => {
ended = true;
},
},
};
}
// The SSE `start` frame carries the message id; pull it out of a `data: {...}`.
function parseStartMessageId(frames: string[]): string | undefined {
for (const f of frames) {
const m = /^data: (\{.*\})\s*$/m.exec(f.trim());
if (!m) continue;
try {
const json = JSON.parse(m[1]);
if (json.type === 'start') return json.messageId;
} catch {
/* not this frame */
}
}
return undefined;
}
describe('AiChatService run-stream attach [integration]', () => {
let db: Kysely<any>;
let aiChatRepo: AiChatRepo;
let msgRepo: AiChatMessageRepo;
let runRepo: AiChatRunRepo;
let workspaceId: string;
let userId: string;
const mcpClients = {
toolsFor: async () => ({
tools: {},
clients: [],
outcomes: [],
instructions: [],
}),
};
// Build the service with the run-stream registry wired and the resumable flag
// ON (the property under test). Deferred tools OFF (irrelevant here).
function buildService(registry: AiChatStreamRegistryService): AiChatService {
return new AiChatService(
{ getChatModel: async () => null } as any,
aiChatRepo,
msgRepo,
{} as any,
{ resolve: async () => null } as any,
{ forUser: async () => ({}) } as any,
mcpClients as any,
{} as any,
{} as any,
{} as any,
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => true,
} as any,
registry,
);
}
// Run-hooks mirroring the controller: begin the durable run AND open() the
// registry entry at begin. Captures the runId so a test can stop it.
function makeRunHooks(
runService: AiChatRunService,
registry: AiChatStreamRegistryService,
box: { runId?: string },
) {
return {
begin: async (chatId: string) => {
const handle = await runService.beginRun({
chatId,
workspaceId,
userId,
trigger: 'user',
});
box.runId = handle.runId;
registry.open(chatId, handle.runId);
return handle;
},
onAssistantSeeded: (runId: string, messageId: string) =>
runService.linkAssistantMessage(runId, workspaceId, messageId),
onStep: (runId: string, n: number) =>
void runService.recordStep(runId, workspaceId, n),
onSettled: (runId: string, status: any, error?: string) =>
runService.finalizeRun(runId, workspaceId, status, error),
};
}
function userUiMessage(text: string) {
return {
id: `u-${Math.random()}`,
role: 'user',
parts: [{ type: 'text', text }],
};
}
async function startRun(opts: {
registry: AiChatStreamRegistryService;
runService?: AiChatRunService;
model: MockLanguageModelV3;
chatId: string;
body: any;
box?: { runId?: string };
}): Promise<{ res: http.ServerResponse; cleanup: () => Promise<void> }> {
const service = buildService(opts.registry);
const { res, cleanup } = await makeRealResponse();
const runHooks = opts.runService
? makeRunHooks(opts.runService, opts.registry, opts.box ?? {})
: undefined;
await service.stream({
user: { id: userId, workspaceId } as any,
workspace: { id: workspaceId, name: 'WS' } as any,
sessionId: 'sess-1',
body: opts.body,
res: { raw: res } as any,
signal: new AbortController().signal,
model: opts.model as any,
role: null,
runHooks,
} as any);
return { res, cleanup };
}
async function assistantRowId(chatId: string): Promise<string> {
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
const row = rows.find((r: any) => r.role === 'assistant');
return row!.id as string;
}
beforeAll(async () => {
db = getTestDb();
aiChatRepo = new AiChatRepo(db as any);
msgRepo = new AiChatMessageRepo(db as any);
runRepo = new AiChatRunRepo(db as any);
workspaceId = (await createWorkspace(db)).id;
userId = (await createUser(db, workspaceId)).id;
});
afterAll(async () => {
await destroyTestDb();
});
it('run-wrapped: replay is the full frame sequence incl [DONE], start.messageId == the seeded DB row id', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const registry = new AiChatStreamRegistryService();
const runService = new AiChatRunService(runRepo, {
isCloud: () => false,
} as never);
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
const box: { runId?: string } = {};
const { cleanup } = await startRun({
registry,
runService,
model,
chatId,
body: { chatId, messages: [userUiMessage('Hi')] },
box,
});
try {
// Wait for the assistant row to settle (terminal callbacks run async).
await waitFor(async () => {
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
return rows.some(
(r: any) =>
r.role === 'assistant' &&
['completed', 'error', 'aborted'].includes(r.status),
);
});
const rowId = await assistantRowId(chatId);
// Finished-run replay with expect=live + the correct anchor.
const sink = liveSink();
const att = await registry.attach(chatId, true, rowId, sink.cb);
expect(att).not.toBeNull();
expect(att!.finished).toBe(true);
// The tee captured frames (consumeSseStream was wired).
expect(att!.replay.length).toBeGreaterThan(0);
// generateMessageId stamped the DB row id onto the streamed start frame.
expect(parseStartMessageId(att!.replay)).toBe(rowId);
// The full sequence includes the streamed text and the terminal marker.
const joined = att!.replay.join('');
expect(joined).toContain('Hello');
expect(att!.replay.some((f) => f.includes('[DONE]'))).toBe(true);
} finally {
registry.onModuleDestroy();
await cleanup();
}
});
it('anchor mismatch with expect=live returns null (invariant 6)', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const registry = new AiChatStreamRegistryService();
const runService = new AiChatRunService(runRepo, {
isCloud: () => false,
} as never);
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
const { cleanup } = await startRun({
registry,
runService,
model,
chatId,
body: { chatId, messages: [userUiMessage('Hi')] },
});
try {
await waitFor(async () => {
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
return rows.some(
(r: any) => r.role === 'assistant' && r.status === 'completed',
);
});
const sink = liveSink();
// A foreign anchor must NOT replay this run's transcript.
expect(
await registry.attach(chatId, true, 'a-different-run-row', sink.cb),
).toBeNull();
} finally {
registry.onModuleDestroy();
await cleanup();
}
});
it('an attach opened BEFORE the first frame follows the live stream from frame 0', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const registry = new AiChatStreamRegistryService();
const runService = new AiChatRunService(runRepo, {
isCloud: () => false,
} as never);
const controlled = makeControlledStream();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: controlled.stream }),
} as any);
const { cleanup } = await startRun({
registry,
runService,
model,
chatId,
body: { chatId, messages: [userUiMessage('Slow please')] },
});
try {
// Attach while the entry exists (opened at begin) but before any frame.
const sink = liveSink();
const att = (await registry.attach(chatId, false, undefined, sink.cb))!;
expect(att.replay).toEqual([]); // nothing streamed yet -> replay from 0
att.start(); // go live (drains nothing, then follows)
// Now emit the whole turn.
controlled.emit({ type: 'stream-start', warnings: [] });
controlled.emit({ type: 'text-start', id: 't1' });
controlled.emit({ type: 'text-delta', id: 't1', delta: 'Zero' });
controlled.emit({ type: 'text-end', id: 't1' });
controlled.emit({
type: 'finish',
finishReason: 'stop',
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
});
controlled.close();
await waitFor(() => sink.frames.some((f) => f.includes('[DONE]')));
// The subscriber saw the stream from the very first frame (`start`) through
// the terminal marker, with the streamed text present.
expect(sink.frames.some((f) => f.includes('"type":"start"'))).toBe(true);
expect(sink.frames.join('')).toContain('Zero');
expect(sink.frames[sink.frames.length - 1]).toContain('[DONE]');
expect(sink.ended()).toBe(true);
} finally {
registry.onModuleDestroy();
await cleanup();
}
});
it('requestStop surfaces {"type":"abort"} + [DONE] + end to the attached subscriber', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const registry = new AiChatStreamRegistryService();
const runService = new AiChatRunService(runRepo, {
isCloud: () => false,
} as never);
// An abort-AWARE model: it streams some partial output, then errors the model
// stream with an AbortError when the run signal aborts — exactly as a real
// provider network stream is torn down on abort (a plain in-memory stream
// would just stall, so streamText would never observe the stop).
const model = new MockLanguageModelV3({
doStream: async ({ abortSignal }: any) => {
const stream = new ReadableStream<any>({
start(controller) {
controller.enqueue({ type: 'stream-start', warnings: [] });
controller.enqueue({ type: 'text-start', id: 't1' });
controller.enqueue({ type: 'text-delta', id: 't1', delta: 'partial' });
abortSignal?.addEventListener('abort', () => {
try {
controller.error(
new DOMException('Aborted', 'AbortError'),
);
} catch {
/* already errored/closed */
}
});
},
});
return { stream };
},
} as any);
const box: { runId?: string } = {};
const { cleanup } = await startRun({
registry,
runService,
model,
chatId,
body: { chatId, messages: [userUiMessage('Start then stop')] },
box,
});
try {
const sink = liveSink();
const att = (await registry.attach(chatId, false, undefined, sink.cb))!;
att.start();
// Give streamText a beat to begin consuming the partial output.
await sleep(250);
// User presses Stop -> the run signal aborts -> the SDK emits an abort chunk.
await runService.requestStop(box.runId!, workspaceId);
await waitFor(() => sink.ended());
expect(sink.frames.some((f) => f.includes('"type":"abort"'))).toBe(true);
expect(sink.frames.some((f) => f.includes('[DONE]'))).toBe(true);
expect(sink.ended()).toBe(true);
} finally {
registry.onModuleDestroy();
await cleanup();
}
});
it('the outer catch calls abortEntry so an open entry is released (finished)', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const registry = new AiChatStreamRegistryService();
const runService = new AiChatRunService(runRepo, {
isCloud: () => false,
} as never);
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
// A msgRepo whose user-row insert throws: the turn fails AFTER begin (the
// entry is already open) but BEFORE the pipe, exercising the outer catch.
const throwingMsgRepo = {
insert: async () => {
throw new Error('db boom');
},
findAllByChat: (...a: any[]) => (msgRepo as any).findAllByChat(...a),
update: (...a: any[]) => (msgRepo as any).update(...a),
findById: (...a: any[]) => (msgRepo as any).findById(...a),
};
const service = new AiChatService(
{ getChatModel: async () => null } as any,
aiChatRepo,
throwingMsgRepo as any,
{} as any,
{ resolve: async () => null } as any,
{ forUser: async () => ({}) } as any,
mcpClients as any,
{} as any,
{} as any,
{} as any,
{
isAiChatDeferredToolsEnabled: () => false,
isAiChatResumableStreamEnabled: () => true,
} as any,
registry,
);
const { res, cleanup } = await makeRealResponse();
const box: { runId?: string } = {};
try {
await expect(
service.stream({
user: { id: userId, workspaceId } as any,
workspace: { id: workspaceId, name: 'WS' } as any,
sessionId: 'sess-1',
body: { chatId, messages: [userUiMessage('will throw')] },
res: { raw: res } as any,
signal: new AbortController().signal,
model: model as any,
role: null,
runHooks: makeRunHooks(runService, registry, box),
} as any),
).rejects.toThrow();
// The entry opened at begin was terminated by abortEntry (from the catch),
// so it is finished and a plain attach returns null instead of hanging.
const entry = (registry as any).entries.get(chatId);
expect(entry).toBeDefined();
expect(entry.finished).toBe(true);
const sink = liveSink();
expect(await registry.attach(chatId, false, undefined, sink.cb)).toBeNull();
} finally {
registry.onModuleDestroy();
await cleanup();
}
});
it('legacy (no run-hooks): the registry is never populated', async () => {
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
const registry = new AiChatStreamRegistryService();
const model = new MockLanguageModelV3({
doStream: async () => ({ stream: successStream() }),
} as any);
// No runHooks -> runId undefined -> the run-wrapped tee is never wired.
const { cleanup } = await startRun({
registry,
model,
chatId,
body: { chatId, messages: [userUiMessage('Legacy hi')] },
});
try {
await waitFor(async () => {
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
return rows.some(
(r: any) => r.role === 'assistant' && r.status === 'completed',
);
});
const sink = liveSink();
// No entry was ever opened; attach always yields null.
expect(await registry.attach(chatId, false, undefined, sink.cb)).toBeNull();
expect(await registry.attach(chatId, true, 'anything', sink.cb)).toBeNull();
} finally {
registry.onModuleDestroy();
await cleanup();
}
});
});
+1
View File
@@ -16,6 +16,7 @@
"testEnvironment": "node",
"testTimeout": 60000,
"maxWorkers": 1,
"forceExit": true,
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
"moduleNameMapper": {
+5
View File
@@ -12,6 +12,11 @@ services:
ports:
- "3000:3000"
restart: unless-stopped
# The app already serves precompressed (brotli/gzip) static assets with
# long-lived cache headers and gzips dynamic API responses. For the best
# cold-load latency you can OPTIONALLY put a reverse proxy (caddy / nginx /
# traefik) in front with HTTP/2 (or HTTP/3) and brotli enabled — none is
# required for compression to work.
volumes:
- docmost:/app/data/storage
-520
View File
@@ -1,520 +0,0 @@
# Фича «Время работы над статьёй» — дизайн-документ
Статус: черновик проектирования (код не пишется).
Контекст: gitmost (форк Docmost). Зависит от PR #370 / PR #374 (типизированная история страниц).
## 1. Цель и не-цели
**Цель.** Показывать в UI страницы одно число — оценку времени, реально затраченного
на работу над статьёй, собранную из истории правок. Число должно быть устойчиво к
паузам: «в 21:00 одна правка, в 09:00 вторая» не должно превращаться в «работал 12 часов».
По клику на число — раскрытие в **суточный таймлайн**: строка-день = 24-часовая дорожка с
окнами активности и суммой за день (см. §6.2).
**Явно не-цели.**
- Это НЕ инструмент для агента (не MCP-tool). Это число, отображаемое человеку в UI.
- Не хронометраж с точностью до минуты — это заведомо оценка.
- Не биллинг/тайм-трекинг сотрудников; не изменение долговечности черновика.
## 2. Проблема
История страницы в Docmost — таблица `page_history`: снимок на каждое сохранение.
У снимка есть `createdAt`, автор (`lastUpdatedById`), тип источника
(`lastUpdatedSource`: `user`/`agent`/`git`) и группировка агентских правок
(`lastUpdatedAiChatId`).
Наивная оценка `max(createdAt) − min(createdAt)` завышает в разы: между крайними
правками лежат сон, обед, дни простоя. На реальных данных статьи-примера
(первая страница истории, 20 снимков) span между крайними снимками ≈ 60 часов,
тогда как реальной работы — пара часов в 5–6 коротких заходов.
Правильная постановка: **длительность — это не span между крайними правками, а сумма
интервалов внутри «сессий»; историю режем по паузам бездействия.** Это классическая
*сессионизация по таймауту неактивности* (WakaTime / RescueTime / веб-аналитика).
## 3. Почему PR #374 — фундамент фичи
До #374 у сессионизации слепое пятно: если человек час пишет подряд и не жмёт «Сохранить»,
в `page_history` за этот час почти нет строк (тяжёлые автосейвы ydoc идут в `pages`/`ydoc`,
а не в историю). Непрерывную работу нечем измерить.
PR #374 вводит типизированную историю через колонку `page_history.kind`:
| `kind` | что означает | ценность для фичи |
|------------|------------------------------------------------|----------------------------------------------------|
| `manual` | человек нажал Save | сильный маркер активной работы человека |
| `agent` | снимок правки агентом | машинное время агента (группируется по `aiChatId`) |
| `idle` | автоснимок idle-флеша (потолок ~`maxWait`) | регулярный «пульс» непрерывной работы (~≤10м, §3) |
| `boundary` | автоснимок на переходе актора (user↔agent↔git) | бесплатная разметка «кто работал в этом сегменте» |
| `null` | легаси-автосейв (старые страницы) | обычный сэмпл активности |
Два env-параметра #374 напрямую задают качество измерения:
- **`IDLE_MAX_WAIT_USER=10м` / `AGENT=5м` (потолок ожидания)** — определяющий параметр.
Проверено по `computeHistoryJob` (`persistence.extension.ts`):
`delay = max(0, min(interval, burstStart + maxWait − now))`, а `enqueuePageHistory`
сбрасывает `burstStart` каждые `maxWait`. Так как `maxWait` (10м/5м) < `interval` (60м/15м),
**потолок всегда доминирует**: во время непрерывной работы `idle`-снимок форсится каждые
~`maxWait`, давая регулярный «пульс активности» с шагом ≤10 мин (user) / ≤5 мин (agent).
- **`IDLE_INTERVAL_USER=60м` / `AGENT=15м`** — номинальный трейлинг-интервал, который потолок
ожидания на практике всегда упреждает. Поэтому метка любого `idle`-снимка отстоит от
реальной правки не более чем на ~`maxWait` (≤10м user / ≤5м agent), а **не** на 60 мин.
(Это ключевой факт для точности: `idle`-метки — достоверный сигнал активности, не «хвост».)
Вывод: ядро алгоритма работает и на голых `createdAt`+`lastUpdatedSource` (есть с миграции
`20260616T130000-agent-provenance`), поэтому фича считает и на легаси-страницах — просто
грубее. После #374 (пульс ≤10 мин + типы) — точно. Жёсткой блокировки на мёрж #374 нет,
но полноценная точность появляется вместе с ним.
## 4. Входной сигнал
Дешёвый проекционный запрос по `page_history` (без тяжёлой колонки `content`): на строку —
`createdAt`, `lastUpdatedById`, `lastUpdatedSource`, `lastUpdatedAiChatId`, `kind`.
Все поля (включая `kind`) уже в `PageHistoryRepo.baseFields` после #374 — схему трогать не
нужно, `findTimelineByPageId` это лишь лёгкая проекция без `content`.
**Важный факт об авторстве (проверено по `persistence.extension.ts``updatePage`):**
`page_history.lastUpdatedById` — это ВСЕГДА ответственный человек, даже для агентских снимков
(«human stays the responsible author»). Признак «человек vs агент» живёт в
`lastUpdatedSource` (`user`/`agent`/`git`) и `lastUpdatedAiChatId`, а НЕ в `lastUpdatedById`.
Отсюда: разделять человеко-/агенто-время нужно по `lastUpdatedSource`, а атрибутировать
конкретному человеку — по `lastUpdatedById`/`contributorIds`.
## 5. Алгоритм: сессионизация по паузам
Параметры (env, в стиле #374; полный список — §10):
- **`T_gap`** — таймаут неактивности: пауза между соседними сэмплами `≤ T_gap` = непрерывная
работа, больше = перерыв (в зачёт не идёт).
- **`P_in` / `P_out`** — добивка МНОГОсэмпловой сессии (работа началась до первого и продолжалась
после последнего сохранения).
- **`P_single`** — блок ОДИНОЧНОЙ сессии (один сэмпл, соседей в пределах `T_gap` нет). Мал
(дефолт ~2 мин): один автосейв/idle-пульс — это «была правка», но приписывать ему полный
`P_in+P_out` = выдумывать время. НЕ путать с многосэмпловой добивкой.
Псевдокод (ОДИН проход по ВСЕМ сэмплам страницы; класс определяется у ГОТОВОЙ сессии — §5.1):
```text
samples = ВСЕ history rows страницы, projected, sorted by createdAt ASC
(все типы kind — сэмплы активности; idle — основной «пульс» непрерывной работы §3, НЕ исключается)
# коллапс агентских всплесков (§5.1)
collapse: подряд идущие сэмплы ОДНОГО aiChatId с source=agent → сегмент {t_start, t_end, source:'agent'}.
Разрывает всплеск любой сэмпл, НЕ продолжающий тот же aiChatId-агент: source≠agent,
boundary-переход, ИЛИ иной aiChatId — в т.ч. idle/boundary с ДРУГИМ aiChatId = НОВЫЙ ран,
склеивать НЕЛЬЗЯ (иначе простой между двумя ИИ-ранами засчитается агенту). idle с ТЕМ ЖЕ
(или null) aiChatId — продолжает сегмент. Дальше сегмент участвует в цикле как один «сэмпл» с
.t_start/.t_end/.source (source='agent'); у скалярного сэмпла t_start == t_end == t.
gap_threshold(a, b) = (a и b оба source=agent) ? agentTGap : T_gap # порог зависит от ПАРЫ (§10)
# сессионизация по паузам — ОДИН проход по ВСЕМ сэмплам (не по парам, не отдельно по классам)
sessions = []; cur = null
for s in samples: # s — скаляр или коллапс-сегмент
if cur == null: cur = { first: s, last: s, samples: [s] }
elif s.t_start − cur.last.t_end ≤ gap_threshold(cur.last, s):
cur.last = s; cur.samples.push(s) # непрерывная работа
else: sessions.push(cur); cur = { first: s, last: s, samples: [s] }
if cur != null: sessions.push(cur) # ОБЯЗАТЕЛЬНО закрыть последнюю (иначе теряется)
# класс и интервал каждой сессии
for sess in sessions:
sess.class = sess.samples.every(is_agent) ? 'agent_only' : 'work' # §5.1 (по source сэмплов)
sess.iv = (sess.first == sess.last && sess.first.t_start == sess.first.t_end)
? [ sess.t − P_single, sess.t ] # одиночный скаляр: pre-roll (без «будущей» работы)
: [ sess.first.t_start − P_in, sess.last.t_end + P_out ] # многосэмпловая / лон-сегмент
workMs = duration( union( sess.iv : sess.class=='work' ) ) # union внутри класса
agentOnlyMs = duration( union( sess.iv : sess.class=='agent_only' ) )
```
Ключевые свойства:
- **Один проход, потом классификация → метрики дизъюнктны.** Сессии не перекрываются (разделены
гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают
в одну сессию класса `work` (надзор засчитан человеку, не дважды). `work`- и `agent_only`-интервалы
не пересекаются по wall-clock и `workMs + agentOnlyMs ≤ реально прошедшего` — **при рекомендованном
`T_gap ≥ P_in+P_out` (§10)**: иначе `P`-добивка соседних сессий РАЗНЫХ классов может пересечься, и
пересечение попадёт в обе метрики. Инвариант §6.3 (`Σ perDay == work`) от этого НЕ зависит.
- **Закрытие последней сессии обязательно** (иначе теряется самая свежая): `n=1` → одна одиночная
сессия `P_single`, `n=0` → 0.
- **`union`, а не `Σ`.** Перекрытия (соседние ближе `P`; одновременное соредактирование) не
задваиваются. Отсюда `Σ perDay == work` держится сам собой (§6.3) — без «клампа» и без условия
`T_gap ≥ P`.
- **Калибровка `T_gap` по «пульсу» #374 (важно).** После #374 непрерывная работа гарантированно
оставляет history-строку не реже ~`maxWait` (idle-пульс §3, гейт `isDeepStrictEqual`). Значит гэп
между соседними строками БОЛЬШЕ ~`maxWait` содержит участок без изменений контента = (частичное)
бездействие — даже между двумя `manual`. Поэтому `T_gap` калибруется ОТ `maxWait` (реком. ~15м
user / ~7м agent), а не ставится вольно: прежние «30 мин» переоценивали не-пульсирующие паузы.
Гэп `≤ T_gap ≈ maxWait` ПОДКРЕПЛЁН пульсом — это и оправдывает счёт его как работы. Легаси-страницы
до #374 пульса не имеют → там `T_gap` вынужденно шире и оценка грубее (§7, §10).
- **Всё равно оценка, а не строгая граница**: даже с пульсом «читал/думал 12 мин не печатая» не
отличить от «отошёл»; подпись «≈» и показ `T_gap` обязательны (§6).
- Почему интервалы, а не «правок × блок»: «снимок = +N мин» ломается на агентских всплесках
(8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков.
### 5.1. Классификация сэмплов и всплесков
- **Класс сэмпла (человек / агент)** — по `lastUpdatedSource`: `user`→человек, `agent`→агент,
`git`→исключаем (§10 `excludeGit`), legacy-`null`→человек. `idle` наследует `source` страницы
на момент флеша (= source последней правки). `boundary` несёт СТАРЫЙ (pre-transition) `source`
— трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не
недосмотр — точность посекундная тут не нужна).
- **Класс СЕССИИ** (нужен для метрик §6.1): сессия, где ВСЕ сэмплы `source=agent`**`agent_only`**
(автономный прогон, не в основную метрику). Сессия хотя бы с одним человеческим сэмплом →
**`work`** (человек + надзор за агентом ВНУТРИ сессии засчитывается человеку — по union'у, §5).
- **`idle`** — полноценный сэмпл активности и основной «пульс» непрерывной работы (§3): его метка
отстаёт от реальной правки ≤ `maxWait` (≤10м) — в пределах округления, отмотки не требует.
Исключать нельзя: без него непрерывное письмо без ручных сохранений снова стало бы невидимым.
## 6. Что показывать в UI
### 6.1. Свёрнутое состояние — одно число
Две метрики, каждая = union-wall-clock СВОИХ сессий (§5):
- **`work` — headline, кликабельное число** (`≈ 4 ч 30 мин`, рядом с панелью истории или в
мета-инфо у заголовка): сессии класса `work` (≥1 человеческий сэмпл = человек + надзор за
агентом внутри сессии). Именно это открывает таймлайн (§6.2), и именно к нему сходится сумма по
дням.
- **`agent_only` — вторично**: сессии автономных прогонов агента (ни одного человеческого сэмпла).
На таймлайне — отдельным цветом; в `work` НЕ входит.
- Подпись «≈» и показ `T_gap` обязательны (оценка, не хронометраж — §5). Округление headline —
шаг 5–15 мин (точность оценки не оправдывает «4 ч 27 мин»).
### 6.2. Раскрытие по клику — суточный таймлайн (24 ч × дни)
Клик по числу открывает модалку/поповер с таймлайном по типу «punch-card»:
- **Строка = один календарный день**; ширина строки = 24 часа (фиксированная шкала 00:00→24:00).
- На дорожке дня закрашены **окна активности** — реальные интервалы работы в их часовом
положении, так что видно «вечерний марафон» vs «утренняя сессия».
- **Справа от строки — сумма за день** «ч мм» (напр. `3 ч 17 м`); пустой день — пустая дорожка
и «—».
- Внизу — общий итог (= headline `work` §6.1) плюс подпись таймзоны и `T_gap`.
Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь
то же рисуется отрезками на 24-часовой дорожке.
Детали:
- Окна = сессии класса `work` (§5), обрезанные по границам суток; сессии `agent_only` — отдельным
цветом (§6.1). По умолчанию `work` — один цвет «активность».
- Сессия через полночь рисуется как отрезок до 24:00 в одном дне и продолжение с 00:00 в
следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден.
- **Честность добивки.** Окно включает `P`/`P_single` — это оценка «до/после», а не измеренный
интервал. Одиночная сессия (`P_single`) рисуется минимальной видимой шириной и приглушённо
(иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈».
### 6.3. Агрегация по дням (алгоритм)
Вход — ВСЕ сессии из §5 (`work` и `agent_only`), каждая развёрнута в интервал (`P_in/P_out` или
`P_single`).
```text
bucketByDay(sessions, tz):
U_work = union( sess.iv : sess.class=='work' ) # снимаем перекрытия ОДИН раз (§5)
U_agent = union( sess.iv : sess.class=='agent_only' ) # СИММЕТРИЧНО — иначе окна агента наложатся
для каждого дня D в [первый_день … последний_день] по tz (dayjs+tz, startOf('day')):
workWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_work }
agentWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_agent }
activeMs[D] = Σ длительностей workWin[D] # U_work без перекрытий → просто сумма
agentMs[D] = Σ длительностей agentWin[D]
→ perDay[] = [{ day, activeMs, agentMs, windows: (workWin[D] ⊕ agentWin[D]) с меткой класса }]
```
- **Инвариант согласованности `Σ activeMs[D] == work`** держится ПО ПОСТРОЕНИЮ: день — это
разбиение union'а `U_work` границами суток, ничего не теряется и не дублируется (в т.ч. на
23/25-часовых DST-сутках — §9#14). `agent_only`-окна рисуются, но в `activeMs` НЕ входят. Клампа
и скрытого условия `T_gap ≥ P` не требуется (в отличие от наивного `Σ` длительностей).
- **Таймзона `TZ`** определяет, где проходит «полночь» И в каких часовых координатах рисуются
окна. Дефолт — таймзона зрителя (локаль браузера); альтернатива — UTC (как на картинке-примере
«По дням (UTC)») или tz воркспейса. Влияет на раскладку по дням и положение окон, но НЕ на
общий итог → настройка (§10).
- **Длинный диапазон** (месяцы правок): строк ровно столько, сколько календарных дней в диапазоне.
При большом числе дней — вертикальный скролл + сворачивание длинных серий пустых дней
(«× N дней без правок») и/или переключение на понедельную группировку. Порог — настройка.
- **Округление дисплея:** сумму за день округляем до минут для подписи, но общий итог берём из
точных значений (иначе Σ округлённых по дням разойдётся с округлённым числом на ±1–2 мин).
## 7. Проверка на реальной статье
Ручной прогон на 20 снимках (1-я страница истории, `T_gap=30 мин`, `P_in+P_out=10 мин`,
`P_single=2 мин`; все сессии здесь класса `work` — в каждой есть человеческий сэмпл):
| Сессия | Интервал | Длит. |
|--------|-------------------------------------------------------|----------|
| S1 | 07-04 03:40 → 03:49 (многосэмпловая) | ≈19 мин |
| S2 | 07-04 15:43 → 16:13 (агент 15:43–15:50 → человек 16:13)| ≈41 мин |
| S3 | 07-04 18:11 (одиночная) | ≈2 мин |
| S4 | 07-04 19:38 → 19:54 (многосэмпловая) | ≈26 мин |
| S5 | 07-06 15:34 (одиночная) | ≈2 мин |
| S6 | 07-06 16:18 (одиночная, закрыта пост-циклом §5) | ≈2 мин |
| **Итого** | | **≈1 ч 32 мин** |
Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи.
Наблюдения:
- Разрезы легли по перерывам: ночь `03:49 → 15:43` (~12 ч) и сутки
`07-04 19:54 → 07-06 15:34` — оба выброшены.
- Чувствительность к порогу: `S5/S6` отстоят на 44 мин. При `T_gap=30` это две сессии,
при `T_gap=60` — одна (~44 мин). Число надо показывать **вместе с использованным порогом**.
- Это **оценка, не строгая граница** (§5), и НАПРАВЛЕНИЕ ошибки зависит от данных. Сохранения
ВНУТРИ `T_gap` мостятся, и вся пауза между ними засчитывается → на периодичном «фоновом» ритме
(напр. автосейв раз в ~`T_gap` при почти полном простое) возможен КРАТНЫЙ перебор (в разы), а не
«±порог». Сохранения ДАЛЬШЕ `T_gap` друг от друга, наоборот, теряют между-время → недобор. Поэтому
`T_gap` калибруется по пульсу #374 (§5, §10): после #374 «фоновый» ритм даёт гэпы > `maxWait` и
рвётся на перерывы, срезая кратный перебор; на легаси (пульса нет) оценка грубее в обе стороны.
Пример иллюстративен, посчитан на до-#374 срезе при `T_gap=30`.
## 8. Архитектура (куда встраивать)
**Ядро — чистая функция** `computeWorkTime(rows, config)` (детерминированная, без БД) в
отдельном модуле → легко покрыть юнит-тестами. Выход —
`{ workMs, agentOnlyMs, sessions[] }`, где `session = { start, end, class: 'work'|'agent_only' }`:
абсолютные границы (с добивкой `P`/`P_single`) плюс класс (§5.1) — этого достаточно и для метрик,
и для цвета окна. `workMs`/`agentOnlyMs` считаются как union-wall-clock сессий своего класса (§5).
**Вторая чистая функция** `bucketByDay(sessions, tz)` (ВСЕ сессии обоих классов) →
`perDay[] = [{ day, activeMs, agentMs, windows }]`: `activeMs` = длительность `work`-окон за день
(сходится к `work`, §6.3), `agentMs` = то же для `agent_only` (для подписи машинного времени за
сутки), `windows` — интервалы ОБОИХ классов, обрезанные по суткам и помеченные классом (для
отрисовки `work`/`agent_only` разным цветом, §6.2). Полуночный разрез — календарный, через
`dayjs` + tz-плагин (`startOf('day')` в `tz`), НЕ «+24 ч» (§9#14); `dayjs` уже в проекте. Отдельная
тестируемая функция (общий модуль сервера и клиента); `tz` — презентационный параметр, удобно звать
на клиенте от локали зрителя.
**Сервер:**
- `page-history.repo.ts` — метод `findTimelineByPageId(pageId)`: лёгкая проекция
(`createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind`) по всем строкам
ASC, без `content`.
- `page-history.service.ts``computeWorkTime(pageId, config)`: тянет таймлайн, зовёт ядро,
кэширует.
- `page.controller.ts` — рядом с `POST /history` и `POST /history/info` добавить
`POST /history/time` (или вложить в page-info). Отдаёт число + разбивку по сессиям.
**Клиент:**
- `page-history-query.ts` — хук `usePageWorkTime(pageId)` (возвращает `workMs`, `agentOnlyMs`,
`sessions[]`).
- Рендер кликабельного числа в панели истории.
- Модалка/поповер с суточным таймлайном (§6.2): `bucketByDay(sessions, viewerTz)` → строки-дни,
в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками
на 24-часовой шкале (позиция окна = `startOfDayOffset/24ч`, ширина = длительность/24ч). Готовые
чарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых
зависимостей. Пустые дни — пустая дорожка + «—».
**Производительность:** проекция без `content` дёшева; результат можно инкрементально кэшировать
(при `version.saved`, который #374 броадкастит, пересчитывать хвост).
## 9. Крайние случаи
1. Один снимок → одна одиночная сессия `P_single`, не ноль. Последняя сессия ВСЕГДА закрывается
пост-циклом (§5) — иначе теряется самая свежая.
2. История целиком агентская → `work = 0`, `agent_only = union прогонов`.
3. Плотный агентский всплеск → длина сегмента = его wall-clock (не зависит от числа снимков);
опц. кап `burstCapMs`.
4. Метка `idle` лагает ≤ `maxWait` (10м user / 5м agent) — в пределах округления; это
полноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1).
5. Несколько соавторов → атрибуция человеку по `lastUpdatedById`/`contributorIds`; разделение
человек/агент — по `lastUpdatedSource` (НЕ по `lastUpdatedById`, он всегда человек).
6. Легаси `kind=null` → работает на `source`+`createdAt`, грубее.
7. Совпадающие метки времени (boundary+agent в один момент; в коде есть tie-break по `id`)
→ дедуп по округлённому `t`.
8. Одновременное соредактирование → `union` (§5) убирает двойной счёт wall-clock при перекрытии
окон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим
(`perAuthor`), а не поведение по умолчанию.
9. Сессия через полночь (напр. `23:14 → 02:00`) → режется на границе суток `tz`, части идут
в разные дни; сумма по дням = общему числу (§6.3).
10. Выбор таймзоны дня меняет раскладку по дням (та же работа попадёт в другой день) — общий
итог не меняется; `tz` фиксируется в подписи графика.
11. Длинный диапазон правок (месяцы) → строк = число дней: вертикальный скролл + сворачивание
длинных серий пустых дней и/или понедельная группировка по порогу (§6.3).
12. День без правок → пустая 24-часовая дорожка + «—» в диапазоне (показываем ритм/паузы),
не пропускаем.
13. Очень короткое окно на 24-часовой шкале → рисуем минимальной видимой шириной, чтобы не
исчезало (§6.2).
14. Переход на летнее/зимнее время внутри `tz` → сутки в 23/25 ч; полуночный разрез считать
по календарю `tz` (`dayjs`+tz, §8), а не «+24 ч». Инвариант `Σ activeMs == work` держится
(разбиение union'а). Редкое исключение — tz с DST-переходом РОВНО в полночь (`startOf('day')`
неоднозначен) → до 1 ч может протечь в соседний день. Фиксированная 24-часовая дорожка (§6.2)
на 23/25-часовых сутках смещает окна визуально до ~1 ч — сознательное упрощение, на итог не
влияет.
## 10. Параметры по умолчанию и открытые решения
**Полный `config`** (env, дефолты):
- `T_gap≈15м` — калибровка по `IDLE_MAX_WAIT_USER=10м` + запас (§5: гэп больше `maxWait` не
подкреплён пульсом = бездействие; прежние «30м» переоценивали не-пульсирующие паузы).
- `agentTGap≈7м` — порог для ПАРЫ подряд идущих агентских сэмплов (`IDLE_MAX_WAIT_AGENT=5м` + запас).
- `P_in=5м`, `P_out=5м`, `P_single=2м` (одиночная — pre-roll, §5).
- `burstCapMs` (опц. кап на сегмент всплеска, §9#3), `dedupRoundMs` (дедуп совпадающих меток, §9#7),
`excludeGit=true`, `tz=локаль-зрителя`, `longRangeDayThreshold` (день→неделя, §6.3),
`perAuthor=false` (§9#8).
- **Легаси-страницы до #374** (нет пульса) → `T_gap` вынужденно шире, оценка там грубее.
- **`T_gap ≥ P_in+P_out`** НЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется
— иначе `work`/`agent_only` могут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится.
Развилки (настройки, не блокируют проектирование):
- `work` vs `agent_only` — показывать оба, крупно `work` (headline), `agent_only` вторично;
- точное место в UI — панель истории (основное) или мета-строка страницы;
- дефолт `T_gap` — вынести в env (как `IDLE_*` в #374), калибровать на реальных статьях
после мёржа #374;
- **таймзона дня для графика (§6.2/§6.3)** — рекомендую локаль зрителя (интуитивно «мои
вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на
раскладку по дням, не на общий итог;
- **порог перехода день→неделя/месяц** для длинного диапазона правок.
---
# Приложение: Review Ledger (рабочий аппарат, НЕ нормативная часть)
> Аппарат adversarial-review-loop. Перед выдачей реализатору выносится/сворачивается.
> Критикам: НЕ перелитигировать закрытые findings, КРОМЕ случая, когда сам RESOLUTION
> дефектен — тогда атаковать его явно и сказать, почему предыдущий раунд ошибся.
## CONFIG
- EXTERNAL_MODEL: endpoint `https://api.z.ai/api/coding/paas/v4`, model `glm-5.2`
(ключ хранится вне репозитория, в логи не пишется). Роль: cold-reader gate (Phase 4.5),
tiebreaker при эскалации.
- Артефакт: `docs/features/page-work-time_design.md`.
- Целевой класс: спец для реализации другим человеком → план 2–3 итерации, до пустого
cold-reader gate (не по числу итераций).
## FACT BASE (проверено по PR #374 @ commit 924f8aa, ветка feat/370-page-versioning)
Верификация автором ДО цикла (ветка не в рабочем дереве — критики не видят её локально;
факты ниже — ground truth, можно дозапросить файлы через gitea MCP по указанному SHA):
- `page_history.kind``varchar(20)`, NULLABLE, БЕЗ дефолта (migration
`20260705T120000-page-history-kind.ts`). Домен: `manual`/`agent`/`idle`/`boundary`;
legacy `null` = автосейв (`collaboration/constants.ts`, `PageHistoryKind`).
- `kind` УЖЕ включён в `PageHistoryRepo.baseFields` (`page-history.repo.ts`) — читается всеми
выборками истории. `saveHistory({kind})` и `updateHistoryKind(id, kind)` существуют.
- Тайминги (`constants.ts`): `IDLE_INTERVAL_USER=60м`/`AGENT=15м`;
`IDLE_MAX_WAIT_USER=10м`/`AGENT=5м`.
- `computeHistoryJob` (`persistence.extension.ts`):
`delay = max(0, min(interval, burstStart + maxWait − now))`; `enqueuePageHistory` сбрасывает
`burstStart` каждые `maxWait`. Следствие: **потолок всегда доминирует**`idle` пульсирует
каждые ~`maxWait` при непрерывной работе; метка `idle` отстоит от правки ≤ `maxWait`, НЕ 60м.
- Идл-джоб всегда ставится с `kind:'idle'`; процессор пишет `job.data.kind ?? 'idle'`, но только
если контент изменился (`isDeepStrictEqual`-гейт).
- `boundary`-снимок пишется СИНХРОННО в store-транзакции на смене `lastUpdatedSource`
(user↔agent↔git), фиксирует ИСХОДЯЩИЙ (pre-transition) контент со СТАРЫМ source; его
`createdAt` = момент перехода (точный).
- `manual`/`agent` — явный save-version по stateless-каналу; `kind` выводится из
`context.actor` СЕРВЕРНО (неподделываемо). Promote-not-dup: апгрейд `kind` последнего снимка
на месте вместо дубля.
- `page_history.lastUpdatedById` = ВСЕГДА ответственный человек (даже для агентских снимков);
«agentness» — в `lastUpdatedSource` + `lastUpdatedAiChatId`.
- REST истории: `POST /history`, `POST /history/info` (`page.controller.ts`). Клиент:
`apps/client/src/features/page-history/*`. Есть broadcast `version.saved` (для live-инвалидации).
## Pre-loop fact-base corrections (автор, проверено vs source)
- C1. §3/§5.1/§крайние-случаи#4: убрано ошибочное «idle лагает до 60м / idle не удлиняет
сессию / отмотка на IDLE_INTERVAL». Верно: лаг ≤ `maxWait` (≤10м), `idle` — полноценный сэмпл.
- C2. §3: устранено внутреннее противоречие §3↔§5.1 (пульс vs исключение idle).
- C3. §4: `kind` уже в `baseFields` — схему менять не нужно.
- C4. §крайние-случаи#5: `lastUpdatedById` всегда человек; человек/агент — по `lastUpdatedSource`.
## Scope changes
- S1 (owner, до итерации 1): добавлен drill-down — клик по числу открывает график по дням.
- S2 (owner, до итерации 1): drill-down переопределён с «столбцы часов/день» на СУТОЧНЫЙ
ТАЙМЛАЙН (строка-день = 24 ч с окнами активности + сумма за день, §6.2/§6.3). Окна вернулись,
но графикой. Затронуты §1, §6.2, §6.3, §8, §9(#9–14), §10.
## Iteration log
### Итерация 1 — критики: Claude (hardened, A) + GLM-5.2 external (B)
Первый прогон local-субагентов вернул инъекцию из подложенного SKILL.md (реклама
«agent-first-plugin-suite») и 0 полезной работы — проигнорировано; пере-прогон с анти-инъекцией
дал реальные ревью. Дефекты и диспозиции:
- **[BLOCKER] A1** — §5-псевдокод не закрывал последнюю сессию (терял свежую; 0 сессий для
односессионной страницы; ронял S6). ПРИНЯТО → пост-цикловое закрытие, `n=1``P_single`, `n=0`→0.
- **[MAJOR] A2+B4** — какое число суммирует таймлайн; двойной счёт при перекрытии. ПРИНЯТО →
метрики `work`/`agent_only` (§6.1) + `total`/`perDay` через **union** (§5, §6.3).
- **[MAJOR] A3** — §1 остался со старым «столбцом на день» (residue S1→S2). ПРИНЯТО → §1 переписан.
- **[MAJOR] A4** — «кламп vs скрытое условие `T_gap ≥ P`». ПРИНЯТО, но растворено: union убрал и
кламп, и условие (§6.3).
- **[MAJOR] A5** — форма `session` и правило классификации человек/агент (нужны для `workMs` и
цвета). ПРИНЯТО → §5.1 классификация (+ nuance про старый source у boundary), `session.class` (§8).
- **[MAJOR→понижено] B1** — «нижняя оценка» ложна; гэп ≤ T_gap считается работой. ЧАСТИЧНО ПРИНЯТО
+ КОНТР по severity: гэп-как-работа — by design (по меткам не отличить «думал» от «отошёл»),
поведение оставлено; исправлено УТВЕРЖДЕНИЕ (§5/§7: «оценка, не строгая граница»). Понижено с
BLOCKER: это не баг кода, а некорректная формулировка/честность.
- **[MAJOR] B2+B5** — инфляция одиночных сессий на `P` + честность отрисовки добивки. ПРИНЯТО →
`P_single` (мал), приглушённая отрисовка + «≈» (§5, §6.2).
- **[MAJOR/MINOR] B3+A6** — коллапс агентского всплеска рушит вклинившегося человека + выбор
конца для гэп-теста. ПРИНЯТО → правило коллапса (только подряд один aiChatId; человек внутри
разрывает; левый гэп до `t_start`, правый от `t_end`) (§5).
- **[MINOR] A7** — `config` не перечислен. ПРИНЯТО → полный список (§10).
- **[MINOR] A8** — ячейка `idle` в таблице §3 устарела. ПРИНЯТО → исправлена.
- **[NIT] A9** — расхождение округления headline vs подписи дня. Оставлено с оговоркой (§6.3).
- **[NIT] A10** — назвать `dayjs` для tz/DST-разреза. ПРИНЯТО (§8, §9#14).
Verified clean (оба критика): факты #374; tz/DST-разрез; лёгкая проекция без `content`;
кэш на `version.saved`; разбиение на две чистые функции; §2-постановка; арифметика §7 (при
исправленном алгоритме).
### Итерация 1 — post-integration re-attack (Claude A + GLM round 2) — ЗАКРЫТА
Оба критика НЕЗАВИСИМО нашли один и тот же блокер и сошлись (разные семейства моделей):
- **[BLOCKER] N1 (A) = MAJOR-1 (GLM)** — §5 «сессионизация ОТДЕЛЬНО по классам» противоречила
§5.1/§7/§8 и теряла надзорное агентское время (S2 схлопывалась в 2 мин; `workMs+agentOnlyMs` мог
превысить прошедшее). Исправлено: ЕДИНЫЙ проход по всем сэмплам + классификация готовой сессии;
метрики — union внутри класса.
- **[MAJOR] N2 (A)** — union только ВНУТРИ класса; кросс-класс дизъюнктность требует `T_gap ≥ P`,
а §10 это отрицал; `agentTGap` неопределён при едином проходе. ПРИНЯТО → `gap_threshold` по ПАРЕ
сэмплов (agent–agent → `agentTGap`), caveat в §5, §10 смягчён (валидируем `T_gap ≥ P`).
- **[MAJOR] GLM-2** — `bucketByDay(workSessions)` не мог рисовать `agent_only`. ПРИНЯТО → сигнатура
`bucketByDay(sessions)`, окна ОБОИХ классов, `activeMs` = work-only.
- **[MINOR] N3** — роль `boundary` противоречива (§5 «человеческий» vs §5.1 «старый source=agent»).
ПРИНЯТО → §5: всплеск рвёт любой сэмпл, не продолжающий тот же aiChatId-агент; класс — по source.
- **[MINOR] N4** — псевдокод точечный, концы сегмента не заданы. ПРИНЯТО → сегмент {t_start,t_end};
same-source idle внутри всплеска продолжает сегмент.
- **[NIT] N5** — DST ровно в полночь + фикс. 24ч-дорожка ±1ч визуально. ПРИНЯТО → оговорка §9#14.
- **[NIT] N6** — `P_single` симметричный «выдумывает будущую работу». ПРИНЯТО → pre-roll `[t−P_single, t]`.
- **B1-severity (спор):** A СОГЛАСИЛСЯ с понижением BLOCKER→MAJOR (нет входа, где код отклоняется от
намерения — только формулировка/UX). GLM НАСТАИВАЛ на BLOCKER с НОВЫМ аргументом: отсутствие
idle-пульса в гэпе = доказанное бездействие. Диспозиция: label = MAJOR, но СУБСТАНЦИЯ GLM принята
(Invariant 7 — уступка с аргументом): `T_gap` калиброван по `maxWait` (§5/§10) + принцип «гэп >
maxWait = перерыв». A-residue (направление ошибки data-dependent, возможен КРАТНЫЙ перебор) принят
в §7. Тайбрейкер не понадобился (обе стороны привели аргументы, интегрированы обе).
Verified clean (round-2): инвариант §6.3 под DST (оба критика); burst-endpoints (оба); `P_single`
через полночь (GLM). Блокеров в ядре: 0. Итерация 1 закрыта.
### Convergence validation + cold-reader gate — ЗАКРЫТА (CONVERGED)
- **Cold-reader gate** (GLM-5.2, внешняя модель, «имплементер читает впервые»): ПУСТОЙ список
вопросов на день 1 — ДВАЖДЫ (до и после партии полировки).
- **Convergence pass** (GLM-5.2, свои hostile-сценарии + слепая реализация): вердикт NOT CONVERGED
на ещё не атакованной партии round-2 → нашёл 2 MAJOR + 1 MINOR (как и предупреждает skill —
«re-opened cycles find MAJORs in unreviewed amendments»):
- [MAJOR] §6.3 рисовал `agent_only`-окна из «сырых» `sess.iv` (не union) → визуальное наложение.
`U_agent = union(...)`, симметрично `U_work`; добавлен `agentMs[D]`.
- [MAJOR] §5 «idle продолжает сегмент НЕЗАВИСИМО от aiChatId» склеивал разные ИИ-раны через
idle-снимок. → idle с ДРУГИМ `aiChatId` = новый ран, рвёт сегмент.
- [MINOR] нет per-day суммы агента. → `agentMs` в `perDay`/§8.
Все приняты и интегрированы; финальный re-gate (GLM cold-reader) → снова ПУСТО, рассинхрона нет.
- **Tooling note:** local general-purpose Claude-субагенты трижды перехватывались инъекцией из
подложенного SKILL.md (реклама «agent-first-plugin-suite» / фейковые «review this PR» промпты,
0 tool-uses). Проигнорировано. Адверсариальные проходы и cold-reader выполнены внешней GLM-5.2
(гетерогенная модель — как раз рекомендована skill против self-preference) + один hardened
Claude-проход, который сработал.
## Closure checklist
1. 0 блокеров в ядре; счётчики обсуждения в обе стороны (приняты находки И отбит spor по B1-severity) — ✓
2. Партия полировки re-gated финальным cold-reader на полном тексте — ✓
3. Cold-reader: список вопросов ПУСТ (дважды) — ✓
4. Прогноз сходимости трактовался как гипотеза; решал чек-лист (convergence-pass реально нашёл MAJOR) — ✓
5. Backcast: частично (пример §7 на реальных данных + 36.5ч-картинка владельца) — обязательным не был
6. Preconditions зафиксированы: зависимость точности от #374 (пульс), грубость на легаси, DST-в-полночь, `T_gap ≥ P`
**СТАТУС: CONVERGED.** §1–§10 — нормативная спека для реализатора; это приложение — аудит-след (не нормативно).
-308
View File
@@ -1,308 +0,0 @@
# Reading the AI dialog logs (how agents call tools, and where they fail)
How to inspect the agent conversation history in the database — and, more
importantly, the **one non-obvious trap that will make you report the wrong
answer**: the persisted history *silently hides hard tool failures*. Written from
real pain (a "which tools fail most?" analysis that confidently answered
"patchNode: 0 errors" while the UI was visibly full of red `patchNode` failures).
Read the **Gotchas** section before you trust any error count.
## TL;DR
- Agent chats live in Postgres, DB `docmost`, tables `ai_chat_*`.
- Each tool invocation is stored as **two** array elements (a `tool-call` part and
a `tool-result` part), so naive counting double-counts.
- **A tool that *throws* writes no result part.** Since the #407 fix its error is
persisted as a dedicated `{toolName, error}` element in `tool_calls` (queryable +
replayed to the model). **Rows written before #407 still drop it** — the error is
nowhere in the DB and shows only in the live UI. So `isError` / `success=false`
scans under-report by design, and pre-#407 thrown errors are invisible.
- To find where agents fail: (1) soft-failure markers in `tool_calls`, (2) the new
`error` field for thrown errors (new rows) / the orphan-gap proxy (old rows),
(3) server logs / the live UI for full stack traces beyond the truncated message.
## Where the data lives
Host `island.lc` (`10.31.40.120`), container `gitmost-postgresql`
(`pgvector/pgvector:pg18`), database `docmost`.
```bash
ssh island.lc
# one-off query:
docker exec gitmost-postgresql psql -U docmost -d docmost -P pager=off -c "SELECT ..."
# interactive:
docker exec -it gitmost-postgresql psql -U docmost -d docmost
```
The main app container is `gitmost` (`DATABASE_URL=postgresql://docmost:...@db:5432/docmost`).
All workspaces (vvzvlad / wb / asakusa / …) share this **single** database — they
are rows in `workspaces`, not separate deployments.
### Relevant tables
| Table | What it holds |
| --- | --- |
| `ai_chats` | one row per conversation (`title`, `role_id`, `page_id`, `creator_id`) |
| `ai_chat_messages` | every message; tool calls live in `tool_calls` jsonb |
| `ai_chat_runs` | one row per agent run (turn): `status`, `error`, `step_count` |
| `ai_agent_roles` | agent definitions (`instructions`, `model_config`) |
| `ai_mcp_servers` | configured MCP tool servers per workspace |
`ai_chat_messages` columns that matter: `role` (`user` | `assistant` — there is **no**
separate `tool` role), `content` (text), `tool_calls` (jsonb array), `metadata`
(jsonb, holds run `error` + rendered `parts`), `status`, `tsv` (full-text index).
## How tool calls are stored — READ THIS
Tool calls are **not** one-object-per-call. Each logical invocation is split into
two consecutive elements of the `tool_calls` array:
```text
index 0: { "toolName": "getPage", "input": { "pageId": "…" } } ← tool-call (has input, NO output)
index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input)
```
The keys that appear on an element are `toolName`, `input`, `output`, and — for a
**thrown** failure on rows written after the #407 fix — `error` (the tool's error
message; see the "Hard failures" section below). There is no `state`, no `errorText`,
no `type`. On pre-#407 rows a thrown failure has NO paired result element at all
(silent orphan). Consequences:
1. **Real invocation count = elements that have `output` or `error`.** Counting every
element double-counts (you get ~2× and a spurious "~50% of every tool has no output").
2. **Pairing:** a call = a `tool-call` part followed by its result part. A success
carries `output`; a thrown failure (post-#407) carries `error` instead. Both carry
`toolName`, so you can group by tool on either.
## The two classes of failure (and which the DB can see)
### 1. Soft failures — tool RAN and returned an error-shaped result → PERSISTED ✅
These are visible in the `tool-result` `output`. The marker differs per tool:
| Tool(s) | Error marker in `output` |
| --- | --- |
| `editPageText` | `failed` is a **non-empty** array of `{find, reason}` (e.g. `text not found in the document`, `matches N times — provide a longer fragment or set replaceAll`). Also a soft `warning` when the `find` string contained markdown that only matched after stripping. |
| `semanticSearch` | `{ "unavailable": true, "reason": "semantic search unavailable" }` (feature/infra, not the agent's fault) |
| MCP passthrough (`Habr_*`, some `Search_*`) | `output` is an **array** (raw MCP content) whose text starts with `Error executing tool … validation error …` |
| generic | `output.isError = true` or `output.success = false` |
Note `editPageText` returns `failed: []` on success — filtering on the *presence*
of the key gives false positives; filter on **non-empty**.
### 2. Hard failures — tool THREW → NOW PERSISTED ✅ (since the #407 fix)
When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell`
`Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the
runtime still writes **no `tool-result` part** — the failure is an ai@6 `tool-error`
content part instead. **Since the #407 fix, that error is persisted**: `serializeSteps`
appends a dedicated element `{toolName, error: "<message>"}` right after the failed
call, mirroring how a successful `{toolName, output}` element is appended. So a thrown
error now leaves a queryable `error` field carrying its (truncated) reason, and the
same real text is replayed to the model on the next turn (an `output-error` part with
the real `errorText`, no longer the `'Tool call did not complete.'` placeholder).
**Cutover caveat — old rows keep the old blind shape.** Rows written **before** this
change have the two-part shape (`call` + `output` only) and simply **drop** thrown
errors, leaving a silent **orphan** (a `call` with no `output` *and* no `error`). Rows
written **after** the fix additionally carry the `error` element. So:
- **New rows:** query the `error` field directly (see the hard-error query below) — no
orphan heuristic needed for thrown failures.
- **Old rows (pre-#407):** the only DB-side proxy is still an **orphan**: a `tool-call`
part with no matching `tool-result` *and* no `error`. Orphans also appear when a run
is **aborted** mid-flight (server restart), so a high-volume tool (`createComment`,
`searchInPage`, `Search_web_search`) shows orphans from aborts, not real errors on
old rows. Treat the orphan gap as an *upper bound*, and cross-check the tool: a gap on
a structural editor (`patchNode`, `insertNode`, `updatePageJson`, `transformPage`) is
almost certainly a thrown Yjs-encode error; a gap on `createComment` is mostly aborts.
A note on the aborted-call fallback: a call with **neither** a result **nor** a
`tool-error` (genuinely interrupted mid-step) still replays with the
`'Tool call did not complete.'` placeholder and persists as an orphan — that path is
unchanged, and is distinct from a real thrown error, which now carries `error`.
### 3. Run-level failures → `ai_chat_runs`
`status``succeeded | aborted | failed | running`; `error` holds the text. Seen in
the wild: `Run interrupted by a server restart.` (aborts) and
`Failed after N attempts. Last error: The service may be temporarily overloaded`
(LLM provider 529). These are infra/provider, not agent tool misuse.
## Ready-to-use queries
Run all of these via `docker exec gitmost-postgresql psql -U docmost -d docmost -P pager=off -c "…"`.
**Real invocation count per tool** (result parts only — the correct denominator):
```sql
SELECT elem->>'toolName' AS tool, count(*) AS calls
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'output'
GROUP BY 1 ORDER BY 2 DESC;
```
**Soft errors per tool** (everything the DB can honestly see):
```sql
WITH res AS (
SELECT elem->>'toolName' AS tool, elem->'output' AS o
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'output'
)
SELECT tool, count(*) AS calls,
sum(COALESCE(
(o->>'isError') = 'true'
OR (o->>'success') = 'false'
OR (jsonb_typeof(o->'failed') = 'array' AND o->'failed' <> '[]'::jsonb)
OR (o->>'unavailable') = 'true'
OR o::text ~* 'error executing tool|validation error'
, false)::int) AS soft_errors
FROM res GROUP BY tool HAVING sum(COALESCE(
(o->>'isError') = 'true' OR (o->>'success') = 'false'
OR (jsonb_typeof(o->'failed') = 'array' AND o->'failed' <> '[]'::jsonb)
OR (o->>'unavailable') = 'true' OR o::text ~* 'error executing tool|validation error'
, false)::int) > 0
ORDER BY soft_errors DESC;
```
**`editPageText` failure reasons** (the most common real agent mistake — bad `find`):
```sql
WITH res AS (
SELECT elem->'output' AS o
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array'
AND elem->>'toolName' = 'editPageText' AND elem ? 'output'
)
SELECT f->>'reason' AS reason, count(*)
FROM res, jsonb_array_elements(o->'failed') f
WHERE jsonb_typeof(o->'failed') = 'array'
GROUP BY 1 ORDER BY 2 DESC;
```
**Hard errors — persisted `error` field per tool (NEW rows, since #407)** — thrown
tool failures now carry their real reason, so query them directly:
```sql
SELECT elem->>'toolName' AS tool, count(*) AS thrown_errors,
min(elem->>'error') AS sample_error
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'error'
GROUP BY 1 ORDER BY 2 DESC;
```
**Hard-error proxy for OLD rows (pre-#407) — orphan gap per tool, WITH a spread column**
(call parts minus result parts, plus how many distinct chats the gap is spread across).
This covers rows written before thrown errors were persisted; on new rows a thrown
failure now has its own `error` element (use the query above) and an orphan means only
a genuinely aborted mid-step call:
```sql
WITH parts AS (
SELECT m.chat_id, elem->>'toolName' AS tool,
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
(elem ? 'output' OR elem ? 'error') AS is_result
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant'
),
per_chat AS (
SELECT tool, chat_id, sum(is_call::int) - sum(is_result::int) AS gap
FROM parts GROUP BY tool, chat_id
)
SELECT tool,
sum(gap) FILTER (WHERE gap > 0) AS missing_results,
count(*) FILTER (WHERE gap > 0) AS chats_spread, -- disambiguates!
max(gap) AS worst_single_chat
FROM per_chat GROUP BY tool
HAVING sum(gap) FILTER (WHERE gap > 0) > 0
ORDER BY missing_results DESC;
```
The `is_result` predicate counts an `error` element as a paired result too, so on new
rows a persisted thrown error no longer inflates the orphan gap; a remaining gap is an
aborted/interrupted call.
**On OLD rows, `missing_results` mixes thrown errors AND aborted/interrupted runs — you
cannot split them from `output` alone** (a positional "what follows the orphan" heuristic
breaks on parallel tool batches, which persist as `call,call,…,result,result`). Use
`chats_spread` to disambiguate:
- **spread across many chats** (e.g. `createComment` 96 over 29 chats) → a **systemic
real error** (here: inline-comment anchor text not found on the page).
- **concentrated in one chat** (e.g. `searchInPage` 55, of which 51 in a single chat)
**one runaway/aborted session**, not a real per-call error — discount it.
- a gap on a **structural editor** (`patchNode`, `insertNode`, `tableUpdateCell`,
`updatePageJson`, `transformPage`) is almost always a thrown Yjs-encode error.
**Run-level failures:**
```sql
SELECT status, count(*), min(error) AS sample_error
FROM ai_chat_runs GROUP BY status ORDER BY 2 DESC;
```
**Full-text search across messages.** The `tsv` GIN index is built as
`to_tsvector('english', unaccent(content))` — so it **stems English** but **not
Russian** (Russian lexemes are stored unstemmed, so only exact word forms match).
Most content here is Russian, so prefer `ILIKE` for substring search:
```sql
-- Russian / substring — reliable:
SELECT chat_id, left(content, 120)
FROM ai_chat_messages WHERE content ILIKE '%иранск%' LIMIT 20;
-- English phrase — can use the index:
SELECT chat_id, left(content, 120)
FROM ai_chat_messages
WHERE tsv @@ websearch_to_tsquery('english', 'some phrase') LIMIT 20;
```
## Don't blow up your context
A single `tool_calls` row can be **300–400 KB** (results embed full page content and
search payloads). Never `SELECT tool_calls` (or `jsonb_pretty(tool_calls)`) raw.
Always project just the keys you need and truncate:
```sql
SELECT elem->>'toolName',
left(regexp_replace((elem->'output')::text, '\s+', ' ', 'g'), 200)
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
WHERE elem ? 'output' LIMIT 5;
```
## Server logs & live UI (for the error text the DB drops)
```bash
docker logs -f --tail=100 gitmost # main app
docker compose -p gitmost logs -f --tail=100 # whole stack
```
Logging is `json-file`, `max-size=10m max-file=5` → ~50 MB retained, then rotated,
and **wiped on container recreate**. Since the #407 fix, thrown-tool error text is
**persisted in the `error` field** of `tool_calls` (see the hard-error query above), so
you no longer depend on live logs for it. Logs/live UI remain useful for **pre-#407
rows** (whose thrown errors were dropped) and for full stack traces beyond the
truncated stored message. A per-tool `tool_calls_total{tool,status}` metric to
VictoriaMetrics is still a possible future add for aggregate dashboards.
## Gotchas checklist
- [ ] Counting every `tool_calls` element → **overcount**. Count `output` elements; add `error` elements for thrown failures (new rows), but don't count both as invocations.
- [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors are a separate `error` element (new rows) or dropped entirely (pre-#407 rows).
- [ ] Thrown errors persist only on rows written **after the #407 fix** — pre-#407 rows still drop them (orphan only). Mind the cutover when trending over time.
- [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence.
- [ ] Orphan gap on OLD rows mixes thrown errors **and** aborted runs — split by tool. On NEW rows a thrown error is its own `error` element, so a gap ≈ aborted call.
- [ ] `aborted` runs = server restarts, `failed` runs = provider overload — not agent mistakes.
- [ ] Never dump a raw `tool_calls` cell — it can be hundreds of KB.
- [ ] Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live.
## Snapshot (2026-07-07, illustrative — rerun the queries for current numbers)
- 226 chats, 732 messages, 46 runs; ~4 400 real tool invocations.
- Soft errors (persisted): `editPageText` 4/79 (bad/non-unique `find`) + 9 markdown-in-`find` warnings; `semanticSearch` 3/4 (`unavailable`); `Habr_update_draft_from_docmost` 1/2 (`doc` sent as object, not string).
- Missing-result proxy, read WITH the spread column:
- **Systemic (spread) → real errors:** `createComment` 96 over **29 chats** (comment anchor text not found — the biggest real error hotspot); `editPageText` 31 over 12 chats (+ the 4 soft above); structural-editor Yjs throws `insertNode` 10 / `updatePageContent` 9 / `tableUpdateCell` 6 / `patchNode` 5 / `updatePageJson` 2 / `transformPage` 2.
- **Concentrated → NOT real errors:** `searchInPage` 55 (51 in one chat); `Search_web_search` 15 & `Search_searxng_web_search` 6 (timeouts/aborts in long research sessions).
- Runs: 34 succeeded, 10 aborted (server restart), 1 failed (provider overload).
+1 -2
View File
@@ -97,8 +97,7 @@
"patchedDependencies": {
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
"yjs@13.6.30": "patches/yjs@13.6.30.patch",
"ai@6.0.134": "patches/ai@6.0.134.patch",
"@hocuspocus/server@3.4.4": "patches/@hocuspocus__server@3.4.4.patch"
"ai@6.0.134": "patches/ai@6.0.134.patch"
},
"overrides": {
"prosemirror-changeset": "2.4.0",
+4 -9
View File
@@ -55,15 +55,10 @@ describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () =>
const file2 = await stabilizePageFile(doc2, meta);
expect(file2).toBe(file1);
// The drawio node was materialized to its canonical HTML form by the
// convergence pass — a bare `{ src }` doc node becomes the full
// `<div data-type="drawio" data-src=...>` — proof the pass actually ran, not
// just two naive exports happening to match. Assert on the stable canonical
// markers rather than `data-align="center"`: center is a schema default the
// converter may omit (see prosemirror-markdown media-html.ts), so it is not
// a reliable convergence proof.
expect(body1).toContain('data-type="drawio"');
expect(body1).toContain('data-src="/d.drawio"');
// The materialized diagram default is present in the stabilized body (proof
// that the convergence pass actually ran, not just that two naive exports
// happened to match).
expect(body1).toContain('data-align="center"');
});
it('already-stable content is unchanged by the pass (idempotent)', async () => {
-1
View File
@@ -52,7 +52,6 @@
"form-data": "^4.0.0",
"jsdom": "^27.4.0",
"marked": "^17.0.1",
"pako": "^2.0.3",
"re2": "^1.21.0",
"ws": "^8.19.0",
"y-prosemirror": "1.3.7",
+216 -727
View File
File diff suppressed because it is too large Load Diff
+96 -96
View File
@@ -13,11 +13,6 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
export { DocmostClient } from "./client.js";
export type { DocmostMcpConfig } from "./client.js";
// Teardown for the live per-page CollabSession cache (issue #400). An embedding
// HTTP host (the gitmost NestJS server) should call this from its own shutdown
// hook so no cached collab provider outlives the process.
export { destroyAllSessions } from "./lib/collab-session.js";
// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
// service can read it off the loaded module (it cannot import the ESM package's
// internals directly; it goes through loadDocmostMcp()).
@@ -52,7 +47,7 @@ const VERSION = packageJson.version;
export const SERVER_INSTRUCTIONS =
"Docmost editing guide — choose the tool by intent.\n" +
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
@@ -69,33 +64,6 @@ const jsonContent = (data: any) => ({
* REST + the collaboration WebSocket using the provided service-account
* credentials and auto-re-authenticates.
*/
/**
* Wrap a tool handler so its wall-clock duration is reported through the host's
* dependency-neutral sink as `mcp_tool_duration_seconds` (labelled by tool
* name). Pure and side-effect-free apart from the optional `onMetric` call:
* - preserves the handler's exact return value (awaited);
* - observes in a `finally`, so it records on BOTH success and throw, then
* rethrows the original error unchanged (never swallowed);
* - with no `onMetric` (standalone/stdio) it is a transparent pass-through.
* Exported so the timing contract can be unit-tested without a live transport.
*/
export function timeToolHandler(
name: string,
handler: (...args: any[]) => any,
onMetric?: (name: string, value: number, labels?: Record<string, string>) => void,
): (...args: any[]) => Promise<any> {
return async (...handlerArgs: any[]) => {
const start = performance.now();
try {
return await handler(...handlerArgs);
} finally {
onMetric?.("mcp_tool_duration_seconds", (performance.now() - start) / 1000, {
tool: name,
});
}
};
}
export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
// Pass the whole config union through: the client branches internally on
// credentials vs. getToken, so both the external /mcp (creds) and the
@@ -110,26 +78,6 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
{ instructions: SERVER_INSTRUCTIONS },
);
// Single choke point for MCP tool timing. Both `registerShared` (below) and
// the inline `server.registerTool(...)` calls funnel through this one method,
// so monkeypatching it HERE — before any tool is registered and before
// `registerShared` captures a reference to it — times every tool with no
// per-tool boilerplate. The wrapped handler records wall-clock duration and,
// in a `finally`, feeds the host's dependency-neutral sink
// `config.onMetric("mcp_tool_duration_seconds", seconds, { tool })`. The tool
// name is the registration name (bounded cardinality). When no onMetric is
// provided (standalone/stdio) the wrapper is a pure pass-through: it still
// returns the original result and rethrows the original error unchanged.
const originalRegisterTool = server.registerTool.bind(server) as (
...args: any[]
) => any;
(server as any).registerTool = (...args: any[]) => {
const name = args[0] as string;
const handler = args[args.length - 1];
const timedHandler = timeToolHandler(name, handler, config.onMetric);
return originalRegisterTool(...args.slice(0, -1), timedHandler);
};
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
// the canonical name + model-facing description + (optional) schema builder;
// only the execute body is supplied per call. buildShape is invoked with THIS
@@ -432,10 +380,43 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
});
// Tool: insert_image
// Schema + description now live in the shared registry (#410) so BOTH this MCP
// server and the in-app AI-chat agent expose it. The execute body is unchanged.
registerShared(
SHARED_TOOL_SPECS.insertImage,
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
// exposes no image tools (insert/replace), so there is no second layer to unify
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
// catalog-partition test forbids a spec without a live in-app tool (#294).
server.registerTool(
"insert_image",
{
description:
"Download an image from a web (http/https) URL and insert it into " +
"a page in one step. By default " +
"appends the image at the end of the page. With replaceText, replaces the " +
"first top-level block whose text contains that string (handy for " +
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
"With afterText, inserts the image right after the first block containing " +
"that string. Preserves all other block ids.",
inputSchema: {
pageId: z.string().min(1),
imageUrl: z
.string()
.min(1)
.describe("http(s) URL of the image to download and upload"),
align: z.enum(["left", "center", "right"]).optional(),
alt: z.string().optional(),
replaceText: z
.string()
.optional()
.describe(
"Replace the first top-level block whose text contains this string with the image",
),
afterText: z
.string()
.optional()
.describe(
"Insert the image right after the first top-level block whose text contains this string",
),
},
},
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
const result = await docmostClient.insertImage(pageId, imageUrl, {
align,
@@ -448,9 +429,34 @@ registerShared(
);
// Tool: replace_image
// Schema + description now live in the shared registry (#410).
registerShared(
SHARED_TOOL_SPECS.replaceImage,
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
server.registerTool(
"replace_image",
{
description:
"Replace an existing image on a page with a new image fetched from a web " +
"(http/https) URL: uploads the new file as a NEW " +
"attachment (fresh clean URL that renders and busts browser caches), then " +
"repoints every image node referencing the old attachmentId (recursively, " +
"incl. callouts/tables) via the live document, preserving comments, " +
"alignment and alt. The old attachment is left as an unreferenced orphan " +
"(Docmost has no API to delete a single attachment; it is removed only when " +
"the page/space is deleted). In-place byte overwrite is avoided because some " +
"Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
inputSchema: {
pageId: z.string().min(1),
attachmentId: z
.string()
.min(1)
.describe("attachmentId of the image currently in the page to replace"),
imageUrl: z
.string()
.min(1)
.describe("http(s) URL of the new image to download"),
align: z.enum(["left", "center", "right"]).optional(),
alt: z.string().optional(),
},
},
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
const result = await docmostClient.replaceImage(
pageId,
@@ -465,38 +471,6 @@ registerShared(
},
);
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
registerShared(
SHARED_TOOL_SPECS.drawioGet,
async ({ pageId, node, format }) => {
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
return jsonContent(result);
},
);
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
registerShared(
SHARED_TOOL_SPECS.drawioCreate,
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
const result = await docmostClient.drawioCreate(
pageId,
{ position, anchorNodeId, anchorText },
xml,
title,
);
return jsonContent(result);
},
);
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
registerShared(
SHARED_TOOL_SPECS.drawioUpdate,
async ({ pageId, node, xml, baseHash }) => {
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
return jsonContent(result);
},
);
// Tool: share_page
// Schema + description now live in the shared registry (#294). The execute body
// keeps this transport's own `searchIndexing ?? true` default.
@@ -805,10 +779,36 @@ server.registerTool(
);
// Tool: insert_footnote
// Schema + description now live in the shared registry (#410) so the in-app
// AI-chat agent exposes it too. The execute body is unchanged.
registerShared(
SHARED_TOOL_SPECS.insertFootnote,
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
// footnote tool, so there is no second layer to unify — stays inline (#294).
server.registerTool(
"insert_footnote",
{
description:
"Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " +
"and WHAT (text). The footnote marker is placed right after anchorText in " +
"the body, and the bottom footnotes list + the numbering are derived " +
"deterministically server-side. You do NOT assign a number, and you " +
"never see or edit the footnotes list — so footnotes cannot end up out " +
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
"SAME text already exists, its number is REUSED (one definition, several " +
"references). The write is atomic and won't clobber concurrent edits; if " +
"anchorText is not found, nothing is written and an error is returned.",
inputSchema: {
pageId: z.string().min(1),
anchorText: z
.string()
.min(1)
.describe(
"A snippet of existing body text; the footnote marker is inserted " +
"immediately after its first occurrence (mark-safe).",
),
text: z
.string()
.min(1)
.describe("The footnote content as markdown (becomes the definition)."),
},
},
async ({ pageId, anchorText, text }) => {
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
return jsonContent(result);
-668
View File
@@ -1,668 +0,0 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
import {
buildCollabWsUrl,
applyDocToFragment,
MutationResult,
} from "./collaboration.js";
import { summarizeChange } from "./diff.js";
/**
* Live per-page collaboration session cache (issue #400).
*
* The one-shot write path (collaboration.mutatePageContent /
* client.mutateLiveContentUnlocked) used to open a NEW HocuspocusProvider, run
* the full connect -> auth -> onLoadDocument -> initial-sync handshake, apply a
* single edit, wait for persistence, and then `provider.destroy()` for EVERY
* content mutation. Disconnecting after every edit means that once the pause
* between calls exceeds the server's write debounce, the server does a full
* store -> unload -> reload per cell, causing 25s connect timeouts and
* event-loop lag under a burst of edits on one page.
*
* This module keeps ONE live provider + ydoc per (wsUrl, pageId, token) alive
* across a SERIES of edits. While the provider stays connected the server never
* enters store -> unload -> reload, its debounce coalesces N writes into 1-2
* stores, and the repeated auth/load/initial-sync disappears.
*
* The synchronous read -> transform -> write section and the per-edit
* persistence-ack logic are preserved VERBATIM from the one-shot machine the
* only change is that they run on a persistent provider instead of a throwaway
* one. See CollabSession.mutate.
*/
/** Time we wait for the initial handshake/sync before giving up. */
const CONNECT_TIMEOUT_MS = 25000;
/** Time we wait for the server to acknowledge our write before giving up. */
const PERSIST_TIMEOUT_MS = 20000;
/**
* Tunables, read fresh from the environment on every acquire so tests (and a
* live rollback) can change them without reloading the module. Mirrors how
* http.ts parses MCP_SESSION_IDLE_MS.
* - MCP_COLLAB_SESSION_IDLE_MS: idle TTL, reset after every op. Default 60s.
* 0 (or negative) DISABLES the cache every op opens its own provider and
* destroys it after the op, i.e. the exact legacy per-op-provider behavior
* (the rollback path).
* - MCP_COLLAB_SESSION_MAX_AGE_MS: hard lifetime checked at acquire; bounds
* the permission-staleness window. Default 10 min.
* - MCP_COLLAB_SESSION_MAX_ENTRIES: registry cap; the least-recently-used
* session is destroy-evicted when the cap is reached. Default 32.
*/
interface SessionConfig {
idleMs: number;
maxAgeMs: number;
maxEntries: number;
}
function parseEnvInt(value: string | undefined, fallback: number): number {
const parsed = parseInt(value ?? "", 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readConfig(): SessionConfig {
// idleMs: allow 0 (disable). A malformed value falls back to the default.
const idleRaw = parseInt(process.env.MCP_COLLAB_SESSION_IDLE_MS ?? "", 10);
const idleMs = Number.isFinite(idleRaw) ? Math.max(0, idleRaw) : 60 * 1000;
const maxAgeMs = Math.max(
0,
parseEnvInt(process.env.MCP_COLLAB_SESSION_MAX_AGE_MS, 10 * 60 * 1000),
);
const maxEntriesRaw = parseEnvInt(
process.env.MCP_COLLAB_SESSION_MAX_ENTRIES,
32,
);
const maxEntries = maxEntriesRaw > 0 ? maxEntriesRaw : 32;
return { idleMs, maxAgeMs, maxEntries };
}
/**
* The subset of HocuspocusProvider this module depends on, so the provider can
* be replaced with a fake in unit tests (there is no server in the test env).
*/
export interface CollabProviderLike {
synced: boolean;
unsyncedChanges: number;
destroy(): void;
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
}
/** The configuration object passed to the provider factory. */
export interface CollabProviderConfig {
url: string;
name: string;
document: Y.Doc;
token: string;
WebSocketPolyfill: unknown;
onConnect: () => void;
onSynced: () => void;
onDisconnect: () => void;
onClose: () => void;
onAuthenticationFailed: () => void;
}
export type CollabProviderFactory = (
config: CollabProviderConfig,
) => CollabProviderLike;
const defaultProviderFactory: CollabProviderFactory = (config) =>
// @ts-ignore - WebSocketPolyfill is required for the Node.js environment.
new HocuspocusProvider(config) as unknown as CollabProviderLike;
let providerFactory: CollabProviderFactory = defaultProviderFactory;
/**
* TEST SEAM: swap the provider factory (pass null to restore the real one).
* Not part of the public API used only by the unit tests, which cannot reach
* a real collaboration server.
*/
export function __setCollabProviderFactory(
factory: CollabProviderFactory | null,
): void {
providerFactory = factory ?? defaultProviderFactory;
}
/** Optional per-acquire hooks (metrics), passed through from the call site. */
export interface AcquireOptions {
/** Invoked when the initial connect handshake times out (CONNECT_TIMEOUT_MS). */
onConnectTimeout?: () => void;
}
type SessionState = "connecting" | "ready" | "dead";
/**
* One live provider + ydoc for a single (wsUrl, pageId, token) triple.
*
* Lifecycle: connecting -> ready -> dead. A session becomes `dead` on the first
* disconnect/close/auth-failure at ANY time, on an idle/eviction/max-age
* teardown, or on an explicit destroy(); death is terminal and removes the
* session from the registry so the next acquire opens a fresh one. We never use
* the provider's auto-reconnect destroying on the first disconnect closes the
* "reconnect drove unsyncedChanges to 0 without retransmitting our write" class
* of false success.
*/
export class CollabSession {
readonly key: string;
readonly pageId: string;
readonly wsUrl: string;
readonly token: string;
readonly createdAt: number;
state: SessionState = "connecting";
/**
* Set true on disconnect/close/auth-failure so a reconnect-driven
* unsyncedChanges->0 cannot be mistaken for a successful persist of our
* write (preserved verbatim from the one-shot machine).
*/
connectionLost = false;
provider: CollabProviderLike | undefined;
private readonly ydoc: Y.Doc;
private readonly cfg: SessionConfig;
/**
* Ephemeral sessions (cache disabled, MCP_COLLAB_SESSION_IDLE_MS<=0) are never
* registered and self-destroy after their single op the legacy
* provider-per-op behavior.
*/
private readonly ephemeral: boolean;
private readonly opts: AcquireOptions | undefined;
private dead = false;
private connectTimer: ReturnType<typeof setTimeout> | undefined;
private idleTimer: ReturnType<typeof setTimeout> | undefined;
private openPromise: Promise<void> | undefined;
private openResolve: (() => void) | undefined;
private openReject: ((err: Error) => void) | undefined;
private openSettled = false;
/**
* The rejector of the CURRENT in-flight mutate, if any. A disconnect/close/
* auth-failure or timeout at ANY time rejects the in-flight op through this
* with the SAME error text the one-shot machine emitted.
*/
private inflightReject: ((err: Error) => void) | undefined;
constructor(
key: string,
pageId: string,
wsUrl: string,
token: string,
cfg: SessionConfig,
ephemeral: boolean,
opts: AcquireOptions | undefined,
) {
this.key = key;
this.pageId = pageId;
this.wsUrl = wsUrl;
this.token = token;
this.cfg = cfg;
this.ephemeral = ephemeral;
this.opts = opts;
this.createdAt = Date.now();
this.ydoc = new Y.Doc();
}
/**
* A cached session may be reused only when it is fully ready, still synced,
* has not lost its connection, and has not exceeded its max age (invariant 5
* "validate on reuse" + the max-age acquire check).
*/
isReusable(): boolean {
return (
!this.dead &&
this.state === "ready" &&
!this.connectionLost &&
!!this.provider &&
this.provider.synced === true &&
Date.now() - this.createdAt < this.cfg.maxAgeMs
);
}
/**
* Connect and wait for the initial sync (onSynced) within CONNECT_TIMEOUT_MS.
* Idempotent: repeated calls return the same in-flight/settled promise.
*/
open(): Promise<void> {
if (this.openPromise) return this.openPromise;
this.openPromise = new Promise<void>((resolve, reject) => {
this.openResolve = resolve;
this.openReject = reject;
this.connectTimer = setTimeout(() => {
// The 25s connect timeout: the collab connection never became ready.
this.opts?.onConnectTimeout?.();
this.teardown(
new Error("Connection timeout to collaboration server"),
false,
);
}, CONNECT_TIMEOUT_MS);
if (process.env.DEBUG)
console.error(`Connecting to WebSocket: ${this.wsUrl}`);
this.provider = providerFactory({
url: this.wsUrl,
name: `page.${this.pageId}`,
document: this.ydoc,
token: this.token,
WebSocketPolyfill: WebSocket,
onConnect: () => {
if (process.env.DEBUG) console.error("WS Connect");
},
// An unexpected disconnect/close at ANY time (during the connect-wait,
// between edits, or during a persistence wait) makes the session dead:
// surface it now instead of hanging, reject any in-flight op with the
// same error text as the one-shot machine, and remove ourselves from
// the registry so the next acquire opens fresh. `teardown` is idempotent
// so the onClose our own destroy() triggers is a harmless no-op.
onDisconnect: () => {
if (process.env.DEBUG) console.error("WS Disconnect");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
true,
);
},
onClose: () => {
if (process.env.DEBUG) console.error("WS Close");
this.teardown(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
true,
);
},
onSynced: () => {
if (this.dead || this.openSettled) return;
if (process.env.DEBUG) console.error("Connected and synced!");
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = undefined;
}
this.state = "ready";
this.openSettled = true;
this.openResolve?.();
},
onAuthenticationFailed: () => {
this.teardown(
new Error("Authentication failed for collaboration connection"),
true,
);
},
});
});
return this.openPromise;
}
/**
* Run one atomic read -> transform -> write against the LIVE doc and wait for
* the server to acknowledge the write.
*
* INVARIANT 1 (read->write atomicity): between `TiptapTransformer.fromYdoc`
* and `applyDocToFragment` there is NO `await`. Yjs applies remote updates
* only when the event loop yields, so this synchronous block sees a consistent
* live doc and no concurrent human edit can interleave and be clobbered
* exactly as in the one-shot onSynced code, just on a persistent provider.
*
* INVARIANT 2 (per-edit ack): after the write, resolve immediately if
* unsyncedChanges is already 0, else wait for the unsyncedChanges->0 event
* (PERSIST_TIMEOUT_MS), guarded by connectionLost so a reconnect handshake
* cannot report a false success.
*
* CONCURRENCY: not safe to invoke concurrently on ONE session the caller
* MUST serialize (hold the per-page lock), mirroring acquireCollabSession.
* The in-flight op is tracked in a single `inflightReject` field, so an
* overlapping second call would clobber the first's rejector and leave it
* hanging on disconnect. A fail-fast guard below rejects the overlap instead.
* Sequential (awaited) mutates are fine: localFinish clears inflightReject
* before the promise settles, so the guard is clear by the time the next runs.
*/
mutate(
transform: (liveDoc: any) => any | null,
): Promise<MutationResult> {
// Belt-and-suspenders (acquire already validated): refuse to write on a
// session that is not in a live, synced, ready state.
if (
this.dead ||
this.state !== "ready" ||
this.connectionLost ||
!this.provider ||
this.provider.synced !== true
) {
return Promise.reject(
new Error("Collaboration session is not in a ready state"),
);
}
// Fail-fast on concurrent use: a second overlapping mutate would overwrite
// the first's inflightReject, so a disconnect would only reject the second
// and hang the first until PERSIST_TIMEOUT_MS. Reject the overlap WITHOUT
// touching the in-flight op's state (no localFinish/teardown here).
if (this.inflightReject) {
return Promise.reject(
new Error(
"mutate already in-flight; caller must serialize (hold the page lock)",
),
);
}
return new Promise<MutationResult>((resolve, reject) => {
let settled = false;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let unsyncedHandler:
| ((data: { number: number }) => void)
| undefined;
// The verifiable result resolved on every success/abort path. Set on
// abort (no-op report) and after a real write (computed change report).
let mutationResult: MutationResult;
const localFinish = (err: Error | null, value?: MutationResult) => {
if (settled) return;
settled = true;
if (persistTimer) clearTimeout(persistTimer);
if (unsyncedHandler && this.provider) {
try {
this.provider.off("unsyncedChanges", unsyncedHandler);
} catch (e) {}
}
this.inflightReject = undefined;
if (err) reject(err);
else resolve(value as MutationResult);
// Post-settle lifecycle: an ephemeral (cache-disabled) session dies with
// its single op; a cached session that is still alive re-arms its idle
// TTL so the clock starts from the LAST op.
if (this.ephemeral) {
this.destroy("ephemeral op complete");
} else if (!this.dead) {
this.armIdle();
}
};
// Register so a disconnect/close/auth-failure/teardown rejects THIS op
// with the connection-loss error text. localFinish's `settled` guard makes
// a racing teardown + normal resolve safe (first one wins).
this.inflightReject = (e: Error) => localFinish(e);
// Resolve once the server acknowledges our update: the provider increments
// unsyncedChanges when the local update is sent and decrements it on the
// server's SyncStatus(applied=true); reaching 0 means the authoritative
// in-memory ydoc on the server now contains our write.
const waitForPersistence = () => {
if (settled) return;
// A missing provider is a failure, not a success: without it the write
// can never have been acknowledged.
if (!this.provider) {
localFinish(new Error("collab provider gone before persistence"));
return;
}
if (this.provider.unsyncedChanges === 0) {
localFinish(null, mutationResult);
return;
}
persistTimer = setTimeout(() => {
localFinish(
new Error(
"Timeout waiting for collaboration server to persist the update",
),
);
}, PERSIST_TIMEOUT_MS);
unsyncedHandler = (data: { number: number }) => {
// Only treat unsyncedChanges->0 as success when the connection is
// still up. A transient disconnect + reconnect handshake can drive the
// counter back to 0 without our write being re-transmitted; in that
// case let the disconnect/close error win instead.
if (data.number === 0 && !this.connectionLost) {
localFinish(null, mutationResult);
}
};
this.provider.on("unsyncedChanges", unsyncedHandler);
};
// CRITICAL: everything between reading the live doc and writing it back
// must stay synchronous (no await). While the JS event loop is not
// yielded, no incoming remote update can interleave, so any already-synced
// concurrent edits are preserved in liveDoc.
let newDoc: any;
let beforeDoc: any;
try {
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
if (
!liveDoc ||
typeof liveDoc !== "object" ||
!Array.isArray(liveDoc.content)
) {
liveDoc = { type: "doc", content: [] };
}
// Snapshot the before-doc for the change report. Docs are
// JSON-serializable, so this is a safe deep clone.
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
newDoc = transform(liveDoc);
if (newDoc == null) {
// Transform aborted — write nothing, return the live doc with a no-op
// change report.
mutationResult = {
doc: liveDoc,
verify: {
changed: false,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "no changes (transform aborted)",
},
};
localFinish(null, mutationResult);
return;
}
// Structural diff into the live fragment (issue #152): preserves the Yjs
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
// end of the document on every agent write.
applyDocToFragment(this.ydoc, newDoc);
} catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller.
localFinish(e instanceof Error ? e : new Error(String(e)));
return;
}
// Compute the verifiable change report AFTER the transact write: it only
// needs the JSON before/after, so it cannot affect the atomic read->write
// window, and summarizeChange never throws.
mutationResult = {
doc: newDoc,
verify: summarizeChange(beforeDoc, newDoc),
};
if (process.env.DEBUG)
console.error("Content written, waiting for server to persist...");
waitForPersistence();
});
}
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
armIdle(): void {
if (this.dead || this.ephemeral) return;
if (this.idleTimer) clearTimeout(this.idleTimer);
if (this.cfg.idleMs > 0) {
this.idleTimer = setTimeout(() => {
this.destroy("idle timeout");
}, this.cfg.idleMs);
// Never let the idle timer keep the process alive.
(this.idleTimer as any).unref?.();
}
}
/**
* Idempotent teardown: mark dead, clear timers, remove from the registry, fail
* any pending open/in-flight op, and destroy the provider. `inflightError` is
* the error a pending open or in-flight op is rejected with; `connectionLoss`
* marks the session as connection-lost so the ack guard cannot report a false
* success on a racing unsyncedChanges->0.
*/
private teardown(inflightError: Error | null, connectionLoss: boolean): void {
if (this.dead) return;
this.dead = true;
this.state = "dead";
if (connectionLoss) this.connectionLost = true;
if (this.connectTimer) {
clearTimeout(this.connectTimer);
this.connectTimer = undefined;
}
if (this.idleTimer) {
clearTimeout(this.idleTimer);
this.idleTimer = undefined;
}
// Remove ourselves from the registry (only if we are still the live entry —
// a re-open under the same key must not be evicted by our teardown).
if (sessions.get(this.key) === this) {
sessions.delete(this.key);
}
// Fail a pending open() and any in-flight mutate with the terminal error.
if (!this.openSettled) {
this.openSettled = true;
this.openReject?.(
inflightError ?? new Error("Collaboration session destroyed"),
);
}
if (this.inflightReject) {
const rej = this.inflightReject;
this.inflightReject = undefined;
rej(inflightError ?? new Error("Collaboration session destroyed"));
}
if (this.provider) {
try {
this.provider.destroy();
} catch (e) {}
this.provider = undefined;
}
}
/**
* Public idempotent teardown used by the acquire/eviction paths and by a
* caller that wants the session dropped after a failed op ("next call
* reconnects fresh").
*/
destroy(reason: string): void {
if (this.dead) return;
if (process.env.DEBUG)
console.error(`Destroying collab session ${this.pageId}: ${reason}`);
this.teardown(new Error(`Collaboration session destroyed: ${reason}`), false);
}
}
/** key = wsUrl + pageId + collabToken (identity isolation: invariant 4). */
const sessions = new Map<string, CollabSession>();
function sessionKey(wsUrl: string, pageId: string, token: string): string {
// The token is part of the key so sessions are NEVER shared between different
// users' MCP sessions (HTTP mode), and a token rotation makes a new entry
// while the old one idles out.
return `${wsUrl}${pageId}${token}`;
}
/**
* Get a live, synced CollabSession for a page, reusing a cached one when it is
* still valid or opening a fresh one otherwise. Does NOT take the per-page lock
* the caller MUST already hold it (both call sites run inside withPageLock,
* which is not reentrant, so acquiring the lock here would deadlock
* mutateLiveContentUnlocked).
*/
export async function acquireCollabSession(
pageId: string,
collabToken: string,
baseUrl: string,
opts?: AcquireOptions,
): Promise<CollabSession> {
const cfg = readConfig();
const wsUrl = buildCollabWsUrl(baseUrl);
// Cache disabled (rollback path): open an unregistered ephemeral session that
// self-destroys after its single op — the exact legacy per-op-provider flow.
if (cfg.idleMs <= 0) {
const session = new CollabSession(
sessionKey(wsUrl, pageId, collabToken),
pageId,
wsUrl,
collabToken,
cfg,
true,
opts,
);
await session.open();
return session;
}
const key = sessionKey(wsUrl, pageId, collabToken);
const existing = sessions.get(key);
if (existing) {
if (existing.isReusable()) {
// Reuse. Refresh LRU order (re-insert = most recently used) and re-arm the
// idle TTL so the reuse counts as activity.
sessions.delete(key);
sessions.set(key, existing);
existing.armIdle();
if (process.env.DEBUG)
console.error(`Reusing collab session for page ${pageId}`);
return existing;
}
// Stale (not synced / past max age / lost): drop it and open fresh.
existing.destroy("stale on reuse");
}
// Enforce the registry cap before inserting: destroy-evict the least recently
// used (the first entry in insertion order) until there is room.
while (sessions.size >= cfg.maxEntries) {
const oldestKey: string | undefined = sessions.keys().next().value;
if (oldestKey === undefined) break;
const victim = sessions.get(oldestKey);
if (victim) victim.destroy("evicted (LRU cap)");
// destroy() removes it from the map; guard against a no-op destroy.
if (sessions.has(oldestKey)) sessions.delete(oldestKey);
}
const session = new CollabSession(
key,
pageId,
wsUrl,
collabToken,
cfg,
false,
opts,
);
sessions.set(key, session);
try {
await session.open();
} catch (e) {
// Failed connect/sync: make sure it is not left cached.
session.destroy("open failed");
throw e;
}
session.armIdle();
if (process.env.DEBUG)
console.error(`Opened new collab session for page ${pageId}`);
return session;
}
/**
* Destroy every cached session. Wired into the process shutdown so a hanging
* session does not keep a doc loaded on the server past exit.
*/
export function destroyAllSessions(): void {
for (const session of [...sessions.values()]) {
session.destroy("process shutdown");
}
sessions.clear();
}
/** TEST-ONLY: number of currently cached sessions. */
export function __sessionCountForTests(): number {
return sessions.size;
}
+210 -24
View File
@@ -1,3 +1,4 @@
import { HocuspocusProvider } from "@hocuspocus/provider";
import { TiptapTransformer } from "@hocuspocus/transformer";
import * as Y from "yjs";
import WebSocket from "ws";
@@ -15,8 +16,7 @@ import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
import { withPageLock } from "./page-lock.js";
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
import { VerifyReport } from "./diff.js";
import { acquireCollabSession } from "./collab-session.js";
import { summarizeChange, VerifyReport } from "./diff.js";
export { markdownToProseMirror };
@@ -194,27 +194,26 @@ export function assertYjsEncodable(doc: any): void {
}
}
/** Time we wait for the initial handshake/sync before giving up. */
const CONNECT_TIMEOUT_MS = 25000;
/** Time we wait for the server to acknowledge our write before giving up. */
const PERSIST_TIMEOUT_MS = 20000;
/**
* Safely mutate the live content of a page over the collaboration websocket.
*
* This is the single safe write path for every MCP content mutation. It:
* 1. serializes per-page writes through withPageLock (no two MCP writes on
* the same page overlap);
* 2. acquires a LIVE, synced CollabSession for the page (issue #400) a
* cached provider whose local ydoc mirrors the authoritative server doc
* (INCLUDING edits/comments/images not yet in the debounced REST snapshot),
* reused across a series of edits instead of a fresh connect/auth/sync per
* call;
* 3. SYNCHRONOUSLY reads the live doc, runs `transform`, and writes the result
* back with no `await` between read and write so no remote update can
* interleave and clobber concurrent human edits (CollabSession.mutate);
* 2. connects to Hocuspocus and waits for the initial sync so the local ydoc
* mirrors the authoritative server doc INCLUDING edits/comments/images
* that are not yet in the debounced REST snapshot;
* 3. inside onSynced, SYNCHRONOUSLY reads the live doc, runs `transform`, and
* writes the result back with no `await` between read and write so no
* remote update can interleave and clobber concurrent human edits;
* 4. waits for the server to acknowledge the write (unsyncedChanges -> 0)
* before resolving, so the next operation observes our change.
*
* On any mutate failure the session is destroyed so the next call reconnects
* fresh; the page lock is held for the whole acquire+mutate so the session's
* synchronous read->write window never overlaps another MCP write on the page.
*
* `transform` receives the live ProseMirror doc and returns the NEW full
* ProseMirror doc to write, or `null` to abort with no write (a no-op). If
* `transform` throws, the error is propagated to the caller (not swallowed).
@@ -231,7 +230,7 @@ export async function mutatePageContent(
baseUrl: string,
transform: (liveDoc: any) => any | null,
): Promise<MutationResult> {
return withPageLock(pageId, async () => {
return withPageLock(pageId, () => {
if (process.env.DEBUG) {
console.error(`Starting realtime content mutate for page ${pageId}`);
// Token prefix is sensitive; only log it under DEBUG.
@@ -240,15 +239,202 @@ export async function mutatePageContent(
);
}
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
try {
return await session.mutate(transform);
} catch (e) {
// Drop the session on any failure so the next call reconnects fresh (this
// also closes the "reconnect drove the counter to 0" false-success class).
session.destroy("mutate failed");
throw e;
}
const ydoc = new Y.Doc();
const wsUrl = buildCollabWsUrl(baseUrl);
if (process.env.DEBUG) console.error(`Connecting to WebSocket: ${wsUrl}`);
return new Promise<MutationResult>((resolve, reject) => {
let provider: HocuspocusProvider | undefined;
let applied = false; // onSynced may fire again on reconnect — apply once.
let settled = false;
// Set true on disconnect/close so a reconnect-driven unsyncedChanges->0
// cannot be mistaken for a successful persist of our write.
let connectionLost = false;
let connectTimer: ReturnType<typeof setTimeout> | undefined;
let persistTimer: ReturnType<typeof setTimeout> | undefined;
let unsyncedHandler: ((data: { number: number }) => void) | undefined;
const cleanup = () => {
if (connectTimer) clearTimeout(connectTimer);
if (persistTimer) clearTimeout(persistTimer);
if (provider) {
if (unsyncedHandler) {
try {
provider.off("unsyncedChanges", unsyncedHandler);
} catch (err) {}
}
try {
provider.destroy();
} catch (err) {}
}
};
const finish = (err: Error | null, value?: MutationResult) => {
if (settled) return;
settled = true;
cleanup();
if (err) reject(err);
else resolve(value as MutationResult);
};
connectTimer = setTimeout(() => {
finish(new Error("Connection timeout to collaboration server"));
}, CONNECT_TIMEOUT_MS);
// Resolve once the server has acknowledged our update. The provider
// increments unsyncedChanges when our local update is sent and
// decrements it when the server replies with a SyncStatus(applied=true);
// reaching 0 means the authoritative in-memory ydoc on the server now
// contains our write.
const waitForPersistence = () => {
if (settled) return;
// A missing provider is a failure, not a success: without it the write
// can never have been acknowledged. Only an actual unsyncedChanges===0
// on a live provider counts as persisted.
if (!provider) {
finish(new Error("collab provider gone before persistence"));
return;
}
if (provider.unsyncedChanges === 0) {
finish(null, mutationResult);
return;
}
persistTimer = setTimeout(() => {
finish(
new Error(
"Timeout waiting for collaboration server to persist the update",
),
);
}, PERSIST_TIMEOUT_MS);
unsyncedHandler = (data: { number: number }) => {
// Only treat unsyncedChanges->0 as success when the connection is
// still up. A transient disconnect + reconnect handshake can drive
// the counter back to 0 without our write being re-transmitted; in
// that case let the disconnect/close error win instead.
if (data.number === 0 && !connectionLost) {
finish(null, mutationResult);
}
};
provider.on("unsyncedChanges", unsyncedHandler);
};
// The verifiable result resolved on every success/abort path. Set on
// abort (no-op report) and after a real write (computed change report).
let mutationResult: MutationResult;
provider = new HocuspocusProvider({
url: wsUrl,
name: `page.${pageId}`,
document: ydoc,
token: collabToken,
// @ts-ignore - Required for Node.js environment
WebSocketPolyfill: WebSocket,
onConnect: () => {
if (process.env.DEBUG) console.error("WS Connect");
},
// An unexpected disconnect/close while we are still waiting (during the
// connect-wait before onSynced, or during the persistence wait after the
// write) means the update will never be acknowledged — surface it now
// instead of hanging until the connect/persist timeout fires. `finish`
// is idempotent via the `settled` flag, so the onClose that our own
// cleanup()->provider.destroy() triggers (after settled=true is set) is
// a harmless no-op and cannot cause a double-resolve.
onDisconnect: () => {
if (process.env.DEBUG) console.error("WS Disconnect");
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
// sees the connection as lost and won't report a false success.
connectionLost = true;
finish(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
);
},
onClose: () => {
if (process.env.DEBUG) console.error("WS Close");
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
// sees the connection as lost and won't report a false success.
connectionLost = true;
finish(
new Error(
"Collaboration connection closed before the update was persisted/synced",
),
);
},
onSynced: () => {
if (applied || settled) return;
applied = true;
if (process.env.DEBUG) console.error("Connected and synced!");
// CRITICAL: everything between reading the live doc and writing it
// back must stay synchronous (no await). While the JS event loop is
// not yielded, no incoming remote update can interleave, so any
// already-synced concurrent edits are preserved in liveDoc.
let newDoc: any;
let beforeDoc: any;
try {
let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default");
if (
!liveDoc ||
typeof liveDoc !== "object" ||
!Array.isArray(liveDoc.content)
) {
liveDoc = { type: "doc", content: [] };
}
// Snapshot the before-doc for the change report. Docs are
// JSON-serializable, so this is a safe deep clone.
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
newDoc = transform(liveDoc);
if (newDoc == null) {
// Transform aborted — write nothing, return the live doc with a
// no-op change report.
mutationResult = {
doc: liveDoc,
verify: {
changed: false,
textInserted: 0,
textDeleted: 0,
blocksChanged: 0,
marks: {},
summary: "no changes (transform aborted)",
},
};
finish(null, mutationResult);
return;
}
// Structural diff into the live fragment (issue #152): preserves
// the Yjs ids of unchanged nodes, so an open editor's cursor is not
// yanked to the end of the document on every agent write.
applyDocToFragment(ydoc, newDoc);
} catch (e) {
// Includes errors thrown by transform (e.g. "afterText not found",
// "text not found"): propagate them verbatim to the caller.
finish(e instanceof Error ? e : new Error(String(e)));
return;
}
// Compute the verifiable change report AFTER the transact write: it
// only needs the JSON before/after, so it cannot affect the atomic
// read->write window, and summarizeChange never throws.
mutationResult = {
doc: newDoc,
verify: summarizeChange(beforeDoc, newDoc),
};
if (process.env.DEBUG)
console.error("Content written, waiting for server to persist...");
waitForPersistence();
},
onAuthenticationFailed: () => {
finish(
new Error("Authentication failed for collaboration connection"),
);
},
});
});
});
}
+10 -84
View File
@@ -17,23 +17,8 @@
* comparing and match across maximal runs of consecutive text nodes within a
* single block, while mapping every normalized character back to its raw index
* so the mark lands on the exact original characters.
*
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
* match the document's plain text. Exactly like edit_page_text's json-edit
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
* `resolveAnchorSelection`. `countAnchorMatches` keeps its OWN parallel exact-wins
* implementation (it needs a raw match COUNT, not a single resolved locator), kept
* deliberately in sync with `resolveAnchorSelection`: raw match use raw, else fall
* back to the stripped count. All four therefore agree on which locator matched
* the suggestion-uniqueness gate depends on count and can/get never disagreeing, so
* these two exact-wins implementations MUST stay in sync if either is changed.
*/
import { stripInlineMarkdown } from "./text-normalize.js";
/** Typographic double-quote variants mapped to ASCII `"`. */
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
@@ -229,17 +214,15 @@ function reconstructRawText(blockContent: any[], match: AnchorMatch): string {
* un-appliable (spurious 409).
*/
export function getAnchoredText(doc: any, selection: string): string | null {
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
if (!found) return null;
const visit = (node: any, depth: number): string | null => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return null;
if (!Array.isArray(node.content)) return null;
const match = findAnchorInBlock(node.content, effective);
const match = findAnchorInBlock(node.content, selection);
if (match) return reconstructRawText(node.content, match);
for (const child of node.content) {
if (child && typeof child === "object" && Array.isArray(child.content)) {
const foundText = visit(child, depth + 1);
if (foundText !== null) return foundText;
const found = visit(child, depth + 1);
if (found !== null) return found;
}
}
return null;
@@ -248,11 +231,12 @@ export function getAnchoredText(doc: any, selection: string): string | null {
}
/**
* RAW (no markdown-strip fallback) depth-first check that `selection` anchors
* somewhere in `doc`. This is the primitive `resolveAnchorSelection` builds on;
* public callers should use `canAnchorInDoc`, which adds the strip fallback.
* Depth-first, document-order check for whether `selection` can be anchored
* anywhere in `doc`. At each node with an array `content`, first try to match
* within that node's own content, then recurse into children that themselves
* have a `content` array.
*/
function rawCanAnchorInDoc(doc: any, selection: string): boolean {
export function canAnchorInDoc(doc: any, selection: string): boolean {
const visit = (node: any, depth: number): boolean => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
if (!Array.isArray(node.content)) return false;
@@ -267,43 +251,6 @@ function rawCanAnchorInDoc(doc: any, selection: string): boolean {
return visit(doc, 0);
}
/**
* Decide the locator that ACTUALLY anchors `selection` in `doc`, applying the
* markdown-strip fallback once (so every public entry point agrees):
* - EXACT WINS: if the verbatim selection anchors anywhere, use it as-is.
* - FALLBACK: only if the verbatim selection anchors nowhere, and the
* markdown-stripped form differs and DOES anchor, use the stripped form and
* flag `normalized` so callers can surface a soft warning.
* - otherwise `found` is false and `selection` is returned unchanged.
*
* The stripped form is used ONLY to LOCATE the anchor; getAnchoredText still
* reconstructs and stores the RAW document substring, so the strip never leaks
* into what gets persisted.
*/
export function resolveAnchorSelection(
doc: any,
selection: string,
): { selection: string; found: boolean; normalized: boolean } {
if (rawCanAnchorInDoc(doc, selection)) {
return { selection, found: true, normalized: false };
}
const stripped = stripInlineMarkdown(selection);
if (stripped !== selection && rawCanAnchorInDoc(doc, stripped)) {
return { selection: stripped, found: true, normalized: true };
}
return { selection, found: false, normalized: false };
}
/**
* Depth-first, document-order check for whether `selection` can be anchored
* anywhere in `doc` (with the markdown-strip fallback). At each node with an
* array `content`, first try to match within that node's own content, then
* recurse into children that themselves have a `content` array.
*/
export function canAnchorInDoc(doc: any, selection: string): boolean {
return resolveAnchorSelection(doc, selection).found;
}
/**
* Split the matched text nodes and splice the comment mark across the range.
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
@@ -368,7 +315,7 @@ function spliceCommentMark(
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
* occurrences inside one block are correctly reported as 2.)
*/
function rawCountAnchorMatches(doc: any, selection: string): number {
export function countAnchorMatches(doc: any, selection: string): number {
const normSel = normalizeForMatch(selection).norm.trim();
if (normSel.length === 0) return 0;
@@ -422,25 +369,6 @@ function rawCountAnchorMatches(doc: any, selection: string): number {
return total;
}
/**
* Uniqueness gate for suggestions, with the SAME markdown-strip fallback as the
* other entry points so count never disagrees with can/get/apply. EXACT WINS: if
* the verbatim selection occurs at all, return its raw occurrence count (so a
* selection that is unique raw stays unique the fallback never runs and cannot
* introduce a spurious second match). Only when the verbatim selection is absent
* do we count occurrences of the markdown-stripped form.
*/
export function countAnchorMatches(doc: any, selection: string): number {
const raw = rawCountAnchorMatches(doc, selection);
if (raw > 0) return raw;
const stripped = stripInlineMarkdown(selection);
if (stripped !== selection) {
const strippedCount = rawCountAnchorMatches(doc, stripped);
if (strippedCount > 0) return strippedCount;
}
return 0;
}
/**
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
* whose content matches `selection`, splice the comment mark across the matched
@@ -452,12 +380,10 @@ export function applyAnchorInDoc(
selection: string,
commentId: string,
): boolean {
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
if (!found) return false;
const visit = (node: any, depth: number): boolean => {
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
if (!Array.isArray(node.content)) return false;
const match = findAnchorInBlock(node.content, effective);
const match = findAnchorInBlock(node.content, selection);
if (match) {
spliceCommentMark(node.content, match, commentId);
return true;
-193
View File
@@ -1,193 +0,0 @@
// Pure-TS schematic SVG preview for draw.io diagrams (issue #423, stage 1).
//
// HARD CONSTRAINT: no backend rendering. This is a dependency-free string
// builder — given the parsed mxGraph cells it draws a rough schematic (rects,
// ellipses, diamonds, edges + labels) that stands in as the diagram's visible
// image UNTIL a human first opens it in the draw.io editor and saves, at which
// point the client replaces this with the pixel-perfect export SVG. It is
// deliberately approximate: it exists so a freshly-agent-created diagram is not
// an empty box in the page.
import type { DrawioCell, DrawioBBox } from "./drawio-xml.js";
import { absolutePos } from "./drawio-xml.js";
function esc(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
/**
* Strip HTML markup from a cell value (draw.io labels are HTML when html=1),
* decode the handful of entities we care about, and collapse whitespace so the
* label fits on the schematic. `<br>` becomes a space (this is a one-line
* preview label, not a faithful multi-line render).
*/
function labelText(value: string): string {
return value
.replace(/<br\s*\/?>/gi, " ")
.replace(/<[^>]+>/g, "")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&#xa;|&#10;/gi, " ")
.replace(/&amp;/g, "&")
.replace(/\s+/g, " ")
.trim();
}
function centeredLabel(cx: number, cy: number, value: string, color = "#000000"): string {
const text = labelText(value);
if (!text) return "";
return (
`<text x="${round(cx)}" y="${round(cy)}" ` +
`font-family="Helvetica, Arial, sans-serif" font-size="12" ` +
`text-anchor="middle" dominant-baseline="middle" fill="${esc(color)}">` +
`${esc(text)}</text>`
);
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
interface ShapeKind {
kind: "ellipse" | "rhombus" | "triangle" | "rect";
}
/**
* Decide which schematic primitive to draw for a vertex. A shape can be named
* either as the style's base token (e.g. "ellipse;…") or as a key (e.g.
* "shape=rhombus" / "ellipse=1"), so both the base style and the map are
* checked.
*/
function shapeKind(
styleMap: Record<string, string>,
baseStyle?: string,
): ShapeKind {
const shape = styleMap.shape ?? baseStyle;
const has = (name: string) => shape === name || styleMap[name] != null;
if (has("ellipse")) return { kind: "ellipse" };
if (has("rhombus")) return { kind: "rhombus" };
if (has("triangle")) return { kind: "triangle" };
// Everything else — including unknown stencils (shape=mxgraph.*), swimlanes,
// and plain boxes — is drawn as a (rounded) rectangle.
return { kind: "rect" };
}
function fill(styleMap: Record<string, string>): string {
const c = styleMap.fillColor;
if (!c || c.toLowerCase() === "none") return "#ffffff";
return c;
}
function stroke(styleMap: Record<string, string>): string {
const c = styleMap.strokeColor;
if (!c || c.toLowerCase() === "none") return "#000000";
return c;
}
/**
* Render the schematic shapes as the INNER content of the `.drawio.svg` (the
* outer <svg> wrapper is added by drawio-xml.buildDrawioSvg). Coordinates are
* absolute (container children are resolved via the parent chain).
*/
export function renderDiagramShapes(cells: DrawioCell[], _bbox: DrawioBBox): string {
const byId = new Map(cells.map((c) => [c.id, c]));
const parts: string[] = [];
// Edges first so vertices sit on top of their connectors.
for (const c of cells) {
if (!c.edge) continue;
parts.push(renderEdge(c, byId));
}
for (const c of cells) {
if (!c.vertex || !c.geometry.hasGeometry) continue;
const g = c.geometry;
if (g.width == null || g.height == null) continue;
const { x, y } = absolutePos(c, byId);
parts.push(renderVertex(c, x, y, g.width, g.height));
}
return `<g>${parts.filter(Boolean).join("")}</g>`;
}
function renderVertex(
c: DrawioCell,
x: number,
y: number,
w: number,
h: number,
): string {
const f = esc(fill(c.styleMap));
const s = esc(stroke(c.styleMap));
const { kind } = shapeKind(c.styleMap, c.baseStyle);
const cx = x + w / 2;
const cy = y + h / 2;
let shape = "";
switch (kind) {
case "ellipse":
shape =
`<ellipse cx="${round(cx)}" cy="${round(cy)}" rx="${round(w / 2)}" ` +
`ry="${round(h / 2)}" fill="${f}" stroke="${s}"/>`;
break;
case "rhombus": {
const pts = [
`${round(cx)},${round(y)}`,
`${round(x + w)},${round(cy)}`,
`${round(cx)},${round(y + h)}`,
`${round(x)},${round(cy)}`,
].join(" ");
shape = `<polygon points="${pts}" fill="${f}" stroke="${s}"/>`;
break;
}
case "triangle": {
const pts = [
`${round(x)},${round(y)}`,
`${round(x + w)},${round(cy)}`,
`${round(x)},${round(y + h)}`,
].join(" ");
shape = `<polygon points="${pts}" fill="${f}" stroke="${s}"/>`;
break;
}
default: {
const rounded = c.styleMap.rounded === "1";
const rx = rounded ? Math.min(12, w / 2, h / 2) : 0;
shape =
`<rect x="${round(x)}" y="${round(y)}" width="${round(w)}" ` +
`height="${round(h)}" rx="${round(rx)}" ry="${round(rx)}" ` +
`fill="${f}" stroke="${s}"/>`;
}
}
return shape + centeredLabel(cx, cy, c.value, c.styleMap.fontColor || "#000000");
}
function renderEdge(c: DrawioCell, byId: Map<string, DrawioCell>): string {
const src = c.source != null ? byId.get(c.source) : undefined;
const tgt = c.target != null ? byId.get(c.target) : undefined;
const p1 = anchorPoint(src, byId);
const p2 = anchorPoint(tgt, byId);
if (!p1 || !p2) return ""; // a floating endpoint with no fixed point: skip
const line =
`<line x1="${round(p1.x)}" y1="${round(p1.y)}" ` +
`x2="${round(p2.x)}" y2="${round(p2.y)}" ` +
`stroke="#000000" stroke-width="1"/>`;
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
return line + centeredLabel(mid.x, mid.y, c.value);
}
/** Center point of a vertex used as an edge anchor (approximate). */
function anchorPoint(
cell: DrawioCell | undefined,
byId: Map<string, DrawioCell>,
): { x: number; y: number } | null {
if (!cell || !cell.geometry.hasGeometry) return null;
const g = cell.geometry;
if (g.width == null || g.height == null) return null;
const { x, y } = absolutePos(cell, byId);
return { x: x + g.width / 2, y: y + g.height / 2 };
}
-771
View File
@@ -1,771 +0,0 @@
// draw.io (mxGraph) XML support for the MCP drawio tools (issue #423, stage 1).
//
// This module owns everything that is pure data-plumbing for draw.io diagrams:
// - the DECODE CHAIN that turns a stored `diagram.drawio.svg` attachment back
// into mxGraph XML (handles both the plain nested-XML form Docmost writes
// and draw.io's own COMPRESSED `<diagram>` payload — base64 + raw-deflate);
// - the ENCODE side that wraps mxGraph XML into the `.drawio.svg` attachment
// using the exact same contract as the import service's createDrawioSvg;
// - a deterministic LINTER that rejects the structural mistakes generators
// make before anything is written (each violation carries the offending
// cellId + position so the model can auto-retry);
// - a stable HASH over the normalized XML, used as the optimistic-lock key.
//
// HARD CONSTRAINT: no backend rendering. Nothing here shells out or renders a
// bitmap; the only runtime dependencies are jsdom (already used across this
// package for XML parsing) and pako (raw-inflate for the compressed format).
import { createHash } from "node:crypto";
import { JSDOM } from "jsdom";
import pako from "pako";
// --- shared XML parser -----------------------------------------------------
// A single reusable JSDOM window; constructing one per parse is wasteful and
// these tools are low-frequency. Only the DOMParser is used.
let _window: any = null;
function xmlWindow(): any {
if (!_window) _window = new JSDOM("").window;
return _window;
}
/** Default mxGraphModel attributes used when the server wraps a cell list. */
const DEFAULT_MODEL_ATTRS =
'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100"';
// --- structured lint errors ------------------------------------------------
export interface DrawioLintIssue {
/** Machine-readable rule id, e.g. "edge-geometry". */
rule: string;
/** Human-readable explanation the model can act on. */
message: string;
/** The offending cell's id, when the rule is cell-scoped. */
cellId?: string;
/** Extra location info: cell index in <root>, or a parser line:col. */
position?: string;
}
/**
* Thrown by the linter and by decode/prepare when the input is unusable. Carries
* the full list of issues so the caller can surface a structured tool-error the
* model auto-retries against.
*/
export class DrawioLintError extends Error {
issues: DrawioLintIssue[];
constructor(issues: DrawioLintIssue[]) {
const summary = issues
.map((i) => {
const where = [
i.cellId != null ? `cellId=${i.cellId}` : null,
i.position != null ? `at ${i.position}` : null,
]
.filter(Boolean)
.join(", ");
return `[${i.rule}] ${i.message}${where ? ` (${where})` : ""}`;
})
.join("; ");
super(`drawio lint failed: ${summary}`);
this.name = "DrawioLintError";
this.issues = issues;
}
}
// --- parsed-cell model -----------------------------------------------------
export interface DrawioGeometry {
x?: number;
y?: number;
width?: number;
height?: number;
relative: boolean;
hasGeometry: boolean;
}
export interface DrawioCell {
id: string;
parent?: string;
source?: string;
target?: string;
vertex: boolean;
edge: boolean;
value: string;
style: string;
styleMap: Record<string, string>;
/** Non-key/value leading token of the style (a base stylename), if any. */
baseStyle?: string;
geometry: DrawioGeometry;
}
export interface DrawioBBox {
width: number;
height: number;
}
// --- style parsing ---------------------------------------------------------
/**
* Parse a draw.io style string into { baseStyle, map }. Grammar:
* [stylename;]key=value;key=value;...
* A single leading token without '=' is the base stylename (e.g. "text" or
* "ellipse"). Every other non-empty segment must be exactly one key=value pair.
* Returns `null` (the segment index) on the first malformed segment so the
* linter can report a precise error.
*/
export function parseStyle(
style: string,
): { baseStyle?: string; map: Record<string, string>; badSegment?: string } {
const map: Record<string, string> = {};
let baseStyle: string | undefined;
const segments = style.split(";");
for (let i = 0; i < segments.length; i++) {
const seg = segments[i].trim();
if (seg === "") continue; // trailing/empty segments are fine
const eq = seg.indexOf("=");
if (eq === -1) {
// A bare token is only valid as the FIRST meaningful segment (base style).
if (baseStyle === undefined && Object.keys(map).length === 0) {
baseStyle = seg;
continue;
}
return { baseStyle, map, badSegment: seg };
}
// A second '=' inside the same segment is malformed.
if (seg.indexOf("=", eq + 1) !== -1) {
return { baseStyle, map, badSegment: seg };
}
const key = seg.slice(0, eq).trim();
const val = seg.slice(eq + 1).trim();
if (key === "") return { baseStyle, map, badSegment: seg };
map[key] = val;
}
return { baseStyle, map };
}
// --- low-level XML helpers -------------------------------------------------
function parseXml(xml: string): { doc: any; error: string | null } {
const parser = new (xmlWindow().DOMParser)();
const doc = parser.parseFromString(xml, "application/xml");
const err = doc.getElementsByTagName("parsererror");
if (err.length > 0) {
// jsdom prefixes the message with "line:col:" — keep it as the position.
return { doc, error: (err[0].textContent || "malformed XML").trim() };
}
return { doc, error: null };
}
function num(v: string | null): number | undefined {
if (v == null || v === "") return undefined;
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
/** Extract the raw `<mxGraphModel …>…</mxGraphModel>` substring, or null. */
function sliceModel(xml: string): string | null {
const open = xml.indexOf("<mxGraphModel");
if (open === -1) return null;
const close = xml.indexOf("</mxGraphModel>", open);
if (close === -1) {
// Self-closed empty model, e.g. `<mxGraphModel .../>`.
const selfClose = xml.indexOf("/>", open);
if (selfClose !== -1) return xml.slice(open, selfClose + 2);
return null;
}
return xml.slice(open, close + "</mxGraphModel>".length);
}
// --- decode chain ----------------------------------------------------------
/**
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a
* base64 payload there (createDrawioSvg); draw.io's own SVG export may store the
* XML entity-encoded instead. The DOM decodes entities for us, so the caller
* only has to distinguish "starts with '<'" (raw XML) from base64.
*/
export function extractContentAttr(svg: string): string {
const { doc, error } = parseXml(svg);
if (!error) {
const root = doc.documentElement;
if (root && root.hasAttribute && root.hasAttribute("content")) {
return root.getAttribute("content") || "";
}
}
// Fallback for a malformed wrapper: pull the attribute directly. The content
// value itself never contains a double-quote (base64 / entity-encoded XML).
const m = /content="([^"]*)"/.exec(svg);
if (m) {
// Decode the handful of XML entities a raw regex would leave encoded.
return m[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&");
}
throw new Error("drawio: SVG has no content= attribute to decode");
}
/**
* Turn a decoded draw.io file (`<mxfile>` or a bare `<mxGraphModel>`, possibly
* with a COMPRESSED `<diagram>` payload) into the mxGraphModel XML. For the
* plain form the raw substring is returned verbatim so a round-trip stays
* byte-stable; the compressed form is inflated (base64 raw-deflate
* decodeURIComponent), which is how draw.io stores diagrams by default.
*/
export function decodeDrawioFileToModel(fileXml: string): string {
// Plain, nested XML: return the model substring untouched (byte-stable).
const sliced = sliceModel(fileXml);
if (sliced) return sliced;
// Otherwise it must be the compressed `<diagram>…</diagram>` text payload.
const open = fileXml.indexOf("<diagram");
if (open !== -1) {
const gt = fileXml.indexOf(">", open);
const close = fileXml.indexOf("</diagram>", gt);
if (gt !== -1 && close !== -1) {
const payload = fileXml.slice(gt + 1, close).trim();
if (payload) {
const inflated = inflateDiagramPayload(payload);
const model = sliceModel(inflated);
if (model) return model;
return inflated;
}
}
}
throw new Error(
"drawio: could not decode file — no <mxGraphModel> and no compressed <diagram> payload",
);
}
/**
* Upper bound on the inflated size of a compressed `<diagram>` payload
* (decompression-bomb guard). `fetchInternalFile` caps the DOWNLOAD at 64 MiB,
* but a tiny crafted compressed payload can inflate to gigabytes and OOM the
* process. A real diagram's mxGraphModel XML is small (KBs to low MBs even for
* large diagrams), so 16 MiB is far above any legitimate payload while keeping
* memory bounded. Chars ~= bytes for the (mostly ASCII) URI-encoded XML.
*/
export const MAX_INFLATED_DIAGRAM_BYTES = 16 * 1024 * 1024;
/**
* Inflate draw.io's compressed diagram payload:
* base64-decode raw-inflate (raw deflate, windowBits -15)
* decodeURIComponent.
*
* Uses pako's streaming Inflate so we can abort as soon as the decompressed
* output exceeds MAX_INFLATED_DIAGRAM_BYTES the full bomb is never
* materialised in memory.
*/
export function inflateDiagramPayload(base64: string): string {
const bytes = Buffer.from(base64, "base64");
const inflator = new pako.Inflate({ raw: true, to: "string" });
let total = 0;
const passthrough = inflator.onData.bind(inflator);
inflator.onData = (chunk: string | Uint8Array) => {
total += chunk.length;
if (total > MAX_INFLATED_DIAGRAM_BYTES) {
// Throwing here propagates out of push(), aborting inflation immediately.
throw new Error(
`drawio: refusing to decode diagram — decompressed size exceeds ` +
`${MAX_INFLATED_DIAGRAM_BYTES} bytes (possible decompression bomb)`,
);
}
passthrough(chunk);
};
inflator.push(bytes, true);
if (inflator.err) {
throw new Error(
`drawio: failed to inflate compressed <diagram> payload (${inflator.msg || inflator.err})`,
);
}
const uriEncoded = inflator.result as string;
return decodeURIComponent(uriEncoded);
}
/** Full decode chain: `.drawio.svg` string → mxGraphModel XML. */
export function decodeDrawioSvg(svg: string): string {
const content = extractContentAttr(svg).trim();
const fileXml = content.startsWith("<")
? content
: Buffer.from(content, "base64").toString("utf-8");
return decodeDrawioFileToModel(fileXml);
}
// --- encode side -----------------------------------------------------------
/**
* Wrap an mxGraphModel in the plain (uncompressed) `<mxfile><diagram>` envelope.
* draw.io opens uncompressed XML fine, and staying uncompressed keeps the
* write path deterministic and the round-trip byte-stable.
*/
export function encodeDrawioFile(modelXml: string, title = "Page-1"): string {
const safeTitle = xmlEscape(title);
return `<mxfile host="drawio"><diagram id="page-1" name="${safeTitle}">${modelXml}</diagram></mxfile>`;
}
/**
* Build the `diagram.drawio.svg` attachment. Mirrors the import service's
* createDrawioSvg contract exactly:
* <svg xmlns= xmlns:xlink= content="${base64(drawioFile)}">${inner}</svg>
* plus width/height/viewBox from the diagram bounding box and the schematic
* preview as the visible children (`inner`).
*/
export function buildDrawioSvg(
modelXml: string,
inner: string,
bbox: DrawioBBox,
title = "Page-1",
): string {
const file = encodeDrawioFile(modelXml, title);
const base64 = Buffer.from(file, "utf-8").toString("base64");
const w = Math.max(1, Math.round(bbox.width));
const h = Math.max(1, Math.round(bbox.height));
return (
`<svg xmlns="http://www.w3.org/2000/svg" ` +
`xmlns:xlink="http://www.w3.org/1999/xlink" ` +
`width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
`content="${base64}">${inner}</svg>`
);
}
function xmlEscape(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// --- normalization + hash --------------------------------------------------
/**
* Normalize mxGraph XML for hashing / stable comparison: drop the whitespace
* between tags and trim. This is intentionally conservative it never reorders
* attributes or cells (that would be lossy) so two documents hash equal iff
* they differ only in inter-tag formatting.
*/
export function normalizeXml(xml: string): string {
return xml.replace(/>\s+</g, "><").trim();
}
/** Stable optimistic-lock hash over the normalized model XML (sha256, hex). */
export function mxHash(modelXml: string): string {
return createHash("sha256").update(normalizeXml(modelXml), "utf-8").digest("hex");
}
// --- cell parsing ----------------------------------------------------------
/** Parse every `<mxCell>` in a model into a structured DrawioCell list. */
export function parseCells(modelXml: string): DrawioCell[] {
const { doc, error } = parseXml(modelXml);
if (error) {
throw new DrawioLintError([
{ rule: "well-formed-xml", message: error, position: firstLineCol(error) },
]);
}
const cells: DrawioCell[] = [];
const els = doc.getElementsByTagName("mxCell");
for (let i = 0; i < els.length; i++) {
cells.push(readCell(els[i]));
}
return cells;
}
function readCell(el: any): DrawioCell {
const style = el.getAttribute("style") || "";
const parsed = parseStyle(style);
const geoEl = firstChildByTag(el, "mxGeometry");
const geometry: DrawioGeometry = geoEl
? {
x: num(geoEl.getAttribute("x")),
y: num(geoEl.getAttribute("y")),
width: num(geoEl.getAttribute("width")),
height: num(geoEl.getAttribute("height")),
relative: geoEl.getAttribute("relative") === "1",
hasGeometry: true,
}
: { relative: false, hasGeometry: false };
return {
id: el.getAttribute("id") ?? "",
parent: el.getAttribute("parent") ?? undefined,
source: el.getAttribute("source") ?? undefined,
target: el.getAttribute("target") ?? undefined,
vertex: el.getAttribute("vertex") === "1",
edge: el.getAttribute("edge") === "1",
value: el.getAttribute("value") ?? "",
style,
styleMap: parsed.map,
baseStyle: parsed.baseStyle,
geometry,
};
}
function firstChildByTag(el: any, tag: string): any {
for (let i = 0; i < el.childNodes.length; i++) {
const c = el.childNodes[i];
if (c.nodeType === 1 && c.tagName === tag) return c;
}
return null;
}
function firstLineCol(msg: string): string | undefined {
const m = /^(\d+:\d+)/.exec(msg);
return m ? m[1] : undefined;
}
// --- bounding box ----------------------------------------------------------
/**
* Absolute bounding box of the diagram from its vertex geometries. Container
* children are relative, so absolute positions are resolved along the parent
* chain before taking the extent. Falls back to a default canvas when empty.
*/
export function computeBBox(cells: DrawioCell[]): DrawioBBox {
const byId = new Map(cells.map((c) => [c.id, c]));
let maxX = 0;
let maxY = 0;
let any = false;
for (const c of cells) {
if (!c.vertex || !c.geometry.hasGeometry) continue;
const g = c.geometry;
if (g.width == null || g.height == null) continue;
const { x, y } = absolutePos(c, byId);
maxX = Math.max(maxX, x + g.width);
maxY = Math.max(maxY, y + g.height);
any = true;
}
if (!any) return { width: 300, height: 200 };
// A small margin so borders/labels are not clipped at the edge.
return { width: Math.ceil(maxX) + 20, height: Math.ceil(maxY) + 20 };
}
/** Absolute (x,y) of a vertex, following its parent chain (containers). */
export function absolutePos(
cell: DrawioCell,
byId: Map<string, DrawioCell>,
): { x: number; y: number } {
let x = cell.geometry.x ?? 0;
let y = cell.geometry.y ?? 0;
const seen = new Set<string>([cell.id]);
let parentId = cell.parent;
while (parentId && !seen.has(parentId)) {
seen.add(parentId);
const p = byId.get(parentId);
// Sentinels (0/1) carry no geometry; stop there.
if (!p || !p.vertex || !p.geometry.hasGeometry) break;
x += p.geometry.x ?? 0;
y += p.geometry.y ?? 0;
parentId = p.parent;
}
return { x, y };
}
// --- linter ----------------------------------------------------------------
/**
* Run every deterministic pre-write rule over a full mxGraphModel string. On any
* violation it throws a DrawioLintError carrying one issue per violation, each
* with the offending cellId + position. Returns the parsed cells on success.
*/
export function lintModel(modelXml: string): {
cells: DrawioCell[];
warnings: string[];
} {
const issues: DrawioLintIssue[] = [];
const warnings: string[] = [];
// Rule: no XML comments. Checked on the raw string (a comment survives DOM
// parsing as a comment node, but the intent is to reject them outright — they
// routinely wrap "TODO" cruft that breaks downstream tooling).
if (modelXml.includes("<!--")) {
issues.push({
rule: "no-comments",
message: "XML comments (<!-- -->) are not allowed in diagram XML",
});
}
// Rule: value escaping + literal newline. Scan raw <mxCell> tags so the error
// can name the cell id even when the whole document is otherwise malformed.
scanRawValues(modelXml, issues);
// Well-formedness — everything below needs a parsed DOM.
const { doc, error } = parseXml(modelXml);
if (error) {
issues.push({
rule: "well-formed-xml",
message: error,
position: firstLineCol(error),
});
throw new DrawioLintError(issues);
}
const root = doc.documentElement;
if (!root || root.tagName !== "mxGraphModel") {
issues.push({
rule: "structure",
message: `root element must be <mxGraphModel>, got <${root ? root.tagName : "?"}>`,
});
throw new DrawioLintError(issues);
}
if (!firstChildByTag(root, "root")) {
issues.push({
rule: "structure",
message: "<mxGraphModel> must contain a <root> element",
});
throw new DrawioLintError(issues);
}
const cells = parseCells(modelXml);
const ids = new Set<string>();
// Rule: sentinel cells id="0" and id="1"(parent="0").
const cell0 = cells.find((c) => c.id === "0");
const cell1 = cells.find((c) => c.id === "1");
if (!cell0) {
issues.push({
rule: "sentinel-cells",
message: 'missing the root sentinel cell <mxCell id="0"/>',
cellId: "0",
});
}
if (!cell1) {
issues.push({
rule: "sentinel-cells",
message: 'missing the layer sentinel cell <mxCell id="1" parent="0"/>',
cellId: "1",
});
} else if (cell1.parent !== "0") {
issues.push({
rule: "sentinel-cells",
message: 'the layer sentinel <mxCell id="1"> must have parent="0"',
cellId: "1",
});
}
cells.forEach((c, index) => {
const pos = `cell #${index}`;
const isSentinel = c.id === "0" || c.id === "1";
// Rule: unique, non-empty ids; user cells must not reuse 0/1.
if (c.id === "") {
issues.push({ rule: "cell-id", message: "cell has an empty id", position: pos });
} else if (ids.has(c.id)) {
issues.push({
rule: "duplicate-id",
message: `duplicate cell id "${c.id}"`,
cellId: c.id,
position: pos,
});
}
ids.add(c.id);
if (isSentinel) return; // sentinels are exempt from the shape rules below
// Rule: vertex XOR edge (a cell may be neither: groups/containers).
if (c.vertex && c.edge) {
issues.push({
rule: "vertex-edge-exclusive",
message: 'a cell cannot be both vertex="1" and edge="1"',
cellId: c.id,
position: pos,
});
}
// Rule: every edge has a child <mxGeometry as="geometry"/>.
if (c.edge && !c.geometry.hasGeometry) {
issues.push({
rule: "edge-geometry",
message:
'edge is missing its child <mxGeometry relative="1" as="geometry"/> — it will not render',
cellId: c.id,
position: pos,
});
}
// Rule: edge endpoints resolve to existing ids.
if (c.edge) {
for (const end of ["source", "target"] as const) {
const ref = c[end];
if (ref != null && ref !== "" && !cellExists(cells, ref)) {
issues.push({
rule: "edge-endpoint",
message: `edge ${end} "${ref}" does not resolve to any cell`,
cellId: c.id,
position: pos,
});
}
}
}
// Rule: parent must exist.
if (c.parent != null && c.parent !== "" && !cellExists(cells, c.parent)) {
issues.push({
rule: "parent-exists",
message: `parent "${c.parent}" does not resolve to any cell`,
cellId: c.id,
position: pos,
});
}
// Rule: style parses as key=value; pairs.
if (c.style !== "") {
const parsed = parseStyle(c.style);
if (parsed.badSegment !== undefined) {
issues.push({
rule: "style-format",
message: `malformed style segment "${parsed.badSegment}" (expected key=value)`,
cellId: c.id,
position: pos,
});
}
}
});
if (issues.length > 0) throw new DrawioLintError(issues);
return { cells, warnings };
}
function cellExists(cells: DrawioCell[], id: string): boolean {
return cells.some((c) => c.id === id);
}
/**
* Raw-string scan of every `value="…"`/`value='…'` on an mxCell tag. Catches an
* unescaped `&`/`<`/`>` and a literal newline character inside a value, keyed to
* the cell's id. Runs before DOM parsing so a value bug is reported with its
* cellId even when the document is otherwise malformed.
*/
function scanRawValues(xml: string, issues: DrawioLintIssue[]): void {
const tagRe = /<mxCell\b([^>]*?)\/?>/g;
let m: RegExpExecArray | null;
while ((m = tagRe.exec(xml)) !== null) {
const attrs = m[1];
const idM = /\bid\s*=\s*"([^"]*)"/.exec(attrs);
const cellId = idM ? idM[1] : undefined;
const valM = /\bvalue\s*=\s*"([^"]*)"/.exec(attrs) || /\bvalue\s*=\s*'([^']*)'/.exec(attrs);
if (!valM) continue;
const raw = valM[1];
// Literal newline (0x0A / 0x0D) inside the attribute value.
if (/[\n\r]/.test(raw)) {
issues.push({
rule: "value-newline",
message:
"value contains a literal newline; use &#xa; (or <br> with html=1) instead",
cellId,
});
}
// Unescaped '<' or '>' inside a value.
if (raw.includes("<") || raw.includes(">")) {
issues.push({
rule: "value-escaping",
message: "value contains an unescaped '<' or '>'; use &lt; / &gt;",
cellId,
});
}
// '&' that does not begin a valid entity.
const badAmp = /&(?!(amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/.test(raw);
if (badAmp) {
issues.push({
rule: "value-escaping",
message: "value contains an unescaped '&'; use &amp;",
cellId,
});
}
}
}
// --- input normalization + prepare -----------------------------------------
/**
* Normalize an accepted tool input into a full mxGraphModel string:
* - a bare `<mxGraphModel>` is used as-is;
* - an `<mxfile>` is decoded to its first page's model;
* - a list of `<mxCell>` is wrapped with the mxGraphModel/root envelope and
* the sentinel cells (id=0, id=1 parent=0) are added when absent.
*/
export function normalizeInput(inputXml: string): string {
let xml = inputXml.trim();
// Strip an optional XML prolog.
if (xml.startsWith("<?xml")) {
const end = xml.indexOf("?>");
if (end !== -1) xml = xml.slice(end + 2).trim();
}
if (xml.startsWith("<mxfile")) {
return decodeDrawioFileToModel(xml);
}
if (xml.startsWith("<mxGraphModel")) {
return xml;
}
if (xml.includes("<mxCell")) {
return wrapCellFragment(xml);
}
throw new DrawioLintError([
{
rule: "unrecognized-input",
message:
"input must be a <mxGraphModel>, an <mxfile>, or a list of <mxCell> elements",
},
]);
}
function wrapCellFragment(fragment: string): string {
// Validate the fragment is well-formed (wrapped so a bare list parses) and
// discover which sentinels are already present.
const { doc, error } = parseXml(`<root>${fragment}</root>`);
if (error) {
throw new DrawioLintError([
{
rule: "well-formed-xml",
message: error,
position: firstLineCol(error),
},
]);
}
const existing = new Set<string>();
const els = doc.getElementsByTagName("mxCell");
for (let i = 0; i < els.length; i++) {
existing.add(els[i].getAttribute("id") ?? "");
}
let prefix = "";
if (!existing.has("0")) prefix += '<mxCell id="0"/>';
if (!existing.has("1")) prefix += '<mxCell id="1" parent="0"/>';
return `<mxGraphModel ${DEFAULT_MODEL_ATTRS}><root>${prefix}${fragment}</root></mxGraphModel>`;
}
export interface PreparedModel {
/** Canonical (normalized) mxGraphModel XML that gets written. */
modelXml: string;
cells: DrawioCell[];
bbox: DrawioBBox;
/** Number of user cells (excludes the id=0/id=1 sentinels). */
cellCount: number;
warnings: string[];
hash: string;
}
/**
* Full pre-write pipeline for create/update: normalize the input into a model,
* lint it (throws DrawioLintError on any violation), then compute the canonical
* form, bounding box, cell count and hash. Never touches the network.
*/
export function prepareModel(inputXml: string): PreparedModel {
const rawModel = normalizeInput(inputXml);
const { cells, warnings } = lintModel(rawModel);
const modelXml = normalizeXml(rawModel);
const bbox = computeBBox(cells);
const cellCount = cells.filter((c) => c.id !== "0" && c.id !== "1").length;
return {
modelXml,
cells,
bbox,
cellCount,
warnings,
hash: mxHash(modelXml),
};
}
/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */
export function countUserCells(modelXml: string): number {
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
}
+24 -23
View File
@@ -12,11 +12,7 @@
* re-import for small wording fixes.
*/
import {
stripInlineMarkdown,
stripBalancedWrappers,
closestBlockHint,
} from "./text-normalize.js";
import { stripInlineMarkdown, stripBalancedWrappers } from "./text-normalize.js";
export interface TextEdit {
find: string;
@@ -309,21 +305,6 @@ export function applyTextEdits(
continue;
}
// HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is
// markdown that only becomes a real footnote when a whole markdown body is
// written (create_page / update_page_content / import_page_markdown). Written
// through edit_page_text it stays a LITERAL string in the text — the exact
// failure mode #410 fixes — so refuse it here (defense-in-depth) and point the
// caller at insert_footnote, mirroring the formatting-marker refusal above.
if (/\^\[[\s\S]*?\]/.test(edit.replace)) {
failed.push({
find: edit.find,
reason:
"edit_page_text writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insert_footnote (anchorText = where, text = the note).",
});
continue;
}
// Gather every inline block in document order (recurse the whole tree so
// nested containers — callouts, list items, table cells, blockquotes — are
// all covered).
@@ -385,9 +366,29 @@ export function applyTextEdits(
} else {
// Append a bounded "closest text" hint: find the FIRST block that
// contains the longest whitespace-delimited token (>= 3 chars) of the
// (stripped, then raw) locator, and quote that block's plain text. Shared
// with create_comment via closestBlockHint so both give the same hint.
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
// (stripped, then raw) locator, and quote that block's plain text.
reason = "text not found in the document.";
const tokenSource = stripped.length > 0 ? stripped : edit.find;
const longestToken = tokenSource
.split(/\s+/)
.filter((t) => t.length >= 3)
.sort((a, b) => b.length - a.length)[0];
if (longestToken) {
const hitBlock = blockPlain.find((plain) =>
plain.includes(longestToken),
);
if (hitBlock) {
// Truncate by code point (spread iterates by code point) so a
// surrogate pair is never split; append the ellipsis only when the
// text was actually longer than the limit.
const points = [...hitBlock];
const snippet =
points.length > 120
? points.slice(0, 120).join("") + "…"
: hitBlock;
reason += ` Closest block text: "${snippet}".`;
}
}
}
failed.push({ find: edit.find, reason });
continue;
-61
View File
@@ -1,61 +0,0 @@
// Minimal ambient type declaration for `pako` (no @types/pako is installed and
// pako 2.x ships no bundled .d.ts). We only use the raw-deflate codec to read
// draw.io's compressed `<diagram>` payload, so declare just that surface.
declare module "pako" {
interface RawOptions {
/** When "string", the result is returned as a (binary/UTF-8) string. */
to?: "string";
/** Raw-deflate window bits; draw.io uses raw deflate (no zlib header). */
windowBits?: number;
level?: number;
}
/** Raw-inflate (windowBits: -15). `to:"string"` yields a string. */
export function inflateRaw(
data: Uint8Array | ArrayBuffer | number[],
options: RawOptions & { to: "string" },
): string;
export function inflateRaw(
data: Uint8Array | ArrayBuffer | number[],
options?: RawOptions,
): Uint8Array;
/** Raw-deflate (windowBits: -15). Used only by tests to build fixtures. */
export function deflateRaw(
data: Uint8Array | string,
options?: RawOptions,
): Uint8Array;
interface InflateStreamOptions {
to?: "string";
windowBits?: number;
/** Raw deflate (no zlib header) — equivalent to windowBits: -15. */
raw?: boolean;
chunkSize?: number;
}
/**
* Streaming inflate. We use it to bound the decompressed size: `onData` is
* invoked per output chunk, letting us abort a decompression bomb before the
* full output is materialised.
*/
export class Inflate {
constructor(options?: InflateStreamOptions);
onData: (chunk: string | Uint8Array) => void;
onEnd: (status: number) => void;
push(
data: Uint8Array | ArrayBuffer | number[] | string,
flushMode?: boolean | number,
): boolean;
result: string | Uint8Array;
err: number;
msg: string;
}
const _default: {
inflateRaw: typeof inflateRaw;
deflateRaw: typeof deflateRaw;
Inflate: typeof Inflate;
};
export default _default;
}
-34
View File
@@ -114,37 +114,3 @@ export function stripInlineMarkdown(s: string): string {
return out;
}
/**
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
* edit_page_text (json-edit) and create_comment (client) so both surface the
* same self-correction affordance.
*
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
* (markdown-stripped first, so `**bold**` contributes `bold`), find the FIRST
* of `blockTexts` that contains it, and return ` Closest block text: "…".` with
* the block quoted (truncated to 120 code points + ellipsis). Returns "" when
* no token qualifies or no block contains it, so the caller can append it
* unconditionally.
*/
export function closestBlockHint(
blockTexts: string[],
locator: string,
): string {
if (typeof locator !== "string" || locator.length === 0) return "";
const stripped = stripInlineMarkdown(locator);
const tokenSource = stripped.length > 0 ? stripped : locator;
const longestToken = tokenSource
.split(/\s+/)
.filter((t) => t.length >= 3)
.sort((a, b) => b.length - a.length)[0];
if (!longestToken) return "";
const hitBlock = blockTexts.find((plain) => plain.includes(longestToken));
if (!hitBlock) return "";
// Truncate by code point (spread iterates by code point) so a surrogate pair
// is never split; append the ellipsis only when the text was actually longer.
const points = [...hitBlock];
const snippet =
points.length > 120 ? points.slice(0, 120).join("") + "…" : hitBlock;
return ` Closest block text: "${snippet}".`;
}
-15
View File
@@ -1,7 +1,6 @@
#!/usr/bin/env node
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createDocmostMcpServer } from "./index.js";
import { destroyAllSessions } from "./lib/collab-session.js";
// Standalone stdio entrypoint. This restores the original behavior of the
// package when run as a CLI (`docmost-mcp`): it reads credentials from the
@@ -34,20 +33,6 @@ async function run() {
console.error("Uncaught exception:", error);
});
// Teardown hook (issue #400): destroy every cached live CollabSession on exit
// so a hanging session does not keep a doc loaded on the server (which would
// also defer the server's afterUnloadDocument cleanup). `exit` runs the
// synchronous idempotent teardown; SIGINT/SIGTERM also run it, then exit.
process.on("exit", () => {
destroyAllSessions();
});
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
destroyAllSessions();
process.exit(0);
});
}
const server = createDocmostMcpServer({
apiUrl: API_URL!,
email: EMAIL!,
+3 -227
View File
@@ -771,13 +771,9 @@ export const SHARED_TOOL_SPECS = {
'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. Always COPY the `selection` " +
'VERBATIM from get_page / search_in_page output — do NOT quote it from ' +
'memory (stale-memory quoting is the top cause of anchor misses). If the ' +
'call fails with a "selection not found" error, the error quotes the ' +
"closest block text (or says the selection spans multiple blocks); retry " +
"with a corrected EXACT selection copied verbatim from a single " +
'paragraph/block. You may also attach a ' +
"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.',
@@ -1007,224 +1003,4 @@ export const SHARED_TOOL_SPECS = {
text: z.string().describe('The new cell text.'),
}),
},
// --- footnote + image write tools (promoted from inline MCP-only, #410) ---
//
// These three were previously registered inline in index.ts as MCP-only,
// because the in-app AI-chat agent had no equivalent. #410 promotes them so the
// in-app agent (esp. the Researcher role) can attach real footnotes/images
// instead of writing literal `^[...]` / placeholder text via editPageText. The
// schema + description are MOVED VERBATIM from the old inline registrations so
// external MCP clients see identical tool names, fields and text.
insertFootnote: {
mcpName: 'insert_footnote',
inAppKey: 'insertFootnote',
description:
'Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) ' +
'and WHAT (text). The footnote marker is placed right after anchorText in ' +
'the body, and the bottom footnotes list + the numbering are derived ' +
'deterministically server-side. You do NOT assign a number, and you ' +
"never see or edit the footnotes list — so footnotes cannot end up out " +
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
'SAME text already exists, its number is REUSED (one definition, several ' +
"references). The write is atomic and won't clobber concurrent edits; if " +
'anchorText is not found, nothing is written and an error is returned.',
// CORE for the in-app agent (#410): keeping it deferred would recreate the
// original asymmetry (footnote tool hidden while editPageText is core), which
// is exactly what makes the agent fall back to literal `^[...]`.
tier: 'core',
catalogLine:
'insertFootnote — attach a numbered footnote right after a snippet of existing body text.',
buildShape: (z) => ({
pageId: z.string().min(1),
anchorText: z
.string()
.min(1)
.describe(
'A snippet of existing body text; the footnote marker is inserted ' +
'immediately after its first occurrence (mark-safe).',
),
text: z
.string()
.min(1)
.describe('The footnote content as markdown (becomes the definition).'),
}),
},
insertImage: {
mcpName: 'insert_image',
inAppKey: 'insertImage',
description:
'Download an image from a web (http/https) URL and insert it into ' +
'a page in one step. By default ' +
'appends the image at the end of the page. With replaceText, replaces the ' +
'first top-level block whose text contains that string (handy for ' +
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
'With afterText, inserts the image right after the first block containing ' +
'that string. Preserves all other block ids.',
tier: 'deferred',
catalogLine:
'insertImage — download a web image and insert it into a page.',
buildShape: (z) => ({
pageId: z.string().min(1),
imageUrl: z
.string()
.min(1)
.describe('http(s) URL of the image to download and upload'),
align: z.enum(['left', 'center', 'right']).optional(),
alt: z.string().optional(),
replaceText: z
.string()
.optional()
.describe(
'Replace the first top-level block whose text contains this string with the image',
),
afterText: z
.string()
.optional()
.describe(
'Insert the image right after the first top-level block whose text contains this string',
),
}),
},
replaceImage: {
mcpName: 'replace_image',
inAppKey: 'replaceImage',
description:
'Replace an existing image on a page with a new image fetched from a web ' +
'(http/https) URL: uploads the new file as a NEW ' +
'attachment (fresh clean URL that renders and busts browser caches), then ' +
'repoints every image node referencing the old attachmentId (recursively, ' +
'incl. callouts/tables) via the live document, preserving comments, ' +
'alignment and alt. The old attachment is left as an unreferenced orphan ' +
'(Docmost has no API to delete a single attachment; it is removed only when ' +
'the page/space is deleted). In-place byte overwrite is avoided because some ' +
'Docmost versions corrupt the attachment (HTTP 500) on overwrite.',
tier: 'deferred',
catalogLine:
'replaceImage — swap an existing page image for one fetched from a web URL.',
buildShape: (z) => ({
pageId: z.string().min(1),
attachmentId: z
.string()
.min(1)
.describe('attachmentId of the image currently in the page to replace'),
imageUrl: z
.string()
.min(1)
.describe('http(s) URL of the new image to download'),
align: z.enum(['left', 'center', 'right']).optional(),
alt: z.string().optional(),
}),
},
// --- draw.io diagrams (issue #423, stage 1) ---
drawioGet: {
mcpName: 'drawio_get',
inAppKey: 'drawioGet',
description:
'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' +
'`.drawio.svg`. `node` is the drawio node\'s attrs.id (from get_outline / ' +
'get_page_json) or "#<index>" for a top-level block. Returns the decoded ' +
'mxGraphModel XML plus meta { attachmentId, title, width, height, ' +
'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' +
'as baseHash to drawio_update. Diagrams a human saved from the editor ' +
'(including draw.io\'s compressed format) decode losslessly.',
tier: 'deferred',
catalogLine:
'drawioGet — read a draw.io diagram as mxGraph XML (+ hash for updates).',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.string()
.min(1)
.describe('The drawio node attrs.id, or "#<index>" for a top-level block.'),
format: z
.enum(['xml', 'svg'])
.optional()
.describe('"xml" (default) for mxGraph XML, or "svg" for the raw .drawio.svg.'),
}),
},
drawioCreate: {
mcpName: 'drawio_create',
inAppKey: 'drawioCreate',
description:
'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' +
'block. `xml` is a bare `<mxGraphModel>` OR a list of `<mxCell>` elements ' +
'(the server wraps it and adds the id=0 / id=1 sentinel cells). The XML is ' +
'LINTED first (well-formedness, sentinel cells, unique ids, vertex XOR ' +
'edge, every edge has a child <mxGeometry as="geometry"/>, edge ' +
'source/target and every parent resolve, style parses, no XML comments, ' +
'value escaping) — a violation returns a structured error naming the rule ' +
'and cellId so you can fix and retry. `where` positions the block like ' +
'insert_node: position before/after (with exactly one of anchorNodeId or ' +
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' +
'no attrs.id): it addresses the new top-level block and can be fed straight ' +
'back into drawio_get / drawio_update for THIS document. It is positional, ' +
'so if you add or remove blocks before it, re-resolve via get_outline. The ' +
'diagram is editable in the draw.io editor and can be re-read with ' +
'drawio_get.',
tier: 'deferred',
catalogLine:
'drawioCreate — create a draw.io diagram from mxGraph XML and insert it.',
buildShape: (z) => ({
pageId: z.string().min(1),
xml: z
.string()
.min(1)
.describe(
'mxGraph XML: a bare <mxGraphModel> or a list of <mxCell> elements.',
),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z
.string()
.optional()
.describe('Anchor block id (for before/after).'),
anchorText: z
.string()
.optional()
.describe('Anchor text fragment (for before/after).'),
title: z.string().optional().describe('Optional diagram title.'),
}),
},
drawioUpdate: {
mcpName: 'drawio_update',
inAppKey: 'drawioUpdate',
description:
'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' +
'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' +
'the drawio_get you based the edit on. If the diagram changed since ' +
'(a human or another agent edited it) the hash mismatches and the update ' +
'is refused with a conflict error — re-read with drawio_get and retry. On ' +
'success it overwrites the diagram attachment and updates the node ' +
'width/height. `node` is the drawio node attrs.id or "#<index>".',
tier: 'deferred',
catalogLine:
'drawioUpdate — replace a draw.io diagram (optimistic-locked by baseHash).',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.string()
.min(1)
.describe('The drawio node attrs.id, or "#<index>" for a top-level block.'),
xml: z
.string()
.min(1)
.describe(
'New mxGraph XML: a bare <mxGraphModel> or a list of <mxCell> elements.',
),
baseHash: z
.string()
.min(1)
.describe('The meta.hash from the drawio_get this edit is based on.'),
}),
},
} satisfies Record<string, SharedToolSpec>;
@@ -1,282 +0,0 @@
// Unit tests for the collab-token cache (issue #435). The live CollabSession
// registry (#400/#431) keys sessions on (wsUrl, pageId, collabToken), so a token
// string that changes every op defeats reuse. This cache holds the last minted
// token per DocmostClient for MCP_COLLAB_TOKEN_TTL_MS so a burst of mutations
// reuses ONE token -> ONE session. These tests exercise both mint sources:
// - the getCollabToken PROVIDER path (in-app agent), via a counting provider fn;
// - the REST /auth/collab-token path (external MCP), via a mock http server.
// getCollabTokenWithReauth is private in TS but a plain method on the compiled
// build, so the tests call it directly (same convention as reauth.test.mjs).
import { test, afterEach, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
// Restore the env knob after each test so cases do not leak into one another.
const ENV_KEY = "MCP_COLLAB_TOKEN_TTL_MS";
afterEach(() => {
delete process.env[ENV_KEY];
});
// ---------------------------------------------------------------------------
// Small mock server for the REST /auth/collab-token path. Counts collab-token
// mints and can be told to 401 the first N of them (to drive the reauth retry).
// ---------------------------------------------------------------------------
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
after(async () => {
await Promise.all(
openServers.map((s) => new Promise((r) => s.close(r))),
);
});
// state: { collabCalls, loginCalls, unauthorizedCollabHits }
function spawnCollabServer(state, { collabAuthFailsFor = 0 } = {}) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
state.loginCalls++;
// A fresh authToken per login so an identity change is observable.
sendJson(res, 200, { success: true }, {
"Set-Cookie": `authToken=login-${state.loginCalls}; Path=/; HttpOnly`,
});
return;
}
if (req.url === "/api/auth/collab-token") {
state.collabCalls++;
if (state.collabCalls <= collabAuthFailsFor) {
sendJson(res, 401, { message: "Unauthorized" });
return;
}
// Unique token per mint so a stale cached value is distinguishable.
sendJson(res, 200, { data: { token: `collab-${state.collabCalls}` } });
return;
}
sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
// ===========================================================================
// PROVIDER path (in-app agent getCollabToken fn)
// ===========================================================================
// A counting provider that returns a distinct token each call so a cached
// (reused) token is visibly the SAME string while a fresh mint is different.
function countingProvider() {
let n = 0;
const fn = async () => {
n++;
return `provider-token-${n}`;
};
return {
fn,
get calls() {
return n;
},
};
}
test("within TTL, repeated calls return the SAME token and mint ONCE (provider path)", async () => {
process.env[ENV_KEY] = "300000"; // 5 min
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const a = await client.getCollabTokenWithReauth();
const b = await client.getCollabTokenWithReauth();
const c = await client.getCollabTokenWithReauth();
assert.equal(a, "provider-token-1");
assert.equal(b, a, "second call reuses the cached token");
assert.equal(c, a, "third call reuses the cached token");
assert.equal(p.calls, 1, "the provider is invoked exactly once within the TTL");
});
test("after TTL expiry a new token is minted (provider path)", async () => {
process.env[ENV_KEY] = "20"; // 20ms TTL
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const a = await client.getCollabTokenWithReauth();
await new Promise((r) => setTimeout(r, 40)); // let the TTL lapse
const b = await client.getCollabTokenWithReauth();
assert.equal(a, "provider-token-1");
assert.equal(b, "provider-token-2", "a fresh token is minted after expiry");
assert.equal(p.calls, 2);
});
test("MCP_COLLAB_TOKEN_TTL_MS=0 disables the cache: mint on EVERY call (provider path)", async () => {
process.env[ENV_KEY] = "0";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
assert.equal(p.calls, 3, "cache disabled -> exact fetch-per-call legacy path");
});
test("a 401 triggers the internal reauth retry, which bypasses the cache and mints fresh (provider path)", async () => {
process.env[ENV_KEY] = "300000";
let n = 0;
const provider = async () => {
n++;
if (n === 1) {
// The FIRST mint fails with an auth error; the internal reauth retry must
// re-invoke the provider (bypassing the empty cache) for a fresh token.
const err = new Error("collab token expired");
err.status = 401;
throw err;
}
return `provider-token-${n}`;
};
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: provider,
});
// Cache is empty: mint #1 401s -> the reauth retry mints #2 and caches it.
const tok = await client.getCollabTokenWithReauth();
assert.equal(tok, "provider-token-2", "the post-401 retry token wins");
assert.equal(n, 2, "exactly one failed mint + one retry, no loop");
// The retried token is what got cached (no extra mint on a cache hit).
const cached = await client.getCollabTokenWithReauth();
assert.equal(cached, "provider-token-2");
assert.equal(n, 2, "served from cache, provider not re-invoked");
});
test("forceRefresh=true bypasses a warm cache and mints a fresh token (provider path)", async () => {
process.env[ENV_KEY] = "300000";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const first = await client.getCollabTokenWithReauth(); // caches token-1
assert.equal(first, "provider-token-1");
// A forced refresh (what the reauth path passes) must NOT return the cached
// token-1; it mints a fresh token-2 and replaces the cache.
const forced = await client.getCollabTokenWithReauth(true);
assert.equal(forced, "provider-token-2", "cache bypassed on forceRefresh");
assert.equal(p.calls, 2);
const cached = await client.getCollabTokenWithReauth();
assert.equal(cached, "provider-token-2", "the fresh token replaced the cache");
assert.equal(p.calls, 2);
});
test("two consecutive mutations keep the SAME token, so the session key is stable (provider path)", async () => {
// The whole point of #435: acquireCollabSession keys on the token, so two
// acquire calls in a burst must be handed the identical token string.
process.env[ENV_KEY] = "300000";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const t1 = await client.getCollabTokenWithReauth();
const t2 = await client.getCollabTokenWithReauth();
assert.equal(t1, t2, "identical token across two mutations -> one session key");
assert.equal(p.calls, 1);
});
// ===========================================================================
// REST /auth/collab-token path (external MCP)
// ===========================================================================
test("within TTL, the REST /auth/collab-token endpoint is hit ONCE", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const a = await client.getCollabTokenWithReauth();
const b = await client.getCollabTokenWithReauth();
assert.equal(a, "collab-1");
assert.equal(b, a, "cached token reused");
assert.equal(state.collabCalls, 1, "POST /auth/collab-token called once");
});
test("TTL=0 hits the REST endpoint on every call", async () => {
process.env[ENV_KEY] = "0";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
assert.equal(state.collabCalls, 2, "cache disabled -> fetch each call");
});
test("401 on REST collab-token re-logs-in and refetches (cache bypassed)", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
// The first collab-token mint 401s; the reauth path logs in and retries.
const baseURL = await spawnCollabServer(state, { collabAuthFailsFor: 1 });
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// Pre-seed a token so the initial call does not perform an initial login.
client.token = "seed";
client.client.defaults.headers.common["Authorization"] = "Bearer seed";
const tok = await client.getCollabTokenWithReauth();
assert.equal(tok, "collab-2", "the post-reauth mint wins, not the failed one");
assert.equal(state.loginCalls, 1, "re-login happened exactly once");
assert.equal(state.collabCalls, 2, "one failed mint + one successful retry");
});
test("a fresh login clears the cache so a collab token cannot outlive the identity", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const before = await client.getCollabTokenWithReauth();
assert.equal(before, "collab-1");
// Simulate an identity change (the 401 interceptor / re-login path calls
// login(), which must drop the cached collab token).
await client.login();
const after = await client.getCollabTokenWithReauth();
assert.equal(after, "collab-2", "cache was invalidated by login(); refetched");
assert.equal(state.collabCalls, 2);
});
@@ -548,94 +548,3 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
);
assert.equal(createPayload.suggestedText, "goodbye");
});
// -----------------------------------------------------------------------------
// 8) #408: a not-found selection error QUOTES the closest block text so the
// model can self-correct instead of blind-retrying.
// -----------------------------------------------------------------------------
test("a not-found selection error includes a 'Closest block text' hint", async () => {
let createCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 200, {
data: {
id: "page-1",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "The quick brown fox jumps" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createCalls++;
sendJson(res, 200, { data: { id: "should-not-happen" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "quick brown cat"),
/Closest block text: "The quick brown fox jumps"/,
"a not-found selection must quote the closest block text",
);
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
});
// -----------------------------------------------------------------------------
// 9) #408: a selection that straddles two blocks gets the explicit
// "spans multiple blocks" message instead of a bare not-found.
// -----------------------------------------------------------------------------
test("a selection spanning multiple blocks gets the explicit spans-multiple-blocks message", async () => {
let createCalls = 0;
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return;
}
if (req.url === "/api/pages/info") {
sendJson(res, 200, {
data: {
id: "page-1",
content: {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "the quick brown" }] },
{ type: "paragraph", content: [{ type: "text", text: "fox jumps over" }] },
],
},
},
});
return;
}
if (req.url === "/api/comments/create") {
createCalls++;
sendJson(res, 200, { data: { id: "should-not-happen" } });
return;
}
sendJson(res, 404, { message: "not found" });
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.createComment("page-1", "body", "inline", "brown fox"),
/spans multiple blocks/,
"a cross-block selection must report the spans-multiple-blocks hint",
);
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
});
@@ -1,467 +0,0 @@
// Contract tests for the drawio_get / drawio_create / drawio_update client
// methods (issue #423). Follows the repo's seam-override pattern (see
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
// write) so the tool logic is exercised without a live Docmost or collab socket.
import { test } from "node:test";
import assert from "node:assert/strict";
import pako from "pako";
import { DocmostClient } from "../../build/client.js";
import {
buildDrawioSvg,
encodeDrawioFile,
normalizeXml,
mxHash,
decodeDrawioSvg,
} from "../../build/lib/drawio-xml.js";
const MODEL =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Hi" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="20" y="20" width="120" height="60" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
// Build a Docmost-style `.drawio.svg` (base64 content) for a model.
function svgFor(model) {
return buildDrawioSvg(normalizeXml(model), "<g/>", { width: 200, height: 120 });
}
// Build a human/compressed-export `.drawio.svg` (base64 content wrapping a
// compressed <diagram> payload), mimicking a diagram a person saved.
function compressedSvgFor(model) {
const compressed = Buffer.from(
pako.deflateRaw(encodeURIComponent(normalizeXml(model))),
).toString("base64");
const file = `<mxfile host="Electron"><diagram id="a" name="Page-1">${compressed}</diagram></mxfile>`;
const content = Buffer.from(file, "utf-8").toString("base64");
return `<svg xmlns="http://www.w3.org/2000/svg" content="${content}"><image href="x"/></svg>`;
}
// The vendored `drawio` node schema (diagramAttributes) declares ONLY these
// attributes; PMNode.fromJSON drops anything else on save. Mirror that here so
// the mock write path behaves like the real one — in particular, a block `id`
// set on a drawio node does NOT survive the save, so a handle keyed on it is
// un-resolvable. This is exactly what the production bug (issue #423 Fix 1) was.
const DRAWIO_SCHEMA_ATTRS = new Set([
"src",
"title",
"alt",
"width",
"height",
"size",
"aspectRatio",
"align",
"attachmentId",
]);
function applyDrawioSchemaDrop(node) {
if (!node || typeof node !== "object") return;
if (node.type === "drawio" && node.attrs && typeof node.attrs === "object") {
for (const key of Object.keys(node.attrs)) {
if (!DRAWIO_SCHEMA_ATTRS.has(key)) delete node.attrs[key];
}
}
if (Array.isArray(node.content)) for (const c of node.content) applyDrawioSchemaDrop(c);
}
function makeClient({ pageDoc, attachmentSvg } = {}) {
const calls = { uploads: [], mutations: [] };
class TestClient extends DocmostClient {
async ensureAuthenticated() {}
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId(pageId) {
return `uuid-${pageId}`;
}
async getPageRaw(pageId) {
return {
id: pageId,
slugId: "s",
title: "P",
spaceId: "sp",
content: pageDoc ?? { type: "doc", content: [] },
};
}
async uploadAttachmentBuffer(pageId, buffer, fileName, mime) {
const id = `att-${calls.uploads.length + 1}`;
calls.uploads.push({ pageId, fileName, mime, svg: buffer.toString("utf-8") });
return { id, fileName, fileSize: buffer.length };
}
async fetchAttachmentText(src) {
return attachmentSvg;
}
mutatePage(pageId, token, apiUrl, transform) {
// Run the transform against a clone of the source doc, capture the result.
const clone = structuredClone(pageDoc ?? { type: "doc", content: [] });
const doc = transform(clone);
// Mirror the real schema: unknown drawio attrs (e.g. a block `id`) are
// dropped on save, so callers can never rely on them to address the node.
if (doc) applyDrawioSchemaDrop(doc);
calls.mutations.push({ pageId, doc });
return Promise.resolve({ doc, verify: { changed: doc != null } });
}
}
const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
return { client, calls };
}
function findDrawio(node, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === "drawio") acc.push(node);
if (Array.isArray(node.content)) for (const c of node.content) findDrawio(c, acc);
return acc;
}
// --- drawio_create ---------------------------------------------------------
test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", async () => {
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client, calls } = makeClient({ pageDoc });
const res = await client.drawioCreate("page1", { position: "append" }, MODEL, "My diagram");
assert.equal(res.success, true);
// The returned handle is an index-based "#<index>" ref (drawio nodes carry no
// persisted attrs.id), addressing the appended top-level block (index 1, after
// the existing paragraph).
assert.equal(res.nodeId, "#1");
assert.equal(res.attachmentId, "att-1");
assert.equal(calls.uploads.length, 1);
assert.equal(calls.uploads[0].fileName, "diagram.drawio.svg");
assert.equal(calls.uploads[0].mime, "image/svg+xml");
// The uploaded SVG carries the model back (round-trips through the decode chain).
assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(MODEL));
// A drawio node was appended with src/attachmentId/dimensions and the title.
const drawios = findDrawio(calls.mutations[0].doc);
assert.equal(drawios.length, 1);
const n = drawios[0];
// No `id` attribute is set/persisted on the node (schema has none).
assert.equal(n.attrs.id, undefined);
assert.equal(n.attrs.attachmentId, "att-1");
assert.match(n.attrs.src, /^\/api\/files\/att-1\//);
assert.ok(n.attrs.width > 0 && n.attrs.height > 0);
assert.equal(n.attrs.title, "My diagram");
});
test("drawio_create: a lint violation throws before any upload", async () => {
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
// Edge with no child geometry -> edge-geometry rule.
const bad =
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
'<mxCell id="3" edge="1" parent="1" source="2" target="2"/></root></mxGraphModel>';
await assert.rejects(
() => client.drawioCreate("page1", { position: "append" }, bad, undefined),
/edge-geometry/,
);
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
});
test("drawio_create: before/after requires exactly one anchor", async () => {
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
await assert.rejects(
() => client.drawioCreate("page1", { position: "before" }, MODEL),
/exactly one of anchorNodeId or anchorText/,
);
});
// --- drawio_get ------------------------------------------------------------
test("drawio_get: decodes the model and returns meta with a hash", async () => {
const pageDoc = {
type: "doc",
content: [
{
type: "drawio",
attrs: {
id: "d1",
src: "/api/files/att-1/diagram.drawio.svg",
attachmentId: "att-1",
title: "T",
width: 200,
height: 120,
},
},
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
const res = await client.drawioGet("page1", "d1", "xml");
assert.equal(res.content, normalizeXml(MODEL));
assert.equal(res.meta.attachmentId, "att-1");
assert.equal(res.meta.title, "T");
assert.equal(res.meta.cellCount, 1);
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
});
test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
const svg = svgFor(MODEL);
const pageDoc = {
type: "doc",
content: [
{ type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } },
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svg });
const res = await client.drawioGet("page1", "d1", "svg");
assert.equal(res.content, svg);
});
test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
const pageDoc = {
type: "doc",
content: [
{ type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } },
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: compressedSvgFor(MODEL) });
const res = await client.drawioGet("page1", "d1", "xml");
assert.equal(res.content, normalizeXml(MODEL));
});
// --- drawio_update ---------------------------------------------------------
const UPDATED_MODEL =
'<mxGraphModel><root>' +
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="2" value="Changed" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="20" y="20" width="300" height="200" as="geometry"/></mxCell>' +
'</root></mxGraphModel>';
function updatePageDoc() {
return {
type: "doc",
content: [
{
type: "drawio",
attrs: {
id: "d1",
src: "/api/files/att-1/diagram.drawio.svg",
attachmentId: "att-1",
width: 200,
height: 120,
},
},
],
};
}
test("drawio_update: stale baseHash -> conflict, no upload", async () => {
const { client, calls } = makeClient({
pageDoc: updatePageDoc(),
attachmentSvg: svgFor(MODEL),
});
await assert.rejects(
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, "deadbeef-stale"),
/conflict/,
);
assert.equal(calls.uploads.length, 0, "no upload on conflict");
});
test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => {
const currentHash = mxHash(normalizeXml(MODEL));
const { client, calls } = makeClient({
pageDoc: updatePageDoc(),
attachmentSvg: svgFor(MODEL),
});
const res = await client.drawioUpdate("page1", "d1", UPDATED_MODEL, currentHash);
assert.equal(res.success, true);
assert.equal(res.attachmentId, "att-1"); // fresh id from the stub sequence
assert.equal(calls.uploads.length, 1);
// The uploaded SVG carries the NEW model.
assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(UPDATED_MODEL));
// The node was repointed with the new bounding-box dimensions:
// vertex maxX=320,maxY=220 + the 20px preview margin -> 340 x 240.
const n = findDrawio(calls.mutations[0].doc)[0];
assert.equal(n.attrs.attachmentId, "att-1");
assert.equal(n.attrs.width, 340);
assert.equal(n.attrs.height, 240);
// The block `id` used as the legacy resolution handle is dropped on save
// (schema declares no `id`); the update still targeted the correct node.
assert.equal(n.attrs.id, undefined);
});
test("drawio_update: baseHash is mandatory", async () => {
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
/baseHash is mandatory/,
);
});
// --- Fix 1: the create handle must resolve on the SAVED doc (no id) ---------
test("drawio_create -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
// Create appends a drawio node after the existing paragraph.
const createDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const create = makeClient({ pageDoc: createDoc });
const res = await create.client.drawioCreate(
"page1",
{ position: "append" },
MODEL,
"T",
);
// The handle is index-based, not a block id.
assert.equal(res.nodeId, "#1");
// Take the document EXACTLY as it was saved: the schema drop stripped the
// node's id, so no id-based handle could ever resolve against it.
const savedDoc = create.calls.mutations[0].doc;
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
// drawio_get with the returned handle resolves the just-created node.
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
assert.equal(got.nodeId, res.nodeId);
assert.equal(got.content, normalizeXml(MODEL));
// drawio_update with the same handle + the hash from get repoints that node.
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
const upd = await upClient.client.drawioUpdate(
"page1",
res.nodeId,
UPDATED_MODEL,
got.meta.hash,
);
assert.equal(upd.success, true);
assert.equal(upd.nodeId, res.nodeId);
const updated = findDrawio(upClient.calls.mutations[0].doc)[0];
assert.equal(
decodeDrawioSvg(upClient.calls.uploads[0].svg),
normalizeXml(UPDATED_MODEL),
);
assert.equal(updated.attrs.width, 340);
});
// --- error paths: the LLM must get a clean error, not a crash --------------
test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
// Page has one paragraph; the requested ref resolves to nothing.
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioGet("page1", "does-not-exist", "xml"),
/no node found for "does-not-exist"/,
);
});
test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => {
const pageDoc = {
type: "doc",
content: [
// A drawio node that carries no `src` (e.g. a half-written node).
{ type: "drawio", attrs: { id: "d1", attachmentId: "att-1" } },
],
};
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioGet("page1", "d1", "xml"),
/node "d1" on page page1 has no src to read/,
);
});
test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
// "#0" resolves to a paragraph. The update must refuse cleanly rather than
// crash or repoint the wrong node.
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client, calls } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
await assert.rejects(
() => client.drawioUpdate("page1", "#0", UPDATED_MODEL, "any-nonempty-hash"),
/node "#0" on page page1 is a paragraph, not a drawio diagram/,
);
assert.equal(calls.uploads.length, 0, "no upload when the node is not a diagram");
assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram");
});
test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => {
// The upload happens before the mutate transform; when the anchor cannot be
// found the write is skipped and the (now unreferenced) attachment is named
// in the error, exactly as the code documents.
const pageDoc = {
type: "doc",
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
};
const { client, calls } = makeClient({ pageDoc });
await assert.rejects(
() =>
client.drawioCreate(
"page1",
{ position: "after", anchorNodeId: "nope" },
MODEL,
"T",
),
(err) =>
/anchor not found/.test(err.message) &&
/unreferenced orphan/.test(err.message) &&
/att-1/.test(err.message),
);
// The orphan was uploaded (and reported), but no node was written.
assert.equal(calls.uploads.length, 1, "attachment uploaded before the failed insert");
const drawios = calls.mutations.length ? findDrawio(calls.mutations[0].doc) : [];
assert.equal(drawios.length, 0, "no drawio node written when the anchor is missing");
});
// --- Fix 2: update targets ONLY the resolved node --------------------------
test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
// A copied diagram: two drawio nodes share one attachmentId. Updating via the
// "#0" handle must touch node #0 only, never the sibling copy.
const shared = {
type: "doc",
content: [
{
type: "drawio",
attrs: {
src: "/api/files/shared/x.svg",
attachmentId: "shared",
width: 200,
height: 120,
},
},
{
type: "drawio",
attrs: {
src: "/api/files/shared/x.svg",
attachmentId: "shared",
width: 200,
height: 120,
},
},
],
};
const { client, calls } = makeClient({
pageDoc: shared,
attachmentSvg: svgFor(MODEL),
});
const res = await client.drawioUpdate(
"page1",
"#0",
UPDATED_MODEL,
mxHash(normalizeXml(MODEL)),
);
assert.equal(res.success, true);
const drawios = findDrawio(calls.mutations[0].doc);
assert.equal(drawios.length, 2);
// Node #0 repointed to the NEW attachment ("att-1" from the stub) and dims.
assert.equal(drawios[0].attrs.attachmentId, "att-1");
assert.equal(drawios[0].attrs.width, 340);
assert.match(drawios[0].attrs.src, /^\/api\/files\/att-1\//);
// Node #1 (the sibling copy) is untouched despite sharing the old attachmentId.
assert.equal(drawios[1].attrs.attachmentId, "shared");
assert.equal(drawios[1].attrs.width, 200);
assert.equal(drawios[1].attrs.src, "/api/files/shared/x.svg");
});
@@ -19,7 +19,6 @@ import { WebSocketServer } from "ws";
import { Hocuspocus } from "@hocuspocus/server";
import { DocmostClient } from "../../build/client.js";
import { buildYDoc } from "../../build/lib/collaboration.js";
import { destroyAllSessions } from "../../build/lib/collab-session.js";
// Import the SAME page-lock module instance that build/client.js imports. ESM
// caches modules by resolved URL, so this `withPageLock` shares the very
// per-page mutex map (`chains`) the client uses — letting the replaceImage test
@@ -189,10 +188,6 @@ async function spawnCollabStack(opts = {}) {
const openStacks = [];
after(async () => {
// #400: tests now leave a cached live CollabSession per page. Destroy them
// first (closes the client ws) so the server.close() below is not racing an
// open collab connection.
destroyAllSessions();
await Promise.all(
openStacks.map(
({ server, hocuspocus }) =>
@@ -275,23 +270,17 @@ test("a UUID input is passed through unchanged and triggers NO /pages/info fetch
);
});
test("repeated slugId edits reuse ONE live collab session and resolve the UUID only once (#400 cache)", async () => {
test("a repeated slugId edit resolves the UUID only once (cache)", async () => {
const { state, baseURL } = await spawnCollabStack();
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// #400: a series of edits on the same page reuses ONE live CollabSession, so
// the connect/handshake happens once and the collab doc is OPENED a single
// time (not per edit). The live ydoc persists between edits (the whole point),
// so the second edit sees the first edit's result: after "hello" -> "hi world"
// it targets the still-present "world".
// Each mock connection re-seeds a fresh "hello world" doc (the mock does not
// persist across connects), so both edits target "hello". The cache assertion
// only concerns the slugId->uuid resolution, not the document content.
await client.editPageText(SLUG, [{ find: "hello", replace: "hi" }]);
await client.editPageText(SLUG, [{ find: "world", replace: "planet" }]);
await client.editPageText(SLUG, [{ find: "hello", replace: "hey" }]);
assert.deepEqual(
state.docNames,
[`page.${UUID}`],
"the two edits must reuse one live collab session -> a single collab-doc open (#400)",
);
assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
assert.equal(
state.pagesInfoCalls.length,
1,
@@ -336,9 +325,8 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
await uploadStarted; // deterministic: replaceImage now holds its page lock.
// (a) OPEN BY UUID: the only collab doc opened so far (the scan pass) used the
// canonical UUID, never the slugId. (#400: the write pass will REUSE this same
// live session rather than reopen, so docNames stays a single entry — asserted
// at the end.)
// canonical UUID, never the slugId. (The write pass opens a second time after
// we release the gate; asserted at the end.)
assert.deepEqual(
state.docNames,
[`page.${UUID}`],
@@ -390,9 +378,8 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
assert.equal(res.success, true);
assert.equal(res.replaced, 1, "the one seeded image must be repointed");
// #400: the write pass REUSES the scan pass's live session, so the collab doc
// is opened ONCE across both passes (never reopened, never by the slugId).
assert.deepEqual(state.docNames, [`page.${UUID}`]);
// Both opens (scan pass + write pass) used the UUID; the slugId never appears.
assert.deepEqual(state.docNames, [`page.${UUID}`, `page.${UUID}`]);
assert.ok(
!state.docNames.includes(`page.${SLUG}`),
"replaceImage must NEVER open the collab doc by the slugId (the #260 bug)",
@@ -1,87 +0,0 @@
// #402 — INTEGRATION test that locks the registerTool monkeypatch installed by
// createDocmostMcpServer (src/index.ts). The sibling unit test
// (test/unit/tool-timing.test.mjs) only exercises the timeToolHandler helper in
// ISOLATION; it never constructs the server, so nothing there proves the factory
// actually (a) wraps every registered tool through that helper and (b) labels
// each sample with the tool's REGISTRATION name.
//
// Here we stand up a real McpServer via the factory, connect a real MCP Client
// over the SDK's in-memory transport, invoke one registered tool, and assert the
// host's onMetric sink received `("mcp_tool_duration_seconds", <number>, { tool
// })` with tool === the exact registration name. This locks both the "monkeypatch
// wraps tools" and "label = registration name" halves of the contract, so a
// mutation to args.slice(0, -1) / the handler-arg detection / the capture order
// is caught.
import { test } from "node:test";
import assert from "node:assert/strict";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createDocmostMcpServer } from "../../build/index.js";
// The tool we drive. get_workspace has NO input schema, so protocol-level input
// validation cannot short-circuit before the handler runs — the wrapped handler
// is guaranteed to execute (and then fail on the unreachable backend, which is
// exactly what we want: the wrapper times in a finally on throw too).
const TOOL_NAME = "get_workspace";
test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => {
const calls = [];
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
// Minimal valid credentials config. apiUrl points at a port that refuses
// connections immediately so the tool's backend call fails FAST (ECONNREFUSED)
// rather than hanging — the wrapper still emits the metric from its finally.
const server = createDocmostMcpServer({
apiUrl: "http://127.0.0.1:1",
email: "x@example.com",
password: "pw",
onMetric,
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const client = new Client(
{ name: "test-client", version: "0.0.0" },
{ capabilities: {} },
);
await Promise.all([
server.connect(serverTransport),
client.connect(clientTransport),
]);
try {
// Invoke the tool. The backend is unreachable, so this either resolves with
// an error result (isError) or rejects — both are fine. What matters is that
// the handler ran through the timing wrapper, which fires onMetric either way.
try {
await client.callTool({ name: TOOL_NAME, arguments: {} });
} catch {
// Tolerate the expected backend failure surfacing as a thrown protocol error.
}
// The wrapper must have fed exactly the timing sample for THIS tool.
const timing = calls.filter(
(c) => c.name === "mcp_tool_duration_seconds",
);
assert.ok(
timing.length >= 1,
"onMetric must receive a mcp_tool_duration_seconds sample from the wrapped handler",
);
const sample = timing.find((c) => c.labels && c.labels.tool === TOOL_NAME);
assert.ok(
sample,
`a timing sample must be labelled with the registration name "${TOOL_NAME}"; ` +
`got labels: ${JSON.stringify(timing.map((c) => c.labels))}`,
);
assert.equal(typeof sample.value, "number");
assert.ok(sample.value >= 0, "duration must be non-negative seconds");
} finally {
await client.close();
await server.close();
}
});
@@ -28,9 +28,8 @@ import { DocmostClient } from "../../build/index.js";
// in the server's DocmostClientLike interface (the in-app per-user tool adapter
// only — it is a SUBSET of the DocmostClient surface — covers only what the in-app adapter
// consumes; the standalone MCP transport (packages/mcp/src/index.ts) calls additional
// client methods (deleteComment/updateComment) that this guard does NOT track — the
// MCP transport's own typecheck covers those. insertImage/replaceImage/insertFootnote
// were MCP-only but are now in-app-consumed too (#410), so they ARE tracked below. Full type-derivation
// client methods (insertImage/replaceImage/deleteComment/updateComment/insertFootnote)
// that this guard does NOT track — the MCP transport's own typecheck covers those). Full type-derivation
// of DocmostClientLike from this class is deferred (see the staged plan in
// docmost-client.loader.ts): the package emits no declarations and the real
// (inferred, concrete) return types conflict with the host's loose
@@ -77,14 +76,6 @@ const HOST_CONTRACT_METHODS = [
"restorePageVersion",
"transformPage",
"stashPage",
// write (image / footnote) — MCP-only until #410 promoted them to in-app tools
"insertImage",
"replaceImage",
"insertFootnote",
// draw.io diagrams (#423, stage 1) — read + create + optimistic-locked update
"drawioGet",
"drawioCreate",
"drawioUpdate",
// write (comment)
"createComment",
"resolveComment",
@@ -1,348 +0,0 @@
import { test, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
acquireCollabSession,
destroyAllSessions,
__setCollabProviderFactory,
__sessionCountForTests,
} from "../../build/lib/collab-session.js";
import { withPageLock } from "../../build/lib/page-lock.js";
// A stand-in for HocuspocusProvider: it shares the ydoc (so the real yjs
// read/transform/write in CollabSession.mutate runs unchanged), auto-completes
// the connect+sync handshake on a microtask (unaffected by mock timers), and
// exposes hooks to drive disconnect/close/auth-failure and the unsyncedChanges
// ack. There is no collaboration server in the test env, so every test drives
// the provider through this fake.
class FakeProvider extends EventEmitter {
static instances = [];
static connectCount = 0;
static reset() {
FakeProvider.instances = [];
FakeProvider.connectCount = 0;
}
static last() {
return FakeProvider.instances[FakeProvider.instances.length - 1];
}
constructor(config, opts = {}) {
super();
this.config = config;
this.ydoc = config.document;
this.synced = false;
this.unsyncedChanges = opts.unsynced ?? 0;
this.destroyed = false;
FakeProvider.instances.push(this);
FakeProvider.connectCount += 1;
if (opts.autoSync !== false) {
// Real HocuspocusProvider fires onSynced asynchronously after the
// handshake; a microtask reproduces that without depending on timers.
queueMicrotask(() => {
if (this.destroyed) return;
this.config.onConnect?.();
this.synced = true;
this.config.onSynced?.();
});
}
}
destroy() {
this.destroyed = true;
}
// --- test drivers ---
_disconnect() {
this.config.onDisconnect?.();
}
_close() {
this.config.onClose?.();
}
_authFail() {
this.config.onAuthenticationFailed?.();
}
_ack() {
this.unsyncedChanges = 0;
this.emit("unsyncedChanges", { number: 0 });
}
}
/** Build a provider factory that stamps every provider with the given opts. */
function factory(opts = {}) {
return (config) => new FakeProvider(config, opts);
}
/** A minimal, schema-valid ProseMirror doc for a write. */
function docWith(text) {
return {
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: String(text) }] },
],
};
}
const ENV_KEYS = [
"MCP_COLLAB_SESSION_IDLE_MS",
"MCP_COLLAB_SESSION_MAX_AGE_MS",
"MCP_COLLAB_SESSION_MAX_ENTRIES",
];
let savedEnv;
beforeEach(() => {
savedEnv = {};
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
FakeProvider.reset();
__setCollabProviderFactory(factory());
});
afterEach(() => {
destroyAllSessions();
__setCollabProviderFactory(null);
mock.timers.reset();
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
test("acquire opens one provider; reuse returns the SAME live session", async () => {
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(a, b, "same (wsUrl,page,token) must reuse the session");
assert.equal(FakeProvider.connectCount, 1, "exactly one connect/sync");
assert.equal(__sessionCountForTests(), 1);
});
test("N mutates on one page open the provider ONCE (coalesced series)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
for (let i = 0; i < 20; i++) {
const r = await session.mutate(() => docWith(`edit ${i}`));
assert.ok(r && r.doc, "each mutate resolves a MutationResult");
}
assert.equal(
FakeProvider.connectCount,
1,
"20 mutates must not reconnect — one live provider for the whole series",
);
});
test("mutate preserves the transform-abort no-op report (null transform)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const r = await session.mutate(() => null);
assert.equal(r.verify.changed, false);
assert.equal(r.verify.summary, "no changes (transform aborted)");
});
test("registry key includes the token: different tokens => different sessions", async () => {
const a = await acquireCollabSession("page-1", "tok-A", "http://h/api");
const b = await acquireCollabSession("page-1", "tok-B", "http://h/api");
assert.notEqual(a, b, "different tokens must never share a session");
assert.equal(FakeProvider.connectCount, 2);
assert.equal(__sessionCountForTests(), 2);
// Same token reuses.
const a2 = await acquireCollabSession("page-1", "tok-A", "http://h/api");
assert.equal(a, a2);
assert.equal(FakeProvider.connectCount, 2);
});
test("disconnect at any time kills the session and removes it from the registry", async () => {
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 1);
FakeProvider.last()._disconnect();
assert.equal(__sessionCountForTests(), 0, "dead session is deregistered");
// Re-acquire opens a brand new provider.
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b);
assert.equal(FakeProvider.connectCount, 2);
});
test("an in-flight mutate rejects with the connection-closed text on disconnect", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 })); // stay pending after write
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const p = session.mutate(() => docWith("x"));
// The write happened synchronously; persistence is pending. Now the socket drops.
FakeProvider.last()._disconnect();
await assert.rejects(
p,
/Collaboration connection closed before the update was persisted\/synced/,
);
});
test("auth failure rejects the pending open with the auth error text", async () => {
__setCollabProviderFactory(factory({ autoSync: false }));
const p = acquireCollabSession("page-1", "tok", "http://h/api");
// Let the provider be constructed, then fail auth.
await Promise.resolve();
FakeProvider.last()._authFail();
await assert.rejects(
p,
/Authentication failed for collaboration connection/,
);
assert.equal(__sessionCountForTests(), 0);
});
test("mutate-error invalidates the session (caller destroys; re-acquire is fresh)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
await assert.rejects(
session.mutate(() => {
throw new Error("afterText not found");
}),
/afterText not found/,
);
// The production caller destroys on failure; mirror that here.
session.destroy("mutate failed");
assert.equal(__sessionCountForTests(), 0);
const fresh = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(fresh, session);
assert.equal(FakeProvider.connectCount, 2);
});
test("a pending write resolves when the server acks (unsyncedChanges -> 0)", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 }));
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
const p = session.mutate(() => docWith("y"));
FakeProvider.last()._ack();
const r = await p;
assert.ok(r.doc);
});
test("concurrent mutate on one session: the second rejects, the first is unaffected", async () => {
__setCollabProviderFactory(factory({ unsynced: 1 })); // first stays pending after write
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
// First mutate: write happens synchronously, persistence ack still pending.
const first = session.mutate(() => docWith("first"));
// Second overlapping mutate on the SAME session must fail fast.
await assert.rejects(
session.mutate(() => docWith("second")),
/mutate already in-flight; caller must serialize \(hold the page lock\)/,
);
// The first op is untouched: its rejector was not clobbered. Ack it now.
FakeProvider.last()._ack();
const r = await first;
assert.ok(r.doc, "the first in-flight mutate still resolves on its own ack");
assert.equal(FakeProvider.connectCount, 1, "no reconnect from the rejected overlap");
});
test("sequential mutates on one session both succeed (the guard doesn't break serialized use)", async () => {
const session = await acquireCollabSession("page-1", "tok", "http://h/api");
// Await the first fully, then run the second: inflightReject was cleared by
// localFinish before the first settled, so the guard is clear for the second.
const r1 = await session.mutate(() => docWith("one"));
assert.ok(r1.doc, "first sequential mutate resolves");
const r2 = await session.mutate(() => docWith("two"));
assert.ok(r2.doc, "second sequential mutate resolves — guard not tripped");
assert.equal(FakeProvider.connectCount, 1, "sequential mutates reuse one provider");
});
test("connect timeout rejects with the connect-timeout text and fires the metric hook", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
__setCollabProviderFactory(factory({ autoSync: false }));
let metricFired = 0;
const p = acquireCollabSession("page-1", "tok", "http://h/api", {
onConnectTimeout: () => {
metricFired += 1;
},
});
mock.timers.tick(25000);
await assert.rejects(p, /Connection timeout to collaboration server/);
assert.equal(metricFired, 1);
assert.equal(__sessionCountForTests(), 0);
});
test("idle TTL destroys the session; re-acquire reconnects", async () => {
mock.timers.enable({ apis: ["setTimeout"] });
process.env.MCP_COLLAB_SESSION_IDLE_MS = "1000";
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 1);
mock.timers.tick(1000); // idle fires
assert.equal(__sessionCountForTests(), 0, "idle timeout destroyed it");
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b);
assert.equal(FakeProvider.connectCount, 2);
});
test("max age is enforced at acquire (destroy + open fresh), idle held off", async () => {
mock.timers.enable({ apis: ["setTimeout", "Date"] });
process.env.MCP_COLLAB_SESSION_MAX_AGE_MS = "1000";
process.env.MCP_COLLAB_SESSION_IDLE_MS = "10000000"; // never fires in this test
const a = await acquireCollabSession("page-1", "tok", "http://h/api");
mock.timers.tick(2000); // past max age, below idle
const b = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(a, b, "a session past its max age must be replaced");
assert.equal(FakeProvider.connectCount, 2);
});
test("registry cap: least-recently-used session is destroy-evicted", async () => {
process.env.MCP_COLLAB_SESSION_MAX_ENTRIES = "2";
const s1 = await acquireCollabSession("page-1", "tok", "http://h/api");
const p1prov = FakeProvider.last();
const s2 = await acquireCollabSession("page-2", "tok", "http://h/api");
const p2prov = FakeProvider.last();
assert.equal(__sessionCountForTests(), 2);
// Touch page-1 so page-2 becomes the least-recently-used entry.
assert.equal(await acquireCollabSession("page-1", "tok", "http://h/api"), s1);
assert.equal(FakeProvider.connectCount, 2, "reuse must not reconnect");
// A third distinct page (cap 2) must evict the LRU (page-2), not page-1.
const s3 = await acquireCollabSession("page-3", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 2);
assert.equal(FakeProvider.connectCount, 3);
assert.equal(p2prov.destroyed, true, "the LRU provider was destroy-evicted");
assert.equal(p1prov.destroyed, false, "the MRU page-1 survived");
// page-1 still reused (survived), page-3 still reused.
assert.equal(await acquireCollabSession("page-1", "tok", "http://h/api"), s1);
assert.equal(await acquireCollabSession("page-3", "tok", "http://h/api"), s3);
assert.equal(FakeProvider.connectCount, 3, "no extra reconnects");
});
test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)", async () => {
process.env.MCP_COLLAB_SESSION_IDLE_MS = "0";
const s1 = await acquireCollabSession("page-1", "tok", "http://h/api");
// Never registered (ephemeral).
assert.equal(__sessionCountForTests(), 0);
const r1 = await s1.mutate(() => docWith("a"));
assert.ok(r1.doc);
// After its single op the ephemeral session self-destroyed.
assert.equal(FakeProvider.instances[0].destroyed, true);
// A second op opens a BRAND NEW provider (no reuse).
const s2 = await acquireCollabSession("page-1", "tok", "http://h/api");
assert.notEqual(s1, s2);
assert.equal(FakeProvider.connectCount, 2);
});
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
const pageId = "page-lock";
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
// each going through the non-locking acquireCollabSession.
const result = await withPageLock(pageId, async () => {
// pass 1: read-only scan (transform returns null -> no write)
const scan = await acquireCollabSession(pageId, "tok", "http://h/api");
const scanRes = await scan.mutate(() => null);
assert.equal(scanRes.verify.changed, false);
// pass 2: the actual write, same held lock
const write = await acquireCollabSession(pageId, "tok", "http://h/api");
return write.mutate(() => docWith("repointed"));
});
assert.ok(result.doc, "the locked scan+write completed without deadlock");
assert.equal(
FakeProvider.connectCount,
1,
"both passes under the held lock reuse ONE live session",
);
});
test("destroyAllSessions tears down every cached session", async () => {
await acquireCollabSession("page-1", "tok", "http://h/api");
await acquireCollabSession("page-2", "tok", "http://h/api");
assert.equal(__sessionCountForTests(), 2);
const provs = [...FakeProvider.instances];
destroyAllSessions();
assert.equal(__sessionCountForTests(), 0);
assert.ok(provs.every((p) => p.destroyed), "all providers destroyed");
});

Some files were not shown because too many files have changed in this diff Show More