Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 793b51234e | |||
| 53d367662b | |||
| f0334d7f9b |
+5
-25
@@ -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).
|
||||
|
||||
@@ -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
|
||||
Vendored
+2
-2
@@ -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": []
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-специфичные миграции только
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -37,14 +37,6 @@ 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,
|
||||
@@ -322,27 +314,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.
|
||||
@@ -965,9 +936,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
|
||||
|
||||
@@ -200,74 +200,6 @@ describe("ChatThread — send now (#198)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// #388: the editor selection is snapshotted at send time and nested inside
|
||||
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
||||
// fresh snapshot.
|
||||
describe("ChatThread — editor selection wiring (#388)", () => {
|
||||
beforeEach(resetState);
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderWithSelection(props: {
|
||||
openPage?: { id: string; title: string } | null;
|
||||
getEditorSelection?: () => unknown;
|
||||
}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ChatThread
|
||||
chatId="c1"
|
||||
initialRows={[]}
|
||||
openPage={props.openPage as never}
|
||||
getEditorSelection={props.getEditorSelection as never}
|
||||
onTurnFinished={vi.fn()}
|
||||
onResumeFallback={vi.fn()}
|
||||
onServerStop={vi.fn()}
|
||||
/>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
it("nests the snapshot from the getter into openPage.selection at send time", () => {
|
||||
const selection = { text: "fix this", blockIds: ["b1"], before: "a " };
|
||||
renderWithSelection({
|
||||
openPage: { id: "p1", title: "Doc" },
|
||||
getEditorSelection: () => selection,
|
||||
});
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection });
|
||||
});
|
||||
|
||||
it("sends selection: null when the getter returns null", () => {
|
||||
renderWithSelection({
|
||||
openPage: { id: "p1", title: "Doc" },
|
||||
getEditorSelection: () => null,
|
||||
});
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null });
|
||||
});
|
||||
|
||||
it("does not send selection at all on a non-page route (openPage null)", () => {
|
||||
const getter = vi.fn(() => ({ text: "sel" }));
|
||||
renderWithSelection({ openPage: null, getEditorSelection: getter });
|
||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
||||
expect(prep({ messages: [], body: {} }).body.openPage).toBeNull();
|
||||
// The getter must not even be consulted when there is no page.
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||
beforeEach(resetState);
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
mergeById,
|
||||
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
||||
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
|
||||
import {
|
||||
dequeue,
|
||||
enqueueMessage,
|
||||
@@ -70,10 +69,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). */
|
||||
@@ -156,7 +151,6 @@ export default function ChatThread({
|
||||
threadKey,
|
||||
initialRows,
|
||||
openPage,
|
||||
getEditorSelection,
|
||||
roleId,
|
||||
roles,
|
||||
onRolePicked,
|
||||
@@ -232,14 +226,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.
|
||||
@@ -402,16 +388,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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 onConnect→connected 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);
|
||||
|
||||
|
||||
@@ -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 client→server 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,
|
||||
@@ -178,7 +171,6 @@ export class PersistenceExtension implements Extension {
|
||||
const dbState = new Uint8Array(page.ydoc);
|
||||
|
||||
Y.applyUpdate(doc, dbState);
|
||||
observeCollabLoad(dbState.length, (performance.now() - startedAt) / 1000);
|
||||
return doc;
|
||||
}
|
||||
|
||||
@@ -192,42 +184,26 @@ export class PersistenceExtension implements Extension {
|
||||
tiptapExtensions,
|
||||
);
|
||||
|
||||
// Reuse this single encode for the size label (do NOT add a second one).
|
||||
const encoded = Y.encodeStateAsUpdate(ydoc);
|
||||
observeCollabLoad(
|
||||
encoded.byteLength,
|
||||
(performance.now() - startedAt) / 1000,
|
||||
);
|
||||
Y.encodeStateAsUpdate(ydoc);
|
||||
return ydoc;
|
||||
}
|
||||
|
||||
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||
observeCollabLoad(0, (performance.now() - startedAt) / 1000);
|
||||
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);
|
||||
@@ -479,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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
rowToUiMessage,
|
||||
prepareAgentStep,
|
||||
flushAssistant,
|
||||
stripNulChars,
|
||||
chatStreamMetadata,
|
||||
accumulateStepUsage,
|
||||
isInterruptResume,
|
||||
@@ -29,13 +28,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 +41,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 +49,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 +75,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 +85,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');
|
||||
@@ -452,45 +448,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
|
||||
@@ -741,7 +698,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 () => {
|
||||
@@ -787,14 +743,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 () => {
|
||||
@@ -807,55 +757,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();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,10 +43,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 +54,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 +172,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 +189,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
|
||||
@@ -459,18 +365,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 +390,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 +676,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'
|
||||
@@ -1425,19 +1279,12 @@ export class AiChatService implements OnModuleInit {
|
||||
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;
|
||||
@@ -1642,25 +1489,16 @@ type StepLike = {
|
||||
/**
|
||||
* 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;
|
||||
@@ -1703,9 +1541,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;
|
||||
}
|
||||
@@ -1892,45 +1730,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
|
||||
@@ -2000,16 +1799,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,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,38 +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 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,33 +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>>;
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,24 +21,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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 — нормативная спека для реализатора; это приложение — аудит-след (не нормативно).
|
||||
@@ -1,270 +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 at all.** Its error text is nowhere
|
||||
in the DB — not in `tool_calls`, `content`, or `metadata`. It is shown live in
|
||||
the UI only. So `isError` / `success=false` scans under-report by design.
|
||||
- To find where agents fail you need **three** sources: (1) soft-failure markers in
|
||||
`tool_calls`, (2) the orphan-gap proxy for thrown errors, (3) server logs / the
|
||||
live UI for the actual error text.
|
||||
|
||||
## 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 **only** keys that ever appear on an element are `toolName`, `input`, `output`.
|
||||
There is no `state`, no `errorText`, no `type`. Consequences:
|
||||
|
||||
1. **Real invocation count = elements that have `output`.** Counting every element
|
||||
double-counts (you get ~2× and a spurious "~50% of every tool has no output").
|
||||
2. **Pairing:** a successful call = a `tool-call` part followed by its `tool-result`
|
||||
part. 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 → NOT PERSISTED ❌ (the trap)
|
||||
|
||||
When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell`
|
||||
→ `Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the
|
||||
runtime writes **no `tool-result` part**. The orphaned `tool-call` part stays, but
|
||||
the error text is **nowhere in the DB**. It is streamed to the UI live and (until
|
||||
rotation) to server logs — that is it.
|
||||
|
||||
So any query like `count(*) FILTER (WHERE output.success = false)` will happily
|
||||
return **0** for `patchNode` even when the chat is visibly full of red failures.
|
||||
That is survivorship bias, not reliability.
|
||||
|
||||
The only DB-side proxy for a thrown error is an **orphan**: a `tool-call` part with
|
||||
no matching `tool-result`. Caveat: 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 from real errors. Treat the
|
||||
orphan gap as an *upper bound* on hard errors, 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.
|
||||
|
||||
### 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-error proxy — orphan gap per tool, WITH a spread column** (call parts minus
|
||||
result parts, plus how many distinct chats the gap is spread across):
|
||||
|
||||
```sql
|
||||
WITH parts AS (
|
||||
SELECT m.chat_id, elem->>'toolName' AS tool,
|
||||
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
|
||||
(elem ? 'output') 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;
|
||||
```
|
||||
|
||||
**`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**. So thrown-tool error text is only reliably
|
||||
caught **in real time** (or in the live chat UI, which renders the failed part with
|
||||
its message). There is no durable, queryable store of hard tool errors today — if you
|
||||
need one, that is a feature to add (persist `output-error` parts, or emit a
|
||||
`tool_calls_total{tool,status}` metric to VictoriaMetrics).
|
||||
|
||||
## Gotchas checklist
|
||||
|
||||
- [ ] Counting every `tool_calls` element → **2× overcount**. Count elements with `output`.
|
||||
- [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors aren't persisted.
|
||||
- [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence.
|
||||
- [ ] Orphan gap mixes thrown errors **and** aborted runs — split by tool before concluding.
|
||||
- [ ] `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).
|
||||
@@ -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 () => {
|
||||
|
||||
+16
-138
@@ -40,7 +40,6 @@ import {
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
@@ -60,12 +59,10 @@ import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||
import { diffDocs, summarizeChange } from "./lib/diff.js";
|
||||
import {
|
||||
applyAnchorInDoc,
|
||||
canAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
getAnchoredText,
|
||||
resolveAnchorSelection,
|
||||
normalizeForMatch,
|
||||
} from "./lib/comment-anchor.js";
|
||||
import { closestBlockHint } from "./lib/text-normalize.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
@@ -140,15 +137,6 @@ export type DocmostMcpConfig = { apiUrl: string } & (
|
||||
has?: (uri: string) => boolean;
|
||||
evict?: (uri: string) => void;
|
||||
};
|
||||
// Dependency-neutral metrics sink. When present, the client emits generic
|
||||
// (name, value, labels) samples; the HOST maps those names onto its own
|
||||
// metrics registry (the package never depends on prom-client or the server).
|
||||
// Absent in standalone/stdio mode → the client is a complete no-op here.
|
||||
onMetric?: (
|
||||
name: string,
|
||||
value: number,
|
||||
labels?: Record<string, string>,
|
||||
) => void;
|
||||
};
|
||||
|
||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||
@@ -185,11 +173,6 @@ export class DocmostClient {
|
||||
// op's image blobs if the final doc put throws. Null when the sink omits them.
|
||||
private sandboxHas: ((uri: string) => boolean) | null = null;
|
||||
private sandboxEvict: ((uri: string) => void) | null = null;
|
||||
// Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric).
|
||||
// Null on the legacy positional form and whenever the host omits it → no-op.
|
||||
private onMetricFn:
|
||||
| ((name: string, value: number, labels?: Record<string, string>) => void)
|
||||
| null = null;
|
||||
// In-flight login dedup: when the token expires, the 401 interceptor,
|
||||
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
|
||||
// can all call login() at once. Memoizing a single promise collapses that
|
||||
@@ -239,8 +222,6 @@ export class DocmostClient {
|
||||
this.sandboxHas = config.sandbox.has ?? null;
|
||||
this.sandboxEvict = config.sandbox.evict ?? null;
|
||||
}
|
||||
// Legacy positional form carries no onMetric → null (complete no-op).
|
||||
this.onMetricFn = config.onMetric ?? null;
|
||||
this.client = axios.create({
|
||||
baseURL: this.apiUrl,
|
||||
// Default request timeout so a hung connection cannot wedge a per-page
|
||||
@@ -466,10 +447,6 @@ export class DocmostClient {
|
||||
};
|
||||
|
||||
connectTimer = setTimeout(() => {
|
||||
// Only the actual 25s collab connect timeout fires here — the agent's
|
||||
// collab connection to the server never became ready. This is the
|
||||
// connect-vs-unload signal; the other finish() paths must NOT emit it.
|
||||
this.onMetricFn?.("collab_connect_timeouts_total", 1);
|
||||
finish(new Error("Connection timeout to collaboration server"));
|
||||
}, CONNECT_TIMEOUT_MS);
|
||||
|
||||
@@ -2477,64 +2454,6 @@ export class DocmostClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
|
||||
private topLevelBlockTexts(doc: any): string[] {
|
||||
const content = doc && Array.isArray(doc.content) ? doc.content : [];
|
||||
return content
|
||||
.map((b: any) => blockPlainText(b))
|
||||
.filter((t: string) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when per-block anchoring failed but the (normalized) selection DOES
|
||||
* appear in the blocks' joined plain text — i.e. it straddles a block
|
||||
* boundary. Blocks are joined with a newline (collapsed to one space by
|
||||
* normalizeForMatch) so a selection whose parts are separated by a paragraph
|
||||
* break still matches. Callers only reach here after single-block anchoring
|
||||
* (incl. the markdown-strip fallback) has already failed.
|
||||
*/
|
||||
private selectionSpansMultipleBlocks(
|
||||
blockTexts: string[],
|
||||
selection: string,
|
||||
): boolean {
|
||||
const normSel = normalizeForMatch(selection).norm.trim();
|
||||
if (normSel.length === 0) return false;
|
||||
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
|
||||
return joined.indexOf(normSel) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the actionable error for a create_comment anchor MISS, porting
|
||||
* edit_page_text's self-correction affordances: an explicit "spans multiple
|
||||
* blocks" message when the selection straddles a block boundary, otherwise a
|
||||
* "closest block text" hint quoting the block that holds the selection's
|
||||
* longest token. `live` switches the wording between the pre-check (reading the
|
||||
* persisted page) and the post-create live-anchor failure (which rolls back).
|
||||
*/
|
||||
private anchorNotFoundError(
|
||||
doc: any,
|
||||
selection: string,
|
||||
live: boolean,
|
||||
): Error {
|
||||
const blockTexts = this.topLevelBlockTexts(doc);
|
||||
const rolled = live ? " The comment was rolled back." : "";
|
||||
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
|
||||
return new Error(
|
||||
"create_comment: the selection spans multiple blocks; anchor on a " +
|
||||
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
|
||||
rolled,
|
||||
);
|
||||
}
|
||||
const where = live ? "in the live document" : "in the page";
|
||||
return new Error(
|
||||
`create_comment: could not find the selection text ${where} to anchor ` +
|
||||
"the comment. Provide the EXACT contiguous text from a single " +
|
||||
"paragraph/block (<=250 chars)." +
|
||||
closestBlockHint(blockTexts, selection) +
|
||||
rolled,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an inline comment anchored to its `selection` text, or a reply.
|
||||
*
|
||||
@@ -2596,10 +2515,6 @@ export class DocmostClient {
|
||||
// Captured in the pre-check below (which already reads the page) and used as
|
||||
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
||||
let anchoredSelection: string | null = null;
|
||||
// Set when the anchor matched only after stripping markdown from the
|
||||
// selection (the strip fallback); surfaced as a soft warning like
|
||||
// edit_page_text does, so a stale-markdown selection is flagged.
|
||||
let anchorNormalized = false;
|
||||
|
||||
// For a top-level comment, fail BEFORE creating anything when the selection
|
||||
// is not present in the persisted document — this avoids leaving an orphan
|
||||
@@ -2615,7 +2530,10 @@ export class DocmostClient {
|
||||
// rejected BEFORE creating the comment.
|
||||
const matches = countAnchorMatches(page.content, selection);
|
||||
if (matches === 0) {
|
||||
throw this.anchorNotFoundError(page.content, selection, false);
|
||||
throw new Error(
|
||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
||||
);
|
||||
}
|
||||
if (matches >= 2) {
|
||||
throw new Error(
|
||||
@@ -2629,27 +2547,18 @@ export class DocmostClient {
|
||||
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
||||
// the raw agent selection below rather than crash.
|
||||
anchoredSelection = getAnchoredText(page.content, selection);
|
||||
anchorNormalized = resolveAnchorSelection(
|
||||
page.content,
|
||||
selection,
|
||||
).normalized;
|
||||
} else {
|
||||
const resolved = resolveAnchorSelection(page.content, selection);
|
||||
if (!resolved.found) {
|
||||
throw this.anchorNotFoundError(page.content, selection, false);
|
||||
}
|
||||
anchorNormalized = resolved.normalized;
|
||||
} else if (!canAnchorInDoc(page.content, selection)) {
|
||||
throw new Error(
|
||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
|
||||
// swallow read/network errors so the live anchor step can still try (and
|
||||
// enforce) anchoring.
|
||||
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
|
||||
// errors so the live anchor step can still try (and enforce) anchoring.
|
||||
if (
|
||||
e instanceof Error &&
|
||||
(e.message.startsWith("create_comment: could not find the selection") ||
|
||||
e.message.startsWith(
|
||||
"create_comment: the selection spans multiple blocks",
|
||||
) ||
|
||||
e.message.startsWith(
|
||||
"create_comment: the suggestion's selection is ambiguous",
|
||||
))
|
||||
@@ -2721,10 +2630,6 @@ export class DocmostClient {
|
||||
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||
let ambiguousInLiveDoc = false;
|
||||
// Captured inside the transform on a not-found abort, so the rollback path
|
||||
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||
// LIVE document (the pre-check page is not in scope there).
|
||||
let liveNotFoundError: Error | null = null;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
@@ -2752,13 +2657,6 @@ export class DocmostClient {
|
||||
const liveCount = countAnchorMatches(doc, selection as string);
|
||||
if (liveCount !== 1) {
|
||||
ambiguousInLiveDoc = liveCount >= 2;
|
||||
if (liveCount === 0) {
|
||||
liveNotFoundError = this.anchorNotFoundError(
|
||||
doc,
|
||||
selection as string,
|
||||
true,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2768,11 +2666,6 @@ export class DocmostClient {
|
||||
}
|
||||
// Selection text not found in the LIVE document: abort the write. The
|
||||
// rollback + throw below turns this into a hard error.
|
||||
liveNotFoundError = this.anchorNotFoundError(
|
||||
doc,
|
||||
selection as string,
|
||||
true,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
@@ -2789,28 +2682,13 @@ export class DocmostClient {
|
||||
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||
// and surface a hard error.
|
||||
await this.safeDeleteComment(newCommentId);
|
||||
if (ambiguousInLiveDoc) {
|
||||
throw new Error(
|
||||
"create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
|
||||
);
|
||||
}
|
||||
throw (
|
||||
liveNotFoundError ??
|
||||
new Error(
|
||||
"create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
)
|
||||
throw new Error(
|
||||
ambiguousInLiveDoc
|
||||
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
|
||||
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
);
|
||||
}
|
||||
|
||||
// Soft warning (like edit_page_text): the selection only matched after
|
||||
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||
if (anchorNormalized) {
|
||||
result.warning =
|
||||
"The selection matched only after stripping markdown syntax; the comment " +
|
||||
"was anchored on the document's plain text. Copy the selection verbatim " +
|
||||
"from get_page / search_in_page output to avoid this.";
|
||||
}
|
||||
|
||||
result.anchored = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
+95
-58
@@ -64,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
|
||||
@@ -105,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
|
||||
@@ -427,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,
|
||||
@@ -443,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,
|
||||
@@ -768,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}".`;
|
||||
}
|
||||
|
||||
@@ -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,116 +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(),
|
||||
}),
|
||||
},
|
||||
} satisfies Record<string, SharedToolSpec>;
|
||||
|
||||
@@ -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,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,10 +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",
|
||||
// write (comment)
|
||||
"createComment",
|
||||
"resolveComment",
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
applyAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
getAnchoredText,
|
||||
resolveAnchorSelection,
|
||||
} from "../../build/lib/comment-anchor.js";
|
||||
|
||||
const COMMENT_ID = "cmt-123";
|
||||
@@ -309,70 +308,3 @@ test("getAnchoredText returns null when the selection does not anchor", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
|
||||
assert.equal(getAnchoredText(doc, "not present"), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #408 MARKDOWN-STRIP FALLBACK. A selection copied with inline markdown still
|
||||
// carries `**`/`` ` ``/`[t](u)` markers the plain document text lacks. When the
|
||||
// verbatim selection anchors nowhere, all four entry points retry with the
|
||||
// markdown stripped — consistently, so the suggestion-uniqueness gate stays
|
||||
// coherent — while what gets STORED remains the raw document substring.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("a markdown-styled selection anchors against plain doc text via the strip fallback", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "a bold word here" }]);
|
||||
// The agent quoted "**bold** word" from a styled view; the doc is plain text.
|
||||
const sel = "**bold** word";
|
||||
const resolved = resolveAnchorSelection(doc, sel);
|
||||
assert.equal(resolved.found, true, "strip fallback finds the anchor");
|
||||
assert.equal(resolved.normalized, true, "reports the soft-warning flag");
|
||||
assert.equal(canAnchorInDoc(doc, sel), true);
|
||||
assert.equal(countAnchorMatches(doc, sel), 1);
|
||||
|
||||
const ok = applyAnchorInDoc(doc, sel, COMMENT_ID);
|
||||
assert.equal(ok, true);
|
||||
const marked = doc.content[0].content.filter((p) => commentMark(p));
|
||||
assert.equal(marked.map((m) => m.text).join(""), "bold word",
|
||||
"the mark lands on the plain-text span");
|
||||
});
|
||||
|
||||
test("getAnchoredText stores the RAW doc substring even when matched via the strip fallback", () => {
|
||||
// Doc uses a smart apostrophe; the agent typed ASCII + markdown emphasis.
|
||||
const doc = paragraphDoc([{ type: "text", text: "it’s bold now" }]);
|
||||
const stored = getAnchoredText(doc, "it's **bold**");
|
||||
assert.equal(stored, "it’s bold",
|
||||
"stored selection is the raw document text, not the stripped/ASCII locator");
|
||||
});
|
||||
|
||||
test("the strip fallback does not flip a raw-unique selection to ambiguous", () => {
|
||||
// "config" appears twice, but the raw phrase "config value" appears once.
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "the config value here" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "another config here" }] },
|
||||
],
|
||||
};
|
||||
// Raw phrase is unique -> exactly 1, and no strip happens (nothing to strip).
|
||||
assert.equal(countAnchorMatches(doc, "config value"), 1);
|
||||
assert.equal(resolveAnchorSelection(doc, "config value").normalized, false);
|
||||
});
|
||||
|
||||
test("EXACT WINS: a raw match short-circuits the strip fallback (count reflects raw)", () => {
|
||||
// A literal "**" run exists raw once; its stripped form would also appear.
|
||||
const doc = paragraphDoc([{ type: "text", text: "use **stars** and stars" }]);
|
||||
// Raw "**stars**" occurs once -> count 1 from the verbatim locator; the
|
||||
// fallback (which would find two "stars") never runs.
|
||||
assert.equal(countAnchorMatches(doc, "**stars**"), 1);
|
||||
assert.equal(resolveAnchorSelection(doc, "**stars**").normalized, false);
|
||||
});
|
||||
|
||||
test("a markdown selection whose stripped form is ambiguous is counted as ambiguous", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "first config here" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "second config here" }] },
|
||||
],
|
||||
};
|
||||
// Verbatim "**config**" matches nothing; stripped "config" matches twice.
|
||||
assert.equal(countAnchorMatches(doc, "**config**"), 2);
|
||||
});
|
||||
|
||||
@@ -140,36 +140,6 @@ test("typo fix wrapped in markdown still applies (not refused)", () => {
|
||||
assert.deepEqual(node.marks, [{ type: "bold" }]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (iv) #410 footnote token: a `replace` containing `^[...]` is refused into
|
||||
// failed[] (it would be written as a LITERAL string, never a real footnote).
|
||||
// Nothing is applied; the reason points at insert_footnote.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("replace containing a `^[...]` footnote token is refused, not applied", () => {
|
||||
const input = doc(paragraph(textNode("The claim stands.")));
|
||||
const snapshot = JSON.parse(JSON.stringify(input));
|
||||
|
||||
const { doc: out, results, failed } = applyTextEdits(input, [
|
||||
{ find: "The claim stands.", replace: "The claim stands.^[See source, p.42]" },
|
||||
]);
|
||||
|
||||
assert.equal(results.length, 0, "nothing applied");
|
||||
assert.equal(failed.length, 1, "one refused edit");
|
||||
assert.equal(failed[0].find, "The claim stands.");
|
||||
assert.match(failed[0].reason, /insert_footnote/);
|
||||
// The document is byte-for-byte untouched — no literal `^[` was written.
|
||||
assert.deepEqual(out, snapshot);
|
||||
});
|
||||
|
||||
test("a plain replace with no footnote token still applies (no false positive)", () => {
|
||||
const input = doc(paragraph(textNode("a caret ^ and a bracket ] apart")));
|
||||
const { results, failed } = applyTextEdits(input, [
|
||||
{ find: "apart", replace: "separate" },
|
||||
]);
|
||||
assert.equal(failed.length, 0, "not refused");
|
||||
assert.equal(results.length, 1, "applied");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// A plain text fix is unaffected by the refuse logic.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -25,11 +25,9 @@ test("nested bulletList with 3 children keeps all children indented under the pa
|
||||
),
|
||||
);
|
||||
|
||||
// Block children of a list item are blank-line separated (loose list) since
|
||||
// the #351 converter fix; the sublist stays nested at the 2-col marker column.
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
"- Parent\n\n - A\n - B\n - C",
|
||||
"- Parent\n - A\n - B\n - C",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -43,10 +41,9 @@ test("nested list under an ordered item indents 3 spaces", () => {
|
||||
),
|
||||
);
|
||||
|
||||
// Blank-line separated block children (loose list) per the #351 converter fix.
|
||||
assert.equal(
|
||||
convertProseMirrorToMarkdown(input),
|
||||
"1. Parent\n\n - Child",
|
||||
"1. Parent\n - Child",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { timeToolHandler } from "../../build/index.js";
|
||||
|
||||
// #402 — the registerTool wrapper times every tool through a single choke point
|
||||
// and feeds the host's dependency-neutral onMetric sink. These assert the
|
||||
// timing contract without a live transport (the factory monkeypatches
|
||||
// server.registerTool with exactly this helper).
|
||||
|
||||
test("times a tool and preserves the handler's return value", async () => {
|
||||
const calls = [];
|
||||
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
|
||||
|
||||
const handler = async (arg) => ({ ok: true, echo: arg });
|
||||
const wrapped = timeToolHandler("get_page", handler, onMetric);
|
||||
|
||||
const result = await wrapped("hello");
|
||||
// Return value passes through untouched.
|
||||
assert.deepEqual(result, { ok: true, echo: "hello" });
|
||||
|
||||
// Exactly one sample, correct name/labels, numeric non-negative duration.
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].name, "mcp_tool_duration_seconds");
|
||||
assert.deepEqual(calls[0].labels, { tool: "get_page" });
|
||||
assert.equal(typeof calls[0].value, "number");
|
||||
assert.ok(calls[0].value >= 0, "duration must be non-negative seconds");
|
||||
});
|
||||
|
||||
test("standalone (no onMetric) is a transparent pass-through", async () => {
|
||||
const handler = async (a, b) => a + b;
|
||||
const wrapped = timeToolHandler("noop_tool", handler, undefined);
|
||||
|
||||
// No throw, result unchanged, and nothing to observe.
|
||||
const result = await wrapped(2, 3);
|
||||
assert.equal(result, 5);
|
||||
});
|
||||
|
||||
test("still observes on throw, then rethrows the original error", async () => {
|
||||
const calls = [];
|
||||
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
|
||||
|
||||
const boom = new Error("handler failed");
|
||||
const handler = async () => {
|
||||
throw boom;
|
||||
};
|
||||
const wrapped = timeToolHandler("boom_tool", handler, onMetric);
|
||||
|
||||
await assert.rejects(() => wrapped(), (err) => err === boom);
|
||||
|
||||
// Observed exactly once despite the throw.
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].name, "mcp_tool_duration_seconds");
|
||||
assert.deepEqual(calls[0].labels, { tool: "boom_tool" });
|
||||
});
|
||||
|
||||
test("standalone still rethrows the original error (no swallow, no observe)", async () => {
|
||||
const boom = new Error("standalone failure");
|
||||
const wrapped = timeToolHandler(
|
||||
"boom_standalone",
|
||||
async () => {
|
||||
throw boom;
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
await assert.rejects(() => wrapped(), (err) => err === boom);
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
# @docmost/prosemirror-markdown
|
||||
|
||||
The single, canonical **ProseMirror ↔ Markdown converter** plus the Docmost
|
||||
schema mirror (#293/#345). Headless and framework-free: no React, no browser
|
||||
runtime. There is exactly ONE copy of this converter in the repo, consumed by:
|
||||
|
||||
- `packages/mcp` (the MCP server),
|
||||
- `packages/git-sync` (two-way Git sync),
|
||||
- `apps/server` (server-side markdown import/export, #345).
|
||||
|
||||
`src/lib/docmost-schema.ts` **mirrors** the upstream Tiptap schema that lives in
|
||||
`packages/editor-ext`. The mirror is not free-floating: `serializer-contract.test.ts`
|
||||
guards the boundary — every schema node must have a converter case, so a drift
|
||||
between `editor-ext` and this package surfaces as a failing test rather than a
|
||||
silent divergence.
|
||||
|
||||
## Why byte-stability matters
|
||||
|
||||
Git sync exports a page to markdown, and re-imports it on the next pull. If
|
||||
`export → import → export` is not **byte-stable**, every pull rewrites files
|
||||
that nobody edited, and the user's history churns with phantom diffs. So the
|
||||
converter is held to more than "it roughly round-trips": the second export pass
|
||||
must be a byte-for-byte fixpoint. That is what the property suite below proves.
|
||||
|
||||
## Two golden layers (do not mix them)
|
||||
|
||||
1. **Corpus fixtures** — `test/fixtures/corpus/`. A fixed, hand-curated set of
|
||||
representative documents (headings, marks, lists, tables, diagrams, columns,
|
||||
details, mentions, …). These are the readable, deterministic "known-good"
|
||||
snapshots. Edit them deliberately.
|
||||
|
||||
2. **Generative property suite** — `test/generative/`. fast-check draws random
|
||||
documents and asserts invariants over them. Two entry points:
|
||||
- `flat-roundtrip.property.test.ts` — flat documents, attribute-level fuzzing.
|
||||
- `nested-roundtrip.property.test.ts` — deeply nested structures.
|
||||
|
||||
The invariants (**P1–P4**):
|
||||
- **P1** — semantic round-trip: `mdToPm(pmToMd(doc))` is canonically equal to
|
||||
`doc` (no data loss for the round-trip-supported space).
|
||||
- **P2** — byte fixpoint: `pmToMd(mdToPm(pmToMd(doc))) === pmToMd(doc)` (the
|
||||
first pass may normalize once; the second pass must be a fixpoint).
|
||||
- **P3** — totality: neither converter throws; output is bounded.
|
||||
- **P4** — parser fuzz totality: for ANY input string, `markdownToProseMirror`
|
||||
does not throw and returns a schema-valid document.
|
||||
|
||||
These invariants are kept **STRICT** — no `it.fails`, skip, or weakening. A
|
||||
failure means the generator found a REAL converter bug.
|
||||
|
||||
## The counterexample process (the DoD)
|
||||
|
||||
This is the point of the generative layer. When a property run diverges:
|
||||
|
||||
1. **A property run surfaces a divergence** (locally, in CI, or in the nightly
|
||||
cron — see below).
|
||||
2. **fast-check shrinks it** to a minimal, human-readable counterexample and
|
||||
prints the reproducing seed.
|
||||
3. **Commit the shrunk doc as a permanent fixture** under
|
||||
`test/fixtures/counterexamples/`, with a matching case in
|
||||
`counterexamples.test.ts`. The fixture stays forever, as a regression pin.
|
||||
4. **FIX the converter** so the counterexample round-trips. **Never weaken a
|
||||
property to hide the bug.**
|
||||
5. If — and only if — a maintainer decides a particular markdown-representable
|
||||
loss is genuinely acceptable, it is recorded as an **explicit ACCEPTED /
|
||||
allowlist entry with a written reason**, not by silently relaxing an
|
||||
invariant.
|
||||
|
||||
### Attribute-coverage allowlist
|
||||
|
||||
`flat-roundtrip.property.test.ts` maintains `ATTR_VALUE_FUZZ_ALLOWLIST`. The
|
||||
suite asserts that every attribute in the live schema is EITHER value-fuzzed by
|
||||
the generator OR explicitly listed in this allowlist. This forces any newly
|
||||
added node attribute to be consciously classified — you cannot add an attr and
|
||||
leave it silently un-exercised; the coverage test fails until you either fuzz it
|
||||
or record why it is held out.
|
||||
|
||||
## Running
|
||||
|
||||
```sh
|
||||
# The full package suite (corpus + generative + contract tests):
|
||||
pnpm --filter @docmost/prosemirror-markdown test
|
||||
```
|
||||
|
||||
The generative suite honours two env knobs (invalid/empty → falls back to the
|
||||
default):
|
||||
|
||||
| Env var | Default (flat) | Default (nested) | Meaning |
|
||||
| -------------------- | -------------- | ---------------- | -------------------------------- |
|
||||
| `PROPERTY_SEED` | `20250705` | `20250705` | fast-check seed (reproducibility) |
|
||||
| `PROPERTY_NUM_RUNS` | `300` | `100` | runs per property |
|
||||
|
||||
```sh
|
||||
# Reproduce a specific counterexample seed with a bigger budget:
|
||||
PROPERTY_SEED=12345 PROPERTY_NUM_RUNS=5000 \
|
||||
pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/
|
||||
```
|
||||
|
||||
### Nightly cron
|
||||
|
||||
`.github/workflows/nightly-property.yml` runs the generative suite every night
|
||||
with `PROPERTY_NUM_RUNS` cranked to ~5000 and a **random** seed, to reach deeper
|
||||
counterexamples than a fixed-seed PR run can. On failure it files a Gitea issue
|
||||
containing the reproducing seed, the run count, and the tail of the output (the
|
||||
shrunk counterexample), which kicks off the counterexample → fixture process
|
||||
above. It can also be triggered manually (`workflow_dispatch`) with custom
|
||||
`num_runs` / `seed`.
|
||||
@@ -1006,8 +1006,6 @@ const Column = Node.create({
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: (el: HTMLElement) => {
|
||||
// Mirrors editor-ext (column.ts): width is a unitless flex-grow
|
||||
// number, so parse it to a Number for parity with the canonical schema.
|
||||
const value = el.getAttribute("data-width");
|
||||
return value ? parseFloat(value) : null;
|
||||
},
|
||||
|
||||
@@ -48,52 +48,6 @@ export interface ConvertProseMirrorToMarkdownOptions {
|
||||
dropResolvedCommentAnchors?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjacent sibling lists that share a markdown MARKER FAMILY re-parse as ONE
|
||||
* merged list — bulletList and taskList both emit `- ` markers (→ a single
|
||||
* `<ul>`), and two orderedLists both emit `1.` markers (→ a single `<ol>`). The
|
||||
* cross-type case is real data loss the editor CAN produce (e.g. a taskList
|
||||
* followed by a bulletList: the merged `<ul>` has a mix of checkbox and plain
|
||||
* items, so `bridgeTaskLists` refuses to convert it and every taskItem loses its
|
||||
* checkbox). Between two such adjacent list children we emit an empty HTML comment
|
||||
* `<!-- -->`: marked renders it as its own HTML block that interrupts the list, so
|
||||
* the two lists stay distinct; on import the comment is inert (parseAttachedComment
|
||||
* → null) and dropped by generateJSON, and re-export re-inserts it, so the marker
|
||||
* is byte-stable. It fires ONLY between two adjacent same-family list nodes — no
|
||||
* separator is emitted for any other join, so non-list output is unchanged.
|
||||
*/
|
||||
const LIST_MARKER_SEPARATOR = "<!-- -->";
|
||||
function listMarkerFamily(type: string | undefined): "ul" | "ol" | null {
|
||||
if (type === "bulletList" || type === "taskList") return "ul";
|
||||
if (type === "orderedList") return "ol";
|
||||
return null;
|
||||
}
|
||||
function adjacentListsMerge(
|
||||
prevType: string | undefined,
|
||||
curType: string | undefined,
|
||||
): boolean {
|
||||
const a = listMarkerFamily(prevType);
|
||||
return a !== null && a === listMarkerFamily(curType);
|
||||
}
|
||||
/**
|
||||
* Render each block child, inserting a `<!-- -->` separator entry between any two
|
||||
* adjacent same-marker-family list nodes (see LIST_MARKER_SEPARATOR). Callers
|
||||
* join the returned strings with their own context separator.
|
||||
*/
|
||||
function renderBlockChildren(
|
||||
children: any[],
|
||||
render: (n: any) => string,
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
let prevType: string | undefined;
|
||||
for (const child of children) {
|
||||
if (adjacentListsMerge(prevType, child?.type)) out.push(LIST_MARKER_SEPARATOR);
|
||||
out.push(render(child));
|
||||
prevType = child?.type;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ProseMirror/TipTap JSON content to Markdown
|
||||
* Supports all Docmost-specific node types and extensions
|
||||
@@ -393,15 +347,9 @@ export function convertProseMirrorToMarkdown(
|
||||
// lossless (the body survives) and byte-stable (it re-exports identically),
|
||||
// so it is deliberately not treated as data loss.
|
||||
const parts: string[] = [];
|
||||
let prevDocType: string | undefined;
|
||||
for (const child of nodeContent) {
|
||||
if (child?.type === "footnotesList") continue;
|
||||
// Keep adjacent same-family sibling lists distinct (see renderBlockChildren).
|
||||
if (adjacentListsMerge(prevDocType, child?.type)) {
|
||||
parts.push(LIST_MARKER_SEPARATOR);
|
||||
}
|
||||
parts.push(processNode(child));
|
||||
prevDocType = child?.type;
|
||||
}
|
||||
for (const [id, def] of footnoteDefs) {
|
||||
if (!referencedFootnoteIds.has(id)) {
|
||||
@@ -635,23 +583,12 @@ export function convertProseMirrorToMarkdown(
|
||||
.map((item: any) => processListItem(item, "-"))
|
||||
.join("\n");
|
||||
|
||||
case "orderedList": {
|
||||
// Honor a non-1 `start`: CommonMark expresses it as the first marker
|
||||
// ("5." starts the list at 5), and marked parses that back into
|
||||
// <ol start="5">. Emitting `${start + index}.` keeps a start=5 list as
|
||||
// "5.","6.",… while a default (start=1) list stays "1.","2.",… See #351.
|
||||
// Only an INTEGER ≥ 2 is a valid explicit start; collapse everything else
|
||||
// (absent, non-number, 0, negative, fractional) to 1. A negative/fractional
|
||||
// start would otherwise emit an untokenizable marker (e.g. "-3.", "2.5.")
|
||||
// that marked cannot parse, re-importing the whole list as a PARAGRAPH.
|
||||
const raw = node.attrs?.start;
|
||||
const start = Number.isInteger(raw) && (raw as number) > 1 ? (raw as number) : 1;
|
||||
case "orderedList":
|
||||
return nodeContent
|
||||
.map((item: any, index: number) =>
|
||||
processListItem(item, `${start + index}.`),
|
||||
processListItem(item, `${index + 1}.`),
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
case "taskList":
|
||||
return nodeContent.map((item: any) => processTaskItem(item)).join("\n");
|
||||
@@ -662,34 +599,20 @@ export function convertProseMirrorToMarkdown(
|
||||
return processTaskItem(node);
|
||||
|
||||
case "listItem":
|
||||
// Direct-listItem path (lists normally render via processListItem, which
|
||||
// handles the marker + indentation). Blank line between block children so
|
||||
// multiple paragraphs do not merge on re-parse; a `<!-- -->` entry
|
||||
// (renderBlockChildren) separates adjacent sibling lists.
|
||||
return renderBlockChildren(nodeContent, processNode).join("\n\n");
|
||||
return nodeContent.map(processNode).join("\n");
|
||||
|
||||
case "blockquote": {
|
||||
case "blockquote":
|
||||
// Prefix EVERY line of EVERY child with "> " and separate block-level
|
||||
// children with a blank ">" line so code blocks / multi-paragraph
|
||||
// quotes round-trip correctly. A `> <!-- -->` separator line is inserted
|
||||
// between two adjacent same-family sibling lists (renderBlockChildren)
|
||||
// so they stay distinct inside the quote.
|
||||
const bqParts: string[] = [];
|
||||
let prevBqType: string | undefined;
|
||||
for (const n of nodeContent) {
|
||||
if (adjacentListsMerge(prevBqType, n?.type)) {
|
||||
bqParts.push(`> ${LIST_MARKER_SEPARATOR}`);
|
||||
}
|
||||
bqParts.push(
|
||||
// quotes round-trip correctly.
|
||||
return nodeContent
|
||||
.map((n: any) =>
|
||||
processNode(n)
|
||||
.split("\n")
|
||||
.map((line: string) => (line.length ? `> ${line}` : ">"))
|
||||
.join("\n"),
|
||||
);
|
||||
prevBqType = n?.type;
|
||||
}
|
||||
return bqParts.join("\n>\n");
|
||||
}
|
||||
)
|
||||
.join("\n>\n");
|
||||
|
||||
case "horizontalRule":
|
||||
return "---";
|
||||
@@ -864,12 +787,9 @@ export function convertProseMirrorToMarkdown(
|
||||
// blockquote-prefixed; a blank line becomes a bare `>` so the callout is
|
||||
// not split.
|
||||
const calloutType = (node.attrs?.type || "info").toLowerCase();
|
||||
const calloutBody = renderBlockChildren(nodeContent, processNode)
|
||||
// Blank line between block children (rendered as a bare `>` after the
|
||||
// prefix pass below) so multiple paragraphs stay separate nodes instead
|
||||
// of merging on re-parse — same rule blockquote already uses. A
|
||||
// `<!-- -->` entry (renderBlockChildren) separates adjacent sibling lists.
|
||||
.join("\n\n")
|
||||
const calloutBody = nodeContent
|
||||
.map(processNode)
|
||||
.join("\n")
|
||||
.split("\n")
|
||||
.map((l: string) => (l.length ? `> ${l}` : ">"))
|
||||
.join("\n");
|
||||
@@ -889,10 +809,7 @@ export function convertProseMirrorToMarkdown(
|
||||
return `<summary>${renderInlineChildren(nodeContent)}</summary>\n\n`;
|
||||
|
||||
case "detailsContent":
|
||||
// Blank line between block children so multiple paragraphs in a details
|
||||
// body survive as separate nodes (a single "\n" merges them on re-parse);
|
||||
// a `<!-- -->` entry (renderBlockChildren) separates adjacent sibling lists.
|
||||
return `${renderBlockChildren(nodeContent, processNode).join("\n\n")}\n`;
|
||||
return `${nodeContent.map(processNode).join("\n")}\n`;
|
||||
|
||||
case "mathInline": {
|
||||
// #293 canon #6: inline math serializes as Obsidian-native `$LaTeX$`
|
||||
@@ -1412,38 +1329,20 @@ export function convertProseMirrorToMarkdown(
|
||||
return `<ul>${children
|
||||
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
|
||||
.join("")}</ul>`;
|
||||
case "orderedList": {
|
||||
// Carry a non-1 `start` on the raw-HTML path (columns/spanned cells) via
|
||||
// the <ol start="N"> attribute, which the tiptap parser reads back into
|
||||
// attrs.start. A default (start=1) list stays a bare <ol>. See #351.
|
||||
// Use the SAME integer-≥-2 guard as the markdown path: a fractional start
|
||||
// would emit `start="2.5"` → parseInt→2 on import (path divergence), and a
|
||||
// 0/negative start is not a valid explicit start either.
|
||||
const raw = block.attrs?.start;
|
||||
const start = Number.isInteger(raw) && (raw as number) > 1 ? (raw as number) : 1;
|
||||
const startAttr = start > 1 ? ` start="${start}"` : "";
|
||||
return `<ol${startAttr}>${children
|
||||
case "orderedList":
|
||||
return `<ol>${children
|
||||
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
|
||||
.join("")}</ol>`;
|
||||
}
|
||||
case "codeBlock": {
|
||||
const lang = block.attrs?.language || "";
|
||||
// The code itself is element TEXT content (between <code> tags), so it
|
||||
// must escape < > & — NOT the attribute escaper. The language rides in
|
||||
// a class ATTRIBUTE, so it uses escapeAttr.
|
||||
//
|
||||
// Read the child text RAW (as `case "codeBlock"` does) and keep it
|
||||
// VERBATIM — do NOT strip the trailing newline. Unlike the markdown fence
|
||||
// path (which strips then relies on marked re-adding one `\n`), the schema
|
||||
// codeBlock parseHTML reads the `<code>` text content back byte-for-byte,
|
||||
// so stripping here would drop a trailing newline the node legitimately
|
||||
// carries and break the round trip inside a column/cell.
|
||||
const code = escapeHtmlText(
|
||||
children
|
||||
.map((child: any) =>
|
||||
typeof child?.text === "string" ? child.text : "",
|
||||
)
|
||||
.join(""),
|
||||
.map(processNode)
|
||||
.join("")
|
||||
.replace(/\n+$/, ""),
|
||||
);
|
||||
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : "";
|
||||
return `<pre><code${cls}>${code}</code></pre>`;
|
||||
@@ -1585,13 +1484,6 @@ export function convertProseMirrorToMarkdown(
|
||||
const indent = " ".repeat(indentWidth);
|
||||
const lines: string[] = [];
|
||||
childStrings.forEach((child, childIndex) => {
|
||||
// Separate consecutive block children with a BLANK line so the item is a
|
||||
// CommonMark "loose" list item and each block stays its own node. Without
|
||||
// it, a second paragraph (`- a\n b`) is re-parsed as a lazy continuation
|
||||
// of the first and the two merge into one paragraph — silent data loss.
|
||||
// The blank line still sits INSIDE the item (the following block keeps the
|
||||
// continuation indent), so nested lists/code blocks remain nested.
|
||||
if (childIndex > 0) lines.push("");
|
||||
child.split("\n").forEach((line, lineIndex) => {
|
||||
if (childIndex === 0 && lineIndex === 0) {
|
||||
// First physical line of the first block gets the marker.
|
||||
@@ -1608,9 +1500,7 @@ export function convertProseMirrorToMarkdown(
|
||||
|
||||
const processListItem = (item: any, prefix: string): string => {
|
||||
const itemContent = item.content || [];
|
||||
// A `<!-- -->` entry separates two adjacent same-family sublists inside the
|
||||
// item so they do not merge on re-parse (see renderBlockChildren).
|
||||
const childStrings = renderBlockChildren(itemContent, processNode);
|
||||
const childStrings = itemContent.map(processNode);
|
||||
if (childStrings.length === 0) return prefix;
|
||||
// The rendered marker is `${prefix} ` (prefix + one space), so its width —
|
||||
// and thus the continuation indent — is prefix.length + 1. This is correct
|
||||
@@ -1624,9 +1514,7 @@ export function convertProseMirrorToMarkdown(
|
||||
const checkbox = checked ? "[x]" : "[ ]";
|
||||
const prefix = `- ${checkbox}`;
|
||||
const itemContent = item.content || [];
|
||||
// A `<!-- -->` entry separates two adjacent same-family sublists inside the
|
||||
// item so they do not merge on re-parse (see renderBlockChildren).
|
||||
const childStrings = renderBlockChildren(itemContent, processNode);
|
||||
const childStrings = itemContent.map(processNode);
|
||||
// An empty task item still needs its checkbox marker; without this guard
|
||||
// the indent below produces "" and the "- [ ]"/"- [x]" row disappears.
|
||||
if (childStrings.length === 0) return prefix;
|
||||
|
||||
@@ -270,10 +270,9 @@ const CALLOUT_CLOSE_RE = /^:::\s*$/;
|
||||
* optional title after the type is allowed but ignored (the Docmost callout
|
||||
* schema has no title). The body is the following contiguous blockquote lines.
|
||||
*/
|
||||
// The callout's own `>` marker may be preceded by an ENCLOSING container prefix:
|
||||
// list-item indentation (` `) and/or blockquote markers (`> `). Group 1 captures
|
||||
// that prefix (lazily, so the LAST `>` before `[!type]` is the callout's own).
|
||||
const CALLOUT_BQ_OPEN_RE = /^([>\s]*?)>\s*\[!(\w+)\]/;
|
||||
const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/;
|
||||
/** Matches any blockquote continuation line (`>` … ). */
|
||||
const BLOCKQUOTE_LINE_RE = /^>/;
|
||||
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
|
||||
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||
|
||||
@@ -403,47 +402,20 @@ async function preprocessCallouts(markdown: string): Promise<string> {
|
||||
// recurse so nested callouts (`> > [!type]`) are handled, then emit the same
|
||||
// callout div the `:::` path produces. A normal blockquote (no `[!type]` on
|
||||
// its first line) does not match and stays a blockquote.
|
||||
//
|
||||
// PREFIX-aware: a callout nested inside a list item and/or a blockquote is
|
||||
// serialized with the enclosing container prefix in front of its own `>`
|
||||
// marker — ` > [!type]` (list indent) or `> > [!type]` (blockquote). We
|
||||
// capture that prefix, take only continuation lines carrying `prefix>`, strip
|
||||
// it, and re-apply the prefix to the emitted HTML block so the callout div
|
||||
// stays WITHIN its container (an unprefixed div would escape and re-parse as
|
||||
// a top-level callout / plain blockquote — silent structure loss).
|
||||
const bqOpen = line.match(CALLOUT_BQ_OPEN_RE);
|
||||
if (bqOpen) {
|
||||
const prefix = bqOpen[1];
|
||||
const type = bqOpen[2].toLowerCase();
|
||||
const cont = prefix + ">"; // a body line = prefix + the callout's own `>`
|
||||
const type = bqOpen[1].toLowerCase();
|
||||
const bodyLines: string[] = [];
|
||||
let j = i + 1;
|
||||
for (; j < lines.length; j++) {
|
||||
if (!lines[j].startsWith(cont)) break;
|
||||
// Drop the prefix + `>` + one optional space, leaving the body content.
|
||||
bodyLines.push(lines[j].slice(prefix.length).replace(/^>\s?/, ""));
|
||||
if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break;
|
||||
bodyLines.push(lines[j].replace(/^>\s?/, ""));
|
||||
}
|
||||
const inner = await transform(bodyLines);
|
||||
const renderedInner = await markedInstance.parse(inner);
|
||||
const block = `<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>`;
|
||||
if (prefix.length === 0) {
|
||||
// Top-level callout: blank lines isolate the HTML block.
|
||||
out.push(`\n${block}\n`);
|
||||
} else if (prefix.includes(">")) {
|
||||
// Enclosing BLOCKQUOTE: prefix every line and add NO surrounding blank
|
||||
// lines — a blank line would terminate the blockquote and split the
|
||||
// callout out of it.
|
||||
out.push(block.split("\n").map((l) => prefix + l).join("\n"));
|
||||
} else {
|
||||
// Pure LIST-ITEM indentation: re-indent and keep the blank-line
|
||||
// separators (a loose list item), so the div sits at the marker column.
|
||||
out.push(
|
||||
`\n${block
|
||||
.split("\n")
|
||||
.map((l) => (l.length ? prefix + l : l))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
}
|
||||
out.push(
|
||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
||||
);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
@@ -625,40 +597,6 @@ function bridgeTaskLists(html: string): string {
|
||||
* (null from parseAttachedComment), an unknown name, a wrong-position comment, or
|
||||
* an unknown/empty attr value is ignored.
|
||||
*/
|
||||
/**
|
||||
* A directive comment is in ATTACHED position when it sits inside a `<p>`/`<hN>`
|
||||
* textblock — bound to that block's text (the `attrs`/`img` conventions). Every
|
||||
* other parent (body, document level, a block container like blockquote/details/
|
||||
* li/column div) is STANDALONE position, where a lone-block directive
|
||||
* (subpages/pagebreak/pageembed/transclusion) is materialized. Broadening
|
||||
* standalone beyond body/document is what lets these nodes survive NESTED inside
|
||||
* a blockquote/callout/details/list item (previously dropped -> silent data loss).
|
||||
*/
|
||||
function isAttachedPosition(tag: string): boolean {
|
||||
return tag === "p" || /^h[1-6]$/.test(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a materialized standalone-directive element in the DOM: replace the
|
||||
* comment IN PLACE when it has a real element parent inside <body> (body itself
|
||||
* or a nested block container), preserving document order; queue it as a leading
|
||||
* div only when the comment is at document level (no parentElement) or directly
|
||||
* under `<html>` (outside <body>, which `document.body.innerHTML` would drop).
|
||||
*/
|
||||
function placeStandalone(
|
||||
comment: any,
|
||||
el: any,
|
||||
tag: string,
|
||||
leadingDivs: any[],
|
||||
): void {
|
||||
if (comment.parentElement && tag !== "html") {
|
||||
comment.replaceWith(el);
|
||||
} else {
|
||||
comment.remove();
|
||||
leadingDivs.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
function applyCommentDirectives(html: string): string {
|
||||
// Cheap early-out: no comments at all -> nothing to intercept.
|
||||
if (!html.includes("<!--")) return html;
|
||||
@@ -726,13 +664,13 @@ function applyCommentDirectives(html: string): string {
|
||||
|
||||
if (parsed.name === "subpages" || parsed.name === "pagebreak") {
|
||||
// #293 canon #5 STANDALONE machinery. A lone comment line is rendered by
|
||||
// marked as its own HTML block; the parser places it under <body>, at
|
||||
// document level (leading), or — when the directive is NESTED — inside a
|
||||
// block CONTAINER (`<blockquote>` for blockquote/callout, `<details>`,
|
||||
// `<li>`, a column `<div>`, …). All of those are STANDALONE position. Only a
|
||||
// comment ATTACHED inside a `<p>`/`<hN>` (bound to that block's text) is
|
||||
// attached position -> INERT.
|
||||
if (isAttachedPosition(tag)) continue; // wrong position -> inert
|
||||
// marked as an HTML block; the parser places it either directly under
|
||||
// <body> (when other content surrounds it) or at document level (when it
|
||||
// leads the output). Both are STANDALONE position. A `subpages`/`pagebreak`
|
||||
// comment sitting inside a `<p>`/`<hN>` (or any other element) is attached
|
||||
// position -> INERT.
|
||||
const standalone = tag === "" || tag === "body" || tag === "html";
|
||||
if (!standalone) continue; // wrong position -> inert
|
||||
const div = document.createElement("div");
|
||||
if (parsed.name === "pagebreak") {
|
||||
div.setAttribute("data-type", "pageBreak");
|
||||
@@ -742,18 +680,26 @@ function applyCommentDirectives(html: string): string {
|
||||
div.setAttribute("data-recursive", "true");
|
||||
}
|
||||
}
|
||||
placeStandalone(comment, div, tag, leadingDivs);
|
||||
if (tag === "body") {
|
||||
// In-body: replace in place so surrounding content keeps its order.
|
||||
comment.replaceWith(div);
|
||||
} else {
|
||||
// Document-level (leading): drop the stray comment and queue the div to
|
||||
// be prepended into body below.
|
||||
comment.remove();
|
||||
leadingDivs.push(div);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.name === "pageembed" || parsed.name === "transclusion") {
|
||||
// #293 canon #8 STANDALONE media. Like subpages/pagebreak: a lone comment
|
||||
// line placed under <body>, at document level (leading), or NESTED inside a
|
||||
// block container (blockquote/callout/details/li/column). An ATTACHED-
|
||||
// position comment (inside a `<p>`/`<hN>`) is INERT. We rebuild the schema
|
||||
// div the raw-HTML path emits (media-html.ts) from the decoded attrs so
|
||||
// serialize/parse stay in sync.
|
||||
if (isAttachedPosition(tag)) continue; // wrong position -> inert
|
||||
// line placed under <body> or at document level (leading). An attached-
|
||||
// position comment (inside a <p>/<hN> with a sibling) is INERT. We rebuild
|
||||
// the schema div the raw-HTML path emits (media-html.ts) from the decoded
|
||||
// attrs so serialize/parse stay in sync.
|
||||
const standalone = tag === "" || tag === "body" || tag === "html";
|
||||
if (!standalone) continue; // wrong position -> inert
|
||||
const el = buildElement(
|
||||
parsed.name === "pageembed"
|
||||
? pageEmbedToHtml({ sourcePageId: parsed.attrs.sourcePageId })
|
||||
@@ -763,7 +709,12 @@ function applyCommentDirectives(html: string): string {
|
||||
}),
|
||||
);
|
||||
if (!el) continue; // defensive: builder always yields an element
|
||||
placeStandalone(comment, el, tag, leadingDivs);
|
||||
if (tag === "body") {
|
||||
comment.replaceWith(el);
|
||||
} else {
|
||||
comment.remove();
|
||||
leadingDivs.push(el);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -855,36 +806,18 @@ function applyCommentDirectives(html: string): string {
|
||||
|
||||
if (!parent) continue; // attrs comment must have an element parent
|
||||
if (parsed.name !== "attrs") continue; // unknown name -> inert
|
||||
const align = parsed.attrs.textAlign;
|
||||
// #293 canon #9 ATTACHED attrs: honored only in attached position.
|
||||
if (tag === "p" || /^h[1-6]$/.test(tag)) {
|
||||
// A real <p>/<hN> host (loose list item, top-level block, …): re-express as
|
||||
// an inline style; the schema's textAlign parseHTML reads `el.style.textAlign`
|
||||
// back onto the paragraph/heading node.
|
||||
if (typeof align === "string" && align) parent.style.textAlign = align;
|
||||
comment.remove();
|
||||
} else if (tag === "li" || tag === "td" || tag === "th") {
|
||||
// TIGHT list item / GFM table cell: marked emits the paragraph's inline
|
||||
// content DIRECTLY inside the <li>/<td>/<th> with NO <p> wrapper, so there
|
||||
// is no element to carry the style — generateJSON materializes the
|
||||
// paragraph later. Wrap the host's LEADING inline content (everything up to
|
||||
// the comment; any trailing block child such as a nested list stays put) in
|
||||
// a <p> carrying the alignment, so the materialized paragraph re-reads it.
|
||||
if (typeof align === "string" && align) {
|
||||
const p = document.createElement("p");
|
||||
p.style.textAlign = align;
|
||||
while (parent.firstChild && parent.firstChild !== comment) {
|
||||
p.appendChild(parent.firstChild);
|
||||
}
|
||||
parent.insertBefore(p, comment);
|
||||
}
|
||||
comment.remove();
|
||||
} else {
|
||||
// Misplaced `attrs` comment (not a textblock/li/cell host): inert. Consume
|
||||
// it anyway so no attached marker ever survives into the parsed body
|
||||
// (matches the pre-existing "consume regardless" behaviour).
|
||||
comment.remove();
|
||||
const isBlock = tag === "p" || /^h[1-6]$/.test(tag);
|
||||
if (!isBlock) continue; // misplaced comment -> inert
|
||||
const align = parsed.attrs.textAlign;
|
||||
if (typeof align === "string" && align) {
|
||||
// Re-express as an inline style; the schema's textAlign parseHTML reads
|
||||
// `el.style.textAlign` back onto the paragraph/heading node.
|
||||
parent.style.textAlign = align;
|
||||
}
|
||||
// Consume the marker regardless (unknown keys are simply ignored) so no
|
||||
// attached comment ever survives into the parsed body.
|
||||
comment.remove();
|
||||
}
|
||||
// Prepend any document-level (leading) standalone divs into body, preserving
|
||||
// their document order relative to each other and ahead of existing content.
|
||||
|
||||
@@ -46,11 +46,7 @@ export function videoToHtml(attrs: Record<string, any>): string {
|
||||
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
|
||||
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
|
||||
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
||||
// align default is "center" (schema): OMIT it so a bare/center node stays
|
||||
// clean and parse's re-materialized "center" default is not a P2 churn — only
|
||||
// a genuinely non-default left/right emits data-align (mirrors imageToHtml).
|
||||
if (attrs.align && attrs.align !== "center")
|
||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
if (attrs.aspectRatio != null)
|
||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
||||
return `<div><video ${parts.join(" ")}></video></div>`;
|
||||
@@ -66,9 +62,7 @@ export function youtubeToHtml(attrs: Record<string, any>): string {
|
||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
||||
if (attrs.height != null)
|
||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
||||
// "center" is the schema default -> omit (see videoToHtml rationale).
|
||||
if (attrs.align && attrs.align !== "center")
|
||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
return `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
@@ -103,9 +97,7 @@ export function diagramToHtml(
|
||||
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
||||
if (attrs.aspectRatio != null)
|
||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
||||
// "center" is the schema default -> omit (see videoToHtml rationale).
|
||||
if (attrs.align && attrs.align !== "center")
|
||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
if (attrs.attachmentId)
|
||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
||||
return `<div ${parts.join(" ")}></div>`;
|
||||
@@ -118,18 +110,10 @@ export function embedToHtml(attrs: Record<string, any>): string {
|
||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
||||
`data-provider="${escapeAttr(attrs.provider ?? "")}"`,
|
||||
];
|
||||
// "center" is the schema default -> omit (see videoToHtml rationale).
|
||||
if (attrs.align && attrs.align !== "center")
|
||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
// embed width/height default to the NUMBERS 800/600 (schema). Getting a data-
|
||||
// attribute back always yields a STRING, so emitting the default here would
|
||||
// round-trip 800 -> "800" (a number->string P1 divergence canonicalize does
|
||||
// NOT normalize). OMIT the defaults so parse re-materializes the numeric
|
||||
// default instead — mirrors the top-level embed path (markdown-converter.ts),
|
||||
// which also emits width/height only when they differ from 800/600.
|
||||
if (attrs.width != null && attrs.width !== 800)
|
||||
if (attrs.align) parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
||||
if (attrs.width != null)
|
||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
||||
if (attrs.height != null && attrs.height !== 600)
|
||||
if (attrs.height != null)
|
||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
||||
return `<div ${parts.join(" ")}></div>`;
|
||||
}
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"_bug": "BUG #351: a `column` whose `width` is a percentage string (e.g. \"50%\") is NOT byte-stable across export->import->export (violates P2). The `column` schema's parseHTML does `parseFloat(getAttribute('data-width'))`, which silently drops the '%' unit and returns the NUMBER 50. So the first export emits data-width=\"50%\" but the re-import stores width=50, and the second export emits data-width=\"50\": md2 !== md1, a permanent GS-EDIT-REVERT churn (every git-sync pull rewrites the column width). The editor authors column widths as percentages, so this is a real data/round-trip defect. Fix belongs in src/lib/docmost-schema.ts column.width parseHTML (preserve the unit / keep the string), which is OUT OF SCOPE for this test-only PR and must be a separate, maintainer-approved change. This flat generator therefore keeps `column.width` frozen (never generates a non-default width).",
|
||||
"doc": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "columns",
|
||||
"attrs": { "layout": "two_equal", "widthMode": "normal" },
|
||||
"content": [
|
||||
{ "type": "column", "attrs": { "width": "50%" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "L" }] }] },
|
||||
{ "type": "column", "attrs": { "width": "50%" }, "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "R" }] }] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -37,19 +37,14 @@
|
||||
* tagged `// ACCEPTED:` inline. Freezing them is correct — there is
|
||||
* nothing to preserve in the target format.
|
||||
*
|
||||
* (b) FIXED & VALUE-FUZZED — attributes that were once PINNED converter
|
||||
* bugs (representable in markdown but dropped) and are now FIXED in
|
||||
* src/, so they are value-fuzzed here at legal non-default values like
|
||||
* any healthy attr. `orderedList.start` (the non-1 start once rendered
|
||||
* as `1.`; the converter now emits the start marker / `<ol start="N">`)
|
||||
* and `column.width` (a unitless flex-grow number that round-trips via
|
||||
* parseFloat) are both fuzzed in OVERRIDES below. The former held-out
|
||||
* `it.fails` cases are gone; `ordered-list-start.json` +
|
||||
* counterexamples.test.ts now stand as PASSING regression pins (per the
|
||||
* epic guardrail, the minimal doc stays forever to guard re-regression).
|
||||
* The #351 media-family sizing attrs (image/video/youtube/pdf/drawio/
|
||||
* excalidraw/embed width/height/size/aspectRatio) are likewise fuzzed
|
||||
* now that they ride round-trip-safely in the per-node `<!--…-->` JSON.
|
||||
* (b) PINNED BUG — the attribute IS representable in markdown but the
|
||||
* converter drops it anyway (a real defect). These are NOT silently
|
||||
* frozen: each is captured as a LOUD `it.fails` counterexample in
|
||||
* test/fixtures/counterexamples/ + counterexamples.test.ts, and the
|
||||
* freeze here only keeps the P1/P2 union green until a MAINTAINER rules
|
||||
* on accept-vs-fix (the epic guardrail reserves that call). These:
|
||||
* `column.width` (parseFloat drops `%`), `orderedList.start` (non-1
|
||||
* start renders as `1.`). Tagged `// PINNED-BUG:` inline.
|
||||
*
|
||||
* (c) DEFERRED-BUG — representable AND round-trips, frozen only because the
|
||||
* flat generator can't yet build a valid instance. Table
|
||||
@@ -69,10 +64,8 @@
|
||||
* number re-parses as a string), EXCEPT `embed.width/height` which the
|
||||
* embed schema keeps numeric — handled per-attr.
|
||||
*
|
||||
* The two former PINNED-BUG attrs (`column.width` P2 churn, `orderedList.start`
|
||||
* P1 loss) are now FIXED and value-fuzzed; `ordered-list-start.json` in
|
||||
* counterexamples.test.ts is a permanent PASSING regression pin, not an
|
||||
* `it.fails` hold-out.
|
||||
* Both PINNED-BUG attrs (`column.width` P2 churn, `orderedList.start` P1 loss)
|
||||
* are captured as committed `it.fails` counterexamples — NOT hidden here.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
@@ -127,11 +120,6 @@ interface AttrPolicy {
|
||||
const num = (...xs: number[]) => fc.constantFrom(...xs);
|
||||
const str = (...xs: string[]) => fc.constantFrom(...xs);
|
||||
const widthStr = str('120', '320', '640');
|
||||
// Media `aspectRatio`/`size` are stringified numerics too (converter emits
|
||||
// String(value)); the schema parseHTML reads them back as strings. Fuzz as
|
||||
// plausible numeric strings so they round-trip byte-stably like widthStr.
|
||||
const aspectRatioStr = str('1.5', '0.75', '1');
|
||||
const sizeStr = str('320', '640');
|
||||
|
||||
// The documented override table, keyed `type.attr`. Every entry is grounded in
|
||||
// the empirical converter probe (see flat-roundtrip.property.test.ts header).
|
||||
@@ -146,10 +134,10 @@ const OVERRIDES: Record<string, AttrPolicy> = {
|
||||
'heading.textAlign': { arb: str('center', 'right', 'justify') },
|
||||
'heading.indent': { frozen: true }, // ACCEPTED: no md representation
|
||||
// ── lists ────────────────────────────────────────────────────────────────
|
||||
// FIXED (#351): the converter now emits the start marker ("5." / <ol start="5">)
|
||||
// and it round-trips, so the start number is value-fuzzed. See
|
||||
// counterexamples.test.ts (ordered-list-start.json) for the regression pin.
|
||||
'orderedList.start': { always: true, arb: num(2, 3, 5, 42) },
|
||||
// PINNED-BUG: markdown CAN express a non-1 start ("5."), but the converter
|
||||
// renders "1." and drops it -> P1 loss. See counterexamples.test.ts
|
||||
// (ordered-list-start.json). Frozen only until the maintainer rules accept-vs-fix.
|
||||
'orderedList.start': { always: true, frozen: true },
|
||||
'orderedList.type': { frozen: true }, // ACCEPTED: a/A/i markers not expressible in GFM
|
||||
'taskItem.checked': { always: true, arb: fc.constant(true) }, // boolean, default false
|
||||
// ── codeBlock ────────────────────────────────────────────────────────────
|
||||
@@ -161,57 +149,16 @@ const OVERRIDES: Record<string, AttrPolicy> = {
|
||||
'image.title': { arb: letterPhraseArb },
|
||||
'image.width': { arb: widthStr, degen: '' },
|
||||
'image.height': { arb: widthStr, degen: '' },
|
||||
// #351 (this PR): media sizing/family attrs ride in the `<!--img {…}-->`
|
||||
// comment JSON via String(value); parseHTML reads them back as strings, so a
|
||||
// numeric string round-trips byte-stably (mirrors image.width).
|
||||
'image.size': { arb: sizeStr, degen: '' },
|
||||
'image.aspectRatio': { arb: aspectRatioStr },
|
||||
// caption is text carried verbatim in the comment JSON (mirrors image.alt).
|
||||
'image.caption': { arb: letterPhraseArb, degen: '' },
|
||||
'video.src': { noDefault: true, arb: urlArb, degen: '' },
|
||||
'video.alt': { arb: letterPhraseArb },
|
||||
'video.width': { arb: widthStr },
|
||||
'video.height': { arb: widthStr },
|
||||
// #351 (this PR): video sizing/align ride in the `<!--video {…}-->` comment.
|
||||
'video.size': { arb: sizeStr },
|
||||
'video.aspectRatio': { arb: aspectRatioStr },
|
||||
// align default "center" is dropped on export; fuzz non-center only.
|
||||
'video.align': { arb: str('left', 'right') },
|
||||
'audio.src': { noDefault: true, arb: urlArb, degen: '' },
|
||||
'youtube.src': { noDefault: true, arb: urlArb },
|
||||
// #351 (this PR): youtube width/height/align ride in the `<!--youtube {…}-->`
|
||||
// comment JSON (String()-coerced dimensions, non-center align only).
|
||||
'youtube.width': { arb: widthStr },
|
||||
'youtube.height': { arb: widthStr },
|
||||
'youtube.align': { arb: str('left', 'right') },
|
||||
'pdf.src': { noDefault: true, arb: urlArb },
|
||||
'pdf.name': { arb: phraseArb },
|
||||
// #351 (this PR): pdf size/width/height ride in the `<!--pdf {…}-->` comment
|
||||
// (String()-coerced dimensions, read back as strings).
|
||||
'pdf.size': { arb: sizeStr },
|
||||
'pdf.width': { arb: widthStr },
|
||||
'pdf.height': { arb: widthStr },
|
||||
'drawio.src': { noDefault: true, arb: urlArb },
|
||||
// #351 (this PR): drawio family attrs ride in the `<!--drawio {…}-->` comment.
|
||||
// Dimensions/size/aspectRatio are String()-coerced numeric strings; title/alt
|
||||
// are text carried verbatim; align fuzzed non-center only.
|
||||
'drawio.width': { arb: widthStr },
|
||||
'drawio.height': { arb: widthStr },
|
||||
'drawio.size': { arb: sizeStr },
|
||||
'drawio.aspectRatio': { arb: aspectRatioStr },
|
||||
'drawio.align': { arb: str('left', 'right') },
|
||||
'drawio.title': { arb: letterPhraseArb },
|
||||
'drawio.alt': { arb: letterPhraseArb },
|
||||
'excalidraw.src': { noDefault: true, arb: urlArb },
|
||||
// #351 (this PR): excalidraw family attrs ride in the `<!--excalidraw {…}-->`
|
||||
// comment (same shape as the drawio family above).
|
||||
'excalidraw.width': { arb: widthStr },
|
||||
'excalidraw.height': { arb: widthStr },
|
||||
'excalidraw.size': { arb: sizeStr },
|
||||
'excalidraw.aspectRatio': { arb: aspectRatioStr },
|
||||
'excalidraw.align': { arb: str('left', 'right') },
|
||||
'excalidraw.title': { arb: letterPhraseArb },
|
||||
'excalidraw.alt': { arb: letterPhraseArb },
|
||||
'attachment.url': { noDefault: true, arb: urlArb },
|
||||
'attachment.name': { arb: phraseArb },
|
||||
// ── callout / status ─────────────────────────────────────────────────────
|
||||
@@ -247,26 +194,14 @@ const OVERRIDES: Record<string, AttrPolicy> = {
|
||||
// widthMode round-trips via the `data-width-mode` attribute (verified P1+P2),
|
||||
// so it is fuzzed, not frozen.
|
||||
'columns.widthMode': { always: true, arb: str('custom') },
|
||||
// column.width is a unitless flex-grow NUMBER (matches editor-ext column.ts);
|
||||
// parseHTML does parseFloat, so String(50) === "50" both ways and a numeric
|
||||
// width round-trips byte-stably. Value-fuzzed as a number.
|
||||
'column.width': { arb: num(25, 50, 75) },
|
||||
// PINNED-BUG: parseFloat import drops the `%` unit -> P2 churn. See
|
||||
// counterexamples.test.ts (columns-column-width-percent.json).
|
||||
'column.width': { frozen: true },
|
||||
// ── embed (schema keeps width/height NUMERIC, not string-coerced) ─────────
|
||||
'embed.src': { noDefault: true, arb: urlArb, degen: '' },
|
||||
'embed.provider': { noDefault: true, arb: str('iframe', 'youtube', 'vimeo') },
|
||||
// #351 (this PR): the embed schema defaults width/height to the NUMBERS 800/600
|
||||
// and the converter only emits them when they differ. But the value round-trips
|
||||
// as a STRING: export stringifies into the comment JSON (String(width)) and the
|
||||
// import path (embedToHtml -> data-width -> embed parseHTML) reads it back as a
|
||||
// string, so an authored NUMBER 400 would diverge under P1 (400 vs "400"). Fuzz
|
||||
// as numeric STRINGS avoiding "800"/"600" so they round-trip byte-stably. The
|
||||
// 800/600 numeric default state still round-trips (omitted on export, re-
|
||||
// materialized as the numeric default). `always` stays because these are
|
||||
// materialized on import but absent from canonicalize's KNOWN_DEFAULTS.
|
||||
'embed.width': { always: true, arb: str('400', '1000', '1200') },
|
||||
'embed.height': { always: true, arb: str('300', '500', '900') },
|
||||
// align default "center" is dropped on export; fuzz non-center only.
|
||||
'embed.align': { arb: str('left', 'right') },
|
||||
'embed.width': { always: true, frozen: true },
|
||||
'embed.height': { always: true, frozen: true },
|
||||
// ── subpages / math / htmlEmbed ──────────────────────────────────────────
|
||||
'subpages.recursive': { always: true, arb: fc.constant(true) }, // boolean, default false
|
||||
'mathBlock.text': { noDefault: true, arb: str('x^2', 'a < b', '\\frac{1}{2}'), degen: '' },
|
||||
|
||||
@@ -7,17 +7,18 @@ import { markdownToProseMirror } from '../../src/lib/markdown-to-prosemirror.js'
|
||||
import { docsCanonicallyEqual } from '../../src/lib/canonicalize.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #351 committed counterexample — a REAL round-trip bug surfaced by the flat
|
||||
// generative probing (attribute level). The bug below is now FIXED in src/
|
||||
// (maintainer-approved), so this case is a permanent PASSING regression pin:
|
||||
// the exact document that once lost data now round-trips cleanly, and the
|
||||
// fixture stays in test/fixtures/counterexamples/ forever (per the epic
|
||||
// guardrail) to guard against a re-regression. The case carries a loud
|
||||
// `// BUG #351 (FIXED):` note recording the defect and its fix site.
|
||||
// #351 committed counterexamples — REAL round-trip bugs surfaced by the flat
|
||||
// generative probing (attribute level). Each is pinned here as an `it.fails`
|
||||
// (vitest passes ONLY WHILE the assertion still fails), so that the day the
|
||||
// underlying src/ bug is fixed, the `it.fails` starts PASSING and vitest turns
|
||||
// this test RED — forcing us to delete the counterexample and (per the epic
|
||||
// guardrail) tighten the generator. A bare `it.fails` would ship silent
|
||||
// corruption, so every case below carries a loud `// BUG #351:` explanation.
|
||||
//
|
||||
// With the bug fixed, the offending attribute is now VALUE-FUZZED by the
|
||||
// generator (see attr-arbitraries.ts: orderedList.start), not held out — this
|
||||
// committed document is the minimal, human-readable pin.
|
||||
// These bugs are NOT worked around by weakening any property: the offending
|
||||
// attribute is kept OUT of the P1/P2 generators (documented in
|
||||
// attr-arbitraries.ts), and the exact failing document lives here as the
|
||||
// regression pin. FIXING the bug is a separate, maintainer-approved src/ change.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -27,20 +28,47 @@ function loadDoc(file: string): any {
|
||||
return JSON.parse(readFileSync(path.join(fixtureDir, file), 'utf8')).doc;
|
||||
}
|
||||
|
||||
describe('#351 counterexamples (round-trip bugs, now FIXED — permanent regression pins)', () => {
|
||||
// BUG #351 (FIXED): an `orderedList` with a non-1 `start` lost its start
|
||||
// number. CommonMark CAN express this ("5." starts the list at 5), but the
|
||||
// converter always emitted "1." and ignored `attrs.start`:
|
||||
describe('#351 counterexamples (known round-trip bugs, pinned as it.fails)', () => {
|
||||
// BUG #351: a `column` with a PERCENTAGE width ("50%") is not byte-stable.
|
||||
// The column schema parses `data-width` with parseFloat, dropping the '%':
|
||||
// md1 = '...data-width="50%"...' (first export)
|
||||
// re-import stores width = 50 (number)
|
||||
// md2 = '...data-width="50"...' (second export) => md2 !== md1
|
||||
// A permanent GS-EDIT-REVERT churn on every git-sync pull. The editor stores
|
||||
// column widths as percentages, so this is a genuine defect. The fix is in
|
||||
// src/lib/docmost-schema.ts (column.width parseHTML must preserve the unit)
|
||||
// and is out of scope for this test-only PR.
|
||||
it.fails('column percentage width is byte-stable (P2)', async () => {
|
||||
const doc = loadDoc('columns-column-width-percent.json');
|
||||
const md1 = convertProseMirrorToMarkdown(doc);
|
||||
const doc2 = await markdownToProseMirror(md1);
|
||||
const md2 = convertProseMirrorToMarkdown(doc2);
|
||||
// This assertion currently FAILS (md2 drops the '%'), which is exactly what
|
||||
// `it.fails` expects. When the schema is fixed, it will PASS and flip this
|
||||
// test red — our cue to remove the pin.
|
||||
expect(md2).toBe(md1);
|
||||
});
|
||||
|
||||
// BUG #351: an `orderedList` with a non-1 `start` loses its start number.
|
||||
// CommonMark CAN express this ("5." starts the list at 5), but the converter
|
||||
// always emits "1." and ignores `attrs.start` (markdown-converter.ts renders
|
||||
// `${index + 1}.`; the <ol> HTML path also omits `start`):
|
||||
// doc.start = 5 -> md1 = "1. alpha" (start dropped on export)
|
||||
// re-import stored start = 1 => docsCanonicallyEqual(rt, doc) === false
|
||||
// A P1 (semantic round-trip) loss of the SAME class as column.width. FIXED in
|
||||
// this PR in src/lib/markdown-converter.ts (the orderedList markdown path emits
|
||||
// `${start + index}.`; the raw-HTML path emits `<ol start="N">`). tiptap's
|
||||
// StarterKit / marked parse the start back, so it round-trips. Permanent P1 pin.
|
||||
it('ordered list start number is preserved (P1)', async () => {
|
||||
// re-import stores start = 1 => docsCanonicallyEqual(rt, doc) === false
|
||||
// This is a P1 (semantic round-trip) loss of the SAME class as column.width:
|
||||
// representable in markdown, silently dropped by the converter. It is pinned
|
||||
// here as the LOUD counterexample rather than being masked as an "accepted
|
||||
// normalization" in the generator — per the epic guardrail, deciding
|
||||
// accept-vs-fix for a markdown-representable loss is a MAINTAINER call, so this
|
||||
// stays a visible known-bug until the maintainer rules on it. The fix would be
|
||||
// in src/lib/markdown-converter.ts (emit the start number on the first item)
|
||||
// and is out of scope for this test-only PR.
|
||||
it.fails('ordered list start number is preserved (P1)', async () => {
|
||||
const doc = loadDoc('ordered-list-start.json');
|
||||
const md1 = convertProseMirrorToMarkdown(doc);
|
||||
const doc2 = await markdownToProseMirror(md1);
|
||||
// Currently FAILS: doc2.start === 1 while doc.start === 5. When the converter
|
||||
// preserves `start`, this PASSES and flips the test red — remove the pin then.
|
||||
expect(docsCanonicallyEqual(doc2, doc)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* Nested whole-document generator (#351, PR 2) — "variant A": a random walk over
|
||||
* the schema's ContentMatch automaton.
|
||||
*
|
||||
* Where the FLAT generator (node-generators.ts) emits `{doc:[ <one target> ]}`,
|
||||
* this module produces arbitrarily DEEP, valid ProseMirror documents. Validity is
|
||||
* NOT hand-asserted: it comes straight from the schema. To fill a container's
|
||||
* block content we start at `nodeType.contentMatch` and walk the automaton —
|
||||
* enumerate the legal next node types (`match.edge(i).type`), let fast-check pick
|
||||
* one (or STOP once `match.validEnd`), generate that child RECURSIVELY, then
|
||||
* advance the automaton via `match.matchType(childType)`. A bad walk therefore
|
||||
* cannot emit a structurally-invalid doc (the `generator validity` test guards
|
||||
* this with `schema.nodeFromJSON(json).check()`).
|
||||
*
|
||||
* ── What the walk drives, and what it delegates ──────────────────────────────
|
||||
* The walk owns BLOCK STRUCTURE (which blocks nest inside which containers, in
|
||||
* what order and how deep). Two things it deliberately delegates, to avoid
|
||||
* REGRESSING the byte-stable space the flat suite already proved empirically:
|
||||
*
|
||||
* - INLINE content of textblocks (paragraph/heading/codeBlock/detailsSummary)
|
||||
* is filled from text-arbitraries.ts — the exact hostile-but-byte-stable
|
||||
* inline corpus the flat suite established. Walking the inline ContentMatch
|
||||
* instead would re-derive (and re-fail) the text-space limitations the flat
|
||||
* suite already pins, which is not this generator's job.
|
||||
* - Node ATTRS come from nodeAttrsArb(type, 'p1') — the round-trip-safe
|
||||
* attribute space. Attribute-degenerate fuzzing (P2/P3 over 'fuzz') is the
|
||||
* flat suite's concern; the nested suite isolates STRUCTURAL round-trip, so
|
||||
* it stays in 'p1' and does not re-litigate frozen/pinned attributes.
|
||||
*
|
||||
* ── Coordination the automaton cannot express ────────────────────────────────
|
||||
* A ContentMatch guarantees a child SEQUENCE is legal, but not cross-sibling
|
||||
* invariants. Two nodes need coordination the walk injects by hand:
|
||||
* - `table`: GFM needs a RECTANGULAR grid with column-consistent alignment.
|
||||
* The automaton happily allows ragged rows / per-cell align, which are not a
|
||||
* converter bug — just a malformed table. So a table is generated ATOMICALLY
|
||||
* (same shape the flat suite proved) rather than walked.
|
||||
* - `columns`: the `layout` attr must agree with the column COUNT. We pick the
|
||||
* layout, derive the count, then walk each column's block body normally — so
|
||||
* columns still gain real nested content, only the count is coordinated.
|
||||
*
|
||||
* ── Excluded from the nested walk ────────────────────────────────────────────
|
||||
* Footnote nodes (footnoteReference / footnotesList / footnoteDefinition) need a
|
||||
* DOCUMENT-GLOBAL id match between a reference and its definition. That
|
||||
* coordination is owned by the flat suite's `footnotes` generator; placing them
|
||||
* independently here would fabricate id mismatches that look like converter bugs
|
||||
* but are generator defects. They are filtered out of the walk (documented in
|
||||
* EXCLUDED). The completeness contract lives in the FLAT suite and is unaffected.
|
||||
*
|
||||
* ── Termination / budgets ────────────────────────────────────────────────────
|
||||
* Two bounds keep every doc finite and the suite fast:
|
||||
* - MAX_DEPTH — a hard cap on block-nesting depth. A precomputed `minDepth`
|
||||
* fixpoint (the minimum extra nesting a subtree of each type needs to be
|
||||
* valid) lets the walk pick a CONTAINER child only when there is depth
|
||||
* headroom to complete it — so the walk can never paint itself into a corner
|
||||
* where a required child cannot fit (no invalid docs, guaranteed termination).
|
||||
* - NODE_BUDGET — a soft cap on total nodes; as it runs low the walk biases
|
||||
* toward STOP (when validEnd) or toward cheap terminating children.
|
||||
*/
|
||||
import fc from 'fast-check';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
import { docmostExtensions } from '../../src/lib/docmost-schema.js';
|
||||
import { nodeAttrsArb } from './attr-arbitraries.js';
|
||||
import {
|
||||
inlineContentArb,
|
||||
headingInlineContentArb,
|
||||
plainInlineContentArb,
|
||||
phraseArb,
|
||||
} from './text-arbitraries.js';
|
||||
|
||||
/** The exact ProseMirror schema the converter targets (built per the issue). */
|
||||
export const schema = getSchema(docmostExtensions as never);
|
||||
|
||||
/** Hard cap on block-nesting depth (doc = depth 0). Kept in the issue's 4–5 band. */
|
||||
export const MAX_DEPTH = 4;
|
||||
/**
|
||||
* Soft cap on total node count per generated document. Kept moderate: every P1/P2
|
||||
* run parses the emitted markdown through jsdom (heavy), so 100+ node docs across
|
||||
* hundreds of runs exhaust the worker heap. 60 still yields deeply-nested docs
|
||||
* (depth 4) while keeping the suite within memory.
|
||||
*/
|
||||
export const NODE_BUDGET = 60;
|
||||
|
||||
/**
|
||||
* Nodes kept OUT of the nested walk: footnote nodes need a doc-global id match a
|
||||
* local walk cannot coordinate (owned by the flat suite's `footnotes` generator).
|
||||
*/
|
||||
const EXCLUDED = new Set<string>([
|
||||
'footnoteReference',
|
||||
'footnotesList',
|
||||
'footnoteDefinition',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Structural-only children that carry `group: "block"` in the schema and so leak
|
||||
* into EVERY block container's ContentMatch, even though the editor only ever
|
||||
* places them inside their one true parent. Choosing them freely (e.g. a bare
|
||||
* `column` at the document root) fabricates documents no editor produces and that
|
||||
* the converter is not designed to round-trip — a GENERATOR artifact, not a
|
||||
* converter bug. They are admitted ONLY when the container being filled is their
|
||||
* dedicated parent. (`column` is in fact always built inside columnsArb, so this
|
||||
* just double-guards it.)
|
||||
*/
|
||||
const DEDICATED_PARENT: Record<string, string> = {
|
||||
column: 'columns',
|
||||
detailsSummary: 'details',
|
||||
detailsContent: 'details',
|
||||
};
|
||||
|
||||
/** Is child type `t` legal as a freely-chosen child of container `parentType`? */
|
||||
function childAllowedUnder(t: string, parentType: string): boolean {
|
||||
const dedicated = DEDICATED_PARENT[t];
|
||||
return dedicated === undefined || dedicated === parentType;
|
||||
}
|
||||
|
||||
/** Textblock (inlineContent) types — filled from the proven inline corpus. */
|
||||
function isTextblock(typeName: string): boolean {
|
||||
return !!schema.nodes[typeName]?.isTextblock;
|
||||
}
|
||||
/** Leaf/atom types — no content, only generated attrs. */
|
||||
function isLeaf(typeName: string): boolean {
|
||||
return !!schema.nodes[typeName]?.isLeaf;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// minDepth fixpoint: the minimum EXTRA block-nesting depth a valid subtree
|
||||
// rooted at each node type requires. Leaves and textblocks need 0 (a textblock
|
||||
// is satisfied by inline content, no block recursion). A container needs
|
||||
// 1 + the cheapest way to satisfy its ContentMatch. Computed as a min–max path
|
||||
// to `validEnd` over the automaton, iterated to a fixpoint over node types.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Cheapest (min over reachable validEnd of max child minDepth) to complete a match. */
|
||||
function minCompletion(
|
||||
match: any,
|
||||
md: Record<string, number>,
|
||||
seen: Set<any>,
|
||||
parentType: string,
|
||||
): number {
|
||||
let best = match.validEnd ? 0 : Infinity;
|
||||
if (seen.has(match)) return best; // a cycle never completes more cheaply
|
||||
seen.add(match);
|
||||
for (let i = 0; i < match.edgeCount; i++) {
|
||||
const edge = match.edge(i);
|
||||
const t = edge.type.name;
|
||||
if (EXCLUDED.has(t) || t === 'text') continue;
|
||||
if (!childAllowedUnder(t, parentType)) continue;
|
||||
const childCost = md[t];
|
||||
if (childCost === undefined || childCost === Infinity) continue;
|
||||
const rest = minCompletion(edge.next, md, seen, parentType);
|
||||
if (rest === Infinity) continue;
|
||||
best = Math.min(best, Math.max(childCost, rest));
|
||||
}
|
||||
seen.delete(match);
|
||||
return best;
|
||||
}
|
||||
|
||||
function computeMinDepth(): Record<string, number> {
|
||||
const md: Record<string, number> = {};
|
||||
for (const name of Object.keys(schema.nodes)) {
|
||||
md[name] = isLeaf(name) || isTextblock(name) ? 0 : Infinity;
|
||||
}
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const name of Object.keys(schema.nodes)) {
|
||||
if (md[name] === 0) continue; // leaves/textblocks fixed at 0
|
||||
const nt: any = schema.nodes[name];
|
||||
const completion = minCompletion(nt.contentMatch, md, new Set(), name);
|
||||
const next = completion === Infinity ? Infinity : 1 + completion;
|
||||
if (next < md[name]) {
|
||||
md[name] = next;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return md;
|
||||
}
|
||||
|
||||
const MIN_DEPTH = computeMinDepth();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Leaf / textblock builders (attrs from 'p1', inline from the proven corpus).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function attachAttrs(typeName: string, base: Record<string, unknown> = {}) {
|
||||
return nodeAttrsArb(typeName, 'p1', base).map((attrs) => {
|
||||
const node: any = { type: typeName };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
/** A leaf/atom block: attrs only, no content. */
|
||||
function leafArb(typeName: string): fc.Arbitrary<any> {
|
||||
return attachAttrs(typeName);
|
||||
}
|
||||
|
||||
/** A textblock, inline content taken from the byte-stable flat corpus. */
|
||||
function textblockArb(typeName: string): fc.Arbitrary<any> {
|
||||
if (typeName === 'codeBlock') {
|
||||
return fc
|
||||
.tuple(
|
||||
nodeAttrsArb('codeBlock', 'p1'),
|
||||
// Fenced code re-imports with a TRAILING NEWLINE (flat suite finding);
|
||||
// author it so the doc is already at the round-trip fixpoint.
|
||||
fc.array(phraseArb, { minLength: 1, maxLength: 3 }).map((l) => l.join('\n') + '\n'),
|
||||
)
|
||||
.map(([attrs, code]) => ({
|
||||
type: 'codeBlock',
|
||||
...(Object.keys(attrs).length ? { attrs } : {}),
|
||||
content: [{ type: 'text', text: code }],
|
||||
}));
|
||||
}
|
||||
const inline =
|
||||
typeName === 'heading'
|
||||
? headingInlineContentArb
|
||||
: typeName === 'detailsSummary'
|
||||
? plainInlineContentArb
|
||||
: inlineContentArb;
|
||||
return fc
|
||||
.tuple(nodeAttrsArb(typeName, 'p1'), inline)
|
||||
.map(([attrs, content]) => ({
|
||||
type: typeName,
|
||||
...(Object.keys(attrs).length ? { attrs } : {}),
|
||||
content,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Coordinated builders: table (atomic, rectangular, column-consistent align)
|
||||
// and columns (layout coupled to count, bodies walked).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A rectangular GFM-safe table (mirrors the flat suite's proven shape). */
|
||||
function tableArb(): fc.Arbitrary<any> {
|
||||
return fc.integer({ min: 1, max: 3 }).chain((cols) => {
|
||||
// One alignment per COLUMN, identical on header + every body cell, so the
|
||||
// second export cannot re-align and churn.
|
||||
const alignsArb = fc.array(fc.constantFrom(undefined, 'left', 'center', 'right'), {
|
||||
minLength: cols,
|
||||
maxLength: cols,
|
||||
});
|
||||
const cell = (header: boolean, align?: string) =>
|
||||
phraseArb.map((t) => ({
|
||||
type: header ? 'tableHeader' : 'tableCell',
|
||||
attrs: { colspan: 1, rowspan: 1, ...(align ? { align } : {}) },
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text: t }] }],
|
||||
}));
|
||||
return alignsArb.chain((aligns) => {
|
||||
const headerRow = fc
|
||||
.tuple(...aligns.map((a) => cell(true, a)))
|
||||
.map((cells) => ({ type: 'tableRow', content: cells }));
|
||||
const bodyRow = fc
|
||||
.tuple(...aligns.map((a) => cell(false, a)))
|
||||
.map((cells) => ({ type: 'tableRow', content: cells }));
|
||||
return fc
|
||||
.tuple(headerRow, fc.array(bodyRow, { minLength: 1, maxLength: 2 }))
|
||||
.map(([h, body]) => ({ type: 'table', content: [h, ...body] }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A columns block: layout ↔ count coupled, each column body walked as blocks. */
|
||||
function columnsArb(depth: number, budget: number): fc.Arbitrary<any> {
|
||||
return fc
|
||||
.constantFrom('two_equal', 'three_equal', 'left_sidebar', 'right_sidebar')
|
||||
.chain((layout) => {
|
||||
const count = layout === 'three_equal' ? 3 : 2;
|
||||
const columnType: any = schema.nodes.column;
|
||||
// Split the remaining budget across the fixed number of columns.
|
||||
const per = Math.max(2, Math.floor((budget - 1) / count));
|
||||
return nodeAttrsArb('columns', 'p1', { layout, widthMode: 'normal' }).chain((attrs) =>
|
||||
fc
|
||||
.tuple(
|
||||
...Array.from({ length: count }, () =>
|
||||
fillMatch(columnType.contentMatch, depth + 1, per, 'column').map(({ children }) => ({
|
||||
type: 'column',
|
||||
content: children,
|
||||
})),
|
||||
),
|
||||
)
|
||||
.map((cols) => ({ type: 'columns', attrs, content: cols })),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The ContentMatch walk.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Count every node in a subtree (block + inline), for budget accounting. */
|
||||
function countNodes(node: any): number {
|
||||
let n = 1;
|
||||
for (const c of node.content ?? []) n += countNodes(c);
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Build a single child node of a given type at `depth`, within `budget`. */
|
||||
function blockNode(typeName: string, depth: number, budget: number): fc.Arbitrary<any> {
|
||||
if (typeName === 'table') return tableArb();
|
||||
if (typeName === 'columns') return columnsArb(depth, budget);
|
||||
if (isTextblock(typeName)) return textblockArb(typeName);
|
||||
if (isLeaf(typeName)) return leafArb(typeName);
|
||||
// Generic container: attrs from 'p1', block content from the automaton walk.
|
||||
const nt: any = schema.nodes[typeName];
|
||||
return nodeAttrsArb(typeName, 'p1').chain((attrs) =>
|
||||
fillMatch(nt.contentMatch, depth, budget - 1, typeName).map(({ children }) => {
|
||||
const node: any = { type: typeName };
|
||||
if (Object.keys(attrs).length) node.attrs = attrs;
|
||||
if (children.length) node.content = children;
|
||||
return node;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill a container's block content by walking its ContentMatch automaton from
|
||||
* `match`. Returns the children array plus the budget left after them.
|
||||
*/
|
||||
function fillMatch(
|
||||
match: any,
|
||||
depth: number,
|
||||
budget: number,
|
||||
parentType: string,
|
||||
): fc.Arbitrary<{ children: any[]; budget: number }> {
|
||||
const canStop = match.validEnd;
|
||||
// A child lives at depth+1; only pick it if its subtree can complete within
|
||||
// MAX_DEPTH. This headroom rule is what makes the walk deadlock-free.
|
||||
const headroom = MAX_DEPTH - (depth + 1);
|
||||
const edges: { t: string; next: any }[] = [];
|
||||
if (headroom >= 0) {
|
||||
for (let i = 0; i < match.edgeCount; i++) {
|
||||
const edge = match.edge(i);
|
||||
const t = edge.type.name;
|
||||
if (EXCLUDED.has(t) || t === 'text') continue;
|
||||
if (!childAllowedUnder(t, parentType)) continue;
|
||||
if ((MIN_DEPTH[t] ?? Infinity) > headroom) continue;
|
||||
edges.push({ t, next: edge.next });
|
||||
}
|
||||
}
|
||||
|
||||
// Decide the next action: STOP (if allowed) or extend with one more child.
|
||||
// Bias toward stopping when the budget is spent; force a child only when the
|
||||
// match is not yet at a valid end.
|
||||
const pool: { weight: number; arbitrary: fc.Arbitrary<{ t: string; next: any } | null> }[] = [];
|
||||
const canGo = edges.length > 0 && (budget > 0 || !canStop);
|
||||
if (canStop) {
|
||||
// Stop is weighted higher when the budget is low so docs stay bounded.
|
||||
pool.push({ weight: budget > 0 ? 2 : 5, arbitrary: fc.constant(null) });
|
||||
}
|
||||
if (canGo && !(canStop && budget <= 0)) {
|
||||
pool.push({ weight: 3, arbitrary: fc.constantFrom(...edges) });
|
||||
}
|
||||
// Forced continuation: not a valid end yet and (budget exhausted) — must place
|
||||
// a mandatory child regardless of budget.
|
||||
if (pool.length === 0) {
|
||||
if (edges.length > 0) {
|
||||
pool.push({ weight: 1, arbitrary: fc.constantFrom(...edges) });
|
||||
} else {
|
||||
// No legal child and not required to place one: stop with what we have.
|
||||
return fc.constant({ children: [], budget });
|
||||
}
|
||||
}
|
||||
|
||||
return fc.oneof(...pool).chain((choice) => {
|
||||
if (choice === null) return fc.constant({ children: [], budget });
|
||||
return blockNode(choice.t, depth + 1, budget).chain((node) => {
|
||||
const cost = countNodes(node);
|
||||
return fillMatch(choice.next, depth, budget - cost, parentType).map(
|
||||
({ children, budget: left }) => ({
|
||||
children: [node, ...children],
|
||||
budget: left,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The nested-document arbitrary: a valid, arbitrarily-deep ProseMirror doc built
|
||||
* by walking the schema from the document root. Attrs stay in the round-trip-safe
|
||||
* 'p1' space; inline content reuses the byte-stable flat corpus.
|
||||
*/
|
||||
export const docArb: fc.Arbitrary<any> = fillMatch(
|
||||
schema.nodes.doc.contentMatch,
|
||||
0,
|
||||
NODE_BUDGET,
|
||||
'doc',
|
||||
).map(({ children }) => ({ type: 'doc', content: children }));
|
||||
|
||||
/** The precomputed minDepth table, exported for inspection/debugging. */
|
||||
export { MIN_DEPTH };
|
||||
@@ -1,29 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { envInt } from './env-int.js';
|
||||
|
||||
// Unit coverage for the shared PROPERTY_SEED / PROPERTY_NUM_RUNS parser. The key
|
||||
// contract is that an explicit "0" is honored (a valid fast-check seed) while
|
||||
// empty/absent/non-numeric fall back to the default.
|
||||
describe('envInt', () => {
|
||||
it('honors an explicit "0" (does not fall back)', () => {
|
||||
expect(envInt('0', 42)).toBe(0);
|
||||
});
|
||||
it('falls back on empty string', () => {
|
||||
expect(envInt('', 42)).toBe(42);
|
||||
});
|
||||
it('falls back on undefined', () => {
|
||||
expect(envInt(undefined, 42)).toBe(42);
|
||||
});
|
||||
it('falls back on a non-numeric string', () => {
|
||||
expect(envInt('abc', 42)).toBe(42);
|
||||
});
|
||||
it('parses a plain integer string', () => {
|
||||
expect(envInt('300', 42)).toBe(300);
|
||||
});
|
||||
it('parses a negative integer', () => {
|
||||
expect(envInt('-5', 42)).toBe(-5);
|
||||
});
|
||||
it('parses a large integer', () => {
|
||||
expect(envInt('1073741824', 42)).toBe(1073741824);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
/**
|
||||
* Parse an integer-ish environment variable with a default fallback.
|
||||
*
|
||||
* Used to read PROPERTY_SEED / PROPERTY_NUM_RUNS in the generative property
|
||||
* suites. An unset/empty/non-numeric value falls back to `dflt`, but an explicit
|
||||
* `"0"` is honored (a valid fast-check seed) — `Number(x) || dflt` would wrongly
|
||||
* swallow 0. See flat-roundtrip.property.test.ts / nested-roundtrip.property.test.ts.
|
||||
*/
|
||||
export const envInt = (v: string | undefined, dflt: number): number =>
|
||||
v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : dflt;
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
coveredTypes,
|
||||
KNOWN_UNCOVERED,
|
||||
} from './node-generators.js';
|
||||
import { envInt } from './env-int.js';
|
||||
|
||||
// ── Attribute-value coverage allowlist ──────────────────────────────────────
|
||||
// The node/mark completeness contract guarantees every TYPE is generated, but
|
||||
@@ -29,32 +28,34 @@ import { envInt } from './env-int.js';
|
||||
// a NEW attribute (or a newly-frozen one) that lands in this bucket flips the
|
||||
// snapshot test red and forces a reviewer to classify it. Each belongs to one of:
|
||||
// - internal/opaque ids & placeholders (attachmentId, slugId, placeholder,
|
||||
// creatorId, anchorId, mime) — no meaningful non-default to assert. These stay
|
||||
// frozen: their value is an opaque token carried verbatim, not a round-trip
|
||||
// shape worth fuzzing;
|
||||
// creatorId, anchorId) — no meaningful non-default to assert;
|
||||
// - dimensions/among the media family with no standalone md form here
|
||||
// (aspectRatio, size, caption, drawio/excalidraw/pdf/video/youtube w/h/align)
|
||||
// — round-trip candidates deferred to a later PR, not silently dropped;
|
||||
// - ACCEPTED limitations with no md representation (indent, callout.icon,
|
||||
// orderedList.type, table spans/bg/colwidth).
|
||||
// The media dimension/family attrs (image/video/youtube/drawio/excalidraw/pdf/
|
||||
// embed width/height/align/size/aspectRatio/caption/title/alt) that were
|
||||
// previously "deferred to a later PR" are IMPLEMENTED (value-fuzzed) in THIS PR
|
||||
// via the OVERRIDES table in attr-arbitraries.ts — they ride in the discriminator
|
||||
// comment JSON and round-trip byte-stably, so they are no longer allowlisted.
|
||||
// orderedList.type, table spans/bg/colwidth);
|
||||
// - PINNED bugs (column.width, orderedList.start) tracked in
|
||||
// counterexamples.test.ts.
|
||||
const ATTR_VALUE_FUZZ_ALLOWLIST = new Set<string>([
|
||||
'attachment.attachmentId', 'attachment.mime', 'attachment.placeholder', 'attachment.size',
|
||||
'audio.attachmentId', 'audio.placeholder', 'audio.size',
|
||||
'callout.icon',
|
||||
'drawio.attachmentId',
|
||||
'excalidraw.attachmentId',
|
||||
'callout.icon', 'column.width',
|
||||
'drawio.align', 'drawio.alt', 'drawio.aspectRatio', 'drawio.attachmentId',
|
||||
'drawio.height', 'drawio.size', 'drawio.title', 'drawio.width',
|
||||
'embed.align', 'embed.height', 'embed.width',
|
||||
'excalidraw.align', 'excalidraw.alt', 'excalidraw.aspectRatio', 'excalidraw.attachmentId',
|
||||
'excalidraw.height', 'excalidraw.size', 'excalidraw.title', 'excalidraw.width',
|
||||
'heading.indent',
|
||||
'image.attachmentId', 'image.placeholder',
|
||||
'image.aspectRatio', 'image.attachmentId', 'image.caption', 'image.placeholder', 'image.size',
|
||||
'mention.anchorId', 'mention.creatorId', 'mention.slugId',
|
||||
'orderedList.type', 'paragraph.indent',
|
||||
'pdf.attachmentId', 'pdf.placeholder',
|
||||
'orderedList.start', 'orderedList.type', 'paragraph.indent',
|
||||
'pdf.attachmentId', 'pdf.height', 'pdf.placeholder', 'pdf.size', 'pdf.width',
|
||||
'tableCell.backgroundColor', 'tableCell.backgroundColorName', 'tableCell.colspan',
|
||||
'tableCell.colwidth', 'tableCell.rowspan',
|
||||
'tableHeader.backgroundColor', 'tableHeader.backgroundColorName', 'tableHeader.colspan',
|
||||
'tableHeader.colwidth', 'tableHeader.rowspan',
|
||||
'video.attachmentId', 'video.placeholder',
|
||||
'video.align', 'video.aspectRatio', 'video.attachmentId', 'video.placeholder', 'video.size',
|
||||
'youtube.align', 'youtube.height', 'youtube.width',
|
||||
]);
|
||||
|
||||
// ── MARK attribute-value coverage ───────────────────────────────────────────
|
||||
@@ -112,20 +113,13 @@ vi.setConfig({ testTimeout: 30000 });
|
||||
|
||||
// Fixed seed so every failure is reproducible; fast-check also prints the
|
||||
// shrunk counterexample. numRuns starts modest to keep CI under budget — the
|
||||
// issue's CI target is ~300-500 per property. Both are overridable via the
|
||||
// PROPERTY_SEED / PROPERTY_NUM_RUNS env vars (an invalid/empty value → NaN →
|
||||
// falls back to the default below): the nightly cron
|
||||
// (.github/workflows/nightly-property.yml) cranks NUM_RUNS to ~5000 with a
|
||||
// random seed to hunt for deeper counterexamples. Each property runs over the
|
||||
// UNION (fc.oneof) of all flat node generators, so the runs are shared across
|
||||
// node types (one test per property keeps the jsdom import cost and memory
|
||||
// bounded — a per-generator × per-property matrix is ~200 heavy tests that
|
||||
// OOMs the worker).
|
||||
// An unset/empty/non-numeric value falls back to the default; an explicit 0 is
|
||||
// honored (a valid fast-check seed) — `Number(x) || default` would swallow it.
|
||||
// The parser is shared with the nested suite (env-int.ts) and unit-tested there.
|
||||
const SEED = envInt(process.env.PROPERTY_SEED, 20250705);
|
||||
const NUM_RUNS = envInt(process.env.PROPERTY_NUM_RUNS, 300);
|
||||
// issue's CI target is ~300-500 per property; the nightly / PR 3 will crank
|
||||
// this up further. Each property runs over the UNION (fc.oneof) of all flat
|
||||
// node generators, so the runs are shared across node types (one test per
|
||||
// property keeps the jsdom import cost and memory bounded — a per-generator ×
|
||||
// per-property matrix is ~200 heavy tests that OOMs the worker).
|
||||
const SEED = 20250705;
|
||||
const NUM_RUNS = 300;
|
||||
|
||||
const P1_GENERATORS = buildGenerators('p1');
|
||||
const FUZZ_GENERATORS = buildGenerators('fuzz');
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import fc from 'fast-check';
|
||||
// Real converters. Importing markdownToProseMirror (transitively, via index)
|
||||
// mutates the global DOM via jsdom at module load — expected, required for
|
||||
// @tiptap/html's generateJSON under Node (same as the flat sibling suite).
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
docsCanonicallyEqual,
|
||||
canonicalizeContent,
|
||||
} from '../../src/lib/index.js';
|
||||
import { firstDivergence } from '../roundtrip-helpers.js';
|
||||
import { schema, docArb } from './doc-generator.js';
|
||||
import { envInt } from './env-int.js';
|
||||
|
||||
// Each run does a real convert + jsdom parse; give ample headroom so the suite
|
||||
// is deterministic under parallel worker load (matching the flat sibling suite).
|
||||
vi.setConfig({ testTimeout: 60000 });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #351 PR 2 — GENERATIVE round-trip over NESTED (whole-document) docs produced
|
||||
// by the ContentMatch random walk (doc-generator.ts). The invariants mirror the
|
||||
// flat suite, plus a parser-fuzz totality property (P4):
|
||||
//
|
||||
// P1 — semantic round-trip: docsCanonicallyEqual(mdToPm(pmToMd(d)), d)
|
||||
// P2 — byte fixpoint (2nd pass): pmToMd(mdToPm(pmToMd(d))) === pmToMd(d)
|
||||
// (the FIRST pass may normalize once; the SECOND pass must be a fixpoint)
|
||||
// P3 — totality: neither converter throws; bounded.
|
||||
// P4 — parser fuzz totality: for ANY string, markdownToProseMirror does NOT
|
||||
// throw and returns a SCHEMA-VALID document.
|
||||
//
|
||||
// GUARDRAIL: a P1/P2/P3/P4 failure means the generator FOUND A REAL CONVERTER
|
||||
// BUG. These invariants are kept STRICT — no it.fails / skip / weakening. A
|
||||
// failure prints the shrunk minimal counterexample for triage.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Both are overridable via the PROPERTY_SEED / PROPERTY_NUM_RUNS env vars (an
|
||||
// invalid/empty value → NaN → falls back to the default below); the nightly
|
||||
// cron (.github/workflows/nightly-property.yml) cranks NUM_RUNS up with a
|
||||
// random seed to hunt for deeper counterexamples.
|
||||
// An unset/empty/non-numeric value falls back to the default; an explicit 0 is
|
||||
// honored (a valid fast-check seed) — `Number(x) || default` would swallow it.
|
||||
// The parser is shared with the flat suite (env-int.ts) and unit-tested there.
|
||||
const SEED = envInt(process.env.PROPERTY_SEED, 20250705);
|
||||
// The nested walk builds far heavier docs than the flat suite (each P1/P2 run
|
||||
// parses the emitted markdown through jsdom), so keep the run count moderate to
|
||||
// hold runtime and worker memory in budget while still exercising deep
|
||||
// structures. P4 (cheap string parsing) runs at a higher count below.
|
||||
const NUM_RUNS = envInt(process.env.PROPERTY_NUM_RUNS, 100);
|
||||
|
||||
const pmToMd = (doc: unknown): string => convertProseMirrorToMarkdown(doc);
|
||||
const mdToPm = (md: string): Promise<any> => markdownToProseMirror(md);
|
||||
|
||||
async function roundTrip(doc: unknown): Promise<{ md1: string; md2: string; doc2: any }> {
|
||||
const md1 = pmToMd(doc);
|
||||
const doc2 = await mdToPm(md1);
|
||||
const md2 = pmToMd(doc2);
|
||||
return { md1, md2, doc2 };
|
||||
}
|
||||
|
||||
describe('#351 nested generative round-trip — generator validity', () => {
|
||||
it('every generated nested doc passes schema.nodeFromJSON(...).check()', () => {
|
||||
// A nested generator that emits an invalid ProseMirror document is a
|
||||
// GENERATOR bug — the ContentMatch walk must only produce schema-valid docs.
|
||||
fc.assert(
|
||||
fc.property(docArb, (doc) => {
|
||||
schema.nodeFromJSON(doc).check(); // throws on an invalid doc
|
||||
return true;
|
||||
}),
|
||||
{ numRuns: NUM_RUNS * 2, seed: SEED },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── STATUS: P1/P2/P3/P4 all GREEN. The nested generator originally surfaced a
|
||||
// batch of real converter bugs; all were fixed in the serializer/parser (see the
|
||||
// #351 hand-off). For the record, the classes it found and that are now fixed:
|
||||
// • Loose (multi-block) list items / task items / callouts / details bodies were
|
||||
// joined with a single "\n", so every block after the first merged into the
|
||||
// first paragraph on re-parse (silent content loss) — now blank-line separated.
|
||||
// • Paragraph `textAlign` was dropped inside a TIGHT list item (no <p> host).
|
||||
// • A nested codeBlock lost its trailing newline on the raw-HTML path.
|
||||
// • Media (embed/video/youtube/drawio/excalidraw) inside `columns` churned a
|
||||
// default `data-align` and coerced embed's numeric width/height to strings.
|
||||
// • pageBreak / pageEmbed / subpages / transclusion were dropped when nested in
|
||||
// blockquote / callout / details / list item (standalone-comment position).
|
||||
// • Callouts nested in a list item or a blockquote (` > [!type]` / `> > [!type]`)
|
||||
// were re-parsed as plain blockquotes (prefix-unaware callout preprocessor).
|
||||
// • Two adjacent sibling lists sharing a marker family (bulletList/taskList →
|
||||
// `<ul>`; orderedList → `<ol>`) merged into one list on re-parse — and for the
|
||||
// cross-type case (taskList beside bulletList) the merged `<ul>` LOST every
|
||||
// taskItem checkbox. The serializer now emits a `<!-- -->` separator between
|
||||
// such adjacent lists (markdown-converter.ts renderBlockChildren), so they stay
|
||||
// distinct and round-trip; the generator therefore emits them freely again.
|
||||
describe('#351 nested generative round-trip — properties', () => {
|
||||
it('P1 — semantic round-trip: docsCanonicallyEqual(mdToPm(pmToMd(d)), d)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(docArb, async (doc) => {
|
||||
const { doc2 } = await roundTrip(doc);
|
||||
if (!docsCanonicallyEqual(doc2, doc)) {
|
||||
const div = firstDivergence(
|
||||
JSON.parse(JSON.stringify(canonicalizeContent(doc2))),
|
||||
JSON.parse(JSON.stringify(canonicalizeContent(doc))),
|
||||
);
|
||||
throw new Error(
|
||||
`P1 divergence @ ${div?.path}: got=${JSON.stringify(div?.a)} want=${JSON.stringify(div?.b)}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ numRuns: NUM_RUNS, seed: SEED },
|
||||
);
|
||||
});
|
||||
|
||||
it('P2 — byte fixpoint: pmToMd(mdToPm(pmToMd(d))) === pmToMd(d)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(docArb, async (doc) => {
|
||||
const { md1, md2 } = await roundTrip(doc);
|
||||
expect(md2).toBe(md1);
|
||||
}),
|
||||
{ numRuns: NUM_RUNS, seed: SEED },
|
||||
);
|
||||
});
|
||||
|
||||
it('P3 — totality: neither converter throws', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(docArb, async (doc) => {
|
||||
await roundTrip(doc);
|
||||
}),
|
||||
{ numRuns: NUM_RUNS, seed: SEED },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P4 — parser fuzz. Independent of the doc generator: for ANY input string the
|
||||
// PARSER (markdownToProseMirror) must be TOTAL — never throw — and must always
|
||||
// return a schema-valid document. The corpus mixes raw unicode strings with
|
||||
// strings assembled from markdown-significant fragments (headings, list bullets,
|
||||
// fences, pipes, thematic breaks, HTML-ish snippets) to probe the block/inline
|
||||
// parsers on hostile but plausible input.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mdFragmentArb: fc.Arbitrary<string> = fc.constantFrom(
|
||||
'# ', '## ', '### ###', '- ', '* ', '+ ', '1. ', '> ', '>> ',
|
||||
'```', '```js', '~~~', '---', '***', '___', '| a | b |', '|---|---|',
|
||||
'[link](http://x)', '', '**', '__', '~~', '`code`',
|
||||
'<div>', '</div>', '<b>', '<!-- c -->', '<table>', '<br>', '&',
|
||||
'\t', '\n', ' ', '\\', '^[fn]', '[^1]:', '- [ ] ', '- [x] ',
|
||||
'$$', '$x$', ':::', '{.class}', '\u0000', '\uFEFF', '😀', 'مرحبا',
|
||||
);
|
||||
|
||||
// Full-unicode strings (fast-check v4 replaced fullUnicodeString with the
|
||||
// `unit: 'binary'` string option, which draws over the whole code-point range).
|
||||
const fullUnicodeStringArb = (max?: number) =>
|
||||
fc.string({ unit: 'binary', ...(max !== undefined ? { maxLength: max } : {}) });
|
||||
|
||||
const assembledMarkdownArb: fc.Arbitrary<string> = fc
|
||||
.array(fc.oneof(mdFragmentArb, fc.string(), fullUnicodeStringArb(8)), {
|
||||
minLength: 1,
|
||||
maxLength: 12,
|
||||
})
|
||||
.map((parts) => parts.join(''));
|
||||
|
||||
const parserInputArb: fc.Arbitrary<string> = fc.oneof(
|
||||
{ weight: 2, arbitrary: fc.string() },
|
||||
{ weight: 2, arbitrary: fullUnicodeStringArb() },
|
||||
{ weight: 3, arbitrary: assembledMarkdownArb },
|
||||
{ weight: 1, arbitrary: fc.array(mdFragmentArb, { minLength: 1, maxLength: 8 }).map((p) => p.join('\n')) },
|
||||
);
|
||||
|
||||
describe('#351 parser fuzz — totality on arbitrary input (P4)', () => {
|
||||
it('P4 — markdownToProseMirror never throws and always returns a schema-valid doc', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(parserInputArb, async (s) => {
|
||||
let result: any;
|
||||
try {
|
||||
result = await mdToPm(s);
|
||||
} catch (e: any) {
|
||||
throw new Error(`P4 parser THREW on input ${JSON.stringify(s)}: ${e?.message ?? e}`);
|
||||
}
|
||||
try {
|
||||
schema.nodeFromJSON(result).check();
|
||||
} catch (e: any) {
|
||||
throw new Error(
|
||||
`P4 parser produced an INVALID doc for input ${JSON.stringify(s)}: ${e?.message ?? e}\n` +
|
||||
`doc=${JSON.stringify(result).slice(0, 600)}`,
|
||||
);
|
||||
}
|
||||
}),
|
||||
{ numRuns: NUM_RUNS * 2, seed: SEED },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -217,9 +217,8 @@ const colChildOf = (doc2: any) =>
|
||||
doc2?.content?.[0]?.content?.[0]?.content?.[0];
|
||||
|
||||
describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
// 1. orderedList honors the start attribute (FIXED #351): markers count up
|
||||
// from `start` ("5.","6.",…), which CommonMark round-trips.
|
||||
it('orderedList start:5 numbers from 5 (start attr honored)', () => {
|
||||
// 1. orderedList renders index+1 and DROPS the start attribute.
|
||||
it('orderedList start:5 restarts numbering at 1 (start attr ignored)', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'orderedList',
|
||||
@@ -230,7 +229,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(out).toBe('5. a\n6. b');
|
||||
expect(out).toBe('1. a\n2. b');
|
||||
});
|
||||
|
||||
// 2. An empty paragraph contributes an empty segment between two "\n\n" joins.
|
||||
@@ -375,9 +374,7 @@ describe('converter gap coverage — emission branches (specs 1–11)', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
// Block children of a task item are blank-line separated (loose list) per the
|
||||
// #351 fix; the sublist stays at the fixed 2-column continuation indent.
|
||||
expect(out).toBe('- [ ] top\n\n - child');
|
||||
expect(out).toBe('- [ ] top\n - child');
|
||||
});
|
||||
|
||||
// 10. A bulletList inside a blockquote: each list line independently prefixed.
|
||||
|
||||
@@ -365,7 +365,7 @@ describe('media / attachment / container full-attribute golden coverage', () =>
|
||||
);
|
||||
});
|
||||
|
||||
it('orderedList inside a column renders via blockToHtml as <ol start="N"> (start attr PRESERVED) with bold->strong, code->code', () => {
|
||||
it('orderedList inside a column renders via blockToHtml as <ol> (start attr DROPPED) with bold->strong, code->code', () => {
|
||||
const out = c({
|
||||
type: 'columns',
|
||||
attrs: { layout: 'two' },
|
||||
@@ -391,13 +391,13 @@ describe('media / attachment / container full-attribute golden coverage', () =>
|
||||
},
|
||||
],
|
||||
});
|
||||
// blockToHtml orderedList path emits <ol start="3"> (FIXED #351), and
|
||||
// inlineToHtml maps bold->strong, code->code.
|
||||
// blockToHtml orderedList path emits a plain <ol> with no start attribute,
|
||||
// and inlineToHtml maps bold->strong, code->code.
|
||||
expect(out).toContain(
|
||||
'<ol start="3"><li><p><strong>a</strong></p></li><li><p><code>b</code></p></li></ol>',
|
||||
'<ol><li><p><strong>a</strong></p></li><li><p><code>b</code></p></li></ol>',
|
||||
);
|
||||
// The start:3 attr IS preserved in the HTML/column container path.
|
||||
expect(out).toContain('start="3"');
|
||||
// The start:3 attr is NOT preserved in the HTML/column container path.
|
||||
expect(out).not.toContain('start=');
|
||||
});
|
||||
|
||||
it('hardBreak inside a column renders as <br> via inlineToHtml (not the markdown two-space form)', () => {
|
||||
|
||||
@@ -178,12 +178,12 @@ describe('blockToHtml: heading / codeBlock(lang & no-lang) / bulletList inside c
|
||||
// A colspan>1 cell forces the WHOLE table to the raw-<table> HTML fallback
|
||||
// (markdown-converter.ts ~287-331). renderHtmlCell emits colspan + align attrs
|
||||
// (312-316) and renders each block child via blockToHtml. An orderedList child
|
||||
// hits the blockToHtml orderedList branch, which emits
|
||||
// <ol start="N"><li><p>..</p></li>..</ol> — the schema's non-1 `start` attr IS
|
||||
// emitted by this HTML <ol> branch (FIXED #351).
|
||||
// hits the blockToHtml orderedList branch (726-729), which emits
|
||||
// <ol><li><p>..</p></li>..</ol> — the schema's `start` attr is NOT emitted by
|
||||
// this HTML <ol> branch.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('spanned table: renderHtmlCell colspan/align + orderedList block child', () => {
|
||||
it('renders the colspan/align cell with an <ol start="N"> (start attr preserved)', () => {
|
||||
it('renders the colspan/align cell with an <ol> (start attr is dropped)', () => {
|
||||
const out = convertProseMirrorToMarkdown(
|
||||
doc({
|
||||
type: 'table',
|
||||
@@ -213,11 +213,11 @@ describe('spanned table: renderHtmlCell colspan/align + orderedList block child'
|
||||
expect(out).toBe(
|
||||
'<table><tbody><tr>' +
|
||||
'<td colspan="2" align="center">' +
|
||||
'<ol start="3"><li><p>one</p></li><li><p>two</p></li></ol>' +
|
||||
'<ol><li><p>one</p></li><li><p>two</p></li></ol>' +
|
||||
'</td>' +
|
||||
'</tr></tbody></table>',
|
||||
);
|
||||
// The HTML <ol> branch propagates the ProseMirror `start` attribute.
|
||||
expect(out).toContain('start="3"');
|
||||
// The HTML <ol> branch does not propagate the ProseMirror `start` attribute.
|
||||
expect(out).not.toContain('start');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,11 +198,7 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
}),
|
||||
);
|
||||
// First line carries the marker; the nested list is indented 2 columns.
|
||||
// Block children of a list item are separated by a BLANK line (loose list):
|
||||
// this is the #351 fix — a single "\n" let a following block merge into the
|
||||
// first paragraph on re-parse (silent content loss). The blank line stays
|
||||
// inside the item, so the sublist remains nested at the 2-col marker column.
|
||||
expect(out).toBe('- parent\n\n - child');
|
||||
expect(out).toBe('- parent\n - child');
|
||||
});
|
||||
|
||||
it('nested ordered list indents by the wider 3-col marker width', () => {
|
||||
@@ -223,9 +219,8 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
// "1. " is 3 columns wide, so the continuation indent is 3 spaces. Block
|
||||
// children are blank-line separated (loose list) per the #351 fix.
|
||||
expect(out).toBe('1. parent\n\n 1. child');
|
||||
// "1. " is 3 columns wide, so the continuation indent is 3 spaces.
|
||||
expect(out).toBe('1. parent\n 1. child');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -544,12 +539,11 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
);
|
||||
|
||||
// The 10th marker is the 4-column "10. "; the nested sublist line must be
|
||||
// indented exactly 4 spaces (prefix.length 3 + 1), NOT 3. Block children are
|
||||
// blank-line separated (loose list) per the #351 fix.
|
||||
expect(out).toContain('10. j\n\n 1. x');
|
||||
// indented exactly 4 spaces (prefix.length 3 + 1), NOT 3.
|
||||
expect(out).toContain('10. j\n 1. x');
|
||||
// Guard against the off-by-one (3-space) regression that would re-parse
|
||||
// the sublist as loose/sibling content on import.
|
||||
expect(out).not.toContain('10. j\n\n 1. x');
|
||||
expect(out).not.toContain('10. j\n 1. x');
|
||||
// And the single-digit items keep the narrower 3-column marker (no body
|
||||
// continuation here, but the marker itself must stay "1. ".."9. ").
|
||||
expect(out.startsWith('1. a\n2. b\n')).toBe(true);
|
||||
@@ -586,18 +580,17 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
content: [para(text('line1')), para(text('line2'))],
|
||||
}),
|
||||
);
|
||||
// The converter emits an Obsidian-native callout: a `> [!type]` opener plus
|
||||
// one `>`-prefixed body line per content line. Block children are separated
|
||||
// by a blank `>` line (#351 fix): a single '\n' let the two paragraphs merge
|
||||
// into one on re-parse. We pin the lowercasing (WARNING -> warning) and the
|
||||
// blank-line-separated multi-child join.
|
||||
expect(out).toBe('> [!warning]\n> line1\n>\n> line2');
|
||||
// NOTE(review): the spec predicted ':::warning\nline1\n\nline2\n:::' (a
|
||||
// The converter joins the callout's rendered children with a single '\n'
|
||||
// and emits an Obsidian-native callout: a `> [!type]` opener plus one
|
||||
// `>`-prefixed body line per content line. We pin the lowercasing
|
||||
// (WARNING -> warning) and the multi-child join.
|
||||
expect(out).toBe('> [!warning]\n> line1\n> line2');
|
||||
// The type is lowercased (an uppercase `[!WARNING]` would not re-import).
|
||||
expect(out.startsWith('> [!warning]\n')).toBe(true);
|
||||
expect(out).not.toContain('[!WARNING]');
|
||||
// Both paragraph children are present, each blockquote-prefixed, blank-`>`
|
||||
// separated so they stay distinct paragraphs on re-parse.
|
||||
expect(out).toContain('> line1\n>\n> line2');
|
||||
// Both paragraph children are present, each blockquote-prefixed.
|
||||
expect(out).toContain('> line1\n> line2');
|
||||
});
|
||||
|
||||
// Spec 4 — blockquote per-line prefixer over a multi-line nested callout.
|
||||
@@ -614,11 +607,13 @@ describe('convertProseMirrorToMarkdown', () => {
|
||||
],
|
||||
}),
|
||||
);
|
||||
// The nested callout renders as an Obsidian callout '> [!info]\n> a\n>\n> b'
|
||||
// (blank-`>` separated children per the #351 fix). The outer blockquote
|
||||
// prefixer then prefixes each of those lines with '> ' again, yielding a
|
||||
// doubly-nested blockquote — the per-line-prefix loop over a multi-line child.
|
||||
expect(out).toBe('> > [!info]\n> > a\n> >\n> > b');
|
||||
// NOTE(review): the spec predicted '> :::info\n> a\n>\n> b\n> :::',
|
||||
// assuming the nested callout body contains a blank line between 'a' and
|
||||
// The nested callout renders as an Obsidian callout '> [!info]\n> a\n> b'
|
||||
// (single-'\n' join, no blank line). The outer blockquote prefixer then
|
||||
// prefixes each of those lines with '> ' again, yielding a doubly-nested
|
||||
// blockquote — the realistic per-line-prefix loop over a multi-line child.
|
||||
expect(out).toBe('> > [!info]\n> > a\n> > b');
|
||||
// Every produced line carries the '> ' prefix (no line escapes to col 0).
|
||||
for (const line of out.split('\n')) {
|
||||
expect(line.startsWith('>')).toBe(true);
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { convertProseMirrorToMarkdown } from '../src/lib/markdown-converter.js';
|
||||
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #351 regression — DEGENERATE orderedList `start` normalization (DO-1).
|
||||
//
|
||||
// Only an INTEGER >= 2 is a valid explicit start. A degenerate start (0,
|
||||
// negative, fractional, non-number) MUST collapse to the default 1 on export:
|
||||
// emitting the raw value would produce an untokenizable marker (e.g. "-3.",
|
||||
// "2.5.") that `marked` cannot parse, causing the WHOLE orderedList (and its
|
||||
// listItems) to re-import as a PARAGRAPH — structural corruption. This test
|
||||
// pins that each degenerate start:
|
||||
// (1) exports to "1."/"2." markers (default numbering),
|
||||
// (2) re-imports as a VALID `orderedList` (not a paragraph) with `start`
|
||||
// defaulting, structure preserved,
|
||||
// (3) is byte-stable (md1 === md2).
|
||||
//
|
||||
// Mutation check: revert the converter guard to `Number(raw) || 1` and this
|
||||
// test goes RED (a fractional/negative start leaks into the marker).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const makeDoc = (start: unknown) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'orderedList',
|
||||
attrs: { type: null, start },
|
||||
content: [
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'alpha' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'listItem',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text: 'beta' }] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('#351 orderedList degenerate start normalization', () => {
|
||||
for (const start of [0, -3, 2.5]) {
|
||||
it(`start=${start} exports as default "1."/"2." markers`, () => {
|
||||
const md = convertProseMirrorToMarkdown(makeDoc(start));
|
||||
expect(md).toContain('1. alpha');
|
||||
expect(md).toContain('2. beta');
|
||||
// The degenerate value must NOT leak into any marker.
|
||||
expect(md).not.toMatch(/-?\d*\.\d+\.\s/); // no fractional marker like "2.5."
|
||||
expect(md).not.toContain('-3.');
|
||||
});
|
||||
|
||||
it(`start=${start} re-imports as a valid orderedList (not a paragraph)`, async () => {
|
||||
const md = convertProseMirrorToMarkdown(makeDoc(start));
|
||||
const doc = (await markdownToProseMirror(md)) as any;
|
||||
const top = doc.content?.[0];
|
||||
expect(top?.type).toBe('orderedList');
|
||||
// start defaults (never the degenerate value); tiptap materializes 1.
|
||||
expect(top?.attrs?.start ?? 1).toBe(1);
|
||||
expect(top?.content?.length).toBe(2);
|
||||
});
|
||||
|
||||
it(`start=${start} is byte-stable across a re-export`, async () => {
|
||||
const md1 = convertProseMirrorToMarkdown(makeDoc(start));
|
||||
const doc = await markdownToProseMirror(md1);
|
||||
const md2 = convertProseMirrorToMarkdown(doc);
|
||||
expect(md2).toBe(md1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user