Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 220142bef6 | |||
| 0af7eb30c3 | |||
| 9a94c8df18 | |||
| 8abde99611 | |||
| d608311cae | |||
| 6dad309b51 | |||
| f34542c881 | |||
| b038d96708 | |||
| f5bbfdb2d4 | |||
| 875f6ba9c5 | |||
| 7741821ee3 | |||
| 48a074e0d2 | |||
| 17e3b3d882 | |||
| 801add63e8 | |||
| 7ad072ba2c | |||
| b041944a23 | |||
| 401d62119c | |||
| 4f8563b5b5 | |||
| 27d51303ba | |||
| 51260793c0 | |||
| c765483947 | |||
| 45ff922dd4 | |||
| e3ca2dc1d5 | |||
| f919ced8c9 | |||
| 4109b2ef7f | |||
| 6b27ff0652 | |||
| e133b86982 | |||
| 579c82617b | |||
| 173f35e473 | |||
| 696b96ac18 | |||
| a96ca8e26b | |||
| f84386f24a | |||
| c0c44fddb9 | |||
| 91e58e3c9f | |||
| 8d062728e5 | |||
| f1cffc2d0f | |||
| bb9a6fd765 | |||
| 1c083fbd3d | |||
| 52763998d3 | |||
| bc433d12e6 | |||
| 141ebb4864 | |||
| 045a0afaad | |||
| be433d40f0 | |||
| e56a05926d | |||
| eae7640f30 | |||
| 0099ba272d | |||
| 826bb491ca | |||
| 7be49c1280 | |||
| c7073b62d1 | |||
| 11b2c55485 | |||
| 3a344626db | |||
| bfb6a52eea | |||
| 0503e8b4b1 | |||
| 31f51eaa47 | |||
| b66929714f | |||
| a09935aa29 | |||
| 047433595e | |||
| 9e95412695 | |||
| 2fa86e2a33 | |||
| e3eece78c3 | |||
| e1b8ef5b8b | |||
| b97cad0ebe | |||
| 080dd82023 | |||
| 5adcd2f08b | |||
| 1e7bd1f9d2 | |||
| 803bbd40b4 | |||
| c7a38e274e | |||
| 791a707eb6 | |||
| 60205398bb | |||
| 1685b32333 | |||
| 4b78e336a2 | |||
| 77e20ecb41 | |||
| d533daa45f | |||
| ba58493909 | |||
| 947796adef | |||
| 38a09c5ca1 | |||
| 1b05224b27 | |||
| b50b32bf64 | |||
| 1d704d4ec5 | |||
| 2cb07ef7fb | |||
| 9ba97b6d2b | |||
| 0b9de2c25e | |||
| 123cba7de1 | |||
| 001eb1c923 | |||
| 002c931c6b | |||
| 307ad49324 | |||
| 1031ed1ae8 | |||
| 3b6634c6be | |||
| fdc37de3e8 | |||
| 4a750c1e7f | |||
| ea7c4d7cd2 | |||
| 255024fbe2 | |||
| b934fb2292 | |||
| 14da295185 | |||
| de8f9c804c | |||
| 8ebdfe156f | |||
| 31176d5f93 | |||
| 5f0c49d47c | |||
| b2e5b51420 | |||
| 26d9a70a1c | |||
| e1ebd79f30 | |||
| fc83ea28ab | |||
| ee15278c8f | |||
| 09ab92eccf | |||
| fe5bd159c4 | |||
| f12b685698 | |||
| f6fc914c95 | |||
| 1d89cc2058 | |||
| 70a9e2a9cb | |||
| 5d8083f8ff | |||
| 3e945305c8 | |||
| 6d7dba970c | |||
| 512bcba5f3 | |||
| 5c1ab9c7b5 | |||
| 14d7b21df0 | |||
| 363f20ab75 | |||
| 3411bda2d1 | |||
| e670f7498a | |||
| 9a8671c3af | |||
| bec2156e96 | |||
| acc705de19 | |||
| a49872444e | |||
| ffc38ea2ca | |||
| 2a951df096 | |||
| 5dc7a2703f | |||
| bfb4c8d8d0 | |||
| f794ac6d6c | |||
| e4e788f151 | |||
| 4be4a75fa3 | |||
| e6171a1810 | |||
| 2f23cf4b65 | |||
| d269bd9efe | |||
| d287c15db4 | |||
| 7cb3199d09 | |||
| e19275e96e | |||
| 3a521ada4d | |||
| 576db3c8f9 | |||
| ddb37376a4 | |||
| dc58974b31 | |||
| c917dcc3c1 | |||
| eddc3b5c33 | |||
| e454fe189c | |||
| ea99d4fe63 | |||
| 23966ce51c | |||
| a53b2f454e | |||
| d219eb7525 | |||
| 93d244478e | |||
| 791f709c18 | |||
| f8a27cba91 | |||
| 61dc9b50c1 | |||
| cebb1cca87 | |||
| 605c0f3dda | |||
| 0f5f048ca2 | |||
| 4cb762b039 | |||
| d0f99052cf | |||
| 8c74659d91 | |||
| fe5b6ecd8c | |||
| 2e6f1c3de5 | |||
| 3cba551800 | |||
| 15a9eba562 | |||
| 76af4f692e | |||
| 4809348457 | |||
| d3d32d637b | |||
| 9a435201b8 | |||
| d6827b9210 |
+89
-3
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# MCP_TOKEN=
|
# MCP_TOKEN=
|
||||||
# MCP_SESSION_IDLE_MS=1800000
|
# MCP_SESSION_IDLE_MS=1800000
|
||||||
#
|
#
|
||||||
|
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
||||||
|
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
||||||
|
# go over the collaboration websocket and are serialized PER PAGE by an
|
||||||
|
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
||||||
|
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
||||||
|
#
|
||||||
|
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
||||||
|
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
||||||
|
# two replicas can each "hold" the lock for the same page at the same time and
|
||||||
|
# serialization is silently lost (concurrent full-document writes race on the
|
||||||
|
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
||||||
|
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
||||||
|
# same constraint applies to the RAM-only stash_page blob store above. There is
|
||||||
|
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
||||||
|
# CONSCIOUS documented constraint, not an oversight (#449).
|
||||||
|
#
|
||||||
|
# To reduce connect-storms the write path caches ONE live collab session per
|
||||||
|
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
||||||
|
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
||||||
|
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
||||||
|
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
||||||
|
#
|
||||||
|
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
||||||
|
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
||||||
|
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
||||||
|
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
||||||
|
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
||||||
|
# the session is torn down and the next write re-auths with a fresh token, picking
|
||||||
|
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
||||||
|
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
||||||
|
# stale-rights window. There is intentionally no push-based cache invalidation on
|
||||||
|
# a rights change — this bounded window is the accepted trade-off (#449).
|
||||||
|
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
||||||
|
#
|
||||||
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
||||||
# content + images to an external consumer WITHOUT bloating the model context or
|
# content + images to an external consumer WITHOUT bloating the model context or
|
||||||
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
||||||
@@ -191,11 +225,26 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
|
|
||||||
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
# 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
|
# 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
|
# ~1 min instead of 15. It cuts a legitimately long but byte-silent single tool
|
||||||
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
# call (a slow crawl that emits nothing until done) on the HTTP (streamable)
|
||||||
# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min).
|
# transport, which opens a fresh request per call. The SSE transport — one
|
||||||
|
# long-lived body across many calls — is NO LONGER governed by this timeout
|
||||||
|
# (as of #489): its idle-BETWEEN-calls window has its own, raised bodyTimeout,
|
||||||
|
# AI_MCP_SSE_BODY_TIMEOUT_MS below. Default 60000 (1 min).
|
||||||
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
||||||
|
|
||||||
|
# bodyTimeout (ms) for the EXTERNAL-MCP SSE transport ONLY (#489). The SSE
|
||||||
|
# transport holds ONE response body open across many tool calls, so undici's
|
||||||
|
# bodyTimeout (time between body bytes) counts the LEGITIMATE silence BETWEEN the
|
||||||
|
# model's tool calls, not just a hung single call. At the tight 1-min silence
|
||||||
|
# timeout above, a normal >1-min gap between calls would break the SSE socket and
|
||||||
|
# the cache would serve a dead client until TTL — so the SSE transport gets its
|
||||||
|
# OWN, RAISED bodyTimeout. A single stuck call is still bounded by the per-call
|
||||||
|
# cap (AI_MCP_CALL_TIMEOUT_MS), and a socket that does break is healed by the
|
||||||
|
# in-run transport-error retry. The HTTP (streamable) transport keeps the tight
|
||||||
|
# timeout. Default 600000 (10 min).
|
||||||
|
# AI_MCP_SSE_BODY_TIMEOUT_MS=600000
|
||||||
|
|
||||||
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
# 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)
|
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
||||||
# but never returns a result — which the silence timeout above never breaks.
|
# but never returns a result — which the silence timeout above never breaks.
|
||||||
@@ -254,6 +303,29 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# registry is process-local).
|
# registry is process-local).
|
||||||
# AI_CHAT_RESUMABLE_STREAM=false
|
# AI_CHAT_RESUMABLE_STREAM=false
|
||||||
|
|
||||||
|
# --- Run lifecycle tunables (#487) ---
|
||||||
|
# These govern the universal run machinery (every turn is now a first-class run,
|
||||||
|
# both modes) and rarely need changing.
|
||||||
|
#
|
||||||
|
# How long a server-side SUPERSEDE ("interrupt and send now") waits for the target
|
||||||
|
# run to settle after issuing Stop before it degrades to a 409 SUPERSEDE_TIMEOUT
|
||||||
|
# (nothing sent, the composer keeps the user's text). 10s is generous under a
|
||||||
|
# healthy DB; do NOT raise it to paper over a slow DB — a SUPERSEDE_TIMEOUT is the
|
||||||
|
# honest signal. Default 10000 (10s).
|
||||||
|
# AI_CHAT_SUPERSEDE_TIMEOUT_MS=10000
|
||||||
|
#
|
||||||
|
# How often the periodic bidirectional reconcile job runs (heals runs/messages
|
||||||
|
# left dangling by a crash or a lost terminal write). Default 120000 (2 min).
|
||||||
|
# AI_CHAT_RECONCILE_INTERVAL_MS=120000
|
||||||
|
#
|
||||||
|
# Wall-clock cap for a SINGLE in-app tool call (a long paginated read, or a content
|
||||||
|
# write whose collab commit hangs) — the per-call half of the composite abort
|
||||||
|
# signal every in-app tool is wrapped with (the other half is the turn's Stop).
|
||||||
|
# The reconcile staleness floor is derived as max(2 x this cap, 15min), so a very
|
||||||
|
# high value delays stale-run recovery (the server boot-warns above 30min). Default
|
||||||
|
# 120000 (2 min).
|
||||||
|
# AI_CHAT_INAPP_TOOL_CALL_CAP_MS=120000
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
# When enabled, anonymous visitors of a published share can ask an AI about that
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
||||||
@@ -301,6 +373,20 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
||||||
# METRICS_PORT=9464
|
# METRICS_PORT=9464
|
||||||
#
|
#
|
||||||
|
# METRICS_BIND — interface the /metrics listener binds to. DEFAULT 127.0.0.1
|
||||||
|
# (loopback only), so the unauthenticated endpoint is NOT exposed on all
|
||||||
|
# interfaces. If the scraper runs in a SEPARATE container and reaches this as
|
||||||
|
# docmost:9464, set METRICS_BIND=0.0.0.0 — but then also set METRICS_TOKEN
|
||||||
|
# and/or keep the port on a private network, since /metrics is otherwise open.
|
||||||
|
# METRICS_BIND=127.0.0.1
|
||||||
|
#
|
||||||
|
# METRICS_TOKEN — optional Bearer token guarding /metrics. When set, every
|
||||||
|
# scrape MUST send `Authorization: Bearer <token>` (others get 401). Configure
|
||||||
|
# the scraper with the same bearer token (e.g. VictoriaMetrics/vmagent
|
||||||
|
# `bearer_token`, Prometheus `authorization.credentials`). Leave unset only
|
||||||
|
# when the endpoint is bound to loopback or an otherwise-trusted network.
|
||||||
|
# METRICS_TOKEN=
|
||||||
|
#
|
||||||
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
||||||
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
||||||
# endpoint is registered and browsers collect + send web-vitals / editor
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
||||||
|
|||||||
@@ -62,6 +62,38 @@ jobs:
|
|||||||
needs: [test, e2e-server, e2e-mcp, build]
|
needs: [test, e2e-server, e2e-mcp, build]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
# Image boot-smoke (issue #476): every other job tests code from the working
|
||||||
|
# tree, but the :develop IMAGE that watchtower pulls was never actually
|
||||||
|
# started anywhere (incident classes #353/#452/#361-boot: startup-migrator
|
||||||
|
# crash-loop, runtime module missing from the image, wrong static-asset
|
||||||
|
# headers). The services below back a smoke boot of the exact image right
|
||||||
|
# before it is pushed; a smoke failure blocks the push.
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
# via mirror.gcr.io (Docker Hub pull-through cache; avoids Hub anonymous
|
||||||
|
# pull rate-limit that randomly fails on shared GitHub runner IPs).
|
||||||
|
image: mirror.gcr.io/pgvector/pgvector:pg18
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: docmost
|
||||||
|
POSTGRES_USER: docmost
|
||||||
|
POSTGRES_PASSWORD: docmost
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U docmost"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
|
redis:
|
||||||
|
# via mirror.gcr.io (see postgres note above).
|
||||||
|
image: mirror.gcr.io/library/redis:7
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 5s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 20
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -82,6 +114,37 @@ jobs:
|
|||||||
id: version
|
id: version
|
||||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Load the image into the local docker daemon so it can be booted (the
|
||||||
|
# push step below exports straight to the registry and leaves nothing
|
||||||
|
# runnable locally). CONVENTION: build-args here must stay TEXTUALLY
|
||||||
|
# IDENTICAL to the push step's build-args — same cache scope + same args
|
||||||
|
# means the layers are reused and the image we smoke IS the image we push.
|
||||||
|
- name: Build image for smoke (load, no push)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
APP_VERSION=${{ steps.version.outputs.value }}
|
||||||
|
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: gitmost:smoke
|
||||||
|
cache-from: type=gha,scope=develop-amd64
|
||||||
|
|
||||||
|
# Boot-smoke the exact image against the job services (see the comment on
|
||||||
|
# `services:` above): health (startup migrator), auth/setup, client dist
|
||||||
|
# served, immutable + brotli asset headers. Fails the job (and therefore
|
||||||
|
# the push) on any miss.
|
||||||
|
- name: Smoke the built image
|
||||||
|
run: bash scripts/ci/image-smoke.sh gitmost:smoke
|
||||||
|
|
||||||
|
# The smoke script leaves the container running on failure precisely so
|
||||||
|
# the boot error (migration mismatch, stack trace) is diagnosable here.
|
||||||
|
- name: Dump smoke container log on failure
|
||||||
|
if: failure()
|
||||||
|
run: docker logs gitmost-smoke 2>&1 | tail -200 || true
|
||||||
|
|
||||||
- name: Build and push develop image
|
- name: Build and push develop image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
@@ -157,6 +220,12 @@ jobs:
|
|||||||
- name: Build prosemirror-markdown
|
- name: Build prosemirror-markdown
|
||||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
|
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
|
||||||
|
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
|
||||||
|
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
|
||||||
|
- name: Build mcp
|
||||||
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -124,9 +124,17 @@ jobs:
|
|||||||
exit "$FAILED"
|
exit "$FAILED"
|
||||||
|
|
||||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
||||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
# reproducing seed into property-output.txt. File a dedup-guarded issue.
|
||||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
#
|
||||||
# the next step under a different title) can never poison this dedup.
|
# Dedup is keyed on a HASH of the SHRUNK COUNTEREXAMPLE (the minimal failing
|
||||||
|
# input), NOT on the issue title prefix. Keying on the prefix would let a
|
||||||
|
# single open issue swallow every OTHER counterexample (a different bug B whose
|
||||||
|
# title shares the prefix would be treated as a duplicate and stay silent until
|
||||||
|
# the first issue is closed). Hashing the shrunk example instead means two
|
||||||
|
# DIFFERENT counterexamples get two DIFFERENT issues, while a re-find of the
|
||||||
|
# SAME counterexample still dedupes onto the existing one. The infra-failure
|
||||||
|
# step (below) still keys on its own distinct title, so it can never poison
|
||||||
|
# this dedup either.
|
||||||
- name: File counterexample issue
|
- name: File counterexample issue
|
||||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
||||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
# so a bare `if:` (implicitly success() && ...) would skip this step
|
||||||
@@ -146,25 +154,48 @@ jobs:
|
|||||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
# Extract the SHRUNK counterexample block: the "Counterexample:" line(s)
|
||||||
|
# up to (but excluding) the "Shrunk N time(s)" / "Got error" line. This is
|
||||||
|
# the minimal failing INPUT and is STABLE across the different seeds/paths
|
||||||
|
# that reach the same bug — unlike the seed, path, or shrink count (which
|
||||||
|
# precede/follow this block and vary run-to-run) and unlike the whole
|
||||||
|
# output (which embeds those varying parts). Hashing THIS is what makes the
|
||||||
|
# dedup identity the bug itself rather than an incidental run detail.
|
||||||
|
CE_TEXT=$(awk '/Counterexample:/{c=1} /Shrunk [0-9]+ time|Got error/{c=0} c{print}' property-output.txt)
|
||||||
|
if [ -z "$CE_TEXT" ]; then
|
||||||
|
# No parseable shrunk block (unexpected — the signature check above
|
||||||
|
# already confirmed fast-check output). Fall back to the reproducing
|
||||||
|
# seed so we still emit a stable identity instead of silently deduping.
|
||||||
|
CE_TEXT="seed:${FAIL_SEED}"
|
||||||
|
fi
|
||||||
|
# Stable short id: first 12 hex chars of sha256 over the counterexample.
|
||||||
|
CE_HASH=$(printf '%s' "$CE_TEXT" | sha256sum | cut -c1-12)
|
||||||
|
# Machine-readable marker embedded in the issue body; the open-issue search
|
||||||
|
# below matches on it (and on the hash in the title) so identity travels
|
||||||
|
# with the issue regardless of any human title edits.
|
||||||
|
CE_MARKER="<!-- counterexample-hash: ${CE_HASH} -->"
|
||||||
|
export CE_HASH CE_MARKER
|
||||||
|
TITLE="${TITLE_PREFIX} [${CE_HASH}] (seed=${FAIL_SEED})"
|
||||||
|
|
||||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
# Dedup on the counterexample hash: skip only if an OPEN issue already
|
||||||
# prefix already exists. A failure of this check must NOT block creation.
|
# carries this exact hash (in its title or its body marker). A different
|
||||||
|
# counterexample has a different hash and is NOT deduped. A failure of this
|
||||||
|
# check must NOT block creation.
|
||||||
EXISTING=""
|
EXISTING=""
|
||||||
if EXISTING=$(curl -sS \
|
if EXISTING=$(curl -sS \
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
-H "Authorization: token ${GITHUB_TOKEN}" \
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
||||||
if printf '%s' "$EXISTING" \
|
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
|
| 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 h=process.env.CE_HASH,m=process.env.CE_MARKER;process.exit(a.some(i=>(typeof i.title==="string"&&i.title.includes(h))||(typeof i.body==="string"&&i.body.includes(m)))?0:1)})'; then
|
||||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
echo "An open issue for counterexample ${CE_HASH} already exists — skipping creation."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
# Build the JSON body with the test output SAFELY escaped (never hand-
|
||||||
# interpolate the counterexample into JSON).
|
# 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' \
|
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- counterexample hash: `%s`\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\n%s\n' \
|
||||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
"$CE_HASH" "$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)" "$CE_MARKER")
|
||||||
|
|
||||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
||||||
'{title: $title, body: $body}' > payload.json
|
'{title: $title, body: $body}' > payload.json
|
||||||
|
|||||||
+99
-13
@@ -1,5 +1,13 @@
|
|||||||
name: Test
|
name: Test
|
||||||
|
|
||||||
|
# NO `paths:` filter on purpose (issue #447). The tool-spec REGISTRY is split
|
||||||
|
# across two packages that MUST stay in sync: the specs live in `packages/mcp`
|
||||||
|
# but the parity/tier guard tests that read them live in the `apps/server` jest
|
||||||
|
# suite. A PR touching only `packages/mcp/**` must therefore still run the SERVER
|
||||||
|
# suite (and vice-versa), or an in-app wiring break slips through green and only
|
||||||
|
# surfaces on develop after merge. The `test` job below runs BOTH suites via
|
||||||
|
# `pnpm -r test` on every PR; the dedicated `mcp-server-parity` job makes that
|
||||||
|
# cross-package gate explicit and fast. Do not add a `paths:` filter here.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
@@ -17,37 +25,65 @@ jobs:
|
|||||||
# filename sorts BEFORE migrations already applied on the target branch (and
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
# the PR so the migration is renamed to a current timestamp before merge. Only
|
# the PR so the migration is renamed to a current timestamp before merge.
|
||||||
# runs for pull_request events (needs a base branch to diff against).
|
# Runs for pull_request (diff against the base branch) AND for push (#476
|
||||||
|
# retrospective: a DIRECT push to develop used to bypass this PR-only gate
|
||||||
|
# entirely — now the push is diffed against its `before` SHA; workflow_call
|
||||||
|
# from develop.yml inherits the caller's push event). workflow_dispatch has
|
||||||
|
# nothing to diff against and still skips the job.
|
||||||
migration-order:
|
migration-order:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request' || github.event_name == 'push'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 5
|
timeout-minutes: 5
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout (full history for the base-branch diff)
|
- name: Checkout (full history for the base diff)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Added migrations must sort after the newest on the base branch
|
- name: Added migrations must sort after the newest on the base
|
||||||
env:
|
env:
|
||||||
TARGET_BRANCH: ${{ github.base_ref }}
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
BEFORE_SHA: ${{ github.event.before }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
MIG_DIR="apps/server/src/database/migrations"
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
|
||||||
# WITHOUT --depth (a shallow graft would truncate the base history and
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
# break the merge-base when the base has moved ahead of the PR merge —
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
git fetch --no-tags origin "$TARGET_BRANCH"
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
BASE="origin/${TARGET_BRANCH}"
|
||||||
|
else
|
||||||
|
# push event: compare against the pre-push tip of the branch.
|
||||||
|
if [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
echo "::notice::branch creation push — nothing to compare"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
# The before-SHA is not in the clone (a force-push rewrote history).
|
||||||
|
# One recovery attempt — refresh every remote head (cheap: the
|
||||||
|
# checkout is already fetch-depth:0); a fetch failure aborts via
|
||||||
|
# `set -e`, which is fail-closed too.
|
||||||
|
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
|
||||||
|
fi
|
||||||
|
if ! git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
# FAIL-CLOSED: without the before-SHA there is no base to prove the
|
||||||
|
# ordering against, and a gate whose job is to BLOCK must not guess.
|
||||||
|
echo "::error::force-push detected — verify migration order manually, then re-run via workflow_dispatch"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
BASE="$BEFORE_SHA"
|
||||||
|
fi
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "$BASE" "$MIG_DIR" | sort | tail -1)
|
||||||
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
# `set -e` above already aborts on a non-zero diff exit.
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
added=$(git diff --diff-filter=A --name-only "${BASE}...HEAD" -- "$MIG_DIR")
|
||||||
bad=0
|
bad=0
|
||||||
for f in $added; do
|
for f in $added; do
|
||||||
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
echo "::error::Migration $f sorts at or before the newest on the base ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
bad=1
|
bad=1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -132,3 +168,53 @@ jobs:
|
|||||||
# isolated `docmost_test` DB and migrates it to latest.
|
# isolated `docmost_test` DB and migrates it to latest.
|
||||||
- name: Run server integration tests
|
- name: Run server integration tests
|
||||||
run: pnpm --filter server test:int
|
run: pnpm --filter server test:int
|
||||||
|
|
||||||
|
# Cross-package tool-spec parity gate (issue #447). The tool-spec registry lives
|
||||||
|
# in `packages/mcp` but its parity/tier guard tests live in the `apps/server`
|
||||||
|
# jest suite, so a PR touching ONLY one of the two packages must still run BOTH
|
||||||
|
# sides — otherwise an in-app wiring break (e.g. PR #434 drawio) passes the mcp
|
||||||
|
# suite green and only surfaces on develop after merge. The `test` job already
|
||||||
|
# runs everything via `pnpm -r test`; this job is a fast, explicitly-named guard
|
||||||
|
# that runs the mcp `node --test` suite AND the server tool-guard jest specs
|
||||||
|
# together, so the coupling is visible and can never be accidentally split by a
|
||||||
|
# path filter. No Postgres/Redis needed: these specs mock the DB/loader.
|
||||||
|
mcp-server-parity:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
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
|
||||||
|
|
||||||
|
# Shared deps first (build/ dirs are gitignored; see test.yml build order).
|
||||||
|
- name: Build editor-ext
|
||||||
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
|
# Build the mcp package so build/ carries a FRESH REGISTRY_STAMP (#447): the
|
||||||
|
# build runs gen-registry-stamp.mjs before tsc, so a build/ vs src/ skew
|
||||||
|
# cannot slip into the tests that exercise the loader's stale-check.
|
||||||
|
- name: Build mcp (regenerates REGISTRY_STAMP)
|
||||||
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
|
# mcp side: the standalone MCP server's own tool-spec / instructions guards.
|
||||||
|
- name: Run mcp tool-spec suite
|
||||||
|
run: pnpm --filter @docmost/mcp test
|
||||||
|
|
||||||
|
# server side: the parity + tier guards that read packages/mcp/src/tool-specs
|
||||||
|
# and assert the in-app AI-chat wiring matches it.
|
||||||
|
- name: Run server tool-spec guard specs
|
||||||
|
run: pnpm --filter server exec jest shared-tool-specs.contract tool-tiers ai-chat-tools.service --runInBand
|
||||||
|
|||||||
+14
@@ -2,6 +2,11 @@
|
|||||||
.env.dev
|
.env.dev
|
||||||
.env.prod
|
.env.prod
|
||||||
data
|
data
|
||||||
|
# Exception: the committed draw.io shape catalog (issue #424) lives in a `data/`
|
||||||
|
# dir, but the bare `data` ignore above is meant for runtime state, not this
|
||||||
|
# bundled build asset. Re-include the directory and its contents.
|
||||||
|
!packages/mcp/data/
|
||||||
|
!packages/mcp/data/**
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
node_modules
|
node_modules
|
||||||
@@ -19,6 +24,15 @@ packages/prosemirror-markdown/build/
|
|||||||
# markdown convention; the package is private and rebuilt at deploy.
|
# markdown convention; the package is private and rebuilt at deploy.
|
||||||
packages/mcp/build/
|
packages/mcp/build/
|
||||||
|
|
||||||
|
# mcp REGISTRY_STAMP codegen output (issue #447). Regenerated into src/ by
|
||||||
|
# scripts/gen-registry-stamp.mjs on every `build`/`pretest` (before tsc), so it
|
||||||
|
# is a build artifact like build/ — never committed, always fresh.
|
||||||
|
packages/mcp/src/registry-stamp.generated.ts
|
||||||
|
|
||||||
|
# token-estimate compiled output (#490; built in CI/Docker via `pnpm build` /
|
||||||
|
# the server `pretest`, never committed, so src/ and prod can never diverge).
|
||||||
|
packages/token-estimate/dist/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -5,6 +5,139 @@ repository. It has two layers: **how to run a task end-to-end** (the
|
|||||||
sections below), and **how the codebase is built** (the technical sections
|
sections below), and **how the codebase is built** (the technical sections
|
||||||
further down, formerly in `CLAUDE.md`).
|
further down, formerly in `CLAUDE.md`).
|
||||||
|
|
||||||
|
## ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE
|
||||||
|
|
||||||
|
THE TEN RULES BELOW ARE HARD CONSTRAINTS. Each one was paid for with a real
|
||||||
|
production incident or a multi-PR bug chain in THIS repository (cited inline).
|
||||||
|
They override convenience, deadlines and "it's just a small feature". A PR that
|
||||||
|
violates any of them MUST be rejected in review regardless of how good the rest
|
||||||
|
of it is. If a task genuinely seems to require breaking one — STOP and raise it
|
||||||
|
with the owner; do not code around it.
|
||||||
|
|
||||||
|
### 1. EVERY BUFFER, CACHE, HISTORY AND PAYLOAD HAS AN EXPLICIT SIZE BUDGET
|
||||||
|
|
||||||
|
Nothing accumulates unboundedly. A row/item cap is NOT a byte cap. Anything
|
||||||
|
replayed to a model, buffered in memory, persisted per step, or refetched by a
|
||||||
|
poll must state its budget in bytes/tokens and enforce it. Rewriting a growing
|
||||||
|
structure in full on every increment is FORBIDDEN — append or diff instead;
|
||||||
|
O(n²) write/serialize patterns do not pass review.
|
||||||
|
(Paid for by: full-row rewrite on every agent step — hundreds of MB of Postgres
|
||||||
|
writes per 50-step run, with every tool output serialized twice; unbounded
|
||||||
|
history replay killing long chats on the provider context window; 32 MB replay
|
||||||
|
buffers per active run.)
|
||||||
|
|
||||||
|
### 2. EVERYTHING LONG-RUNNING TERMINATES BY CONSTRUCTION
|
||||||
|
|
||||||
|
Every run / row / session / lease / subscriber / queue entry must define AT
|
||||||
|
DESIGN TIME: its owner; every terminal state; who writes the terminal state on
|
||||||
|
EVERY path (success, error, abort, disconnect in each phase, process restart);
|
||||||
|
retries for the terminal write; and a periodic sweeper that does not depend on
|
||||||
|
a reboot. A best-effort terminal write with no retry and no sweep is FORBIDDEN.
|
||||||
|
(Paid for by: assistant rows stuck 'streaming' forever; runs stuck 'running'
|
||||||
|
409-locking their chat until a restart — the #183/#184 follow-up chain.)
|
||||||
|
|
||||||
|
### 3. EVERY AWAIT IS CANCELLABLE AND DEADLINED; NEVER BLOCK THE EVENT LOOP
|
||||||
|
|
||||||
|
Every async step inside a request or agent turn honors the turn's AbortSignal
|
||||||
|
AND a wall-clock deadline — including in-app tools, lock queues and pagination
|
||||||
|
loops, not just external calls. Synchronous CPU work beyond ~50 ms goes to a
|
||||||
|
worker_thread. Promise.race DOES NOT cancel synchronous work — using it as a
|
||||||
|
"timeout" for sync computation is forbidden (the timer only fires after the
|
||||||
|
event loop is free again, i.e. after the damage is done).
|
||||||
|
(Paid for by: in-app tools ignoring abortSignal and writing pages AFTER Stop;
|
||||||
|
the synchronous ELK layout freezing every SSE stream in the process; the
|
||||||
|
step-0 MCP handshake hang — #397.)
|
||||||
|
|
||||||
|
### 4. ONE SOURCE OF TRUTH; EVERYTHING ELSE IS A REBUILDABLE CACHE
|
||||||
|
|
||||||
|
Postgres is the authoritative state. Every in-memory structure (registries,
|
||||||
|
caches, client stores) must be reconstructible from the DB and treated as
|
||||||
|
lossy. The client renders SERVER-DECLARED state — "a run is active" is a server
|
||||||
|
fact delivered as data, never inferred from side signals (204 vs 2xx, the
|
||||||
|
flavor of a disconnect). A new feature must name the owner of each piece of
|
||||||
|
state before implementation starts.
|
||||||
|
(Paid for by: the strip/restore resume machinery, silently frozen UIs and
|
||||||
|
ghost sends after unmount — the #381→#432→#456 chain.)
|
||||||
|
|
||||||
|
### 5. STATE MACHINES ARE EXPLICIT — ONE-SHOT FLAGS ARE FORBIDDEN
|
||||||
|
|
||||||
|
A complex lifecycle (chat thread, resume/reconnect, run) lives in a named-state
|
||||||
|
automaton (reducer / enum) where every state has an owner and a rendered
|
||||||
|
representation — including the failure states. Adding a boolean ref that one
|
||||||
|
callback arms and another reads-and-clears is FORBIDDEN in the AI-chat client.
|
||||||
|
New behavior = a new named state + explicit transitions, and the interruption
|
||||||
|
matrix (disconnect in each phase × restart × stop × supersede) is enumerated at
|
||||||
|
design time, not discovered one incident at a time.
|
||||||
|
(Paid for by: 26 one-shot useRef flags in chat-thread.tsx and the drip of
|
||||||
|
"one more missing transition" across #381→#386/#389→#432→#456.)
|
||||||
|
|
||||||
|
### 6. NO NEW MODE FORKS; A FLAG IS FOR ROLLOUT, THEN IT DIES
|
||||||
|
|
||||||
|
A behavior flag that forks a code path must ship with a written sunset
|
||||||
|
condition; stacking a new flag onto the existing matrix without deleting or
|
||||||
|
scheduling an old one is forbidden. While a temporary fork exists, BOTH sides
|
||||||
|
must share identical lifecycle handling (abort semantics, error listeners,
|
||||||
|
concurrency gates) — asymmetric forks are outlawed.
|
||||||
|
(Paid for by: legacy vs autonomous divergence — the one-active-run gate and
|
||||||
|
the socket 'error' listener each existing on only ONE side; 2^4 flag
|
||||||
|
combinations each with different abort semantics.)
|
||||||
|
|
||||||
|
### 7. NO HAND-SYNCED MIRRORS — CODEGEN OR A CI PARITY TEST, NOTHING LESS
|
||||||
|
|
||||||
|
Two copies of the same knowledge (schema, tool registry, glyph map, probe
|
||||||
|
body, hash/normalize algorithm, label list) require either generation from a
|
||||||
|
single source or a CI test that FAILS on drift. A "mirror this change over
|
||||||
|
there" comment is NOT a guard and does not pass review.
|
||||||
|
(Paid for by: #293 — three drifting converter copies losing data; #447 —
|
||||||
|
REGISTRY_STAMP covering only one of the mirrored files; ~10 still-unguarded
|
||||||
|
mirrors across the MCP layer.)
|
||||||
|
|
||||||
|
### 8. CACHES, HEADERS, BUFFERS AND FSM TRANSITIONS GET AN INTEGRATION TEST OF THE OBSERVABLE PROPERTY
|
||||||
|
|
||||||
|
A unit test of a pure helper DOES NOT COUNT for these. Test the real header on
|
||||||
|
the real HTTP response, the real cache hit under real token sources, the real
|
||||||
|
transition under a really-killed socket. If the observable property cannot be
|
||||||
|
tested, the design is wrong — fix the design, not the test.
|
||||||
|
(Paid for by: #431→#439 — a cache keyed on a fresh-per-call JWT, so it NEVER
|
||||||
|
hit and became prod incident #435 while its unit tests stayed green; and by
|
||||||
|
the #352→#455 immutable-cache header silently overwritten by a framework
|
||||||
|
default AFTER the unit-tested code ran.)
|
||||||
|
|
||||||
|
### 9. CLIENT INPUT IS HOSTILE UNTIL VALIDATED — ALSO BEFORE PERSISTENCE
|
||||||
|
|
||||||
|
Anything from the browser (message parts, ids, titles, selections, flags) is
|
||||||
|
validated/sanitized BEFORE it is persisted into a row that will later be
|
||||||
|
replayed into a prompt, a converter or another subsystem. A poisoned row must
|
||||||
|
never be able to permanently brick a chat or a page on every subsequent read.
|
||||||
|
(Paid for by: unvalidated UIMessage parts persisted verbatim — one bad row
|
||||||
|
500s the chat on every later turn; #159 client-spoofed page titles; #388
|
||||||
|
selection re-sanitized server-side for the same reason.)
|
||||||
|
|
||||||
|
### 10. FAILURES ARE LOUD AND SPECIFIC; SILENT DEGRADATION IS FORBIDDEN
|
||||||
|
|
||||||
|
Extends the error convention below: a fire-and-forget write is allowed ONLY
|
||||||
|
with a metric or a greppable ERROR log; a degraded mode (dead cached MCP
|
||||||
|
client, stopped poll, exhausted retries, evicted buffer) must be VISIBLE to
|
||||||
|
the user or the operator. A feature that can quietly stop working — a frozen
|
||||||
|
"streaming…" UI, a poll that silently gives up, a cache serving corpses — does
|
||||||
|
not pass review.
|
||||||
|
(Paid for by: the degraded poll's silent 10-minute death leaving a forever-
|
||||||
|
"streaming" answer; dead MCP clients served from cache while every external
|
||||||
|
tool call failed; #435 being caught in minutes ONLY because metrics — #403 —
|
||||||
|
existed.)
|
||||||
|
|
||||||
|
## Default skill for feature design
|
||||||
|
|
||||||
|
For any feature-design request — the user hands over a raw feature idea, asks
|
||||||
|
to design or think through a feature, or to draft an issue («спроектируй»,
|
||||||
|
«продумай фичу», «составь ишью», "design X", "write an issue for X") — invoke
|
||||||
|
the `orchestrator-feature-designer` skill (Skill tool) BEFORE any other work.
|
||||||
|
It is the default operating mode for design work in this repository: research
|
||||||
|
→ design checklist (R1–R10) → forks resolved with the human → adversarial
|
||||||
|
self-attack → filed PR-sized issues. Do not design features or write issues
|
||||||
|
ad-hoc while this skill is available. This does not apply to non-design work
|
||||||
|
(bug fixes, reviews, retrospectives, refactors already specified by an issue).
|
||||||
|
|
||||||
## Task lifecycle
|
## Task lifecycle
|
||||||
|
|
||||||
### 1. Start: sync with develop
|
### 1. Start: sync with develop
|
||||||
@@ -201,7 +334,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked; jsdom (Node only) | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, `apps/server` (server-side markdown import/export, #345), AND `apps/client` (markdown paste/copy + AI-chat render, via the `browser` entry — native `DOMParser`, no jsdom in the client bundle, #347); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -248,6 +381,22 @@ pnpm collab:dev # run the collaboration server process standalone (
|
|||||||
> that order). Reach for it whenever you run a consumer package's checks on their
|
> that order). Reach for it whenever you run a consumer package's checks on their
|
||||||
> own rather than through the full `pnpm build`.
|
> own rather than through the full `pnpm build`.
|
||||||
|
|
||||||
|
> **Editing an MCP tool spec requires a rebuild (issue #447).** The running
|
||||||
|
> server loads the **compiled** `packages/mcp/build/` of `@docmost/mcp` (via the
|
||||||
|
> runtime loader in `apps/server/src/core/ai-chat/tools/docmost-client.loader.ts`),
|
||||||
|
> but the parity/tier guard tests read `packages/mcp/src/tool-specs.ts`. So if you
|
||||||
|
> edit `tool-specs.ts` (any tool name, description, tier, catalog line, or input
|
||||||
|
> schema) **without rebuilding**, `build/` and `src/` silently diverge — the tests
|
||||||
|
> stay green while the server serves the OLD tools. To close that gap, the build
|
||||||
|
> emits a `REGISTRY_STAMP` (a deterministic hash of the tool-specs content, via
|
||||||
|
> `scripts/gen-registry-stamp.mjs` before `tsc`); on dev/test startup the loader
|
||||||
|
> recomputes it from `src/` and **refuses to start with a "@docmost/mcp build is
|
||||||
|
> stale …" error** on a mismatch (a pure no-op in prod, where only `build/` ships).
|
||||||
|
> After editing tool specs, rebuild:
|
||||||
|
> ```bash
|
||||||
|
> pnpm --filter @docmost/mcp build # or: pnpm --filter @docmost/mcp watch
|
||||||
|
> ```
|
||||||
|
|
||||||
**Lint** (per package — there is no root lint script):
|
**Lint** (per package — there is no root lint script):
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter server lint # eslint --fix on server .ts
|
pnpm --filter server lint # eslint --fix on server .ts
|
||||||
@@ -306,12 +455,12 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
- `core/ai-chat/tools/` — the agent's ~40 read+write tools. Every tool runs under the **calling user's** CASL permissions via a per-user loopback access token (`docmost-client.loader.ts`), so the agent can never exceed what the user could do. Only **reversible** operations are exposed (page history + trash; no permanent delete). Agent edits get an "AI agent" provenance badge in page history (`20260616T130000-agent-provenance` migration).
|
||||||
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
- `core/ai-chat/embedding/` — RAG indexer + a BullMQ consumer on `AI_QUEUE` that embeds pages into `page_embeddings` (vector search), complementing Postgres full-text search. Pages are (re)indexed on edit; `AI_EMBEDDING_TIMEOUT_MS` bounds a hung embeddings endpoint.
|
||||||
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
- `core/ai-chat/external-mcp/` — admins can attach external MCP servers (e.g. Tavily) to give the agent web access. **`ssrf-guard.ts` validates outbound MCP URLs against SSRF** — keep that guard in the path when touching external-MCP connection logic.
|
||||||
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **detached/autonomous agent runs** (`#184`), behind the per-workspace `settings.ai.autonomousRuns` flag (off by default). When on, a turn becomes a server-side RUN that survives a browser disconnect; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
- `core/ai-chat/ai-chat-run.service.ts` + `ai_chat_runs` — **every agent turn is now a first-class server-side RUN** (`#184`, universalized in `#487`): its lifecycle is tracked in `ai_chat_runs` in **both** modes, and the single-active-run-per-chat concurrency gate is enforced universally (a legacy second tab now gets a clean `409 A_RUN_ALREADY_ACTIVE` instead of a second parallel stream that interleaved history). The per-workspace `settings.ai.autonomousRuns` flag (off by default) **no longer gates whether a turn is a run** — it now controls **only the browser-disconnect semantics**: when ON the run is *detached* (a disconnect leaves it executing server-side; only an explicit `POST /ai-chat/stop` ends it, and a client reconnects/live-follows via `POST /ai-chat/run`); when OFF (legacy) a disconnect ends the turn by stopping its run via the run's stop lever. `#487` also adds a server-side **supersede** CAS ("interrupt and send now") to `POST /ai-chat/stream` (`supersede: { runId }`): it atomically stops the chat's currently-active run and waits for it to settle before the new turn claims the slot, returning `SUPERSEDE_INVALID` / `SUPERSEDE_TARGET_MISMATCH` / `SUPERSEDE_TIMEOUT` on the non-proceed branches. **DEPLOY CONSTRAINT — single-instance only in phase 1:** Stop and the AbortController that backs it are process-local, so a Stop only aborts a run executing on the **same** replica that owns it (cross-instance pub/sub stop is phase 2). Do **not** enable `autonomousRuns` on a horizontally-scaled deployment (multiple replicas behind a load balancer, or Docmost cloud `CLOUD=true`) — run a single instance instead. The server logs a startup WARNING when it detects a multi-instance deployment (`CLOUD=true`) so the constraint is visible. The startup sweep settles any run left dangling by a restart.
|
||||||
|
|
||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, 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`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
@@ -321,8 +470,10 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch carries TWO independent server fixes, each with its own tripwire test: (1) it disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`); (2) it fixes `writeToServerResponse`'s drain-hang — the loop awaited only `"drain"` under backpressure, so a mid-write client disconnect parked the pipe forever and leaked the reader/buffers until restart; it now races `"drain"` against `"close"`/`"error"`, cancels the reader on disconnect, and swallows the fire-and-forget read rejection (#486; tripwire: `apps/server/src/integrations/ai/ai-sdk-drain-hang.patch.spec.ts`). Both tripwires assert BOTH installed dist builds carry their patch marker. The patch MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
- **Upstream tracking (report the analysis upstream, don't just carry it):** both `ai` fixes and the hocuspocus one are candidates for upstreaming so we can eventually drop the local patch — the analysis is already written up in each patch's `PATCH(...)` header comments. File (a) an upstream **issue** on `vercel/ai` for the O(n²) cumulative `partialOutput` accumulation (heap OOM), (b) an upstream **issue** on `vercel/ai` for the `writeToServerResponse` drain-hang, and (c) an upstream **PR** on `@hocuspocus/server` for the connect-vs-unload race (local marker `PATCH(gitmost #401)` in `patches/@hocuspocus__server@3.4.4.patch`). Do NOT edit the patch files to add links — the patch bytes feed `patch_hash` in `pnpm-lock.yaml` (`ai@6.0.134` → `e8c599b3…`), so any content change there desyncs the lockfile pin and breaks `pnpm install`; keep upstream references here instead.
|
||||||
|
- **`ai` version is split across the monorepo and MUST be aligned deliberately, NOT casually:** the server pins `ai@6.0.134` (patched, exact — the `patchedDependencies` key forces that version), while the client declares `ai@6.0.207` (unpatched — the server-side `writeToServerResponse`/`partialOutput` fixes are dead code in the browser, so the mismatch is currently benign but is real drift). Alignment is a **planned, install-gated step**, never a bare `package.json` edit: (1) choose the target version; (2) re-create ALL THREE patch hunks (partialOutput publish-each, the `DefaultStreamTextResult` lazy-`output` wiring, and the drain-hang race) against the target dist via `pnpm patch` — the line offsets shift between versions, so the current patch WILL fail to apply as-is; (3) run a full `pnpm install` so the lockfile + new `patch_hash` regenerate together; (4) confirm both tripwire specs still find their markers. `pnpm install` FAILS HARD on an unapplied patch — that failure is the guardrail, so treat the port as a deliberate plan rather than discovering it as a deploy-time surprise.
|
||||||
|
- **The MCP tool inventory in `SERVER_INSTRUCTIONS` is GENERATED from the registry** (`packages/mcp/src/server-instructions.ts`: `buildToolInventory()` over `SHARED_TOOL_SPECS`) and spliced into the hand-written routing prose (`ROUTING_PROSE`). So adding/renaming/removing a **shared** spec in `packages/mcp/src/tool-specs.ts` auto-updates the `<tool_inventory>` — no manual `SERVER_INSTRUCTIONS` edit needed. Only an **inline** MCP-only tool (those registered via `server.registerTool(...)` in `index.ts`, not through the registry) needs a one-line entry in `INLINE_MCP_INVENTORY`. Enforced by `packages/mcp/test/unit/tool-inventory.test.mjs`, which fails when a registered tool is missing from the generated inventory (there is no `EXCEPTIONS` opt-out anymore — every tool must appear). Update `ROUTING_PROSE` when a tool's *intent guidance* (when-to-use) changes. `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|
||||||
|
|||||||
+319
@@ -10,8 +10,132 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **External MCP tool names are now camelCase (all renamed).** Every tool on the
|
||||||
|
external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the
|
||||||
|
external MCP name now matches the in-app tool name exactly (one logical tool,
|
||||||
|
one name everywhere). For example `get_node` → `getNode`, `edit_page_text` →
|
||||||
|
`editPageText`, `patch_node` → `patchNode`. The tools' behaviour, inputs and
|
||||||
|
outputs are unchanged — only the names change. The single-word `search`
|
||||||
|
keeps its name.
|
||||||
|
|
||||||
|
*Migration (external MCP clients only — the in-app AI agent already used these
|
||||||
|
names and is unaffected):* update anything that refers to a tool by its
|
||||||
|
string name — permission allowlists (`mcp__gitmost-*__get_node` →
|
||||||
|
`mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters,
|
||||||
|
and metrics dashboards that group by the `tool` label — and roll it out in
|
||||||
|
lockstep with this deploy, because the old snake_case names stop resolving.
|
||||||
|
Released together with the `import_page_markdown`/`update_page_markdown`
|
||||||
|
change below so external configs break exactly once.
|
||||||
|
|
||||||
|
Full mapping (old → new):
|
||||||
|
|
||||||
|
| Old (snake_case) | New (camelCase) |
|
||||||
|
| --- | --- |
|
||||||
|
| `check_new_comments` | `checkNewComments` |
|
||||||
|
| `copy_page_content` | `copyPageContent` |
|
||||||
|
| `create_comment` | `createComment` |
|
||||||
|
| `create_page` | `createPage` |
|
||||||
|
| `delete_comment` | `deleteComment` |
|
||||||
|
| `delete_node` | `deleteNode` |
|
||||||
|
| `delete_page` | `deletePage` |
|
||||||
|
| `diff_page_versions` | `diffPageVersions` |
|
||||||
|
| `docmost_transform` | `docmostTransform` |
|
||||||
|
| `drawio_create` | `drawioCreate` |
|
||||||
|
| `drawio_get` | `drawioGet` |
|
||||||
|
| `drawio_guide` | `drawioGuide` |
|
||||||
|
| `drawio_shapes` | `drawioShapes` |
|
||||||
|
| `drawio_update` | `drawioUpdate` |
|
||||||
|
| `edit_page_text` | `editPageText` |
|
||||||
|
| `export_page_markdown` | `exportPageMarkdown` |
|
||||||
|
| `get_node` | `getNode` |
|
||||||
|
| `get_outline` | `getOutline` |
|
||||||
|
| `get_page` | `getPage` |
|
||||||
|
| `get_page_json` | `getPageJson` |
|
||||||
|
| `get_workspace` | `getWorkspace` |
|
||||||
|
| `insert_footnote` | `insertFootnote` |
|
||||||
|
| `insert_image` | `insertImage` |
|
||||||
|
| `insert_node` | `insertNode` |
|
||||||
|
| `list_comments` | `listComments` |
|
||||||
|
| `list_page_history` | `listPageHistory` |
|
||||||
|
| `list_pages` | `listPages` |
|
||||||
|
| `list_shares` | `listShares` |
|
||||||
|
| `list_spaces` | `listSpaces` |
|
||||||
|
| `move_page` | `movePage` |
|
||||||
|
| `patch_node` | `patchNode` |
|
||||||
|
| `rename_page` | `renamePage` |
|
||||||
|
| `replace_image` | `replaceImage` |
|
||||||
|
| `resolve_comment` | `resolveComment` |
|
||||||
|
| `restore_page_version` | `restorePageVersion` |
|
||||||
|
| `search` | `search` (unchanged) |
|
||||||
|
| `search_in_page` | `searchInPage` |
|
||||||
|
| `share_page` | `sharePage` |
|
||||||
|
| `stash_page` | `stashPage` |
|
||||||
|
| `table_delete_row` | `tableDeleteRow` |
|
||||||
|
| `table_get` | `tableGet` |
|
||||||
|
| `table_insert_row` | `tableInsertRow` |
|
||||||
|
| `table_update_cell` | `tableUpdateCell` |
|
||||||
|
| `unshare_page` | `unsharePage` |
|
||||||
|
| `update_comment` | `updateComment` |
|
||||||
|
| `update_page_json` | `updatePageJson` |
|
||||||
|
| `update_page_markdown` | `updatePageMarkdown` |
|
||||||
|
|
||||||
|
(#412)
|
||||||
|
|
||||||
|
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
|
||||||
|
The external `/mcp` surface no longer exposes `importPageMarkdown` (the
|
||||||
|
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
|
||||||
|
its place it now exposes **`updatePageMarkdown`** — a plain-Markdown
|
||||||
|
full-body replace (`{pageId, content, title?}`) that pairs with
|
||||||
|
`updatePageJson`, re-imports the whole body (block ids regenerate) and
|
||||||
|
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
|
||||||
|
*Migration:* MCP clients that called `importPageMarkdown` to overwrite a
|
||||||
|
page's body from Markdown should call `updatePageMarkdown` instead (pass the
|
||||||
|
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
|
||||||
|
comment anchors/diagrams is no longer available on the external MCP surface;
|
||||||
|
export remains via `exportPageMarkdown`. The in-app AI agent is unaffected —
|
||||||
|
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
|
||||||
|
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The
|
||||||
|
external names shown here are the post-#412 camelCase names. (#411)
|
||||||
|
|
||||||
|
- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The
|
||||||
|
block-level read/write tools default to Markdown so a block round trip is
|
||||||
|
`getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns
|
||||||
|
`{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which
|
||||||
|
restores the previous `{ …, node }` ProseMirror subtree); comment anchors —
|
||||||
|
including resolved ones — are preserved in the markdown so a write-back never
|
||||||
|
orphans a thread, and a node that cannot be a document top-level block
|
||||||
|
(`tableRow`/`tableCell`/`tableHeader` addressed via `#<index>`) auto-falls back
|
||||||
|
to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a
|
||||||
|
`markdown` input alongside `node` (provide exactly one): the markdown fragment
|
||||||
|
may rewrite/insert several blocks at once and supports `^[...]` footnotes.
|
||||||
|
*Migration (external MCP clients only):* a client that consumed `getNode`'s
|
||||||
|
`node` field must now either read `markdown`, or pass `format: "json"` to keep
|
||||||
|
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
||||||
|
breaking window so external configs break exactly once. (#413)
|
||||||
|
|
||||||
|
- **The Prometheus `/metrics` listener now binds to `127.0.0.1` (loopback) by
|
||||||
|
default instead of `0.0.0.0` (all interfaces).** This closes an unauthenticated
|
||||||
|
endpoint that was previously reachable on every interface. **DEPLOY MIGRATION —
|
||||||
|
cross-container scraping breaks silently otherwise:** if your scraper runs in a
|
||||||
|
SEPARATE container and reaches the app as `docmost:9464` (the exact topology the
|
||||||
|
old `0.0.0.0` hardcode served), you MUST now set `METRICS_BIND=0.0.0.0` — and,
|
||||||
|
because that re-exposes the endpoint, also set `METRICS_TOKEN=<secret>` and
|
||||||
|
configure the scraper with a matching Bearer token. Without `METRICS_BIND`, the
|
||||||
|
scraper can no longer connect and metrics go dark with no error. See the
|
||||||
|
`METRICS_BIND` / `METRICS_TOKEN` block in `.env.example` for the migration.
|
||||||
|
Same-host (loopback) scrapers need no change. (#486)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **A drifted comment suggestion can be re-synced instead of failing forever
|
||||||
|
with a 409.** A suggestion whose stored anchor no longer matched the live
|
||||||
|
document used to reject every apply attempt with an unrecoverable conflict; a
|
||||||
|
new resync path re-reads the live anchor so the suggestion applies against the
|
||||||
|
current text, and orphaned anchors (whose marked run was deleted) are
|
||||||
|
reconciled rather than left blocking. (#496)
|
||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. The row is
|
||||||
@@ -85,6 +209,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
dangling by a restart. Phase 1 is single-instance-only (cross-instance Stop is
|
||||||
not yet reliable); the server warns at startup on a horizontally-scaled
|
not yet reliable); the server warns at startup on a horizontally-scaled
|
||||||
deployment. (#184)
|
deployment. (#184)
|
||||||
|
- **Server-side "interrupt and send now" (supersede) for AI chat.** `POST
|
||||||
|
/ai-chat/stream` now accepts a `supersede: { runId }` field: when the user sends
|
||||||
|
a new message while a run is active, the server atomically stops that run and
|
||||||
|
waits for it to settle before the new turn claims the chat's single run slot,
|
||||||
|
instead of the send being rejected as concurrent. The compare-and-set surfaces
|
||||||
|
three codes on its non-proceed branches — `SUPERSEDE_INVALID` (the targeted run
|
||||||
|
is malformed / belongs to another chat), `SUPERSEDE_TARGET_MISMATCH` (a
|
||||||
|
different run is now active; carries the current `activeRunId`), and
|
||||||
|
`SUPERSEDE_TIMEOUT` (the previous run did not stop within the settle window, so
|
||||||
|
nothing was sent and the composer keeps the text). Tunable via
|
||||||
|
`AI_CHAT_SUPERSEDE_TIMEOUT_MS` (default 10s). (#487)
|
||||||
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
- **Out-of-band page transfer via an in-RAM blob sandbox (`stash_page`).** A
|
||||||
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
new MCP tool serializes a whole page (its full ProseMirror JSON, with every
|
||||||
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
internal image/file mirrored) into an ephemeral in-RAM blob and returns only
|
||||||
@@ -146,9 +281,56 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
by physical key position and matched against the commands; genuine Cyrillic
|
by physical key position and matched against the commands; genuine Cyrillic
|
||||||
search terms keep priority over remapped candidates, and short wrong-layout
|
search terms keep priority over remapped candidates, and short wrong-layout
|
||||||
prefixes match by command title. (#283, #285, #287)
|
prefixes match by command title. (#283, #285, #287)
|
||||||
|
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
|
||||||
|
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
|
||||||
|
substrings of page titles and body text — so technical tokens the full-text
|
||||||
|
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
|
||||||
|
even when the FTS query is empty. It returns a location `path`, a windowed
|
||||||
|
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
|
||||||
|
`parentPageId` subtree scope, and applies the page-level permission filter
|
||||||
|
before the limit. The web UI never sets `substring`, so its full-text search
|
||||||
|
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
|
||||||
|
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
|
||||||
|
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
|
||||||
|
a sequential scan. (#443)
|
||||||
|
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
|
||||||
|
`search` response shape changes for the agent surface: each hit now carries
|
||||||
|
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
|
||||||
|
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Every AI-chat turn is now a first-class server-side run, and one run per chat
|
||||||
|
is enforced in both modes.** The run machinery from `#184` was universalized: a
|
||||||
|
turn is tracked in `ai_chat_runs` and gated by the single-active-run-per-chat
|
||||||
|
index regardless of the `settings.ai.autonomousRuns` flag. **Behavior change:**
|
||||||
|
a second tab (or a double-submit) that starts a turn while one is already active
|
||||||
|
on the chat is now rejected up front with `409 A_RUN_ALREADY_ACTIVE` (carrying
|
||||||
|
the `activeRunId`); previously, on the legacy path, it opened a second parallel
|
||||||
|
stream on the same chat that interleaved history. The `autonomousRuns` flag no
|
||||||
|
longer controls whether a turn is a run — it now governs **only** the
|
||||||
|
browser-disconnect semantics (ON = detached/survives a disconnect; OFF = a
|
||||||
|
disconnect stops the run). (#487)
|
||||||
|
- **Vendor `ai` patch: upstream-tracking + version-alignment plan documented.**
|
||||||
|
The two local `ai@6.0.134` fixes (O(n²) `partialOutput` heap-OOM; the
|
||||||
|
`writeToServerResponse` drain-hang) and the hocuspocus connect-vs-unload race
|
||||||
|
now have explicit upstream-reporting and `ai`-version-alignment steps recorded
|
||||||
|
in `AGENTS.md` (client `ai@6.0.207` vs server `ai@6.0.134`-patched drift). The
|
||||||
|
patch bytes are unchanged — they feed the lockfile `patch_hash`, so the
|
||||||
|
alignment is called out as an install-gated plan rather than a bare version
|
||||||
|
bump. No runtime change.
|
||||||
|
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||||
|
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||||
|
generator, and the AI-chat markdown renderer all now use
|
||||||
|
`@docmost/prosemirror-markdown` (via its new `browser` entry — native
|
||||||
|
`DOMParser`, no jsdom in the client bundle) instead of the hand-written
|
||||||
|
`marked`/`turndown` markdown layer in `editor-ext`, which was **deleted**. As a
|
||||||
|
result, pasting canonical markdown (`^[…]` footnotes, `<!--img …-->`,
|
||||||
|
`> [!type]` callouts, `$…$` math, `==…==` highlight, standalone `<!--subpages-->`
|
||||||
|
comments) now produces the SAME nodes the server import produces for the same
|
||||||
|
text. Chat/reasoning markdown now renders through the editor schema (list items
|
||||||
|
are wrapped in `<p>`; CSS keeps them tight). (#347)
|
||||||
|
|
||||||
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
||||||
a page "Shared to web" now defaults to the page alone; descendant pages become
|
a page "Shared to web" now defaults to the page alone; descendant pages become
|
||||||
public only when you explicitly turn on the dedicated "Include sub-pages"
|
public only when you explicitly turn on the dedicated "Include sub-pages"
|
||||||
@@ -169,6 +351,79 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **MCP write tools no longer report a false failure that provokes a duplicate
|
||||||
|
write.** `drawioCreate` used to throw when the diagram landed as a NESTED block
|
||||||
|
(anchored inside a callout or table cell) because there is no `#<index>` handle
|
||||||
|
for it — but the diagram was already written, so a retry-prone agent re-created
|
||||||
|
it and produced a duplicate. It now returns success with `nodeId: null` plus a
|
||||||
|
warning that explains the write landed and how to re-read it (via
|
||||||
|
`getOutline` / `getPageJson` by `attachmentId`). Separately, when the live
|
||||||
|
collaboration-session cache hits its LRU entry cap, evicting a session whose
|
||||||
|
write is still in flight no longer rejects that write as a hard failure — it is
|
||||||
|
reported as INDETERMINATE ("the update may already have persisted; verify
|
||||||
|
before retry") so the agent re-reads instead of blind-retrying, and a
|
||||||
|
still-connecting session is no longer picked as an idle eviction victim by a
|
||||||
|
parallel acquire. (#494)
|
||||||
|
- **A long AI chat no longer bricks on the model's context window, and each turn
|
||||||
|
stops re-persisting the whole tool-output history.** Tool outputs are now
|
||||||
|
stored ONCE, in `metadata.parts`; the `tool_calls` trace keeps only per-step
|
||||||
|
outcome flags (a v2 trace shape), ending the O(N²) write amplification that
|
||||||
|
re-wrote every prior output on every step (measured on a live Postgres via the
|
||||||
|
`pg_current_wal_lsn()` delta: the trace column shrank ~3200×, the full
|
||||||
|
assistant row ~51%). The persisted record is unchanged in content — the full
|
||||||
|
history still lives in `metadata.parts`. At REPLAY time only, the history sent
|
||||||
|
to the provider is now bounded by a deterministic, prompt-cache-friendly token
|
||||||
|
budget: `floor(0.7 × chatContextWindow)` when a window is configured (no cap —
|
||||||
|
anti-brick protection, not a cost limiter), a flat 100k fallback for installs
|
||||||
|
with no window set (exactly the ones that hit terminal overflow), or off when
|
||||||
|
the window is explicitly `0`. Trimming truncates old tool outputs first, then
|
||||||
|
mechanically collapses the oldest turns, always keeping the recent turns full
|
||||||
|
and the tool-call/result pairing balanced. A provider context-overflow 400 is
|
||||||
|
now classified and used as a reactive signal: the row is stamped so the NEXT
|
||||||
|
turn re-trims aggressively (0.5×), which un-bricks a chat that just 400'd. The
|
||||||
|
client token badge and the server budgeter now share one estimator (new
|
||||||
|
`@docmost/token-estimate` package) so they can never diverge. Deferred-tool
|
||||||
|
activation is also cached in the chat metadata to avoid re-resolving it each
|
||||||
|
turn. (#490)
|
||||||
|
- **A chat with one malformed message part no longer 500s on every turn, and a
|
||||||
|
failed send no longer duplicates the user's message.** Incoming client parts
|
||||||
|
are now whitelisted to `text` (a forged tool-result part can no longer reach
|
||||||
|
the persisted history or the model context), and the turn is converted BEFORE
|
||||||
|
the user row is inserted, so a mid-flight failure cannot leave a duplicate
|
||||||
|
user row that a retry then compounds. A single part that still fails to convert
|
||||||
|
degrades to a `[tool context omitted]` marker on that one row instead of
|
||||||
|
bricking the whole chat. (#489)
|
||||||
|
- **A transport drop to an external MCP server now heals within the same turn.**
|
||||||
|
On an undici transport error, a read-only MCP tool reconnects its server and
|
||||||
|
retries once within the run; a write is never auto-retried (it may already have
|
||||||
|
applied). One flapping server no longer nulls the shared client cache, so other
|
||||||
|
servers' cached clients are untouched. The SSE transport also gets a raised
|
||||||
|
body-timeout so a legitimate >1-min idle between the model's tool calls no
|
||||||
|
longer breaks a long-lived SSE socket (new `AI_MCP_SSE_BODY_TIMEOUT_MS`, default
|
||||||
|
10 min; see `.env.example`). (#489)
|
||||||
|
- **Decisions on comment suggestions now leave a durable audit record.**
|
||||||
|
Applying or dismissing a comment suggestion hard-deletes the (childless)
|
||||||
|
subject comment, so the only surviving trace of who decided what is the audit
|
||||||
|
event — but the audit trail was wired to a Noop service that silently
|
||||||
|
swallowed every event. The trail is now DB-backed, so
|
||||||
|
`comment.suggestion_applied` / `comment.suggestion_dismissed` (and the other
|
||||||
|
comment-decision events) persist to the `audit` table and can be reviewed
|
||||||
|
after the comment is gone. A persistence failure is still swallowed with a
|
||||||
|
warning so it never breaks the originating request. (#496)
|
||||||
|
- **Applying a comment suggestion no longer strips the replaced run's inline
|
||||||
|
formatting.** The suggested text was re-inserted carrying only the comment
|
||||||
|
anchor mark, silently dropping bold/italic/code/link on the affected run; the
|
||||||
|
prevailing formatting of the replaced run is now carried onto the applied
|
||||||
|
text. (#496)
|
||||||
|
- **Markdown round-trips no longer silently drop a line that opens with a block
|
||||||
|
trigger.** When a document is exported to Markdown and re-imported (git-sync
|
||||||
|
stabilize, agent writes), a paragraph or continuation line (after a hard break)
|
||||||
|
that begins with a block marker — an ATX heading `#`, a blockquote/callout `>`,
|
||||||
|
a list marker (`-`/`*`/`+`/`N.`/`N)`), a code fence, a table `|`, a thematic
|
||||||
|
break (`---`), or a setext underline (`--`, `----`, or a lone `=`) — is now
|
||||||
|
backslash-escaped so it round-trips as text instead of being re-parsed into a
|
||||||
|
heading/list/quote/rule and losing its content. Front-matter stripping is
|
||||||
|
scoped to the import path only. (#493)
|
||||||
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
@@ -177,6 +432,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
|
|
||||||
|
- **The server no longer leaks a hung stream pipe on every mid-run client
|
||||||
|
disconnect.** The same `ai@6.0.134` pnpm patch now also fixes the SDK's
|
||||||
|
`writeToServerResponse`, which awaited only a `"drain"` event under
|
||||||
|
backpressure: when a client disconnected mid-write the socket never drained, so
|
||||||
|
the write loop parked forever, `response.end()` was unreachable, and the stream
|
||||||
|
reader plus buffered chunks were pinned until process restart (every mid-run
|
||||||
|
disconnect in autonomous mode leaked one). The patch races `"drain"` against
|
||||||
|
`"close"`/`"error"`, cancels the reader and ends the response on disconnect, and
|
||||||
|
swallows the fire-and-forget read rejection instead of crashing on an
|
||||||
|
unhandledRejection. (#486)
|
||||||
|
|
||||||
|
- **A failed autonomous agent-run start no longer becomes an unstoppable ghost
|
||||||
|
run.** When `beginRun` failed for a transient reason (e.g. a DB-pool blip),
|
||||||
|
the turn previously continued with NO run row — invisible to `/stop`, not
|
||||||
|
aborted on disconnect, and able to slip a second run past the one-run-per-chat
|
||||||
|
gate, leaving an unstoppable run until restart. The turn now fails fast with an
|
||||||
|
honest `503 A_RUN_BEGIN_FAILED` before the first byte (no orphan state), and the
|
||||||
|
client shows a "temporary — please try again" message instead of a misleading
|
||||||
|
"provider not configured". (#486)
|
||||||
|
|
||||||
|
- **A pathological draw.io graph can no longer wedge the whole server.** The ELK
|
||||||
|
auto-layout (`layout:"elk"`) ran elkjs synchronously on the main event loop, so
|
||||||
|
a graph at the node/edge cap blocked ALL HTTP/SSE/loopback traffic while it
|
||||||
|
churned — and the old `setTimeout` "timeout" could never fire because the same
|
||||||
|
thread was blocked. Layout now runs in a worker thread with the timeout enforced
|
||||||
|
by `worker.terminate()`; the main loop stays responsive. (#486)
|
||||||
|
|
||||||
|
- **The `/health` Redis probe no longer leaks a client on every tick while Redis
|
||||||
|
is down.** It built a new `ioredis` client per probe and disconnected it only on
|
||||||
|
success, so during an outage each health tick added another forever-reconnecting
|
||||||
|
client (an unbounded handle leak). A single long-lived probe client is now
|
||||||
|
reused and closed on shutdown. (#486)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
@@ -252,6 +540,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
through that exact share (its own share or an ancestor `includeSubPages`
|
through that exact share (its own share or an ancestor `includeSubPages`
|
||||||
share); any other value now returns the generic "not found" instead of
|
share); any other value now returns the generic "not found" instead of
|
||||||
serving the page. (#218)
|
serving the page. (#218)
|
||||||
|
- **MCP tool-allowlist semantics flipped: an empty `[]` now means deny-all
|
||||||
|
(previously it was coerced to "no restrictions").** For an external MCP server,
|
||||||
|
a stored `tool_allowlist` of `[]` now denies **every** tool of that server
|
||||||
|
(zero tools reach the agent) instead of being treated as an empty/unset filter
|
||||||
|
that allowed all of them. A corrupt or non-array stored value now **fails
|
||||||
|
closed** to deny-all rather than silently allowing everything. The admin form
|
||||||
|
no longer silently widens an existing deny-all server: leaving its tag field
|
||||||
|
empty preserves `[]` (deny-all) on save instead of NULL-ing the column to
|
||||||
|
allow-all, so a routine rename/toggle can no longer grant the agent every tool.
|
||||||
|
"No restrictions" is still expressible — a genuinely unrestricted server stores
|
||||||
|
NULL, and clearing the field on such a server keeps it NULL. Operationally
|
||||||
|
significant: audit any server that was created or left with a literal `[]`, as
|
||||||
|
it now exposes no tools until an explicit allowlist (or NULL) is set. (#476)
|
||||||
|
|
||||||
|
- **Tool and provider error text no longer leaks to anonymous readers in the
|
||||||
|
public-share AI chat.** A failing tool's raw error (which could carry an
|
||||||
|
internal page title or a stack fragment) and a provider error (which bundles the
|
||||||
|
provider `statusCode` and response body — potentially the internal baseUrl or
|
||||||
|
model name) were streamed verbatim to the anonymous reader over SSE. Errors are
|
||||||
|
now sanitized at the source: the share toolset collapses any unclassified tool
|
||||||
|
error to a safe generic string (safe, classified tool messages still pass
|
||||||
|
through for the model's self-correction), and the anonymous stream `onError`
|
||||||
|
maps provider failures to a fixed set of neutral strings — the full detail goes
|
||||||
|
only to the server log. A UI render gate is layered on top. (closes #394)
|
||||||
|
|
||||||
|
- **The Prometheus `/metrics` endpoint can now require Bearer authentication and
|
||||||
|
is loopback-bound by default.** Previously it listened on all interfaces with no
|
||||||
|
auth. Setting `METRICS_TOKEN` requires every scrape to present
|
||||||
|
`Authorization: Bearer <token>` (compared in constant time), and the listener
|
||||||
|
defaults to `127.0.0.1` (see the Breaking Changes entry for the cross-container
|
||||||
|
migration). (#486)
|
||||||
|
|
||||||
## [0.94.0] - 2026-06-26
|
## [0.94.0] - 2026-06-26
|
||||||
|
|
||||||
|
|||||||
+15
@@ -45,6 +45,11 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# The mcp package reads its data files (drawio-presets.json, drawio-shape-index.json.gz)
|
||||||
|
# at runtime via `new URL("../../data/…", import.meta.url)` relative to build/lib/*.js,
|
||||||
|
# i.e. from packages/mcp/data/. tsc emits only build/, so ship data/ explicitly or
|
||||||
|
# drawioFromGraph and the shape catalog die with ENOENT on packages/mcp/data/*.
|
||||||
|
COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
||||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
@@ -81,4 +86,14 @@ VOLUME ["/app/data/storage"]
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
|
||||||
|
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
|
||||||
|
# stash_page blob store + cached collab sessions are RAM-only and process-local.
|
||||||
|
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
|
||||||
|
# sessions silently breaks per-page write serialization (two replicas can lock
|
||||||
|
# the same page at once) and makes stash_page blobs unreachable across replicas.
|
||||||
|
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
|
||||||
|
# sessions / consistent hashing on page id). There is deliberately no
|
||||||
|
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
|
||||||
|
# collaboration write path" block) and packages/mcp/README.md for details.
|
||||||
CMD ["pnpm", "start"]
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
||||||
"@casl/react": "5.0.1",
|
"@casl/react": "5.0.1",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
"@docmost/token-estimate": "workspace:*",
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "8.3.18",
|
"@mantine/core": "8.3.18",
|
||||||
"@mantine/dates": "8.3.18",
|
"@mantine/dates": "8.3.18",
|
||||||
|
|||||||
@@ -256,6 +256,9 @@
|
|||||||
"Invite link": "Ссылка для приглашения",
|
"Invite link": "Ссылка для приглашения",
|
||||||
"Copy": "Копировать",
|
"Copy": "Копировать",
|
||||||
"Copy to space": "Копировать в пространство",
|
"Copy to space": "Копировать в пространство",
|
||||||
|
"Copy chat": "Копировать чат",
|
||||||
|
"Dock to sidebar": "Закрепить в боковой панели",
|
||||||
|
"Undock": "Открепить",
|
||||||
"Copied": "Скопировано",
|
"Copied": "Скопировано",
|
||||||
"Failed to export chat": "Не удалось экспортировать чат",
|
"Failed to export chat": "Не удалось экспортировать чат",
|
||||||
"Duplicate": "Дублировать",
|
"Duplicate": "Дублировать",
|
||||||
@@ -285,6 +288,9 @@
|
|||||||
"Alt text": "Альтернативный текст",
|
"Alt text": "Альтернативный текст",
|
||||||
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
"Describe this for accessibility.": "Опишите это для специальных возможностей.",
|
||||||
"Add a description": "Добавить описание",
|
"Add a description": "Добавить описание",
|
||||||
|
"Caption": "Подпись",
|
||||||
|
"Add a caption": "Добавить подпись",
|
||||||
|
"Shown below the image.": "Отображается под изображением.",
|
||||||
"Justify": "По ширине",
|
"Justify": "По ширине",
|
||||||
"Merge cells": "Объединить ячейки",
|
"Merge cells": "Объединить ячейки",
|
||||||
"Split cell": "Разделить ячейку",
|
"Split cell": "Разделить ячейку",
|
||||||
@@ -388,22 +394,6 @@
|
|||||||
"Quote": "Цитата",
|
"Quote": "Цитата",
|
||||||
"Image": "Изображение",
|
"Image": "Изображение",
|
||||||
"Audio": "Аудио",
|
"Audio": "Аудио",
|
||||||
"Transcribe": "Транскрибировать",
|
|
||||||
"Transcribing…": "Транскрибация…",
|
|
||||||
"No speech detected": "Речь не распознана",
|
|
||||||
"Transcription failed": "Не удалось распознать речь",
|
|
||||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
|
||||||
"Start dictation": "Начать диктовку",
|
|
||||||
"Stop recording": "Остановить запись",
|
|
||||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
|
||||||
"No microphone found": "Микрофон не найден",
|
|
||||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
|
||||||
"Could not start recording": "Не удалось начать запись",
|
|
||||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
|
||||||
"Dictation": "Диктовка",
|
|
||||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
|
||||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
|
||||||
"This page is read-only": "Страница открыта только для чтения",
|
|
||||||
"Embed PDF": "Встроить PDF",
|
"Embed PDF": "Встроить PDF",
|
||||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||||
"Embed as PDF": "Встроить как PDF",
|
"Embed as PDF": "Встроить как PDF",
|
||||||
@@ -419,9 +409,6 @@
|
|||||||
"Footnote {{number}}": "Сноска {{number}}",
|
"Footnote {{number}}": "Сноска {{number}}",
|
||||||
"Go to footnote": "Перейти к сноске",
|
"Go to footnote": "Перейти к сноске",
|
||||||
"Back to reference": "Вернуться к ссылке",
|
"Back to reference": "Вернуться к ссылке",
|
||||||
"Back to references": "Вернуться к ссылкам",
|
|
||||||
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
|
||||||
"Empty footnote": "Пустая сноска",
|
|
||||||
"Math inline": "Строчная формула",
|
"Math inline": "Строчная формула",
|
||||||
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
"Insert inline math equation.": "Вставить математическое выражение в строку.",
|
||||||
"Math block": "Блок формулы",
|
"Math block": "Блок формулы",
|
||||||
@@ -447,6 +434,9 @@
|
|||||||
"{{count}} command available_other": "Доступно {{count}} команд",
|
"{{count}} command available_other": "Доступно {{count}} команд",
|
||||||
"{{count}} result available_one": "Доступен 1 результат",
|
"{{count}} result available_one": "Доступен 1 результат",
|
||||||
"{{count}} result available_other": "Доступно {{count}} результатов",
|
"{{count}} result available_other": "Доступно {{count}} результатов",
|
||||||
|
"{{count}} result found_one": "Найден {{count}} результат",
|
||||||
|
"{{count}} result found_few": "Найдено {{count}} результата",
|
||||||
|
"{{count}} result found_other": "Найдено {{count}} результатов",
|
||||||
"Equal columns": "Равные столбцы",
|
"Equal columns": "Равные столбцы",
|
||||||
"Left sidebar": "Левая боковая панель",
|
"Left sidebar": "Левая боковая панель",
|
||||||
"Right sidebar": "Правая боковая панель",
|
"Right sidebar": "Правая боковая панель",
|
||||||
@@ -456,6 +446,7 @@
|
|||||||
"Names do not match": "Названия не совпадают",
|
"Names do not match": "Названия не совпадают",
|
||||||
"Today, {{time}}": "Сегодня, {{time}}",
|
"Today, {{time}}": "Сегодня, {{time}}",
|
||||||
"Yesterday, {{time}}": "Вчера, {{time}}",
|
"Yesterday, {{time}}": "Вчера, {{time}}",
|
||||||
|
"now": "сейчас",
|
||||||
"Space created successfully": "Пространство успешно создано",
|
"Space created successfully": "Пространство успешно создано",
|
||||||
"Space updated successfully": "Пространство успешно обновлено",
|
"Space updated successfully": "Пространство успешно обновлено",
|
||||||
"Space deleted successfully": "Пространство успешно удалено",
|
"Space deleted successfully": "Пространство успешно удалено",
|
||||||
@@ -559,6 +550,7 @@
|
|||||||
"Add 2FA method": "Добавить метод 2FA",
|
"Add 2FA method": "Добавить метод 2FA",
|
||||||
"Backup codes": "Резервные коды",
|
"Backup codes": "Резервные коды",
|
||||||
"Disable": "Отключить",
|
"Disable": "Отключить",
|
||||||
|
"disabled": "отключено",
|
||||||
"Invalid verification code": "Недействительный код подтверждения",
|
"Invalid verification code": "Недействительный код подтверждения",
|
||||||
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
"New backup codes have been generated": "Новые резервные коды сгенерированы",
|
||||||
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
"Failed to regenerate backup codes": "Не удалось заново сгенерировать резервные коды",
|
||||||
@@ -702,62 +694,6 @@
|
|||||||
"AI search": "Поиск ИИ",
|
"AI search": "Поиск ИИ",
|
||||||
"AI Answer": "Ответ ИИ",
|
"AI Answer": "Ответ ИИ",
|
||||||
"Ask AI": "Спросить ИИ",
|
"Ask AI": "Спросить ИИ",
|
||||||
"AI agent": "AI-агент",
|
|
||||||
"Take a look at the current document": "Посмотри текущий документ",
|
|
||||||
"Start automatically": "Запускать автоматически",
|
|
||||||
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
|
||||||
"Launch message": "Стартовое сообщение",
|
|
||||||
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
|
||||||
"AI agent is typing…": "AI-агент печатает…",
|
|
||||||
"{{name}} is typing…": "{{name}} печатает…",
|
|
||||||
"Thinking…": "Думаю…",
|
|
||||||
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
|
||||||
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
|
||||||
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
|
||||||
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
|
||||||
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
|
||||||
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
|
||||||
"Agent role": "Роль агента",
|
|
||||||
"AI chat": "AI-чат",
|
|
||||||
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
|
||||||
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
|
||||||
"Ask a question…": "Задайте вопрос…",
|
|
||||||
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
|
||||||
"Ask the AI agent…": "Спросите AI-агента…",
|
|
||||||
"Copy chat": "Копировать чат",
|
|
||||||
"Dock to sidebar": "Закрепить в боковой панели",
|
|
||||||
"Undock": "Открепить",
|
|
||||||
"Created successfully": "Успешно создано",
|
|
||||||
"Context size / model limit": "Размер контекста / лимит модели",
|
|
||||||
"Context window (tokens)": "Окно контекста (токены)",
|
|
||||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
|
||||||
"Deleted successfully": "Успешно удалено",
|
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
|
||||||
"AI agent {{name}}": "AI-агент {{name}}",
|
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
|
||||||
"Failed": "Ошибка",
|
|
||||||
"OK · {{n}}": "OK · {{n}}",
|
|
||||||
"Test": "Тест",
|
|
||||||
"No tools available": "Инструменты недоступны",
|
|
||||||
"Available tools": "Доступные инструменты",
|
|
||||||
"Minimize": "Свернуть",
|
|
||||||
"No chats yet.": "Чатов пока нет.",
|
|
||||||
"Send": "Отправить",
|
|
||||||
"Send when the agent finishes": "Отправить, когда агент закончит",
|
|
||||||
"Queue message": "Поставить в очередь",
|
|
||||||
"Remove queued message": "Убрать из очереди",
|
|
||||||
"Send now": "Отправить сейчас",
|
|
||||||
"Interrupt and send now": "Прервать и отправить сейчас",
|
|
||||||
"Something went wrong": "Что-то пошло не так",
|
|
||||||
"Stop": "Стоп",
|
|
||||||
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
|
||||||
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
|
||||||
"Universal assistant": "Универсальный ассистент",
|
|
||||||
"You": "Вы",
|
|
||||||
"AI is thinking...": "ИИ обрабатывает запрос...",
|
"AI is thinking...": "ИИ обрабатывает запрос...",
|
||||||
"Thinking": "Думаю",
|
"Thinking": "Думаю",
|
||||||
"Ask a question...": "Задайте вопрос...",
|
"Ask a question...": "Задайте вопрос...",
|
||||||
@@ -784,8 +720,40 @@
|
|||||||
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"Manage API keys for all users in the workspace. View the <anchor>API documentation</anchor> for usage details.": "Управляйте API-ключами для всех пользователей в рабочем пространстве. Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
"View the <anchor>API documentation</anchor> for usage details.": "Смотрите <anchor>документацию по API</anchor> для получения информации об использовании.",
|
||||||
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
"View the <anchor>MCP documentation</anchor>.": "Смотрите <anchor>документацию по MCP</anchor>.",
|
||||||
"Instructions": "Инструкции",
|
"AI / Models": "ИИ / Модели",
|
||||||
|
"AI / External tools (MCP)": "ИИ / Внешние инструменты (MCP)",
|
||||||
|
"Add server": "Добавить сервер",
|
||||||
|
"Edit server": "Изменить сервер",
|
||||||
|
"Delete server": "Удалить сервер",
|
||||||
|
"Are you sure you want to delete this MCP server?": "Вы уверены, что хотите удалить этот MCP-сервер?",
|
||||||
|
"No external servers configured": "Внешние серверы не настроены",
|
||||||
|
"Server name": "Имя сервера",
|
||||||
|
"Transport": "Транспорт",
|
||||||
|
"URL": "URL",
|
||||||
|
"Authorization header": "Заголовок авторизации",
|
||||||
|
"Tool allowlist": "Список разрешённых инструментов",
|
||||||
|
"Optional. Leave empty to allow all tools the server exposes.": "Необязательно. Оставьте пустым, чтобы разрешить все инструменты, которые предоставляет сервер.",
|
||||||
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
"Optional guidance for the agent on how and when to use this server's tools. Injected into the system prompt. The server's tools are namespaced as \"<server name>_*\".": "Необязательное указание агенту, как и когда использовать инструменты этого сервера. Добавляется в системный промпт. Инструменты сервера именуются с префиксом «<имя сервера>_*».",
|
||||||
|
"Test": "Тест",
|
||||||
|
"Available tools": "Доступные инструменты",
|
||||||
|
"No tools available": "Инструменты недоступны",
|
||||||
|
"Failed": "Ошибка",
|
||||||
|
"OK · {{n}}": "OK · {{n}}",
|
||||||
|
"Created successfully": "Успешно создано",
|
||||||
|
"Deleted successfully": "Успешно удалено",
|
||||||
|
"Clear": "Очистить",
|
||||||
|
"Provider": "Провайдер",
|
||||||
|
"•••• set": "•••• задан",
|
||||||
|
"Clear key": "Очистить ключ",
|
||||||
|
"Base URL": "Базовый URL",
|
||||||
|
"Chat model": "Модель чата",
|
||||||
|
"Embedding model": "Модель эмбеддингов",
|
||||||
|
"System message": "Системное сообщение",
|
||||||
|
"A built-in safety framework is always appended.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||||
|
"Test connection": "Проверить соединение",
|
||||||
|
"Connection successful": "Соединение установлено",
|
||||||
|
"Connection failed": "Не удалось установить соединение",
|
||||||
|
"Only workspace admins can manage AI provider settings.": "Управлять настройками провайдера ИИ могут только администраторы рабочего пространства.",
|
||||||
"Sources": "Источники",
|
"Sources": "Источники",
|
||||||
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
"AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
|
||||||
"No answer available": "Ответ недоступен",
|
"No answer available": "Ответ недоступен",
|
||||||
@@ -1013,6 +981,7 @@
|
|||||||
"Try again": "Попробовать снова",
|
"Try again": "Попробовать снова",
|
||||||
"Untitled chat": "Чат без названия",
|
"Untitled chat": "Чат без названия",
|
||||||
"No document": "Без документа",
|
"No document": "Без документа",
|
||||||
|
"You": "Вы",
|
||||||
"What can I help you with?": "Чем я могу вам помочь?",
|
"What can I help you with?": "Чем я могу вам помочь?",
|
||||||
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
"Are you sure you want to revoke this {{credential}}": "Вы уверены, что хотите отозвать этот {{credential}}",
|
||||||
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
"Automatically provision users and groups from your identity provider via SCIM.": "Автоматически предоставляйте доступ пользователям и группам из вашего провайдера удостоверений через SCIM.",
|
||||||
@@ -1041,6 +1010,9 @@
|
|||||||
"Page menu": "Меню страницы",
|
"Page menu": "Меню страницы",
|
||||||
"Expand": "Развернуть",
|
"Expand": "Развернуть",
|
||||||
"Collapse": "Свернуть",
|
"Collapse": "Свернуть",
|
||||||
|
"Expand all": "Развернуть все",
|
||||||
|
"Collapse all": "Свернуть все",
|
||||||
|
"Couldn't expand the tree: {{reason}}": "Не удалось развернуть дерево: {{reason}}",
|
||||||
"Comment menu": "Меню комментария",
|
"Comment menu": "Меню комментария",
|
||||||
"Group menu": "Меню группы",
|
"Group menu": "Меню группы",
|
||||||
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
"Show hidden breadcrumbs": "Показать скрытые хлебные крошки",
|
||||||
@@ -1077,7 +1049,7 @@
|
|||||||
"Search pages and spaces...": "Поиск страниц и пространств...",
|
"Search pages and spaces...": "Поиск страниц и пространств...",
|
||||||
"No results found": "Результаты не найдены",
|
"No results found": "Результаты не найдены",
|
||||||
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
"You don't have permission to create pages here": "У вас нет прав на создание страниц здесь",
|
||||||
"Chat menu": "Меню чата",
|
"Chat menu for {{title}}": "Меню чата для {{title}}",
|
||||||
"API key menu": "Меню API-ключа",
|
"API key menu": "Меню API-ключа",
|
||||||
"Jump to comment selection": "Перейти к выбору комментария",
|
"Jump to comment selection": "Перейти к выбору комментария",
|
||||||
"Slash commands": "Команды со слешем",
|
"Slash commands": "Команды со слешем",
|
||||||
@@ -1131,6 +1103,9 @@
|
|||||||
"Undo": "Отменить",
|
"Undo": "Отменить",
|
||||||
"Redo": "Повторить",
|
"Redo": "Повторить",
|
||||||
"Backlinks": "Обратные ссылки",
|
"Backlinks": "Обратные ссылки",
|
||||||
|
"Back to references": "Вернуться к ссылкам",
|
||||||
|
"Back to reference {{label}}": "Вернуться к ссылке {{label}}",
|
||||||
|
"Empty footnote": "Пустая сноска",
|
||||||
"Last updated by": "Последний изменивший",
|
"Last updated by": "Последний изменивший",
|
||||||
"Last updated": "Последнее обновление",
|
"Last updated": "Последнее обновление",
|
||||||
"Stats": "Статистика",
|
"Stats": "Статистика",
|
||||||
@@ -1164,6 +1139,7 @@
|
|||||||
"Page title": "Заголовок страницы",
|
"Page title": "Заголовок страницы",
|
||||||
"Page content": "Содержимое страницы",
|
"Page content": "Содержимое страницы",
|
||||||
"Member actions": "Действия с участником",
|
"Member actions": "Действия с участником",
|
||||||
|
"Member actions for {{name}}": "Действия с участником {{name}}",
|
||||||
"Toggle password visibility": "Переключить видимость пароля",
|
"Toggle password visibility": "Переключить видимость пароля",
|
||||||
"Send comment": "Отправить комментарий",
|
"Send comment": "Отправить комментарий",
|
||||||
"Token actions": "Действия с токеном",
|
"Token actions": "Действия с токеном",
|
||||||
@@ -1183,11 +1159,187 @@
|
|||||||
"Removed from favorites": "Удалено из избранного",
|
"Removed from favorites": "Удалено из избранного",
|
||||||
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
"Added {{name}} to favorites": "{{name}} добавлено в избранное",
|
||||||
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
"Removed {{name}} from favorites": "{{name}} удалено из избранного",
|
||||||
|
"Label added": "Метка добавлена",
|
||||||
|
"Label removed": "Метка удалена",
|
||||||
|
"Image updated": "Изображение обновлено",
|
||||||
|
"Unsupported image type": "Неподдерживаемый тип изображения",
|
||||||
|
"Member deactivated": "Участник деактивирован",
|
||||||
|
"Member activated": "Участник активирован",
|
||||||
|
"Name is required": "Укажите имя",
|
||||||
|
"Name must be 40 characters or fewer": "Имя должно содержать не более 40 символов",
|
||||||
|
"Group name must be at least 2 characters": "Название группы должно содержать не менее 2 символов",
|
||||||
|
"Group name must be 100 characters or fewer": "Название группы должно содержать не более 100 символов",
|
||||||
|
"Description must be 500 characters or fewer": "Описание должно содержать не более 500 символов",
|
||||||
|
"Invalid invitation link": "Недействительная ссылка-приглашение",
|
||||||
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
"Page menu for {{name}}": "Меню страницы для {{name}}",
|
||||||
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
|
"AI chat": "AI-чат",
|
||||||
|
"Ask a question about this documentation.": "Задайте вопрос об этой документации.",
|
||||||
|
"Ask a question…": "Задайте вопрос…",
|
||||||
|
"Thinking…": "Думаю…",
|
||||||
|
"Thinking… · {{count}} tokens": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_one": "Думаю… · {{count}} токен",
|
||||||
|
"Thinking… · {{count}} tokens_few": "Думаю… · {{count}} токена",
|
||||||
|
"Thinking… · {{count}} tokens_many": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking… · {{count}} tokens_other": "Думаю… · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_one": "Размышления · {{count}} токен",
|
||||||
|
"Thinking · {{count}} tokens_few": "Размышления · {{count}} токена",
|
||||||
|
"Thinking · {{count}} tokens_many": "Размышления · {{count}} токенов",
|
||||||
|
"Thinking · {{count}} tokens_other": "Размышления · {{count}} токенов",
|
||||||
|
"The assistant is unavailable right now. Please try again.": "Ассистент сейчас недоступен. Попробуйте ещё раз.",
|
||||||
|
"Public share assistant": "Ассистент публичного доступа",
|
||||||
|
"Let anonymous visitors of public shares ask an AI assistant scoped to that share's pages. You pay for the tokens.": "Позвольте анонимным посетителям публичных ссылок обращаться к ИИ-ассистенту в рамках страниц этой публикации. Токены оплачиваете вы.",
|
||||||
|
"Public assistant model": "Модель публичного ассистента",
|
||||||
|
"Defaults to the chat model": "По умолчанию используется модель чата",
|
||||||
|
"Optional cheaper model id for the public assistant. Empty uses the chat model above.": "Необязательный более дешёвый идентификатор модели для публичного ассистента. Если пусто, используется модель чата выше.",
|
||||||
|
"Assistant identity": "Личность ассистента",
|
||||||
|
"Pick an agent role whose persona the public assistant adopts. The safety rules always still apply.": "Выберите роль агента, чью личность примет публичный ассистент. Правила безопасности всегда остаются в силе.",
|
||||||
|
"Built-in assistant persona": "Встроенная личность ассистента",
|
||||||
|
"Minimize": "Свернуть",
|
||||||
|
"Context size / model limit": "Размер контекста / лимит модели",
|
||||||
|
"Context window (tokens)": "Окно контекста (токены)",
|
||||||
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||||
|
"AI agent": "AI-агент",
|
||||||
|
"Take a look at the current document": "Посмотри текущий документ",
|
||||||
|
"AI agent is typing…": "AI-агент печатает…",
|
||||||
|
"{{name}} is typing…": "{{name}} печатает…",
|
||||||
|
"Send": "Отправить",
|
||||||
|
"Send when the agent finishes": "Отправить, когда агент закончит",
|
||||||
|
"Queue message": "Поставить в очередь",
|
||||||
|
"Remove queued message": "Убрать из очереди",
|
||||||
|
"Send now": "Отправить сейчас",
|
||||||
|
"Interrupt and send now": "Прервать и отправить сейчас",
|
||||||
|
"Stop": "Стоп",
|
||||||
|
"Response stopped.": "Ответ остановлен.",
|
||||||
|
"Connection lost — the answer was interrupted.": "Соединение потеряно — ответ был прерван.",
|
||||||
|
"Response stopped (manually or the connection dropped).": "Ответ остановлен (вручную или из-за разрыва соединения).",
|
||||||
|
"Chat menu": "Меню чата",
|
||||||
|
"No chats yet.": "Чатов пока нет.",
|
||||||
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
|
"Ask the AI agent…": "Спросите AI-агента…",
|
||||||
|
"Ask the AI agent anything about your workspace.": "Спросите AI-агента о чём угодно по вашему рабочему пространству.",
|
||||||
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
|
"Something went wrong": "Что-то пошло не так",
|
||||||
|
"AI chat is disabled for this workspace.": "AI-чат отключён для этого рабочего пространства.",
|
||||||
|
"The AI provider is not configured. Ask an administrator to set it up.": "AI-провайдер не настроен. Попросите администратора настроить его.",
|
||||||
|
"The AI agent could not respond. Please try again.": "AI-агент не смог ответить. Попробуйте ещё раз.",
|
||||||
|
"Searched pages": "Поиск по страницам",
|
||||||
|
"Read page": "Прочитана страница",
|
||||||
|
"Created page": "Создана страница",
|
||||||
|
"Updated page": "Обновлена страница",
|
||||||
|
"Renamed page": "Переименована страница",
|
||||||
|
"Moved page": "Перемещена страница",
|
||||||
|
"Deleted page (to trash)": "Удалена страница (в корзину)",
|
||||||
|
"Commented": "Добавлен комментарий",
|
||||||
|
"Resolved comment": "Комментарий решён",
|
||||||
|
"Ran tool {{name}}": "Выполнен инструмент {{name}}",
|
||||||
|
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
||||||
|
"AI agent {{name}}": "AI-агент {{name}}",
|
||||||
|
"Endpoints": "Эндпоинты",
|
||||||
|
"where we fetch models": "откуда мы получаем модели",
|
||||||
|
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "Все эндпоинты совместимы с OpenAI. Укажите в базовом URL адрес OpenAI, OpenRouter, локального Ollama или любого self-hosted сервера.",
|
||||||
|
"Chat / LLM": "Чат / LLM",
|
||||||
|
"root": "корневой",
|
||||||
|
"Semantic search": "Семантический поиск",
|
||||||
|
"Voice / STT": "Голос / STT",
|
||||||
|
"Voice dictation": "Голосовой ввод",
|
||||||
|
"Streaming dictation": "Потоковый голосовой ввод",
|
||||||
|
"Transcribe as you speak, cutting on pauses": "Транскрибирование по мере речи, с разбивкой на паузах",
|
||||||
|
"Voice dictation is not available yet.": "Голосовой ввод пока недоступен.",
|
||||||
|
"Test endpoint": "Проверить эндпоинт",
|
||||||
|
"Save and test": "Сохранить и проверить",
|
||||||
|
"Save endpoints": "Сохранить эндпоинты",
|
||||||
|
"Configured and enabled": "Настроено и включено",
|
||||||
|
"Configured but disabled": "Настроено, но отключено",
|
||||||
|
"Enabled but not configured": "Включено, но не настроено",
|
||||||
|
"Not configured": "Не настроено",
|
||||||
|
"External tools": "Внешние инструменты",
|
||||||
|
"Gitmost as MCP client": "Gitmost как MCP-клиент",
|
||||||
|
"Servers the agent calls out to.": "Серверы, к которым обращается агент.",
|
||||||
|
"MCP server": "MCP-сервер",
|
||||||
|
"expose the workspace": "открыть доступ к рабочему пространству",
|
||||||
|
"Enable MCP server": "Включить MCP-сервер",
|
||||||
|
"Exposes the workspace as an MCP server at /mcp — this provides a capability, it doesn't consume a model.": "Открывает рабочее пространство как MCP-сервер по адресу /mcp — это предоставляет возможность, а не потребляет модель.",
|
||||||
|
"Resolves to {{url}}": "Разрешается в {{url}}",
|
||||||
|
"Model": "Модель",
|
||||||
|
"Done": "Готово",
|
||||||
|
"shared prompt · safety framework appended automatically": "общий промпт · правила безопасности добавляются автоматически",
|
||||||
|
"/v1/chat/completions · root endpoint — Embeddings and Voice inherit its URL and key": "/v1/chat/completions · корневой эндпоинт — Эмбеддинги и Голос наследуют его URL и ключ",
|
||||||
|
"/v1/embeddings · embeds pages so semantic search can find them": "/v1/embeddings · создаёт эмбеддинги страниц, чтобы их находил семантический поиск",
|
||||||
|
"/v1/audio/transcriptions · works with local whisper (speaches / faster-whisper-server)": "/v1/audio/transcriptions · работает с локальным whisper (speaches / faster-whisper-server)",
|
||||||
|
"Vector search · requires pgvector": "Векторный поиск · требуется pgvector",
|
||||||
|
"Embedding API key": "API-ключ для эмбеддингов",
|
||||||
|
"Embeddings": "Эмбеддинги",
|
||||||
|
"Leave empty to use the chat API key": "Оставьте пустым, чтобы использовать API-ключ чата",
|
||||||
|
"Leave empty to use the chat base URL": "Оставьте пустым, чтобы использовать базовый URL чата",
|
||||||
|
"Reindex now": "Переиндексировать сейчас",
|
||||||
|
"Start dictation": "Начать диктовку",
|
||||||
|
"Stop recording": "Остановить запись",
|
||||||
|
"Transcribing…": "Транскрибация…",
|
||||||
|
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||||
|
"No microphone found": "Микрофон не найден",
|
||||||
|
"Could not start recording": "Не удалось начать запись",
|
||||||
|
"Transcription failed": "Не удалось распознать речь",
|
||||||
|
"Transcribe": "Транскрибировать",
|
||||||
|
"No speech detected": "Речь не распознана",
|
||||||
|
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||||
|
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||||
|
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||||
|
"Dictation": "Диктовка",
|
||||||
|
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||||
|
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||||
|
"This page is read-only": "Страница открыта только для чтения",
|
||||||
|
"Request format": "Формат запроса",
|
||||||
|
"How transcription requests are sent to the endpoint": "Как запросы на транскрибирование отправляются на эндпоинт",
|
||||||
|
"OpenAI-compatible (multipart/form-data)": "Совместимо с OpenAI (multipart/form-data)",
|
||||||
|
"OpenRouter (JSON, base64 audio)": "OpenRouter (JSON, аудио в base64)",
|
||||||
"Dictation language": "Язык диктовки",
|
"Dictation language": "Язык диктовки",
|
||||||
"Auto-detect": "Автоопределение",
|
"Auto-detect": "Автоопределение",
|
||||||
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
"Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
|
||||||
|
"Agent role": "Роль агента",
|
||||||
|
"Universal assistant": "Универсальный ассистент",
|
||||||
|
"Add role": "Добавить роль",
|
||||||
|
"Edit role": "Изменить роль",
|
||||||
|
"Role name": "Название роли",
|
||||||
|
"e.g. Proofreader": "напр. Корректор",
|
||||||
|
"Optional. Shown as the chat badge.": "Необязательно. Отображается как значок чата.",
|
||||||
|
"Optional. A short note about what this role does.": "Необязательно. Краткое описание того, что делает эта роль.",
|
||||||
|
"Instructions": "Инструкции",
|
||||||
|
"The built-in safety framework is always added automatically.": "Встроенный набор правил безопасности всегда добавляется автоматически.",
|
||||||
|
"Model provider override": "Переопределение провайдера модели",
|
||||||
|
"Optional. Defaults to the workspace provider.": "Необязательно. По умолчанию используется провайдер рабочего пространства.",
|
||||||
|
"Model override": "Переопределение модели",
|
||||||
|
"Optional. Defaults to the workspace model.": "Необязательно. По умолчанию используется модель рабочего пространства.",
|
||||||
|
"e.g. gpt-4o-mini": "напр. gpt-4o-mini",
|
||||||
|
"If you choose a different provider, it must already be configured in AI settings.": "Если вы выбираете другого провайдера, он уже должен быть настроен в настройках ИИ.",
|
||||||
|
"Start automatically": "Запускать автоматически",
|
||||||
|
"When on, picking this role sends a launch message and starts the chat. When off, the role is selected and you type the first message yourself.": "Когда включено, выбор этой роли отправляет стартовое сообщение и начинает чат. Когда выключено, роль выбирается, а первое сообщение вы вводите сами.",
|
||||||
|
"Launch message": "Стартовое сообщение",
|
||||||
|
"Sent automatically when this role is picked. Leave empty to use the default text. Ignored when “Start automatically” is off.": "Отправляется автоматически при выборе этой роли. Оставьте пустым, чтобы использовать текст по умолчанию. Игнорируется, когда «Запускать автоматически» выключено.",
|
||||||
|
"Agent roles": "Роли агента",
|
||||||
|
"Reusable presets that shape the agent's behavior (and optionally its model). Picked when starting a new chat.": "Многоразовые пресеты, определяющие поведение агента (и, при желании, его модель). Выбираются при запуске нового чата.",
|
||||||
|
"No roles configured": "Роли не настроены",
|
||||||
|
"Delete role": "Удалить роль",
|
||||||
|
"Are you sure you want to delete this role?": "Вы уверены, что хотите удалить эту роль?",
|
||||||
|
"HTML embed": "HTML-вставка",
|
||||||
|
"Edit HTML embed": "Изменить HTML-вставку",
|
||||||
|
"HTML embed is disabled in this workspace": "HTML-вставки отключены в этом рабочем пространстве",
|
||||||
|
"Click to add HTML / CSS / JS": "Нажмите, чтобы добавить HTML / CSS / JS",
|
||||||
|
"This HTML/CSS/JS runs in a sandboxed frame and cannot access the viewer's session, cookies, or API.": "Этот HTML/CSS/JS выполняется в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего.",
|
||||||
|
"<script>...</script>": "<script>...</script>",
|
||||||
|
"Height (px, blank = auto)": "Высота (px, пусто = авто)",
|
||||||
|
"advanced": "дополнительно",
|
||||||
|
"Enable HTML embed": "Включить HTML-вставки",
|
||||||
|
"Allow members to insert raw HTML/CSS/JavaScript blocks. The block renders in a sandboxed frame and cannot access the viewer's session, cookies, or API. Off by default.": "Разрешить участникам вставлять блоки с необработанным HTML/CSS/JavaScript. Блок отображается в изолированном фрейме и не имеет доступа к сессии, cookie или API просматривающего. По умолчанию выключено.",
|
||||||
|
"When enabled, any member can insert an HTML embed block. The toggle just enables or disables the block type workspace-wide.": "Когда включено, любой участник может вставить блок HTML-вставки. Переключатель просто включает или отключает этот тип блока во всём рабочем пространстве.",
|
||||||
|
"Embeds run inside a sandboxed iframe with a separate origin, so they cannot read or modify the page they are embedded in.": "Вставки выполняются в изолированном iframe с отдельным источником, поэтому они не могут читать или изменять страницу, в которую встроены.",
|
||||||
|
"Turning this off hides existing embeds (they render as a disabled placeholder) and stops serving them on public share pages.": "Отключение этой опции скрывает существующие вставки (они отображаются как отключённая заглушка) и прекращает их показ на публичных страницах.",
|
||||||
|
"Analytics / tracker": "Аналитика / трекер",
|
||||||
|
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Вставляется дословно в <head> только ПУБЛИЧНЫХ страниц (тот же источник). Для сниппетов аналитики (Google Analytics, Яндекс.Метрика и т. п.). Только для администраторов.",
|
||||||
|
"Go to login page": "Перейти на страницу входа",
|
||||||
|
"Move to space": "Переместить в пространство",
|
||||||
"Float left (wrap text)": "Обтекание слева",
|
"Float left (wrap text)": "Обтекание слева",
|
||||||
"Float right (wrap text)": "Обтекание справа",
|
"Float right (wrap text)": "Обтекание справа",
|
||||||
"Inline (side by side)": "В ряд",
|
"Inline (side by side)": "В ряд",
|
||||||
@@ -1199,6 +1351,7 @@
|
|||||||
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
"Showing {{count}} subpages_one": "Показано {{count}} подстраница",
|
||||||
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
"Showing {{count}} subpages_few": "Показано {{count}} подстраницы",
|
||||||
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
"Showing {{count}} subpages_many": "Показано {{count}} подстраниц",
|
||||||
|
"Showing {{count}} subpages_other": "Показано {{count}} подстраниц",
|
||||||
"Protocol": "Протокол",
|
"Protocol": "Протокол",
|
||||||
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
"How chat requests are sent and how reasoning is surfaced": "Как отправляются запросы чата и как показывается reasoning",
|
||||||
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
"OpenAI-compatible (surfaces reasoning)": "OpenAI-совместимый (показывает reasoning)",
|
||||||
@@ -1268,7 +1421,6 @@
|
|||||||
"Retry": "Повторить",
|
"Retry": "Повторить",
|
||||||
"The catalog is empty": "Каталог пуст",
|
"The catalog is empty": "Каталог пуст",
|
||||||
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||||
@@ -35,3 +35,31 @@ describe("isChunkLoadError", () => {
|
|||||||
expect(isChunkLoadError(err)).toBe(false);
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The window gate replaces the old one-shot flag: it must permit recovery across
|
||||||
|
// several deploys in one tab (each > window apart) while still stopping an infinite
|
||||||
|
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
||||||
|
describe("shouldAutoReload", () => {
|
||||||
|
const WINDOW = 5 * 60 * 1000;
|
||||||
|
const NOW = 1_000_000_000_000;
|
||||||
|
|
||||||
|
it("allows a reload when we have never auto-reloaded", () => {
|
||||||
|
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
||||||
|
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,25 @@ import { ReactNode } from "react";
|
|||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
// sessionStorage key holding the epoch-ms timestamp of the last automatic reload.
|
||||||
|
const RELOAD_AT_KEY = "chunk-reload-at";
|
||||||
|
// Allow at most one automatic reload per this window. A stale-deploy 404 is cured
|
||||||
|
// by a single reload, so anything inside the window is treated as a reload loop
|
||||||
|
// (permanently-broken chunk) and falls through to the manual UI. A window (rather
|
||||||
|
// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too.
|
||||||
|
const RELOAD_WINDOW_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Pure window decision, unit-tested in isolation: auto-reload only if we have never
|
||||||
|
// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the
|
||||||
|
// window. Anything inside the window is suppressed to break an infinite reload loop.
|
||||||
|
export function shouldAutoReload(
|
||||||
|
now: number,
|
||||||
|
lastReloadAt: number | null,
|
||||||
|
windowMs: number,
|
||||||
|
): boolean {
|
||||||
|
if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true;
|
||||||
|
return now - lastReloadAt > windowMs;
|
||||||
|
}
|
||||||
|
|
||||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
@@ -24,12 +42,16 @@ export function isChunkLoadError(error: unknown): boolean {
|
|||||||
function handleError(error: unknown) {
|
function handleError(error: unknown) {
|
||||||
if (!isChunkLoadError(error)) return;
|
if (!isChunkLoadError(error)) return;
|
||||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
// the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this
|
||||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
// recovers across multiple deploys in a single tab's lifetime, yet a
|
||||||
// flag is already set we fall through to the manual recovery UI below.
|
// permanently-broken lazy chunk (which would loop) is stopped after the first
|
||||||
|
// reload and falls through to the manual recovery UI below.
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
const raw = sessionStorage.getItem(RELOAD_AT_KEY);
|
||||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10);
|
||||||
|
const now = Date.now();
|
||||||
|
if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return;
|
||||||
|
sessionStorage.setItem(RELOAD_AT_KEY, String(now));
|
||||||
} catch {
|
} catch {
|
||||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
|
|||||||
@@ -86,19 +86,11 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
const EDGE_MARGIN = 8;
|
||||||
|
|
||||||
// #184 phase 1.5 / #430: backstop for the degraded-poll fallback. The poll is
|
// #184 phase 1.5 / #430 / #488: the degraded-poll fallback. The window owns only
|
||||||
// armed when a resume attempt could not attach to the live run and disarmed by the
|
// a DUMB 2.5s timer, gated by an armed flag; the THREAD's run-lifecycle FSM owns
|
||||||
// thread on settle / local stream; this cap is the ONLY backstop against an endless
|
// arm/disarm AND the inactivity cap that turns a stuck run into a `stalled` banner
|
||||||
// tick (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no
|
// (#488 commit 4a — the cap moved into the thread so polling->stalled is a single
|
||||||
// run).
|
// FSM transition; the window no longer silently stops polling at the cap).
|
||||||
//
|
|
||||||
// #430: measured from RUN ACTIVITY, not from arm-time. A real autonomous run takes
|
|
||||||
// 11-25 min — longer than a fixed 10-min-from-start cap, which used to cut the poll
|
|
||||||
// off mid-run. Instead we cap on INACTIVITY: keep polling as long as the run is
|
|
||||||
// still making progress (its persisted rows keep changing), and only give up after
|
|
||||||
// this long with NO new activity. A genuinely stuck run produces no row changes, so
|
|
||||||
// the idle cap still bounds it; a long-but-progressing run polls to completion.
|
|
||||||
const DEGRADED_POLL_IDLE_MAX_MS = 10 * 60_000;
|
|
||||||
|
|
||||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
@@ -259,17 +251,13 @@ export default function AiChatWindow() {
|
|||||||
[roles],
|
[roles],
|
||||||
);
|
);
|
||||||
|
|
||||||
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
// #184 phase 1.5 / #488: degraded-poll fallback. ChatThread's FSM arms this via
|
||||||
// ChatThread could not attach to a still-running run it arms this via
|
// onResumeFallback(true) when it enters a poll-bearing recovery (attach 204 /
|
||||||
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
// starved finish / stop) and disarms it on settle / local stream / stalled. The
|
||||||
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
// window owns ONLY the dumb 2.5s timer; the THREAD owns arm/disarm AND the
|
||||||
|
// inactivity cap (a stuck run -> the thread's `stalled` banner disarms this).
|
||||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
const [degradedPoll, setDegradedPoll] = useState(false);
|
||||||
// #430: timestamp of the LAST run activity while the poll is armed — stamped on
|
|
||||||
// arm and re-stamped whenever the polled rows change (see the effect below). The
|
|
||||||
// idle cap is measured from this, so a long-but-progressing run keeps polling.
|
|
||||||
const lastActivityAtRef = useRef(0);
|
|
||||||
const onResumeFallback = useCallback((active: boolean): void => {
|
const onResumeFallback = useCallback((active: boolean): void => {
|
||||||
if (active) lastActivityAtRef.current = Date.now();
|
|
||||||
setDegradedPoll(active);
|
setDegradedPoll(active);
|
||||||
}, []);
|
}, []);
|
||||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
||||||
@@ -281,33 +269,17 @@ export default function AiChatWindow() {
|
|||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(
|
useAiChatMessagesQuery(
|
||||||
activeChatId ?? undefined,
|
activeChatId ?? undefined,
|
||||||
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
// DELIBERATELY DUMB: poll every 2.5s WHILE ARMED, otherwise off. NO error
|
||||||
// and while the run is still active (#430: under the INACTIVITY cap, not a
|
// checks (TanStack resets fetchFailureCount each fetch; the poll must survive
|
||||||
// fixed-from-start cap); otherwise off. NO error checks (TanStack v5 resets
|
// a server restart), NO tail checks, NO cap here — the settled/stalled/idle-cap
|
||||||
// fetchFailureCount each fetch, so consecutive errors are not expressible —
|
// semantics all live in ChatThread's FSM, which disarms via onResumeFallback.
|
||||||
// and the poll must survive a server restart) and NO tail checks (the
|
() => (degradedPoll === true ? 2500 : false),
|
||||||
// settled/local-stream semantics live in ChatThread, which disarms via
|
|
||||||
// onResumeFallback(false)). The idle cap is the only backstop.
|
|
||||||
() =>
|
|
||||||
degradedPoll === true &&
|
|
||||||
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS
|
|
||||||
? 2500
|
|
||||||
: false,
|
|
||||||
// #344: gate on windowOpen too — no message history is fetched (and no
|
// #344: gate on windowOpen too — no message history is fetched (and no
|
||||||
// degraded poll runs) while the window is closed; it loads when the window
|
// degraded poll runs) while the window is closed; it loads when the window
|
||||||
// opens with an active chat.
|
// opens with an active chat.
|
||||||
windowOpen,
|
windowOpen,
|
||||||
);
|
);
|
||||||
|
|
||||||
// #430: re-stamp the activity clock whenever the polled rows change while the
|
|
||||||
// poll is armed. TanStack keeps the same `messageRows` reference across refetches
|
|
||||||
// that return deep-equal data (structural sharing), so a new reference means the
|
|
||||||
// run genuinely progressed — which extends the inactivity cap above. A stuck run
|
|
||||||
// yields no reference change, so the cap eventually fires and stops the poll.
|
|
||||||
useEffect(() => {
|
|
||||||
if (degradedPoll) lastActivityAtRef.current = Date.now();
|
|
||||||
}, [degradedPoll, messageRows]);
|
|
||||||
|
|
||||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
||||||
// this workspace. When the feature is off no runs are ever created, so the
|
// this workspace. When the feature is off no runs are ever created, so the
|
||||||
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
||||||
|
|||||||
@@ -55,6 +55,15 @@
|
|||||||
padding-inline-start: 1.4em;
|
padding-inline-start: 1.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The canonical converter renders list items through the editor schema, which
|
||||||
|
wraps each item's content in a <p> (listItem content is `paragraph+`). Drop
|
||||||
|
that paragraph's block margin so list items render TIGHT (no extra vertical
|
||||||
|
gap), matching the previous marked output — same rule already applied to
|
||||||
|
table cells above (issue #347). */
|
||||||
|
.markdown li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
||||||
wide LLM table must scroll horizontally instead of collapsing its columns:
|
wide LLM table must scroll horizontally instead of collapsing its columns:
|
||||||
`.markdown` sets `word-break: break-word`, which (with the default table
|
`.markdown` sets `word-break: break-word`, which (with the default table
|
||||||
@@ -172,6 +181,14 @@
|
|||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same as `.markdown li p` above: the canonical converter wraps every list
|
||||||
|
item's content in a <p>, so without this each reasoning-panel list item would
|
||||||
|
pick up `.reasoningText p`'s 4px bottom margin and render too loose. Drop it
|
||||||
|
so Reasoning-panel lists stay tight, mirroring the pre-#347 marked output. */
|
||||||
|
.reasoningText li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.inputWrapper {
|
.inputWrapper {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding-top: var(--mantine-spacing-xs);
|
padding-top: var(--mantine-spacing-xs);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -47,6 +47,13 @@ interface MessageItemProps {
|
|||||||
* agent's raw query/argument text.
|
* agent's raw query/argument text.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
|
/**
|
||||||
|
* Forwarded to ToolCallCard: whether a failed tool card renders its raw
|
||||||
|
* errorText. Defaults to true (internal chat). The public share passes false so
|
||||||
|
* internal detail in a tool error is never painted (belt to the server-side
|
||||||
|
* byte sanitization).
|
||||||
|
*/
|
||||||
|
showErrors?: boolean;
|
||||||
/**
|
/**
|
||||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||||
* their href so they become inert text). Defaults to false (internal chat,
|
* their href so they become inert text). Defaults to false (internal chat,
|
||||||
@@ -125,6 +132,7 @@ function MessageItem({
|
|||||||
message,
|
message,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
showErrors = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
turnStreaming = false,
|
turnStreaming = false,
|
||||||
@@ -219,6 +227,7 @@ function MessageItem({
|
|||||||
part={part as unknown as ToolUiPart}
|
part={part as unknown as ToolUiPart}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
showInput={showInput}
|
||||||
|
showErrors={showErrors}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,6 +293,7 @@ export function arePropsEqual(
|
|||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
prev.showInput === next.showInput &&
|
prev.showInput === next.showInput &&
|
||||||
|
prev.showErrors === next.showErrors &&
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName &&
|
prev.assistantName === next.assistantName &&
|
||||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ interface MessageListProps {
|
|||||||
* doesn't see the agent's raw query/argument text.
|
* doesn't see the agent's raw query/argument text.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
|
/**
|
||||||
|
* Forwarded to MessageItem -> ToolCallCard: whether a failed tool card renders
|
||||||
|
* its raw errorText. Defaults to true (internal chat). The public share passes
|
||||||
|
* false so internal detail in a tool error is never painted.
|
||||||
|
*/
|
||||||
|
showErrors?: boolean;
|
||||||
/**
|
/**
|
||||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||||
* the rendered answers (drop their href so they render as inert text).
|
* the rendered answers (drop their href so they render as inert text).
|
||||||
@@ -127,6 +133,7 @@ export default function MessageList({
|
|||||||
emptyState,
|
emptyState,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
showErrors = true,
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
@@ -217,6 +224,7 @@ export default function MessageList({
|
|||||||
signature={messageSignature(message)}
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
showInput={showInput}
|
||||||
|
showErrors={showErrors}
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ interface ToolCallCardProps {
|
|||||||
* the extra summary line, leaving the card (the action log) intact.
|
* the extra summary line, leaving the card (the action log) intact.
|
||||||
*/
|
*/
|
||||||
showInput?: boolean;
|
showInput?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether to render the tool's raw errorText on a failed call. Defaults to true
|
||||||
|
* (the internal chat, where the operator may debug). The public share passes
|
||||||
|
* false: a tool error string can carry internal detail (an internal page title,
|
||||||
|
* a stack fragment, a provider message). This is the RENDER gate only — the
|
||||||
|
* authoritative fix also sanitizes the bytes server-side (see
|
||||||
|
* PublicShareChatToolsService.forShare), so a share reader never receives raw
|
||||||
|
* error text over the wire, not just never sees it painted (#394).
|
||||||
|
*/
|
||||||
|
showErrors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +51,7 @@ export default function ToolCallCard({
|
|||||||
part,
|
part,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
showInput = true,
|
||||||
|
showErrors = true,
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toolName = getToolName(part);
|
const toolName = getToolName(part);
|
||||||
@@ -74,7 +85,7 @@ export default function ToolCallCard({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{state === "error" && part.errorText && (
|
{state === "error" && showErrors && part.errorText && (
|
||||||
<Text size="xs" c="red" mt={2}>
|
<Text size="xs" c="red" mt={2}>
|
||||||
{part.errorText}
|
{part.errorText}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -57,6 +57,25 @@ export async function stopRun(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #488: the run-fact — "is a run active on this chat?" — first-class from the
|
||||||
|
* server (POST /ai-chat/run). Called on mount to seed the client FSM's run-fact
|
||||||
|
* and to VERIFY after a supersede mismatch (an observer following a superseded
|
||||||
|
* run asks for the latest run and follows it). Returns the latest run row (with
|
||||||
|
* its `id` and `status`) and its projected assistant message, or `run: null` when
|
||||||
|
* the chat has never had a run. Owner-gated server-side.
|
||||||
|
*/
|
||||||
|
export async function getRun(chatId: string): Promise<{
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}> {
|
||||||
|
const req = await api.post<{
|
||||||
|
run: { id: string; status: string } | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}>("/ai-chat/run", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the chat bound to a document (the current user's most-recent chat
|
* Resolve the chat bound to a document (the current user's most-recent chat
|
||||||
* created on that page), or null when there is none. Drives auto-open-on-page.
|
* created on that page), or null when there is none. Drives auto-open-on-page.
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# AI-chat run-lifecycle FSM — design spec (#488)
|
||||||
|
|
||||||
|
This is the written design that `run-fsm.ts` implements. It ships in the PR (issue
|
||||||
|
#488 commit 1: "the spec is written FIRST and enters the PR"). It has four parts:
|
||||||
|
(1) the event × state transition table, (2) the map of every `chat-thread.tsx` ref
|
||||||
|
to {FSM state | FSM context | stays data}, (3) the run-fact protocol, (4) the
|
||||||
|
invariants.
|
||||||
|
|
||||||
|
The reducer is a **pure function** `reduce(machine, event) → machine`. The returned
|
||||||
|
machine carries the **command effects** for that transition; a thin runtime in
|
||||||
|
`chat-thread.tsx` dispatches events and executes effects. Because it is pure, the
|
||||||
|
whole machine is enumerable and unit-tested directly (event × state → next state is
|
||||||
|
the observable property) — see `run-fsm.test.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Event × state transition table
|
||||||
|
|
||||||
|
Phases: `idle | sending | streaming | attaching | reconnecting(attempt,failed) |
|
||||||
|
polling(reason) | stalled | stopping | superseding | error(kind)`.
|
||||||
|
Context (orthogonal): `epoch`, `ownership: local|observer`, `runFact: {runId}|null`,
|
||||||
|
`liveFollow` (are we following a live run we locally streamed — the reconnect
|
||||||
|
ladder — vs a one-shot mount-attach resume? both are `observer`, but a live-follow
|
||||||
|
drop RE-ENTERS the ladder (#488 commit 3) while a mount-resume drop polls).
|
||||||
|
|
||||||
|
Legend: **†** = command-transition (bumps `epoch`, I1). Effects in `[…]`.
|
||||||
|
|
||||||
|
| Event (source) | From phase(s) | → To phase | Effects / ctx |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `SEND_LOCAL` (user send) | idle, error, polling, stalled, reconnecting | sending **†** | `[cancelReconnect, disarmPoll]`, ownership=local |
|
||||||
|
| `STREAM_START{runId}` (SDK `start` metadata) | sending, attaching, reconnecting, superseding | streaming | `[cancelReconnect, disarmPoll]`, runFact←runId |
|
||||||
|
| `FINISH_CLEAN` (onFinish clean) | streaming, … | idle | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||||
|
| `FINISH_ABORT` (onFinish isAbort) | streaming, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4 exits stopping by this DATA) |
|
||||||
|
| `FINISH_DISCONNECT` (observer, NOT liveFollow) | streaming(observer) | polling(disconnect-visible) | `[armPoll]` (a mount-resume drop polls) |
|
||||||
|
| `FINISH_DISCONNECT{hasVisibleContent}` (local drop OR liveFollow) | streaming | reconnecting(1) **†** *iff runFact\|liveFollow* | `[scheduleReconnect(1)]` (+`armPoll` if visible), ownership=observer, liveFollow=true (commit 3: repeatable) |
|
||||||
|
| `FINISH_DISCONNECT` (no runFact, not liveFollow) | streaming | idle | runFact←null (plain terminal "connection lost") |
|
||||||
|
| `STREAM_INCOMPLETE{reason}` (observer starved/torn clean finish) | streaming(observer) | polling(reason) | `[armPoll(reason)]` |
|
||||||
|
| `FINISH_ERROR{kind}` (onFinish isError) | any | error(kind) | `[disarmPoll, cancelReconnect]`, runFact←null |
|
||||||
|
| `STREAM_START{runId}` (first assistant frame of a local turn) | sending | streaming | runFact←runId, `[cancelReconnect, disarmPoll]` |
|
||||||
|
| `ATTACH_START{runId}` (mount resume) | **idle only** (F2) | attaching **†** | `[resumeStream]`, ownership=observer, runFact←runId; ignored from any non-idle phase |
|
||||||
|
| `ATTACH_LIVE` (attach GET 2xx) | attaching | streaming | — |
|
||||||
|
| `ATTACH_NONE` (attach GET 204/err/throw) | attaching | polling(attach-none) | `[armPoll(attach-none)]` |
|
||||||
|
| `RECONNECT_ATTEMPT{n}` (backoff timer) | reconnecting | reconnecting(n) **†** | `[resumeStream]` |
|
||||||
|
| `RECONNECT_ATTACHED` (reconnect GET 2xx) | reconnecting | streaming | `[cancelReconnect, disarmPoll]` — **counter reset** (commit 3) |
|
||||||
|
| `RECONNECT_NONE` (reconnect GET 204/err), attempt<MAX | reconnecting | reconnecting(n+1) **†** | `[armPoll(attach-none), scheduleReconnect(n+1)]` |
|
||||||
|
| `RECONNECT_NONE`, attempt=MAX | reconnecting | reconnecting(MAX, failed) | `[armPoll(reconnect-exhausted)]` |
|
||||||
|
| `RETRY` (manual, failed banner) | reconnecting(failed) | reconnecting(1) **†** | `[resumeStream]` |
|
||||||
|
| `RETRY` (manual, stalled banner) | stalled | polling(attach-none) **†** | `[armPoll]` |
|
||||||
|
| `POLL_TERMINAL` (settled tail merged) | polling, reconnecting, stopping | idle | `[disarmPoll, cancelReconnect]`, runFact←null (I4) |
|
||||||
|
| `POLL_IDLE_CAP` (inactivity cap) | polling, reconnecting | stalled | `[disarmPoll, cancelReconnect]` (commit 4a — no more silent) |
|
||||||
|
| `RUN_FACT{null}` (POST /run → null/terminal, 204) | reconnecting/attaching/polling/stopping | idle | `[cancelReconnect, disarmPoll]`, runFact←null (I3 fresh-negative gate) |
|
||||||
|
| `RUN_FACT{runId}` | any | (same) | runFact←runId (pessimism toward an attempt) |
|
||||||
|
| `STOP_REQUESTED` (user Stop) | streaming, reconnecting, polling | stopping **†** | `[stopRun, abortAttach, cancelReconnect, armPoll]` (poll drives the terminal — I4 exit by data) |
|
||||||
|
| `SUPERSEDE_REQUESTED{targetRunId}` (interrupt+send) | streaming, reconnecting, polling, error | superseding **†** | `[supersede(target), cancelReconnect, disarmPoll]` |
|
||||||
|
| `SUPERSEDE_READY{runId}` (CAS ok) | superseding | streaming | ownership=local, runFact←runId |
|
||||||
|
| `SUPERSEDE_MISMATCH{currentRunId}` (409 SUPERSEDE_TARGET_MISMATCH) | superseding | error(supersede-mismatch) | `[postRun(verify)]`, runFact←currentRunId |
|
||||||
|
| `SUPERSEDE_TIMEOUT` (409 SUPERSEDE_TIMEOUT) | superseding | error(supersede-timeout) | — (composer keeps text; no auto-retry) |
|
||||||
|
| `SUPERSEDE_INVALID` (409 SUPERSEDE_INVALID) | superseding | error(supersede-invalid) | — |
|
||||||
|
| `RUN_ALREADY_ACTIVE{activeRunId}` (409 A_RUN_ALREADY_ACTIVE, plain POST) | sending | error(run-already-active) | runFact←activeRunId (composer offers supersede; NO auto-retry) |
|
||||||
|
| `DISPOSE` (unmount) | any | idle **†** | `[abortAttach, cancelReconnect, disarmPoll]` (I1/I5 — epoch++ kills late callbacks) |
|
||||||
|
|
||||||
|
**`stopping` honors any finish (re-review MEDIUM):** BEFORE the epoch filter, a
|
||||||
|
stream finish (`FINISH_*`/`STREAM_INCOMPLETE`) arriving in phase `stopping` exits
|
||||||
|
`stopping -> idle` regardless of generation. A plain Stop has no successor stream,
|
||||||
|
so the aborted stream's finish IS the expected end (I4 exit by data) — and it
|
||||||
|
carries the PRE-stop generation (STOP_REQUESTED bumped the epoch), so the filter
|
||||||
|
would otherwise strand the machine in `stopping` (no idle-cap covers it). The filter
|
||||||
|
stays in force for `superseding` (that is the F1 supersede drop).
|
||||||
|
|
||||||
|
**Epoch filter (I1):** the reducer then drops any event carrying an `epoch` that
|
||||||
|
does not equal the current `ctx.epoch`. Outcome events (`STREAM_START`, `ATTACH_*`,
|
||||||
|
`RECONNECT_*`, `SUPERSEDE_*`, **`FINISH_*`/`STREAM_INCOMPLETE`**, `RUN_FACT`) are
|
||||||
|
stamped with the generation the corresponding STREAM started under (the runtime
|
||||||
|
holds a per-owned-stream `turnEpoch`); trigger events (user actions, fresh
|
||||||
|
disconnects) carry no epoch. **F1:** this is what makes a SUPERSEDED stream's late
|
||||||
|
`onFinish` (a dead stream A closing after the CAS started stream B) get dropped, so
|
||||||
|
A cannot drive the live new run into a false reconnect or reset its run-fact. The
|
||||||
|
supersede path additionally ABORTS A and starts B only from A's onFinish (a
|
||||||
|
microtask), because ai@6 `AbstractChat.makeRequest` corrupts overlapping streams
|
||||||
|
(A's `finally` reads then nulls the shared `activeResponse`).
|
||||||
|
|
||||||
|
**Removed events (scope-cut, internal review):** `RUN_SUPERSEDED` (a ghost feature —
|
||||||
|
never dispatched; the observer-superseded case is handled by the degraded poll,
|
||||||
|
which follows the latest rows regardless of runId), `RECONNECT_BEGIN` (reconnect is
|
||||||
|
entered by `FINISH_DISCONNECT`), and `POLL_ACTIVITY` (the window's activity clock was
|
||||||
|
removed when the idle-cap moved into the thread). The reducer and this table now
|
||||||
|
share exactly the dispatched event set.
|
||||||
|
|
||||||
|
### 409-code → event map (the real #487 contract consumed here)
|
||||||
|
|
||||||
|
| Server response | Event dispatched | error kind → banner |
|
||||||
|
|---|---|---|
|
||||||
|
| 409 `A_RUN_ALREADY_ACTIVE` (+ body.activeRunId) | `RUN_ALREADY_ACTIVE{activeRunId}` | run-already-active → "already answering / interrupt & send" |
|
||||||
|
| 409 `SUPERSEDE_TARGET_MISMATCH` (+ body.activeRunId) | `SUPERSEDE_MISMATCH{currentRunId}` | supersede-mismatch → verify via /run |
|
||||||
|
| 409 `SUPERSEDE_TIMEOUT` | `SUPERSEDE_TIMEOUT` | supersede-timeout → "couldn't interrupt in time, resend" |
|
||||||
|
| 409 `SUPERSEDE_INVALID` | `SUPERSEDE_INVALID` | supersede-invalid → "couldn't interrupt this run" |
|
||||||
|
| 503 `A_RUN_BEGIN_FAILED` | `FINISH_ERROR{begin-failed}` | begin-failed → "could not start, temporary" |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ref-map — every `chat-thread.tsx` ref → its new home (MIGRATION RESOLVED)
|
||||||
|
|
||||||
|
The migration is COMPLETE: the 13 run-lifecycle FLAGS below are GONE from
|
||||||
|
`chat-thread.tsx` (collapsed into FSM phase/ctx/effects, or deleted). What remains
|
||||||
|
are identity/data mirrors, effect-owned controllers/timers, and ONE React-liveness
|
||||||
|
bit — none of which is a run-lifecycle flag, so the post-merge "no new flags" rule
|
||||||
|
holds. **Pending column: empty.**
|
||||||
|
|
||||||
|
| # | Old ref | Resolved to | Where now |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | `reconcileTailRef` | **FSM phase** | reconcile-merge gated on `phase ∈ {polling, reconnecting, stopping}` |
|
||||||
|
| 2 | `noStreamHandledRef` | **FSM epoch (I1)** | the attach outcome's epoch guard drops the stale/second outcome |
|
||||||
|
| 3 | `onNoActiveStreamRef` | **FSM event** | transport → `handleAttachOutcome` dispatches `ATTACH_NONE`/`RECONNECT_NONE` |
|
||||||
|
| 4 | `onReconnectAttachedRef` | **FSM event** | transport dispatches `ATTACH_LIVE` / `RECONNECT_ATTACHED` |
|
||||||
|
| 5 | `resumedTurnRef` + `resumedTurn` state | **FSM ctx `ownership`** | `ownership==='observer'` ⇒ never flush; hides "Send now" |
|
||||||
|
| 6 | `reconnectStateRef` + `reconnectState` state | **FSM phase** | `reconnecting(attempt,failed)` renders the banner |
|
||||||
|
| 7 | `reconnectTimerRef` | **effect-owned timer** | owned by `scheduleReconnect`/`cancelReconnect` effects (not a flag) |
|
||||||
|
| 8 | `flushOnAbortRef` | **DELETED** | the stop→flush dance is replaced by the CAS supersede (commit 5) |
|
||||||
|
| 9 | `interruptNextSendRef` | **DELETED** | the server injects the interrupt note from the supersede itself |
|
||||||
|
| 10 | `supersedeRetryRef` | **DELETED** (commit 5) | the client 409 retry ladder is gone; CAS supersede replaces it |
|
||||||
|
| 11 | `stopPendingRef` | **FSM phase `stopping`** | the deferred stop fires from the chat-id adoption effect while `stopping` |
|
||||||
|
| 12 | `mountedRef` | **retained (React liveness)** | orthogonal to run-lifecycle; gates imperative onFinish side-effects post-unmount. Epoch (I1) handles stale COMMAND-outcomes; DISPOSE bumps it |
|
||||||
|
| 13 | `attemptResumeRef` | **FSM `ATTACH_START` + run-fact** | mount arms attach ONLY on a confirmed active run (commit 4b: streaming-tail status, or POST /run for a user tail) |
|
||||||
|
| 14 | `stripRef` | **data** (attachStrategy) | strip+replay detail; the `resumeStream` effect reads it |
|
||||||
|
| 15 | `strippedRowRef` | **data** (attachStrategy) | the anchor row |
|
||||||
|
| 16 | `attachAbortRef` | **effect-owned controller** | aborted by the `abortAttach` effect in cleanup (I5) |
|
||||||
|
| 17–25 | `chatIdRef`, `openPageRef`, `getEditorSelectionRef`, `roleIdRef`, `stableIdRef`, `queuedRef`, `sendMessageRef`, `statusRef`, `lastForwardedChatIdRef` | **data** (identity/send mirrors) | unchanged — not lifecycle flags |
|
||||||
|
| NEW | `pendingSupersedeRef` | **data** (send-plumbing) | the runId injected into the next `POST /stream {supersede}`; the single replacement for the 3 DELETED one-shots (#8/#9/#10) — net −2 refs |
|
||||||
|
| NEW | `idleCapTimerRef` | **effect-owned timer** | the stalled inactivity cap → `POLL_IDLE_CAP` (commit 4a); not a flag |
|
||||||
|
|
||||||
|
Net: the 13 lifecycle flags (#1–#13) are eliminated: **8** → FSM phase/ctx/epoch/event
|
||||||
|
(#1–#6, #11, #13), **3** deleted (#8/#9/#10), **`reconnectTimerRef` (#7)** becomes an
|
||||||
|
effect-owned controller, and **`mountedRef` (#12)** is retained as React liveness
|
||||||
|
(8 + 3 + 1 + 1 = 13). (`attachAbortRef` (#16) is outside the #1–#13 set — it was
|
||||||
|
already an effect-owned controller.) Two effect-owned timers + one send-plumbing data
|
||||||
|
ref are added — none is a boolean lifecycle latch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Run-fact protocol (`runFact: {runId} | null`) — I3
|
||||||
|
|
||||||
|
"A run is active" is first-class from the SERVER, not inferred from an assistant
|
||||||
|
message. Sources, in the order they update `ctx.runFact`:
|
||||||
|
|
||||||
|
1. **Init (mount):** `POST /ai-chat/run { chatId }` → `{ run, message }`. A `run`
|
||||||
|
with a non-terminal `status` seeds `runFact = { runId: run.id }`; a null/terminal
|
||||||
|
run seeds `null`. This is what arms the resume attempt (`ATTACH_START`) — the
|
||||||
|
attempt is armed ONLY on a positive fact (commit 4b: a user-tail with no active
|
||||||
|
run no longer arms a pointless poll on every open).
|
||||||
|
2. **Live update:** the `start` stream metadata carries `runId` → `STREAM_START{runId}`.
|
||||||
|
3. **Attach outcomes:** `ATTACH_LIVE` (2xx) confirms active; a 204 on a non-stripped
|
||||||
|
path is an authoritative NEGATIVE fact → the runtime dispatches `RUN_FACT{null}`,
|
||||||
|
which cancels recovery (I3 fresh-negative gate).
|
||||||
|
4. **Poll (future resume-stack iteration #491):** the delta will carry the run field;
|
||||||
|
until then the poll drives to a terminal ROW, dispatched as `POLL_TERMINAL`.
|
||||||
|
|
||||||
|
Pessimism rule: a stale-but-positive fact PERMITS entering recovery (attach); the
|
||||||
|
204 then cuts it. A fresh negative fact gates recovery OUT immediately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Invariants
|
||||||
|
|
||||||
|
- **I1 — Epoch (generation counter).** Every command-emitting transition bumps
|
||||||
|
`ctx.epoch`; every async outcome event carries its issuing epoch; the reducer
|
||||||
|
drops stale-epoch outcomes. Replaces the one-shot-ref zoo (`noStreamHandledRef`,
|
||||||
|
the flush/interrupt/supersede one-shots, the `mountedRef` late-callback gate).
|
||||||
|
- **I2 — Ownership is context, not state.** `local | observer` is orthogonal to the
|
||||||
|
transport phase. The queue flushes ONLY under local ownership; an observer
|
||||||
|
following a detached run never flushes (was `resumedTurnRef`).
|
||||||
|
- **I3 — Run-fact is first-class from the server.** Reconnect is entered by the
|
||||||
|
run-fact, not by an assistant message (commit 2). A fresh negative fact cancels
|
||||||
|
recovery.
|
||||||
|
- **I4 — Exit `stopping` by DATA.** A terminal row / negative run-fact / terminal
|
||||||
|
finish exits `stopping`, never the stopRun HTTP response (which returns after the
|
||||||
|
abort but before finalization — keying off it would unlock the composer on a 409).
|
||||||
|
- **I5 — Dispose protocol.** Command controllers (attach GET, POST /stream, POST
|
||||||
|
/run) are effect-owned and aborted in cleanup (`abortAttach` on `DISPOSE`), not
|
||||||
|
render-phase refs. A client abort of an already-sent POST does not cancel the
|
||||||
|
server action, so disarming on unmount is safe.
|
||||||
|
- **attachStrategy** (strip+replay today) is behind the `resumeStream` effect; the
|
||||||
|
resume-stack iteration (#491) swaps it to tail-only WITHOUT touching the FSM.
|
||||||
|
- **Queue** stays a data structure; flush/interrupt decisions are transitions.
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
reduce,
|
||||||
|
initialMachine,
|
||||||
|
reconnectDelayMs,
|
||||||
|
RECONNECT_MAX_ATTEMPTS,
|
||||||
|
type Machine,
|
||||||
|
type Effect,
|
||||||
|
type Event,
|
||||||
|
} from "./run-fsm";
|
||||||
|
|
||||||
|
// Drive a sequence of events through the reducer, returning the final machine.
|
||||||
|
function run(m: Machine, ...events: Event[]): Machine {
|
||||||
|
return events.reduce(reduce, m);
|
||||||
|
}
|
||||||
|
function withRunFact(runId = "run-1"): Machine {
|
||||||
|
return {
|
||||||
|
...initialMachine(),
|
||||||
|
ctx: { epoch: 0, ownership: "local", runFact: { runId }, liveFollow: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function effectTypes(m: Machine): string[] {
|
||||||
|
return m.effects.map((e) => e.type);
|
||||||
|
}
|
||||||
|
function hasEffect(m: Machine, type: Effect["type"]): boolean {
|
||||||
|
return m.effects.some((e) => e.type === type);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("run-fsm — epoch invariant (I1)", () => {
|
||||||
|
it("drops an outcome carrying a stale epoch", () => {
|
||||||
|
// A command bumps the epoch; an outcome stamped with the OLD epoch is dropped.
|
||||||
|
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" }); // epoch 0->1, attaching
|
||||||
|
expect(m0.ctx.epoch).toBe(1);
|
||||||
|
expect(m0.phase.name).toBe("attaching");
|
||||||
|
// A late ATTACH_LIVE from a SUPERSEDED attempt (epoch 0) must NOT drive us.
|
||||||
|
const stale = reduce(m0, { type: "ATTACH_LIVE", epoch: 0 });
|
||||||
|
expect(stale.phase.name).toBe("attaching");
|
||||||
|
expect(stale.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an outcome carrying the current epoch", () => {
|
||||||
|
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const live = reduce(m0, { type: "ATTACH_LIVE", epoch: m0.ctx.epoch });
|
||||||
|
expect(live.phase.name).toBe("streaming");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an outcome with no epoch is never dropped (trigger events)", () => {
|
||||||
|
const m0 = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const disposed = reduce(m0, { type: "DISPOSE" });
|
||||||
|
expect(disposed.phase.name).toBe("idle");
|
||||||
|
expect(hasEffect(disposed, "abortAttach")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("every command-transition increments the epoch exactly once", () => {
|
||||||
|
let m = initialMachine();
|
||||||
|
const before = m.ctx.epoch;
|
||||||
|
m = reduce(m, { type: "SEND_LOCAL" });
|
||||||
|
expect(m.ctx.epoch).toBe(before + 1);
|
||||||
|
m = reduce(m, { type: "STOP_REQUESTED" });
|
||||||
|
expect(m.ctx.epoch).toBe(before + 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — local turn", () => {
|
||||||
|
it("SEND_LOCAL → sending, local ownership, cancels recovery", () => {
|
||||||
|
const m = reduce(withRunFact(), { type: "SEND_LOCAL" });
|
||||||
|
expect(m.phase.name).toBe("sending");
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
expect(effectTypes(m)).toEqual(
|
||||||
|
expect.arrayContaining(["cancelReconnect", "disarmPoll"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STREAM_START adopts the runId into the run-fact and goes streaming", () => {
|
||||||
|
const m = run(initialMachine(), { type: "SEND_LOCAL" });
|
||||||
|
const s = reduce(m, { type: "STREAM_START", runId: "run-9", epoch: m.ctx.epoch });
|
||||||
|
expect(s.phase.name).toBe("streaming");
|
||||||
|
expect(s.ctx.runFact).toEqual({ runId: "run-9" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINISH_CLEAN → idle, run-fact cleared, poll/reconnect disarmed", () => {
|
||||||
|
const streaming = run(initialMachine(), { type: "SEND_LOCAL" }, { type: "STREAM_START", runId: "r" });
|
||||||
|
const done = reduce(streaming, { type: "FINISH_CLEAN" });
|
||||||
|
expect(done.phase.name).toBe("idle");
|
||||||
|
expect(done.ctx.runFact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 2 — SSE break BEFORE the first assistant frame must still recover.
|
||||||
|
describe("run-fsm — commit 2: reconnect by run-fact, not by assistant message", () => {
|
||||||
|
it("FINISH_DISCONNECT with an active run-fact → reconnecting (even with no visible content)", () => {
|
||||||
|
// Setup-phase break: no assistant frame yet, but a run-fact exists.
|
||||||
|
const streaming = withRunFact("run-2");
|
||||||
|
const m = reduce(streaming, {
|
||||||
|
type: "FINISH_DISCONNECT",
|
||||||
|
hasVisibleContent: false,
|
||||||
|
epoch: streaming.ctx.epoch,
|
||||||
|
});
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
||||||
|
// No visible content -> no poll arm yet (the reconnect ladder rebuilds it).
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINISH_DISCONNECT WITH visible content also arms the poll", () => {
|
||||||
|
const m = reduce(withRunFact("run-2"), {
|
||||||
|
type: "FINISH_DISCONNECT",
|
||||||
|
hasVisibleContent: true,
|
||||||
|
epoch: 0,
|
||||||
|
});
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FINISH_DISCONNECT with NO run-fact → idle (plain connection-lost)", () => {
|
||||||
|
const m = reduce(initialMachine(), {
|
||||||
|
type: "FINISH_DISCONNECT",
|
||||||
|
hasVisibleContent: true,
|
||||||
|
epoch: 0,
|
||||||
|
});
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 3 — a SECOND break after a successful re-attach starts a NEW ladder.
|
||||||
|
describe("run-fsm — commit 3: repeated reconnect cycles", () => {
|
||||||
|
it("two breaks in a row produce two reconnect cycles (counter resets on attach)", () => {
|
||||||
|
let m = withRunFact("run-3");
|
||||||
|
// First break -> reconnecting(1).
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
// Attempt fires, re-attaches live.
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("streaming");
|
||||||
|
// SECOND break: the counter was reset, so a fresh ladder starts at attempt 1
|
||||||
|
// (the old one-shot !wasResumed gate would have sent this to silent poll).
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") expect(m.phase.attempt).toBe(1);
|
||||||
|
expect(hasEffect(m, "scheduleReconnect")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a MOUNT-attach observer drop falls to POLL, not the reconnect ladder", () => {
|
||||||
|
// Distinguishes commit 3 from a one-shot resume: an observer that never
|
||||||
|
// live-followed (liveFollow false) polls on a drop.
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
expect(m.ctx.liveFollow).toBe(false);
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("polling");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("STREAM_INCOMPLETE (observer starved/torn finish) → polling", () => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "STREAM_INCOMPLETE", reason: "starved", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "polling", reason: "starved" });
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liveFollow is set on the first local drop and kept across a re-attach", () => {
|
||||||
|
let m = withRunFact("run-3");
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.liveFollow).toBe(true);
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.liveFollow).toBe(true); // kept — so a second drop reconnects
|
||||||
|
// A clean finish clears it.
|
||||||
|
m = reduce(m, { type: "FINISH_CLEAN", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.liveFollow).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RECONNECT_NONE backs off through the ladder, then fails at the cap", () => {
|
||||||
|
let m = withRunFact("run-3");
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: false, epoch: m.ctx.epoch });
|
||||||
|
for (let n = 1; n < RECONNECT_MAX_ATTEMPTS; n++) {
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: n, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") {
|
||||||
|
expect(m.phase.attempt).toBe(n + 1);
|
||||||
|
expect(m.phase.failed).toBe(false);
|
||||||
|
}
|
||||||
|
// The belt-and-suspenders poll is armed each failed attempt.
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
}
|
||||||
|
// Final attempt fails -> failed banner (Retry), poll armed.
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: RECONNECT_MAX_ATTEMPTS, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_NONE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") expect(m.phase.failed).toBe(true);
|
||||||
|
// RETRY restarts at attempt 1.
|
||||||
|
m = reduce(m, { type: "RETRY" });
|
||||||
|
expect(m.phase.name).toBe("reconnecting");
|
||||||
|
if (m.phase.name === "reconnecting") {
|
||||||
|
expect(m.phase.attempt).toBe(1);
|
||||||
|
expect(m.phase.failed).toBe(false);
|
||||||
|
}
|
||||||
|
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnectDelayMs is the exponential backoff 1s,2s,4s,8s,16s", () => {
|
||||||
|
expect([1, 2, 3, 4, 5].map(reconnectDelayMs)).toEqual([1000, 2000, 4000, 8000, 16000]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 4 — polling stalled-state + user-tail gating.
|
||||||
|
describe("run-fsm — commit 4: stalled + run-fact gating", () => {
|
||||||
|
it("POLL_IDLE_CAP: polling → stalled with a banner (poll disarmed), not silent", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("polling");
|
||||||
|
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||||
|
expect(m.phase.name).toBe("stalled");
|
||||||
|
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RETRY from stalled re-arms the poll", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||||
|
m = reduce(m, { type: "RETRY" });
|
||||||
|
expect(m.phase.name).toBe("polling");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a fresh NEGATIVE run-fact while attaching cancels recovery (user-tail, no active run)", () => {
|
||||||
|
// The mount POST /run returns no active run: attaching → idle, no poll armed.
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.runFact).toBeNull();
|
||||||
|
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a negative run-fact while polling stops the poll", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POLL_TERMINAL settles polling → idle (I4 data-driven exit)", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "POLL_TERMINAL" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.runFact).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 5 — error classification + supersede CAS transitions.
|
||||||
|
describe("run-fsm — commit 5: supersede CAS + error classification", () => {
|
||||||
|
it("SUPERSEDE_REQUESTED → superseding, fires the CAS effect, bumps epoch", () => {
|
||||||
|
const streaming = withRunFact("run-old");
|
||||||
|
const m = reduce(streaming, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
expect(m.phase.name).toBe("superseding");
|
||||||
|
expect(m.ctx.epoch).toBe(streaming.ctx.epoch + 1);
|
||||||
|
const sup = m.effects.find((e) => e.type === "supersede");
|
||||||
|
expect(sup).toEqual({ type: "supersede", targetRunId: "run-old" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_READY → streaming as the new local owner", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_READY", runId: "run-new", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("streaming");
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-new" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_MISMATCH → error(supersede-mismatch) + verify via /run (no blind banner)", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_MISMATCH", currentRunId: "run-x", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "supersede-mismatch" });
|
||||||
|
expect(hasEffect(m, "postRun")).toBe(true);
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-x" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_TIMEOUT → error(supersede-timeout), no auto-retry effect", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "supersede-timeout" });
|
||||||
|
expect(m.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SUPERSEDE_INVALID → error(supersede-invalid)", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_INVALID", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "supersede-invalid" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stale SUPERSEDE outcome from a superseded epoch is dropped", () => {
|
||||||
|
let m = reduce(withRunFact("run-old"), { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
const supersedingEpoch = m.ctx.epoch;
|
||||||
|
// The user retriggers, bumping the epoch again.
|
||||||
|
m = reduce(m, { type: "SUPERSEDE_REQUESTED", targetRunId: "run-old" });
|
||||||
|
// The first CAS's late TIMEOUT (old epoch) must NOT knock us out of superseding.
|
||||||
|
const late = reduce(m, { type: "SUPERSEDE_TIMEOUT", epoch: supersedingEpoch });
|
||||||
|
expect(late.phase.name).toBe("superseding");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RUN_ALREADY_ACTIVE (plain POST gate) → error(run-already-active), no retry effect", () => {
|
||||||
|
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), { type: "RUN_ALREADY_ACTIVE" });
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||||
|
expect(m.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#497/S4: RUN_ALREADY_ACTIVE{activeRunId} ADOPTS the server's active run as the run-fact", () => {
|
||||||
|
// The server sends `activeRunId` so a later supersede can TARGET that run
|
||||||
|
// instead of a blind promote+abort. Absorb it into runFact.
|
||||||
|
const m = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
||||||
|
type: "RUN_ALREADY_ACTIVE",
|
||||||
|
activeRunId: "run-foreign",
|
||||||
|
});
|
||||||
|
expect(m.phase).toEqual({ name: "error", kind: "run-already-active" });
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-foreign" });
|
||||||
|
expect(m.effects).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("#497/S4: RUN_ALREADY_ACTIVE without an activeRunId keeps the prior run-fact", () => {
|
||||||
|
const seeded = reduce(run(initialMachine(), { type: "SEND_LOCAL" }), {
|
||||||
|
type: "RUN_FACT",
|
||||||
|
runFact: { runId: "run-prior" },
|
||||||
|
});
|
||||||
|
const m = reduce(seeded, { type: "RUN_ALREADY_ACTIVE" });
|
||||||
|
expect(m.ctx.runFact).toEqual({ runId: "run-prior" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 F2 — a late mount `getRun → ATTACH_START` must not hijack a local turn.
|
||||||
|
describe("run-fsm — F2: ATTACH_START only from idle", () => {
|
||||||
|
it("ATTACH_START from a local `sending` turn is ignored (no observer hijack)", () => {
|
||||||
|
const sending = reduce(initialMachine(), { type: "SEND_LOCAL" }); // idle -> sending, local
|
||||||
|
const m = reduce(sending, { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(m.phase.name).toBe("sending");
|
||||||
|
expect(m.ctx.ownership).toBe("local"); // NOT flipped to observer
|
||||||
|
expect(m.effects).toEqual([]); // no resumeStream
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ATTACH_START from idle attaches as normal", () => {
|
||||||
|
const m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(m.phase.name).toBe("attaching");
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
expect(hasEffect(m, "resumeStream")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — stop (I4: exit by data)", () => {
|
||||||
|
it("STOP_REQUESTED → stopping, fires stopRun + abortAttach, no data-independent exit", () => {
|
||||||
|
const m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
expect(m.phase.name).toBe("stopping");
|
||||||
|
expect(effectTypes(m)).toEqual(expect.arrayContaining(["stopRun", "abortAttach"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping exits on the aborted stream's finish carrying the PRE-STOP epoch", () => {
|
||||||
|
// MEDIUM (#488 re-review): STOP_REQUESTED is a command that BUMPS the epoch, but
|
||||||
|
// the runtime stamps the aborted stream's onFinish with the stream's START (pre-
|
||||||
|
// stop) generation — exactly what the component sends. `stopping` must HONOR
|
||||||
|
// that finish regardless of generation (no idle-cap covers `stopping`).
|
||||||
|
// MUTATION-VERIFY: remove the honor-in-`stopping` branch and this hangs in
|
||||||
|
// `stopping` (the epoch filter drops the pre-stop finish) -> red.
|
||||||
|
const preStopEpoch = withRunFact().ctx.epoch; // E1 (the stream's start epoch)
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" }); // E1 -> E2, stopping
|
||||||
|
expect(m.ctx.epoch).toBe(preStopEpoch + 1);
|
||||||
|
m = reduce(m, { type: "FINISH_ABORT", epoch: preStopEpoch }); // NOT the current epoch
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.runFact).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping exits on a clean finish carrying the pre-stop epoch too", () => {
|
||||||
|
const preStopEpoch = withRunFact().ctx.epoch;
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
m = reduce(m, { type: "FINISH_CLEAN", epoch: preStopEpoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stopping exits on a negative run-fact (data)", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
m = reduce(m, { type: "RUN_FACT", runFact: null, epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review #4: `stopping` arms the poll but had no inactivity backstop.
|
||||||
|
it("review-4: POLL_IDLE_CAP in `stopping` exits to idle (bounded), NOT stalled", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "STOP_REQUESTED" });
|
||||||
|
expect(m.phase.name).toBe("stopping");
|
||||||
|
expect(hasEffect(m, "armPoll")).toBe(true);
|
||||||
|
// MUTATION-VERIFY: drop the `stopping` branch in POLL_IDLE_CAP and this hangs
|
||||||
|
// in `stopping` (poll forever) -> red.
|
||||||
|
m = reduce(m, { type: "POLL_IDLE_CAP" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(hasEffect(m, "disarmPoll")).toBe(true);
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review #1: positive attach outcomes must be guarded by the SOURCE phase — the
|
||||||
|
// epoch filter alone is insufficient because POLL_TERMINAL uses to() (no epoch
|
||||||
|
// bump) and does not abort the in-flight GET.
|
||||||
|
describe("run-fsm — review-1: attach outcomes guarded by source phase", () => {
|
||||||
|
it("a late RECONNECT_ATTACHED after POLL_TERMINAL stays idle (no phantom streaming)", () => {
|
||||||
|
let m = withRunFact("run-1");
|
||||||
|
m = reduce(m, { type: "FINISH_DISCONNECT", hasVisibleContent: true, epoch: m.ctx.epoch });
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTEMPT", attempt: 1, epoch: m.ctx.epoch }); // attach GET
|
||||||
|
const epoch = m.ctx.epoch;
|
||||||
|
// The armed degraded poll reaches the terminal row FIRST (epoch unchanged).
|
||||||
|
m = reduce(m, { type: "POLL_TERMINAL" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.epoch).toBe(epoch); // POLL_TERMINAL did NOT bump the epoch
|
||||||
|
// The slow GET returns live 2xx under the SAME epoch — must NOT resurrect.
|
||||||
|
m = reduce(m, { type: "RECONNECT_ATTACHED", epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a late ATTACH_LIVE / ATTACH_NONE after leaving `attaching` is ignored", () => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const epoch = m.ctx.epoch;
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch }); // attaching -> polling
|
||||||
|
m = reduce(m, { type: "POLL_TERMINAL" }); // -> idle (epoch unchanged)
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch }); // late 2xx, same epoch
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
// And a late ATTACH_NONE (not `attaching`) is a no-op too.
|
||||||
|
m = reduce(m, { type: "ATTACH_NONE", epoch });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Review #2: every terminal transition resets ownership to local.
|
||||||
|
describe("run-fsm — review-2: terminal transitions reset ownership to local", () => {
|
||||||
|
const observer = (): Machine => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
it("FINISH_CLEAN resets ownership", () => {
|
||||||
|
const m = reduce(observer(), { type: "FINISH_CLEAN", epoch: observer().ctx.epoch });
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
it("FINISH_ERROR / POLL_TERMINAL / RUN_FACT(null) reset ownership", () => {
|
||||||
|
let o = observer();
|
||||||
|
expect(reduce(o, { type: "FINISH_ERROR", kind: "stream", epoch: o.ctx.epoch }).ctx.ownership).toBe("local");
|
||||||
|
// POLL_TERMINAL from an observer polling phase
|
||||||
|
let p = reduce(observer(), { type: "STREAM_INCOMPLETE", reason: "starved", epoch: observer().ctx.epoch });
|
||||||
|
expect(reduce(p, { type: "POLL_TERMINAL" }).ctx.ownership).toBe("local");
|
||||||
|
// RUN_FACT(null) from an observer attaching phase
|
||||||
|
let a = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(reduce(a, { type: "RUN_FACT", runFact: null, epoch: a.ctx.epoch }).ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — ownership (I2) is context, orthogonal to phase", () => {
|
||||||
|
it("attach/reconnect set observer; send/supersede-ready set local", () => {
|
||||||
|
let m = reduce(initialMachine(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
expect(m.ctx.ownership).toBe("observer");
|
||||||
|
m = reduce(m, { type: "ATTACH_LIVE", epoch: m.ctx.epoch });
|
||||||
|
expect(m.phase.name).toBe("streaming");
|
||||||
|
expect(m.ctx.ownership).toBe("observer"); // still observing a detached run
|
||||||
|
// A local send flips ownership back to local.
|
||||||
|
m = reduce(m, { type: "SEND_LOCAL" });
|
||||||
|
expect(m.ctx.ownership).toBe("local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("run-fsm — dispose (I5)", () => {
|
||||||
|
it("DISPOSE from any phase aborts controllers and bumps epoch", () => {
|
||||||
|
let m = reduce(withRunFact(), { type: "ATTACH_START", runId: "r" });
|
||||||
|
const before = m.ctx.epoch;
|
||||||
|
m = reduce(m, { type: "DISPOSE" });
|
||||||
|
expect(m.phase.name).toBe("idle");
|
||||||
|
expect(m.ctx.epoch).toBe(before + 1);
|
||||||
|
expect(effectTypes(m)).toEqual(
|
||||||
|
expect.arrayContaining(["abortAttach", "cancelReconnect", "disarmPoll"]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,600 @@
|
|||||||
|
/**
|
||||||
|
* Run-lifecycle finite state machine for a single AI-chat thread (#488).
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* The resume/reconnect/poll/stop/supersede lifecycle used to be spread across
|
||||||
|
* ~26 `useRef` one-shot flags in `chat-thread.tsx`, each disarmed "on every
|
||||||
|
* path". Ownerless flag combinations produced silent UI freezes, and every fix
|
||||||
|
* added another ref (the #381 -> #432 -> #456 spiral). This module replaces that
|
||||||
|
* ref-zoo with ONE pure reducer whose transitions are enumerable and unit-
|
||||||
|
* testable in isolation (event x state -> next state is the observable property).
|
||||||
|
*
|
||||||
|
* The reducer is PURE: it owns no timers, no fetches, no React state. It maps
|
||||||
|
* `(machine, event) -> machine`, where the returned machine carries the list of
|
||||||
|
* COMMAND EFFECTS to run for that transition. A thin runtime in `chat-thread.tsx`
|
||||||
|
* dispatches events (from SDK callbacks / HTTP outcomes) and executes the
|
||||||
|
* effects (attach GET, POST /stream, POST /run, POST /stop, backoff timers,
|
||||||
|
* poll arm/disarm). The runtime lives in a THREAD, not the window, so a late SDK
|
||||||
|
* callback dies with the owner (kills the "event from a dead view" class, #161).
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* INVARIANTS (see run-fsm.spec.md for the full spec + tables)
|
||||||
|
* ----------------------------------------------------------------------------
|
||||||
|
* I1 EPOCH (generation counter). Commands (`resumeStream`, `postRun`, `stop`,
|
||||||
|
* `supersede`, `scheduleReconnect`) are async; their outcomes arrive on the
|
||||||
|
* SAME SDK/HTTP callbacks. Every command-emitting transition increments
|
||||||
|
* `ctx.epoch`; every OUTCOME event carries the epoch it was issued under;
|
||||||
|
* the reducer DROPS an outcome whose epoch != the current epoch. This is
|
||||||
|
* what the one-shot-ref zoo used to approximate by hand.
|
||||||
|
* I2 OWNERSHIP is a CONTEXT FIELD (`'local' | 'observer'`), not a state —
|
||||||
|
* orthogonal to the transport phase. The queue is flushed ONLY by a local
|
||||||
|
* owner (an observer following a detached run never flushes).
|
||||||
|
* I3 RUN-FACT ("a run is active") is first-class from the server: `runFact`
|
||||||
|
* holds the server-confirmed active run id (POST /run on mount, the `start`
|
||||||
|
* metadata runId, attach outcomes). Reconnect is entered by the RUN-FACT,
|
||||||
|
* not by the presence of an assistant message (#488 commit 2). A fresh
|
||||||
|
* negative fact (null) cancels reconnect immediately.
|
||||||
|
* I4 Exit `stopping` by DATA (a terminal row / negative run-fact), NEVER by the
|
||||||
|
* stopRun HTTP response (which returns after abort, before finalization).
|
||||||
|
* I5 Command controllers are effect-owned (abort in cleanup), NOT render-phase
|
||||||
|
* refs — expressed here as the `abortAttach` effect on disposing transitions.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phases (the transport lifecycle). Ownership / runFact are CONTEXT, not here.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Why the degraded poll is the active recovery. */
|
||||||
|
export type PollReason =
|
||||||
|
| "attach-none" // mount attach returned 204 / error — nothing live to attach
|
||||||
|
| "starved" // a resumed finish carried no visible content
|
||||||
|
| "disconnect-visible" // a live disconnect WITH on-screen content — poll to terminal
|
||||||
|
| "reconnect-exhausted"; // the live re-attach ladder gave up
|
||||||
|
|
||||||
|
/** The classified error kind (drives the banner text + composer behavior). */
|
||||||
|
export type ErrorKind =
|
||||||
|
| "stream" // a generic provider/network stream error (useChat error)
|
||||||
|
| "run-already-active" // 409 A_RUN_ALREADY_ACTIVE (a plain POST hit the gate)
|
||||||
|
| "supersede-mismatch" // 409 SUPERSEDE_TARGET_MISMATCH (CAS target moved)
|
||||||
|
| "supersede-timeout" // 409 SUPERSEDE_TIMEOUT (old run did not settle in W)
|
||||||
|
| "supersede-invalid" // 409 SUPERSEDE_INVALID (bad supersede target)
|
||||||
|
| "begin-failed"; // 503 A_RUN_BEGIN_FAILED (could not start the run)
|
||||||
|
|
||||||
|
export type Phase =
|
||||||
|
| { name: "idle" }
|
||||||
|
| { name: "sending" } // local POST in flight, before the first frame
|
||||||
|
| { name: "streaming" } // receiving frames
|
||||||
|
| { name: "attaching" } // mount-time attach GET in flight
|
||||||
|
| { name: "reconnecting"; attempt: number; failed: boolean }
|
||||||
|
| { name: "polling"; reason: PollReason }
|
||||||
|
| { name: "stalled" } // poll hit the inactivity cap — banner + Retry
|
||||||
|
| { name: "stopping" }
|
||||||
|
| { name: "superseding" }
|
||||||
|
| { name: "error"; kind: ErrorKind };
|
||||||
|
|
||||||
|
export type Ownership = "local" | "observer";
|
||||||
|
|
||||||
|
/** The server-confirmed active run, or null when no run is active. */
|
||||||
|
export type RunFact = { runId: string } | null;
|
||||||
|
|
||||||
|
export interface Ctx {
|
||||||
|
/** I1: generation counter — every command-transition increments it. */
|
||||||
|
epoch: number;
|
||||||
|
/** I2: does THIS client own the turn's writes (local streamer) or observe? */
|
||||||
|
ownership: Ownership;
|
||||||
|
/** I3: the server-confirmed active run. */
|
||||||
|
runFact: RunFact;
|
||||||
|
/**
|
||||||
|
* Are we FOLLOWING a live run we were locally streaming (the reconnect ladder),
|
||||||
|
* as opposed to a one-shot mount-attach resume? Both are `ownership: 'observer'`,
|
||||||
|
* but they recover DIFFERENTLY on a drop: a live-follow drop RE-ENTERS the
|
||||||
|
* reconnect ladder (#488 commit 3 — the second break after a successful re-attach
|
||||||
|
* must reconnect again, not fall to silent poll), while a mount-resume drop falls
|
||||||
|
* to the degraded poll. This is the ctx bit that separates the two WITHOUT a new
|
||||||
|
* component ref (it is why commit 3 needs the FSM, not a surgical patch).
|
||||||
|
*/
|
||||||
|
liveFollow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Machine {
|
||||||
|
phase: Phase;
|
||||||
|
ctx: Ctx;
|
||||||
|
/** Command effects to run for the transition that produced THIS machine.
|
||||||
|
* The runtime executes them and does not read them again. */
|
||||||
|
effects: Effect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Command effects (the reducer's only side-channel — executed by the runtime).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Effect =
|
||||||
|
/** POST /run to (re)establish or verify the run-fact. `reason` is diagnostic. */
|
||||||
|
| { type: "postRun"; reason: "mount" | "verify" }
|
||||||
|
/** Trigger the SDK `resumeStream()` (attach GET via prepareReconnectToStream). */
|
||||||
|
| { type: "resumeStream" }
|
||||||
|
/** Schedule a reconnect attempt after a backoff, then dispatch RECONNECT_ATTEMPT. */
|
||||||
|
| { type: "scheduleReconnect"; attempt: number; delayMs: number }
|
||||||
|
/** Cancel any pending reconnect backoff timer. */
|
||||||
|
| { type: "cancelReconnect" }
|
||||||
|
/** Arm the degraded poll (the window's dumb timer follows the run in the DB). */
|
||||||
|
| { type: "armPoll"; reason: PollReason }
|
||||||
|
/** Disarm the degraded poll. */
|
||||||
|
| { type: "disarmPoll" }
|
||||||
|
/** POST /stop the chat's active run (authoritative detached-run stop). */
|
||||||
|
| { type: "stopRun" }
|
||||||
|
/** POST /stream { supersede: { runId } } — the CAS "interrupt and send now". */
|
||||||
|
| { type: "supersede"; targetRunId: string }
|
||||||
|
/** Abort the in-flight attach/reconnect GET controller (dispose / observer stop). */
|
||||||
|
| { type: "abortAttach" };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Events. An OUTCOME event MAY carry `epoch`; if it does and it does not equal
|
||||||
|
// the current epoch, the reducer drops it (I1). Trigger events (user actions,
|
||||||
|
// fresh disconnects) carry no epoch and are never dropped.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Event =
|
||||||
|
// -- local turn --
|
||||||
|
| { type: "SEND_LOCAL" }
|
||||||
|
| { type: "STREAM_START"; runId?: string; epoch?: number }
|
||||||
|
/** An OBSERVER's attached stream ended WITHOUT reaching terminal (a starved
|
||||||
|
* clean replay, or a torn resume) — fall to the degraded poll to drive the row
|
||||||
|
* to its real terminal state. (A live-follow drop uses FINISH_DISCONNECT.) */
|
||||||
|
| { type: "STREAM_INCOMPLETE"; reason: PollReason; epoch?: number }
|
||||||
|
| { type: "FINISH_CLEAN"; epoch?: number }
|
||||||
|
| { type: "FINISH_ABORT"; epoch?: number }
|
||||||
|
| { type: "FINISH_DISCONNECT"; hasVisibleContent: boolean; epoch?: number }
|
||||||
|
| { type: "FINISH_ERROR"; kind: ErrorKind; epoch?: number }
|
||||||
|
// -- mount attach (resume) --
|
||||||
|
| { type: "ATTACH_START"; runId?: string }
|
||||||
|
| { type: "ATTACH_LIVE"; epoch?: number }
|
||||||
|
| { type: "ATTACH_NONE"; epoch?: number }
|
||||||
|
// -- reconnect after a live disconnect (entered by FINISH_DISCONNECT, #488 c2) --
|
||||||
|
| { type: "RECONNECT_ATTEMPT"; attempt: number; epoch?: number }
|
||||||
|
| { type: "RECONNECT_ATTACHED"; epoch?: number }
|
||||||
|
| { type: "RECONNECT_NONE"; epoch?: number }
|
||||||
|
| { type: "RETRY" }
|
||||||
|
// -- degraded poll --
|
||||||
|
| { type: "POLL_TERMINAL" }
|
||||||
|
| { type: "POLL_IDLE_CAP" }
|
||||||
|
// -- run-fact (server-confirmed active run) --
|
||||||
|
| { type: "RUN_FACT"; runFact: RunFact; epoch?: number }
|
||||||
|
// -- stop --
|
||||||
|
| { type: "STOP_REQUESTED" }
|
||||||
|
// -- supersede (CAS) --
|
||||||
|
| { type: "SUPERSEDE_REQUESTED"; targetRunId: string }
|
||||||
|
| { type: "SUPERSEDE_READY"; runId?: string; epoch?: number }
|
||||||
|
| { type: "SUPERSEDE_MISMATCH"; currentRunId?: string; epoch?: number }
|
||||||
|
| { type: "SUPERSEDE_TIMEOUT"; epoch?: number }
|
||||||
|
| { type: "SUPERSEDE_INVALID"; epoch?: number }
|
||||||
|
| { type: "RUN_ALREADY_ACTIVE"; activeRunId?: string }
|
||||||
|
// -- lifecycle --
|
||||||
|
| { type: "DISPOSE" };
|
||||||
|
|
||||||
|
export const RECONNECT_MAX_ATTEMPTS = 5;
|
||||||
|
export const RECONNECT_BASE_DELAY_MS = 1000;
|
||||||
|
/** Backoff before attempt N (1-based): 1s, 2s, 4s, 8s, 16s. */
|
||||||
|
export function reconnectDelayMs(attempt: number): number {
|
||||||
|
return RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructors / helpers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function initialMachine(overrides?: Partial<Ctx>): Machine {
|
||||||
|
return {
|
||||||
|
phase: { name: "idle" },
|
||||||
|
ctx: { epoch: 0, ownership: "local", runFact: null, liveFollow: false, ...overrides },
|
||||||
|
effects: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a machine result: a phase, optional ctx patch, and effects. Empty
|
||||||
|
* effects by default. Never mutates the input. */
|
||||||
|
function to(
|
||||||
|
m: Machine,
|
||||||
|
phase: Phase,
|
||||||
|
opts?: { ctx?: Partial<Ctx>; effects?: Effect[] },
|
||||||
|
): Machine {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
ctx: { ...m.ctx, ...(opts?.ctx ?? {}) },
|
||||||
|
effects: opts?.effects ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No transition: keep the phase, clear effects (so a re-run does not re-fire). */
|
||||||
|
function stay(m: Machine): Machine {
|
||||||
|
return { phase: m.phase, ctx: m.ctx, effects: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A command-transition: same as `to` but bumps the epoch (I1). Any outcome
|
||||||
|
* event issued under the old epoch is dropped once this lands. */
|
||||||
|
function command(
|
||||||
|
m: Machine,
|
||||||
|
phase: Phase,
|
||||||
|
effects: Effect[],
|
||||||
|
ctx?: Partial<Ctx>,
|
||||||
|
): Machine {
|
||||||
|
return {
|
||||||
|
phase,
|
||||||
|
ctx: { ...m.ctx, ...(ctx ?? {}), epoch: m.ctx.epoch + 1 },
|
||||||
|
effects,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// The pure reducer.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** The terminal stream-finish events (one turn's stream ended). */
|
||||||
|
function isFinishEvent(event: Event): boolean {
|
||||||
|
return (
|
||||||
|
event.type === "FINISH_ABORT" ||
|
||||||
|
event.type === "FINISH_CLEAN" ||
|
||||||
|
event.type === "FINISH_DISCONNECT" ||
|
||||||
|
event.type === "FINISH_ERROR" ||
|
||||||
|
event.type === "STREAM_INCOMPLETE"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduce(m: Machine, event: Event): Machine {
|
||||||
|
// MEDIUM (#488 re-review): honor ANY stream finish in `stopping` regardless of
|
||||||
|
// generation. A plain user Stop has NO successor stream — the aborted stream's
|
||||||
|
// finish IS the expected end of the stop, so exit `stopping -> idle` by that DATA
|
||||||
|
// (I4). The epoch filter below must NOT drop it: STOP_REQUESTED bumped the epoch,
|
||||||
|
// but the finish carries the PRE-stop generation (the runtime stamps it with the
|
||||||
|
// stream's start epoch), so I1 would otherwise strand the machine in `stopping`
|
||||||
|
// forever (no idle-cap covers `stopping`). The epoch filter stays in force for
|
||||||
|
// `superseding` (a successor B owns) — that is the F1 supersede drop.
|
||||||
|
if (m.phase.name === "stopping" && isFinishEvent(event)) {
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
// Reset ownership to local on this terminal transition (review #2): otherwise
|
||||||
|
// an observer-stop leaves ownership 'observer' and hides "Send now" forever.
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// I1: drop a stale outcome (an event issued under a superseded epoch).
|
||||||
|
if ("epoch" in event && event.epoch !== undefined && event.epoch !== m.ctx.epoch) {
|
||||||
|
return stay(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
// ---- local turn ----------------------------------------------------
|
||||||
|
case "SEND_LOCAL":
|
||||||
|
// A local send owns the view: leave any recovery, become the local
|
||||||
|
// streamer, disarm poll/reconnect. epoch++ so a late recovery outcome
|
||||||
|
// from the previous phase is dropped.
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "sending" },
|
||||||
|
[{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
{ ownership: "local", liveFollow: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
case "STREAM_INCOMPLETE":
|
||||||
|
// An OBSERVER's attached stream ended incomplete (starved / torn) — follow
|
||||||
|
// the run to terminal via the degraded poll.
|
||||||
|
return to(m, { name: "polling", reason: event.reason }, {
|
||||||
|
effects: [{ type: "armPoll", reason: event.reason }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "STREAM_START": {
|
||||||
|
// First frame arrived. Adopt the run-fact runId if present. sending ->
|
||||||
|
// streaming; a reconnect/attach that just went live also lands here.
|
||||||
|
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
||||||
|
return to(m, { name: "streaming" }, {
|
||||||
|
ctx: { runFact },
|
||||||
|
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "FINISH_CLEAN":
|
||||||
|
// A clean terminal outcome. The run is done — clear the run-fact and go
|
||||||
|
// idle. (The queue flush is a component concern gated by ownership; the
|
||||||
|
// FSM only models the phase.) Review #2: reset ownership to local so a
|
||||||
|
// just-finished observer-attach turn re-exposes "Send now" for the queue.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "FINISH_ABORT":
|
||||||
|
// A user Stop / intentional abort finished. If we were stopping, the
|
||||||
|
// terminal data has now arrived (I4) — go idle. The run-fact is cleared.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "FINISH_DISCONNECT":
|
||||||
|
// A LIVE SSE drop. Recovery depends on WHO we are (I2 + liveFollow):
|
||||||
|
// - a mount-attach OBSERVER (a one-shot resume, NOT live-follow) that drops
|
||||||
|
// -> the degraded poll drives the row to terminal from the DB.
|
||||||
|
if (m.ctx.ownership === "observer" && !m.ctx.liveFollow) {
|
||||||
|
return to(m, { name: "polling", reason: "disconnect-visible" }, {
|
||||||
|
effects: [{ type: "armPoll", reason: "disconnect-visible" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// - a LOCAL live turn (first drop) OR a live-follow re-attach (a SUBSEQUENT
|
||||||
|
// drop) -> (re-)enter the reconnect ladder. #488 commit 3: allowed
|
||||||
|
// REPEATEDLY — `liveFollow` is kept across a successful re-attach, so the
|
||||||
|
// second break reconnects again instead of falling to silent poll.
|
||||||
|
// #488 commit 2: gated on the RUN-FACT (or an existing live-follow), NOT on
|
||||||
|
// the presence of an assistant message — a setup-phase break still recovers.
|
||||||
|
// - visible content already on screen -> keep it, ALSO poll to terminal
|
||||||
|
// (a full replay could clobber the fuller live tail);
|
||||||
|
// - no visible content -> the reconnect ladder rebuilds it.
|
||||||
|
if (m.ctx.runFact || m.ctx.liveFollow) {
|
||||||
|
const effects: Effect[] = [
|
||||||
|
{ type: "scheduleReconnect", attempt: 1, delayMs: reconnectDelayMs(1) },
|
||||||
|
];
|
||||||
|
if (event.hasVisibleContent) effects.push({ type: "armPoll", reason: "disconnect-visible" });
|
||||||
|
return command(m, { name: "reconnecting", attempt: 1, failed: false }, effects, {
|
||||||
|
ownership: "observer",
|
||||||
|
liveFollow: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// No run to recover: a plain disconnect. Surface the terminal notice.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
});
|
||||||
|
|
||||||
|
case "FINISH_ERROR":
|
||||||
|
return to(m, { name: "error", kind: event.kind }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- mount attach (resume) ----------------------------------------
|
||||||
|
case "ATTACH_START":
|
||||||
|
// A reopened tab attaches to a still-running run: observer ownership.
|
||||||
|
// #488 F2: ONLY from idle. The mount `getRun` round-trip resolves async, and
|
||||||
|
// a local send may have started meanwhile (phase `sending`, ownership local);
|
||||||
|
// a late ATTACH_START must NOT hijack that local turn into an observer-attach
|
||||||
|
// (queue would stop flushing, "Send now" would hide). Guarding in the reducer
|
||||||
|
// covers every dispatch source.
|
||||||
|
if (m.phase.name !== "idle") return stay(m);
|
||||||
|
return command(m, { name: "attaching" }, [{ type: "resumeStream" }], {
|
||||||
|
ownership: "observer",
|
||||||
|
runFact: event.runId ? { runId: event.runId } : m.ctx.runFact,
|
||||||
|
});
|
||||||
|
|
||||||
|
case "ATTACH_LIVE":
|
||||||
|
// The attach GET returned a live 2xx stream — follow it as an observer.
|
||||||
|
// Review #1: guard by SOURCE phase. The epoch filter alone is not enough — a
|
||||||
|
// POLL_TERMINAL uses to() (no epoch bump) and does not abort the in-flight
|
||||||
|
// GET, so a slow 2xx landing after the machine already left `attaching` (e.g.
|
||||||
|
// the armed poll saw the terminal row -> idle) would resurrect a settled run
|
||||||
|
// into a phantom `streaming`. Only enter streaming FROM `attaching`.
|
||||||
|
if (m.phase.name !== "attaching") return stay(m);
|
||||||
|
return to(m, { name: "streaming" });
|
||||||
|
|
||||||
|
case "ATTACH_NONE":
|
||||||
|
// 204 / non-2xx / throw: nothing live to attach. Arm the degraded poll to
|
||||||
|
// follow the run to terminal from the DB. This is a soft-negative run-fact
|
||||||
|
// (204 on a non-stripped path is authoritative-negative; the runtime may
|
||||||
|
// pass a RUN_FACT null separately). Keep the run-fact as-is here.
|
||||||
|
// Review #1: guard by source phase for consistency (a late outcome after the
|
||||||
|
// machine already left `attaching` must not re-arm a poll).
|
||||||
|
if (m.phase.name !== "attaching") return stay(m);
|
||||||
|
return to(m, { name: "polling", reason: "attach-none" }, {
|
||||||
|
effects: [{ type: "armPoll", reason: "attach-none" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- reconnect after a live disconnect ----------------------------
|
||||||
|
case "RECONNECT_ATTEMPT":
|
||||||
|
// A scheduled backoff fired — fire the attach GET. epoch++ so the previous
|
||||||
|
// attempt's late outcome cannot drive this one.
|
||||||
|
if (m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "reconnecting", attempt: event.attempt, failed: false },
|
||||||
|
[{ type: "resumeStream" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
case "RECONNECT_ATTACHED":
|
||||||
|
// #488 commit 3: a live re-attach succeeded. Reset to streaming — the
|
||||||
|
// attempt counter is dropped, so a LATER disconnect can start a fresh
|
||||||
|
// ladder from attempt 1 (the old one-shot `!wasResumed` gate forbade a
|
||||||
|
// second cycle, sending the second break to silent poll).
|
||||||
|
// Review #1: guard by SOURCE phase. The armed degraded poll can reach the
|
||||||
|
// terminal row (POLL_TERMINAL -> idle, via to(), NO epoch bump, GET not
|
||||||
|
// aborted) BEFORE a slow reconnect GET returns 2xx; without this guard that
|
||||||
|
// late RECONNECT_ATTACHED (same epoch) would resurrect a settled run into a
|
||||||
|
// phantom `streaming`. Only re-enter streaming FROM `reconnecting`.
|
||||||
|
if (m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
return to(m, { name: "streaming" }, {
|
||||||
|
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "RECONNECT_NONE": {
|
||||||
|
// 204 / error during a reconnect attempt. Arm the degraded poll as the
|
||||||
|
// belt-and-suspenders fallback, then either back off to the next attempt
|
||||||
|
// or, at the cap, surface the manual Retry ("failed").
|
||||||
|
if (m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
const attempt = m.phase.attempt;
|
||||||
|
if (attempt < RECONNECT_MAX_ATTEMPTS) {
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "reconnecting", attempt: attempt + 1, failed: false },
|
||||||
|
[
|
||||||
|
{ type: "armPoll", reason: "attach-none" },
|
||||||
|
{ type: "scheduleReconnect", attempt: attempt + 1, delayMs: reconnectDelayMs(attempt + 1) },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return to(m, { name: "reconnecting", attempt, failed: true }, {
|
||||||
|
effects: [{ type: "armPoll", reason: "reconnect-exhausted" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "RETRY":
|
||||||
|
// Manual Retry from the "failed" reconnect banner OR the stalled banner.
|
||||||
|
if (m.phase.name === "reconnecting" && m.phase.failed) {
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "reconnecting", attempt: 1, failed: false },
|
||||||
|
[{ type: "resumeStream" }],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (m.phase.name === "stalled") {
|
||||||
|
// Re-arm the poll to try to catch the run up again.
|
||||||
|
return command(m, { name: "polling", reason: "attach-none" }, [
|
||||||
|
{ type: "armPoll", reason: "attach-none" },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return stay(m);
|
||||||
|
|
||||||
|
// ---- degraded poll -------------------------------------------------
|
||||||
|
case "POLL_TERMINAL":
|
||||||
|
// The run reached a terminal row via the poll (or the reconcile merge). Go
|
||||||
|
// idle and disarm everything (I4: this is a DATA-driven exit, incl. exit
|
||||||
|
// from `stopping`). Review #2: reset ownership to local.
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "POLL_IDLE_CAP":
|
||||||
|
// Review #4: `stopping` also arms the poll (STOP_REQUESTED) but has NO other
|
||||||
|
// backstop — an observer-stop with no SDK stream to fire onFinish, whose
|
||||||
|
// server stop never drives the run terminal, would poll the DB forever. Give
|
||||||
|
// it a bounded exit: cap -> idle + disarm (NOT `stalled`; Stop was already
|
||||||
|
// pressed, so there is nothing for the user to retry).
|
||||||
|
if (m.phase.name === "stopping") {
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// #488 commit 4a: the poll hit the inactivity cap. Instead of going SILENT
|
||||||
|
// (the old "forever half-done answer"), surface a stalled banner + Retry.
|
||||||
|
if (m.phase.name !== "polling" && m.phase.name !== "reconnecting") return stay(m);
|
||||||
|
return to(m, { name: "stalled" }, {
|
||||||
|
effects: [{ type: "disarmPoll" }, { type: "cancelReconnect" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- run-fact ------------------------------------------------------
|
||||||
|
case "RUN_FACT": {
|
||||||
|
const runFact = event.runFact;
|
||||||
|
// A fresh NEGATIVE fact (no active run) cancels recovery immediately (I3):
|
||||||
|
// there is nothing to reconnect to / poll for.
|
||||||
|
if (!runFact) {
|
||||||
|
if (
|
||||||
|
m.phase.name === "reconnecting" ||
|
||||||
|
m.phase.name === "attaching" ||
|
||||||
|
m.phase.name === "polling" ||
|
||||||
|
m.phase.name === "stopping"
|
||||||
|
) {
|
||||||
|
return to(m, { name: "idle" }, {
|
||||||
|
// Review #2: reset ownership to local on this terminal transition.
|
||||||
|
ctx: { runFact: null, liveFollow: false, ownership: "local" },
|
||||||
|
effects: [{ type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return to(m, m.phase, { ctx: { runFact: null } });
|
||||||
|
}
|
||||||
|
// A positive fact just updates the context (pessimism toward an attempt: a
|
||||||
|
// stale-but-positive fact permits entering recovery; a 204 will cut it).
|
||||||
|
return to(m, m.phase, { ctx: { runFact } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- stop ----------------------------------------------------------
|
||||||
|
case "STOP_REQUESTED":
|
||||||
|
// Authoritative stop of a detached run. Enter `stopping` and fire stopRun +
|
||||||
|
// abort the local/attach reader. ALSO arm the poll so the terminal row is
|
||||||
|
// observed — the exit is by DATA (I4: a terminal row / negative run-fact),
|
||||||
|
// never by the stopRun HTTP response (which returns after abort, before
|
||||||
|
// finalization). For a local turn the aborted stream's onFinish (ANY finish)
|
||||||
|
// is HONORED in `stopping` at the top of reduce() — regardless of generation
|
||||||
|
// — and exits to idle; the armed poll is the fallback for an observer stop
|
||||||
|
// with no local onFinish.
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "stopping" },
|
||||||
|
[
|
||||||
|
{ type: "stopRun" },
|
||||||
|
{ type: "abortAttach" },
|
||||||
|
{ type: "cancelReconnect" },
|
||||||
|
{ type: "armPoll", reason: "attach-none" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- supersede (CAS) ----------------------------------------------
|
||||||
|
case "SUPERSEDE_REQUESTED":
|
||||||
|
// "Interrupt and send now": CAS POST /stream { supersede }. epoch++ so a
|
||||||
|
// late outcome of the interrupted run is dropped.
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "superseding" },
|
||||||
|
[{ type: "supersede", targetRunId: event.targetRunId }, { type: "cancelReconnect" }, { type: "disarmPoll" }],
|
||||||
|
);
|
||||||
|
|
||||||
|
case "SUPERSEDE_READY": {
|
||||||
|
// CAS succeeded (old run stopped/settled, slot taken, new run begun). We
|
||||||
|
// are now the local streamer of the NEW run. Adopt its runId if provided.
|
||||||
|
const runFact = event.runId ? { runId: event.runId } : m.ctx.runFact;
|
||||||
|
return to(m, { name: "streaming" }, {
|
||||||
|
ctx: { ownership: "local", runFact, liveFollow: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
case "SUPERSEDE_MISMATCH":
|
||||||
|
// The active run moved between the click and the CAS. Per the spec: verify
|
||||||
|
// via /run rather than blindly banner — the mismatch may be our own already-
|
||||||
|
// superseded run. Surface a classified error AND fire a run-fact verify.
|
||||||
|
return to(m, { name: "error", kind: "supersede-mismatch" }, {
|
||||||
|
ctx: { runFact: event.currentRunId ? { runId: event.currentRunId } : m.ctx.runFact },
|
||||||
|
effects: [{ type: "postRun", reason: "verify" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
case "SUPERSEDE_TIMEOUT":
|
||||||
|
// The old run did not settle within W. Nothing persisted; the composer keeps
|
||||||
|
// its text. Classified error, NO auto-retry (the old client retry ladder is
|
||||||
|
// removed in #488 commit 5).
|
||||||
|
return to(m, { name: "error", kind: "supersede-timeout" });
|
||||||
|
|
||||||
|
case "SUPERSEDE_INVALID":
|
||||||
|
return to(m, { name: "error", kind: "supersede-invalid" });
|
||||||
|
|
||||||
|
case "RUN_ALREADY_ACTIVE":
|
||||||
|
// A plain POST hit the one-active-run gate. NO auto-retry — the composer
|
||||||
|
// offers "interrupt and send" (supersede) instead. #497/S4: adopt the
|
||||||
|
// server's activeRunId as the run-fact so that supersede can TARGET the
|
||||||
|
// (possibly foreign-tab) active run via the CAS, rather than a blind
|
||||||
|
// promote+abort that just 409s again. A stale/absent id keeps the prior fact.
|
||||||
|
return to(m, { name: "error", kind: "run-already-active" }, {
|
||||||
|
ctx: { runFact: event.activeRunId ? { runId: event.activeRunId } : m.ctx.runFact },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- lifecycle -----------------------------------------------------
|
||||||
|
case "DISPOSE":
|
||||||
|
// Unmount: abort in-flight controllers, drop timers, and bump the epoch so
|
||||||
|
// NO late callback can drive this (now dead) machine (I5).
|
||||||
|
return command(
|
||||||
|
m,
|
||||||
|
{ name: "idle" },
|
||||||
|
[
|
||||||
|
{ type: "abortAttach" },
|
||||||
|
{ type: "cancelReconnect" },
|
||||||
|
{ type: "disarmPoll" },
|
||||||
|
],
|
||||||
|
{ liveFollow: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness guard.
|
||||||
|
const _never: never = event;
|
||||||
|
void _never;
|
||||||
|
return stay(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
resolveAdoptedChatId,
|
resolveAdoptedChatId,
|
||||||
newlyAddedChatIds,
|
newlyAddedChatIds,
|
||||||
extractServerChatId,
|
extractServerChatId,
|
||||||
|
extractRunId,
|
||||||
} from "./adopt-chat-id";
|
} from "./adopt-chat-id";
|
||||||
|
|
||||||
describe("resolveAdoptedChatId", () => {
|
describe("resolveAdoptedChatId", () => {
|
||||||
@@ -70,3 +71,17 @@ describe("extractServerChatId", () => {
|
|||||||
expect(extractServerChatId(undefined)).toBeUndefined();
|
expect(extractServerChatId(undefined)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("extractRunId", () => {
|
||||||
|
it("reads a string runId from the start metadata", () => {
|
||||||
|
expect(extractRunId({ metadata: { runId: "run-1" } })).toBe("run-1");
|
||||||
|
});
|
||||||
|
it("returns undefined when runId is absent", () => {
|
||||||
|
expect(extractRunId({ metadata: { chatId: "c" } })).toBeUndefined();
|
||||||
|
expect(extractRunId({})).toBeUndefined();
|
||||||
|
expect(extractRunId(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
it("returns undefined for a non-string runId", () => {
|
||||||
|
expect(extractRunId({ metadata: { runId: 7 } })).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ export function extractServerChatId(
|
|||||||
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
return typeof m?.chatId === "string" ? m.chatId : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #488: read the authoritative RUN id off a streaming assistant message. The
|
||||||
|
* server attaches it as `message.metadata.runId` on the `start` part when a run
|
||||||
|
* wraps the turn (see server `chatStreamMetadata`, #184/#487). This is the live
|
||||||
|
* run-fact update the client FSM adopts (mirrors `extractServerChatId`). Returns
|
||||||
|
* it only when it is a string; undefined otherwise.
|
||||||
|
*/
|
||||||
|
export function extractRunId(
|
||||||
|
message: { metadata?: unknown } | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const m = message?.metadata as { runId?: string } | undefined;
|
||||||
|
return typeof m?.runId === "string" ? m.runId : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
* The deduped set of ids present in `afterIds` but not in `beforeIds`. A
|
||||||
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
* paginated/flatMapped list can repeat the same id, so dedupe: one genuinely-new
|
||||||
|
|||||||
@@ -33,29 +33,44 @@ describe("collapseBlankLines", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
describe("collapseBlankLines + renderChatMarkdown (canonical converter)", () => {
|
||||||
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
// Chat markdown now renders through @docmost/prosemirror-markdown (issue #347):
|
||||||
|
// the SAME converter the editor/import use. Its list items are schema-shaped —
|
||||||
|
// each <li>'s content is wrapped in a <p> (listItem content is `paragraph+`) —
|
||||||
|
// so the HTML always carries `<li><p>…</p></li>` regardless of blank-line
|
||||||
|
// looseness in the source (the converter has no tight/loose distinction). The
|
||||||
|
// visual tightness that `collapseBlankLines` used to buy is now provided by
|
||||||
|
// CSS (`.markdown li p { margin: 0 }`), not the HTML shape.
|
||||||
|
it("renders a blank-line-separated bullet list as a real <ul> list", () => {
|
||||||
const loose =
|
const loose =
|
||||||
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
// Clean, un-namespaced HTML (DOMSerializer, not XMLSerializer) — no xmlns.
|
||||||
expect(html).toContain("<li>item one</li>");
|
|
||||||
expect(html).not.toContain("<li><p>");
|
|
||||||
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
|
||||||
expect(html).toContain("<ul>");
|
expect(html).toContain("<ul>");
|
||||||
|
expect(html).not.toMatch(/<ul[^>]*xmlns/);
|
||||||
|
// The item text is present (inside the schema's <li><p> wrapper).
|
||||||
|
expect(html).toContain("item one");
|
||||||
|
// The intro paragraph renders as its own paragraph before the list.
|
||||||
expect(html).toContain("<p>Intro paragraph.</p>");
|
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
it("renders an ordered list (1. 2.) as a real <ol> list", () => {
|
||||||
const loose = "Intro.\n\n1. first\n\n2. second";
|
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
expect(html).toContain("<ol>");
|
expect(html).toContain("<ol>");
|
||||||
expect(html).toContain("<li>first</li>");
|
expect(html).not.toMatch(/<ol[^>]*xmlns/);
|
||||||
expect(html).not.toContain("<li><p>");
|
expect(html).toContain("first");
|
||||||
|
expect(html).toContain("second");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
it("wraps list-item content in <p> (schema shape; tightness is CSS)", () => {
|
||||||
|
// The canonical converter always wraps a list item's content in a paragraph,
|
||||||
|
// whether or not the source had blank lines between items.
|
||||||
const loose = "- a\n\n- b";
|
const loose = "- a\n\n- b";
|
||||||
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||||
|
// And a "tight" source produces the identical wrapping (no distinction).
|
||||||
|
expect(renderChatMarkdown(collapseBlankLines(loose), {})).toContain(
|
||||||
|
"<li><p>",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ describe("estimateTokens", () => {
|
|||||||
expect(estimateTokens("")).toBe(0);
|
expect(estimateTokens("")).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ceils chars/4 so any non-empty text is at least 1 token", () => {
|
// #490: migrated onto the shared @docmost/token-estimate module (chars/2.5, up
|
||||||
|
// from the old client-only chars/4) so the client counter and the server replay
|
||||||
|
// budgeter can never diverge.
|
||||||
|
it("ceils chars/2.5 so any non-empty text is at least 1 token", () => {
|
||||||
expect(estimateTokens("a")).toBe(1);
|
expect(estimateTokens("a")).toBe(1);
|
||||||
expect(estimateTokens("abcd")).toBe(1);
|
expect(estimateTokens("ab")).toBe(1);
|
||||||
expect(estimateTokens("abcde")).toBe(2);
|
expect(estimateTokens("abcde")).toBe(2); // 5 / 2.5 = 2
|
||||||
expect(estimateTokens("12345678")).toBe(2);
|
expect(estimateTokens("x".repeat(10))).toBe(4); // 10 / 2.5 = 4
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,10 @@
|
|||||||
* Rough client-side token estimation for AI-chat UI affordances.
|
* Rough client-side token estimation for AI-chat UI affordances.
|
||||||
*
|
*
|
||||||
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
* No provider streams exact per-token usage mid-stream, so any in-flight figure
|
||||||
* is a CLIENT ESTIMATE (chars/≈4 heuristic). Pure + unit-testable: it never runs
|
* is a CLIENT ESTIMATE. This re-exports the SHARED estimator from
|
||||||
* a real BPE tokenizer (that would be O(n²) on the hot path, bloat the bundle,
|
* `@docmost/token-estimate` (chars/2.5) so the in-body counter and the server's
|
||||||
* and be wrong for Gemini/Ollama anyway). Used by the in-body reasoning counter
|
* replay budgeter use the SAME heuristic — two divergent estimators would mean
|
||||||
* ("Thinking · N tokens").
|
* "the badge shows 60%" while "the budgeter already trimmed" (#490). Used by the
|
||||||
|
* in-body reasoning counter ("Thinking · N tokens").
|
||||||
*/
|
*/
|
||||||
|
export { estimateTokens } from "@docmost/token-estimate";
|
||||||
/**
|
|
||||||
* Rough token estimate for a piece of text using the standard chars/≈4 heuristic.
|
|
||||||
* Returns 0 for empty/whitespace-free-of-content input, and ceils so any
|
|
||||||
* non-empty text counts as at least one token.
|
|
||||||
*/
|
|
||||||
export function estimateTokens(text: string): number {
|
|
||||||
if (!text) return 0;
|
|
||||||
return Math.ceil(text.length / 4);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,6 +23,89 @@ describe("describeChatError", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("classifies an A_RUN_BEGIN_FAILED 503 as a temporary run-start failure, NOT provider-not-configured (#486)", () => {
|
||||||
|
// The FULL real body the server writes for a beginRun failure: a
|
||||||
|
// ServiceUnavailableException(object) whose response is serialized verbatim
|
||||||
|
// onto the raw socket, self-describing statusCode 503 + the run-start code.
|
||||||
|
const body =
|
||||||
|
'{"message":"Could not start the agent run. This is usually temporary — please try again.","code":"A_RUN_BEGIN_FAILED","statusCode":503}';
|
||||||
|
expect(describeChatError(body, t)).toEqual({
|
||||||
|
title: "Could not start the run",
|
||||||
|
detail:
|
||||||
|
"The agent run could not be started. This is usually temporary — please try again.",
|
||||||
|
});
|
||||||
|
// ORDER GUARD: even though the body ALSO carries statusCode 503 (which the
|
||||||
|
// generic branch matches), the A_RUN_BEGIN_FAILED branch runs first, so it is
|
||||||
|
// never mislabeled "AI provider not configured".
|
||||||
|
expect(describeChatError(body, t).title).not.toBe(
|
||||||
|
"AI provider not configured",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #488 commit 5: the #487 concurrency-gate / supersede 409s. FULL real bodies:
|
||||||
|
// a ConflictException(object) whose response is serialized verbatim, carrying a
|
||||||
|
// `code` and statusCode 409. Each must classify to a human text, not raw JSON.
|
||||||
|
it("classifies A_RUN_ALREADY_ACTIVE (409) as already-answering, not raw JSON", () => {
|
||||||
|
const body =
|
||||||
|
'{"message":"A run is already active for this chat","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe(
|
||||||
|
"The agent is already answering",
|
||||||
|
);
|
||||||
|
// Never leaks the raw code as the detail.
|
||||||
|
expect(describeChatError(body, t).detail).not.toContain("A_RUN_ALREADY_ACTIVE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies SUPERSEDE_TARGET_MISMATCH (409) as run-changed", () => {
|
||||||
|
// Real server body shape: the current run id is `activeRunId` (NOT `runId`) —
|
||||||
|
// see ai-chat.controller.ts. describeChatError classifies off `code` only.
|
||||||
|
const body =
|
||||||
|
'{"message":"active run does not match the supersede target","code":"SUPERSEDE_TARGET_MISMATCH","activeRunId":"run-x","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe(
|
||||||
|
"Couldn't interrupt — the run changed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies SUPERSEDE_TIMEOUT (409) as couldn't-interrupt-in-time", () => {
|
||||||
|
const body =
|
||||||
|
'{"message":"the run did not settle within the supersede window","code":"SUPERSEDE_TIMEOUT","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe("Couldn't interrupt in time");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies SUPERSEDE_INVALID (409) as couldn't-interrupt-that-run", () => {
|
||||||
|
const body =
|
||||||
|
'{"message":"supervise requires chatId","code":"SUPERSEDE_INVALID","statusCode":409}';
|
||||||
|
expect(describeChatError(body, t).title).toBe(
|
||||||
|
"Couldn't interrupt that run",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ORDER GUARD: A_RUN_ALREADY_ACTIVE wins over any generic status branch", () => {
|
||||||
|
// Even though the body could superficially look 4xx-ish, the code branch runs
|
||||||
|
// first, so it is never mislabeled by a generic status heading.
|
||||||
|
const body =
|
||||||
|
'{"message":"conflict","code":"A_RUN_ALREADY_ACTIVE","statusCode":409}';
|
||||||
|
const view = describeChatError(body, t);
|
||||||
|
expect(view.title).not.toBe("Something went wrong");
|
||||||
|
expect(view.title).not.toBe("AI provider not configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a token-degeneration abort under the SAME 'Response stopped.' marker the live view shows (#495)", () => {
|
||||||
|
// The exact reason the server persists in metadata.error on a degeneration
|
||||||
|
// abort (ai-chat.service OUTPUT_DEGENERATION_ERROR). Live, this event shows
|
||||||
|
// the neutral "Response stopped." notice; the persisted banner MUST match it
|
||||||
|
// so live and refetch never disagree.
|
||||||
|
const view = describeChatError(
|
||||||
|
"Output degeneration detected (repeated token loop)",
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
expect(view.title).toBe("Response stopped.");
|
||||||
|
expect(view.detail).toBe(
|
||||||
|
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||||
|
);
|
||||||
|
// Regression guard: it must NOT fall through to the generic heading.
|
||||||
|
expect(view.title).not.toBe("Something went wrong");
|
||||||
|
});
|
||||||
|
|
||||||
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
it("classifies a dropped connection (ECONNRESET) as a lost-connection error", () => {
|
||||||
expect(
|
expect(
|
||||||
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
describeChatError("Cannot connect to API: read ECONNRESET", t).title,
|
||||||
|
|||||||
@@ -24,6 +24,75 @@ export function describeChatError(
|
|||||||
): ChatErrorView {
|
): ChatErrorView {
|
||||||
const msg = message ?? "";
|
const msg = message ?? "";
|
||||||
|
|
||||||
|
// Our own "could not start the run" gate (A_RUN_BEGIN_FAILED, #486): a 503
|
||||||
|
// whose body carries this code is a TEMPORARY server-side failure while
|
||||||
|
// starting the run (e.g. a DB-pool blip), NOT an unconfigured provider. It MUST
|
||||||
|
// be matched STRICTLY BEFORE the generic 503 branch below, which would
|
||||||
|
// otherwise mislabel it "The AI provider is not configured" and tell the user
|
||||||
|
// to call an admin instead of just retrying.
|
||||||
|
if (/"code"\s*:\s*"A_RUN_BEGIN_FAILED"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Could not start the run"),
|
||||||
|
detail: t(
|
||||||
|
"The agent run could not be started. This is usually temporary — please try again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// #488 commit 5: the #487 concurrency-gate / supersede 409s. These arrive as a
|
||||||
|
// ConflictException(object) body carrying a `code` (and statusCode 409). They
|
||||||
|
// MUST be classified by `code` STRICTLY BEFORE any generic status branch, or the
|
||||||
|
// user sees the raw JSON `{"code":"A_RUN_ALREADY_ACTIVE",…}`. The code strings
|
||||||
|
// are the real #487 server contract (ai-chat.controller.ts) — do not invent.
|
||||||
|
if (/"code"\s*:\s*"A_RUN_ALREADY_ACTIVE"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("The agent is already answering"),
|
||||||
|
detail: t(
|
||||||
|
"This chat already has a run in progress. Wait for it to finish, or interrupt it and send now.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/"code"\s*:\s*"SUPERSEDE_TARGET_MISMATCH"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Couldn't interrupt — the run changed"),
|
||||||
|
detail: t(
|
||||||
|
"The run you tried to interrupt is no longer the active one. Check the latest answer and try again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/"code"\s*:\s*"SUPERSEDE_TIMEOUT"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Couldn't interrupt in time"),
|
||||||
|
detail: t(
|
||||||
|
"The previous run didn't stop in time. Nothing was sent — try sending again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (/"code"\s*:\s*"SUPERSEDE_INVALID"/.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Couldn't interrupt that run"),
|
||||||
|
detail: t(
|
||||||
|
"The run to interrupt doesn't belong to this chat. Reload and try again.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Our own token-degeneration abort (#444): the server aborts a runaway
|
||||||
|
// repetition loop and persists this exact reason in metadata.error. LIVE, the
|
||||||
|
// same abort surfaces as the neutral "Response stopped." notice (the client
|
||||||
|
// cannot tell it from a manual Stop mid-stream), so the persisted banner must
|
||||||
|
// read the SAME "Response stopped." marker — otherwise the live view and a
|
||||||
|
// later refetch show two different texts for one event. The detail explains the
|
||||||
|
// loop-guard cause without contradicting the shared heading.
|
||||||
|
if (/output degeneration detected|repeated token loop/i.test(msg)) {
|
||||||
|
return {
|
||||||
|
title: t("Response stopped."),
|
||||||
|
detail: t(
|
||||||
|
"The answer was stopped automatically because the model fell into a repeated output loop.",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
if (/"statusCode"\s*:\s*403\b/.test(msg)) {
|
||||||
return {
|
return {
|
||||||
title: t("AI chat is disabled"),
|
title: t("AI chat is disabled"),
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import { markdownToHtml } from "@docmost/editor-ext";
|
import {
|
||||||
|
markdownToProseMirrorSync,
|
||||||
|
docmostExtensions,
|
||||||
|
} from "@docmost/prosemirror-markdown/browser";
|
||||||
|
import { getSchema } from "@tiptap/core";
|
||||||
|
import { Node as PMNode, DOMSerializer } from "@tiptap/pm/model";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
// The Docmost editor schema, built once. Chat markdown is rendered through the
|
||||||
|
// SAME schema the editor/import use (issue #347), so chat output matches how the
|
||||||
|
// page would render the same markdown.
|
||||||
|
const chatSchema = getSchema(docmostExtensions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown -> HTML for chat display, via the canonical converter. We serialize
|
||||||
|
* the ProseMirror doc with `DOMSerializer` into a real element and read its
|
||||||
|
* `innerHTML` (rather than `@tiptap/html`'s `generateHTML`, whose browser path
|
||||||
|
* uses `XMLSerializer` and stamps a `xmlns` on every block) so the markup is
|
||||||
|
* clean HTML. `li > p` wrapping is inherent to the schema (listItem content is
|
||||||
|
* `paragraph+`); the chat CSS zeroes those paragraph margins so lists still
|
||||||
|
* render tight.
|
||||||
|
*/
|
||||||
|
function markdownToChatHtml(markdown: string): string {
|
||||||
|
const doc = markdownToProseMirrorSync(markdown);
|
||||||
|
const node = PMNode.fromJSON(chatSchema, doc);
|
||||||
|
const div = document.createElement("div");
|
||||||
|
DOMSerializer.fromSchema(chatSchema).serializeFragment(
|
||||||
|
node.content,
|
||||||
|
{ document },
|
||||||
|
div,
|
||||||
|
);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderChatMarkdownOptions {
|
export interface RenderChatMarkdownOptions {
|
||||||
/**
|
/**
|
||||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||||
@@ -63,22 +94,32 @@ function neutralizeInternalLinksHook(node: Element): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
* canonical converter (issue #347): markdown -> ProseMirror JSON (the SAME
|
||||||
* chat output matches the editor's markdown flavor, then sanitize with
|
* `markdownToProseMirrorSync` the editor paste/import path uses, so chat output
|
||||||
* DOMPurify — LLM output is untrusted, so it must never reach the DOM unsanitized.
|
* matches the editor's markdown flavor) -> HTML via `markdownToChatHtml`
|
||||||
|
* (DOMSerializer), then sanitize with DOMPurify — LLM output is untrusted, so it
|
||||||
|
* must never reach the DOM unsanitized.
|
||||||
*
|
*
|
||||||
* `markdownToHtml` can return `string | Promise<string>` (it has async marked
|
* Stays SYNCHRONOUS: both callers render inside React (a memo and a useMemo),
|
||||||
* extensions registered). In practice plain chat markdown resolves
|
* so the whole pipeline must resolve without awaiting. The converter's sync
|
||||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
* entry makes that possible; on any conversion error we return "" so the caller
|
||||||
* for that branch (the caller renders the raw text fallback instead).
|
* falls back to raw text (the same fallback the old Promise-guard produced).
|
||||||
*/
|
*/
|
||||||
export function renderChatMarkdown(
|
export function renderChatMarkdown(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
options: RenderChatMarkdownOptions = {},
|
options: RenderChatMarkdownOptions = {},
|
||||||
): string {
|
): string {
|
||||||
if (!markdown) return "";
|
if (!markdown) return "";
|
||||||
const html = markdownToHtml(markdown);
|
let html: string;
|
||||||
if (typeof html !== "string") return "";
|
try {
|
||||||
|
// markdown -> canonical PM JSON -> HTML (native DOMParser in the browser;
|
||||||
|
// jsdom is never bundled — see @docmost/prosemirror-markdown/browser).
|
||||||
|
html = markdownToChatHtml(markdown);
|
||||||
|
} catch {
|
||||||
|
// Malformed/unsupported markdown must not crash the chat render; fall back
|
||||||
|
// to raw text (empty return -> caller shows the plain-text branch).
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.neutralizeInternalLinks) {
|
if (!options.neutralizeInternalLinks) {
|
||||||
// Internal chat: unchanged behavior, no hook registered.
|
// Internal chat: unchanged behavior, no hook registered.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import { yHistoryAvailability } from "./use-toolbar-state.ts";
|
||||||
|
|
||||||
|
// Undo/redo availability is derived from the Yjs UndoManager's PRIVATE
|
||||||
|
// `undoStack` / `redoStack` fields (see use-toolbar-state.ts for why we read the
|
||||||
|
// stack lengths directly instead of the expensive `editor.can().undo()` dry-run).
|
||||||
|
// These tests lock in the behavior AND pin the library shape so a yjs / y-undo
|
||||||
|
// upgrade that renames/restructures those internals fails loudly here rather than
|
||||||
|
// silently enabling/disabling the toolbar buttons in production.
|
||||||
|
describe("yHistoryAvailability", () => {
|
||||||
|
it("reports availability from the stack lengths", () => {
|
||||||
|
expect(yHistoryAvailability({ undoStack: [], redoStack: [] })).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
yHistoryAvailability({ undoStack: [{}], redoStack: [] }),
|
||||||
|
).toEqual({ canUndo: true, canRedo: false });
|
||||||
|
expect(
|
||||||
|
yHistoryAvailability({ undoStack: [{}], redoStack: [{}, {}] }),
|
||||||
|
).toEqual({ canUndo: true, canRedo: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the private stack shape is unrecognized (upgrade guard)", () => {
|
||||||
|
// Simulates a yjs / y-undo upgrade that renames or restructures the private
|
||||||
|
// fields: the caller then falls back to the safe prosemirror-history default
|
||||||
|
// instead of throwing on `.length` of undefined or reading garbage.
|
||||||
|
expect(yHistoryAvailability(undefined)).toBeNull();
|
||||||
|
expect(yHistoryAvailability(null)).toBeNull();
|
||||||
|
expect(yHistoryAvailability({})).toBeNull();
|
||||||
|
expect(yHistoryAvailability({ undoStack: 5, redoStack: 5 })).toBeNull();
|
||||||
|
// Only one stack present (partial rename) is still not trusted.
|
||||||
|
expect(yHistoryAvailability({ undoStack: [] })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pin-test: a real yjs UndoManager still exposes undoStack/redoStack arrays", () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const text = doc.getText("prosemirror");
|
||||||
|
const undoManager = new Y.UndoManager(text);
|
||||||
|
|
||||||
|
// Fresh manager: both stacks empty -> nothing to undo/redo.
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A tracked edit must push onto the private undoStack. If a future yjs
|
||||||
|
// renames these fields, yHistoryAvailability(undoManager) returns null and
|
||||||
|
// the expectation below fails loudly.
|
||||||
|
text.insert(0, "hello");
|
||||||
|
undoManager.stopCapturing();
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undoing moves the item to the redoStack -> redo becomes available.
|
||||||
|
undoManager.undo();
|
||||||
|
expect(yHistoryAvailability(undoManager)).toEqual({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,30 @@ export interface ToolbarState {
|
|||||||
// When neither history backend is installed (the pre-sync static editor —
|
// When neither history backend is installed (the pre-sync static editor —
|
||||||
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
// mainExtensions only, undoRedo disabled), both fall through to 0 -> false,
|
||||||
// matching the previous `safeCan` behavior.
|
// matching the previous `safeCan` behavior.
|
||||||
|
// Reads the Yjs UndoManager's undo/redo availability from its stack lengths.
|
||||||
|
//
|
||||||
|
// `undoStack` / `redoStack` are PRIVATE y-undo / yjs internals, so we touch them
|
||||||
|
// defensively: a yjs or y-undo upgrade that renames or restructures these fields
|
||||||
|
// must not silently mis-drive the toolbar buttons (nor throw on `.length` of
|
||||||
|
// `undefined`). We only trust them when they are actually arrays; otherwise this
|
||||||
|
// returns null and the caller falls back to a safe default. The pin-test in
|
||||||
|
// use-toolbar-state.test.ts asserts the current library shape, so an upgrade that
|
||||||
|
// breaks this contract fails loudly there instead of failing silently in the UI.
|
||||||
|
export function yHistoryAvailability(
|
||||||
|
undoManager: unknown,
|
||||||
|
): { canUndo: boolean; canRedo: boolean } | null {
|
||||||
|
if (!undoManager || typeof undoManager !== "object") return null;
|
||||||
|
const { undoStack, redoStack } = undoManager as {
|
||||||
|
undoStack?: unknown;
|
||||||
|
redoStack?: unknown;
|
||||||
|
};
|
||||||
|
if (!Array.isArray(undoStack) || !Array.isArray(redoStack)) return null;
|
||||||
|
return {
|
||||||
|
canUndo: undoStack.length > 0,
|
||||||
|
canRedo: redoStack.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function historyAvailability(editor: Editor): {
|
function historyAvailability(editor: Editor): {
|
||||||
canUndo: boolean;
|
canUndo: boolean;
|
||||||
canRedo: boolean;
|
canRedo: boolean;
|
||||||
@@ -43,16 +67,14 @@ function historyAvailability(editor: Editor): {
|
|||||||
|
|
||||||
// Collaboration history (Yjs) takes precedence when present.
|
// Collaboration history (Yjs) takes precedence when present.
|
||||||
const yState = yUndoPluginKey.getState(state) as
|
const yState = yUndoPluginKey.getState(state) as
|
||||||
| { undoManager?: { undoStack: unknown[]; redoStack: unknown[] } }
|
| { undoManager?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (yState?.undoManager) {
|
const yAvail = yHistoryAvailability(yState?.undoManager);
|
||||||
return {
|
if (yAvail) return yAvail;
|
||||||
canUndo: yState.undoManager.undoStack.length > 0,
|
|
||||||
canRedo: yState.undoManager.redoStack.length > 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
// Plain prosemirror-history (returns 0 when the history plugin is absent).
|
||||||
|
// This is also the safe default when a Yjs UndoManager is present but its
|
||||||
|
// private stack shape is no longer recognized (yHistoryAvailability -> null).
|
||||||
return {
|
return {
|
||||||
canUndo: undoDepth(state) > 0,
|
canUndo: undoDepth(state) > 0,
|
||||||
canRedo: redoDepth(state) > 0,
|
canRedo: redoDepth(state) > 0,
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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 { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { MarkdownClipboard } from "./markdown-clipboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration coverage for the async `handlePaste` seam (issue #347). The paste
|
||||||
|
* conversion moved to `@docmost/prosemirror-markdown`'s browser entry, whose
|
||||||
|
* `markdownToProseMirror` is async — so `handlePaste` captures the range, claims
|
||||||
|
* the event (returns true), and dispatches the insert on the next microtask.
|
||||||
|
* These tests drive that path end to end on a minimal schema (a plain-markdown
|
||||||
|
* paste whose converted nodes fit paragraph/text/bold/italic), asserting the
|
||||||
|
* text lands with the right marks and that the raw markdown syntax is consumed
|
||||||
|
* (recognized as markdown, not inserted literally).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeEditor() {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.appendChild(element);
|
||||||
|
return new Editor({
|
||||||
|
element,
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
MarkdownClipboard.configure({ transformPastedText: true }),
|
||||||
|
],
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the markdownClipboard plugin and invoke its handlePaste directly with a
|
||||||
|
// synthetic clipboard event (jsdom has no real paste pipeline). The plugin's
|
||||||
|
// handlePaste closes over the extension `this`, so calling it off the plugin
|
||||||
|
// props preserves `this.editor`/`this.options`.
|
||||||
|
function paste(editor: Editor, text: string): boolean {
|
||||||
|
const view = editor.view;
|
||||||
|
const plugin = view.state.plugins.find(
|
||||||
|
(p: any) => p.props && p.spec?.key,
|
||||||
|
) as any;
|
||||||
|
const event = {
|
||||||
|
clipboardData: {
|
||||||
|
getData: (type: string) => (type === "text/plain" ? text : ""),
|
||||||
|
},
|
||||||
|
} as unknown as ClipboardEvent;
|
||||||
|
// Find the specific handlePaste that belongs to the markdown clipboard plugin.
|
||||||
|
const md = view.state.plugins.find(
|
||||||
|
(p: any) => typeof p.props?.handlePaste === "function",
|
||||||
|
) as any;
|
||||||
|
return md.props.handlePaste(view, event, view.state.selection.content());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the microtask queue so the async .then() dispatch runs.
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
describe("MarkdownClipboard handlePaste (async md -> PM)", () => {
|
||||||
|
it("converts a plain-markdown paste with bold/italic into marked text", async () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const claimed = paste(editor, "hello **bold** and *italic*");
|
||||||
|
// The paste is claimed synchronously (async insert follows).
|
||||||
|
expect(claimed).toBe(true);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const json = editor.getJSON();
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
// The raw markdown asterisks are consumed (recognized), not inserted literally.
|
||||||
|
expect(editor.getText()).not.toContain("**");
|
||||||
|
expect(editor.getText()).toContain("bold");
|
||||||
|
expect(editor.getText()).toContain("italic");
|
||||||
|
// The bold/italic marks materialized.
|
||||||
|
expect(text).toContain('"bold"');
|
||||||
|
expect(text).toContain('"italic"');
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a bullet list paste as list structure (not literal '-')", async () => {
|
||||||
|
// A bullet list is not representable in this minimal schema, so the converter
|
||||||
|
// output would fail PMNode.fromJSON and the catch inserts raw text. Use a
|
||||||
|
// paste whose nodes DO fit the schema to assert the happy path instead: two
|
||||||
|
// paragraphs separated by a blank line.
|
||||||
|
const editor = makeEditor();
|
||||||
|
paste(editor, "first para\n\nsecond para");
|
||||||
|
await flush();
|
||||||
|
const json = editor.getJSON() as any;
|
||||||
|
const paras = (json.content || []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
);
|
||||||
|
// Two paragraphs materialized from the blank-line-separated markdown.
|
||||||
|
expect(paras.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(editor.getText()).toContain("first para");
|
||||||
|
expect(editor.getText()).toContain("second para");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to raw text when conversion yields nodes the schema lacks", async () => {
|
||||||
|
// `# heading` converts to a `heading` node absent from this minimal schema,
|
||||||
|
// so PMNode.fromJSON throws and the catch re-inserts the raw text — the user
|
||||||
|
// never loses their clipboard content.
|
||||||
|
const editor = makeEditor();
|
||||||
|
paste(editor, "# a heading line");
|
||||||
|
await flush();
|
||||||
|
// Content is preserved (either as heading text or literal), never dropped.
|
||||||
|
expect(editor.getText()).toContain("a heading line");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The async seam captures the target range synchronously, then replaces on the
|
||||||
|
// next microtask. If the document changed under it between capture and resolve
|
||||||
|
// (impossible in prod — same microtask — but pinned here), BOTH the success
|
||||||
|
// (replaceRange) and the fail-open (insertText) branches must fall back to the
|
||||||
|
// LIVE selection rather than a stale absolute range, so neither clobbers content
|
||||||
|
// nor throws a RangeError. We force the mid-flight change by dispatching a
|
||||||
|
// doc-mutating transaction AFTER the synchronous claim but BEFORE flushing the
|
||||||
|
// microtask that runs the `.then`/`.catch`.
|
||||||
|
describe("MarkdownClipboard handlePaste — doc-changed-mid-flight guard", () => {
|
||||||
|
// Replace the whole doc with one paragraph of `text` (synchronous dispatch).
|
||||||
|
// An empty string yields an empty paragraph (a text node may not be empty).
|
||||||
|
function seedContent(editor: Editor, text: string) {
|
||||||
|
editor.commands.setContent({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
text
|
||||||
|
? { type: "paragraph", content: [{ type: "text", text }] }
|
||||||
|
: { type: "paragraph" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("success branch: mid-flight doc change routes the paste to the LIVE selection, never the stale range (clobber-proving)", async () => {
|
||||||
|
// The paste captures a NON-EMPTY range {1,5} (over "AAAA"). Then, before the
|
||||||
|
// async resolve, the doc GROWS ("MARKER" inserted at the start) and the cursor
|
||||||
|
// is parked at the doc END. The captured {1,5} is now stale and points INTO
|
||||||
|
// "MARKER". A WORKING guard replaces at the live (end) selection → MARKER is
|
||||||
|
// untouched. A BROKEN guard replaces the stale {1,5} → it erases the first
|
||||||
|
// characters of MARKER (this is what a zero-width `from==to` range could never
|
||||||
|
// reveal, which is why the earlier version was vacuous).
|
||||||
|
const editor = makeEditor();
|
||||||
|
seedContent(editor, "AAAABBBB");
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 5 }); // captured range = {1,5}
|
||||||
|
const claimed = paste(editor, "hello **bold**");
|
||||||
|
expect(claimed).toBe(true);
|
||||||
|
|
||||||
|
// Mid-flight: grow the doc and move the cursor to a KNOWN-safe end position.
|
||||||
|
editor.view.dispatch(editor.view.state.tr.insertText("MARKER", 1));
|
||||||
|
const end = editor.state.doc.content.size;
|
||||||
|
editor.commands.setTextSelection({ from: end, to: end });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const text = editor.getText();
|
||||||
|
// MARKER intact only if the guard used the live selection, not the stale range.
|
||||||
|
expect(text).toContain("MARKER");
|
||||||
|
expect(text).toContain("bold");
|
||||||
|
expect(text).not.toContain("**");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail-open branch: a mid-flight doc SHRINK makes the stale `to` out of bounds — the guard must avoid a RangeError (throw-proving)", async () => {
|
||||||
|
// The paste captures a range {1,9} over an 8-char paragraph, then the
|
||||||
|
// conversion FAILS (`# heading` -> a heading node the minimal schema lacks,
|
||||||
|
// so PMNode.fromJSON throws -> the fail-open catch runs). Before the reject,
|
||||||
|
// the doc is SHRUNK to an empty paragraph, so the captured `to` (9) is now far
|
||||||
|
// past the doc's end. A WORKING guard inserts the raw text at the live (valid)
|
||||||
|
// selection → "raw heading" lands. A BROKEN guard does insertText(md, 1, 9) on
|
||||||
|
// a size-2 doc → RangeError, so the dispatch never runs and "raw heading" is
|
||||||
|
// absent (the assertion reddens). A zero-width/growing-doc setup could never
|
||||||
|
// push `to` out of bounds, which is why the earlier version was vacuous.
|
||||||
|
const editor = makeEditor();
|
||||||
|
seedContent(editor, "AAAABBBB");
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 9 }); // captured range = {1,9}
|
||||||
|
paste(editor, "# raw heading");
|
||||||
|
|
||||||
|
// Mid-flight: shrink the doc so the captured `to` = 9 is now out of bounds.
|
||||||
|
seedContent(editor, "");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const text = editor.getText();
|
||||||
|
// Raw text lands (via the live selection) only if the guard avoided the
|
||||||
|
// stale, now-out-of-bounds range.
|
||||||
|
expect(text).toContain("raw heading");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two pastes in flight: neither payload is lost (no data loss)", async () => {
|
||||||
|
// Prod-unreachable (two paste events are separate macrotasks, and each
|
||||||
|
// conversion resolves on a microtask before the next), but pinned here: when
|
||||||
|
// both resolve back-to-back, the second sees the changed doc and inserts at
|
||||||
|
// the live selection the first left — so the two payloads may INTERLEAVE, but
|
||||||
|
// neither is dropped. We assert no data loss, not contiguity.
|
||||||
|
const editor = makeEditor();
|
||||||
|
paste(editor, "alphaword");
|
||||||
|
paste(editor, "betaword");
|
||||||
|
await flush();
|
||||||
|
const text = editor.getText();
|
||||||
|
// Neither payload fully dropped (interleaving may split one of them).
|
||||||
|
expect(text).toContain("alpha");
|
||||||
|
expect(text).toContain("beta");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
// Markdown conversion now goes through the canonical package's BROWSER entry
|
||||||
|
// (issue #347): the same converter the server import/export uses, resolved via
|
||||||
|
// the `browser` exports condition so it runs on the native `DOMParser` (the
|
||||||
|
// client jsdom vitest env provides one) with jsdom never bundled.
|
||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
markdownToProseMirrorSync,
|
||||||
|
} from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
normalizeTableColumnWidths,
|
normalizeTableColumnWidths,
|
||||||
classifyClipboardSelection,
|
classifyClipboardSelection,
|
||||||
@@ -175,10 +182,13 @@ describe("classifyClipboardSelection", () => {
|
|||||||
|
|
||||||
// Output-level tests for the table clipboard regression: copying a table must
|
// Output-level tests for the table clipboard regression: copying a table must
|
||||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
||||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
// These exercise the actual markdown produced by convertProseMirrorToMarkdown —
|
||||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
// the same serializer step the clipboardTextSerializer now runs (issue #347) —
|
||||||
// shape that the classifier-flag tests above do not cover.
|
// so they pin the OUTPUT shape that the classifier-flag tests above do not cover.
|
||||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
// Input is ProseMirror JSON (what the copied slice serializes to), matching the
|
||||||
|
// clipboardTextSerializer's new call: it wraps the slice content in a synthetic
|
||||||
|
// `doc` (and the bare-rows case in a `table`) and calls the converter.
|
||||||
|
describe("table clipboard markdown output (convertProseMirrorToMarkdown)", () => {
|
||||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
||||||
function lines(md: string): string[] {
|
function lines(md: string): string[] {
|
||||||
return md
|
return md
|
||||||
@@ -188,10 +198,10 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
||||||
// of the padding turndown emits.
|
// of the padding the serializer emits.
|
||||||
function isSeparatorRow(line: string): boolean {
|
function isSeparatorRow(line: string): boolean {
|
||||||
const compact = line.replace(/\s+/g, "");
|
const compact = line.replace(/\s+/g, "");
|
||||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
return /^\|(?::?-{2,}:?\|)+$/.test(compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split a pipe-delimited row into trimmed cell values.
|
// Split a pipe-delimited row into trimmed cell values.
|
||||||
@@ -203,42 +213,33 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
.map((c) => c.trim());
|
.map((c) => c.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
const cell = (t: string) => ({
|
||||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
type: "tableCell",
|
||||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
});
|
||||||
// const table = document.createElement("table");
|
const headerCell = (t: string) => ({
|
||||||
// const tbody = document.createElement("tbody");
|
type: "tableHeader",
|
||||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
// div.appendChild(table);
|
});
|
||||||
// return htmlToMarkdown(div.innerHTML);
|
const row = (nodes: any[]) => ({ type: "tableRow", content: nodes });
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [c1, c2] of [
|
|
||||||
["a", "b"],
|
|
||||||
["c", "d"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = c1;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = c2;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
||||||
|
// Mirror the serializer's `wrapBareRows` branch: bare tableRow nodes are
|
||||||
|
// wrapped in a synthetic `table` and convertProseMirrorToMarkdown is called
|
||||||
|
// (see markdown-clipboard.ts clipboardTextSerializer).
|
||||||
|
const rows = [
|
||||||
|
row([cell("a"), cell("b")]),
|
||||||
|
row([cell("c"), cell("d")]),
|
||||||
|
];
|
||||||
|
const md = convertProseMirrorToMarkdown({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "table", content: rows }],
|
||||||
|
});
|
||||||
const ls = lines(md);
|
const ls = lines(md);
|
||||||
|
|
||||||
// Valid GFM: a header/data separator row is present (an empty header is
|
// Valid GFM: a header/data separator row is present.
|
||||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
// NOT the old broken "one value per line" shape: every line is pipe-delimited.
|
||||||
// and no line is a bare cell value on its own.
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
||||||
// The cell values land in real pipe-delimited data rows.
|
// The cell values land in real pipe-delimited data rows.
|
||||||
@@ -248,39 +249,21 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
||||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
// Mirror the serializer's non-wrap branch: the full `table` node is the
|
||||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
// slice content and convertProseMirrorToMarkdown runs on it.
|
||||||
const div = document.createElement("div");
|
const md = convertProseMirrorToMarkdown({
|
||||||
const table = document.createElement("table");
|
type: "doc",
|
||||||
|
content: [
|
||||||
const thead = document.createElement("thead");
|
{
|
||||||
const headerRow = document.createElement("tr");
|
type: "table",
|
||||||
for (const h of ["Name", "Age"]) {
|
content: [
|
||||||
const th = document.createElement("th");
|
row([headerCell("Name"), headerCell("Age")]),
|
||||||
th.textContent = h;
|
row([cell("Alice"), cell("30")]),
|
||||||
headerRow.appendChild(th);
|
row([cell("Bob"), cell("25")]),
|
||||||
}
|
],
|
||||||
thead.appendChild(headerRow);
|
},
|
||||||
table.appendChild(thead);
|
],
|
||||||
|
});
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [name, age] of [
|
|
||||||
["Alice", "30"],
|
|
||||||
["Bob", "25"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = name;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = age;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
const ls = lines(md);
|
||||||
|
|
||||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
// Proper GFM structure: separator row + all rows pipe-delimited.
|
||||||
@@ -296,3 +279,146 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #347 acceptance: pasting CANONICAL markdown yields the SAME nodes the server
|
||||||
|
// import produces for the same text. The paste path calls markdownToProseMirror
|
||||||
|
// (the package browser entry) — the identical converter the server import uses —
|
||||||
|
// so asserting the converter (via the browser entry, on the native DOMParser)
|
||||||
|
// recognizes each canon form pins the paste-parity guarantee. These forms were
|
||||||
|
// NOT recognized by the old editor-ext marked layer the paste used before.
|
||||||
|
describe("canonical markdown paste recognition (browser entry parity)", () => {
|
||||||
|
// Collect every node type present in a doc (recursively).
|
||||||
|
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
|
||||||
|
if (!n || typeof n !== "object") return set;
|
||||||
|
if (n.type) set.add(n.type);
|
||||||
|
if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const findNode = (n: any, type: string): any => {
|
||||||
|
if (!n || typeof n !== "object") return undefined;
|
||||||
|
if (n.type === type) return n;
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const c of n.content) {
|
||||||
|
const hit = findNode(c, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const allText = (n: any): string => {
|
||||||
|
if (!n || typeof n !== "object") return "";
|
||||||
|
if (typeof n.text === "string") return n.text;
|
||||||
|
if (Array.isArray(n.content)) return n.content.map(allText).join("");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
it("^[…] inline footnote -> footnoteReference + footnotesList", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("Body^[a note here].");
|
||||||
|
const types = collectTypes(doc);
|
||||||
|
expect(types.has("footnoteReference")).toBe(true);
|
||||||
|
expect(types.has("footnotesList")).toBe(true);
|
||||||
|
expect(types.has("footnoteDefinition")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('<!--img {…}--> attached image comment -> image with align', () => {
|
||||||
|
const doc = markdownToProseMirrorSync(
|
||||||
|
' <!--img {"align":"left"}-->',
|
||||||
|
);
|
||||||
|
const img = findNode(doc, "image");
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.attrs?.align).toBe("left");
|
||||||
|
expect(img.attrs?.src).toBe("/files/x.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("> [!type] Obsidian callout -> callout node with type", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("> [!warning]\n> be careful");
|
||||||
|
const callout = findNode(doc, "callout");
|
||||||
|
expect(callout).toBeTruthy();
|
||||||
|
expect(callout.attrs?.type).toBe("warning");
|
||||||
|
expect(allText(callout)).toContain("be careful");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("$…$ inline math -> mathInline node", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("Euler: $e^{i\\pi}+1=0$ done");
|
||||||
|
const math = findNode(doc, "mathInline");
|
||||||
|
expect(math).toBeTruthy();
|
||||||
|
expect(math.attrs?.text).toContain("e^{i\\pi}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("==…== highlight -> highlight mark", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("A ==marked== word");
|
||||||
|
const marked = findNode(doc, "text");
|
||||||
|
// The highlighted run carries a `highlight` mark somewhere in the doc.
|
||||||
|
const hasHighlight = (n: any): boolean => {
|
||||||
|
if (!n || typeof n !== "object") return false;
|
||||||
|
if (
|
||||||
|
n.type === "text" &&
|
||||||
|
(n.marks || []).some((m: any) => m.type === "highlight")
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
|
||||||
|
};
|
||||||
|
expect(marked).toBeTruthy();
|
||||||
|
expect(hasHighlight(doc)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("<!--subpages--> standalone comment -> subpages node", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("intro\n\n<!--subpages-->\n\nafter");
|
||||||
|
expect(collectTypes(doc).has("subpages")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #347 negatives: plain text carrying markdown-LIKE punctuation must NOT be
|
||||||
|
// silently converted/mangled (currency, bare `==`, a `[^1]` reference form).
|
||||||
|
describe("plain-text paste negatives (no phantom conversion)", () => {
|
||||||
|
const findNode = (n: any, type: string): any => {
|
||||||
|
if (!n || typeof n !== "object") return undefined;
|
||||||
|
if (n.type === type) return n;
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const c of n.content) {
|
||||||
|
const hit = findNode(c, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
|
||||||
|
if (!n || typeof n !== "object") return set;
|
||||||
|
if (n.type) set.add(n.type);
|
||||||
|
if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const allText = (n: any): string => {
|
||||||
|
if (!n || typeof n !== "object") return "";
|
||||||
|
if (typeof n.text === "string") return n.text;
|
||||||
|
if (Array.isArray(n.content)) return n.content.map(allText).join("");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
it("currency `$5 and $10` is NOT turned into math", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("It costs $5 and $10 total");
|
||||||
|
expect(findNode(doc, "mathInline")).toBeFalsy();
|
||||||
|
expect(allText(doc)).toContain("$5 and $10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lone `==` is NOT turned into a highlight", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("compare a == b in code");
|
||||||
|
const hasHighlight = (n: any): boolean => {
|
||||||
|
if (!n || typeof n !== "object") return false;
|
||||||
|
if (
|
||||||
|
n.type === "text" &&
|
||||||
|
(n.marks || []).some((m: any) => m.type === "highlight")
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
|
||||||
|
};
|
||||||
|
expect(hasHighlight(doc)).toBe(false);
|
||||||
|
expect(allText(doc)).toContain("== b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a `[^1]` reference form (no `^[`) is NOT turned into a footnote", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("see note [^1] for details");
|
||||||
|
expect(collectTypes(doc).has("footnoteReference")).toBe(false);
|
||||||
|
expect(allText(doc)).toContain("[^1]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
||||||
import { Extension } from "@tiptap/core";
|
import { Extension } from "@tiptap/core";
|
||||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
import { DOMParser, DOMSerializer, Fragment, Slice, Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { find } from "linkifyjs";
|
import { find } from "linkifyjs";
|
||||||
import {
|
import {
|
||||||
markdownToHtml,
|
|
||||||
htmlToMarkdown,
|
|
||||||
canonicalizeFootnotes,
|
canonicalizeFootnotes,
|
||||||
FOOTNOTES_LIST_NAME,
|
FOOTNOTES_LIST_NAME,
|
||||||
FOOTNOTE_REFERENCE_NAME,
|
FOOTNOTE_REFERENCE_NAME,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
|
// Markdown <-> ProseMirror conversion now lives ONLY in the canonical
|
||||||
|
// `@docmost/prosemirror-markdown` package (issue #347). The BROWSER entry uses
|
||||||
|
// the native `DOMParser` for its HTML->DOM stage (jsdom stays out of the client
|
||||||
|
// bundle) while producing the SAME nodes the server import does — so a paste of
|
||||||
|
// canonical markdown (`^[…]`, `<!--img …-->`, `> [!type]`, `$…$`, `==…==`,
|
||||||
|
// standalone comments) is recognized identically to import.
|
||||||
|
import {
|
||||||
|
markdownToProseMirror,
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
} from "@docmost/prosemirror-markdown/browser";
|
||||||
import type { Schema } from "@tiptap/pm/model";
|
import type { Schema } from "@tiptap/pm/model";
|
||||||
|
|
||||||
export const MarkdownClipboard = Extension.create({
|
export const MarkdownClipboard = Extension.create({
|
||||||
@@ -39,25 +47,24 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
classifyClipboardSelection(topLevelNodes);
|
classifyClipboardSelection(topLevelNodes);
|
||||||
if (!asMarkdown) return null;
|
if (!asMarkdown) return null;
|
||||||
|
|
||||||
const div = document.createElement("div");
|
// Convert the copied selection to Markdown through the canonical
|
||||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
// package (issue #347), the SAME serializer the server export uses,
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
// so a copied table/list matches the on-disk markdown form. The
|
||||||
|
// converter takes a ProseMirror `doc` JSON, so wrap the slice's
|
||||||
|
// top-level content in a synthetic doc.
|
||||||
|
const content = slice.content.toJSON() as any[];
|
||||||
if (wrapBareRows) {
|
if (wrapBareRows) {
|
||||||
// A partial table cell-selection serializes to bare <tr> nodes
|
// A partial table cell-selection serializes to bare `tableRow`
|
||||||
// (prosemirror-tables returns the whole `table` node only when the
|
// nodes (prosemirror-tables yields the whole `table` node only for
|
||||||
// entire table is selected). Bare <tr> would be foster-parented
|
// a full-table selection). The converter's table case expects a
|
||||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
// `table` wrapper, so wrap the bare rows in one — mirroring the old
|
||||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
// <table><tbody> wrap that the HTML->markdown step needed.
|
||||||
const table = document.createElement("table");
|
return convertProseMirrorToMarkdown({
|
||||||
const tbody = document.createElement("tbody");
|
type: "doc",
|
||||||
tbody.appendChild(fragment);
|
content: [{ type: "table", content }],
|
||||||
table.appendChild(tbody);
|
});
|
||||||
div.appendChild(table);
|
|
||||||
} else {
|
|
||||||
div.appendChild(fragment);
|
|
||||||
}
|
}
|
||||||
return htmlToMarkdown(div.innerHTML);
|
return convertProseMirrorToMarkdown({ type: "doc", content });
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) => {
|
handlePaste: (view, event, slice) => {
|
||||||
if (!event.clipboardData) {
|
if (!event.clipboardData) {
|
||||||
@@ -95,37 +102,115 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tr } = view.state;
|
const schema = this.editor.schema;
|
||||||
const { from, to } = view.state.selection;
|
// Capture the target range NOW. markdownToProseMirror RETURNS A
|
||||||
|
// PROMISE (kept async only for the Node consumers' contract; the
|
||||||
|
// conversion pipeline itself is synchronous), so the actual replace
|
||||||
|
// happens on the next microtask. No user input can interleave a
|
||||||
|
// microtask, so the state is unchanged when we dispatch — but we
|
||||||
|
// still re-read the live state before replacing and, if the doc did
|
||||||
|
// change under us, fall back to the live selection rather than the
|
||||||
|
// captured (now-stale) range.
|
||||||
|
const from = view.state.selection.from;
|
||||||
|
const to = view.state.selection.to;
|
||||||
|
const startDoc = view.state.doc;
|
||||||
|
const md = text.replace(/\n+$/, "");
|
||||||
|
|
||||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
void markdownToProseMirror(md)
|
||||||
const body = elementFromString(parsed);
|
.then((doc) => {
|
||||||
normalizeTableColumnWidths(body);
|
if (view.isDestroyed) return;
|
||||||
|
// Canonical PM-JSON -> HTML via the LIVE editor schema, then
|
||||||
|
// reuse the UNCHANGED downstream seam (normalizeTableColumnWidths
|
||||||
|
// + parseSlice + canonicalizePastedFootnotes). The JSON->HTML->
|
||||||
|
// JSON hop is lossless (same schema both directions); it lets the
|
||||||
|
// existing paste-insertion logic stay byte-identical — only the
|
||||||
|
// SOURCE of the markdown conversion changed (issue #347 guardrail:
|
||||||
|
// no converter logic in the client, only a call into the package).
|
||||||
|
const node = PMNode.fromJSON(schema, doc);
|
||||||
|
const div = document.createElement("div");
|
||||||
|
DOMSerializer.fromSchema(schema).serializeFragment(
|
||||||
|
node.content,
|
||||||
|
{ document },
|
||||||
|
div,
|
||||||
|
);
|
||||||
|
|
||||||
const parsedSlice = DOMParser.fromSchema(
|
const body = elementFromString(div.innerHTML);
|
||||||
this.editor.schema,
|
normalizeTableColumnWidths(body);
|
||||||
).parseSlice(body, {
|
|
||||||
preserveWhitespace: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A markdown paste builds its ProseMirror fragment directly (DOM ->
|
const parsedSlice = DOMParser.fromSchema(schema).parseSlice(
|
||||||
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
|
body,
|
||||||
// reorders an existing list. So a pasted markdown block whose footnote
|
{ preserveWhitespace: true },
|
||||||
// definitions are out of order (or contains orphan defs) would be
|
);
|
||||||
// stored out of order. Canonicalize the self-contained pasted block so
|
|
||||||
// its footnotes come out reference-ordered, deduped and orphan-free
|
|
||||||
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
|
|
||||||
// to whole-block pastes that carry their own footnotesList.
|
|
||||||
const contentNodes = canonicalizePastedFootnotes(
|
|
||||||
parsedSlice,
|
|
||||||
this.editor.schema,
|
|
||||||
);
|
|
||||||
|
|
||||||
tr.replaceRange(from, to, contentNodes);
|
// A markdown paste builds its ProseMirror fragment directly (DOM
|
||||||
const insertEnd = tr.mapping.map(from, 1);
|
// -> parseSlice), bypassing the editor's footnoteSyncPlugin, which
|
||||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
// never reorders an existing list. So a pasted markdown block whose
|
||||||
tr.setMeta('paste', true)
|
// footnote definitions are out of order (or contains orphan defs)
|
||||||
view.dispatch(tr);
|
// would be stored out of order. Canonicalize the self-contained
|
||||||
|
// pasted block so its footnotes come out reference-ordered, deduped
|
||||||
|
// and orphan-free (issue #228). See canonicalizePastedFootnotes for
|
||||||
|
// why this is scoped to whole-block pastes that carry their own
|
||||||
|
// footnotesList.
|
||||||
|
const contentNodes = canonicalizePastedFootnotes(
|
||||||
|
parsedSlice,
|
||||||
|
schema,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Target the captured range (normally still valid — same
|
||||||
|
// microtask). If the doc changed under us since capture, the
|
||||||
|
// captured absolute from/to are stale, so fall back to the live
|
||||||
|
// selection rather than StepMap-mapping the old range.
|
||||||
|
const tr = view.state.tr;
|
||||||
|
let mappedFrom = from;
|
||||||
|
let mappedTo = to;
|
||||||
|
if (view.state.doc !== startDoc) {
|
||||||
|
// Defensive: if the doc changed under us, fall back to the
|
||||||
|
// current selection rather than a stale absolute range.
|
||||||
|
mappedFrom = view.state.selection.from;
|
||||||
|
mappedTo = view.state.selection.to;
|
||||||
|
}
|
||||||
|
tr.replaceRange(mappedFrom, mappedTo, contentNodes);
|
||||||
|
const insertEnd = tr.mapping.map(mappedFrom, 1);
|
||||||
|
tr.setSelection(
|
||||||
|
TextSelection.near(
|
||||||
|
tr.doc.resolve(Math.max(mappedFrom, insertEnd - 2)),
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tr.setMeta("paste", true);
|
||||||
|
view.dispatch(tr);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Fail-open: a conversion error must not swallow the paste
|
||||||
|
// silently in a way that loses the text. We already claimed the
|
||||||
|
// event (returned true), so re-insert the raw text as a plain
|
||||||
|
// paragraph so the user never loses their clipboard content.
|
||||||
|
// Log it: this catch covers BOTH the converter and the success
|
||||||
|
// `.then` body (e.g. PMNode.fromJSON throwing on a schema drift
|
||||||
|
// between the canonical package and the live editor schema), so a
|
||||||
|
// silent degrade to raw text would otherwise be an invisible,
|
||||||
|
// non-reproducible regression ("my table pasted as text").
|
||||||
|
console.error(
|
||||||
|
"markdown paste conversion failed, inserting raw text",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
if (view.isDestroyed) return;
|
||||||
|
const tr = view.state.tr;
|
||||||
|
// Same guard the success path uses: if the doc changed under us
|
||||||
|
// since the range was captured (normally never — same microtask),
|
||||||
|
// the captured absolute from/to are stale and would throw a
|
||||||
|
// RangeError here (an unhandled rejection on a hot paste path).
|
||||||
|
// Fall back to the live selection instead of a stale range.
|
||||||
|
if (view.state.doc !== startDoc) {
|
||||||
|
const sel = view.state.selection;
|
||||||
|
tr.insertText(md, sel.from, sel.to);
|
||||||
|
} else {
|
||||||
|
tr.insertText(md, from, to);
|
||||||
|
}
|
||||||
|
tr.setMeta("paste", true);
|
||||||
|
view.dispatch(tr);
|
||||||
|
});
|
||||||
|
// Claim the paste: we insert asynchronously above.
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
// Strip trailing whitespace-only paragraphs from pasted content.
|
// Strip trailing whitespace-only paragraphs from pasted content.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Italic } from "@tiptap/extension-italic";
|
|||||||
import { Link } from "@tiptap/extension-link";
|
import { Link } from "@tiptap/extension-link";
|
||||||
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
const ZWSP = ""; // U+200B — asserted ABSENT (the block-escape lives in the serializer now)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #377 — the web-side bridge must append the native host's transcript below the
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
@@ -18,8 +18,9 @@ const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
|||||||
* regression would be caught), asserting the resulting document rather than
|
* regression would be caught), asserting the resulting document rather than
|
||||||
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
* parsing); col-0 markdown block triggers are stored verbatim (the git-sync
|
||||||
* paragraphs; absent/empty/non-string -> no-op.
|
* serializer block-escapes them, so no client-side ZWSP is needed);
|
||||||
|
* absent/empty/non-string -> no-op.
|
||||||
*/
|
*/
|
||||||
describe("gitmostInsertTranscriptIntoEditor", () => {
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
const makeEditor = () =>
|
const makeEditor = () =>
|
||||||
@@ -91,19 +92,22 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
|||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
it("inserts col-0 markdown block triggers as verbatim paragraph text (no ZWSP workaround)", () => {
|
||||||
const editor = makeEditor();
|
const editor = makeEditor();
|
||||||
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
// Trigger lines (some with a leaked indent) + a normal prefixed line. The
|
||||||
|
// git-sync serializer now block-escapes a leading trigger itself, so the
|
||||||
|
// bridge inserts each line's TEXT byte-exact (only the leaked indent is
|
||||||
|
// trimmed) — no invisible ZWSP is prepended anymore.
|
||||||
const inserted = gitmostInsertTranscriptIntoEditor(
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
editor,
|
editor,
|
||||||
[
|
[
|
||||||
"- dash",
|
"- dash",
|
||||||
" > quote", // leading indent must be trimmed then neutralized
|
" > quote", // leading indent is trimmed, text otherwise verbatim
|
||||||
"# hash",
|
"# hash",
|
||||||
"1. one",
|
"1. one",
|
||||||
"> [!info] note",
|
"> [!info] note",
|
||||||
"```js",
|
"```js",
|
||||||
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
"---",
|
||||||
"***",
|
"***",
|
||||||
"___",
|
"___",
|
||||||
"You: normal line",
|
"You: normal line",
|
||||||
@@ -116,20 +120,23 @@ describe("gitmostInsertTranscriptIntoEditor", () => {
|
|||||||
.map((n: any) => n.content?.[0]?.text)
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
.filter((t: any) => typeof t === "string") as string[];
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
// Each trigger line is stored as its own byte-exact text (indent trimmed);
|
||||||
// trimmed first); the normal `You:` line is left byte-exact.
|
// the git-sync round-trip keeps it a paragraph via the serializer's
|
||||||
|
// block-escape, so no ZWSP is needed here.
|
||||||
expect(texts).toEqual([
|
expect(texts).toEqual([
|
||||||
ZWSP + "- dash",
|
"- dash",
|
||||||
ZWSP + "> quote",
|
"> quote",
|
||||||
ZWSP + "# hash",
|
"# hash",
|
||||||
ZWSP + "1. one",
|
"1. one",
|
||||||
ZWSP + "> [!info] note",
|
"> [!info] note",
|
||||||
ZWSP + "```js",
|
"```js",
|
||||||
ZWSP + "---",
|
"---",
|
||||||
ZWSP + "***",
|
"***",
|
||||||
ZWSP + "___",
|
"___",
|
||||||
"You: normal line",
|
"You: normal line",
|
||||||
]);
|
]);
|
||||||
|
// Guard: no invisible ZWSP leaked into any inserted line.
|
||||||
|
for (const t of texts) expect(t).not.toContain(ZWSP);
|
||||||
|
|
||||||
editor.destroy();
|
editor.destroy();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -240,45 +240,22 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
|
||||||
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
|
||||||
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
|
||||||
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
|
||||||
const GITMOST_ZWSP = "";
|
|
||||||
|
|
||||||
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
|
||||||
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
|
||||||
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
|
||||||
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
|
||||||
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
|
||||||
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
|
||||||
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
|
||||||
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
|
||||||
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
|
||||||
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
|
||||||
// that node carries NO text, an un-neutralized separator line would LOSE its
|
|
||||||
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
|
||||||
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
|
||||||
// and never matches, so prefixed lines are left byte-exact.
|
|
||||||
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
|
||||||
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
|
||||||
|
|
||||||
// Append a transcript block BELOW the recording's audio node in a live editor:
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
// line. The transcript is plain text, `\n`-separated, each line already
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
// leak into the display). A line that begins with a col-0 markdown block
|
||||||
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
// trigger (`#`/`-`/`>`/`1.`/fence/`---`/…) needs no client-side workaround: the
|
||||||
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
// git-sync serializer (packages/prosemirror-markdown, `case "paragraph"`) now
|
||||||
// list/quote/heading/callout/code/table (defensive boundary against the
|
// block-escapes such a leading trigger, so the doc->markdown->doc round-trip
|
||||||
// serializer's missing block-escape). This is best-effort and meant to run
|
// keeps the line a paragraph on its own — the former invisible-ZWSP defense is
|
||||||
// AFTER the audio has already been inserted; the caller must guard against a
|
// gone. This is best-effort and meant to run AFTER the audio has already been
|
||||||
// throw so a transcript failure never fails the (already successful) recording.
|
// inserted; the caller must guard against a throw so a transcript failure never
|
||||||
// Returns true when a block was inserted, false when there was nothing to
|
// fails the (already successful) recording. Returns true when a block was
|
||||||
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
// inserted, false when there was nothing to insert (transcript
|
||||||
// no-op, not an error.
|
// undefined/empty/not-a-string). A non-string value is a no-op, not an error.
|
||||||
export function gitmostInsertTranscriptIntoEditor(
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
editor: Editor,
|
editor: Editor,
|
||||||
transcript: unknown,
|
transcript: unknown,
|
||||||
@@ -288,13 +265,7 @@ export function gitmostInsertTranscriptIntoEditor(
|
|||||||
.split("\n")
|
.split("\n")
|
||||||
// Trim each line and drop blank (whitespace-only) ones.
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter((line) => line.length > 0);
|
||||||
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
|
||||||
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
|
||||||
// `Speaker N:`) never match and stay byte-exact.
|
|
||||||
.map((line) =>
|
|
||||||
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
|
||||||
);
|
|
||||||
if (lines.length === 0) return false;
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
const content = [
|
const content = [
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ vi.mock("@/lib/local-emitter.ts", () => ({
|
|||||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
// convertProseMirrorToMarkdown echoes a marker carried on the fake editor's
|
||||||
// purely via the fake page editor's getHTML().
|
// getJSON() doc, so each test controls the markdown purely via the fake page
|
||||||
vi.mock("@docmost/editor-ext", () => ({
|
// editor (issue #347: the hook now serializes editor JSON through the package).
|
||||||
htmlToMarkdown: (html: string) => html,
|
vi.mock("@docmost/prosemirror-markdown/browser", () => ({
|
||||||
|
convertProseMirrorToMarkdown: (doc: { __md?: string }) => doc?.__md ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const notificationsShowMock = vi.fn();
|
const notificationsShowMock = vi.fn();
|
||||||
@@ -53,10 +54,12 @@ import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
|||||||
|
|
||||||
// --- Test helpers -------------------------------------------------------------
|
// --- Test helpers -------------------------------------------------------------
|
||||||
|
|
||||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
function makePageEditor(pageId: string, md = "content"): Editor {
|
||||||
return {
|
return {
|
||||||
isDestroyed: false,
|
isDestroyed: false,
|
||||||
getHTML: () => html,
|
// The mocked convertProseMirrorToMarkdown reads `__md` back off this doc,
|
||||||
|
// so `md` is exactly the markdown the hook will send to the title service.
|
||||||
|
getJSON: () => ({ type: "doc", __md: md }),
|
||||||
storage: { pageId },
|
storage: { pageId },
|
||||||
} as unknown as Editor;
|
} as unknown as Editor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
@@ -49,7 +49,9 @@ export function useGeneratePageTitle(pageId: string) {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||||
|
|
||||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
// Serialize the live editor content to markdown through the canonical
|
||||||
|
// converter (issue #347), matching the on-disk/export markdown form.
|
||||||
|
const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON()).trim();
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
|
|||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -199,8 +199,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
|
|
||||||
const handleCopyAsMarkdown = () => {
|
const handleCopyAsMarkdown = () => {
|
||||||
if (!pageEditor) return;
|
if (!pageEditor) return;
|
||||||
const html = pageEditor.getHTML();
|
// Copy the page as canonical markdown through the shared converter (issue
|
||||||
const markdown = htmlToMarkdown(html);
|
// #347), so "Copy as markdown" matches the server export byte-for-byte.
|
||||||
|
const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON());
|
||||||
const title = page?.title ? `# ${page.title}\n\n` : "";
|
const title = page?.title ? `# ${page.title}\n\n` : "";
|
||||||
clipboard.copy(`${title}${markdown}`);
|
clipboard.copy(`${title}${markdown}`);
|
||||||
notifications.show({ message: t("Copied") });
|
notifications.show({ message: t("Copied") });
|
||||||
|
|||||||
@@ -665,6 +665,13 @@ export function updateCacheOnMovePage(
|
|||||||
pageData: Partial<IPage>,
|
pageData: Partial<IPage>,
|
||||||
) {
|
) {
|
||||||
invalidatePageTree();
|
invalidatePageTree();
|
||||||
|
// Invalidate the moved page's breadcrumbs (#523). The tree-side child-loss
|
||||||
|
// guard removes the moved node from the local tree when its new parent is an
|
||||||
|
// unloaded branch, so `findBreadcrumbPath` misses it and the breadcrumb bar
|
||||||
|
// falls back to the server `["breadcrumbs", pageId]` query — which this move
|
||||||
|
// must invalidate, otherwise the crumbs keep showing the OLD parent until a
|
||||||
|
// refocus/navigation.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["breadcrumbs", pageId] });
|
||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import type { IPage } from "@/features/page/types/page.types";
|
||||||
|
|
||||||
|
// A fresh QueryClient stands in for the app singleton (importing the real
|
||||||
|
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom).
|
||||||
|
vi.mock("@/main.tsx", async () => {
|
||||||
|
const { QueryClient } = await import("@tanstack/react-query");
|
||||||
|
return { queryClient: new QueryClient() };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { updateCacheOnMovePage } from "./page-query";
|
||||||
|
|
||||||
|
// #523: the tree-side child-loss guard removes the moved node from the local
|
||||||
|
// tree when its new parent is an unloaded branch, so `findBreadcrumbPath` misses
|
||||||
|
// it and the breadcrumb bar falls back to the server `["breadcrumbs", pageId]`
|
||||||
|
// query. That query MUST be invalidated by a move, or the crumbs keep showing
|
||||||
|
// the OLD parent until a refocus/navigation.
|
||||||
|
describe("updateCacheOnMovePage — breadcrumbs invalidation (#523)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates the moved page's ['breadcrumbs', pageId] query", () => {
|
||||||
|
const spy = vi.spyOn(queryClient, "invalidateQueries");
|
||||||
|
|
||||||
|
updateCacheOnMovePage("s1", "moved-page", "old-parent", "new-parent", {
|
||||||
|
id: "moved-page",
|
||||||
|
} as Partial<IPage>);
|
||||||
|
|
||||||
|
const invalidatedBreadcrumbs = spy.mock.calls.some(
|
||||||
|
([arg]) =>
|
||||||
|
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
|
||||||
|
(arg as { queryKey: unknown[] }).queryKey[0] === "breadcrumbs" &&
|
||||||
|
(arg as { queryKey: unknown[] }).queryKey[1] === "moved-page",
|
||||||
|
);
|
||||||
|
expect(invalidatedBreadcrumbs).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -55,7 +55,14 @@ type Props<T extends object> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DRAG_TYPE = 'doc-tree-item';
|
const DRAG_TYPE = 'doc-tree-item';
|
||||||
const AUTO_EXPAND_MS = 500;
|
// Hover-hold before a collapsed row auto-expands during a drag. 2s (not ~0.5s)
|
||||||
|
// so merely dragging the cursor THROUGH the tree never expands rows — only a
|
||||||
|
// deliberate hold does (#523).
|
||||||
|
const AUTO_EXPAND_MS = 2000;
|
||||||
|
// How long the "a page just moved in here" cue stays on a collapsed target after
|
||||||
|
// a make-child drop. Long enough to notice at a glance during frequent
|
||||||
|
// collapsed-drops, short enough not to linger. Code-only UX constant (#523).
|
||||||
|
const DROP_LANDED_HIGHLIGHT_MS = 1800;
|
||||||
|
|
||||||
function DocTreeRowInner<T extends object>(props: Props<T>) {
|
function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||||
const {
|
const {
|
||||||
@@ -93,7 +100,11 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
const rowRef = useRef<HTMLElement>(null);
|
const rowRef = useRef<HTMLElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [instruction, setInstruction] = useState<Instruction | null>(null);
|
const [instruction, setInstruction] = useState<Instruction | null>(null);
|
||||||
|
// Transient "just received a child" cue: a make-child drop no longer expands
|
||||||
|
// the (collapsed) target, so flash the row instead so the move isn't invisible.
|
||||||
|
const [landedChild, setLandedChild] = useState(false);
|
||||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const landedChildTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const cancelAutoExpand = useCallback(() => {
|
const cancelAutoExpand = useCallback(() => {
|
||||||
if (autoExpandTimerRef.current) {
|
if (autoExpandTimerRef.current) {
|
||||||
@@ -249,11 +260,24 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
? getDragLabel(sourceNode)
|
? getDragLabel(sourceNode)
|
||||||
: 'item';
|
: 'item';
|
||||||
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
|
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
|
||||||
// After a make-child drop, expand this row so the user sees the
|
// Do NOT auto-expand the target on drop: a drop must leave the node
|
||||||
// just-dropped child — especially important when the row had no
|
// collapsed. Intentional expansion is handled solely by the
|
||||||
// children before (chevron just appeared) so the drop would
|
// hover-hold timer (AUTO_EXPAND_MS). Feedback that the drop landed is
|
||||||
// otherwise be invisible.
|
// given by the post-move flash + landed-cue highlight + live-region
|
||||||
if (op.kind === 'make-child') onToggle(node.id, true);
|
// announce above. When the make-child target is collapsed, flash a
|
||||||
|
// distinct "child moved in here" cue on the row (it stays collapsed).
|
||||||
|
if (op.kind === 'make-child' && !isOpen) {
|
||||||
|
if (landedChildTimerRef.current) {
|
||||||
|
clearTimeout(landedChildTimerRef.current);
|
||||||
|
}
|
||||||
|
setLandedChild(true);
|
||||||
|
landedChildTimerRef.current = setTimeout(() => {
|
||||||
|
setLandedChild(false);
|
||||||
|
landedChildTimerRef.current = null;
|
||||||
|
}, DROP_LANDED_HIGHLIGHT_MS);
|
||||||
|
}
|
||||||
|
// Restore the openness of the MOVED page itself (source) — untouched
|
||||||
|
// by the above; the target is never expanded here.
|
||||||
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
|
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -281,6 +305,17 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
|
|
||||||
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
|
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
|
||||||
|
|
||||||
|
// Clear the landed-child cue timer on unmount (mirrors autoExpandTimerRef).
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (landedChildTimerRef.current) {
|
||||||
|
clearTimeout(landedChildTimerRef.current);
|
||||||
|
landedChildTimerRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const effectiveInst =
|
const effectiveInst =
|
||||||
instruction?.type === 'instruction-blocked'
|
instruction?.type === 'instruction-blocked'
|
||||||
? instruction.desired
|
? instruction.desired
|
||||||
@@ -317,6 +352,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
|||||||
className={styles.node}
|
className={styles.node}
|
||||||
data-dragging={isDragging || undefined}
|
data-dragging={isDragging || undefined}
|
||||||
data-selected={isSelected || undefined}
|
data-selected={isSelected || undefined}
|
||||||
|
data-landed-child={landedChild || undefined}
|
||||||
data-receiving-drop={
|
data-receiving-drop={
|
||||||
receivingDrop === 'make-child'
|
receivingDrop === 'make-child'
|
||||||
? blocked
|
? blocked
|
||||||
|
|||||||
@@ -281,10 +281,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
setOpenTreeNodes((prev) => ({ ...prev, [id]: isOpen }));
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
const node = treeModel.find(data, id) as SpaceTreeNode | null;
|
||||||
if (
|
// Same "unloaded branch" predicate the realtime insert paths use
|
||||||
node?.hasChildren &&
|
// (`isUnloadedBranch`) so the lazy-load gate and the realtime inserts
|
||||||
(!node.children || node.children.length === 0)
|
// (`insertByPosition` / `placeByPosition`) can never disagree about what
|
||||||
) {
|
// counts as unloaded (#525). Note: local raw `insert` (DnD/create-page)
|
||||||
|
// does not yet route through it — see #525 follow-up.
|
||||||
|
if (treeModel.isUnloadedBranch(node)) {
|
||||||
const fetched = await fetchAllAncestorChildren({
|
const fetched = await fetchAllAncestorChildren({
|
||||||
pageId: id,
|
pageId: id,
|
||||||
spaceId: node.spaceId,
|
spaceId: node.spaceId,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
|
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
|
|
||||||
|
// --- Boundary mocks: only the network/query + router/i18n surfaces the hook
|
||||||
|
// touches. The tree math (treeModel, dropOpToMovePayload) runs for real so the
|
||||||
|
// child-loss guard is exercised end-to-end.
|
||||||
|
const moveMutate = vi.fn().mockResolvedValue({});
|
||||||
|
const updateCacheOnMovePageMock = vi.fn();
|
||||||
|
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||||
|
useCreatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useUpdatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useRemovePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useMovePageMutation: () => ({ mutateAsync: moveMutate }),
|
||||||
|
updateCacheOnMovePage: (...args: unknown[]) =>
|
||||||
|
updateCacheOnMovePageMock(...args),
|
||||||
|
}));
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => vi.fn(),
|
||||||
|
useParams: () => ({ spaceSlug: "space", pageSlug: undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (s: string) => s }),
|
||||||
|
}));
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Import AFTER mocks so the hook binds to them.
|
||||||
|
import { useTreeMutation } from "./use-tree-mutation";
|
||||||
|
|
||||||
|
function node(
|
||||||
|
id: string,
|
||||||
|
over: Partial<SpaceTreeNode> = {},
|
||||||
|
): SpaceTreeNode {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
slugId: `slug-${id}`,
|
||||||
|
name: id.toUpperCase(),
|
||||||
|
position: "a0",
|
||||||
|
spaceId: "space-1",
|
||||||
|
parentPageId: null as unknown as string,
|
||||||
|
hasChildren: false,
|
||||||
|
children: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(before: SpaceTreeNode[]) {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(treeDataAtom, before);
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<Provider store={store}>{children}</Provider>
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useTreeMutation("space-1"), { wrapper });
|
||||||
|
return { store, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useTreeMutation.handleMove — child-loss guard (#523)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
moveMutate.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("make-child into an UNLOADED folder does NOT materialize [source] — keeps it lazy-loadable", async () => {
|
||||||
|
// F has children on the server but none are loaded here (canonical unloaded
|
||||||
|
// form: hasChildren + children:[]). X sits at root.
|
||||||
|
const before = [
|
||||||
|
node("F", { position: "a0", hasChildren: true, children: [] }),
|
||||||
|
node("X", { position: "a5" }),
|
||||||
|
];
|
||||||
|
const { store, result } = setup(before);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleMove("X", {
|
||||||
|
kind: "make-child",
|
||||||
|
targetId: "F",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = store.get(treeDataAtom);
|
||||||
|
const f = treeModel.find(tree, "F");
|
||||||
|
// The guard leaves F unloaded (children stay []), so a later expand fetches
|
||||||
|
// the FULL server set (incl. X) instead of showing a misleading partial [X].
|
||||||
|
// MUTATION: dropping the guard (using the `move` result) would put children
|
||||||
|
// === [X] here and redden this.
|
||||||
|
expect(f?.children).toEqual([]);
|
||||||
|
expect(f?.hasChildren).toBe(true);
|
||||||
|
// X is removed from its old (root) slot; it reappears on expand/load of F.
|
||||||
|
expect(treeModel.find(tree, "X")).toBeNull();
|
||||||
|
// The server move is still persisted.
|
||||||
|
expect(moveMutate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("make-child into a LOADED folder appends source and KEEPS the existing children", async () => {
|
||||||
|
// F is loaded with one child c1; moving X in must preserve c1 (no loss) and
|
||||||
|
// append X — this path does NOT hit the guard.
|
||||||
|
const before = [
|
||||||
|
node("F", {
|
||||||
|
position: "a0",
|
||||||
|
hasChildren: true,
|
||||||
|
children: [node("c1", { position: "a1", parentPageId: "F" })],
|
||||||
|
}),
|
||||||
|
node("X", { position: "a5" }),
|
||||||
|
];
|
||||||
|
const { store, result } = setup(before);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleMove("X", {
|
||||||
|
kind: "make-child",
|
||||||
|
targetId: "F",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = store.get(treeDataAtom);
|
||||||
|
const f = treeModel.find(tree, "F");
|
||||||
|
expect(f?.children?.map((n) => n.id)).toEqual(["c1", "X"]);
|
||||||
|
expect(f?.hasChildren).toBe(true);
|
||||||
|
// X now lives under F.
|
||||||
|
expect(treeModel.find(tree, "X")?.parentPageId).toBe("F");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("make-child into a genuinely-empty leaf materializes the child (nothing to lose)", async () => {
|
||||||
|
// Leaf L has no server children (hasChildren:false). Dropping X in should
|
||||||
|
// show X immediately — the guard must NOT fire here.
|
||||||
|
const before = [
|
||||||
|
node("L", { position: "a0", hasChildren: false, children: [] }),
|
||||||
|
node("X", { position: "a5" }),
|
||||||
|
];
|
||||||
|
const { store, result } = setup(before);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleMove("X", {
|
||||||
|
kind: "make-child",
|
||||||
|
targetId: "L",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const tree = store.get(treeDataAtom);
|
||||||
|
const l = treeModel.find(tree, "L");
|
||||||
|
expect(l?.children?.map((n) => n.id)).toEqual(["X"]);
|
||||||
|
expect(l?.hasChildren).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,11 +61,48 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
if (!source) return;
|
if (!source) return;
|
||||||
const oldParentId = source.parentPageId ?? null;
|
const oldParentId = source.parentPageId ?? null;
|
||||||
|
|
||||||
// optimistic apply with the new position from the payload
|
// Child-loss guard (#523, twin of #525's realtime `insertByPosition` fix).
|
||||||
let optimistic = treeModel.update(after, sourceId, {
|
// We no longer auto-expand the make-child target on drop, so the old
|
||||||
position: payload.position,
|
// `onToggle(target, true)` — which was ALSO the only trigger of the
|
||||||
parentPageId: payload.parentPageId,
|
// corrective lazy-load — is gone. `treeModel.move` materialized
|
||||||
} as Partial<SpaceTreeNode>);
|
// `target.children = [source]` (only the moved node); if the target is an
|
||||||
|
// UNLOADED branch (server has children but none are loaded here), keeping
|
||||||
|
// that partial `[source]` list would defeat the lazy-load gate and hide the
|
||||||
|
// target's OTHER server children (the #159 #1 data-loss class). So for an
|
||||||
|
// unloaded make-child target, build the optimistic tree WITHOUT
|
||||||
|
// materializing source under it: just remove source from its old parent and
|
||||||
|
// flag the target `hasChildren`. The gate stays armed and a later manual
|
||||||
|
// expand fetches the FULL set (incl. the moved page, which the awaited
|
||||||
|
// server move persists). Predicate is the gate's (`isUnloadedBranch`), NOT
|
||||||
|
// `insertByPosition`'s old `=== undefined` (canonical unloaded is `[]`).
|
||||||
|
const target =
|
||||||
|
op.kind === "make-child"
|
||||||
|
? (treeModel.find(before, op.targetId) as SpaceTreeNode | null)
|
||||||
|
: null;
|
||||||
|
const unloadedMakeChild =
|
||||||
|
op.kind === "make-child" && treeModel.isUnloadedBranch(target);
|
||||||
|
|
||||||
|
let optimistic: SpaceTreeNode[];
|
||||||
|
if (unloadedMakeChild) {
|
||||||
|
// Do NOT materialize [source] into the unloaded target.
|
||||||
|
optimistic = treeModel.remove(before, sourceId);
|
||||||
|
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||||
|
hasChildren: true,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
} else {
|
||||||
|
// optimistic apply with the new position from the payload
|
||||||
|
optimistic = treeModel.update(after, sourceId, {
|
||||||
|
position: payload.position,
|
||||||
|
parentPageId: payload.parentPageId,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
// For make-child onto a previously-childless (loaded) target: flip
|
||||||
|
// hasChildren on so the new parent shows its chevron.
|
||||||
|
if (op.kind === "make-child") {
|
||||||
|
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||||
|
hasChildren: true,
|
||||||
|
} as Partial<SpaceTreeNode>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the old parent has no children left, mark hasChildren: false so the
|
// If the old parent has no children left, mark hasChildren: false so the
|
||||||
// chevron disappears. Without this, the empty parent keeps rendering an
|
// chevron disappears. Without this, the empty parent keeps rendering an
|
||||||
@@ -79,14 +116,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For make-child onto a previously-childless target: flip hasChildren on
|
|
||||||
// so the new parent shows its chevron.
|
|
||||||
if (op.kind === "make-child") {
|
|
||||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
|
||||||
hasChildren: true,
|
|
||||||
} as Partial<SpaceTreeNode>);
|
|
||||||
}
|
|
||||||
|
|
||||||
setData(optimistic);
|
setData(optimistic);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -74,6 +74,48 @@ describe("treeModel.isDescendant", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #525: the single "is this branch unloaded?" predicate shared by the lazy-load
|
||||||
|
// gate and the insert paths. Unloaded == server says hasChildren but none are
|
||||||
|
// present locally (canonical form `children: []`, also `undefined`). A parent
|
||||||
|
// without hasChildren is genuinely empty, not unloaded.
|
||||||
|
describe("treeModel.isUnloadedBranch", () => {
|
||||||
|
type PH = TreeNode<{ name: string; hasChildren?: boolean }>;
|
||||||
|
it("true for hasChildren + empty array (canonical unloaded form)", () => {
|
||||||
|
const n: PH = { id: "p", name: "P", hasChildren: true, children: [] };
|
||||||
|
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||||
|
});
|
||||||
|
it("true for hasChildren + undefined children", () => {
|
||||||
|
const n: PH = { id: "p", name: "P", hasChildren: true };
|
||||||
|
expect(treeModel.isUnloadedBranch(n)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false for hasChildren + already-loaded children", () => {
|
||||||
|
const n: PH = {
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
hasChildren: true,
|
||||||
|
children: [{ id: "c", name: "C" }],
|
||||||
|
};
|
||||||
|
expect(treeModel.isUnloadedBranch(n)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a genuinely-empty parent (no hasChildren)", () => {
|
||||||
|
expect(
|
||||||
|
treeModel.isUnloadedBranch({
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
hasChildren: false,
|
||||||
|
children: [],
|
||||||
|
} as PH),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
treeModel.isUnloadedBranch({ id: "p", name: "P" } as PH),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for null/undefined", () => {
|
||||||
|
expect(treeModel.isUnloadedBranch(null)).toBe(false);
|
||||||
|
expect(treeModel.isUnloadedBranch(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("treeModel.visible", () => {
|
describe("treeModel.visible", () => {
|
||||||
it("returns only root nodes when no openIds", () => {
|
it("returns only root nodes when no openIds", () => {
|
||||||
const v = treeModel.visible(fixture, new Set());
|
const v = treeModel.visible(fixture, new Set());
|
||||||
@@ -197,43 +239,64 @@ describe("treeModel.insertByPosition", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #159 #1: inserting/moving a node under a parent whose children are NOT
|
type PH = TreeNode<{
|
||||||
// loaded (`children === undefined`, e.g. a collapsed page) must NOT materialize
|
name: string;
|
||||||
// a partial `[node]` list — that would defeat the lazy-load gate and hide the
|
position?: string;
|
||||||
// parent's other real children. The node is left to be lazy-loaded; only
|
hasChildren?: boolean;
|
||||||
// `hasChildren` is flagged so the chevron appears.
|
}>;
|
||||||
it("does NOT materialize a child under an UNLOADED parent (children undefined)", () => {
|
|
||||||
type PH = TreeNode<{
|
// #159 #1 / #525: inserting/moving a node under an UNLOADED parent must NOT
|
||||||
name: string;
|
// materialize a partial `[node]` list — that would defeat the lazy-load gate and
|
||||||
position?: string;
|
// hide the parent's other real children. The canonical unloaded form here is
|
||||||
hasChildren?: boolean;
|
// `children: []` + `hasChildren: true` (from `pageToTreeNode` /
|
||||||
}>;
|
// `pruneCollapsedChildren`), which the pre-#525 `=== undefined` guard MISSED.
|
||||||
|
// The node is left to be lazy-loaded; the chevron stays enabled.
|
||||||
|
it("does NOT materialize a child under an UNLOADED parent (children: [], hasChildren: true)", () => {
|
||||||
const tree: PH[] = [
|
const tree: PH[] = [
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
{ id: "p", name: "P", position: "a0", hasChildren: true, children: [] },
|
||||||
];
|
];
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
const parent = treeModel.find(t, "p");
|
const parent = treeModel.find(t, "p");
|
||||||
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
// The node was NOT inserted (children stay unloaded -> lazy-load fetches the
|
||||||
// full set, including this node, on expand).
|
// full set, including this node, on expand). MUTATION: the pre-#525 predicate
|
||||||
expect(parent?.children).toBeUndefined();
|
// `children === undefined` does not fire for `[]`, so it would insert `[x]`
|
||||||
|
// here and reredden this expectation.
|
||||||
|
expect(parent?.children).toEqual([]);
|
||||||
expect(treeModel.find(t, "x")).toBeNull();
|
expect(treeModel.find(t, "x")).toBeNull();
|
||||||
// ...but the chevron is enabled so the user can expand to load it.
|
// ...and the chevron stays enabled so the user can expand to load it.
|
||||||
expect((parent as PH).hasChildren).toBe(true);
|
expect((parent as PH).hasChildren).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("DOES insert under a LOADED-but-empty parent (children: [])", () => {
|
it("does NOT materialize a child under an UNLOADED parent (children undefined, hasChildren: true)", () => {
|
||||||
type PH = TreeNode<{
|
const tree: PH[] = [
|
||||||
name: string;
|
{ id: "p", name: "P", position: "a0", hasChildren: true }, // children: undefined
|
||||||
position?: string;
|
];
|
||||||
hasChildren?: boolean;
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
}>;
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
|
const parent = treeModel.find(t, "p");
|
||||||
|
expect(parent?.children).toBeUndefined();
|
||||||
|
expect(treeModel.find(t, "x")).toBeNull();
|
||||||
|
expect((parent as PH).hasChildren).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOES insert under a genuinely-empty parent (children: [], hasChildren: false)", () => {
|
||||||
const tree: PH[] = [
|
const tree: PH[] = [
|
||||||
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
{ id: "p", name: "P", position: "a0", hasChildren: false, children: [] },
|
||||||
];
|
];
|
||||||
const node: PH = { id: "x", name: "X", position: "a1" };
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
const t = treeModel.insertByPosition(tree, "p", node);
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
// A loaded (empty) child list is complete, so the node IS inserted.
|
// No server children (`hasChildren: false`), so materializing the first child
|
||||||
|
// is correct — nothing is hidden.
|
||||||
|
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DOES insert under a genuinely-empty parent (children undefined, hasChildren: false)", () => {
|
||||||
|
const tree: PH[] = [
|
||||||
|
{ id: "p", name: "P", position: "a0", hasChildren: false }, // children: undefined
|
||||||
|
];
|
||||||
|
const node: PH = { id: "x", name: "X", position: "a1" };
|
||||||
|
const t = treeModel.insertByPosition(tree, "p", node);
|
||||||
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
expect(treeModel.find(t, "p")?.children?.map((n) => n.id)).toEqual(["x"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,26 @@ export const treeModel = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// A branch is "unloaded" when the server says it HAS children (`hasChildren`)
|
||||||
|
// but none are present locally. The canonical unloaded form in this codebase
|
||||||
|
// is `children: []` (produced by `pageToTreeNode` and by `pruneCollapsedChildren`
|
||||||
|
// resetting collapsed branches), NOT `children: undefined` — so a predicate that
|
||||||
|
// only checks `=== undefined` misses the real case and materializes a misleading
|
||||||
|
// partial list (#525). This is the SINGLE source of truth for "should a
|
||||||
|
// fetch/materialize be deferred?", shared by the lazy-load gate (`handleToggle`)
|
||||||
|
// and the realtime insert paths (`insertByPosition` / `placeByPosition`), so they
|
||||||
|
// can never drift apart again. (The local raw `insert` primitive and its DnD/
|
||||||
|
// create-page callers do NOT yet route through this predicate — see #525
|
||||||
|
// follow-up.) A parent WITHOUT `hasChildren` is genuinely empty
|
||||||
|
// (no server children) — inserting its first child is correct, not deferred.
|
||||||
|
isUnloadedBranch<T extends object>(
|
||||||
|
node: TreeNode<T> | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!node) return false;
|
||||||
|
const hasChildren = (node as { hasChildren?: boolean }).hasChildren === true;
|
||||||
|
return hasChildren && (node.children == null || node.children.length === 0);
|
||||||
|
},
|
||||||
|
|
||||||
isDescendant<T extends object>(
|
isDescendant<T extends object>(
|
||||||
tree: TreeNode<T>[],
|
tree: TreeNode<T>[],
|
||||||
ancestorId: string,
|
ancestorId: string,
|
||||||
@@ -127,14 +147,15 @@ export const treeModel = {
|
|||||||
}
|
}
|
||||||
const parent = treeModel.find(tree, parentId);
|
const parent = treeModel.find(tree, parentId);
|
||||||
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
// The parent is in the tree but its children have NOT been lazy-loaded yet
|
||||||
// (`children === undefined`, distinct from a loaded-but-empty `[]`). Inserting
|
// (`hasChildren` set + children absent/empty — see `isUnloadedBranch`; the
|
||||||
|
// canonical unloaded form is `children: []`, NOT just `undefined`). Inserting
|
||||||
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
// here would MATERIALIZE a misleading partial child list (`[node]`) that
|
||||||
// defeats the lazy-load gate — which fetches only when children are
|
// defeats the lazy-load gate — which fetches only when children are
|
||||||
// absent/empty — so the parent's OTHER real children would never load and the
|
// absent/empty — so the parent's OTHER real children would never load and the
|
||||||
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
// moved/added node would be the only one shown (a silent data loss, #159 #1).
|
||||||
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
// Instead, leave the children unloaded and just flag `hasChildren` so the
|
||||||
// chevron appears; expanding fetches the FULL set (including this node).
|
// chevron appears; expanding fetches the FULL set (including this node).
|
||||||
if (parent && parent.children === undefined) {
|
if (parent && treeModel.isUnloadedBranch(parent)) {
|
||||||
return treeModel.update(
|
return treeModel.update(
|
||||||
tree,
|
tree,
|
||||||
parentId,
|
parentId,
|
||||||
|
|||||||
@@ -150,6 +150,45 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
|
||||||
|
the collapsed target, so the moved page is momentarily invisible; pulse the
|
||||||
|
target row in a distinct teal (NOT the blue make-child highlight, NOT the
|
||||||
|
neutral post-move flash) so the landing is noticeable while the node stays
|
||||||
|
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
|
||||||
|
which the row clears the attribute and the animation stops. */
|
||||||
|
@keyframes landedChildPulse {
|
||||||
|
0% {
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-teal-2),
|
||||||
|
rgba(45, 212, 191, 0.30)
|
||||||
|
);
|
||||||
|
outline-color: light-dark(
|
||||||
|
var(--mantine-color-teal-6),
|
||||||
|
var(--mantine-color-teal-5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
outline-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.node[data-landed-child="true"] {
|
||||||
|
outline: 2px solid transparent;
|
||||||
|
outline-offset: -1px;
|
||||||
|
animation: landedChildPulse 0.9s ease-out 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.node[data-landed-child="true"] {
|
||||||
|
animation: none;
|
||||||
|
background-color: light-dark(
|
||||||
|
var(--mantine-color-teal-1),
|
||||||
|
rgba(45, 212, 191, 0.18)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.dropLine {
|
.dropLine {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: var(--drop-line-indent, 0);
|
left: var(--drop-line-indent, 0);
|
||||||
|
|||||||
@@ -168,6 +168,10 @@ export default function ShareAiWidget({
|
|||||||
// Anonymous reader: suppress the tool-argument summary line so the
|
// Anonymous reader: suppress the tool-argument summary line so the
|
||||||
// agent's raw query/argument text isn't shown on the public share.
|
// agent's raw query/argument text isn't shown on the public share.
|
||||||
showInput={false}
|
showInput={false}
|
||||||
|
// Anonymous reader: never paint a tool's raw errorText (it can carry
|
||||||
|
// internal detail). This is the render gate; the bytes are also
|
||||||
|
// sanitized server-side in PublicShareChatToolsService.forShare (#394).
|
||||||
|
showErrors={false}
|
||||||
// Anonymous reader: neutralize internal/relative links in the
|
// Anonymous reader: neutralize internal/relative links in the
|
||||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||||
// leak as clickable links (external http(s) links are kept).
|
// leak as clickable links (external http(s) links are kept).
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ let currentAlias: IShareAlias | null = null;
|
|||||||
let availabilityResult: {
|
let availabilityResult: {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
currentPageId: string | null;
|
} = { valid: true, available: true };
|
||||||
} = { valid: true, available: true, currentPageId: null };
|
|
||||||
|
|
||||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||||
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
useShareAliasForPageQuery: () => ({ data: currentAlias }),
|
||||||
@@ -56,7 +55,7 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setMutateAsync.mockReset();
|
setMutateAsync.mockReset();
|
||||||
currentAlias = null;
|
currentAlias = null;
|
||||||
availabilityResult = { valid: true, available: true, currentPageId: null };
|
availabilityResult = { valid: true, available: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
it("shows a 'will move it here' HINT (not a terminal error) when the name belongs to another page, and keeps Save enabled", async () => {
|
||||||
@@ -65,7 +64,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
availabilityResult = {
|
availabilityResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
available: false,
|
available: false,
|
||||||
currentPageId: "page-X",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
renderSection("page-Y");
|
renderSection("page-Y");
|
||||||
@@ -97,7 +95,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
availabilityResult = {
|
availabilityResult = {
|
||||||
valid: true,
|
valid: true,
|
||||||
available: false,
|
available: false,
|
||||||
currentPageId: "page-X",
|
|
||||||
};
|
};
|
||||||
// The server rejects the un-confirmed save asking the client to confirm.
|
// The server rejects the un-confirmed save asking the client to confirm.
|
||||||
setMutateAsync.mockRejectedValueOnce({
|
setMutateAsync.mockRejectedValueOnce({
|
||||||
@@ -106,7 +103,6 @@ describe("ShareAliasSection — taken-name handling is never a dead end", () =>
|
|||||||
status: 409,
|
status: 409,
|
||||||
data: {
|
data: {
|
||||||
code: "ALIAS_REASSIGN_REQUIRED",
|
code: "ALIAS_REASSIGN_REQUIRED",
|
||||||
currentPageId: "page-X",
|
|
||||||
currentPageTitle: "Alias Test Page X",
|
currentPageTitle: "Alias Test Page X",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export default function ShareAliasSection({
|
|||||||
const [availability, setAvailability] = useState<{
|
const [availability, setAvailability] = useState<{
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
currentPageId: string | null;
|
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [reassign, setReassign] = useState<{
|
const [reassign, setReassign] = useState<{
|
||||||
alias: string;
|
alias: string;
|
||||||
@@ -76,7 +75,6 @@ export default function ShareAliasSection({
|
|||||||
setAvailability({
|
setAvailability({
|
||||||
valid: res.valid,
|
valid: res.valid,
|
||||||
available: res.available,
|
available: res.available,
|
||||||
currentPageId: res.currentPageId,
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setAvailability(null);
|
setAvailability(null);
|
||||||
|
|||||||
@@ -108,7 +108,6 @@ export interface IShareAliasAvailability {
|
|||||||
alias: string;
|
alias: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
available: boolean;
|
available: boolean;
|
||||||
currentPageId: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ISharedPageTree {
|
export interface ISharedPageTree {
|
||||||
|
|||||||
@@ -82,17 +82,19 @@ describe("applyMoveTreeNode", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159)", () => {
|
it("does NOT create a partial child list when the destination is loaded-but-collapsed (children unloaded) — keeps it lazy-loadable (#159 #525)", () => {
|
||||||
// `dstCollapsed` is in the tree but its children were never lazy-loaded
|
// `dstCollapsed` is in the tree but its children were never lazy-loaded. The
|
||||||
// (children === undefined). The OLD behavior inserted `src` as the ONLY
|
// CANONICAL unloaded form here is `hasChildren: true` + `children: []` (from
|
||||||
// child ([src]), which defeated the lazy-load gate and HID the parent's
|
// `pageToTreeNode` / `pruneCollapsedChildren`), NOT `children: undefined`.
|
||||||
// other real children. Now the move leaves children unloaded (so expanding
|
// The pre-#525 predicate (`children === undefined`) missed this form and
|
||||||
// fetches the FULL set, including src) and just flags hasChildren.
|
// inserted `src` as the ONLY child ([src]), defeating the lazy-load gate and
|
||||||
|
// HIDING the parent's other real children. Now the move leaves children
|
||||||
|
// unloaded (so expanding fetches the FULL set, including src).
|
||||||
const tree: SpaceTreeNode[] = [
|
const tree: SpaceTreeNode[] = [
|
||||||
node("dstCollapsed", {
|
node("dstCollapsed", {
|
||||||
position: "a0",
|
position: "a0",
|
||||||
hasChildren: false,
|
hasChildren: true,
|
||||||
children: undefined as unknown as SpaceTreeNode[],
|
children: [],
|
||||||
}),
|
}),
|
||||||
node("src", { position: "a9" }),
|
node("src", { position: "a9" }),
|
||||||
];
|
];
|
||||||
@@ -105,9 +107,10 @@ describe("applyMoveTreeNode", () => {
|
|||||||
pageData: {},
|
pageData: {},
|
||||||
});
|
});
|
||||||
const dst = treeModel.find(next, "dstCollapsed");
|
const dst = treeModel.find(next, "dstCollapsed");
|
||||||
// Children stay unloaded -> the lazy-load gate fetches the FULL set (incl.
|
// Children stay unloaded ([] not materialized to [src]) -> the lazy-load gate
|
||||||
// src) on expand, rather than showing a misleading partial [src] list.
|
// fetches the FULL set (incl. src) on expand. MUTATION: the pre-#525
|
||||||
expect(dst?.children).toBeUndefined();
|
// `=== undefined` predicate would insert [src] here and redden this.
|
||||||
|
expect(dst?.children).toEqual([]);
|
||||||
expect(dst?.hasChildren).toBe(true);
|
expect(dst?.hasChildren).toBe(true);
|
||||||
// src moved away from its old root slot (it lives under dstCollapsed
|
// src moved away from its old root slot (it lives under dstCollapsed
|
||||||
// server-side and reappears when the parent is expanded/loaded).
|
// server-side and reappears when the parent is expanded/loaded).
|
||||||
|
|||||||
+10
-2
@@ -28,6 +28,7 @@ import {
|
|||||||
IAiMcpServerCreate,
|
IAiMcpServerCreate,
|
||||||
IAiMcpServerUpdate,
|
IAiMcpServerUpdate,
|
||||||
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
} from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
|
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
@@ -121,13 +122,20 @@ export default function AiMcpServerForm({
|
|||||||
async function handleSubmit(values: FormValues) {
|
async function handleSubmit(values: FormValues) {
|
||||||
const headers = resolveHeaders();
|
const headers = resolveHeaders();
|
||||||
|
|
||||||
|
// An empty tag field means "no restriction" (sent as null) — since #476 the
|
||||||
|
// server persists a literal `[]` as deny-all (zero tools). But a server that
|
||||||
|
// was ALREADY deny-all loads into an empty field too; sending null there
|
||||||
|
// would silently widen it to allow-all on a routine edit, so preserve `[]`.
|
||||||
|
// See resolveToolAllowlist for the full rationale.
|
||||||
|
const toolAllowlist = resolveToolAllowlist(values.toolAllowlist, server);
|
||||||
|
|
||||||
if (isEdit && server) {
|
if (isEdit && server) {
|
||||||
const payload: IAiMcpServerUpdate = {
|
const payload: IAiMcpServerUpdate = {
|
||||||
id: server.id,
|
id: server.id,
|
||||||
name: values.name,
|
name: values.name,
|
||||||
transport: values.transport,
|
transport: values.transport,
|
||||||
url: values.url,
|
url: values.url,
|
||||||
toolAllowlist: values.toolAllowlist,
|
toolAllowlist,
|
||||||
// Always sent: a blank value clears the stored guidance (server -> null).
|
// Always sent: a blank value clears the stored guidance (server -> null).
|
||||||
instructions: values.instructions,
|
instructions: values.instructions,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
@@ -140,7 +148,7 @@ export default function AiMcpServerForm({
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
transport: values.transport,
|
transport: values.transport,
|
||||||
url: values.url,
|
url: values.url,
|
||||||
toolAllowlist: values.toolAllowlist,
|
toolAllowlist,
|
||||||
// Blank => server stores null (no guidance).
|
// Blank => server stores null (no guidance).
|
||||||
instructions: values.instructions,
|
instructions: values.instructions,
|
||||||
enabled: values.enabled,
|
enabled: values.enabled,
|
||||||
|
|||||||
+29
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveToolAllowlist } from "./ai-mcp-server-form.utils.ts";
|
||||||
|
|
||||||
|
describe("resolveToolAllowlist", () => {
|
||||||
|
it("sends the typed tools when the field is non-empty", () => {
|
||||||
|
expect(resolveToolAllowlist(["a", "b"], { toolAllowlist: null })).toEqual([
|
||||||
|
"a",
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates as null (unrestricted) when empty and there is no server", () => {
|
||||||
|
expect(resolveToolAllowlist([], undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends null for an empty field on a previously-unrestricted server", () => {
|
||||||
|
expect(resolveToolAllowlist([], { toolAllowlist: null })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves deny-all: an empty field on a `[]` server stays `[]`, not null", () => {
|
||||||
|
// The core #476/#477 guard: editing a deny-all server (rename/toggle) with
|
||||||
|
// an empty tag field must NOT silently widen it to allow-all.
|
||||||
|
expect(resolveToolAllowlist([], { toolAllowlist: [] })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sends explicit tools even if the server was deny-all", () => {
|
||||||
|
expect(resolveToolAllowlist(["x"], { toolAllowlist: [] })).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
import { IAiMcpServer } from "@/features/workspace/services/ai-mcp-server-service.ts";
|
||||||
|
|
||||||
|
// Resolve the tool allowlist value to persist from the form field.
|
||||||
|
//
|
||||||
|
// An empty tag field normally means "no restriction" and is sent as null so
|
||||||
|
// the server drops the column (all tools allowed). But a server that was
|
||||||
|
// ALREADY deny-all (a stored literal `[]`, meaning zero tools — creatable via
|
||||||
|
// the API) loads into the form as an empty field too. Coercing that empty
|
||||||
|
// field to null on submit would SILENTLY widen a deny-all server to allow-all
|
||||||
|
// on any routine edit (rename, toggle) — the exact silent-widen class #476
|
||||||
|
// closed on the read side. So when the edited server was deny-all, preserve
|
||||||
|
// `[]` (deny-all); only a genuinely-unrestricted server (stored null/absent)
|
||||||
|
// stays null.
|
||||||
|
export function resolveToolAllowlist(
|
||||||
|
fieldValue: string[],
|
||||||
|
server?: Pick<IAiMcpServer, "toolAllowlist">,
|
||||||
|
): string[] | null {
|
||||||
|
if (fieldValue.length > 0) return fieldValue;
|
||||||
|
const wasDenyAll =
|
||||||
|
Array.isArray(server?.toolAllowlist) && server.toolAllowlist.length === 0;
|
||||||
|
return wasDenyAll ? [] : null;
|
||||||
|
}
|
||||||
+124
@@ -6,6 +6,8 @@ import {
|
|||||||
nextReindexPollInterval,
|
nextReindexPollInterval,
|
||||||
isReindexComplete,
|
isReindexComplete,
|
||||||
isReindexButtonLoading,
|
isReindexButtonLoading,
|
||||||
|
reindexRunKey,
|
||||||
|
isNewReindexRun,
|
||||||
} from './ai-provider-settings';
|
} from './ai-provider-settings';
|
||||||
|
|
||||||
describe('resolveCardStatus', () => {
|
describe('resolveCardStatus', () => {
|
||||||
@@ -221,6 +223,128 @@ describe('isReindexComplete', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('reindexRunKey', () => {
|
||||||
|
it('is null when the status carries no run identity', () => {
|
||||||
|
expect(reindexRunKey(undefined)).toBeNull();
|
||||||
|
expect(
|
||||||
|
reindexRunKey({ reindexing: false, indexedPages: 5, totalPages: 5 }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null for a legacy/degraded record with an empty runId', () => {
|
||||||
|
// The server sends runId='' for a record written before the field existed;
|
||||||
|
// the client must treat that as "no identity" (fall back to prior behaviour).
|
||||||
|
expect(
|
||||||
|
reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: '',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('folds runId and startedAt into one stable key', () => {
|
||||||
|
expect(
|
||||||
|
reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toBe('run-a:1000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when the runId changes for the same startedAt', () => {
|
||||||
|
const a = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
});
|
||||||
|
const b = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-b',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
});
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when the same runId restarts at a new startedAt', () => {
|
||||||
|
const a = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
});
|
||||||
|
const b = reindexRunKey({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: 'run-a',
|
||||||
|
reindexStartedAt: 2000,
|
||||||
|
});
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isNewReindexRun (poll keying on runId)', () => {
|
||||||
|
// Derive the status shape from the helper itself so the test needs no export
|
||||||
|
// of the component-internal ReindexStatus type.
|
||||||
|
type ReindexStatusLike = NonNullable<Parameters<typeof reindexRunKey>[0]>;
|
||||||
|
const run = (runId: string, startedAt: number): ReindexStatusLike => ({
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 0,
|
||||||
|
totalPages: 10,
|
||||||
|
runId,
|
||||||
|
reindexStartedAt: startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('first identity after none latched is a NEW run', () => {
|
||||||
|
expect(isNewReindexRun(null, run('run-a', 1000))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the SAME identity is not a new run (same run being watched)', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(isNewReindexRun(key, run('run-a', 1000))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a DIFFERENT runId is a new run (reset per-run poll state)', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(isNewReindexRun(key, run('run-b', 1000))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an identity-less poll (no runId / cleared record) is never a new run', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(
|
||||||
|
isNewReindexRun(key, {
|
||||||
|
reindexing: false,
|
||||||
|
indexedPages: 10,
|
||||||
|
totalPages: 10,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a legacy empty-runId poll does not spuriously reset a latched run', () => {
|
||||||
|
const key = reindexRunKey(run('run-a', 1000));
|
||||||
|
expect(
|
||||||
|
isNewReindexRun(key, {
|
||||||
|
reindexing: true,
|
||||||
|
indexedPages: 3,
|
||||||
|
totalPages: 10,
|
||||||
|
runId: '',
|
||||||
|
reindexStartedAt: 1000,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isReindexButtonLoading', () => {
|
describe('isReindexButtonLoading', () => {
|
||||||
it('loads while the POST mutation is pending', () => {
|
it('loads while the POST mutation is pending', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+54
-1
@@ -173,9 +173,43 @@ export function resolveKeyField(
|
|||||||
// Subset of the status payload that drives the reindex poll decisions.
|
// Subset of the status payload that drives the reindex poll decisions.
|
||||||
type ReindexStatus = Pick<
|
type ReindexStatus = Pick<
|
||||||
IAiSettings,
|
IAiSettings,
|
||||||
"reindexing" | "indexedPages" | "totalPages"
|
"reindexing" | "indexedPages" | "totalPages" | "runId" | "reindexStartedAt"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stable per-RUN key for the reindex poll: `runId:startedAt`, or `null` when
|
||||||
|
* the status carries no run identity (no active run, or a legacy/degraded
|
||||||
|
* server record with an empty runId). Two polls of the SAME run share a key; a
|
||||||
|
* new run mints a fresh runId and so a different key.
|
||||||
|
*
|
||||||
|
* This is the single place the client turns the server's run identity into the
|
||||||
|
* value it keys on — it removes the "is this the same run I've been watching or
|
||||||
|
* a brand-new one?" ambiguity that made a class of reindex-status bugs (a stale
|
||||||
|
* pre-reindex snapshot vs a fresh run) get fixed twice (#262). `startedAt` is
|
||||||
|
* folded in so a run that somehow reuses a runId but restarted is still new.
|
||||||
|
*/
|
||||||
|
export function reindexRunKey(status: ReindexStatus | undefined): string | null {
|
||||||
|
const runId = status?.runId;
|
||||||
|
if (!runId) return null;
|
||||||
|
return `${runId}:${status?.reindexStartedAt ?? ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether the latest poll represents a NEW reindex run relative to the
|
||||||
|
* run key the client last latched (`prevKey`, `null` if none yet). True only
|
||||||
|
* when the status carries an identity AND it differs from the latched one — the
|
||||||
|
* signal to reset any per-run poll state (the "seen active" latch / progress the
|
||||||
|
* UI held). The same identity (or no identity) is NOT a new run, so an unchanged
|
||||||
|
* or identity-less poll never resets mid-run.
|
||||||
|
*/
|
||||||
|
export function isNewReindexRun(
|
||||||
|
prevKey: string | null,
|
||||||
|
status: ReindexStatus | undefined,
|
||||||
|
): boolean {
|
||||||
|
const key = reindexRunKey(status);
|
||||||
|
return key !== null && key !== prevKey;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
* Decide the TanStack Query `refetchInterval` while a reindex may be running.
|
||||||
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
* Returns the poll interval (ms) to keep polling, or `false` to stop.
|
||||||
@@ -320,6 +354,13 @@ export default function AiProviderSettings() {
|
|||||||
// counter at 0 until a manual reload. A ref (not state) because it must not
|
// counter at 0 until a manual reload. A ref (not state) because it must not
|
||||||
// trigger a render and is only ever read where `reindexing` is already false.
|
// trigger a render and is only ever read where `reindexing` is already false.
|
||||||
const reindexSeenActiveRef = useRef(false);
|
const reindexSeenActiveRef = useRef(false);
|
||||||
|
// The run identity (runId:startedAt) the current poll window is keyed on. When
|
||||||
|
// a poll reports a DIFFERENT runId the server has started a NEW run, so we
|
||||||
|
// re-latch to it and reset `reindexSeenActiveRef` — a fresh run must never
|
||||||
|
// inherit the previous run's "seen active"/completion state (which would stop
|
||||||
|
// polling immediately or read the old run's counters as this run's). null =
|
||||||
|
// no run keyed yet (steady state, or a legacy record without a runId).
|
||||||
|
const reindexRunKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Only admins may read the (masked) AI settings; the server enforces this too.
|
// Only admins may read the (masked) AI settings; the server enforces this too.
|
||||||
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
const { data: settings, isLoading } = useAiSettingsQuery(isAdmin, (query) =>
|
||||||
@@ -336,6 +377,14 @@ export default function AiProviderSettings() {
|
|||||||
// unmount because the deadline state goes away with the component.
|
// unmount because the deadline state goes away with the component.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reindexDeadline === null) return;
|
if (reindexDeadline === null) return;
|
||||||
|
// Key the poll on the run identity: if this poll carries a runId different
|
||||||
|
// from the one we latched, the server started a NEW run, so adopt it and
|
||||||
|
// drop the per-run "seen active" latch (a fresh run must not inherit the
|
||||||
|
// previous run's completion state). Same runId => same run, leave it alone.
|
||||||
|
if (isNewReindexRun(reindexRunKeyRef.current, settings)) {
|
||||||
|
reindexRunKeyRef.current = reindexRunKey(settings);
|
||||||
|
reindexSeenActiveRef.current = false;
|
||||||
|
}
|
||||||
// Latch "we have seen the active run" the moment a poll reports it, so the
|
// Latch "we have seen the active run" the moment a poll reports it, so the
|
||||||
// completion check below (and the refetchInterval's) only fires once the run
|
// completion check below (and the refetchInterval's) only fires once the run
|
||||||
// has genuinely started — never on the stale pre-reindex snapshot.
|
// has genuinely started — never on the stale pre-reindex snapshot.
|
||||||
@@ -1220,6 +1269,10 @@ export default function AiProviderSettings() {
|
|||||||
// immediately.
|
// immediately.
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
reindexSeenActiveRef.current = false;
|
reindexSeenActiveRef.current = false;
|
||||||
|
// Forget the previous run's identity so the first poll of
|
||||||
|
// this window (carrying the new run's runId) is recognized
|
||||||
|
// as a new run and keyed afresh.
|
||||||
|
reindexRunKeyRef.current = null;
|
||||||
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
setReindexDeadline(Date.now() + REINDEX_POLL_CAP_MS);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ export interface IAiMcpServerCreate {
|
|||||||
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
// Auth headers map (e.g. { Authorization: 'Bearer ...' }). Encrypted on save;
|
||||||
// never returned.
|
// never returned.
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
toolAllowlist?: string[];
|
// Omit/null => no restriction; `[]` is persisted verbatim and means
|
||||||
|
// deny-all (zero tools) since #476.
|
||||||
|
toolAllowlist?: string[] | null;
|
||||||
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
// Admin-authored prompt guidance (#180). Blank => stored as null.
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -43,7 +45,9 @@ export interface IAiMcpServerUpdate {
|
|||||||
transport?: McpTransport;
|
transport?: McpTransport;
|
||||||
url?: string;
|
url?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
toolAllowlist?: string[];
|
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||||
|
// and means deny-all (zero tools) since #476.
|
||||||
|
toolAllowlist?: string[] | null;
|
||||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared.
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@@ -51,6 +51,14 @@ export interface IAiSettings {
|
|||||||
// True while a full workspace reindex is actively running; the counts above
|
// True while a full workspace reindex is actively running; the counts above
|
||||||
// then reflect the live run progress (done climbs 0 -> total).
|
// then reflect the live run progress (done climbs 0 -> total).
|
||||||
reindexing?: boolean;
|
reindexing?: boolean;
|
||||||
|
// Identity of the ACTIVE reindex run (present only while `reindexing`). The
|
||||||
|
// poll keys on `runId`: a changed value means a NEW run (reset the per-run
|
||||||
|
// poll state the UI latched), the same value is the run already being watched.
|
||||||
|
// Absent/empty ('') => no identity available; the client keeps prior behaviour.
|
||||||
|
runId?: string;
|
||||||
|
// Epoch-ms the active run started; paired with `runId` so a restart with a
|
||||||
|
// recycled id is still detected as a new run.
|
||||||
|
reindexStartedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { templateRoute } from "./route-template";
|
import { templateRoute, KNOWN_ROUTE_TEMPLATES } from "./route-template";
|
||||||
|
|
||||||
describe("templateRoute", () => {
|
describe("templateRoute", () => {
|
||||||
it("templates a space page path (never leaks slugs)", () => {
|
it("templates a space page path (never leaks slugs)", () => {
|
||||||
@@ -32,4 +32,30 @@ describe("templateRoute", () => {
|
|||||||
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
expect(templateRoute("/weird/unknown/thing")).toBe("other");
|
||||||
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
expect(templateRoute("/s/team/p/slug/extra/segments")).toBe("other");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The server's /api/telemetry/vitals mirror (ALLOWED_ROUTE_TEMPLATES) drops any
|
||||||
|
// route outside KNOWN_ROUTE_TEMPLATES, so templateRoute must NEVER emit a label
|
||||||
|
// that is not in that dictionary — otherwise legit client metrics get dropped.
|
||||||
|
it("only ever emits labels contained in KNOWN_ROUTE_TEMPLATES (#495)", () => {
|
||||||
|
const samples = [
|
||||||
|
"/",
|
||||||
|
"/home",
|
||||||
|
"/settings/members",
|
||||||
|
"/settings/groups/g-1",
|
||||||
|
"/s/team",
|
||||||
|
"/s/team/trash",
|
||||||
|
"/s/team/p/slug",
|
||||||
|
"/p/slug",
|
||||||
|
"/share/abc",
|
||||||
|
"/share/abc/p/slug",
|
||||||
|
"/share/p/slug",
|
||||||
|
"/labels/urgent",
|
||||||
|
"/invites/inv-1",
|
||||||
|
"/weird/unknown/thing", // -> "other"
|
||||||
|
"/deep/unmatched/x/y/z", // -> "other"
|
||||||
|
];
|
||||||
|
for (const path of samples) {
|
||||||
|
expect(KNOWN_ROUTE_TEMPLATES.has(templateRoute(path))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,6 +44,22 @@ const STATIC_ROUTES = new Set<string>([
|
|||||||
'/settings/sharing',
|
'/settings/sharing',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The COMPLETE, finite vocabulary `templateRoute` can ever emit: the two
|
||||||
|
* synthetic labels (`/` and `other`), the static routes, and the dynamic
|
||||||
|
* templates. Exported so the public `/api/telemetry/vitals` endpoint can reject
|
||||||
|
* any `route` outside this dictionary server-side (the endpoint is anonymous, so
|
||||||
|
* an un-checked `route` is a free-text write surface). The server keeps a mirror
|
||||||
|
* (`ALLOWED_ROUTE_TEMPLATES` in client-metrics.constants.ts) — this is the
|
||||||
|
* canonical source; keep them in lockstep.
|
||||||
|
*/
|
||||||
|
export const KNOWN_ROUTE_TEMPLATES: ReadonlySet<string> = new Set<string>([
|
||||||
|
'/',
|
||||||
|
'other',
|
||||||
|
...STATIC_ROUTES,
|
||||||
|
...ROUTE_PATTERNS.map((p) => p.template),
|
||||||
|
]);
|
||||||
|
|
||||||
export function templateRoute(pathname: string): string {
|
export function templateRoute(pathname: string): string {
|
||||||
// Normalise a trailing slash (except root).
|
// Normalise a trailing slash (except root).
|
||||||
const path =
|
const path =
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import "@mantine/spotlight/styles.css";
|
|||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
import '@mantine/dates/styles.css';
|
import '@mantine/dates/styles.css';
|
||||||
import "@/styles/a11y-overrides.css";
|
import "@/styles/a11y-overrides.css";
|
||||||
|
import "@/styles/notification-overrides.css";
|
||||||
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
@@ -47,7 +48,15 @@ function renderApp() {
|
|||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
{/* top-center: toasts sit in the top of the viewport, in the line
|
||||||
|
of sight, and no longer cover centered content (e.g. "Load
|
||||||
|
more"). The below-chrome vertical offset is applied via a
|
||||||
|
position-scoped CSS rule in notification-overrides.css (NOT an
|
||||||
|
inline `style`): Mantine renders all six position containers at
|
||||||
|
once and an inline root style would land on every one, giving the
|
||||||
|
bottom-* containers both top+bottom → full-viewport transparent
|
||||||
|
overlays that swallow clicks. */}
|
||||||
|
<Notifications position="top-center" limit={3} zIndex={10000} />
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
404 after a deploy is caught and recovered here instead of
|
404 after a deploy is caught and recovered here instead of
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* Toast (Mantine Notification) visibility overrides.
|
||||||
|
* Mantine renders colorless toasts on --mantine-color-body (== the page
|
||||||
|
* background: white in light mode) with a faint shadow, so on white pages the
|
||||||
|
* card has no visible edge. These rules give every toast a type-tinted
|
||||||
|
* background, a WCAG-checked border and a stronger shadow so it separates from
|
||||||
|
* the page. The [data-mantine-color-scheme] + static-class selector (0,2,0)
|
||||||
|
* beats Mantine's own (0,1,0) rules regardless of stylesheet order (Mantine's
|
||||||
|
* bg/border rules wrap the scheme attribute in :where(), so they stay (0,1,0)).
|
||||||
|
* --notification-color is defined on the same element (defaults to primary,
|
||||||
|
* set per `color` prop), so tint/border follow the toast type. This also covers
|
||||||
|
* the loading/import toast (no accent bar, since the spinner takes the icon
|
||||||
|
* slot): its visibility comes from tone + border + shadow + the colored spinner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||||
|
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||||
|
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||||
|
*
|
||||||
|
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||||
|
* containers simultaneously (`position` only routes toasts into one via the
|
||||||
|
* store); the root `style` prop would be applied to every one of them by
|
||||||
|
* getStyles("root"). A blanket `top` would land on the bottom-* containers too
|
||||||
|
* (which carry `bottom:16px`) → position:fixed + both edges + height:auto makes
|
||||||
|
* them stretch the full viewport height, and the container root has neither
|
||||||
|
* pointer-events:none nor a background, so those transparent z-10000 overlays
|
||||||
|
* would swallow clicks across the whole page. Restricting to top-* leaves the
|
||||||
|
* bottom containers at height:0.
|
||||||
|
*
|
||||||
|
* Specificity: `.mantine-Notifications-root[data-position^='top']` is (0,2,0)
|
||||||
|
* (class + attribute) and beats Mantine's own top rule
|
||||||
|
* `.m_b37d9ac7:where([data-position='top-center']){top:16px}` which is (0,1,0)
|
||||||
|
* (the :where() contributes 0), regardless of stylesheet order.
|
||||||
|
*/
|
||||||
|
.mantine-Notifications-root[data-position^='top'] {
|
||||||
|
top: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||||
|
/* ~10% type color over white: clearly off-white, text contrast preserved */
|
||||||
|
background-color: color-mix(in srgb, var(--notification-color) 10%, var(--mantine-color-white));
|
||||||
|
/* Border must clear WCAG 3:1 non-text contrast on white. The repo rejects
|
||||||
|
gray-4 for this (a11y-overrides.css); gray-6 base (~3.32:1) darkened by the
|
||||||
|
type color stays >= 3:1. */
|
||||||
|
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-gray-6));
|
||||||
|
box-shadow: var(--mantine-shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-mantine-color-scheme='dark'] .mantine-Notification-root {
|
||||||
|
/* Dark page (dark-7/8) vs toast (dark-6) already separate a little; border +
|
||||||
|
shadow carry the type cue here (a 7% dark tint was near-invisible). */
|
||||||
|
background-color: color-mix(in srgb, var(--notification-color) 14%, var(--mantine-color-dark-6));
|
||||||
|
border: 1px solid color-mix(in srgb, var(--notification-color) 45%, var(--mantine-color-dark-3));
|
||||||
|
box-shadow: var(--mantine-shadow-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mantine's message-with-title color is gray-6 (#868e96, already only ~3.32:1
|
||||||
|
on white — below AA 4.5:1); the new tint pushes it lower. Bump to gray-7 to
|
||||||
|
keep multi-line colored toasts readable, consistent with the repo's existing
|
||||||
|
WCAG tuning (theme.ts already bumps this same gray-6 up elsewhere). */
|
||||||
|
[data-mantine-color-scheme='light'] .mantine-Notification-description[data-with-title] {
|
||||||
|
color: var(--mantine-color-gray-7);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build && pnpm --filter @docmost/token-estimate build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -44,6 +44,7 @@
|
|||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
"@docmost/prosemirror-markdown": "workspace:*",
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
"@docmost/token-estimate": "workspace:*",
|
||||||
"@fastify/compress": "^9.0.0",
|
"@fastify/compress": "^9.0.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
@@ -206,6 +207,7 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
|
"^@docmost/token-estimate$": "<rootDir>/../../../packages/token-estimate/src/index.ts",
|
||||||
"^src/(.*)$": "<rootDir>/$1",
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
|
|||||||
import KeyvRedis from '@keyv/redis';
|
import KeyvRedis from '@keyv/redis';
|
||||||
import { LoggerModule } from './common/logger/logger.module';
|
import { LoggerModule } from './common/logger/logger.module';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
import { NoopAuditModule } from './integrations/audit/audit.module';
|
import { AuditModule } from './integrations/audit/audit.module';
|
||||||
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
import { ThrottleModule } from './integrations/throttle/throttle.module';
|
||||||
import { McpModule } from './integrations/mcp/mcp.module';
|
import { McpModule } from './integrations/mcp/mcp.module';
|
||||||
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
import { SandboxModule } from './integrations/sandbox/sandbox.module';
|
||||||
@@ -55,7 +55,7 @@ try {
|
|||||||
middleware: { mount: true },
|
middleware: { mount: true },
|
||||||
}),
|
}),
|
||||||
LoggerModule,
|
LoggerModule,
|
||||||
NoopAuditModule,
|
AuditModule,
|
||||||
CoreModule,
|
CoreModule,
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
EnvironmentModule,
|
EnvironmentModule,
|
||||||
|
|||||||
@@ -529,4 +529,107 @@ describe('replaceYjsMarkedText', () => {
|
|||||||
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
expect(result).toEqual({ applied: false, currentText: 'abcdef' });
|
||||||
expect(text.toDelta()).toEqual(before);
|
expect(text.toDelta()).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #496: apply must NOT silently strip the replaced run's inline formatting.
|
||||||
|
// Build a paragraph and format the marked range with extra marks, then assert
|
||||||
|
// the replacement carries them.
|
||||||
|
function buildFormatted(
|
||||||
|
runs: Array<{ text: string; attrs?: Record<string, any> }>,
|
||||||
|
): { fragment: Y.XmlFragment; text: Y.XmlText } {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const fragment = ydoc.getXmlFragment('default');
|
||||||
|
const para = new Y.XmlElement('paragraph');
|
||||||
|
fragment.insert(0, [para]);
|
||||||
|
const text = new Y.XmlText();
|
||||||
|
para.insert(0, [text]);
|
||||||
|
text.insert(0, runs.map((r) => r.text).join(''));
|
||||||
|
let offset = 0;
|
||||||
|
for (const run of runs) {
|
||||||
|
if (run.attrs) text.format(offset, run.text.length, run.attrs);
|
||||||
|
offset += run.text.length;
|
||||||
|
}
|
||||||
|
return { fragment, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('preserves the original run formatting (bold + link) on the replacement', () => {
|
||||||
|
const { fragment, text } = buildFormatted([
|
||||||
|
{ text: 'see ' },
|
||||||
|
{
|
||||||
|
text: 'old',
|
||||||
|
attrs: {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
bold: true,
|
||||||
|
link: { href: 'https://x.test' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ text: ' end' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'old', 'new');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'new' });
|
||||||
|
// The comment anchor AND the bold/link marks survive the delete+insert.
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{ insert: 'see ' },
|
||||||
|
{
|
||||||
|
insert: 'new',
|
||||||
|
attributes: {
|
||||||
|
comment: { commentId: 'c1', resolved: false },
|
||||||
|
bold: true,
|
||||||
|
link: { href: 'https://x.test' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ insert: ' end' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed formatting under the mark: replacement takes the DOMINANT (longest) run, NOT the leading one', () => {
|
||||||
|
// Leading run is SHORT + plain ("x", 1 char); the following run is LONGER +
|
||||||
|
// bold ("bolded", 6 chars), same commentId. The longest run is deliberately
|
||||||
|
// NOT first: a "first-wins" pick would carry plain (no bold), so asserting
|
||||||
|
// bold on the result only holds if the code genuinely selects the LONGEST run.
|
||||||
|
const { fragment, text } = buildFormatted([
|
||||||
|
{ text: 'x', attrs: { comment: { commentId: 'c1', resolved: false } } },
|
||||||
|
{
|
||||||
|
text: 'bolded',
|
||||||
|
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'xbolded', 'Z');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'Z',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mixed formatting under the mark: on a length tie the FIRST run wins', () => {
|
||||||
|
// Two equal-length runs (2 chars each) with different formatting, same
|
||||||
|
// commentId. The reduce keeps the accumulator on a tie, so the FIRST run
|
||||||
|
// (italic) prevails over the later bold one.
|
||||||
|
const { fragment, text } = buildFormatted([
|
||||||
|
{
|
||||||
|
text: 'AA',
|
||||||
|
attrs: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'BB',
|
||||||
|
attrs: { comment: { commentId: 'c1', resolved: false }, bold: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = replaceYjsMarkedText(fragment, 'c1', 'AABB', 'Z');
|
||||||
|
|
||||||
|
expect(result).toEqual({ applied: true, currentText: 'Z' });
|
||||||
|
expect(text.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'Z',
|
||||||
|
attributes: { comment: { commentId: 'c1', resolved: false }, italic: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ type MarkedSegment = {
|
|||||||
length: number;
|
length: number;
|
||||||
text: string;
|
text: string;
|
||||||
markAttrs: Record<string, any>;
|
markAttrs: Record<string, any>;
|
||||||
|
// The FULL attribute set of this delta run — the `comment` mark plus any
|
||||||
|
// inline formatting (bold/italic/code/link/…). Captured so apply can carry the
|
||||||
|
// original run's formatting onto the replacement instead of dropping it.
|
||||||
|
attributes: Record<string, any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -202,6 +206,7 @@ export function replaceYjsMarkedText(
|
|||||||
length,
|
length,
|
||||||
text: insert,
|
text: insert,
|
||||||
markAttrs: markAttr,
|
markAttrs: markAttr,
|
||||||
|
attributes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
offset += length;
|
offset += length;
|
||||||
@@ -251,15 +256,25 @@ export function replaceYjsMarkedText(
|
|||||||
return { applied: false, currentText: joinedText };
|
return { applied: false, currentText: joinedText };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. All guards passed: delete the marked run and re-insert newText with the
|
// 3. All guards passed: delete the marked run and re-insert newText at the
|
||||||
// same comment attributes at the same offset. Atomic within the caller's
|
// same offset. Atomic within the caller's transaction.
|
||||||
// transaction.
|
|
||||||
const start = segments[0].offset;
|
const start = segments[0].offset;
|
||||||
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
const len = segments.reduce((sum, s) => sum + s.length, 0);
|
||||||
const markAttrs = segments[0].markAttrs;
|
|
||||||
|
// Carry the ORIGINAL run's formatting onto the replacement (#496): inserting
|
||||||
|
// with only the `comment` mark silently dropped bold/italic/code/link of the
|
||||||
|
// replaced text. Yjs applies one flat attribute set to the whole insert, so
|
||||||
|
// when the marked run mixes formatting we pick the DOMINANT segment (the one
|
||||||
|
// covering the most characters) and apply its attributes — a v1 that preserves
|
||||||
|
// the common single-format case exactly and, for a mixed run, keeps the
|
||||||
|
// prevailing style rather than losing all of it. `attributes` already carries
|
||||||
|
// the `comment` mark (every collected segment is filtered on it above), so the
|
||||||
|
// anchor is preserved by copying the run's attribute set verbatim.
|
||||||
|
const dominant = segments.reduce((a, b) => (b.length > a.length ? b : a));
|
||||||
|
const insertAttrs = { ...dominant.attributes };
|
||||||
|
|
||||||
node.delete(start, len);
|
node.delete(start, len);
|
||||||
node.insert(start, newText, { comment: markAttrs });
|
node.insert(start, newText, insertAttrs);
|
||||||
|
|
||||||
return { applied: true, currentText: newText };
|
return { applied: true, currentText: newText };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
||||||
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
||||||
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
||||||
|
|
||||||
@@ -10,13 +11,12 @@ import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
|||||||
*
|
*
|
||||||
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
|
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
|
||||||
* this exercises the REAL server import conversion path that ImportService uses
|
* this exercises the REAL server import conversion path that ImportService uses
|
||||||
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
|
* (`markdownToProseMirror`, the canonical converter — issue #345/#347) and
|
||||||
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
|
* asserts that such a node is DETECTED and STRIPPABLE — so the share read path's
|
||||||
* that such a node is DETECTED and STRIPPABLE — so the share read path's
|
|
||||||
* master-toggle strip can remove it when the workspace toggle is OFF.
|
* master-toggle strip can remove it when the workspace toggle is OFF.
|
||||||
*/
|
*/
|
||||||
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
||||||
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
|
it('round-trips through markdownToProseMirror and is DETECTED (base64 data-source)', async () => {
|
||||||
const source = '<script>steal()</script>';
|
const source = '<script>steal()</script>';
|
||||||
const encoded = encodeHtmlEmbedSource(source);
|
const encoded = encodeHtmlEmbedSource(source);
|
||||||
const md = [
|
const md = [
|
||||||
@@ -27,12 +27,9 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
|
|||||||
'World',
|
'World',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const html = await markdownToHtml(md);
|
// The canonical importer parses the raw block-level div into a real
|
||||||
// marked preserves the raw block-level div verbatim.
|
// htmlEmbed node carrying the decoded source.
|
||||||
expect(html).toContain('data-type="htmlEmbed"');
|
const json = await markdownToProseMirror(md);
|
||||||
|
|
||||||
const json = htmlToJson(html);
|
|
||||||
// The div parses into a real htmlEmbed node carrying the decoded source.
|
|
||||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||||
|
|
||||||
// Because it is detected, the share master-toggle strip can remove it.
|
// Because it is detected, the share master-toggle strip can remove it.
|
||||||
@@ -59,8 +56,7 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
|
|||||||
// therefore stripping) does not depend on the source being well-formed, so
|
// therefore stripping) does not depend on the source being well-formed, so
|
||||||
// the bypass cannot be hidden by sending a malformed data-source.
|
// the bypass cannot be hidden by sending a malformed data-source.
|
||||||
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
||||||
const html = await markdownToHtml(md);
|
const json = await markdownToProseMirror(md);
|
||||||
const json = htmlToJson(html);
|
|
||||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ function makeRepo(overrides: Record<string, jest.Mock> = {}) {
|
|||||||
workspaceId: v.workspaceId,
|
workspaceId: v.workspaceId,
|
||||||
})),
|
})),
|
||||||
update: jest.fn(async () => ({ id: 'run-1' })),
|
update: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
|
// #487: terminal finalize now goes through the CONDITIONAL write. Default
|
||||||
|
// returns a truthy row (the run WAS active -> this call wrote it).
|
||||||
|
finalizeIfActive: jest.fn(async () => ({ id: 'run-1', status: 'succeeded' })),
|
||||||
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
markStopRequested: jest.fn(async () => ({ id: 'run-1' })),
|
||||||
findActiveByChat: jest.fn(async () => undefined),
|
findActiveByChat: jest.fn(async () => undefined),
|
||||||
findLatestByChat: jest.fn(async () => undefined),
|
findLatestByChat: jest.fn(async () => undefined),
|
||||||
@@ -336,14 +339,12 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'provider blew up');
|
||||||
|
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
expect(repo.update).toHaveBeenCalledWith(
|
// #487: the terminal write is CONDITIONAL (finalizeIfActive); finishedAt is
|
||||||
|
// stamped inside the repo method, so the service passes just status + error.
|
||||||
|
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({
|
expect.objectContaining({ status: 'failed', error: 'provider blew up' }),
|
||||||
status: 'failed',
|
|
||||||
error: 'provider blew up',
|
|
||||||
finishedAt: expect.any(Date),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -366,8 +367,8 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
// A second settle (e.g. a streamText callback firing after the catch) no-ops.
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
await svc.finalizeRun('run-1', 'ws-1', 'completed', undefined);
|
||||||
|
|
||||||
expect(repo.update).toHaveBeenCalledTimes(1);
|
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||||
expect(repo.update).toHaveBeenCalledWith(
|
expect(repo.finalizeIfActive).toHaveBeenCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'failed', error: 'first' }),
|
expect.objectContaining({ status: 'failed', error: 'first' }),
|
||||||
@@ -389,8 +390,8 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
const updateGate = new Promise((res) => {
|
const updateGate = new Promise((res) => {
|
||||||
resolveUpdate = res;
|
resolveUpdate = res;
|
||||||
});
|
});
|
||||||
const update = jest.fn(() => updateGate);
|
const finalizeIfActive = jest.fn(() => updateGate);
|
||||||
const repo = makeRepo({ update });
|
const repo = makeRepo({ finalizeIfActive });
|
||||||
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
await svc.beginRun({
|
await svc.beginRun({
|
||||||
chatId: 'chat-1',
|
chatId: 'chat-1',
|
||||||
@@ -399,23 +400,23 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Fire both before the (pending) update resolves. The first synchronously
|
// Fire both before the (pending) update resolves. The first synchronously
|
||||||
// claims the entry (active.delete) and awaits update; the second, started in
|
// claims the entry (active.delete) and awaits the write; the second, started
|
||||||
// the same macrotask, finds the entry already gone and returns at the claim
|
// in the same macrotask, finds the entry already gone and returns at the claim
|
||||||
// WITHOUT ever calling update.
|
// WITHOUT ever writing.
|
||||||
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
const p1 = svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
const p2 = svc.finalizeRun('run-1', 'ws-1', 'error', 'safety-net');
|
||||||
|
|
||||||
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
// The decisive assertion: exactly one caller reached the terminal UPDATE.
|
||||||
expect(update).toHaveBeenCalledTimes(1);
|
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Let the single in-flight update land; both calls resolve cleanly.
|
// Let the single in-flight update land; both calls resolve cleanly.
|
||||||
resolveUpdate({ id: 'run-1' });
|
resolveUpdate({ id: 'run-1', status: 'succeeded' });
|
||||||
await Promise.all([p1, p2]);
|
await Promise.all([p1, p2]);
|
||||||
|
|
||||||
expect(update).toHaveBeenCalledTimes(1);
|
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||||
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
// The winner is the FIRST caller ('completed' -> 'succeeded'); the late
|
||||||
// 'error' settle never wrote, so it could not clobber the real status.
|
// 'error' settle never wrote, so it could not clobber the real status.
|
||||||
expect(update).toHaveBeenCalledWith(
|
expect(finalizeIfActive).toHaveBeenCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'succeeded' }),
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
@@ -431,10 +432,10 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
// 409s until a restart. The fix updates FIRST and retries.
|
// 409s until a restart. The fix updates FIRST and retries.
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const repo = makeRepo({
|
const repo = makeRepo({
|
||||||
update: jest.fn(async () => {
|
finalizeIfActive: jest.fn(async () => {
|
||||||
calls += 1;
|
calls += 1;
|
||||||
if (calls === 1) throw new Error('deadlock detected');
|
if (calls === 1) throw new Error('deadlock detected');
|
||||||
return { id: 'run-1' };
|
return { id: 'run-1', status: 'succeeded' };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
@@ -447,26 +448,29 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
|
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
|
||||||
// The retry landed the terminal write: the entry is dropped (slot freed) and
|
// The retry landed the terminal write: the entry is dropped (slot freed), no
|
||||||
// the row carries the real terminal status — NOT stranded at 'running'.
|
// zombie left, and the row carries the real terminal status.
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
expect(svc.isLocallyActive('run-1')).toBe(false);
|
||||||
expect(repo.update).toHaveBeenCalledTimes(2);
|
expect(svc.hasZombie('run-1')).toBe(false);
|
||||||
expect(repo.update).toHaveBeenLastCalledWith(
|
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(2);
|
||||||
|
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'succeeded' }),
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('F6: if the terminal write keeps failing, the entry is RETAINED and a LATER settle completes it (chat not permanently 409d)', async () => {
|
it('#487 give-up: if the terminal write keeps failing, finalizeRun leaves a ZOMBIE (does NOT restore the entry) and settleZombie re-drives it', async () => {
|
||||||
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
// Worst case: the DB is down for the whole first finalize (all attempts fail).
|
||||||
// The run must NOT be silently lost — the entry stays so a subsequent settle
|
// #487 changes the give-up behaviour: the entry is NOT restored (a restored
|
||||||
// (a streamText callback, requestStop -> onAbort, or a future sweep) can retry.
|
// entry is indistinguishable from a live run). Instead a ZOMBIE record holds
|
||||||
|
// the intended terminal status, and a re-drive (settleZombie — called by the
|
||||||
|
// reconcile / supersede / opportunistic paths) applies it later.
|
||||||
let healthy = false;
|
let healthy = false;
|
||||||
const repo = makeRepo({
|
const repo = makeRepo({
|
||||||
update: jest.fn(async () => {
|
finalizeIfActive: jest.fn(async () => {
|
||||||
if (!healthy) throw new Error('pool exhausted');
|
if (!healthy) throw new Error('pool exhausted');
|
||||||
return { id: 'run-1' };
|
return { id: 'run-1', status: 'succeeded' };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
@@ -480,35 +484,83 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
// First settle: every bounded attempt fails -> entry retained, NOT settled.
|
// First settle: every bounded attempt fails -> ZOMBIE, entry NOT restored.
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(true);
|
expect(svc.isLocallyActive('run-1')).toBe(false); // NOT a live entry
|
||||||
// F12: the give-up emits ONE explicit, greppable ERROR (run + chat context)
|
expect(svc.hasZombie('run-1')).toBe(true);
|
||||||
// so an operator can tell "gave up, run held in memory" from a per-attempt
|
expect(svc.zombieRunIds()).toContain('run-1');
|
||||||
// blip — distinct from the per-attempt warns.
|
// The give-up emits ONE explicit, greppable ERROR mentioning the zombie.
|
||||||
const gaveUp = errorSpy.mock.calls.some(
|
const gaveUp = errorSpy.mock.calls.some(
|
||||||
(c) =>
|
(c) =>
|
||||||
/NON-TERMINAL/.test(String(c[0])) &&
|
/NON-TERMINAL/.test(String(c[0])) &&
|
||||||
|
/ZOMBIE/.test(String(c[0])) &&
|
||||||
/run-1/.test(String(c[0])) &&
|
/run-1/.test(String(c[0])) &&
|
||||||
/chat-1/.test(String(c[0])),
|
/chat-1/.test(String(c[0])),
|
||||||
);
|
);
|
||||||
expect(gaveUp).toBe(true);
|
expect(gaveUp).toBe(true);
|
||||||
|
// The settle notifier resolved as terminalWriteFailed (a subscriber learns the
|
||||||
|
// slot still needs the intended status applied).
|
||||||
|
const outcome = await svc.peekSettled('run-1');
|
||||||
|
expect(outcome).toEqual({
|
||||||
|
status: 'succeeded',
|
||||||
|
error: null,
|
||||||
|
terminalWriteFailed: true,
|
||||||
|
});
|
||||||
|
|
||||||
// The DB recovers; a later settle now succeeds and frees the slot.
|
// The DB recovers; a re-drive settles the zombie via the conditional UPDATE.
|
||||||
healthy = true;
|
healthy = true;
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
const redriven = await svc.settleZombie('run-1');
|
||||||
expect(svc.isLocallyActive('run-1')).toBe(false);
|
expect(redriven).toBe(true);
|
||||||
expect(repo.update).toHaveBeenLastCalledWith(
|
expect(svc.hasZombie('run-1')).toBe(false);
|
||||||
|
expect(repo.finalizeIfActive).toHaveBeenLastCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws-1',
|
'ws-1',
|
||||||
expect.objectContaining({ status: 'succeeded' }),
|
expect.objectContaining({ status: 'succeeded' }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// And it is now idempotent: a further settle no-ops (terminal row already
|
// A later finalizeRun is idempotent (row already terminal): it no-ops at the
|
||||||
// written), so a double-settle can never clobber the real status.
|
// once-gate, never re-writing.
|
||||||
const callsBefore = repo.update.mock.calls.length;
|
const callsBefore = repo.finalizeIfActive.mock.calls.length;
|
||||||
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late');
|
||||||
expect(repo.update).toHaveBeenCalledTimes(callsBefore);
|
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(callsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#487 double-settle collapses to a benign no-op (conditional write; notifier resolves once)', async () => {
|
||||||
|
// A second concurrent settle is stopped at the synchronous active.delete
|
||||||
|
// claim, so the terminal write runs exactly once and the notifier resolves
|
||||||
|
// exactly once with the FIRST settler's outcome.
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'aborted');
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'error', 'late'); // no-op
|
||||||
|
|
||||||
|
expect(repo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||||
|
const outcome = await svc.peekSettled('run-1');
|
||||||
|
// peekSettled after resolve+delete falls through (notifier dropped, no zombie)
|
||||||
|
// -> undefined; the FIRST settler already resolved any earlier subscriber.
|
||||||
|
expect(outcome).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#487 late settledPromise subscriber gets the resolved outcome', async () => {
|
||||||
|
const repo = makeRepo();
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({ chatId: 'chat-1', workspaceId: 'ws-1', userId: 'u1' });
|
||||||
|
|
||||||
|
// Subscribe BEFORE settle: hold the promise reference (as supersede does).
|
||||||
|
const early = svc.peekSettled('run-1');
|
||||||
|
expect(early).toBeDefined();
|
||||||
|
|
||||||
|
await svc.finalizeRun('run-1', 'ws-1', 'completed');
|
||||||
|
|
||||||
|
// The reference grabbed before settle resolves with the written outcome, even
|
||||||
|
// though the notifier was dropped from the map on resolve (bounded).
|
||||||
|
await expect(early).resolves.toEqual({
|
||||||
|
status: 'succeeded',
|
||||||
|
error: null,
|
||||||
|
terminalWriteFailed: false,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
it('recordStep / linkAssistantMessage are best-effort: a repo failure is swallowed', async () => {
|
||||||
@@ -525,3 +577,197 @@ describe('AiChatRunService run lifecycle', () => {
|
|||||||
).resolves.toBeUndefined();
|
).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#487 AiChatRunService.supersede (CAS)', () => {
|
||||||
|
const chat = 'chat-1';
|
||||||
|
const ws = 'ws-1';
|
||||||
|
|
||||||
|
it('degrade: no active run on the chat -> caller sends a normal turn', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => undefined),
|
||||||
|
findActiveByChat: jest.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'degrade' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalid: the target run belongs to a DIFFERENT chat -> 400', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: 'run-x',
|
||||||
|
chatId: 'other-chat',
|
||||||
|
workspaceId: ws,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({ kind: 'invalid' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mismatch: a DIFFERENT run is active than the one targeted -> current runId', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => ({ id: 'run-x', chatId: chat, workspaceId: ws })),
|
||||||
|
findActiveByChat: jest.fn(async () => ({
|
||||||
|
id: 'run-live',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'running',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
expect(await svc.supersede(chat, 'run-x', ws)).toEqual({
|
||||||
|
kind: 'mismatch',
|
||||||
|
activeRunId: 'run-live',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ready: the target IS active -> stop it, await its (fast) settle, free the slot', async () => {
|
||||||
|
// Simulate a live long TOOL (NOT a slow UPDATE): the run stays active until an
|
||||||
|
// explicit Stop unwinds it; commit-1's race makes that settle land quickly.
|
||||||
|
// The abort listener stands in for streamText's onAbort -> finalizeRun.
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: 'run-1',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'aborted',
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
findActiveByChat: jest.fn(async () => ({
|
||||||
|
id: 'run-1',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'running',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||||
|
handle.signal.addEventListener('abort', () => {
|
||||||
|
void svc.finalizeRun('run-1', ws, 'aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// supersede: getRun -> getActiveByChat(==target) -> requestStop -> the abort
|
||||||
|
// listener settles the run -> awaitSettled resolves -> ready.
|
||||||
|
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||||
|
kind: 'ready',
|
||||||
|
});
|
||||||
|
expect(handle.signal.aborted).toBe(true); // Stop reached the run
|
||||||
|
});
|
||||||
|
|
||||||
|
it('timeout: the target never settles within W -> 409 SUPERSEDE_TIMEOUT (nothing persisted)', async () => {
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
||||||
|
findActiveByChat: jest.fn(async () => ({
|
||||||
|
id: 'run-1',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'running',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||||
|
// Do NOT settle the run: a tiny W elapses -> timeout.
|
||||||
|
const result = await svc.supersede(chat, 'run-1', ws, 30);
|
||||||
|
expect(result).toEqual({ kind: 'timeout' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ready then a DUPLICATE supersede POST degrades (the run is already gone)', async () => {
|
||||||
|
let active: unknown = {
|
||||||
|
id: 'run-1',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: 'run-1',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'aborted',
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
findActiveByChat: jest.fn(async () => active),
|
||||||
|
finalizeIfActive: jest.fn(async () => {
|
||||||
|
active = undefined; // settling frees the active slot
|
||||||
|
return { id: 'run-1', status: 'aborted' };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
const handle = await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||||
|
handle.signal.addEventListener('abort', () => {
|
||||||
|
void svc.finalizeRun('run-1', ws, 'aborted');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||||
|
kind: 'ready',
|
||||||
|
});
|
||||||
|
// The duplicate POST for the same target now finds no active run -> degrade.
|
||||||
|
expect(await svc.supersede(chat, 'run-1', ws)).toEqual({ kind: 'degrade' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconcileStaleRuns: aborts a stale run with NO entry/zombie; NEVER touches a live entry', async () => {
|
||||||
|
const finalizeIfActive = jest.fn(async () => ({ id: 'x', status: 'aborted' }));
|
||||||
|
const repo = makeRepo({
|
||||||
|
insert: jest.fn(async (v: any) => ({
|
||||||
|
id: 'live-1',
|
||||||
|
status: 'running',
|
||||||
|
chatId: v.chatId,
|
||||||
|
workspaceId: v.workspaceId,
|
||||||
|
})),
|
||||||
|
finalizeIfActive,
|
||||||
|
findStaleActive: jest.fn(async () => [
|
||||||
|
{ id: 'orphan-1', workspaceId: ws, chatId: 'c-orphan' },
|
||||||
|
{ id: 'live-1', workspaceId: ws, chatId: 'c-live' },
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
// A LIVE run this replica owns (in the `active` map).
|
||||||
|
await svc.beginRun({ chatId: 'c-live', workspaceId: ws, userId: 'u1' });
|
||||||
|
expect(svc.isLocallyActive('live-1')).toBe(true);
|
||||||
|
|
||||||
|
const aborted = await svc.reconcileStaleRuns(15 * 60 * 1000);
|
||||||
|
expect(aborted).toBe(1);
|
||||||
|
// The orphan (no entry) was aborted; the live entry was NEVER passed to the DB.
|
||||||
|
expect(finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||||
|
expect(finalizeIfActive).toHaveBeenCalledWith(
|
||||||
|
'orphan-1',
|
||||||
|
ws,
|
||||||
|
expect.objectContaining({ status: 'aborted' }),
|
||||||
|
);
|
||||||
|
expect(svc.isLocallyActive('live-1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gave-up zombie: supersede applies the intended status (settleZombie) then is ready', async () => {
|
||||||
|
let healthy = false;
|
||||||
|
let active: unknown = {
|
||||||
|
id: 'run-1',
|
||||||
|
chatId: chat,
|
||||||
|
workspaceId: ws,
|
||||||
|
status: 'running',
|
||||||
|
};
|
||||||
|
const repo = makeRepo({
|
||||||
|
findById: jest.fn(async () => ({ id: 'run-1', chatId: chat, workspaceId: ws })),
|
||||||
|
findActiveByChat: jest.fn(async () => active),
|
||||||
|
finalizeIfActive: jest.fn(async () => {
|
||||||
|
if (!healthy) throw new Error('db down');
|
||||||
|
active = undefined;
|
||||||
|
return { id: 'run-1', status: 'aborted' };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
|
const svc = new AiChatRunService(repo as never, makeEnv() as never);
|
||||||
|
await svc.beginRun({ chatId: chat, workspaceId: ws, userId: 'u1' });
|
||||||
|
|
||||||
|
// The run's terminal write gives up -> zombie (row still 'running').
|
||||||
|
await svc.finalizeRun('run-1', ws, 'aborted');
|
||||||
|
expect(svc.hasZombie('run-1')).toBe(true);
|
||||||
|
|
||||||
|
// The DB recovers; supersede awaits the (already-resolved, terminalWriteFailed)
|
||||||
|
// settle, then settleZombie applies the intended status -> ready.
|
||||||
|
healthy = true;
|
||||||
|
expect(await svc.supersede(chat, 'run-1', ws, 10_000)).toEqual({
|
||||||
|
kind: 'ready',
|
||||||
|
});
|
||||||
|
expect(svc.hasZombie('run-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,88 @@ export class RunAlreadyActiveError extends Error {
|
|||||||
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
export type TurnTerminalStatus = 'completed' | 'error' | 'aborted';
|
||||||
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
export type RunTerminalStatus = 'succeeded' | 'failed' | 'aborted';
|
||||||
|
|
||||||
|
/** The terminal run statuses — the row is done once it reads one of these. */
|
||||||
|
export const RUN_TERMINAL_STATUSES: readonly RunTerminalStatus[] = [
|
||||||
|
'succeeded',
|
||||||
|
'failed',
|
||||||
|
'aborted',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Whether a persisted run status is terminal (settled). */
|
||||||
|
export function isRunTerminal(status: string | null | undefined): boolean {
|
||||||
|
return (
|
||||||
|
status === 'succeeded' || status === 'failed' || status === 'aborted'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: the outcome a run's {@link AiChatRunService.finalizeRun} settled with.
|
||||||
|
* `terminalWriteFailed` = the terminal write GAVE UP after the bounded retry, so
|
||||||
|
* the row is still non-terminal ('running') and a ZOMBIE record holds the
|
||||||
|
* `intended` status for a later re-drive (reconcile / supersede / boot sweep). A
|
||||||
|
* subscriber (supersede, #487 commit 3) uses this to decide whether the slot is
|
||||||
|
* genuinely free or must first have the intended status applied.
|
||||||
|
*/
|
||||||
|
export interface RunSettleOutcome {
|
||||||
|
status: RunTerminalStatus;
|
||||||
|
error: string | null;
|
||||||
|
terminalWriteFailed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: how long a supersede waits for the target run to settle after Stop before
|
||||||
|
* it degrades to `SUPERSEDE_TIMEOUT`. W=10s is generous under a HEALTHY DB: commit
|
||||||
|
* 1's race-on-abort makes an in-app tool abort->settle in ms/hundreds of ms, so a
|
||||||
|
* live run releases its slot well within the window. Under a DB brownout the
|
||||||
|
* timeout is normal (the write cannot land); W must NOT be raised to paper
|
||||||
|
* over a slow DB — a SUPERSEDE_TIMEOUT is the honest signal (nothing persisted,
|
||||||
|
* the composer keeps the user's text). Env-tunable for ops, default 10s.
|
||||||
|
*/
|
||||||
|
export const SUPERSEDE_SETTLE_TIMEOUT_MS = (() => {
|
||||||
|
const raw = Number(process.env.AI_CHAT_SUPERSEDE_TIMEOUT_MS);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : 10_000;
|
||||||
|
})();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: the result of the supersede CAS ({@link AiChatRunService.supersede}).
|
||||||
|
* - `degrade` : no active run on the chat (it ended between click and POST) —
|
||||||
|
* the caller sends a NORMAL turn (NOT a mismatch);
|
||||||
|
* - `invalid` : the target runId belongs to a DIFFERENT chat (malformed CAS 400);
|
||||||
|
* - `mismatch` : a DIFFERENT run is active than the one the client targeted —
|
||||||
|
* 409 SUPERSEDE_TARGET_MISMATCH carrying the current `activeRunId`
|
||||||
|
* (the client does NOT auto-retry);
|
||||||
|
* - `timeout` : the target did not settle within W — 409 SUPERSEDE_TIMEOUT,
|
||||||
|
* nothing persisted;
|
||||||
|
* - `ready` : the target was stopped AND settled (or its zombie's intended was
|
||||||
|
* applied) — the slot is free; the caller may beginRun the new run.
|
||||||
|
*/
|
||||||
|
export type SupersedeResult =
|
||||||
|
| { kind: 'degrade' }
|
||||||
|
| { kind: 'invalid' }
|
||||||
|
| { kind: 'mismatch'; activeRunId: string }
|
||||||
|
| { kind: 'timeout' }
|
||||||
|
| { kind: 'ready' };
|
||||||
|
|
||||||
|
/** A one-shot settle notifier (#487): `resolve` is called EXACTLY ONCE. */
|
||||||
|
interface Deferred<T> {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: a run whose terminal write GAVE UP (every bounded attempt failed). The
|
||||||
|
* row is stranded non-terminal ('running'); this record is the ONLY thing that
|
||||||
|
* distinguishes it from a live run, and carries the `intended` terminal status so
|
||||||
|
* a re-drive can apply it via the conditional UPDATE. Process-local (phase-1
|
||||||
|
* single-process assumption): a restart drops it, and the boot sweep then writes
|
||||||
|
* 'aborted' over the intended — a documented loss (see finalizeRun).
|
||||||
|
*/
|
||||||
|
interface ZombieRun {
|
||||||
|
workspaceId: string;
|
||||||
|
chatId: string;
|
||||||
|
intended: { status: RunTerminalStatus; error: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
export function mapTurnStatusToRun(
|
export function mapTurnStatusToRun(
|
||||||
status: TurnTerminalStatus,
|
status: TurnTerminalStatus,
|
||||||
): RunTerminalStatus {
|
): RunTerminalStatus {
|
||||||
@@ -101,6 +183,22 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
// uptime — negligible in phase 1's single process.
|
// uptime — negligible in phase 1's single process.
|
||||||
private readonly settled = new Set<string>();
|
private readonly settled = new Set<string>();
|
||||||
|
|
||||||
|
// #487 runId -> one-shot settle notifier. Kept in a SEPARATE map from `active`
|
||||||
|
// ON PURPOSE: it must OUTLIVE the `active.delete` claim inside finalizeRun (the
|
||||||
|
// claim frees the slot the instant finalize starts), so a subscriber can still
|
||||||
|
// await the outcome after the entry is gone. Created in beginRun, resolved
|
||||||
|
// EXACTLY ONCE in finalizeRun, then removed (bounded). Absence => this replica
|
||||||
|
// has no live notifier: a subscriber falls back to the zombie map, then to the
|
||||||
|
// row (see peekSettled). Process-local (phase-1 single-process assumption).
|
||||||
|
private readonly settledPromises = new Map<string, Deferred<RunSettleOutcome>>();
|
||||||
|
|
||||||
|
// #487 runId -> ZOMBIE record: a run whose terminal write gave up (row stranded
|
||||||
|
// non-terminal). BOUNDED — an entry is added only on give-up and removed on a
|
||||||
|
// successful re-drive (settleZombie) or when the row is found already terminal;
|
||||||
|
// a process restart clears it (and the boot sweep settles the stranded row).
|
||||||
|
// Process-local (phase-1 single-process assumption).
|
||||||
|
private readonly zombies = new Map<string, ZombieRun>();
|
||||||
|
|
||||||
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
// Bounded retry for the terminal write (F6): a single PK UPDATE can fail
|
||||||
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
// transiently under many fire-and-forget writes (pool exhaustion, deadlock, a
|
||||||
// brief connection blip). Riding out that blip in-place matters because the
|
// brief connection blip). Riding out that blip in-place matters because the
|
||||||
@@ -224,6 +322,10 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
chatId: args.chatId,
|
chatId: args.chatId,
|
||||||
workspaceId: args.workspaceId,
|
workspaceId: args.workspaceId,
|
||||||
});
|
});
|
||||||
|
// #487: arm the one-shot settle notifier BEFORE returning, so a subscriber
|
||||||
|
// that races in immediately after begin always finds a promise to await. It
|
||||||
|
// is resolved exactly once when the run settles (or gives up).
|
||||||
|
this.settledPromises.set(run.id, this.makeDeferred<RunSettleOutcome>());
|
||||||
return { runId: run.id, signal: controller.signal };
|
return { runId: run.id, signal: controller.signal };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,47 +365,43 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize a run to its terminal status (succeeded / failed / aborted),
|
* Finalize a run to its terminal status (succeeded / failed / aborted) via a
|
||||||
* stamping finishedAt + any error. Best-effort, but ROBUST against a transient
|
* CONDITIONAL UPDATE, stamping finishedAt + any error. Atomically safe against a
|
||||||
* terminal-write failure (F6) AND atomically safe against a concurrent settle.
|
* concurrent settle AND robust against a transient terminal-write failure.
|
||||||
*
|
*
|
||||||
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
* ATOMIC ONCE-CLAIM (the gate must close in ONE synchronous tick): two
|
||||||
* finalizeRun calls for the SAME run can race — the documented real path is
|
* finalizeRun calls for the SAME run can race — the documented real path is
|
||||||
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
* AiChatService.stream's safety-net catch settling the turn to 'error' while a
|
||||||
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
* streamText terminal callback (onFinish/onAbort/onError) ALSO settles it. The
|
||||||
* `settled.has` check alone is NOT a gate: it is read BEFORE the awaited UPDATE,
|
* claim happens via `active.delete`, a SYNCHRONOUS check-and-clear with NO await
|
||||||
* so two callers can both see `false` and both write the row (last-write-wins
|
* between the gate and the entry removal: the second concurrent caller finds the
|
||||||
* clobbers the real terminal status, and the bounded retry only widens that
|
* entry already gone and returns in the same tick, before any UPDATE.
|
||||||
* window). The claim therefore happens via `active.delete`, a SYNCHRONOUS
|
|
||||||
* check-and-clear with NO await between the gate and the entry removal: the
|
|
||||||
* second concurrent caller finds the entry already gone and returns in the same
|
|
||||||
* tick, before any UPDATE. The transition "nobody is finalizing" -> "I am
|
|
||||||
* finalizing" is thus a single atomic step.
|
|
||||||
*
|
*
|
||||||
* ORDER MATTERS (F6): once we own the claim, the terminal UPDATE happens FIRST;
|
* ALL TERMINAL WRITES ARE CONDITIONAL (#487): `finalizeIfActive` only flips a
|
||||||
* only once it SUCCEEDS do we record the run as settled. If the UPDATE fails on
|
* row still in pending|running (mirror of the assistant message's
|
||||||
* every bounded attempt we RESTORE the in-memory entry, leave the run UNsettled,
|
* `onlyIfStreaming`). So even a settle that DID reach the UPDATE (e.g. a
|
||||||
* and emit an ERROR signal that the row is left non-terminal 'running' (which
|
* reconcile stamp racing an owner finalize) can never clobber a terminal status
|
||||||
* would 409 every future turn in the chat until recovery). An in-process retry
|
* — the loser matches nothing and is a benign no-op. `active.delete` is the
|
||||||
* by a LATER settle is only POSSIBLE, never guaranteed: it needs (a) the entry
|
* fast, in-process gate; the conditional WHERE is the authoritative one.
|
||||||
* to have been restored at the give-up path AND (b) a fresh settler to arrive
|
|
||||||
* AFTER that restore. A concurrent settler that arrives DURING the retry window
|
|
||||||
* — while the entry is deleted for backoff and not yet restored — is consumed at
|
|
||||||
* the synchronous `active.delete` claim (it finds nothing to delete and returns
|
|
||||||
* a no-op), so it does NOT become an in-process retrier. The NO-streamText path
|
|
||||||
* (the turn threw before streamText was wired, so ONLY the safety-net ever
|
|
||||||
* settles) likewise has no second in-process settler at all. The UNCONDITIONAL
|
|
||||||
* backstop in every case is the boot sweep on the next restart (phase 1 has no
|
|
||||||
* periodic in-process sweep); the retained entry is bounded (cleared on restart)
|
|
||||||
* and harmless meanwhile.
|
|
||||||
*
|
*
|
||||||
* IDEMPOTENT on SUCCESS (#184 review): the terminal write happens AT MOST ONCE
|
* ZOMBIE ON GIVE-UP (#487): if every bounded attempt THROWS (the DB is down for
|
||||||
* per run. After a successful write the once-gate keys off {@link settled} (the
|
* the whole finalize), we do NOT restore the entry. The row is stranded
|
||||||
* terminal row already written) so a settle arriving AFTER the entry was already
|
* non-terminal ('running'); we record a ZOMBIE `{ terminalWriteFailed, intended
|
||||||
* dropped-and-settled returns early; a settle racing the in-flight write is
|
* }` (the ONLY thing distinguishing this dead run from a live one) and resolve
|
||||||
* stopped earlier still, by the `active.delete` claim. Either way a genuine
|
* the settle notifier with `terminalWriteFailed: true`. A restore would make the
|
||||||
* double-settle collapses to a single write and a late settle can never clobber
|
* zombie indistinguishable from a live run to every reader; instead a re-drive
|
||||||
* the real terminal status or double-write the row.
|
* (settleZombie, called by the periodic reconcile / supersede / opportunistic
|
||||||
|
* paths) applies the intended status later via the same conditional UPDATE.
|
||||||
|
*
|
||||||
|
* DOCUMENTED LOSS (#487, single-process phase 1): if the process RESTARTS before
|
||||||
|
* a zombie is re-driven, the in-memory zombie map is gone and the boot sweep
|
||||||
|
* (unconditional) writes 'aborted' over the ACTUAL intended status. This is
|
||||||
|
* unavoidable while the run lifecycle is single-process — there is no durable
|
||||||
|
* record of `intended`; a cross-process durable intent is deferred to phase 2.
|
||||||
|
*
|
||||||
|
* IDEMPOTENT: the settle notifier resolves EXACTLY ONCE; a second settle is
|
||||||
|
* stopped at `settled.has` or the `active.delete` claim, so a double-settle
|
||||||
|
* collapses to a single write and can never double-resolve or clobber the row.
|
||||||
*/
|
*/
|
||||||
async finalizeRun(
|
async finalizeRun(
|
||||||
runId: string,
|
runId: string,
|
||||||
@@ -314,13 +412,17 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
// ---- Atomic once-claim (synchronous; NO await before the gate closes) ----
|
||||||
// Already terminally written -> idempotent no-op.
|
// Already terminally written -> idempotent no-op.
|
||||||
if (this.settled.has(runId)) return;
|
if (this.settled.has(runId)) return;
|
||||||
// Capture the entry BEFORE the delete so a total-failure path can restore it.
|
// Capture the entry BEFORE the delete for the give-up log context.
|
||||||
const entry = this.active.get(runId);
|
const entry = this.active.get(runId);
|
||||||
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
// SYNCHRONOUS check-and-clear: the FIRST caller deletes (claims) the entry;
|
||||||
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
// any concurrent SECOND caller finds nothing to delete and returns HERE, in
|
||||||
// the same tick, before any await — so it can never reach the UPDATE.
|
// the same tick, before any await — so it can never reach the UPDATE.
|
||||||
if (!this.active.delete(runId)) return;
|
if (!this.active.delete(runId)) return;
|
||||||
|
|
||||||
|
const status = mapTurnStatusToRun(turnStatus);
|
||||||
|
const err = error ?? null;
|
||||||
|
const chatId = entry?.chatId ?? 'unknown';
|
||||||
|
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
for (
|
for (
|
||||||
let attempt = 1;
|
let attempt = 1;
|
||||||
@@ -328,47 +430,294 @@ export class AiChatRunService implements OnModuleInit {
|
|||||||
attempt++
|
attempt++
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await this.runRepo.update(runId, workspaceId, {
|
const row = await this.runRepo.finalizeIfActive(runId, workspaceId, {
|
||||||
status: mapTurnStatusToRun(turnStatus),
|
status,
|
||||||
finishedAt: new Date(),
|
error: err,
|
||||||
error: error ?? null,
|
|
||||||
});
|
});
|
||||||
// Terminal write landed: arm the once-gate. The entry is already gone
|
// No throw => the row is now terminal (we wrote it, or it was ALREADY
|
||||||
// (claimed above); we do NOT restore it. The slot is now free.
|
// terminal — another writer won the conditional UPDATE, a benign no-op).
|
||||||
this.settled.add(runId);
|
this.settled.add(runId);
|
||||||
|
this.zombies.delete(runId);
|
||||||
|
// Resolve with the persisted outcome: our status when WE wrote it, else
|
||||||
|
// the row's real terminal status (re-read on the already-terminal path so
|
||||||
|
// a subscriber never sees a status we did not actually persist).
|
||||||
|
const outcome: RunSettleOutcome = row
|
||||||
|
? { status, error: err, terminalWriteFailed: false }
|
||||||
|
: await this.readTerminalOutcome(runId, workspaceId, status, err);
|
||||||
|
this.resolveSettled(runId, outcome);
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err2) {
|
||||||
lastError = err;
|
lastError = err2;
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
`Failed to finalize run ${runId} (attempt ${attempt}/${
|
||||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
||||||
}): ${err instanceof Error ? err.message : 'unknown error'}`,
|
}): ${err2 instanceof Error ? err2.message : 'unknown error'}`,
|
||||||
);
|
);
|
||||||
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
if (attempt < AiChatRunService.FINALIZE_MAX_ATTEMPTS) {
|
||||||
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
await this.delay(AiChatRunService.FINALIZE_RETRY_BASE_MS * attempt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Every attempt failed: this is a give-up, materially worse than a per-attempt
|
// Every attempt threw: GIVE UP. The row is stranded non-terminal ('running').
|
||||||
// blip — the row is left NON-TERMINAL ('running'), so emit ONE explicit,
|
// Do NOT restore the entry (a restored entry is indistinguishable from a live
|
||||||
// greppable ERROR so an operator can tell "survived a blip" from "gave up, run
|
// run); leave a ZOMBIE record instead, and resolve the notifier as
|
||||||
// held in memory until recovery" (the last warn alone says only "attempt 3/3").
|
// terminalWriteFailed so a subscriber knows the slot still needs the intended
|
||||||
|
// status applied. One explicit, greppable ERROR so an operator can tell a
|
||||||
|
// give-up from a per-attempt blip.
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
`Run ${runId} (chat ${entry?.chatId ?? 'unknown'}) left NON-TERMINAL ` +
|
`Run ${runId} (chat ${chatId}) left NON-TERMINAL ('running'): terminal ` +
|
||||||
`('running'): terminal write failed after ${
|
`write failed after ${AiChatRunService.FINALIZE_MAX_ATTEMPTS} attempts; ` +
|
||||||
AiChatRunService.FINALIZE_MAX_ATTEMPTS
|
`ZOMBIE recorded (intended '${status}'), recovery deferred to reconcile / ` +
|
||||||
} attempts; entry retained in memory, recovery deferred to next settle / ` +
|
`supersede / boot sweep`,
|
||||||
`boot sweep`,
|
|
||||||
lastError,
|
lastError,
|
||||||
);
|
);
|
||||||
// RESTORE the claimed entry (and leave the run UNsettled) so a LATER settle
|
this.zombies.set(runId, {
|
||||||
// that arrives AFTER this restore MAY retry the terminal write — but that
|
workspaceId,
|
||||||
// in-process retry is NOT guaranteed (a concurrent settler caught in the retry
|
chatId,
|
||||||
// window above is consumed at the `active.delete` claim, and the no-streamText
|
intended: { status, error: err },
|
||||||
// path has no second settler at all). The UNCONDITIONAL backstop in every case
|
});
|
||||||
// is the boot sweep on the next restart; the restored entry is bounded and
|
this.resolveSettled(runId, { status, error: err, terminalWriteFailed: true });
|
||||||
// cleared on restart.
|
}
|
||||||
if (entry) this.active.set(runId, entry);
|
|
||||||
|
/**
|
||||||
|
* #487: re-drive a zombie run's intended terminal write (the conditional
|
||||||
|
* UPDATE). Called by the periodic reconcile (commit 4), an opportunistic
|
||||||
|
* single-chat reconcile, and supersede (commit 3). On success — the row is now
|
||||||
|
* terminal (written OR found already terminal) — the zombie is cleared and the
|
||||||
|
* once-gate armed; on another failure the zombie is kept for a later retry.
|
||||||
|
* Returns true when the row is now terminal. Best-effort; never throws.
|
||||||
|
*/
|
||||||
|
async settleZombie(runId: string): Promise<boolean> {
|
||||||
|
const z = this.zombies.get(runId);
|
||||||
|
if (!z) return false;
|
||||||
|
try {
|
||||||
|
await this.runRepo.finalizeIfActive(runId, z.workspaceId, {
|
||||||
|
status: z.intended.status,
|
||||||
|
error: z.intended.error,
|
||||||
|
});
|
||||||
|
this.zombies.delete(runId);
|
||||||
|
this.settled.add(runId);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Re-drive of zombie run ${runId} (chat ${z.chatId}) failed; will retry ` +
|
||||||
|
`later: ${err instanceof Error ? err.message : 'unknown error'}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487 reconcile clause (c): abort runs the DB still shows active (pending|
|
||||||
|
* running) but that this replica does NOT own — NO live entry AND NO zombie —
|
||||||
|
* and that have been UNTOUCHED past `staleMs` (from last-progress `updated_at`,
|
||||||
|
* NOT startedAt, so a legit long marathon is never a candidate). "No entry" is
|
||||||
|
* the PRIMARY gate: a live entry (an actively-executing run on this replica) is
|
||||||
|
* NEVER aborted, whatever its age. Returns the number aborted. Best-effort —
|
||||||
|
* never throws (a periodic-job failure must not crash the process).
|
||||||
|
*/
|
||||||
|
async reconcileStaleRuns(staleMs: number): Promise<number> {
|
||||||
|
let candidates: Array<{ id: string; workspaceId: string; chatId: string }>;
|
||||||
|
try {
|
||||||
|
candidates = await this.runRepo.findStaleActive(staleMs);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Reconcile (stale runs) query failed: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let aborted = 0;
|
||||||
|
for (const c of candidates) {
|
||||||
|
// PRIMARY gate: never touch a live entry, and never race a zombie we are
|
||||||
|
// already re-driving (settleZombie owns those).
|
||||||
|
if (this.active.has(c.id) || this.zombies.has(c.id)) continue;
|
||||||
|
try {
|
||||||
|
const row = await this.runRepo.finalizeIfActive(c.id, c.workspaceId, {
|
||||||
|
status: 'aborted',
|
||||||
|
error: 'Run aborted by reconcile: no live runner (stale).',
|
||||||
|
});
|
||||||
|
if (row) {
|
||||||
|
aborted += 1;
|
||||||
|
this.settled.add(c.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Reconcile abort of stale run ${c.id} failed: ${
|
||||||
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return aborted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: the run's settle outcome as seen by THIS replica, or undefined when it
|
||||||
|
* has no record (the caller then reads the row — the DB is the source of truth).
|
||||||
|
* A LIVE deferred (still settling, or resolved-but-not-yet-consumed) wins; a
|
||||||
|
* ZOMBIE synthesizes the give-up outcome. A subscriber (supersede) races this
|
||||||
|
* against a timeout.
|
||||||
|
*/
|
||||||
|
peekSettled(runId: string): Promise<RunSettleOutcome> | undefined {
|
||||||
|
const d = this.settledPromises.get(runId);
|
||||||
|
if (d) return d.promise;
|
||||||
|
const z = this.zombies.get(runId);
|
||||||
|
if (z) {
|
||||||
|
return Promise.resolve({
|
||||||
|
status: z.intended.status,
|
||||||
|
error: z.intended.error,
|
||||||
|
terminalWriteFailed: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: await a run's settle outcome, bounded by `timeoutMs`. Returns the
|
||||||
|
* outcome on settle, or undefined on TIMEOUT (or when this replica has no record
|
||||||
|
* of the run and its row is not terminal). Uses the LIVE settle notifier / the
|
||||||
|
* zombie synth when present; else reads the row (the DB is the source of truth
|
||||||
|
* once the in-memory record is gone). The subscriber (supersede) grabs this
|
||||||
|
* right after Stop; commit 1's race makes the settle land in ms on a healthy DB.
|
||||||
|
*/
|
||||||
|
async awaitSettled(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<RunSettleOutcome | undefined> {
|
||||||
|
const pending = this.peekSettled(runId);
|
||||||
|
if (pending) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const timeout = new Promise<undefined>((resolve) => {
|
||||||
|
timer = setTimeout(() => resolve(undefined), timeoutMs);
|
||||||
|
timer.unref?.();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await Promise.race([pending, timeout]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No live notifier and no zombie: read the row (already settled-and-written,
|
||||||
|
// or unknown here). A terminal row is an outcome; anything else -> undefined.
|
||||||
|
const row = await this.runRepo.findById(runId, workspaceId);
|
||||||
|
if (row && isRunTerminal(row.status)) {
|
||||||
|
return {
|
||||||
|
status: row.status as RunTerminalStatus,
|
||||||
|
error: row.error ?? null,
|
||||||
|
terminalWriteFailed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: the SERVER supersede CAS for `POST /stream { supersede: { runId: X } }`.
|
||||||
|
* Atomically transitions "X is the chat's active run" -> "X is stopped, settled,
|
||||||
|
* slot free" so the caller can start a replacement run. See {@link
|
||||||
|
* SupersedeResult} for the branch semantics.
|
||||||
|
*
|
||||||
|
* On a `ready` result the caller MUST still go through the normal beginRun gate
|
||||||
|
* (the partial unique index) — between the slot freeing here and beginRun a
|
||||||
|
* neighbouring tab's ordinary POST can win the slot (documented SLOT-THEFT: the
|
||||||
|
* loser then gets a MISMATCH carrying the NEW runId). There is also NO side-
|
||||||
|
* effect quiescence: an in-flight write of the stopped run may still land AFTER
|
||||||
|
* the new run starts (commit 1 stops the NEXT call, not one already committing),
|
||||||
|
* so the caller adds a prompt note to the new run.
|
||||||
|
*/
|
||||||
|
async supersede(
|
||||||
|
chatId: string,
|
||||||
|
targetRunId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
timeoutMs: number = SUPERSEDE_SETTLE_TIMEOUT_MS,
|
||||||
|
): Promise<SupersedeResult> {
|
||||||
|
// Validate the target belongs to THIS chat (a CAS targeting another chat's run
|
||||||
|
// is malformed -> 400). A missing row is NOT invalid: the run may have ended
|
||||||
|
// and been pruned; the active-run check below decides degrade vs mismatch.
|
||||||
|
const target = await this.getRun(targetRunId, workspaceId);
|
||||||
|
if (target && target.chatId !== chatId) return { kind: 'invalid' };
|
||||||
|
|
||||||
|
const active = await this.getActiveForChat(chatId, workspaceId);
|
||||||
|
// No active run: it ended between the client's click and this POST — this is a
|
||||||
|
// DEGRADE to a normal send, NOT a mismatch (the user's intent still holds).
|
||||||
|
if (!active) return { kind: 'degrade' };
|
||||||
|
// A DIFFERENT run is active than the one the client saw -> mismatch. The
|
||||||
|
// client does not auto-retry; it surfaces the new runId.
|
||||||
|
if (active.id !== targetRunId) {
|
||||||
|
return { kind: 'mismatch', activeRunId: active.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The target IS active: stop it, then await its settle within W.
|
||||||
|
await this.requestStop(targetRunId, workspaceId);
|
||||||
|
const outcome = await this.awaitSettled(targetRunId, workspaceId, timeoutMs);
|
||||||
|
if (!outcome) return { kind: 'timeout' };
|
||||||
|
// Gave up (terminal write failed): apply the intended status via the
|
||||||
|
// conditional UPDATE so the slot actually frees. If that ALSO fails, the row
|
||||||
|
// is still stranded -> treat as a timeout (nothing persisted for the new run).
|
||||||
|
if (outcome.terminalWriteFailed) {
|
||||||
|
const settled = await this.settleZombie(targetRunId);
|
||||||
|
if (!settled) return { kind: 'timeout' };
|
||||||
|
}
|
||||||
|
return { kind: 'ready' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #487 test/diagnostic seam: whether a give-up zombie is held for this run. */
|
||||||
|
hasZombie(runId: string): boolean {
|
||||||
|
return this.zombies.has(runId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #487: every zombie runId held on this replica (reconcile clause a, commit 4). */
|
||||||
|
zombieRunIds(): string[] {
|
||||||
|
return [...this.zombies.keys()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #487: create a one-shot deferred (resolve captured for a later single call). */
|
||||||
|
private makeDeferred<T>(): Deferred<T> {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #487: resolve a run's settle notifier EXACTLY ONCE, then drop it (bounded).
|
||||||
|
* A subscriber that already grabbed the promise still resolves; a later one
|
||||||
|
* falls back to the zombie map / the row (see peekSettled). */
|
||||||
|
private resolveSettled(runId: string, outcome: RunSettleOutcome): void {
|
||||||
|
const d = this.settledPromises.get(runId);
|
||||||
|
if (!d) return;
|
||||||
|
this.settledPromises.delete(runId);
|
||||||
|
d.resolve(outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** #487: read the persisted terminal outcome when the conditional finalize was a
|
||||||
|
* no-op (the row was already terminal). Falls back to the intended status when
|
||||||
|
* the read fails or the row is unexpectedly missing/non-terminal. */
|
||||||
|
private async readTerminalOutcome(
|
||||||
|
runId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
fallbackStatus: RunTerminalStatus,
|
||||||
|
fallbackError: string | null,
|
||||||
|
): Promise<RunSettleOutcome> {
|
||||||
|
try {
|
||||||
|
const row = await this.runRepo.findById(runId, workspaceId);
|
||||||
|
if (row && isRunTerminal(row.status)) {
|
||||||
|
return {
|
||||||
|
status: row.status as RunTerminalStatus,
|
||||||
|
error: row.error ?? null,
|
||||||
|
terminalWriteFailed: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to the intended status — best-effort only.
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: fallbackStatus,
|
||||||
|
error: fallbackError,
|
||||||
|
terminalWriteFailed: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
/** Small async backoff between terminal-write retries (F6). Isolated so it is
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fail-fast guard for beginRun failures (#486, commit 4).
|
||||||
|
*
|
||||||
|
* When runHooks.begin() rejects for a reason OTHER than RunAlreadyActiveError
|
||||||
|
* (e.g. a DB-pool blip), the turn must NOT continue untracked. The old code
|
||||||
|
* logged and streamed anyway, leaving a run with NO run-row: in autonomous mode
|
||||||
|
* nobody could abort it (/stop can't see it, disconnect doesn't abort it, and the
|
||||||
|
* one-run gate would admit a SECOND run) — an unstoppable invisible run until
|
||||||
|
* restart. The fix throws A_RUN_BEGIN_FAILED (503) BEFORE the first byte and
|
||||||
|
* before the user row is persisted.
|
||||||
|
*
|
||||||
|
* We drive `stream()` directly on a prototype instance wired with only the
|
||||||
|
* collaborators it touches before the throw, so the assertion is on the REAL
|
||||||
|
* control flow, not a mock of it.
|
||||||
|
*/
|
||||||
|
describe('AiChatService beginRun failure (#486)', () => {
|
||||||
|
function makeService(insertSpy: jest.Mock): AiChatService {
|
||||||
|
// Bypass the (heavy) DI constructor: exercise the real stream() method on a
|
||||||
|
// bare prototype instance with just the fields reached before the throw.
|
||||||
|
// `any` because the private `logger` field makes a typed intersection collapse.
|
||||||
|
const svc = Object.create(AiChatService.prototype);
|
||||||
|
svc.aiChatRepo = {
|
||||||
|
// Existing chat -> no insert path; chatId is kept as-is.
|
||||||
|
findById: jest.fn().mockResolvedValue({ id: 'chat1' }),
|
||||||
|
};
|
||||||
|
svc.aiChatMessageRepo = { insert: insertSpy };
|
||||||
|
svc.logger = new Logger('test');
|
||||||
|
return svc as AiChatService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseArgs = () => {
|
||||||
|
const write = jest.fn();
|
||||||
|
const res = {
|
||||||
|
raw: { write, writableEnded: false, headersSent: false },
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
user: { id: 'u1' } as never,
|
||||||
|
workspace: { id: 'w1' } as never,
|
||||||
|
sessionId: 's1',
|
||||||
|
// openPage undefined -> resolveOpenPageContext returns null without any DB
|
||||||
|
// call; chatId present -> the existing-chat path.
|
||||||
|
body: { chatId: 'chat1', messages: [] } as never,
|
||||||
|
res: res as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
write,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('throws A_RUN_BEGIN_FAILED (503) before the first byte and before persisting the user turn', async () => {
|
||||||
|
const insertSpy = jest.fn();
|
||||||
|
const svc = makeService(insertSpy);
|
||||||
|
const { write, ...args } = baseArgs();
|
||||||
|
|
||||||
|
const runHooks = {
|
||||||
|
begin: jest.fn().mockRejectedValue(new Error('DB pool exhausted')),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await svc.stream({ ...args, runHooks });
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
const http = caught as ServiceUnavailableException;
|
||||||
|
expect(http.getStatus()).toBe(503);
|
||||||
|
expect(http.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
|
||||||
|
|
||||||
|
// Fail-fast: nothing was written to the socket and NO user message row was
|
||||||
|
// persisted, so the turn left no orphan state to clean up.
|
||||||
|
expect(write).not.toHaveBeenCalled();
|
||||||
|
expect(insertSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still maps a lost-the-race RunAlreadyActiveError to a 409, not A_RUN_BEGIN_FAILED', async () => {
|
||||||
|
const insertSpy = jest.fn();
|
||||||
|
const svc = makeService(insertSpy);
|
||||||
|
const { write, ...args } = baseArgs();
|
||||||
|
|
||||||
|
const runHooks = {
|
||||||
|
begin: jest.fn().mockRejectedValue(new RunAlreadyActiveError('chat1')),
|
||||||
|
} as never;
|
||||||
|
|
||||||
|
let caught: unknown;
|
||||||
|
try {
|
||||||
|
await svc.stream({ ...args, runHooks });
|
||||||
|
} catch (e) {
|
||||||
|
caught = e;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(caught).toBeInstanceOf(ConflictException);
|
||||||
|
expect((caught as ConflictException).getResponse()).toMatchObject({
|
||||||
|
code: 'A_RUN_ALREADY_ACTIVE',
|
||||||
|
});
|
||||||
|
expect(write).not.toHaveBeenCalled();
|
||||||
|
expect(insertSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -115,7 +115,7 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
|||||||
|
|
||||||
// Drive the SAME applyFinalize the service calls (no duplicated logic).
|
// Drive the SAME applyFinalize the service calls (no duplicated logic).
|
||||||
async function dispatchFinalize(
|
async function dispatchFinalize(
|
||||||
repo: { insert: jest.Mock; update: jest.Mock },
|
repo: { insert: jest.Mock; finalizeOwner: jest.Mock },
|
||||||
assistantId: string | undefined,
|
assistantId: string | undefined,
|
||||||
flushed: AssistantFlush,
|
flushed: AssistantFlush,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -135,21 +135,22 @@ describe('finalizeAssistant dispatch (planFinalizeAssistant + applyFinalize)', (
|
|||||||
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
|
expect(planFinalizeAssistant(undefined)).toEqual({ kind: 'insert' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(a) upfront insert succeeded -> finalize UPDATEs the row by id', async () => {
|
it('(a) upfront insert succeeded -> finalize CONDITIONALLY updates the row by id (#487 owner-write)', async () => {
|
||||||
const repo = { insert: jest.fn(), update: jest.fn() };
|
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
||||||
const flushed = flushAssistant([], 'final answer', 'completed', {
|
const flushed = flushAssistant([], 'final answer', 'completed', {
|
||||||
finishReason: 'stop',
|
finishReason: 'stop',
|
||||||
});
|
});
|
||||||
await dispatchFinalize(repo, 'a1', flushed);
|
await dispatchFinalize(repo, 'a1', flushed);
|
||||||
expect(repo.update).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
// #487: the owner write is the CONDITIONAL finalizeOwner, not a raw update.
|
||||||
|
expect(repo.finalizeOwner).toHaveBeenCalledWith('a1', workspaceId, flushed);
|
||||||
expect(repo.insert).not.toHaveBeenCalled();
|
expect(repo.insert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
|
it('(b) upfront insert failed -> finalize INSERTs the terminal payload', async () => {
|
||||||
const repo = { insert: jest.fn(), update: jest.fn() };
|
const repo = { insert: jest.fn(), finalizeOwner: jest.fn() };
|
||||||
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
|
const flushed = flushAssistant([], 'partial', 'error', { error: 'boom' });
|
||||||
await dispatchFinalize(repo, undefined, flushed);
|
await dispatchFinalize(repo, undefined, flushed);
|
||||||
expect(repo.update).not.toHaveBeenCalled();
|
expect(repo.finalizeOwner).not.toHaveBeenCalled();
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(1);
|
expect(repo.insert).toHaveBeenCalledTimes(1);
|
||||||
const arg = repo.insert.mock.calls[0][0];
|
const arg = repo.insert.mock.calls[0][0];
|
||||||
// The fallback insert carries the terminal content/status/metadata.
|
// The fallback insert carries the terminal content/status/metadata.
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
HttpException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487 commit 3 — the single concurrency GATE (both modes) + the server supersede
|
||||||
|
* CAS, at the controller boundary. The gate + CAS run BEFORE res.hijack(), so a
|
||||||
|
* rejected concurrent start / a CAS branch returns clean JSON (an HttpException
|
||||||
|
* the controller's post-hijack catch re-serializes). These assert the OBSERVABLE
|
||||||
|
* HTTP contract against the real controller + a stubbed run service.
|
||||||
|
*/
|
||||||
|
describe('#487 AiChatController.stream — gate + supersede', () => {
|
||||||
|
const user = { id: 'u1' } as User;
|
||||||
|
|
||||||
|
function wsWith(autonomousRuns: boolean): Workspace {
|
||||||
|
return {
|
||||||
|
id: 'ws1',
|
||||||
|
settings: { ai: { chat: true, autonomousRuns } },
|
||||||
|
} as unknown as Workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReqRes(body: Record<string, unknown>) {
|
||||||
|
const req = {
|
||||||
|
raw: { sessionId: 'sess', once: jest.fn(), destroyed: false },
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
const res = {
|
||||||
|
raw: {
|
||||||
|
writableEnded: false,
|
||||||
|
headersSent: false,
|
||||||
|
on: jest.fn(),
|
||||||
|
once: jest.fn(),
|
||||||
|
setHeader: jest.fn(),
|
||||||
|
end: jest.fn(),
|
||||||
|
statusCode: 200,
|
||||||
|
flushHeaders: jest.fn(),
|
||||||
|
},
|
||||||
|
hijack: jest.fn(),
|
||||||
|
status: jest.fn().mockReturnThis(),
|
||||||
|
send: jest.fn(),
|
||||||
|
};
|
||||||
|
return { req, res };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeController(
|
||||||
|
runServiceOverrides: Record<string, jest.Mock>,
|
||||||
|
// The chat assertOwnedChat resolves. Default: a chat OWNED by `user` (u1), so
|
||||||
|
// the ownership gate is transparent to the gate/CAS assertions below. Pass a
|
||||||
|
// foreign-owner (or undefined) chat to exercise the #487 owner rejection.
|
||||||
|
chat: { creatorId: string } | undefined = { creatorId: 'u1' },
|
||||||
|
) {
|
||||||
|
const aiChatService = {
|
||||||
|
resolveRoleForRequest: jest.fn().mockResolvedValue(null),
|
||||||
|
getChatModel: jest.fn().mockResolvedValue({}),
|
||||||
|
stream: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const aiChatRunService = {
|
||||||
|
getActiveForChat: jest.fn().mockResolvedValue(undefined),
|
||||||
|
supersede: jest.fn(),
|
||||||
|
beginRun: jest.fn().mockResolvedValue({
|
||||||
|
runId: 'run-new',
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
}),
|
||||||
|
linkAssistantMessage: jest.fn(),
|
||||||
|
recordStep: jest.fn(),
|
||||||
|
finalizeRun: jest.fn(),
|
||||||
|
requestStop: jest.fn(),
|
||||||
|
...runServiceOverrides,
|
||||||
|
};
|
||||||
|
const aiChatRepo = { findById: jest.fn().mockResolvedValue(chat) };
|
||||||
|
const controller = new AiChatController(
|
||||||
|
aiChatService as never,
|
||||||
|
aiChatRunService as never,
|
||||||
|
aiChatRepo as never, // aiChatRepo
|
||||||
|
{} as never, // aiChatMessageRepo
|
||||||
|
{} as never, // aiTranscription
|
||||||
|
{} as never, // pageRepo
|
||||||
|
);
|
||||||
|
return { controller, aiChatService, aiChatRunService, aiChatRepo };
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeOf = (err: unknown) =>
|
||||||
|
(((err as HttpException).getResponse() as Record<string, unknown>) ?? {})
|
||||||
|
.code;
|
||||||
|
|
||||||
|
describe('single concurrency gate — BOTH modes reject the second tab with 409', () => {
|
||||||
|
for (const autonomousRuns of [true, false]) {
|
||||||
|
it(`rejects a concurrent start with 409 A_RUN_ALREADY_ACTIVE (autonomousRuns=${autonomousRuns})`, async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({
|
||||||
|
getActiveForChat: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'run-live', chatId: 'c1' }),
|
||||||
|
});
|
||||||
|
const { req, res } = makeReqRes({ chatId: 'c1' });
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await controller.stream(
|
||||||
|
req as never,
|
||||||
|
res as never,
|
||||||
|
user,
|
||||||
|
wsWith(autonomousRuns),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(ConflictException);
|
||||||
|
expect((thrown as HttpException).getStatus()).toBe(409);
|
||||||
|
expect(codeOf(thrown)).toBe('A_RUN_ALREADY_ACTIVE');
|
||||||
|
// Rejected BEFORE committing to the stream (no hijack, no service.stream).
|
||||||
|
expect(res.hijack).not.toHaveBeenCalled();
|
||||||
|
expect(aiChatRunService.getActiveForChat).toHaveBeenCalledWith(
|
||||||
|
'c1',
|
||||||
|
'ws1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// #487 [security, F1]: stream() MUST owner-gate an existing chat exactly like its
|
||||||
|
// six sibling endpoints, BEFORE the supersede CAS. Otherwise a same-workspace
|
||||||
|
// non-owner could POST a supersede against another user's chat and (a) harvest
|
||||||
|
// that user's active runId from the 409 SUPERSEDE_TARGET_MISMATCH body, then (b)
|
||||||
|
// requestStop the foreign run. The gate must reject FIRST — no run lookup, no
|
||||||
|
// supersede, no stop, no runId leak.
|
||||||
|
describe('cross-user ownership gate (F1)', () => {
|
||||||
|
it('a non-owner streaming against someone else\'s chat is rejected (403) with NO runId leak and NO foreign requestStop', async () => {
|
||||||
|
// A live run exists on the victim's chat. Without the gate the supersede CAS
|
||||||
|
// would run and (faithful to the run service) return a MISMATCH carrying the
|
||||||
|
// victim's runId — the exact leak. With the gate it must never be reached.
|
||||||
|
const getActiveForChat = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'run-victim', chatId: 'c-other' });
|
||||||
|
const supersede = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ kind: 'mismatch', activeRunId: 'run-victim' });
|
||||||
|
const requestStop = jest.fn();
|
||||||
|
const { controller, aiChatService } = makeController(
|
||||||
|
{ getActiveForChat, supersede, requestStop },
|
||||||
|
{ creatorId: 'someone-else' }, // the chat is NOT owned by u1
|
||||||
|
);
|
||||||
|
const { req, res } = makeReqRes({
|
||||||
|
chatId: 'c-other',
|
||||||
|
supersede: { runId: 'guessed-uuid' },
|
||||||
|
});
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
// Rejected by the ownership gate (403), the SAME shape the neighbors use.
|
||||||
|
expect(thrown).toBeInstanceOf(ForbiddenException);
|
||||||
|
expect((thrown as HttpException).getStatus()).toBe(403);
|
||||||
|
// Crucially NOT a 409 that would carry activeRunId — no runId is leaked.
|
||||||
|
const payload = JSON.stringify(
|
||||||
|
(thrown as HttpException).getResponse() ?? {},
|
||||||
|
);
|
||||||
|
expect(payload).not.toContain('run-victim');
|
||||||
|
expect(codeOf(thrown)).not.toBe('SUPERSEDE_TARGET_MISMATCH');
|
||||||
|
// The gate short-circuits BEFORE any run machinery runs.
|
||||||
|
expect(getActiveForChat).not.toHaveBeenCalled();
|
||||||
|
expect(supersede).not.toHaveBeenCalled();
|
||||||
|
expect(requestStop).not.toHaveBeenCalled();
|
||||||
|
expect(aiChatService.stream).not.toHaveBeenCalled();
|
||||||
|
expect(res.hijack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersede MISMATCH -> 409 SUPERSEDE_TARGET_MISMATCH carrying the current runId', async () => {
|
||||||
|
const { controller } = makeController({
|
||||||
|
supersede: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ kind: 'mismatch', activeRunId: 'run-other' }),
|
||||||
|
});
|
||||||
|
const { req, res } = makeReqRes({
|
||||||
|
chatId: 'c1',
|
||||||
|
supersede: { runId: 'run-x' },
|
||||||
|
});
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(ConflictException);
|
||||||
|
expect(codeOf(thrown)).toBe('SUPERSEDE_TARGET_MISMATCH');
|
||||||
|
expect(
|
||||||
|
((thrown as HttpException).getResponse() as Record<string, unknown>)
|
||||||
|
.activeRunId,
|
||||||
|
).toBe('run-other');
|
||||||
|
expect(res.hijack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersede TIMEOUT -> 409 SUPERSEDE_TIMEOUT, nothing streamed', async () => {
|
||||||
|
const { controller } = makeController({
|
||||||
|
supersede: jest.fn().mockResolvedValue({ kind: 'timeout' }),
|
||||||
|
});
|
||||||
|
const { req, res } = makeReqRes({
|
||||||
|
chatId: 'c1',
|
||||||
|
supersede: { runId: 'run-x' },
|
||||||
|
});
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(false));
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(ConflictException);
|
||||||
|
expect(codeOf(thrown)).toBe('SUPERSEDE_TIMEOUT');
|
||||||
|
expect(res.hijack).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersede INVALID (target on another chat) -> 400 SUPERSEDE_INVALID', async () => {
|
||||||
|
const { controller } = makeController({
|
||||||
|
supersede: jest.fn().mockResolvedValue({ kind: 'invalid' }),
|
||||||
|
});
|
||||||
|
const { req, res } = makeReqRes({
|
||||||
|
chatId: 'c1',
|
||||||
|
supersede: { runId: 'run-x' },
|
||||||
|
});
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersede without chatId -> 400 SUPERSEDE_INVALID', async () => {
|
||||||
|
const { controller, aiChatRunService } = makeController({});
|
||||||
|
const { req, res } = makeReqRes({ supersede: { runId: 'run-x' } });
|
||||||
|
let thrown: unknown;
|
||||||
|
try {
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||||
|
} catch (e) {
|
||||||
|
thrown = e;
|
||||||
|
}
|
||||||
|
expect(thrown).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(codeOf(thrown)).toBe('SUPERSEDE_INVALID');
|
||||||
|
expect(aiChatRunService.supersede).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersede READY -> proceeds to stream with superseded=true', async () => {
|
||||||
|
const { controller, aiChatService } = makeController({
|
||||||
|
supersede: jest.fn().mockResolvedValue({ kind: 'ready' }),
|
||||||
|
getActiveForChat: jest.fn().mockResolvedValue(undefined), // slot free after CAS
|
||||||
|
});
|
||||||
|
const { req, res } = makeReqRes({
|
||||||
|
chatId: 'c1',
|
||||||
|
supersede: { runId: 'run-x' },
|
||||||
|
});
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(true));
|
||||||
|
expect(res.hijack).toHaveBeenCalled();
|
||||||
|
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(true);
|
||||||
|
// The run hooks are always present now (both modes).
|
||||||
|
expect(aiChatService.stream.mock.calls[0][0].runHooks).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supersede DEGRADE -> proceeds to a normal send (superseded=false)', async () => {
|
||||||
|
const { controller, aiChatService } = makeController({
|
||||||
|
supersede: jest.fn().mockResolvedValue({ kind: 'degrade' }),
|
||||||
|
});
|
||||||
|
const { req, res } = makeReqRes({
|
||||||
|
chatId: 'c1',
|
||||||
|
supersede: { runId: 'run-x' },
|
||||||
|
});
|
||||||
|
await controller.stream(req as never, res as never, user, wsWith(false));
|
||||||
|
expect(aiChatService.stream).toHaveBeenCalledTimes(1);
|
||||||
|
expect(aiChatService.stream.mock.calls[0][0].superseded).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -418,6 +418,19 @@ export class AiChatController {
|
|||||||
|
|
||||||
const body = (req.body ?? {}) as AiChatStreamBody;
|
const body = (req.body ?? {}) as AiChatStreamBody;
|
||||||
|
|
||||||
|
// #487 [security]: gate cross-user access to an EXISTING chat BEFORE anything
|
||||||
|
// reads its runs. Every sibling endpoint (getRun/stop/history/rename/delete/
|
||||||
|
// attachRunStream) owner-checks the chat via assertOwnedChat; stream() must too.
|
||||||
|
// Without this a same-workspace member who is NOT the chat owner could POST a
|
||||||
|
// supersede against another user's chat and (a) harvest that user's active runId
|
||||||
|
// out of the 409 SUPERSEDE_TARGET_MISMATCH body, then (b) requestStop the foreign
|
||||||
|
// run. Gate on the chatId the client sent, when present — a brand-new chat (no
|
||||||
|
// chatId) has no prior owner to check. Mirrors /stop's owner check (403 as the
|
||||||
|
// neighbors do), and runs pre-hijack so it returns clean JSON.
|
||||||
|
if (body.chatId) {
|
||||||
|
await this.assertOwnedChat(body.chatId, user, workspace);
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the agent role for this turn BEFORE hijack: existing chats read it
|
// Resolve the agent role for this turn BEFORE hijack: existing chats read it
|
||||||
// from ai_chats.role_id (authoritative), a new chat from body.roleId. The
|
// from ai_chats.role_id (authoritative), a new chat from body.roleId. The
|
||||||
// role drives both the persona and the optional model override below.
|
// role drives both the persona and the optional model override below.
|
||||||
@@ -432,12 +445,66 @@ export class AiChatController {
|
|||||||
// HttpException) instead of breaking mid-stream.
|
// HttpException) instead of breaking mid-stream.
|
||||||
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
const model = await this.aiChatService.getChatModel(workspace.id, role);
|
||||||
|
|
||||||
// #184: one active run per chat. For an EXISTING chat reject a concurrent
|
// #487: server-side supersede CAS ("interrupt and send now"). When the client
|
||||||
// start with a clean 409 BEFORE hijack (the common double-submit / second-tab
|
// asks to replace a live run, atomically STOP it and wait for it to settle
|
||||||
// case), so the user gets JSON, not a mid-stream error. A brand-new chat
|
// before this turn claims the slot. Runs BEFORE hijack so every branch returns
|
||||||
// (no chatId) cannot have a prior run, and the DB partial unique index is the
|
// clean JSON (the client keeps the composer text on a 409). See
|
||||||
// backstop against any race that slips past this check.
|
// AiChatRunService.supersede for the branch semantics.
|
||||||
if (autonomousRuns && body.chatId) {
|
let superseded = false;
|
||||||
|
const supersedeRunId = body.supersede?.runId;
|
||||||
|
if (supersedeRunId) {
|
||||||
|
if (!body.chatId) {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'supersede requires chatId',
|
||||||
|
code: 'SUPERSEDE_INVALID',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await this.aiChatRunService.supersede(
|
||||||
|
body.chatId,
|
||||||
|
supersedeRunId,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
switch (result.kind) {
|
||||||
|
case 'invalid':
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: 'The run to supersede does not belong to this chat',
|
||||||
|
code: 'SUPERSEDE_INVALID',
|
||||||
|
});
|
||||||
|
case 'mismatch':
|
||||||
|
// A DIFFERENT run is active than the one the client targeted. Surface
|
||||||
|
// the CURRENT runId; the client does NOT auto-retry (a stale CAS).
|
||||||
|
throw new ConflictException({
|
||||||
|
message: 'A different agent run is now active on this chat',
|
||||||
|
code: 'SUPERSEDE_TARGET_MISMATCH',
|
||||||
|
activeRunId: result.activeRunId,
|
||||||
|
});
|
||||||
|
case 'timeout':
|
||||||
|
// The target did not settle within W — nothing was persisted, the
|
||||||
|
// composer keeps the text. NOT a rollback: the stop is already issued.
|
||||||
|
throw new ConflictException({
|
||||||
|
message:
|
||||||
|
'The previous run did not stop in time; nothing was sent — please try again',
|
||||||
|
code: 'SUPERSEDE_TIMEOUT',
|
||||||
|
});
|
||||||
|
case 'ready':
|
||||||
|
// The target stopped and settled: the slot is free. Prompt the new run
|
||||||
|
// that the old run's last operations may still be applying.
|
||||||
|
superseded = true;
|
||||||
|
break;
|
||||||
|
case 'degrade':
|
||||||
|
// The run already ended between click and POST — send normally.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// #487: one active run per chat — ENFORCED IN BOTH MODES now (legacy mode used
|
||||||
|
// to have NO gate, so two tabs streamed two parallel turns on one chat, which
|
||||||
|
// interleaved history and crashed convertToModelMessages). Reject a concurrent
|
||||||
|
// start with a clean pre-hijack 409 (double-submit / second-tab). A brand-new
|
||||||
|
// chat (no chatId) cannot have a prior run, and the DB partial unique index in
|
||||||
|
// beginRun is the authoritative backstop for any race that slips past here
|
||||||
|
// (including a slot stolen between a supersede release and beginRun).
|
||||||
|
if (body.chatId) {
|
||||||
const active = await this.aiChatRunService.getActiveForChat(
|
const active = await this.aiChatRunService.getActiveForChat(
|
||||||
body.chatId,
|
body.chatId,
|
||||||
workspace.id,
|
workspace.id,
|
||||||
@@ -446,107 +513,94 @@ export class AiChatController {
|
|||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
message: 'An agent run is already in progress for this chat',
|
message: 'An agent run is already in progress for this chat',
|
||||||
code: 'A_RUN_ALREADY_ACTIVE',
|
code: 'A_RUN_ALREADY_ACTIVE',
|
||||||
|
activeRunId: active.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run-lifecycle hooks (#184), only when the flag is on. They wrap the turn in
|
// #487: the turn is ALWAYS a first-class RUN now (both modes). The mode
|
||||||
// a durable run whose abort is governed by the run (explicit stop), persist
|
// difference is only the abort semantics on a browser disconnect (onClose
|
||||||
// its progress, and settle its terminal status — see AiChatRunService.
|
// below). currentRunId is captured at begin so a legacy disconnect can stop
|
||||||
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
// the run through its stop lever.
|
||||||
? {
|
let currentRunId: string | undefined;
|
||||||
begin: async (chatId) => {
|
const runHooks: AiChatRunHooks = {
|
||||||
const handle = await this.aiChatRunService.beginRun({
|
begin: async (chatId) => {
|
||||||
chatId,
|
const handle = await this.aiChatRunService.beginRun({
|
||||||
workspaceId: workspace.id,
|
chatId,
|
||||||
userId: user.id,
|
workspaceId: workspace.id,
|
||||||
trigger: 'user',
|
userId: user.id,
|
||||||
});
|
trigger: 'user',
|
||||||
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
|
});
|
||||||
// frame) so a tab that attaches in the begin->seed window finds an
|
currentRunId = handle?.runId;
|
||||||
// entry to wait on. Gated on AI_CHAT_RESUMABLE_STREAM: with the flag
|
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
|
||||||
// off nothing is registered and attach always 204s.
|
// frame) so a tab that attaches in the begin->seed window finds an entry
|
||||||
if (
|
// to wait on. Gated on AI_CHAT_RESUMABLE_STREAM.
|
||||||
handle?.runId &&
|
if (
|
||||||
this.environment?.isAiChatResumableStreamEnabled?.()
|
handle?.runId &&
|
||||||
) {
|
this.environment?.isAiChatResumableStreamEnabled?.()
|
||||||
this.streamRegistry?.open(chatId, handle.runId);
|
) {
|
||||||
}
|
this.streamRegistry?.open(chatId, handle.runId);
|
||||||
return handle;
|
|
||||||
},
|
|
||||||
onAssistantSeeded: (runId, messageId) =>
|
|
||||||
this.aiChatRunService.linkAssistantMessage(
|
|
||||||
runId,
|
|
||||||
workspace.id,
|
|
||||||
messageId,
|
|
||||||
),
|
|
||||||
onStep: (runId, stepCount) =>
|
|
||||||
void this.aiChatRunService.recordStep(
|
|
||||||
runId,
|
|
||||||
workspace.id,
|
|
||||||
stepCount,
|
|
||||||
),
|
|
||||||
onSettled: (runId, status, error) =>
|
|
||||||
this.aiChatRunService.finalizeRun(
|
|
||||||
runId,
|
|
||||||
workspace.id,
|
|
||||||
status,
|
|
||||||
error,
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
: undefined;
|
return handle;
|
||||||
|
},
|
||||||
|
onAssistantSeeded: (runId, messageId) =>
|
||||||
|
this.aiChatRunService.linkAssistantMessage(
|
||||||
|
runId,
|
||||||
|
workspace.id,
|
||||||
|
messageId,
|
||||||
|
),
|
||||||
|
onStep: (runId, stepCount) =>
|
||||||
|
void this.aiChatRunService.recordStep(runId, workspace.id, stepCount),
|
||||||
|
onSettled: (runId, status, error) =>
|
||||||
|
this.aiChatRunService.finalizeRun(runId, workspace.id, status, error),
|
||||||
|
};
|
||||||
|
|
||||||
// Abort the agent loop when the client disconnects. `close` also fires on
|
// Handle a client disconnect. `close` also fires on normal completion, so only
|
||||||
// normal completion, so only abort when the response has not finished
|
// act when the response has not finished writing (a genuine disconnect). `once`
|
||||||
// writing (a genuine disconnect). `once` fires at most once and self-removes;
|
// fires at most once and self-removes; we also drop it on response `finish`.
|
||||||
// we also drop it on response `finish` so it never lingers after the stream
|
|
||||||
// completes normally (the AI SDK pipes the response fire-and-forget, so we
|
|
||||||
// cannot simply remove it once `stream()` returns).
|
|
||||||
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: wall-clock at
|
// DIAGNOSTIC (Safari stream-drop investigation) — temporary: wall-clock at
|
||||||
// which a Safari disconnect is observed, measured from request receipt.
|
// which a Safari disconnect is observed, measured from request receipt.
|
||||||
const reqStartedAt = Date.now();
|
const reqStartedAt = Date.now();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const onClose = (): void => {
|
const onClose = (): void => {
|
||||||
// A genuine disconnect leaves the response unfinished (unlike a normal
|
|
||||||
// completion, which also fires `close`). Such a drop — e.g. a reverse
|
|
||||||
// proxy cutting the SSE mid-answer — is otherwise invisible server-side,
|
|
||||||
// so log it here.
|
|
||||||
if (!res.raw.writableEnded) {
|
if (!res.raw.writableEnded) {
|
||||||
if (autonomousRuns) {
|
if (autonomousRuns) {
|
||||||
// #184: the turn is a DETACHED run. A disconnect must NOT abort it —
|
// #184: a DETACHED run — a disconnect must NOT stop it. The run keeps
|
||||||
// the run keeps executing and persisting server-side; the client
|
// executing and persisting server-side; the client reconnects via
|
||||||
// reconnects via /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
// /ai-chat/run (or re-stops via /ai-chat/stop). Log only.
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`AI chat stream: client disconnected; run continues server-side ` +
|
`AI chat stream: client disconnected; run continues server-side ` +
|
||||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
// #487: legacy — a disconnect ENDS the turn, but the turn is now a RUN,
|
||||||
|
// so stop it through the run's stop lever (requestStop). streamText no
|
||||||
|
// longer consumes the socket signal (effectiveSignal is the run signal),
|
||||||
|
// so aborting `controller` would do nothing; requestStop aborts the run.
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`AI chat stream: client disconnected before completion; aborting turn ` +
|
`AI chat stream: client disconnected before completion; stopping the ` +
|
||||||
`(elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
`run (elapsed=${Date.now() - reqStartedAt}ms since request received)`,
|
||||||
);
|
);
|
||||||
controller.abort();
|
if (currentRunId) {
|
||||||
|
void this.aiChatRunService.requestStop(currentRunId, workspace.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
req.raw.once('close', onClose);
|
req.raw.once('close', onClose);
|
||||||
res.raw.once('finish', () => req.raw.off('close', onClose));
|
res.raw.once('finish', () => req.raw.off('close', onClose));
|
||||||
|
|
||||||
// #184: in detached mode the turn is NOT aborted on disconnect, so the SDK's
|
// #184/#487: the run/pipe can outlive the socket in BOTH modes now (autonomous
|
||||||
// pipe keeps writing to a socket the client may have dropped — for the rest of
|
// keeps going; legacy keeps going until requestStop's abort unwinds the turn).
|
||||||
// the (continuing) run. A write to the dead socket can emit an 'error' on the
|
// The SDK's pipe may then write to a dropped socket and emit an 'error' on the
|
||||||
// raw response; without a listener that surfaces as an unhandled error event.
|
// raw response — swallow it so it never surfaces as an unhandled error event.
|
||||||
// Swallow it (the run continues server-side regardless). Legacy mode aborts on
|
res.raw.on('error', (err) => {
|
||||||
// disconnect, so it does not need this and keeps its exact prior behavior.
|
this.logger.debug(
|
||||||
if (autonomousRuns) {
|
`AI chat stream: post-disconnect socket error swallowed: ${
|
||||||
res.raw.on('error', (err) => {
|
err instanceof Error ? err.message : String(err)
|
||||||
this.logger.debug(
|
}`,
|
||||||
`AI chat detached stream: post-disconnect socket error swallowed: ${
|
);
|
||||||
err instanceof Error ? err.message : String(err)
|
});
|
||||||
}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Commit to streaming: hijack so Fastify stops managing the response and
|
// Commit to streaming: hijack so Fastify stops managing the response and
|
||||||
// the AI SDK can write the UI-message stream directly to the Node socket.
|
// the AI SDK can write the UI-message stream directly to the Node socket.
|
||||||
@@ -562,8 +616,10 @@ export class AiChatController {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
model,
|
model,
|
||||||
role,
|
role,
|
||||||
// #184: present only when the flag is on; wraps the turn in a durable run.
|
// #487: the turn is always run-wrapped now (both modes).
|
||||||
runHooks,
|
runHooks,
|
||||||
|
// #487: warn the new run that a superseded run's last ops may still apply.
|
||||||
|
superseded,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Any failure AFTER hijack can no longer go through Nest's exception
|
// Any failure AFTER hijack can no longer go through Nest's exception
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// #489 — client-parts validation + resilient history conversion.
|
||||||
|
//
|
||||||
|
// These unit tests exercise the two exported helpers against the REAL
|
||||||
|
// `convertToModelMessages` from `ai` (NOT a mock): a genuinely malformed part
|
||||||
|
// (a `null` element inside a parts array) makes the real converter throw
|
||||||
|
// ("Cannot read properties of null"), which is the actual production
|
||||||
|
// "bricked chat" mechanism this fix defends against. Asserting against the real
|
||||||
|
// converter (rather than a mock-shaped error) is the whole point — a mock would
|
||||||
|
// hide a version change in the converter's throw behaviour.
|
||||||
|
import { convertToModelMessages, type UIMessage } from 'ai';
|
||||||
|
import {
|
||||||
|
sanitizeUserParts,
|
||||||
|
convertHistoryResilient,
|
||||||
|
TOOL_CONTEXT_OMITTED_MARKER,
|
||||||
|
} from './ai-chat.service';
|
||||||
|
|
||||||
|
type Row = Omit<UIMessage, 'id'> & { id: string };
|
||||||
|
|
||||||
|
describe('sanitizeUserParts (#489, branch: validation on receipt)', () => {
|
||||||
|
it('keeps whitelisted text parts unchanged', () => {
|
||||||
|
const drops: string[] = [];
|
||||||
|
const out = sanitizeUserParts(
|
||||||
|
[
|
||||||
|
{ type: 'text', text: 'a' },
|
||||||
|
{ type: 'text', text: 'b' },
|
||||||
|
] as UIMessage['parts'],
|
||||||
|
(t) => drops.push(t),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([
|
||||||
|
{ type: 'text', text: 'a' },
|
||||||
|
{ type: 'text', text: 'b' },
|
||||||
|
]);
|
||||||
|
expect(drops).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a non-text part (a tool-part in input-available) and reports its type', () => {
|
||||||
|
const drops: string[] = [];
|
||||||
|
const out = sanitizeUserParts(
|
||||||
|
[
|
||||||
|
{ type: 'text', text: 'hi' },
|
||||||
|
{
|
||||||
|
type: 'tool-getPage',
|
||||||
|
toolCallId: 't1',
|
||||||
|
state: 'input-available',
|
||||||
|
input: { pageId: 'p' },
|
||||||
|
},
|
||||||
|
] as unknown as UIMessage['parts'],
|
||||||
|
(t) => drops.push(t),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([{ type: 'text', text: 'hi' }]);
|
||||||
|
expect(drops).toEqual(['tool-getPage']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a null part (the shape that would poison convertToModelMessages)', () => {
|
||||||
|
const drops: string[] = [];
|
||||||
|
const out = sanitizeUserParts(
|
||||||
|
[{ type: 'text', text: 'hi' }, null] as unknown as UIMessage['parts'],
|
||||||
|
(t) => drops.push(t),
|
||||||
|
);
|
||||||
|
expect(out).toEqual([{ type: 'text', text: 'hi' }]);
|
||||||
|
expect(drops).toEqual(['(unknown)']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when nothing survives (so a null metadata is persisted)', () => {
|
||||||
|
const out = sanitizeUserParts(
|
||||||
|
[
|
||||||
|
{ type: 'tool-x', toolCallId: 't', state: 'input-available' },
|
||||||
|
] as unknown as UIMessage['parts'],
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
expect(out).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for a non-array input', () => {
|
||||||
|
expect(
|
||||||
|
sanitizeUserParts(undefined as unknown as UIMessage['parts'], () => undefined),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertHistoryResilient (#489, branches: happy + per-row degradation)', () => {
|
||||||
|
it('happy path: healthy history converts identically to convertToModelMessages, no degrade', async () => {
|
||||||
|
const history: Row[] = [
|
||||||
|
{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
{ id: 'a1', role: 'assistant', parts: [{ type: 'text', text: 'hello' }] },
|
||||||
|
];
|
||||||
|
const degrades: number[] = [];
|
||||||
|
const out = await convertHistoryResilient(history, (i) => degrades.push(i));
|
||||||
|
const expected = await convertToModelMessages(history as UIMessage[]);
|
||||||
|
expect(out).toEqual(expected);
|
||||||
|
expect(degrades).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('REAL poison: a null part throws in the batch converter but is isolated and degraded to a marker', async () => {
|
||||||
|
// Sanity: the real converter genuinely throws on this shape.
|
||||||
|
const poisoned: Row = {
|
||||||
|
id: 'a1',
|
||||||
|
role: 'assistant',
|
||||||
|
parts: [
|
||||||
|
{ type: 'text', text: 'earlier answer' },
|
||||||
|
null,
|
||||||
|
] as unknown as UIMessage['parts'],
|
||||||
|
};
|
||||||
|
await expect(
|
||||||
|
convertToModelMessages([poisoned as UIMessage]),
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const history: Row[] = [
|
||||||
|
{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'first' }] },
|
||||||
|
poisoned,
|
||||||
|
{ id: 'u2', role: 'user', parts: [{ type: 'text', text: 'second' }] },
|
||||||
|
];
|
||||||
|
const degrades: number[] = [];
|
||||||
|
const out = await convertHistoryResilient(history, (i) => degrades.push(i));
|
||||||
|
|
||||||
|
// Only the poisoned row (index 1) is degraded.
|
||||||
|
expect(degrades).toEqual([1]);
|
||||||
|
// Healthy rows survive verbatim.
|
||||||
|
const flat = JSON.stringify(out);
|
||||||
|
expect(flat).toContain('first');
|
||||||
|
expect(flat).toContain('second');
|
||||||
|
// The degraded row carries its readable text AND the truncation marker so the
|
||||||
|
// model sees that tool context was omitted (never a silent loss).
|
||||||
|
expect(flat).toContain('earlier answer');
|
||||||
|
expect(flat).toContain(TOOL_CONTEXT_OMITTED_MARKER);
|
||||||
|
// The whole batch converted (3 model messages, none dropped).
|
||||||
|
expect(out).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a fully-poisoned row (no readable text) still degrades to just the marker', async () => {
|
||||||
|
const history: Row[] = [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
role: 'assistant',
|
||||||
|
parts: [null] as unknown as UIMessage['parts'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const out = await convertHistoryResilient(history, () => undefined);
|
||||||
|
expect(out).toHaveLength(1);
|
||||||
|
expect(JSON.stringify(out)).toContain(TOOL_CONTEXT_OMITTED_MARKER);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { PROMPT_TOOL_NAMES } from './ai-chat.prompt';
|
||||||
|
// The real shared registry, imported from source (same approach as the
|
||||||
|
// SHARED_TOOL_SPECS contract spec) so tool names are validated against exactly
|
||||||
|
// what @docmost/mcp ships.
|
||||||
|
import { SHARED_TOOL_SPECS } from '../../../../../packages/mcp/src/tool-specs';
|
||||||
|
import { INLINE_TOOL_TIERS, LOAD_TOOLS_NAME } from './tools/tool-tiers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #448 guard — a nonexistent tool name in ai-chat.prompt.ts must fail a test.
|
||||||
|
*
|
||||||
|
* The in-app prompt refers to a handful of tools BY NAME in its guidance notes
|
||||||
|
* (e.g. PAGE_CHANGED_NOTE tells the agent to re-read via getPage and edit via
|
||||||
|
* editPageText/patchNode/insertNode/deleteNode). Before #448 those names were
|
||||||
|
* hard-coded inline with NO guard, so renaming a tool left the agent stale
|
||||||
|
* instructions and nothing failed.
|
||||||
|
*
|
||||||
|
* APPROACH — substitution + a precise source scan:
|
||||||
|
* 1. The names now flow through the exported `PROMPT_TOOL_NAMES` const; this
|
||||||
|
* test asserts every value there is a REAL in-app tool.
|
||||||
|
* 2. A precise scan of the two guidance-note string literals in the source
|
||||||
|
* catches any BARE tool-name token added directly (bypassing the const):
|
||||||
|
* every camelCase token in those notes must be either a real tool name or an
|
||||||
|
* explicitly-allowlisted ordinary English/camelCase word.
|
||||||
|
*
|
||||||
|
* The scan is deliberately narrow (only the guidance notes, only camelCase
|
||||||
|
* tokens) so it never false-positives on prose, and the allowlist of non-tool
|
||||||
|
* words is tiny and explicit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// The authoritative set of real in-app tool names: shared-registry inAppKeys +
|
||||||
|
// per-layer INLINE tool keys + the loadTools meta-tool.
|
||||||
|
const VALID_TOOL_NAMES = new Set<string>([
|
||||||
|
...Object.values(SHARED_TOOL_SPECS).map((s) => s.inAppKey),
|
||||||
|
...Object.keys(INLINE_TOOL_TIERS),
|
||||||
|
LOAD_TOOLS_NAME,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Ordinary camelCase words that appear in the guidance-note prose and are NOT
|
||||||
|
// tool names. Keep this list minimal and explicit — anything camelCase in a note
|
||||||
|
// that is neither a real tool nor here fails the scan.
|
||||||
|
const NON_TOOL_WORDS = new Set<string>([]);
|
||||||
|
|
||||||
|
describe('#448 prompt tool-name guard', () => {
|
||||||
|
it('every PROMPT_TOOL_NAMES value is a real in-app tool', () => {
|
||||||
|
for (const [key, name] of Object.entries(PROMPT_TOOL_NAMES)) {
|
||||||
|
expect(typeof name).toBe('string');
|
||||||
|
expect(VALID_TOOL_NAMES.has(name)).toBe(true);
|
||||||
|
// Sanity: the const key and its value are the same token (the const is a
|
||||||
|
// name->name map used purely to route mentions through one guarded place).
|
||||||
|
expect(key).toBe(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the guidance notes reference no bogus tool name (bare-literal scan)', () => {
|
||||||
|
const src = readFileSync(
|
||||||
|
join(__dirname, 'ai-chat.prompt.ts'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract the two guidance-note string constants and the current-page
|
||||||
|
// selection line — the only places the prompt names tools in prose. Each is
|
||||||
|
// a `const NAME =` ... `;` block; we scan their raw text for camelCase
|
||||||
|
// tokens. (Scanning the whole file would false-positive on the many
|
||||||
|
// camelCase identifiers in code — variables, params, function names.)
|
||||||
|
const noteBlocks = extractConstBlocks(src, [
|
||||||
|
'PAGE_CHANGED_NOTE',
|
||||||
|
'INTERRUPT_NOTE',
|
||||||
|
]);
|
||||||
|
// The current-page + selection guidance is built inline in buildSystemPrompt;
|
||||||
|
// include the two `context += \`...\`` template lines that mention tools.
|
||||||
|
const contextLines = src
|
||||||
|
.split('\n')
|
||||||
|
.filter((l) => l.includes('context +=') && l.includes('getCurrentPage'))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
// Neutralize string-literal escape sequences (\n, \t, ...) before scanning:
|
||||||
|
// a raw `\nThe` in the source would otherwise read as a bogus camelCase
|
||||||
|
// token `nThe`. Replace any backslash-escape with a space.
|
||||||
|
const scanText = (noteBlocks + '\n' + contextLines).replace(/\\./g, ' ');
|
||||||
|
expect(scanText.length).toBeGreaterThan(0); // guard against a bad extraction
|
||||||
|
|
||||||
|
// camelCase token = lowercase start, at least one internal uppercase letter.
|
||||||
|
const tokens = new Set(scanText.match(/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g) ?? []);
|
||||||
|
const offenders = [...tokens].filter(
|
||||||
|
(t) => !VALID_TOOL_NAMES.has(t) && !NON_TOOL_WORDS.has(t),
|
||||||
|
);
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the specific tools the notes rely on are all real (regression pins)', () => {
|
||||||
|
for (const name of [
|
||||||
|
'getPage',
|
||||||
|
'editPageText',
|
||||||
|
'patchNode',
|
||||||
|
'insertNode',
|
||||||
|
'deleteNode',
|
||||||
|
'getCurrentPage',
|
||||||
|
'loadTools',
|
||||||
|
]) {
|
||||||
|
expect(VALID_TOOL_NAMES.has(name)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the raw text of one or more top-level `const NAME = ... ;` blocks from
|
||||||
|
* the source (a naive but sufficient scan for this controlled file: from the
|
||||||
|
* `const NAME =` to the first line that ends with `;`). Returns the blocks
|
||||||
|
* concatenated.
|
||||||
|
*/
|
||||||
|
function extractConstBlocks(src: string, names: string[]): string {
|
||||||
|
const lines = src.split('\n');
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const start = lines.findIndex((l) => l.trimStart().startsWith(`const ${name} =`));
|
||||||
|
if (start < 0) continue;
|
||||||
|
for (let i = start; i < lines.length; i++) {
|
||||||
|
out.push(lines[i]);
|
||||||
|
if (lines[i].trimEnd().endsWith(';')) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
@@ -2,6 +2,30 @@ import { Workspace } from '@docmost/db/types/entity.types';
|
|||||||
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
import type { McpServerInstruction } from './external-mcp/mcp-clients.service';
|
||||||
import { CORE_TOOL_KEYS, type ToolCatalogEntry } from './tools/tool-tiers';
|
import { CORE_TOOL_KEYS, type ToolCatalogEntry } from './tools/tool-tiers';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The in-app tool names this prompt refers to BY NAME in its guidance notes
|
||||||
|
* (issue #448). Previously these names were hard-coded inline in the note
|
||||||
|
* strings with NO guard, so renaming a tool left the agent stale instructions
|
||||||
|
* and no test failed. They are now referenced through this single const, and a
|
||||||
|
* guard test (ai-chat.prompt.tool-names.spec.ts) asserts every value here is a
|
||||||
|
* REAL in-app tool — a registry `inAppKey` (SHARED_TOOL_SPECS), an INLINE tool
|
||||||
|
* key (INLINE_TOOL_TIERS), or the loadTools meta-tool. Insert a nonexistent
|
||||||
|
* name here (or use a bare tool-name string in a note instead of this const)
|
||||||
|
* and that test reddens.
|
||||||
|
*
|
||||||
|
* `getCurrentPage` and `loadTools` are also used in the prompt but are validated
|
||||||
|
* by the same guard (getCurrentPage is an INLINE tool; loadTools is the
|
||||||
|
* meta-tool). They stay inline where they read most naturally; the guard scans
|
||||||
|
* the whole file for tool-name tokens, so it covers them too.
|
||||||
|
*/
|
||||||
|
export const PROMPT_TOOL_NAMES = {
|
||||||
|
getPage: 'getPage',
|
||||||
|
editPageText: 'editPageText',
|
||||||
|
patchNode: 'patchNode',
|
||||||
|
insertNode: 'insertNode',
|
||||||
|
deleteNode: 'deleteNode',
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default agent persona used when the admin has not configured a custom system
|
* Default agent persona used when the admin has not configured a custom system
|
||||||
* prompt (`settings.ai.provider.systemPrompt`).
|
* prompt (`settings.ai.provider.systemPrompt`).
|
||||||
@@ -77,6 +101,22 @@ const INTERRUPT_NOTE =
|
|||||||
'assume your previous response was complete, and do not silently restart the ' +
|
'assume your previous response was complete, and do not silently restart the ' +
|
||||||
'partial work — build on it or follow the new instruction.';
|
'partial work — build on it or follow the new instruction.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #487: injected on a turn started by SUPERSEDING a previous run (the user hit
|
||||||
|
* "interrupt and send now" while a run was live). The previous run was Stopped,
|
||||||
|
* but there is NO side-effect quiescence — a write it had already committed, or
|
||||||
|
* one committing at the moment of Stop, may land with a small delay AFTER this new
|
||||||
|
* run starts. So the model is told its picture of the page/state may be a beat
|
||||||
|
* stale and to re-read before assuming an edit did or did not apply.
|
||||||
|
*/
|
||||||
|
const SUPERSEDE_NOTE =
|
||||||
|
'NOTE: A previous agent run in this conversation was just interrupted so this ' +
|
||||||
|
'new turn could start. That run was stopped, but any operation it had already ' +
|
||||||
|
'begun (e.g. a page edit) may still be applied with a short delay. Do not ' +
|
||||||
|
'assume the document/state is exactly as the interrupted run left it — if you ' +
|
||||||
|
'need to rely on the current content, RE-READ it with the page tools before ' +
|
||||||
|
'acting rather than trusting a cached view.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Injected on a turn where the open page was hand-edited by the user (or anyone
|
* Injected on a turn where the open page was hand-edited by the user (or anyone
|
||||||
* else) AFTER the agent's previous response ended (#274). The server takes a
|
* else) AFTER the agent's previous response ended (#274). The server takes a
|
||||||
@@ -91,15 +131,15 @@ const PAGE_CHANGED_NOTE =
|
|||||||
'NOTE: The user edited the open page AFTER your last response in this ' +
|
'NOTE: The user edited the open page AFTER your last response in this ' +
|
||||||
'conversation, so any copy of that page you produced or remember from earlier ' +
|
'conversation, so any copy of that page you produced or remember from earlier ' +
|
||||||
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
|
'is now STALE and must not be reused. Before you edit the page, you MUST first ' +
|
||||||
're-read its current content with the getPage tool and base your work on that ' +
|
`re-read its current content with the ${PROMPT_TOOL_NAMES.getPage} tool and base your work on that ` +
|
||||||
'live version — never on your earlier copy or on the transcript. The unified ' +
|
'live version — never on your earlier copy or on the transcript. The unified ' +
|
||||||
'diff below shows exactly what the user changed since you last spoke (lines ' +
|
'diff below shows exactly what the user changed since you last spoke (lines ' +
|
||||||
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
|
'starting with "-" were removed, "+" were added) and is the source of truth. ' +
|
||||||
'Preserve every one of the user\'s edits: make the smallest change that ' +
|
'Preserve every one of the user\'s edits: make the smallest change that ' +
|
||||||
'satisfies the request using the targeted edit tools (editPageText, patchNode, ' +
|
`satisfies the request using the targeted edit tools (${PROMPT_TOOL_NAMES.editPageText}, ${PROMPT_TOOL_NAMES.patchNode}, ` +
|
||||||
'insertNode, deleteNode) rather than replacing the whole page, and do not ' +
|
`${PROMPT_TOOL_NAMES.insertNode}, ${PROMPT_TOOL_NAMES.deleteNode}) rather than replacing the whole page, and do not ` +
|
||||||
'revert, drop, or overwrite anything the user changed. If a full rewrite is ' +
|
`revert, drop, or overwrite anything the user changed. If a full rewrite is ` +
|
||||||
'truly unavoidable, start from the current getPage content and carry over all ' +
|
`truly unavoidable, start from the current ${PROMPT_TOOL_NAMES.getPage} content and carry over all ` +
|
||||||
'of the user\'s edits.';
|
'of the user\'s edits.';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,6 +219,14 @@ export interface BuildSystemPromptInput {
|
|||||||
* (partial) answer was cut off by the user's new message.
|
* (partial) answer was cut off by the user's new message.
|
||||||
*/
|
*/
|
||||||
interrupted?: boolean;
|
interrupted?: boolean;
|
||||||
|
/**
|
||||||
|
* #487: true when THIS turn was started by superseding a still-live previous run
|
||||||
|
* ("interrupt and send now"). Adds SUPERSEDE_NOTE so the model knows the previous
|
||||||
|
* run's last operations may still be applying and to re-read state it depends on.
|
||||||
|
* Distinct from `interrupted` (which is about a PARTIAL prior answer in history);
|
||||||
|
* both can be set together. Self-clears — set only for the superseding turn.
|
||||||
|
*/
|
||||||
|
superseded?: boolean;
|
||||||
/**
|
/**
|
||||||
* Set only when the open page was edited by the user AFTER the agent's previous
|
* Set only when the open page was edited by the user AFTER the agent's previous
|
||||||
* turn ended (#274), confirmed server-side by diffing the current page against
|
* turn ended (#274), confirmed server-side by diffing the current page against
|
||||||
@@ -287,6 +335,7 @@ export function buildSystemPrompt({
|
|||||||
openedPage,
|
openedPage,
|
||||||
mcpInstructions,
|
mcpInstructions,
|
||||||
interrupted,
|
interrupted,
|
||||||
|
superseded,
|
||||||
pageChanged,
|
pageChanged,
|
||||||
deferredToolsEnabled,
|
deferredToolsEnabled,
|
||||||
toolCatalog,
|
toolCatalog,
|
||||||
@@ -336,6 +385,13 @@ export function buildSystemPrompt({
|
|||||||
context += `\n${INTERRUPT_NOTE}`;
|
context += `\n${INTERRUPT_NOTE}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Supersede note (#487): present only for a turn that stopped and replaced a
|
||||||
|
// still-live previous run — warns the model the previous run's last operations
|
||||||
|
// may still be applying (no side-effect quiescence).
|
||||||
|
if (superseded) {
|
||||||
|
context += `\n${SUPERSEDE_NOTE}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Per-turn page-change note (#274). Added to the context section (inside the
|
// Per-turn page-change note (#274). Added to the context section (inside the
|
||||||
// safety sandwich), present only when the server detected that the open page
|
// safety sandwich), present only when the server detected that the open page
|
||||||
// was edited by the user since the agent's last turn ended. The diff content is
|
// was edited by the user since the agent's last turn ended. The diff content is
|
||||||
|
|||||||
@@ -89,11 +89,22 @@ describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
|||||||
const runRepo = {
|
const runRepo = {
|
||||||
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
insert: jest.fn().mockResolvedValue({ id: 'run-1', status: 'running' }),
|
||||||
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
update: jest.fn().mockResolvedValue({ id: 'run-1' }),
|
||||||
|
// #487: the terminal settle now goes through the CONDITIONAL write.
|
||||||
|
finalizeIfActive: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'run-1', status: 'failed' }),
|
||||||
|
findById: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
const runService = new AiChatRunService(runRepo as never, { isCloud: () => false } as never);
|
||||||
|
|
||||||
// The user-message insert (the first bare await after beginRun) throws.
|
// The user-message insert throws. #489 runs the history load + convert BEFORE
|
||||||
|
// the insert (convert-before-insert, so a retry cannot duplicate the user row),
|
||||||
|
// so `findAllByChat` (a real repo method) is now called first — stub it to an
|
||||||
|
// empty history so the flow reaches the insert. Both awaits are AFTER beginRun,
|
||||||
|
// so the "exception after beginRun -> settled to error" invariant is unchanged;
|
||||||
|
// the throw point simply moved from insert to a later insert after a no-op load.
|
||||||
const aiChatMessageRepo = {
|
const aiChatMessageRepo = {
|
||||||
|
findAllByChat: jest.fn().mockResolvedValue([]),
|
||||||
insert: jest.fn().mockRejectedValue(new Error('insert boom')),
|
insert: jest.fn().mockRejectedValue(new Error('insert boom')),
|
||||||
};
|
};
|
||||||
const aiChatRepo = {
|
const aiChatRepo = {
|
||||||
@@ -148,9 +159,10 @@ describe('AiChatService.stream run-lifecycle safety net (#184)', () => {
|
|||||||
|
|
||||||
// The run was begun...
|
// The run was begun...
|
||||||
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
expect(runRepo.insert).toHaveBeenCalledTimes(1);
|
||||||
// ...then settled to a terminal FAILED status by the safety net...
|
// ...then settled to a terminal FAILED status by the safety net (via the
|
||||||
expect(runRepo.update).toHaveBeenCalledTimes(1);
|
// #487 conditional write)...
|
||||||
expect(runRepo.update).toHaveBeenCalledWith(
|
expect(runRepo.finalizeIfActive).toHaveBeenCalledTimes(1);
|
||||||
|
expect(runRepo.finalizeIfActive).toHaveBeenCalledWith(
|
||||||
'run-1',
|
'run-1',
|
||||||
'ws1',
|
'ws1',
|
||||||
expect.objectContaining({ status: 'failed' }),
|
expect.objectContaining({ status: 'failed' }),
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { ConflictException, Logger } from '@nestjs/common';
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Logger,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
|
||||||
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
// Mock the AI SDK so we can PROVE no provider call is made for the turn we are
|
||||||
// about to reject. The race rejection happens at runHooks.begin(), long before
|
// about to reject. The race rejection happens at runHooks.begin(), long before
|
||||||
@@ -151,6 +155,8 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
findAllByChat: jest.fn(async () => []),
|
findAllByChat: jest.fn(async () => []),
|
||||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||||
};
|
};
|
||||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
const tools = { forUser: jest.fn(async () => ({})) };
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
@@ -175,7 +181,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
{} as never, // pageAccess
|
{} as never, // pageAccess
|
||||||
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
|
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as never, // environment
|
||||||
);
|
);
|
||||||
return { svc };
|
return { svc, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
@@ -281,7 +287,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
// Drive stream() to the point streamText is called, capturing the options object
|
// Drive stream() to the point streamText is called, capturing the options object
|
||||||
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
// (which carries onStepFinish/onFinish/onError/onAbort) and the run hooks.
|
||||||
async function captureStreamCallbacks() {
|
async function captureStreamCallbacks() {
|
||||||
const { svc } = makeService();
|
const { svc, aiChatMessageRepo } = makeService();
|
||||||
let capturedOpts: any;
|
let capturedOpts: any;
|
||||||
streamTextMock.mockImplementation((opts: any) => {
|
streamTextMock.mockImplementation((opts: any) => {
|
||||||
capturedOpts = opts;
|
capturedOpts = opts;
|
||||||
@@ -308,7 +314,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
runHooks: runHooks as never,
|
runHooks: runHooks as never,
|
||||||
});
|
});
|
||||||
expect(capturedOpts).toBeDefined();
|
expect(capturedOpts).toBeDefined();
|
||||||
return { capturedOpts, runHooks };
|
return { capturedOpts, runHooks, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|
||||||
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
it('F9: onStepFinish bumps the run step count, onFinish settles the run "completed" (the dominant autonomous-run path)', async () => {
|
||||||
@@ -328,7 +334,13 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
usage: {},
|
usage: {},
|
||||||
steps: [],
|
steps: [],
|
||||||
});
|
});
|
||||||
expect(runHooks.onSettled).toHaveBeenCalledWith('run-1', 'completed');
|
// #487: onFinish passes the (undefined) error slot so a message-finalize
|
||||||
|
// failure could error-mark the run; on the success path it is undefined.
|
||||||
|
expect(runHooks.onSettled).toHaveBeenCalledWith(
|
||||||
|
'run-1',
|
||||||
|
'completed',
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('F9: onAbort settles the run "aborted"', async () => {
|
it('F9: onAbort settles the run "aborted"', async () => {
|
||||||
@@ -357,25 +369,70 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
|
|||||||
expect.stringContaining('provider exploded'),
|
expect.stringContaining('provider exploded'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #490 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is classified,
|
||||||
|
// records a distinguishable cause, and stamps metadata.replayOverflow so the NEXT
|
||||||
|
// turn's budgeter trims aggressively (the recovery that un-bricks the chat).
|
||||||
|
it('#490: a context-overflow 400 stamps replayOverflow on the finalized row', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
const { capturedOpts, aiChatMessageRepo } = await captureStreamCallbacks();
|
||||||
|
|
||||||
|
const overflow = Object.assign(new Error('too large'), {
|
||||||
|
statusCode: 400,
|
||||||
|
message:
|
||||||
|
"This model's maximum context length is 128000 tokens. However, your messages resulted in 214000 tokens. Please reduce the length.",
|
||||||
|
});
|
||||||
|
await capturedOpts.onError({ error: overflow });
|
||||||
|
|
||||||
|
// The seed row exists (finalizeOwner is the owner-write path).
|
||||||
|
expect(aiChatMessageRepo.finalizeOwner).toHaveBeenCalled();
|
||||||
|
const calls = aiChatMessageRepo.finalizeOwner.mock.calls as any[][];
|
||||||
|
const patch = calls[calls.length - 1][2] as {
|
||||||
|
status: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(patch.status).toBe('error');
|
||||||
|
expect(patch.metadata.replayOverflow).toBe(true);
|
||||||
|
expect(patch.metadata.error).toContain('контекстное окно');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('#490: a non-overflow error does NOT stamp replayOverflow', async () => {
|
||||||
|
jest
|
||||||
|
.spyOn(Logger.prototype, 'error')
|
||||||
|
.mockImplementation(() => undefined as never);
|
||||||
|
const { capturedOpts, aiChatMessageRepo } = await captureStreamCallbacks();
|
||||||
|
await capturedOpts.onError({ error: new Error('network reset') });
|
||||||
|
const calls = aiChatMessageRepo.finalizeOwner.mock.calls as any[][];
|
||||||
|
const patch = calls[calls.length - 1][2] as {
|
||||||
|
status: string;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect('replayOverflow' in patch.metadata).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* F14 — the begin-failure RESILIENCE branch (the `else` of the run-race guard).
|
* F14 — the begin-failure branch (the `else` of the run-race guard).
|
||||||
*
|
*
|
||||||
* stream() wraps runHooks.begin in try/catch with TWO branches:
|
* stream() wraps runHooks.begin in try/catch with TWO branches:
|
||||||
* - RunAlreadyActiveError -> 409 ConflictException (pinned above).
|
* - RunAlreadyActiveError -> 409 ConflictException (pinned above).
|
||||||
* - ANY OTHER begin failure -> SWALLOW + continue UNTRACKED on the socket signal
|
* - ANY OTHER begin failure -> throw ServiceUnavailableException(A_RUN_BEGIN_FAILED)
|
||||||
* (legacy fallback): it logs "...streaming without run tracking", leaves
|
* BEFORE the first byte (#486, commit 4).
|
||||||
* `effectiveSignal = signal` (runId undefined) and serves the turn anyway.
|
|
||||||
*
|
*
|
||||||
* The contract: a transient beginRun failure (e.g. a non-unique DB error inserting
|
* POLICY CHANGE (#486): the OLD contract here was "SWALLOW + stream the turn
|
||||||
* the run row) must STILL serve the user's turn — it must NOT re-throw and must NOT
|
* UNTRACKED on the socket signal". That was reversed: an untracked run is
|
||||||
* be misclassified as a 409. A regression that re-threw here would break EVERY turn
|
* invisible to /stop, is not aborted on disconnect, and slips past the one-run
|
||||||
* on a begin failure with nothing to catch it. This branch is otherwise undriven by
|
* gate — an unstoppable ghost run in autonomous mode. Now a plain begin failure
|
||||||
* any spec, so it is pinned here SEPARATELY from the 409 path: a plain begin error
|
* FAILS the turn fast with a 503 A_RUN_BEGIN_FAILED, before any user row is
|
||||||
* proceeds to streamText with the SOCKET signal and still persists the user turn.
|
* persisted and before streamText runs. This case is INVERTED (not deleted) so
|
||||||
|
* the "plain begin failure" path stays explicitly pinned under the new policy.
|
||||||
*/
|
*/
|
||||||
describe('AiChatService.stream — begin-failure resilience / legacy fallback (#184 F14)', () => {
|
describe('AiChatService.stream — begin-failure fails the turn (#184 F14 / #486)', () => {
|
||||||
const streamTextMock = streamText as unknown as jest.Mock;
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
|
||||||
function makeStreamResult() {
|
function makeStreamResult() {
|
||||||
@@ -411,6 +468,8 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
|
|||||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
findAllByChat: jest.fn(async () => []),
|
findAllByChat: jest.fn(async () => []),
|
||||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||||
};
|
};
|
||||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
const tools = { forUser: jest.fn(async () => ({})) };
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
@@ -455,7 +514,7 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
|
|||||||
|
|
||||||
afterEach(() => jest.restoreAllMocks());
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) does NOT 409 — it swallows, logs, and streams the turn UNTRACKED on the socket signal', async () => {
|
it('a PLAIN begin() failure (NOT RunAlreadyActiveError) FAILS the turn with a 503 A_RUN_BEGIN_FAILED before the first byte — NO untracked stream (#486)', async () => {
|
||||||
const errorSpy = jest
|
const errorSpy = jest
|
||||||
.spyOn(Logger.prototype, 'error')
|
.spyOn(Logger.prototype, 'error')
|
||||||
.mockImplementation(() => undefined as never);
|
.mockImplementation(() => undefined as never);
|
||||||
@@ -487,28 +546,26 @@ describe('AiChatService.stream — begin-failure resilience / legacy fallback (#
|
|||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The turn proceeds: NO throw at all (in particular NOT a 409).
|
// NEW POLICY: the turn is REJECTED with a 503 A_RUN_BEGIN_FAILED (not a 409,
|
||||||
await expect(promise).resolves.toBeUndefined();
|
// and NOT swallowed into an untracked stream).
|
||||||
|
await expect(promise).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
const err = (await promise.catch(
|
||||||
|
(e) => e,
|
||||||
|
)) as ServiceUnavailableException;
|
||||||
|
expect(err.getStatus()).toBe(503);
|
||||||
|
expect(err.getResponse()).toMatchObject({ code: 'A_RUN_BEGIN_FAILED' });
|
||||||
|
|
||||||
expect(begin).toHaveBeenCalledTimes(1);
|
expect(begin).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// The resilience branch logged the legacy-fallback warning.
|
// It logged the fail-the-turn line.
|
||||||
expect(errorSpy).toHaveBeenCalledWith(
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('streaming without run tracking'),
|
expect.stringContaining('failing the turn'),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The turn really streamed: the user message was persisted and streamText ran.
|
// Fail-fast: the turn NEVER streamed — no user row persisted, no streamText
|
||||||
expect(aiChatMessageRepo.insert).toHaveBeenCalled();
|
// call, so no orphan/untracked run was left behind.
|
||||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
expect(aiChatMessageRepo.insert).not.toHaveBeenCalled();
|
||||||
|
expect(streamTextMock).not.toHaveBeenCalled();
|
||||||
// The decisive wiring: with no run handle, the fallback uses the SOCKET signal
|
|
||||||
// (effectiveSignal = signal, runId undefined) — not a run-bound signal. #444:
|
|
||||||
// the signal is unioned with the degeneration controller via AbortSignal.any,
|
|
||||||
// so assert the socket abort still reaches the turn rather than identity.
|
|
||||||
const passed = streamTextMock.mock.calls[0][0].abortSignal as AbortSignal;
|
|
||||||
expect(passed.aborted).toBe(false);
|
|
||||||
socketController.abort();
|
|
||||||
expect(passed.aborted).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
compactToolOutput,
|
compactToolOutput,
|
||||||
assistantParts,
|
assistantParts,
|
||||||
serializeSteps,
|
serializeSteps,
|
||||||
|
type StepPartsCache,
|
||||||
rowToUiMessage,
|
rowToUiMessage,
|
||||||
prepareAgentStep,
|
prepareAgentStep,
|
||||||
stepBudgetWarning,
|
stepBudgetWarning,
|
||||||
@@ -28,10 +29,14 @@ import {
|
|||||||
FINAL_STEP_NUDGE,
|
FINAL_STEP_NUDGE,
|
||||||
STEP_LIMIT_NO_ANSWER_MARKER,
|
STEP_LIMIT_NO_ANSWER_MARKER,
|
||||||
OUTPUT_DEGENERATION_ERROR,
|
OUTPUT_DEGENERATION_ERROR,
|
||||||
|
lastAssistantContextTokens,
|
||||||
|
lastAssistantReplayOverflow,
|
||||||
|
seedActivatedTools,
|
||||||
} from './ai-chat.service';
|
} from './ai-chat.service';
|
||||||
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
|
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
|
||||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||||
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
|
import { resolveEffectiveReplayThreshold } from './history-budget';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
|
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
|
||||||
@@ -114,6 +119,54 @@ describe('compactToolOutput', () => {
|
|||||||
describe('assistantParts', () => {
|
describe('assistantParts', () => {
|
||||||
type AnyPart = Record<string, unknown>;
|
type AnyPart = Record<string, unknown>;
|
||||||
|
|
||||||
|
// #490 memoization: assistantParts builds each step's parts once and caches
|
||||||
|
// them by the step OBJECT's identity, so a mid-stream flush does not
|
||||||
|
// re-stringify every prior step's (large) output. Observable property: with a
|
||||||
|
// shared cache, the second call over the SAME step object returns the cached
|
||||||
|
// (identical) part array even if the step's underlying output was swapped —
|
||||||
|
// proving the work was memoized, not redone.
|
||||||
|
it('memoizes a step by identity (shared cache => one build per step)', () => {
|
||||||
|
const cache: StepPartsCache = new WeakMap();
|
||||||
|
const step = {
|
||||||
|
text: 'x',
|
||||||
|
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: {} }],
|
||||||
|
toolResults: [{ toolCallId: 'c1', toolName: 'getPage', output: { v: 1 } }],
|
||||||
|
};
|
||||||
|
const first = assistantParts([step], '', cache) as AnyPart[];
|
||||||
|
expect((first.find((p) => p.type === 'tool-getPage')!.output as any).v).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Swap the output for a NEW value; a re-build would pick it up, a cache hit
|
||||||
|
// keeps the first result.
|
||||||
|
step.toolResults[0] = {
|
||||||
|
toolCallId: 'c1',
|
||||||
|
toolName: 'getPage',
|
||||||
|
output: { v: 2 },
|
||||||
|
};
|
||||||
|
const second = assistantParts([step], '', cache) as AnyPart[];
|
||||||
|
expect((second.find((p) => p.type === 'tool-getPage')!.output as any).v).toBe(
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Same cached part objects are reused.
|
||||||
|
expect(second.find((p) => p.type === 'tool-getPage')).toBe(
|
||||||
|
first.find((p) => p.type === 'tool-getPage'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('without a cache, each call rebuilds (no stale memo)', () => {
|
||||||
|
const step = {
|
||||||
|
text: 'x',
|
||||||
|
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: {} }],
|
||||||
|
toolResults: [{ toolCallId: 'c1', toolName: 'getPage', output: { v: 1 } }],
|
||||||
|
};
|
||||||
|
const first = assistantParts([step], '') as AnyPart[];
|
||||||
|
step.toolResults[0].output = { v: 2 };
|
||||||
|
const second = assistantParts([step], '') as AnyPart[];
|
||||||
|
expect((second.find((p) => p.type === 'tool-getPage')!.output as any).v).toBe(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('emits output-available for a tool-call WITH a paired result', () => {
|
it('emits output-available for a tool-call WITH a paired result', () => {
|
||||||
const steps = [
|
const steps = [
|
||||||
{
|
{
|
||||||
@@ -231,61 +284,320 @@ describe('assistantParts', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('serializeSteps', () => {
|
// #490 trace format v2: per call the trace stores { input } for the call and an
|
||||||
|
// OUTCOME element — { ok: true } on success, { error, kind: 'thrown' } on a
|
||||||
|
// thrown tool-error, { error, kind: 'interrupted' } on a mid-step abort. The tool
|
||||||
|
// OUTPUT is no longer duplicated here (it lives once in metadata.parts).
|
||||||
|
describe('serializeSteps (trace v2)', () => {
|
||||||
it('returns null when there are no calls or results', () => {
|
it('returns null when there are no calls or results', () => {
|
||||||
expect(serializeSteps([])).toBeNull();
|
expect(serializeSteps([])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('flattens calls and results into a compact trace', () => {
|
it('pairs a successful call with an { ok: true } outcome and NO output', () => {
|
||||||
const trace = serializeSteps([
|
const trace = serializeSteps([
|
||||||
{
|
{
|
||||||
toolCalls: [{ toolName: 'getPage', input: { id: 'p1' } }],
|
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: { id: 'p1' } }],
|
||||||
toolResults: [{ toolName: 'getPage', output: { title: 'T' } }],
|
toolResults: [{ toolCallId: 'c1', toolName: 'getPage' }],
|
||||||
},
|
},
|
||||||
]) as Array<Record<string, unknown>>;
|
]) as Array<Record<string, unknown>>;
|
||||||
expect(trace).toHaveLength(2);
|
expect(trace).toHaveLength(2);
|
||||||
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
|
expect(trace[0]).toEqual({ toolName: 'getPage', input: { id: 'p1' } });
|
||||||
expect(trace[1]).toEqual({ toolName: 'getPage', output: { title: 'T' } });
|
expect(trace[1]).toEqual({ toolName: 'getPage', ok: true });
|
||||||
|
// The output is NOT stored in the trace any more (dedup: it lives in parts).
|
||||||
|
expect(trace.some((e) => 'output' in e)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records a THROWN tool failure (tool-error part) with its error message', () => {
|
it('records a THROWN failure with { error, kind: "thrown" }', () => {
|
||||||
const trace = serializeSteps([
|
const trace = serializeSteps([
|
||||||
{
|
{
|
||||||
toolCalls: [{ toolName: 'editPageText', input: { id: 'p1' } }],
|
toolCalls: [
|
||||||
|
{ toolCallId: 'c1', toolName: 'editPageText', input: { id: 'p1' } },
|
||||||
|
],
|
||||||
toolResults: [],
|
toolResults: [],
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'tool-error',
|
type: 'tool-error',
|
||||||
|
toolCallId: 'c1',
|
||||||
toolName: 'editPageText',
|
toolName: 'editPageText',
|
||||||
error: new Error('page is locked'),
|
error: new Error('page is locked'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]) as Array<Record<string, unknown>>;
|
]) as Array<Record<string, unknown>>;
|
||||||
// The call element is followed by a paired error element (mirroring how a
|
|
||||||
// successful result is appended), so the failure survives in the trace.
|
|
||||||
expect(trace).toHaveLength(2);
|
expect(trace).toHaveLength(2);
|
||||||
expect(trace[0]).toEqual({ toolName: 'editPageText', input: { id: 'p1' } });
|
expect(trace[0]).toEqual({ toolName: 'editPageText', input: { id: 'p1' } });
|
||||||
expect(trace[1]).toEqual({
|
expect(trace[1]).toEqual({
|
||||||
toolName: 'editPageText',
|
toolName: 'editPageText',
|
||||||
error: 'page is locked',
|
error: 'page is locked',
|
||||||
|
kind: 'thrown',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('truncates a very long tool-error message to the tool-output limit', () => {
|
it('marks an interrupted call (no result, no throw) with kind "interrupted"', () => {
|
||||||
|
const trace = serializeSteps([
|
||||||
|
{
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: 'c1', toolName: 'createComment', input: { x: 1 } },
|
||||||
|
],
|
||||||
|
toolResults: [],
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
]) as Array<Record<string, unknown>>;
|
||||||
|
expect(trace).toHaveLength(2);
|
||||||
|
expect(trace[1]).toEqual({
|
||||||
|
toolName: 'createComment',
|
||||||
|
error: 'Tool call did not complete.',
|
||||||
|
kind: 'interrupted',
|
||||||
|
});
|
||||||
|
// Structurally distinct from a thrown hard-fail so it never inflates an
|
||||||
|
// error-rate scan.
|
||||||
|
expect((trace[1] as { kind: string }).kind).not.toBe('thrown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates a very long thrown-error message to the tool-output limit', () => {
|
||||||
const long = 'x'.repeat(5000);
|
const long = 'x'.repeat(5000);
|
||||||
const trace = serializeSteps([
|
const trace = serializeSteps([
|
||||||
{
|
{
|
||||||
toolCalls: [{ toolName: 'editPageText', input: {} }],
|
toolCalls: [{ toolCallId: 'c1', toolName: 'editPageText', input: {} }],
|
||||||
toolResults: [],
|
toolResults: [],
|
||||||
content: [{ type: 'tool-error', toolName: 'editPageText', error: long }],
|
content: [
|
||||||
|
{
|
||||||
|
type: 'tool-error',
|
||||||
|
toolCallId: 'c1',
|
||||||
|
toolName: 'editPageText',
|
||||||
|
error: long,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]) as Array<Record<string, unknown>>;
|
]) as Array<Record<string, unknown>>;
|
||||||
const errorText = trace[1].error as string;
|
const errorText = trace[1].error as string;
|
||||||
// Truncated (not the full 5000 chars) and carries the omission marker.
|
|
||||||
expect(errorText.length).toBeLessThan(long.length);
|
expect(errorText.length).toBeLessThan(long.length);
|
||||||
expect(errorText).toContain('chars omitted');
|
expect(errorText).toContain('chars omitted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('pairs parallel calls in one step with their outcomes by id', () => {
|
||||||
|
const trace = serializeSteps([
|
||||||
|
{
|
||||||
|
toolCalls: [
|
||||||
|
{ toolCallId: 'a', toolName: 'getPage', input: {} },
|
||||||
|
{ toolCallId: 'b', toolName: 'searchPages', input: {} },
|
||||||
|
],
|
||||||
|
toolResults: [{ toolCallId: 'b', toolName: 'searchPages' }],
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-error', toolCallId: 'a', toolName: 'getPage', error: 'nope' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]) as Array<Record<string, unknown>>;
|
||||||
|
// call a, outcome a (thrown), call b, outcome b (ok)
|
||||||
|
expect(trace).toHaveLength(4);
|
||||||
|
expect(trace[1]).toEqual({ toolName: 'getPage', error: 'nope', kind: 'thrown' });
|
||||||
|
expect(trace[3]).toEqual({ toolName: 'searchPages', ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #490: every assistant row flushAssistant writes carries the v2 era marker so a
|
||||||
|
// dual-shape diagnostic query can branch on the trace shape without inspecting it.
|
||||||
|
describe('toolTraceVersion era marker (#490)', () => {
|
||||||
|
it('stamps metadata.toolTraceVersion = 2 on every flushed row', () => {
|
||||||
|
const seed = flushAssistant([], '', 'streaming');
|
||||||
|
expect(seed.metadata.toolTraceVersion).toBe(2);
|
||||||
|
const done = flushAssistant(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: 'ok',
|
||||||
|
toolCalls: [{ toolCallId: 'c1', toolName: 'getPage', input: {} }],
|
||||||
|
toolResults: [{ toolCallId: 'c1', toolName: 'getPage' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
'completed',
|
||||||
|
{ finishReason: 'stop' },
|
||||||
|
);
|
||||||
|
expect(done.metadata.toolTraceVersion).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #490 replay-budget signal helpers over persisted history.
|
||||||
|
describe('lastAssistantContextTokens', () => {
|
||||||
|
const row = (
|
||||||
|
role: string,
|
||||||
|
metadata: Record<string, unknown> | null,
|
||||||
|
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
|
||||||
|
|
||||||
|
it('reads the most recent assistant turn contextTokens (provider fact)', () => {
|
||||||
|
const hist = [
|
||||||
|
row('user', null),
|
||||||
|
row('assistant', { contextTokens: 12000 }),
|
||||||
|
row('user', null),
|
||||||
|
row('assistant', { contextTokens: 41000 }),
|
||||||
|
];
|
||||||
|
expect(lastAssistantContextTokens(hist)).toBe(41000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when the last assistant turn recorded no usage', () => {
|
||||||
|
const hist = [row('assistant', { error: 'boom' }), row('user', null)];
|
||||||
|
expect(lastAssistantContextTokens(hist)).toBeUndefined();
|
||||||
|
expect(lastAssistantContextTokens([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #490 snapshotOpenPage fast-path: skip the full Markdown export + upsert when a
|
||||||
|
// snapshot already exists at the page's CURRENT version (same updated_at instant).
|
||||||
|
describe('snapshotOpenPage fast-path (#490)', () => {
|
||||||
|
function makeSvc(existingSnapshot: unknown, pageUpdatedAt: Date) {
|
||||||
|
const exportPageMarkdown = jest.fn(async () => '# md');
|
||||||
|
const upsert = jest.fn(async () => undefined);
|
||||||
|
const findByChatPage = jest.fn(async () => existingSnapshot);
|
||||||
|
const pageRepo = {
|
||||||
|
findById: jest.fn(async () => ({
|
||||||
|
id: 'p1',
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
updatedAt: pageUpdatedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
{} as never, // aiChatRepo
|
||||||
|
{} as never, // aiChatMessageRepo
|
||||||
|
{ findByChatPage, upsert } as never, // aiChatPageSnapshotRepo
|
||||||
|
{} as never, // aiSettings
|
||||||
|
{ exportPageMarkdown } as never, // tools
|
||||||
|
{} as never, // mcpClients
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
pageRepo as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
|
);
|
||||||
|
return { svc, exportPageMarkdown, upsert, findByChatPage };
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = () =>
|
||||||
|
[
|
||||||
|
'chat1',
|
||||||
|
'p1',
|
||||||
|
{ id: 'ws1' } as never,
|
||||||
|
{ id: 'u1' } as never,
|
||||||
|
'sess',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
it('skips export + upsert when the snapshot is already at this page version', async () => {
|
||||||
|
const t = new Date('2026-07-07T10:00:00Z');
|
||||||
|
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||||
|
{ pageUpdatedAt: t, contentMd: '# md' },
|
||||||
|
t,
|
||||||
|
);
|
||||||
|
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||||
|
.snapshotOpenPage(...args());
|
||||||
|
expect(exportPageMarkdown).not.toHaveBeenCalled();
|
||||||
|
expect(upsert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports + upserts when the page advanced since the snapshot', async () => {
|
||||||
|
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||||
|
{ pageUpdatedAt: new Date('2026-07-07T10:00:00Z'), contentMd: 'old' },
|
||||||
|
new Date('2026-07-07T11:00:00Z'),
|
||||||
|
);
|
||||||
|
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||||
|
.snapshotOpenPage(...args());
|
||||||
|
expect(exportPageMarkdown).toHaveBeenCalledTimes(1);
|
||||||
|
expect(upsert).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds (exports + upserts) on the first turn (no snapshot yet)', async () => {
|
||||||
|
const { svc, exportPageMarkdown, upsert } = makeSvc(
|
||||||
|
undefined,
|
||||||
|
new Date('2026-07-07T10:00:00Z'),
|
||||||
|
);
|
||||||
|
await (svc as unknown as { snapshotOpenPage: (...a: unknown[]) => Promise<void> })
|
||||||
|
.snapshotOpenPage(...args());
|
||||||
|
expect(exportPageMarkdown).toHaveBeenCalledTimes(1);
|
||||||
|
expect(upsert).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #490 deferred-tool activation persisted across turns.
|
||||||
|
describe('seedActivatedTools', () => {
|
||||||
|
const valid = new Set(['Search_web', 'getPageJson', 'diffPageVersions']);
|
||||||
|
|
||||||
|
it('seeds from persisted metadata, intersected with current valid names', () => {
|
||||||
|
expect(
|
||||||
|
seedActivatedTools(
|
||||||
|
{ activatedTools: ['Search_web', 'getPageJson'] },
|
||||||
|
valid,
|
||||||
|
),
|
||||||
|
).toEqual(['Search_web', 'getPageJson']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('drops a stored tool that is no longer valid (allowlist/role changed)', () => {
|
||||||
|
// 'Habr_publish' was activated before but is not in the current allowlist.
|
||||||
|
expect(
|
||||||
|
seedActivatedTools({ activatedTools: ['Search_web', 'Habr_publish'] }, valid),
|
||||||
|
).toEqual(['Search_web']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty/robust for missing, non-array, or unknown-shaped metadata', () => {
|
||||||
|
expect(seedActivatedTools(undefined, valid)).toEqual([]);
|
||||||
|
expect(seedActivatedTools({}, valid)).toEqual([]);
|
||||||
|
expect(seedActivatedTools({ activatedTools: 'nope' }, valid)).toEqual([]);
|
||||||
|
expect(
|
||||||
|
seedActivatedTools({ activatedTools: [1, 'getPageJson', null] }, valid),
|
||||||
|
).toEqual(['getPageJson']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('de-duplicates stored names', () => {
|
||||||
|
expect(
|
||||||
|
seedActivatedTools(
|
||||||
|
{ activatedTools: ['getPageJson', 'getPageJson'] },
|
||||||
|
valid,
|
||||||
|
),
|
||||||
|
).toEqual(['getPageJson']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('lastAssistantReplayOverflow', () => {
|
||||||
|
const row = (
|
||||||
|
role: string,
|
||||||
|
metadata: Record<string, unknown> | null,
|
||||||
|
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
|
||||||
|
|
||||||
|
it('is true only when the LAST assistant turn overflowed', () => {
|
||||||
|
expect(
|
||||||
|
lastAssistantReplayOverflow([
|
||||||
|
row('assistant', { replayOverflow: true }),
|
||||||
|
row('user', null),
|
||||||
|
]),
|
||||||
|
).toBe(true);
|
||||||
|
// A recovered (later, non-overflow) assistant turn clears it.
|
||||||
|
expect(
|
||||||
|
lastAssistantReplayOverflow([
|
||||||
|
row('assistant', { replayOverflow: true }),
|
||||||
|
row('user', null),
|
||||||
|
row('assistant', { contextTokens: 5 }),
|
||||||
|
]),
|
||||||
|
).toBe(false);
|
||||||
|
expect(lastAssistantReplayOverflow([])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #490 reactive recovery: a prior turn stamped `replayOverflow` must make the
|
||||||
|
// NEXT turn's effective budget the AGGRESSIVE 0.5x cut — that harder trim is
|
||||||
|
// what un-bricks a chat that just 400'd on the context window. This exercises
|
||||||
|
// the exact wiring the service uses: read the stamp, then scale the threshold.
|
||||||
|
it('#490: a prior replayOverflow drives the next turn to the 0.5x aggressive budget', () => {
|
||||||
|
const history = [
|
||||||
|
row('assistant', { replayOverflow: true }),
|
||||||
|
row('user', null),
|
||||||
|
];
|
||||||
|
const priorOverflowed = lastAssistantReplayOverflow(history);
|
||||||
|
expect(priorOverflowed).toBe(true);
|
||||||
|
// Base budget 100k -> aggressive recovery halves it to 50k this turn.
|
||||||
|
expect(resolveEffectiveReplayThreshold(100_000, priorOverflowed)).toBe(50_000);
|
||||||
|
// Odd base floors, not rounds.
|
||||||
|
expect(resolveEffectiveReplayThreshold(99_999, true)).toBe(49_999);
|
||||||
|
// No prior overflow -> the base budget is used verbatim (no aggressive cut).
|
||||||
|
expect(resolveEffectiveReplayThreshold(100_000, false)).toBe(100_000);
|
||||||
|
// An explicit off-switch (null) is never overridden, even on recovery.
|
||||||
|
expect(resolveEffectiveReplayThreshold(null, true)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('rowToUiMessage', () => {
|
describe('rowToUiMessage', () => {
|
||||||
@@ -618,6 +930,23 @@ describe('flushAssistant', () => {
|
|||||||
expect(flushed.metadata.error).toBe('boom');
|
expect(flushed.metadata.error).toBe('boom');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #490 observability: the replay budgeter's decision is stamped on the turn.
|
||||||
|
it('records replayTrimmedToTokens + replayOverflow when provided', () => {
|
||||||
|
const f = flushAssistant([], '', 'error', {
|
||||||
|
error: 'ctx',
|
||||||
|
replayTrimmedToTokens: 42_000,
|
||||||
|
replayOverflow: true,
|
||||||
|
});
|
||||||
|
expect(f.metadata.replayTrimmedToTokens).toBe(42_000);
|
||||||
|
expect(f.metadata.replayOverflow).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the replay metadata when not provided', () => {
|
||||||
|
const f = flushAssistant([], '', 'completed', { finishReason: 'stop' });
|
||||||
|
expect('replayTrimmedToTokens' in f.metadata).toBe(false);
|
||||||
|
expect('replayOverflow' in f.metadata).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
// #274 observability: the page-change diff the agent saw this turn is persisted
|
// #274 observability: the page-change diff the agent saw this turn is persisted
|
||||||
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
// to metadata.pageChanged when a non-empty diff was injected, and omitted when
|
||||||
// the diff is empty/whitespace or the arg is not supplied.
|
// the diff is empty/whitespace or the arg is not supplied.
|
||||||
@@ -1315,8 +1644,12 @@ describe('AiChatService page-change lifecycle (#274)', () => {
|
|||||||
describe('isInterruptResume', () => {
|
describe('isInterruptResume', () => {
|
||||||
// history tail is the just-inserted user row; [len-2] is the previous turn.
|
// history tail is the just-inserted user row; [len-2] is the previous turn.
|
||||||
const withPrev = (
|
const withPrev = (
|
||||||
prev: { role: string; status?: string | null } | null,
|
prev: {
|
||||||
): Array<{ role: string; status?: string | null }> =>
|
role: string;
|
||||||
|
status?: string | null;
|
||||||
|
metadata?: unknown;
|
||||||
|
} | null,
|
||||||
|
): Array<{ role: string; status?: string | null; metadata?: unknown }> =>
|
||||||
prev
|
prev
|
||||||
? [prev, { role: 'user', status: null }]
|
? [prev, { role: 'user', status: null }]
|
||||||
: [{ role: 'user', status: null }];
|
: [{ role: 'user', status: null }];
|
||||||
@@ -1357,6 +1690,33 @@ describe('isInterruptResume', () => {
|
|||||||
it('false when there is no preceding turn (only the new user row)', () => {
|
it('false when there is no preceding turn (only the new user row)', () => {
|
||||||
expect(isInterruptResume(withPrev(null), true)).toBe(false);
|
expect(isInterruptResume(withPrev(null), true)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('#487 EXCLUDES a reconcile stamp (finalizeFailed) — not a genuine interruption', () => {
|
||||||
|
// A row a reconcile settled to 'aborted' carries metadata.finalizeFailed. It
|
||||||
|
// must NOT be treated as an interrupt-resume (that would inject a false
|
||||||
|
// "you were interrupted" note), even though its status is 'aborted'.
|
||||||
|
expect(
|
||||||
|
isInterruptResume(
|
||||||
|
withPrev({
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'aborted',
|
||||||
|
metadata: { finalizeFailed: true },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
// A genuine abort (no finalizeFailed) still counts.
|
||||||
|
expect(
|
||||||
|
isInterruptResume(
|
||||||
|
withPrev({
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'aborted',
|
||||||
|
metadata: { parts: [] },
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1409,7 +1769,7 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wire only the deps reached on the way to the pipe call, plus a spy registry.
|
// Wire only the deps reached on the way to the pipe call, plus a spy registry.
|
||||||
function makeService(opts: { resumable: boolean }) {
|
function makeService(opts: { resumable: boolean; history?: unknown[] }) {
|
||||||
const aiChatRepo = {
|
const aiChatRepo = {
|
||||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
insert: jest.fn(),
|
insert: jest.fn(),
|
||||||
@@ -1417,8 +1777,11 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
|
|||||||
const aiChatMessageRepo = {
|
const aiChatMessageRepo = {
|
||||||
// Both the user insert and the assistant seed return the same row id.
|
// Both the user insert and the assistant seed return the same row id.
|
||||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
findAllByChat: jest.fn(async () => []),
|
findAllByChat: jest.fn(async () => opts.history ?? []),
|
||||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
// #487: the terminal owner-write + the opportunistic reconcile query.
|
||||||
|
finalizeOwner: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||||
};
|
};
|
||||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
const tools = { forUser: jest.fn(async () => ({})) };
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
@@ -1453,7 +1816,7 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
|
|||||||
} as never,
|
} as never,
|
||||||
streamRegistry as never,
|
streamRegistry as never,
|
||||||
);
|
);
|
||||||
return { svc, streamRegistry };
|
return { svc, streamRegistry, aiChatMessageRepo };
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
@@ -1536,6 +1899,86 @@ describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', ()
|
|||||||
await expect(drive(svc, makeRunHooks())).rejects.toThrow('boom');
|
await expect(drive(svc, makeRunHooks())).rejects.toThrow('boom');
|
||||||
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
|
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #489 REGRESSION (against the REAL convertToModelMessages — not mocked here):
|
||||||
|
// a persisted history row whose parts contain a `null` element makes the real
|
||||||
|
// convertToModelMessages THROW ("Cannot read properties of null"). Pre-fix that
|
||||||
|
// 500-ed every turn forever and each retry appended a duplicate user row. The
|
||||||
|
// fix converts BEFORE the insert and isolates the poisoned row per-row, degrading
|
||||||
|
// it to text with a "[tool context omitted]" marker. Assert the turn still runs,
|
||||||
|
// the marker reaches the model, and exactly ONE user row is inserted.
|
||||||
|
it('#489: a poisoned OLD-history row keeps the chat working; the marker reaches the model; one user insert', async () => {
|
||||||
|
const { svc, aiChatMessageRepo } = makeService({
|
||||||
|
resumable: false,
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: 'old-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'earlier answer',
|
||||||
|
// A null part is the poison: rowToUiMessage keeps it (the array is
|
||||||
|
// non-empty) and the real convertToModelMessages throws on it.
|
||||||
|
metadata: { parts: [{ type: 'text', text: 'earlier answer' }, null] },
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Must NOT throw — the poisoned row is degraded, not fatal.
|
||||||
|
await drive(svc, makeRunHooks());
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
const passedMessages = streamTextMock.mock.calls[0][0].messages;
|
||||||
|
const serialized = JSON.stringify(passedMessages);
|
||||||
|
// The model sees the truncation marker (silent tool-context loss is not ok)
|
||||||
|
// AND the row's readable text is preserved alongside it.
|
||||||
|
expect(serialized).toContain('[tool context omitted]');
|
||||||
|
expect(serialized).toContain('earlier answer');
|
||||||
|
// Exactly ONE user row inserted (no duplicate), inserted AFTER conversion.
|
||||||
|
const userInserts = aiChatMessageRepo.insert.mock.calls
|
||||||
|
.map((c: unknown[]) => c[0] as { role?: string })
|
||||||
|
.filter((r) => r.role === 'user');
|
||||||
|
expect(userInserts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #489: client-supplied non-text parts (a tool-part in `input-available`, the
|
||||||
|
// exact "bricking" payload) are dropped ON RECEIPT — never persisted — so they
|
||||||
|
// can never poison future turns. Only the text survives into metadata.parts.
|
||||||
|
it('#489: a non-text client part is stripped before persist (only text survives)', async () => {
|
||||||
|
const { svc, aiChatMessageRepo } = makeService({ resumable: false });
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'u1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 's1',
|
||||||
|
body: {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 'm1',
|
||||||
|
role: 'user',
|
||||||
|
parts: [
|
||||||
|
{ type: 'text', text: 'hello' },
|
||||||
|
// untrusted tool-part — must be dropped, never persisted
|
||||||
|
{
|
||||||
|
type: 'tool-getPage',
|
||||||
|
toolCallId: 't1',
|
||||||
|
state: 'input-available',
|
||||||
|
input: { pageId: 'p' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
runHooks: makeRunHooks() as never,
|
||||||
|
});
|
||||||
|
const userInsert = aiChatMessageRepo.insert.mock.calls
|
||||||
|
.map((c: unknown[]) => c[0] as { role?: string; metadata?: unknown })
|
||||||
|
.find((r) => r.role === 'user');
|
||||||
|
const parts = (userInsert?.metadata as { parts?: Array<{ type: string }> })
|
||||||
|
?.parts;
|
||||||
|
expect(parts).toEqual([{ type: 'text', text: 'hello' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1623,6 +2066,19 @@ describe('AiChatService.stream — token-degeneration reaction (#444)', () => {
|
|||||||
return { id };
|
return { id };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// #487: the terminal owner-write records into the SAME `updated` recorder so
|
||||||
|
// assertions on the terminal 'completed'/'error'/'aborted' write still hold.
|
||||||
|
finalizeOwner: jest.fn(
|
||||||
|
async (
|
||||||
|
id: string,
|
||||||
|
workspaceId: string,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
updated.push({ id, workspaceId, patch });
|
||||||
|
return { id };
|
||||||
|
},
|
||||||
|
),
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||||
};
|
};
|
||||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
const tools = { forUser: jest.fn(async () => ({})) };
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
@@ -1882,3 +2338,148 @@ describe('AiChatService.stream — token-degeneration reaction (#444)', () => {
|
|||||||
expect(patch.content).not.toContain(STEP_LIMIT_NO_ANSWER_MARKER);
|
expect(patch.content).not.toContain(STEP_LIMIT_NO_ANSWER_MARKER);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #487 F3 — the reconcile() / reconcileChat() ORCHESTRATORS. The individual
|
||||||
|
// clauses are exercised elsewhere; these pin the production orchestration the
|
||||||
|
// per-clause specs do not: the clause ORDER, the per-clause try/catch ISOLATION
|
||||||
|
// (one clause throwing must NOT abort the others), and reconcileChat() (which runs
|
||||||
|
// at the start of every turn and was entirely uncovered).
|
||||||
|
describe('AiChatService.reconcile / reconcileChat orchestrators (#487 F3)', () => {
|
||||||
|
let warnSpy: jest.SpyInstance;
|
||||||
|
beforeEach(() => {
|
||||||
|
// Silence the intentional clause-failure warnings (kept out of test output).
|
||||||
|
warnSpy = jest
|
||||||
|
.spyOn(Logger.prototype, 'warn')
|
||||||
|
.mockImplementation(() => undefined);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeService(opts: {
|
||||||
|
messageRepo?: Record<string, jest.Mock>;
|
||||||
|
runService?: Record<string, jest.Mock>;
|
||||||
|
}) {
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => []),
|
||||||
|
stampTerminalIfStreaming: jest.fn(async () => undefined),
|
||||||
|
sweepStreamingWithoutActiveRun: jest.fn(async () => 0),
|
||||||
|
...(opts.messageRepo ?? {}),
|
||||||
|
};
|
||||||
|
const aiChatRunService = opts.runService
|
||||||
|
? {
|
||||||
|
zombieRunIds: jest.fn(() => []),
|
||||||
|
settleZombie: jest.fn(async () => true),
|
||||||
|
reconcileStaleRuns: jest.fn(async () => 0),
|
||||||
|
...opts.runService,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
const svc = new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
{} as never, // aiChatRepo
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiChatPageSnapshotRepo
|
||||||
|
{} as never, // aiSettings
|
||||||
|
{} as never, // tools
|
||||||
|
{} as never, // mcpClients
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{} as never, // environment
|
||||||
|
{} as never, // streamRegistry
|
||||||
|
aiChatRunService as never, // aiChatRunService (#487)
|
||||||
|
);
|
||||||
|
return { svc, aiChatMessageRepo, aiChatRunService };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('reconcile() fires all four clauses IN ORDER (a -> b -> c -> d)', async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
const { svc } = makeService({
|
||||||
|
messageRepo: {
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => {
|
||||||
|
order.push('b:find');
|
||||||
|
return [
|
||||||
|
{ messageId: 'm1', workspaceId: 'ws1', runStatus: 'succeeded' },
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
stampTerminalIfStreaming: jest.fn(async () => {
|
||||||
|
order.push('b:stamp');
|
||||||
|
}),
|
||||||
|
sweepStreamingWithoutActiveRun: jest.fn(async () => {
|
||||||
|
order.push('d');
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
runService: {
|
||||||
|
zombieRunIds: jest.fn(() => ['z1']),
|
||||||
|
settleZombie: jest.fn(async () => {
|
||||||
|
order.push('a');
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
reconcileStaleRuns: jest.fn(async () => {
|
||||||
|
order.push('c');
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.reconcile();
|
||||||
|
|
||||||
|
expect(order).toEqual(['a', 'b:find', 'b:stamp', 'c', 'd']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a clause that THROWS does not abort the remaining clauses (per-clause try/catch isolation)', async () => {
|
||||||
|
const { svc, aiChatMessageRepo, aiChatRunService } = makeService({
|
||||||
|
messageRepo: {
|
||||||
|
// Clause (b) blows up mid-reconcile.
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => {
|
||||||
|
throw new Error('clause b DB blip');
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
runService: {
|
||||||
|
zombieRunIds: jest.fn(() => ['z1']),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// reconcile() must SETTLE (the clause-b failure is swallowed), not reject.
|
||||||
|
await expect(svc.reconcile()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// (a) ran before (b); crucially (c) and (d) STILL ran despite (b) throwing —
|
||||||
|
// the property a missing try/catch would break. MUTATION-VERIFY: drop clause
|
||||||
|
// (b)'s try/catch and this reddens (the throw propagates, skipping c + d).
|
||||||
|
expect(aiChatRunService!.settleZombie).toHaveBeenCalled(); // (a)
|
||||||
|
expect(aiChatRunService!.reconcileStaleRuns).toHaveBeenCalled(); // (c)
|
||||||
|
expect(
|
||||||
|
aiChatMessageRepo.sweepStreamingWithoutActiveRun,
|
||||||
|
).toHaveBeenCalled(); // (d)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reconcileChat() settles THIS chat\'s stuck streaming rows by their run status', async () => {
|
||||||
|
const { svc, aiChatMessageRepo } = makeService({
|
||||||
|
messageRepo: {
|
||||||
|
findStreamingWithTerminalRun: jest.fn(async () => [
|
||||||
|
{ messageId: 'm1', workspaceId: 'ws1', runStatus: 'failed' },
|
||||||
|
{ messageId: 'm2', workspaceId: 'ws1', runStatus: 'succeeded' },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await svc.reconcileChat('chat-1', 'ws1');
|
||||||
|
|
||||||
|
// Scoped to THIS chat and bounded at 50 (the user-facing opportunistic path).
|
||||||
|
expect(
|
||||||
|
aiChatMessageRepo.findStreamingWithTerminalRun,
|
||||||
|
).toHaveBeenCalledWith(50, { chatId: 'chat-1', workspaceId: 'ws1' });
|
||||||
|
// failed-run -> 'error'; every other terminal status -> 'aborted'.
|
||||||
|
expect(aiChatMessageRepo.stampTerminalIfStreaming).toHaveBeenCalledWith(
|
||||||
|
'm1',
|
||||||
|
'ws1',
|
||||||
|
'error',
|
||||||
|
);
|
||||||
|
expect(aiChatMessageRepo.stampTerminalIfStreaming).toHaveBeenCalledWith(
|
||||||
|
'm2',
|
||||||
|
'ws1',
|
||||||
|
'aborted',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,209 @@
|
|||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { Client } from 'pg';
|
||||||
|
import { flushAssistant, serializeSteps } from './ai-chat.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #490 write-volume regression — an OBSERVABLE-PROPERTY test on a LIVE Postgres,
|
||||||
|
* not "bytes through a mock repo" (a mock measures exactly the thing that does not
|
||||||
|
* hurt). It drives a realistic 50-step run where each step returns a ~100 KB tool
|
||||||
|
* output and, at every `onStepFinish`, UPDATEs the assistant row the way the
|
||||||
|
* service does — then reads the REAL write volume via the `pg_current_wal_lsn()`
|
||||||
|
* delta around the run.
|
||||||
|
*
|
||||||
|
* The property proven: v2 stores each tool OUTPUT only in `metadata.parts`, no
|
||||||
|
* longer ALSO in the `tool_calls` trace. So:
|
||||||
|
* 1. the trace (`tool_calls`) column's write volume is now O(Σ steps) — tiny,
|
||||||
|
* linear outcome flags — vs the pre-#490 O(N²) that re-persisted every prior
|
||||||
|
* output on every step; and
|
||||||
|
* 2. the FULL-row write volume drops sharply (the duplicated output copy is gone).
|
||||||
|
*
|
||||||
|
* Connects to the local gitmost test Postgres (docker `gitmost-test-pg` on :5432);
|
||||||
|
* SKIPS cleanly when that DB is not reachable so it never breaks a DB-less CI.
|
||||||
|
*/
|
||||||
|
const CONN =
|
||||||
|
process.env.WAL_TEST_DATABASE_URL ??
|
||||||
|
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost';
|
||||||
|
|
||||||
|
// A step whose tool output is ~100 KB (a page read), in the SDK StepLike shape.
|
||||||
|
// The body is INCOMPRESSIBLE random text — a `'x'.repeat()` filler would TOAST-
|
||||||
|
// compress to nothing and hide the real write volume (a page body does not).
|
||||||
|
function makeStep(i: number, outputBytes = 100_000) {
|
||||||
|
const body = randomBytes(Math.ceil(outputBytes * 0.75)).toString('base64');
|
||||||
|
return {
|
||||||
|
text: `step ${i} reasoning`,
|
||||||
|
toolCalls: [{ toolCallId: `c${i}`, toolName: 'getPage', input: { id: `p${i}` } }],
|
||||||
|
toolResults: [
|
||||||
|
{
|
||||||
|
toolCallId: `c${i}`,
|
||||||
|
toolName: 'getPage',
|
||||||
|
output: { id: `p${i}`, title: `Page ${i}`, body },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pre-#490 (v1) trace: outputs stored a SECOND time in `tool_calls`
|
||||||
|
// (the duplication #490 removed). Mirrors the OLD serializeSteps shape.
|
||||||
|
function v1Trace(steps: ReturnType<typeof makeStep>[]): unknown {
|
||||||
|
const calls: unknown[] = [];
|
||||||
|
for (const s of steps) {
|
||||||
|
for (const c of s.toolCalls) calls.push({ toolName: c.toolName, input: c.input });
|
||||||
|
for (const r of s.toolResults)
|
||||||
|
calls.push({ toolName: r.toolName, output: r.output });
|
||||||
|
}
|
||||||
|
return calls;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walDelta(
|
||||||
|
client: Client,
|
||||||
|
fn: () => Promise<void>,
|
||||||
|
): Promise<number> {
|
||||||
|
const before = (await client.query('SELECT pg_current_wal_lsn() AS l')).rows[0]
|
||||||
|
.l as string;
|
||||||
|
await fn();
|
||||||
|
// NOTE: do NOT pg_switch_wal() here — a segment switch pads the LSN to the next
|
||||||
|
// 16 MB boundary and would swamp the actual write delta. The raw LSN advances by
|
||||||
|
// the bytes of WAL emitted, which is exactly what we want to measure.
|
||||||
|
const after = (await client.query('SELECT pg_current_wal_lsn() AS l')).rows[0]
|
||||||
|
.l as string;
|
||||||
|
return Number(
|
||||||
|
(await client.query('SELECT pg_wal_lsn_diff($1,$2) AS d', [after, before]))
|
||||||
|
.rows[0].d,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('#490 write-volume on a live Postgres (pg_current_wal_lsn delta)', () => {
|
||||||
|
let client: Client | undefined;
|
||||||
|
let available = false;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
try {
|
||||||
|
client = new Client(CONN);
|
||||||
|
await client.connect();
|
||||||
|
await client.query('SELECT pg_current_wal_lsn()');
|
||||||
|
available = true;
|
||||||
|
} catch {
|
||||||
|
available = false;
|
||||||
|
client = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await client?.end().catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
const STEPS = 50;
|
||||||
|
|
||||||
|
it('v2 trace write volume is O(Σ steps) — a tiny fraction of the v1 duplicate', async () => {
|
||||||
|
if (!available || !client) {
|
||||||
|
console.warn('SKIP: gitmost-test-pg not reachable; skipping WAL test.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = client;
|
||||||
|
// Isolated table so we measure only the tool_calls (trace) column's writes.
|
||||||
|
await c.query('DROP TABLE IF EXISTS _wal_trace');
|
||||||
|
await c.query('CREATE TABLE _wal_trace(id int primary key, tool_calls jsonb)');
|
||||||
|
await c.query("INSERT INTO _wal_trace VALUES (1, '[]'::jsonb)");
|
||||||
|
|
||||||
|
const steps: ReturnType<typeof makeStep>[] = [];
|
||||||
|
|
||||||
|
// v1: each step re-persists ALL prior outputs into the trace (the O(N²) churn).
|
||||||
|
const v1 = await walDelta(c, async () => {
|
||||||
|
const acc: ReturnType<typeof makeStep>[] = [];
|
||||||
|
for (let i = 0; i < STEPS; i++) {
|
||||||
|
acc.push(makeStep(i));
|
||||||
|
await c.query('UPDATE _wal_trace SET tool_calls=$1 WHERE id=1', [
|
||||||
|
JSON.stringify(v1Trace(acc)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
steps.push(...acc);
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.query("UPDATE _wal_trace SET tool_calls='[]'::jsonb WHERE id=1");
|
||||||
|
|
||||||
|
// v2: the REAL serializeSteps — outcome flags only, NO outputs.
|
||||||
|
const v2 = await walDelta(c, async () => {
|
||||||
|
const acc: ReturnType<typeof makeStep>[] = [];
|
||||||
|
for (let i = 0; i < STEPS; i++) {
|
||||||
|
acc.push(makeStep(i));
|
||||||
|
await c.query('UPDATE _wal_trace SET tool_calls=$1 WHERE id=1', [
|
||||||
|
JSON.stringify(serializeSteps(acc)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.query('DROP TABLE IF EXISTS _wal_trace');
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[#490 WAL] trace column over ${STEPS} steps: v1=${(v1 / 1e6).toFixed(1)}MB ` +
|
||||||
|
`v2=${(v2 / 1e6).toFixed(2)}MB (${(v1 / v2).toFixed(0)}x smaller)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// The trace no longer carries outputs: v2 is a tiny fraction of v1's WAL.
|
||||||
|
expect(v2).toBeLessThan(v1 * 0.1);
|
||||||
|
// And v2's trace WAL is small in absolute terms — O(Σ steps) of flags, not
|
||||||
|
// O(N² × output). 50 steps of ~40-byte flags is well under a few MB of WAL.
|
||||||
|
expect(v2).toBeLessThan(5_000_000);
|
||||||
|
// v1's duplicate alone is huge (≈ the 100 KB output re-written N² times).
|
||||||
|
expect(v1).toBeGreaterThan(50_000_000);
|
||||||
|
}, 120_000);
|
||||||
|
|
||||||
|
it('the full assistant row write drops sharply once the duplicate is gone', async () => {
|
||||||
|
if (!available || !client) return;
|
||||||
|
const c = client;
|
||||||
|
await c.query('DROP TABLE IF EXISTS _wal_full');
|
||||||
|
await c.query(
|
||||||
|
'CREATE TABLE _wal_full(id int primary key, content text, tool_calls jsonb, metadata jsonb, status text)',
|
||||||
|
);
|
||||||
|
await c.query("INSERT INTO _wal_full VALUES (1, '', '[]'::jsonb, '{}'::jsonb, 'streaming')");
|
||||||
|
|
||||||
|
const writeRow = async (patch: {
|
||||||
|
content: string;
|
||||||
|
toolCalls: unknown;
|
||||||
|
metadata: unknown;
|
||||||
|
status: string;
|
||||||
|
}) =>
|
||||||
|
c.query(
|
||||||
|
'UPDATE _wal_full SET content=$1, tool_calls=$2, metadata=$3, status=$4 WHERE id=1',
|
||||||
|
[
|
||||||
|
patch.content,
|
||||||
|
JSON.stringify(patch.toolCalls ?? null),
|
||||||
|
JSON.stringify(patch.metadata),
|
||||||
|
patch.status,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// v2 (real flushAssistant): outputs live once, in metadata.parts.
|
||||||
|
const v2 = await walDelta(c, async () => {
|
||||||
|
const acc: ReturnType<typeof makeStep>[] = [];
|
||||||
|
for (let i = 0; i < STEPS; i++) {
|
||||||
|
acc.push(makeStep(i));
|
||||||
|
await writeRow(flushAssistant(acc as never, '', 'streaming'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.query("UPDATE _wal_full SET content='', tool_calls='[]'::jsonb, metadata='{}'::jsonb WHERE id=1");
|
||||||
|
|
||||||
|
// v1: same row PLUS the duplicated outputs in the trace column.
|
||||||
|
const v1 = await walDelta(c, async () => {
|
||||||
|
const acc: ReturnType<typeof makeStep>[] = [];
|
||||||
|
for (let i = 0; i < STEPS; i++) {
|
||||||
|
acc.push(makeStep(i));
|
||||||
|
const f = flushAssistant(acc as never, '', 'streaming');
|
||||||
|
await writeRow({ ...f, toolCalls: v1Trace(acc) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await c.query('DROP TABLE IF EXISTS _wal_full');
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(
|
||||||
|
`[#490 WAL] full row over ${STEPS} steps: v1=${(v1 / 1e6).toFixed(1)}MB ` +
|
||||||
|
`v2=${(v2 / 1e6).toFixed(1)}MB (saved ${((1 - v2 / v1) * 100).toFixed(0)}%)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Removing the duplicated trace copy is a large, real write-volume reduction.
|
||||||
|
expect(v2).toBeLessThan(v1 * 0.75);
|
||||||
|
}, 120_000);
|
||||||
|
});
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { buildChatMarkdown, normalizeLang } from './chat-markdown.util';
|
import {
|
||||||
|
buildChatMarkdown,
|
||||||
|
normalizeLang,
|
||||||
|
labelledToolNames,
|
||||||
|
} from './chat-markdown.util';
|
||||||
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
||||||
|
import { SHARED_TOOL_SPECS } from '../../../../../packages/mcp/src/tool-specs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
|
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
|
||||||
@@ -455,3 +460,43 @@ describe('buildChatMarkdown (server) — structure', () => {
|
|||||||
expect(md).toContain('````');
|
expect(md).toContain('````');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #494 — REVERSE drift-guard for the export's friendly tool labels. A label keyed
|
||||||
|
* by a tool name that no longer exists silently degrades to the generic
|
||||||
|
* "Ran tool <name>" line; nothing reddened before. This asserts every labelled
|
||||||
|
* name is a real in-app tool and that both languages label the same set.
|
||||||
|
*/
|
||||||
|
describe('tool-label parity (#494)', () => {
|
||||||
|
// In-app tool names come from the shared registry (inAppKey, excluding
|
||||||
|
// mcpOnly specs) PLUS the inline in-app-only tools that carry a friendly label.
|
||||||
|
// The only labelled inline tool is the hybrid semantic search.
|
||||||
|
const INLINE_INAPP_LABELLED = new Set(['searchPages']);
|
||||||
|
|
||||||
|
function validInAppToolNames(): Set<string> {
|
||||||
|
const names = new Set<string>(INLINE_INAPP_LABELLED);
|
||||||
|
for (const spec of Object.values(SHARED_TOOL_SPECS)) {
|
||||||
|
if ((spec as { mcpOnly?: boolean }).mcpOnly) continue;
|
||||||
|
names.add((spec as { inAppKey: string }).inAppKey);
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('en and ru label the SAME set of tools', () => {
|
||||||
|
expect(labelledToolNames('en').sort()).toEqual(
|
||||||
|
labelledToolNames('ru').sort(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every labelled tool name is a real in-app tool', () => {
|
||||||
|
const valid = validInAppToolNames();
|
||||||
|
const dead = labelledToolNames('en').filter((n) => !valid.has(n));
|
||||||
|
expect(dead).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the guard REDDENS for an unknown label key (mutation check)', () => {
|
||||||
|
const valid = validInAppToolNames();
|
||||||
|
// A hypothetical renamed-away label must be caught.
|
||||||
|
expect(valid.has('getPageRenamedAway')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const LABELS: Record<
|
|||||||
searchPages: 'Searched pages',
|
searchPages: 'Searched pages',
|
||||||
getPage: 'Read page',
|
getPage: 'Read page',
|
||||||
createPage: 'Created page',
|
createPage: 'Created page',
|
||||||
updatePageContent: 'Updated page',
|
updatePageMarkdown: 'Updated page',
|
||||||
renamePage: 'Renamed page',
|
renamePage: 'Renamed page',
|
||||||
movePage: 'Moved page',
|
movePage: 'Moved page',
|
||||||
deletePage: 'Deleted page (to trash)',
|
deletePage: 'Deleted page (to trash)',
|
||||||
@@ -96,7 +96,7 @@ const LABELS: Record<
|
|||||||
searchPages: 'Искал по страницам',
|
searchPages: 'Искал по страницам',
|
||||||
getPage: 'Прочитал страницу',
|
getPage: 'Прочитал страницу',
|
||||||
createPage: 'Создал страницу',
|
createPage: 'Создал страницу',
|
||||||
updatePageContent: 'Обновил страницу',
|
updatePageMarkdown: 'Обновил страницу',
|
||||||
renamePage: 'Переименовал страницу',
|
renamePage: 'Переименовал страницу',
|
||||||
movePage: 'Переместил страницу',
|
movePage: 'Переместил страницу',
|
||||||
deletePage: 'Удалил страницу (в корзину)',
|
deletePage: 'Удалил страницу (в корзину)',
|
||||||
@@ -154,6 +154,17 @@ function toolLabel(name: string, lang: ExportLang): string {
|
|||||||
return LABELS[lang].tools[name] ?? LABELS[lang].ranTool(name);
|
return LABELS[lang].tools[name] ?? LABELS[lang].ranTool(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tool names that carry a hand-written friendly export label, per language.
|
||||||
|
* Exported for the drift-guard (#494): a label keyed by a tool name that no
|
||||||
|
* longer exists is a DEAD entry (the tool was renamed and now silently falls back
|
||||||
|
* to the generic `ranTool(name)` line). The guard asserts every key here is a
|
||||||
|
* real in-app tool AND that the two languages label the SAME set of tools.
|
||||||
|
*/
|
||||||
|
export function labelledToolNames(lang: ExportLang): string[] {
|
||||||
|
return Object.keys(LABELS[lang].tools);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ export class CreateMcpServerDto {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
|
// Omit/null => no restriction; `[]` is persisted verbatim and means deny-all
|
||||||
|
// (zero tools) since #476. @IsOptional() skips validation for null as well,
|
||||||
|
// so an explicit null is accepted.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
toolAllowlist?: string[];
|
toolAllowlist?: string[] | null;
|
||||||
|
|
||||||
// Admin-authored guidance ("how/when to use this server's tools") injected
|
// Admin-authored guidance ("how/when to use this server's tools") injected
|
||||||
// into the agent system prompt next to the tool descriptions (#180). Trusted,
|
// into the agent system prompt next to the tool descriptions (#180). Trusted,
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ export class UpdateMcpServerDto {
|
|||||||
@IsObject()
|
@IsObject()
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
|
|
||||||
|
// Absent => unchanged; null => no restriction; `[]` is persisted verbatim
|
||||||
|
// and means deny-all (zero tools) since #476. @IsOptional() skips validation
|
||||||
|
// for null as well, so an explicit null is accepted.
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
toolAllowlist?: string[];
|
toolAllowlist?: string[] | null;
|
||||||
|
|
||||||
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared
|
// Admin-authored prompt guidance (#180). Absent => unchanged; blank => cleared
|
||||||
// (stored as null by the repo). Capped to bound prompt/token size.
|
// (stored as null by the repo). Capped to bound prompt/token size.
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { type Tool } from 'ai';
|
||||||
|
import { McpClientsService } from './mcp-clients.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool-allowlist filtering semantics on the merged external toolset (#476).
|
||||||
|
*
|
||||||
|
* COVERAGE CHOICE (documented per issue #476): the full corrupt-row chain
|
||||||
|
* (DB value -> repo normalizeRow -> toolsFor filter) is covered on TWO levels
|
||||||
|
* instead of one live-stub-MCP-server integration test:
|
||||||
|
* (a) apps/server/test/integration/ai-mcp-server-repo.int-spec.ts pins the
|
||||||
|
* repo read/write semantics against a real Postgres — `[]` round-trips
|
||||||
|
* as jsonb `[]`, a present-but-corrupt value fails CLOSED to `[]` with
|
||||||
|
* an error log;
|
||||||
|
* (b) THIS spec pins what the toolset builder does with the repo's output —
|
||||||
|
* null = unrestricted, `['alpha']` = only alpha, `[]` (including the
|
||||||
|
* corrupt-row fallback) = ZERO tools.
|
||||||
|
* Together they prove the end-to-end property "corrupt/empty allowlist can
|
||||||
|
* never widen to all tools" without a live stub HTTP MCP server.
|
||||||
|
*
|
||||||
|
* The drive path mirrors mcp-namespacing.spec.ts: stub the repo's listEnabled,
|
||||||
|
* spy the private `connect` to return a fake client, inspect the merged keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fakeTool(): Tool {
|
||||||
|
return { description: 'x', inputSchema: undefined } as unknown as Tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FakeServer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: string;
|
||||||
|
url: string;
|
||||||
|
headersEnc: string | null;
|
||||||
|
toolAllowlist: 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a service whose repo returns `servers` and whose fake clients expose
|
||||||
|
* `rawTools` from tools(). Returns the merged tool keys produced by toolsFor.
|
||||||
|
*/
|
||||||
|
async function mergedKeysFor(
|
||||||
|
servers: FakeServer[],
|
||||||
|
rawTools: Record<string, Tool>,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const repoStub = {
|
||||||
|
listEnabled: jest.fn().mockResolvedValue(servers),
|
||||||
|
};
|
||||||
|
const service = new McpClientsService(repoStub as never, {} as never);
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as unknown as { connect: (s: FakeServer) => unknown },
|
||||||
|
'connect',
|
||||||
|
)
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
tools: () => Promise.resolve(rawTools),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-1');
|
||||||
|
// Release the lease so the service does not hold the fake clients open.
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
return Object.keys(toolset.tools);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('external MCP tool-allowlist filtering (via toolsFor, #476)', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
const RAW = () => ({
|
||||||
|
alpha: fakeTool(),
|
||||||
|
beta: fakeTool(),
|
||||||
|
gamma: fakeTool(),
|
||||||
|
});
|
||||||
|
|
||||||
|
it("['alpha'] lets ONLY alpha through", async () => {
|
||||||
|
const keys = await mergedKeysFor(
|
||||||
|
[server({ id: 'id-1', name: 'srv', toolAllowlist: ['alpha'] })],
|
||||||
|
RAW(),
|
||||||
|
);
|
||||||
|
expect(keys).toEqual(['srv_alpha']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null (no restriction) lets every tool through', async () => {
|
||||||
|
const keys = await mergedKeysFor(
|
||||||
|
[server({ id: 'id-1', name: 'srv', toolAllowlist: null })],
|
||||||
|
RAW(),
|
||||||
|
);
|
||||||
|
expect(keys.sort()).toEqual(['srv_alpha', 'srv_beta', 'srv_gamma']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[] (deny-all) yields ZERO tools — an empty array is authoritative, not falsy (#476)', async () => {
|
||||||
|
// This is the regression the #476 change guards: `[]` used to fall through
|
||||||
|
// the old `allow.length > 0` check and expose ALL tools. It must expose NONE.
|
||||||
|
const keys = await mergedKeysFor(
|
||||||
|
[server({ id: 'id-1', name: 'srv', toolAllowlist: [] })],
|
||||||
|
RAW(),
|
||||||
|
);
|
||||||
|
expect(keys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the corrupt-row fallback ([] from the repo) also yields ZERO tools (#476)', async () => {
|
||||||
|
// The repo turns a present-but-corrupt tool_allowlist into `[]` (fail-closed,
|
||||||
|
// see normalizeRow in ai-mcp-server.repo.ts + the int-spec); this pins that
|
||||||
|
// the toolset builder honours that fallback as deny-all rather than allow-all.
|
||||||
|
const corruptFallback: string[] = [];
|
||||||
|
const keys = await mergedKeysFor(
|
||||||
|
[server({ id: 'id-1', name: 'srv', toolAllowlist: corruptFallback })],
|
||||||
|
RAW(),
|
||||||
|
);
|
||||||
|
expect(keys).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allowlisted names not exposed by the server are ignored (no phantom tools)', async () => {
|
||||||
|
const keys = await mergedKeysFor(
|
||||||
|
[
|
||||||
|
server({
|
||||||
|
id: 'id-1',
|
||||||
|
name: 'srv',
|
||||||
|
toolAllowlist: ['alpha', 'does-not-exist'],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
RAW(),
|
||||||
|
);
|
||||||
|
expect(keys).toEqual(['srv_alpha']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a deny-all server contributes no prompt instructions (0 tools merged)', async () => {
|
||||||
|
const repoStub = {
|
||||||
|
listEnabled: jest.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
...server({ id: 'id-1', name: 'srv', toolAllowlist: [] }),
|
||||||
|
instructions: 'use the tools wisely',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const service = new McpClientsService(repoStub as never, {} as never);
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as unknown as { connect: (s: FakeServer) => unknown },
|
||||||
|
'connect',
|
||||||
|
)
|
||||||
|
.mockImplementation(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
tools: () => Promise.resolve(RAW()),
|
||||||
|
close: () => Promise.resolve(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-1');
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
expect(Object.keys(toolset.tools)).toEqual([]);
|
||||||
|
// mergeNamespaced reported 0 contributed tools, so no guidance is attached.
|
||||||
|
expect(toolset.instructions).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { errors } from 'undici';
|
||||||
|
import {
|
||||||
|
McpClientsService,
|
||||||
|
isRetryableConnectError,
|
||||||
|
} from './mcp-clients.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #489 — external-MCP in-run transport recovery.
|
||||||
|
*
|
||||||
|
* The transport-error classification + retry gate are exercised against the REAL
|
||||||
|
* undici error CLASSES prod throws (`errors.SocketError` / `errors.BodyTimeoutError`,
|
||||||
|
* carrying the true `UND_ERR_*` codes and class names), wrapped EXACTLY as undici's
|
||||||
|
* `fetch` wraps them — a `TypeError('fetch failed'|'terminated')` whose `.cause` is
|
||||||
|
* the undici error. These are the real classes, not hand-rolled `{code:'...'}`
|
||||||
|
* mocks: constructing the genuine class is what makes this a faithful test of the
|
||||||
|
* prod predicate (epic root-cause #4 — a mock-shaped predicate would leave the
|
||||||
|
* evict/retry path silently dead in production while CI stays green). We construct
|
||||||
|
* rather than drive a live fetch because Jest's environment degrades the live-fetch
|
||||||
|
* error to a generic `Error` cause (no undici code), which would NOT be the prod
|
||||||
|
* shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A REAL undici socket reset, wrapped as fetch wraps it. */
|
||||||
|
function realSocketResetError(): unknown {
|
||||||
|
const err = new TypeError('fetch failed');
|
||||||
|
(err as { cause?: unknown }).cause = new errors.SocketError('other side closed');
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A REAL undici body timeout, wrapped as fetch wraps it. */
|
||||||
|
function realBodyTimeoutError(): unknown {
|
||||||
|
const err = new TypeError('terminated');
|
||||||
|
(err as { cause?: unknown }).cause = new errors.BodyTimeoutError();
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FakeServer = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
transport: 'http' | 'sse';
|
||||||
|
url: string;
|
||||||
|
headersEnc: string | null;
|
||||||
|
toolAllowlist: string[] | null;
|
||||||
|
instructions: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = (over: Partial<FakeServer> = {}): FakeServer => ({
|
||||||
|
id: 's1',
|
||||||
|
name: 'srv',
|
||||||
|
transport: 'http',
|
||||||
|
url: 'http://example.test/mcp',
|
||||||
|
headersEnc: null,
|
||||||
|
toolAllowlist: null,
|
||||||
|
instructions: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildService(servers: FakeServer[], trusted = false) {
|
||||||
|
const repo = { listEnabled: jest.fn().mockResolvedValue(servers) };
|
||||||
|
const service = new McpClientsService(repo as never, {} as never);
|
||||||
|
// Seed a DETERMINISTIC write-class map so the retry gate is controlled here
|
||||||
|
// (the production map loads from @docmost/mcp via a dynamic ESM import). getPage
|
||||||
|
// is a read, patchNode is a write — the real classifications.
|
||||||
|
(
|
||||||
|
service as unknown as { writeClassMapPromise: Promise<unknown> }
|
||||||
|
).writeClassMapPromise = Promise.resolve({
|
||||||
|
getPage: 'readOnly',
|
||||||
|
patchNode: 'write',
|
||||||
|
});
|
||||||
|
// The service only APPLIES that map to a TRUSTED internal Docmost server
|
||||||
|
// (isInternalDocmostServer, really false for every third-party row). A retry
|
||||||
|
// test needs a trusted server to exercise the readOnly-retry path at all, so it
|
||||||
|
// passes trusted=true to model a Docmost-origin server; the third-party
|
||||||
|
// double-apply test leaves it at the real value (false).
|
||||||
|
if (trusted) {
|
||||||
|
jest
|
||||||
|
.spyOn(
|
||||||
|
service as unknown as {
|
||||||
|
isInternalDocmostServer: (s: FakeServer) => boolean;
|
||||||
|
},
|
||||||
|
'isInternalDocmostServer',
|
||||||
|
)
|
||||||
|
.mockReturnValue(true);
|
||||||
|
}
|
||||||
|
return { service, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Spy the private `connect` so each call yields a controlled fake client whose
|
||||||
|
* single tool's execute is the supplied function. Returns the connect spy. */
|
||||||
|
function stubConnect(
|
||||||
|
service: McpClientsService,
|
||||||
|
toolName: string,
|
||||||
|
execs: Array<(...a: unknown[]) => Promise<unknown>>,
|
||||||
|
) {
|
||||||
|
let n = 0;
|
||||||
|
return jest
|
||||||
|
.spyOn(
|
||||||
|
service as unknown as { connect: (s: FakeServer) => Promise<unknown> },
|
||||||
|
'connect',
|
||||||
|
)
|
||||||
|
.mockImplementation(async () => {
|
||||||
|
const exec = execs[Math.min(n, execs.length - 1)];
|
||||||
|
n += 1;
|
||||||
|
return {
|
||||||
|
tools: async () => ({ [toolName]: { description: 'x', execute: exec } }),
|
||||||
|
close: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const opts = (abortSignal?: AbortSignal) =>
|
||||||
|
({ toolCallId: 't', messages: [], abortSignal }) as never;
|
||||||
|
|
||||||
|
describe('isRetryableConnectError (#489, REAL error shapes)', () => {
|
||||||
|
it('classifies a real undici socket reset and body timeout as retryable', async () => {
|
||||||
|
const socketErr = await realSocketResetError();
|
||||||
|
const bodyErr = await realBodyTimeoutError();
|
||||||
|
expect(isRetryableConnectError(socketErr)).toBe(true);
|
||||||
|
expect(isRetryableConnectError(bodyErr)).toBe(true);
|
||||||
|
// Unwraps a wrapped cause chain (e.g. an MCPClientError around the socket err).
|
||||||
|
const wrapped = new Error('mcp call failed');
|
||||||
|
(wrapped as { cause?: unknown }).cause = socketErr;
|
||||||
|
expect(isRetryableConnectError(wrapped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT classify an application-level error as a transport break', () => {
|
||||||
|
expect(isRetryableConnectError(new Error('validation failed'))).toBe(false);
|
||||||
|
expect(isRetryableConnectError({ name: 'HttpError', status: 400 })).toBe(false);
|
||||||
|
expect(isRetryableConnectError(undefined)).toBe(false);
|
||||||
|
expect(isRetryableConnectError('boom')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('McpClientsService in-run transport recovery (#489)', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('a readOnly tool whose transport breaks reconnects and retries WITHIN the same run', async () => {
|
||||||
|
const realErr = await realSocketResetError();
|
||||||
|
const { service } = buildService([server()], true);
|
||||||
|
const first = jest.fn().mockRejectedValue(realErr);
|
||||||
|
const second = jest.fn().mockResolvedValue({ ok: true });
|
||||||
|
const connectSpy = stubConnect(service, 'getPage', [first, second]);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-1');
|
||||||
|
const tool = toolset.tools['srv_getPage'];
|
||||||
|
const result = await (tool.execute as (a: unknown, o: unknown) => Promise<unknown>)(
|
||||||
|
{ pageId: 'p' },
|
||||||
|
opts(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The repeat call within the run got a LIVE client and succeeded.
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(first).toHaveBeenCalledTimes(1);
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
// Exactly one reconnect was minted (initial build connect + one recovery).
|
||||||
|
expect(connectSpy).toHaveBeenCalledTimes(2);
|
||||||
|
// The run accumulated BOTH leases (old + reconnected) — released together at end.
|
||||||
|
expect(toolset.clients).toHaveLength(2);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a WRITE tool does NOT auto-retry on a transport error (indeterminate)', async () => {
|
||||||
|
const realErr = await realSocketResetError();
|
||||||
|
const { service } = buildService([server()], true);
|
||||||
|
const exec = jest.fn().mockRejectedValue(realErr);
|
||||||
|
const connectSpy = stubConnect(service, 'patchNode', [exec]);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-2');
|
||||||
|
const tool = toolset.tools['srv_patchNode'];
|
||||||
|
await expect(
|
||||||
|
(tool.execute as (a: unknown, o: unknown) => Promise<unknown>)(
|
||||||
|
{ pageId: 'p' },
|
||||||
|
opts(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/MAY have already applied/);
|
||||||
|
|
||||||
|
// Called exactly once — NO blind retry (avoids double-apply, the #435 class).
|
||||||
|
expect(exec).toHaveBeenCalledTimes(1);
|
||||||
|
// No fresh connection was minted for a write.
|
||||||
|
expect(connectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT retry (or reconnect) after the run is aborted (Stop)', async () => {
|
||||||
|
const realErr = await realSocketResetError();
|
||||||
|
const { service } = buildService([server()], true);
|
||||||
|
const controller = new AbortController();
|
||||||
|
// The transport error arrives, but the run was Stopped in the same tick.
|
||||||
|
const first = jest.fn().mockImplementation(async () => {
|
||||||
|
controller.abort();
|
||||||
|
throw realErr;
|
||||||
|
});
|
||||||
|
const second = jest.fn().mockResolvedValue({ ok: true });
|
||||||
|
const connectSpy = stubConnect(service, 'getPage', [first, second]);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-3');
|
||||||
|
const tool = toolset.tools['srv_getPage'];
|
||||||
|
await expect(
|
||||||
|
(tool.execute as (a: unknown, o: unknown) => Promise<unknown>)(
|
||||||
|
{ pageId: 'p' },
|
||||||
|
opts(controller.signal),
|
||||||
|
),
|
||||||
|
).rejects.toBeDefined();
|
||||||
|
|
||||||
|
// getPage IS readOnly, but the Stop blocks the retry — no second call, no mint.
|
||||||
|
expect(second).not.toHaveBeenCalled();
|
||||||
|
expect(connectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an app-level (non-transport) tool error is surfaced verbatim, never retried', async () => {
|
||||||
|
const { service } = buildService([server()], true);
|
||||||
|
const appErr = new Error('tool says: bad input');
|
||||||
|
const exec = jest.fn().mockRejectedValue(appErr);
|
||||||
|
const connectSpy = stubConnect(service, 'getPage', [exec]);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-4');
|
||||||
|
const tool = toolset.tools['srv_getPage'];
|
||||||
|
await expect(
|
||||||
|
(tool.execute as (a: unknown, o: unknown) => Promise<unknown>)(
|
||||||
|
{ pageId: 'p' },
|
||||||
|
opts(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow('tool says: bad input');
|
||||||
|
expect(exec).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connectSpy).toHaveBeenCalledTimes(1); // no reconnect for an app error
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
// #489 (review, MEDIUM) — the Docmost write-class map keys by DOCMOST tool
|
||||||
|
// names; a THIRD-PARTY server may name a WRITE tool `getPage` (a Docmost read
|
||||||
|
// name). It must NOT inherit readOnly and must NOT auto-retry on a transport
|
||||||
|
// error — a blind retry of that write is a double-apply (the #435 class). Here
|
||||||
|
// the server is UNTRUSTED (buildService default, isInternalDocmostServer=false),
|
||||||
|
// so the map is not applied and `getPage` classifies as a write.
|
||||||
|
//
|
||||||
|
// MUTATION-VERIFY: forcing the server "trusted" (buildService(..., true)) makes
|
||||||
|
// `getPage` inherit readOnly -> it WOULD reconnect+retry (connect twice) and the
|
||||||
|
// assertions below fail — i.e. removing the trust scope re-opens the bug.
|
||||||
|
it('a THIRD-PARTY WRITE tool named like a Docmost read does NOT auto-retry (no double-apply)', async () => {
|
||||||
|
const realErr = await realSocketResetError();
|
||||||
|
// Untrusted: default trusted=false — a real third-party server.
|
||||||
|
const { service } = buildService([server()]);
|
||||||
|
const exec = jest.fn().mockRejectedValue(realErr);
|
||||||
|
const connectSpy = stubConnect(service, 'getPage', [exec, exec]);
|
||||||
|
|
||||||
|
const toolset = await service.toolsFor('ws-5');
|
||||||
|
const tool = toolset.tools['srv_getPage'];
|
||||||
|
await expect(
|
||||||
|
(tool.execute as (a: unknown, o: unknown) => Promise<unknown>)(
|
||||||
|
{ pageId: 'p' },
|
||||||
|
opts(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/MAY have already applied/);
|
||||||
|
|
||||||
|
// Exactly one call, NO reconnect — the name collision granted no readOnly-retry.
|
||||||
|
expect(exec).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connectSpy).toHaveBeenCalledTimes(1);
|
||||||
|
await Promise.all(toolset.clients.map((c) => c.close()));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -106,8 +106,11 @@ describe('McpClientsService.decryptHeaders', () => {
|
|||||||
|
|
||||||
describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => {
|
describe('McpClientsService.guardedFetch (SSRF per-request guard)', () => {
|
||||||
// The bound guardedFetch closure lives on the instance as a private field.
|
// The bound guardedFetch closure lives on the instance as a private field.
|
||||||
|
// #489 split it into per-transport HTTP/SSE bindings (they differ only in the
|
||||||
|
// dispatcher's bodyTimeout); the SSRF guard is identical, so testing the HTTP
|
||||||
|
// one is sufficient.
|
||||||
const guardedFetchOf = (service: McpClientsService) =>
|
const guardedFetchOf = (service: McpClientsService) =>
|
||||||
(service as unknown as { guardedFetch: typeof fetch }).guardedFetch;
|
(service as unknown as { guardedFetchHttp: typeof fetch }).guardedFetchHttp;
|
||||||
|
|
||||||
let fetchSpy: jest.SpiedFunction<typeof fetch>;
|
let fetchSpy: jest.SpiedFunction<typeof fetch>;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { isIP } from 'node:net';
|
import { isIP } from 'node:net';
|
||||||
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
|
import { lookup as dnsLookup, type LookupAddress } from 'node:dns';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { type Tool, type ToolCallOptions } from 'ai';
|
import { type Tool, type ToolCallOptions } from 'ai';
|
||||||
import { createMCPClient } from '@ai-sdk/mcp';
|
import { createMCPClient } from '@ai-sdk/mcp';
|
||||||
@@ -10,9 +11,29 @@ import {
|
|||||||
streamingDispatcherOptions,
|
streamingDispatcherOptions,
|
||||||
mcpStreamTimeoutMs,
|
mcpStreamTimeoutMs,
|
||||||
mcpCallTimeoutMs,
|
mcpCallTimeoutMs,
|
||||||
|
mcpSseBodyTimeoutMs,
|
||||||
} from '../../../integrations/ai/ai-streaming-fetch';
|
} from '../../../integrations/ai/ai-streaming-fetch';
|
||||||
import { SecretBoxService } from '../../../integrations/crypto/secret-box';
|
import { SecretBoxService } from '../../../integrations/crypto/secret-box';
|
||||||
import { isUrlAllowed, isIpAllowed } from './ssrf-guard';
|
import { isUrlAllowed, isIpAllowed } from './ssrf-guard';
|
||||||
|
// TYPE-ONLY (erased at compile): @docmost/mcp is ESM-only and cannot be a runtime
|
||||||
|
// `require()` from this commonjs module (same constraint as docmost-client.loader).
|
||||||
|
// The write-class MAP is loaded lazily via the dynamic-import trick below.
|
||||||
|
import type { ToolWriteClass } from '@docmost/mcp';
|
||||||
|
|
||||||
|
// TS(commonjs) downlevels a literal `import()` to `require()`, which cannot load
|
||||||
|
// the ESM-only @docmost/mcp. Indirect through Function so the real dynamic
|
||||||
|
// `import()` survives compilation (same trick as docmost-client.loader.ts).
|
||||||
|
const esmImport = new Function(
|
||||||
|
'specifier',
|
||||||
|
'return import(specifier)',
|
||||||
|
) as (specifier: string) => Promise<unknown>;
|
||||||
|
|
||||||
|
/** Local read-only predicate — avoids a value import of the ESM-only package.
|
||||||
|
* Only a pure read is retry-safe after a transport break (a write is
|
||||||
|
* indeterminate). Kept in lockstep with @docmost/mcp's isRetryableWriteClass. */
|
||||||
|
function isReadOnlyWriteClass(writeClass: ToolWriteClass | undefined): boolean {
|
||||||
|
return writeClass === 'readOnly';
|
||||||
|
}
|
||||||
|
|
||||||
/** A closable external MCP client handle. */
|
/** A closable external MCP client handle. */
|
||||||
export interface Closable {
|
export interface Closable {
|
||||||
@@ -81,12 +102,52 @@ const MAX_TOOL_NAME_LENGTH = 64;
|
|||||||
* close until the turn releases it, so a TTL expiry mid-turn never closes a
|
* close until the turn releases it, so a TTL expiry mid-turn never closes a
|
||||||
* client a stream is still executing against.
|
* client a stream is still executing against.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Where a merged (namespaced) tool came from, so the per-run recovery wrapper
|
||||||
|
* (#489) can, on a transport error, reconnect THAT server and re-resolve the SAME
|
||||||
|
* underlying tool by its raw name. `writeClass` gates the single auto-retry (a
|
||||||
|
* read is retry-safe; a write is indeterminate). `serverIndex` indexes the
|
||||||
|
* entry's `servers` array (which server config to reconnect).
|
||||||
|
*/
|
||||||
|
interface ToolProvenance {
|
||||||
|
serverIndex: number;
|
||||||
|
rawName: string;
|
||||||
|
writeClass: ToolWriteClass | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live reconnected server (its fresh client + raw call-timeout-wrapped tools). */
|
||||||
|
interface RecoveredServerState {
|
||||||
|
client: McpClient;
|
||||||
|
tools: Record<string, Tool>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-run, per-server recovery binding (#489). `current` is the server's LIVE
|
||||||
|
* target for this run: `null` means "use the ORIGINAL cached client/template";
|
||||||
|
* a non-null value is a reconnected throwaway client all this server's tools now
|
||||||
|
* call. `reconnecting` dedupes concurrent reconnects so only ONE fresh client is
|
||||||
|
* minted per death (a losing concurrent call awaits it and retries on the SAME
|
||||||
|
* new client — the CAS-by-identity rule).
|
||||||
|
*/
|
||||||
|
interface ServerBinding {
|
||||||
|
current: RecoveredServerState | null;
|
||||||
|
reconnecting?: Promise<RecoveredServerState>;
|
||||||
|
}
|
||||||
|
|
||||||
interface CacheEntry {
|
interface CacheEntry {
|
||||||
tools: Record<string, Tool>;
|
tools: Record<string, Tool>;
|
||||||
clients: McpClient[];
|
clients: McpClient[];
|
||||||
outcomes: ServerOutcome[];
|
outcomes: ServerOutcome[];
|
||||||
/** Prompt guidance for qualifying servers (see McpServerInstruction). */
|
/** Prompt guidance for qualifying servers (see McpServerInstruction). */
|
||||||
instructions: McpServerInstruction[];
|
instructions: McpServerInstruction[];
|
||||||
|
/**
|
||||||
|
* The enabled server configs used to build this entry (#489), so the per-run
|
||||||
|
* recovery wrapper can reconnect a specific server by index. Parallel to the
|
||||||
|
* indices referenced by {@link toolMeta}.
|
||||||
|
*/
|
||||||
|
servers: AiMcpServer[];
|
||||||
|
/** merged-tool-key -> provenance (#489), for the per-run recovery wrapper. */
|
||||||
|
toolMeta: Record<string, ToolProvenance>;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
/** Active leases (turns currently using these clients). */
|
/** Active leases (turns currently using these clients). */
|
||||||
refCount: number;
|
refCount: number;
|
||||||
@@ -120,20 +181,82 @@ export class McpClientsService {
|
|||||||
*/
|
*/
|
||||||
private readonly cache = new Map<string, Promise<CacheEntry>>();
|
private readonly cache = new Map<string, Promise<CacheEntry>>();
|
||||||
/**
|
/**
|
||||||
* A single shared SSRF-pinned dispatcher for ALL outbound external-MCP fetches.
|
* SSRF-pinned dispatchers for outbound external-MCP fetches. Both use the SAME
|
||||||
* Its custom connect.lookup runs per connection, so one instance safely guards
|
* custom connect.lookup (so every connection is IP-validated), but carry a
|
||||||
* every server's connections (we never connect to an unvalidated IP).
|
* DIFFERENT `bodyTimeout` (#489): the HTTP (streamable) transport opens a fresh
|
||||||
|
* request per call, so it keeps the tight silence timeout; the SSE transport
|
||||||
|
* holds ONE long-lived body open across many calls, so a >1-min idle BETWEEN
|
||||||
|
* calls is LEGITIMATE and must not break the socket — it gets a much larger
|
||||||
|
* bodyTimeout. (headersTimeout stays tight on both.)
|
||||||
*/
|
*/
|
||||||
private readonly dispatcher: Dispatcher = buildPinnedDispatcher();
|
private readonly dispatcherHttp: Dispatcher = buildPinnedDispatcher(
|
||||||
/** guardedFetch bound to the pinned dispatcher; reused by every transport. */
|
mcpStreamTimeoutMs(),
|
||||||
private readonly guardedFetch: typeof fetch = (input, init) =>
|
);
|
||||||
guardedFetch(this.dispatcher, input, init);
|
private readonly dispatcherSse: Dispatcher = buildPinnedDispatcher(
|
||||||
|
mcpSseBodyTimeoutMs(),
|
||||||
|
);
|
||||||
|
/** guardedFetch bound to each dispatcher; picked by transport type in connect(). */
|
||||||
|
private readonly guardedFetchHttp: typeof fetch = (input, init) =>
|
||||||
|
guardedFetch(this.dispatcherHttp, input, init);
|
||||||
|
private readonly guardedFetchSse: typeof fetch = (input, init) =>
|
||||||
|
guardedFetch(this.dispatcherSse, input, init);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Memoized write-class map (#489), loaded lazily from @docmost/mcp via the
|
||||||
|
* dynamic-import trick. Keyed by tool name (=== mcpName). A tool NOT in the map
|
||||||
|
* (any third-party external MCP tool) classifies as `undefined` -> treated as a
|
||||||
|
* write by the retry gate (the safe default: never blind-retry an unknown tool).
|
||||||
|
* On any load failure the map is `{}` (every tool -> no auto-retry), so a
|
||||||
|
* missing/older @docmost/mcp build only DISABLES retries, never mis-retries.
|
||||||
|
*/
|
||||||
|
private writeClassMapPromise: Promise<Record<string, ToolWriteClass>> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repo: AiMcpServerRepo,
|
private readonly repo: AiMcpServerRepo,
|
||||||
private readonly secretBox: SecretBoxService,
|
private readonly secretBox: SecretBoxService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether an external MCP server is the TRUSTED internal Docmost MCP server —
|
||||||
|
* the only server whose tools may be classified by the Docmost write-class map
|
||||||
|
* (#489 review). Today this is ALWAYS false: every `ai_mcp_servers` row is an
|
||||||
|
* admin-configured THIRD-PARTY endpoint (there is no builtin/self flag, sentinel
|
||||||
|
* URL, or synthetic server in this path — Docmost's OWN tools are exposed via the
|
||||||
|
* separate in-app tools path, never through this external-MCP client). So no
|
||||||
|
* third-party tool can inherit `readOnly` by a name collision with a Docmost read
|
||||||
|
* tool, and none is ever auto-retried on a transport error (which would risk a
|
||||||
|
* double-apply — the #435 class). Flip this (an explicit `kind`/`isBuiltin`
|
||||||
|
* column, or a configured self-MCP URL) if a trusted internal server is ever
|
||||||
|
* introduced. A method (not a free function) so it is a single, mockable seam.
|
||||||
|
*/
|
||||||
|
private isInternalDocmostServer(_server: AiMcpServer): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lazily load + memoize the shared write-class map (see the field doc). */
|
||||||
|
private getWriteClassMap(): Promise<Record<string, ToolWriteClass>> {
|
||||||
|
if (!this.writeClassMapPromise) {
|
||||||
|
this.writeClassMapPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const entry = require.resolve('@docmost/mcp');
|
||||||
|
const mod = (await esmImport(pathToFileURL(entry).href)) as {
|
||||||
|
SHARED_TOOL_WRITE_CLASS?: Record<string, ToolWriteClass>;
|
||||||
|
};
|
||||||
|
return mod.SHARED_TOOL_WRITE_CLASS ?? {};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Could not load MCP write-class map (auto-retry disabled): ${shortError(
|
||||||
|
err,
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return this.writeClassMapPromise;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build (or reuse a cached) external toolset for a workspace. Returns the
|
* Build (or reuse a cached) external toolset for a workspace. Returns the
|
||||||
* merged tools, the open client handles to release, and per-server outcomes.
|
* merged tools, the open client handles to release, and per-server outcomes.
|
||||||
@@ -162,11 +285,37 @@ export class McpClientsService {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// One release handle drives the whole leased entry; closing it releases all
|
|
||||||
// underlying clients together (they share the same lease lifecycle).
|
// #489: the run accumulates a SET of leases — the primary cache lease PLUS any
|
||||||
|
// throwaway client minted by an in-run transport-recovery reconnect. They are
|
||||||
|
// NEVER released mid-run (releasing a swapped-out client while a concurrent
|
||||||
|
// in-flight call still holds it would INDUCE a second failure); the caller
|
||||||
|
// releases the WHOLE set together at turn-end. A recovery reconnect pushes its
|
||||||
|
// lease onto this live array, which the consumer closes over.
|
||||||
|
const leaseSet: Closable[] = [release];
|
||||||
|
|
||||||
|
// #489: per-RUN transport-recovery binding, one per server, SHARED by all of
|
||||||
|
// that server's tools so a swap by one call is seen by the next (CAS by
|
||||||
|
// identity). Kept per-run (here, not in the cached entry) because the binding
|
||||||
|
// + lease-set state is per-run.
|
||||||
|
const bindings = new Map<number, ServerBinding>();
|
||||||
|
const capMs = mcpCallTimeoutMs();
|
||||||
|
|
||||||
|
// Wrap each cached tool with the recovery layer. On a transport error a
|
||||||
|
// declared readOnly tool reconnects its server and retries ONCE; a write is
|
||||||
|
// never blind-retried (indeterminate — may have applied before the reset). A
|
||||||
|
// tool without provenance (a minimal stub entry in a test) passes through raw.
|
||||||
|
const tools: Record<string, Tool> = {};
|
||||||
|
for (const [key, tool] of Object.entries(entry.tools)) {
|
||||||
|
const meta = entry.toolMeta?.[key];
|
||||||
|
tools[key] = meta
|
||||||
|
? this.wrapWithTransportRecovery(entry, meta, tool, leaseSet, bindings, capMs)
|
||||||
|
: tool;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tools: entry.tools,
|
tools,
|
||||||
clients: [release],
|
clients: leaseSet,
|
||||||
outcomes: entry.outcomes,
|
outcomes: entry.outcomes,
|
||||||
instructions: entry.instructions,
|
instructions: entry.instructions,
|
||||||
};
|
};
|
||||||
@@ -254,6 +403,16 @@ export class McpClientsService {
|
|||||||
// Per-call total wall-clock cap, read once for this build (env-overridable).
|
// Per-call total wall-clock cap, read once for this build (env-overridable).
|
||||||
const callTimeoutMs = mcpCallTimeoutMs();
|
const callTimeoutMs = mcpCallTimeoutMs();
|
||||||
const instructions: McpServerInstruction[] = [];
|
const instructions: McpServerInstruction[] = [];
|
||||||
|
// merged-key -> provenance for the per-run recovery wrapper (#489).
|
||||||
|
const toolMeta: Record<string, ToolProvenance> = {};
|
||||||
|
// Shared Docmost write-class map (#489) — classifies a tool by its raw name.
|
||||||
|
// Loaded ONLY when at least one server is a TRUSTED internal Docmost server
|
||||||
|
// (see isInternalDocmostServer): for third-party servers the map is never
|
||||||
|
// applied (a name collision must not grant readOnly-retry), so we skip the
|
||||||
|
// dynamic ESM load entirely in that (currently universal) case.
|
||||||
|
const writeClassMap = servers.some((s) => this.isInternalDocmostServer(s))
|
||||||
|
? await this.getWriteClassMap()
|
||||||
|
: null;
|
||||||
|
|
||||||
// Per-server connect+tools result, still tagged with its server so the merge
|
// 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).
|
// below can be applied in the SAME order as `servers` (see the parallel note).
|
||||||
@@ -285,9 +444,13 @@ export class McpClientsService {
|
|||||||
try {
|
try {
|
||||||
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
||||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||||
|
// Allowlist semantics (#476): null/absent = no restriction (all tools);
|
||||||
|
// ANY array — including `[]` — is authoritative, so an EMPTY allowlist
|
||||||
|
// yields ZERO tools (deny-all). Do NOT add a `.length > 0` escape here:
|
||||||
|
// that read `[]` as falsy and silently widened deny-all to allow-all
|
||||||
|
// (the repo also fails corrupt rows closed to `[]` for the same reason).
|
||||||
const allow = server.toolAllowlist;
|
const allow = server.toolAllowlist;
|
||||||
const picked =
|
const picked = Array.isArray(allow) ? pick(raw, allow) : raw;
|
||||||
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
|
||||||
// Bound each tool's execute with a per-call total-timeout guard before
|
// 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.
|
// merging, so a single chatty-but-stuck call is aborted after the cap.
|
||||||
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
|
const guarded = wrapToolsWithCallTimeout(picked, callTimeoutMs);
|
||||||
@@ -327,11 +490,23 @@ export class McpClientsService {
|
|||||||
// against names already merged from earlier servers, so no external
|
// against names already merged from earlier servers, so no external
|
||||||
// tool is silently overwritten on collision. The returned count drives
|
// tool is silently overwritten on collision. The returned count drives
|
||||||
// whether this server's prompt guidance is included (≥1 tool merged).
|
// whether this server's prompt guidance is included (≥1 tool merged).
|
||||||
|
// #489 (review): the Docmost write-class map keys by DOCMOST tool names and
|
||||||
|
// may ONLY be trusted for a server KNOWN to be the internal Docmost MCP
|
||||||
|
// server. Every row here is an admin-configured THIRD-PARTY endpoint, so a
|
||||||
|
// third-party WRITE tool that happens to be named like a Docmost read
|
||||||
|
// (getPage, listPages, ...) must NOT inherit readOnly — that would auto-retry
|
||||||
|
// a mutation on a transport error (double-apply, the #435 class). Gate the
|
||||||
|
// map on the trust check; untrusted servers get writeClass=undefined -> the
|
||||||
|
// recovery wrapper treats them as writes and never auto-retries.
|
||||||
|
const trustWriteClass = this.isInternalDocmostServer(server);
|
||||||
const merged = this.mergeNamespaced(
|
const merged = this.mergeNamespaced(
|
||||||
tools,
|
tools,
|
||||||
result.guarded,
|
result.guarded,
|
||||||
server.name,
|
server.name,
|
||||||
server.id,
|
server.id,
|
||||||
|
toolMeta,
|
||||||
|
i,
|
||||||
|
trustWriteClass ? writeClassMap : null,
|
||||||
);
|
);
|
||||||
outcomes.push({ name: server.name, ok: true });
|
outcomes.push({ name: server.name, ok: true });
|
||||||
// Include this server's guidance ONLY when it actually contributed at
|
// Include this server's guidance ONLY when it actually contributed at
|
||||||
@@ -353,6 +528,8 @@ export class McpClientsService {
|
|||||||
clients,
|
clients,
|
||||||
outcomes,
|
outcomes,
|
||||||
instructions,
|
instructions,
|
||||||
|
servers,
|
||||||
|
toolMeta,
|
||||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||||
refCount: 0,
|
refCount: 0,
|
||||||
evicted: false,
|
evicted: false,
|
||||||
@@ -379,18 +556,33 @@ export class McpClientsService {
|
|||||||
picked: Record<string, Tool>,
|
picked: Record<string, Tool>,
|
||||||
serverName: string,
|
serverName: string,
|
||||||
serverId: string,
|
serverId: string,
|
||||||
|
toolMeta: Record<string, ToolProvenance>,
|
||||||
|
serverIndex: number,
|
||||||
|
// The Docmost write-class map, or `null` for an UNTRUSTED (third-party)
|
||||||
|
// server whose tools must all default to write (never auto-retried).
|
||||||
|
writeClassMap: Record<string, ToolWriteClass> | null,
|
||||||
): { count: number; prefix: string } {
|
): { count: number; prefix: string } {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const [name, tool] of Object.entries(namespace(picked, serverName))) {
|
for (const { full, raw, tool } of namespace(picked, serverName)) {
|
||||||
let key = name;
|
let key = full;
|
||||||
if (key in target) {
|
if (key in target) {
|
||||||
const original = key;
|
const original = key;
|
||||||
key = disambiguate(name, serverId, (candidate) => candidate in target);
|
key = disambiguate(full, serverId, (candidate) => candidate in target);
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
`External MCP tool name "${original}" collided; renamed to "${key}"`,
|
`External MCP tool name "${original}" collided; renamed to "${key}"`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
target[key] = tool;
|
target[key] = tool;
|
||||||
|
// Record provenance so the per-run recovery wrapper (#489) can reconnect
|
||||||
|
// this tool's server and re-resolve it by its raw name. writeClass is set
|
||||||
|
// ONLY from a TRUSTED (internal-Docmost) map; for a third-party server the
|
||||||
|
// map is null -> writeClass stays undefined -> the wrapper treats the tool
|
||||||
|
// as a write and never auto-retries it (no double-apply on name collision).
|
||||||
|
toolMeta[key] = {
|
||||||
|
serverIndex,
|
||||||
|
rawName: raw,
|
||||||
|
writeClass: writeClassMap ? writeClassMap[raw] : undefined,
|
||||||
|
};
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
return { count, prefix: namespacePrefix(serverName) };
|
return { count, prefix: namespacePrefix(serverName) };
|
||||||
@@ -424,7 +616,10 @@ export class McpClientsService {
|
|||||||
// Defense in depth: re-validate the actual request host on EVERY fetch
|
// Defense in depth: re-validate the actual request host on EVERY fetch
|
||||||
// AND pin the socket to a validated IP via the dispatcher's connect
|
// AND pin the socket to a validated IP via the dispatcher's connect
|
||||||
// lookup, closing the DNS-rebinding TOCTOU between check and connect.
|
// lookup, closing the DNS-rebinding TOCTOU between check and connect.
|
||||||
fetch: this.guardedFetch,
|
// #489: the SSE transport uses the raised-bodyTimeout dispatcher (idle
|
||||||
|
// between calls is legit); HTTP uses the tight one.
|
||||||
|
fetch:
|
||||||
|
transportType === 'sse' ? this.guardedFetchSse : this.guardedFetchHttp,
|
||||||
},
|
},
|
||||||
})) as unknown as McpClient;
|
})) as unknown as McpClient;
|
||||||
return client;
|
return client;
|
||||||
@@ -505,6 +700,176 @@ export class McpClientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap one merged external tool with the per-run transport-recovery layer (#489).
|
||||||
|
*
|
||||||
|
* attempt 1 runs on the server's CURRENT binding (the cached client, or a client
|
||||||
|
* a sibling tool already reconnected this run). On a REAL transport error
|
||||||
|
* (undici/@ai-sdk socket/body-timeout shapes — {@link isRetryableConnectError},
|
||||||
|
* NOT a mock) and ONLY for a declared readOnly tool, it reconnects the server
|
||||||
|
* and retries EXACTLY ONCE on the fresh client; a write is surfaced as an
|
||||||
|
* indeterminate error (it may have applied before the reset — never
|
||||||
|
* blind-retried). A single per-call cap bounds BOTH attempts + the reconnect,
|
||||||
|
* and the run's abort signal is checked before the retry AND before minting a
|
||||||
|
* fresh connection (no connection is opened for a stopped run).
|
||||||
|
*/
|
||||||
|
private wrapWithTransportRecovery(
|
||||||
|
entry: CacheEntry,
|
||||||
|
meta: ToolProvenance,
|
||||||
|
template: Tool,
|
||||||
|
leaseSet: Closable[],
|
||||||
|
bindings: Map<number, ServerBinding>,
|
||||||
|
capMs: number,
|
||||||
|
): Tool {
|
||||||
|
const original = template.execute;
|
||||||
|
if (typeof original !== 'function') return template;
|
||||||
|
const service = this;
|
||||||
|
const { serverIndex, rawName, writeClass } = meta;
|
||||||
|
|
||||||
|
let binding = bindings.get(serverIndex);
|
||||||
|
if (!binding) {
|
||||||
|
binding = { current: null };
|
||||||
|
bindings.set(serverIndex, binding);
|
||||||
|
}
|
||||||
|
const boundBinding = binding;
|
||||||
|
|
||||||
|
const execute = async (args: unknown, options: ToolCallOptions) => {
|
||||||
|
// The per-call cap governs the WHOLE sequence (attempt1 + reconnect +
|
||||||
|
// attempt2). Compose it with the run's abort signal so a Stop or the cap
|
||||||
|
// ends any awaited call — @ai-sdk/mcp does not settle on abort, so we RACE.
|
||||||
|
const capController = new AbortController();
|
||||||
|
const capTimer = setTimeout(() => {
|
||||||
|
capController.abort(new Error(`MCP tool call timed out after ${capMs}ms`));
|
||||||
|
}, capMs);
|
||||||
|
capTimer.unref?.();
|
||||||
|
const runSignal = options?.abortSignal;
|
||||||
|
const composed = runSignal
|
||||||
|
? AbortSignal.any([runSignal, capController.signal])
|
||||||
|
: capController.signal;
|
||||||
|
const stopped = () => runSignal?.aborted === true || capController.signal.aborted;
|
||||||
|
|
||||||
|
const callOn = async (
|
||||||
|
exec: NonNullable<Tool['execute']>,
|
||||||
|
): Promise<unknown> => {
|
||||||
|
const aborted = new Promise<never>((_, reject) => {
|
||||||
|
const fail = () => reject(abortReason(composed));
|
||||||
|
if (composed.aborted) fail();
|
||||||
|
else composed.addEventListener('abort', fail, { once: true });
|
||||||
|
});
|
||||||
|
return Promise.race([exec(args, { ...options, abortSignal: composed }), aborted]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const execFor = (
|
||||||
|
state: RecoveredServerState | null,
|
||||||
|
): NonNullable<Tool['execute']> | undefined =>
|
||||||
|
state ? (state.tools[rawName]?.execute as NonNullable<Tool['execute']>) : original;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Snapshot the target BEFORE the call so a swap by a concurrent call is
|
||||||
|
// detected by identity in the catch.
|
||||||
|
const attemptState = boundBinding.current;
|
||||||
|
const attemptExec = execFor(attemptState);
|
||||||
|
if (typeof attemptExec !== 'function') {
|
||||||
|
throw new Error(`external MCP tool "${rawName}" is not callable`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await callOn(attemptExec);
|
||||||
|
} catch (err) {
|
||||||
|
// Never retry on a Stop or an exhausted cap.
|
||||||
|
if (stopped()) throw err;
|
||||||
|
// Only a genuine transport break is a recovery candidate.
|
||||||
|
if (!isRetryableConnectError(err)) throw err;
|
||||||
|
// A write tool is INDETERMINATE on a transport error (may have applied
|
||||||
|
// before the reset) — surface that; do NOT auto-retry (double-apply is
|
||||||
|
// the #435 incident class).
|
||||||
|
if (!isReadOnlyWriteClass(writeClass)) {
|
||||||
|
throw new Error(
|
||||||
|
`external MCP tool "${rawName}" hit a transport error and MAY have already ` +
|
||||||
|
`applied on the server — not retried automatically; verify state before ` +
|
||||||
|
`retrying. (${shortError(err)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Abort check BEFORE minting a fresh connection (no socket for a
|
||||||
|
// stopped run). LIMITATION (#489, LOW): the reconnect's own connect is
|
||||||
|
// bounded by CONNECT_TIMEOUT_MS but does NOT itself observe `composed`,
|
||||||
|
// so a Stop that lands DURING the handshake is only honored at the next
|
||||||
|
// `stopped()` gate (before the retry) — a bounded ≤5s late-abort window;
|
||||||
|
// the throwaway client is closed at turn-end regardless. Threading
|
||||||
|
// `composed` into the SHARED (CAS-deduped) reconnect is deliberately
|
||||||
|
// avoided: it would let the first caller's abort tear down a reconnect a
|
||||||
|
// concurrent still-live caller depends on.
|
||||||
|
if (stopped()) throw err;
|
||||||
|
// CAS-swap by IDENTITY: mint+swap only if nobody swapped since this
|
||||||
|
// call's snapshot; a losing concurrent call awaits the same reconnect
|
||||||
|
// and retries on the SAME fresh client.
|
||||||
|
let target: RecoveredServerState;
|
||||||
|
if (boundBinding.current === attemptState) {
|
||||||
|
if (!boundBinding.reconnecting) {
|
||||||
|
boundBinding.reconnecting = (async () => {
|
||||||
|
const server = entry.servers[serverIndex];
|
||||||
|
const fresh = await service.reconnectServer(server, capMs);
|
||||||
|
leaseSet.push(fresh.lease); // accumulate; released at turn-end
|
||||||
|
boundBinding.current = fresh.state;
|
||||||
|
return fresh.state;
|
||||||
|
})();
|
||||||
|
// Clear the in-flight marker once it settles (success or failure) so
|
||||||
|
// a LATER death of the new client can reconnect again.
|
||||||
|
void boundBinding.reconnecting.then(
|
||||||
|
() => (boundBinding.reconnecting = undefined),
|
||||||
|
() => (boundBinding.reconnecting = undefined),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
target = await boundBinding.reconnecting;
|
||||||
|
} else {
|
||||||
|
target = boundBinding.current as RecoveredServerState;
|
||||||
|
}
|
||||||
|
// Abort check BEFORE the retry.
|
||||||
|
if (stopped()) throw err;
|
||||||
|
const retryExec = execFor(target);
|
||||||
|
if (typeof retryExec !== 'function') throw err;
|
||||||
|
return await callOn(retryExec);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(capTimer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { ...template, execute } as unknown as Tool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect ONE server for an in-run recovery (#489): open a fresh client and
|
||||||
|
* list+wrap its tools. The throwaway client is NOT cached — it is owned by the
|
||||||
|
* RUN via the returned lease (closed at turn-end), independent of the shared
|
||||||
|
* cache entry (whose TTL rebuild heals future turns). On a failure the fresh
|
||||||
|
* client is closed so its socket never leaks.
|
||||||
|
*/
|
||||||
|
private async reconnectServer(
|
||||||
|
server: AiMcpServer,
|
||||||
|
capMs: number,
|
||||||
|
): Promise<{ state: RecoveredServerState; lease: Closable }> {
|
||||||
|
const client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
||||||
|
let tools: Record<string, Tool>;
|
||||||
|
try {
|
||||||
|
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||||
|
const allow = server.toolAllowlist;
|
||||||
|
const picked =
|
||||||
|
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
||||||
|
tools = wrapToolsWithCallTimeout(picked, capMs);
|
||||||
|
} catch (err) {
|
||||||
|
void client.close().catch(() => undefined);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
let released = false;
|
||||||
|
const lease: Closable = {
|
||||||
|
close: async () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
await client.close().catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { state: { client, tools }, lease };
|
||||||
|
}
|
||||||
|
|
||||||
/** Mark an entry evicted; close its clients now if nothing is leasing them. */
|
/** Mark an entry evicted; close its clients now if nothing is leasing them. */
|
||||||
private evict(entry: CacheEntry): void {
|
private evict(entry: CacheEntry): void {
|
||||||
clearTimeout(entry.timer);
|
clearTimeout(entry.timer);
|
||||||
@@ -554,22 +919,21 @@ export function validateResolvedAddresses(addrs: readonly LookupAddress[]): {
|
|||||||
* certificate validation still uses the real hostname (we never rewrite the URL
|
* certificate validation still uses the real hostname (we never rewrite the URL
|
||||||
* to an IP literal).
|
* to an IP literal).
|
||||||
*/
|
*/
|
||||||
function buildPinnedDispatcher(): Agent {
|
function buildPinnedDispatcher(bodyTimeoutMs: number): Agent {
|
||||||
// External-MCP traffic uses a DEDICATED, shorter silence timeout
|
// External-MCP traffic uses a DEDICATED, shorter HEADERS silence timeout
|
||||||
// (`AI_MCP_STREAM_TIMEOUT_MS`, default 1 min) — deliberately tighter than the
|
// (`AI_MCP_STREAM_TIMEOUT_MS`, default 1 min) — deliberately tighter than the
|
||||||
// chat provider's 15-min `streamTimeoutMs()` — so a byte-silent/hung MCP
|
// 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 ~1 min instead of 15. We keep the keep-alive options
|
||||||
// from `streamingDispatcherOptions()` but OVERRIDE headers/body timeouts.
|
// from `streamingDispatcherOptions()` but OVERRIDE the timeouts. `bodyTimeout`
|
||||||
// Accepted trade-off: a legitimately long but byte-silent single tool call,
|
// is passed in per-transport (#489): tight for HTTP (fresh request per call),
|
||||||
// and an SSE transport idling >1 min BETWEEN tool calls, are also cut here; the
|
// raised for SSE (one long-lived body across calls — idle BETWEEN calls is
|
||||||
// per-call total cap (wrapToolsWithCallTimeout, `AI_MCP_CALL_TIMEOUT_MS`) is the
|
// legit). The per-call total cap (`AI_MCP_CALL_TIMEOUT_MS`) is the complementary
|
||||||
// complementary guard for chatty-but-stuck calls that keep the socket warm yet
|
// guard for chatty-but-stuck calls that keep the socket warm yet never return.
|
||||||
// never return.
|
const headersMs = mcpStreamTimeoutMs();
|
||||||
const mcpSilenceMs = mcpStreamTimeoutMs();
|
|
||||||
return new Agent({
|
return new Agent({
|
||||||
...streamingDispatcherOptions(),
|
...streamingDispatcherOptions(),
|
||||||
headersTimeout: mcpSilenceMs,
|
headersTimeout: headersMs,
|
||||||
bodyTimeout: mcpSilenceMs,
|
bodyTimeout: bodyTimeoutMs,
|
||||||
connect: {
|
connect: {
|
||||||
lookup: (hostname, _options, callback) => {
|
lookup: (hostname, _options, callback) => {
|
||||||
// Always resolve ALL addresses ourselves; do not trust the caller's
|
// Always resolve ALL addresses ourselves; do not trust the caller's
|
||||||
@@ -669,18 +1033,22 @@ function pick(
|
|||||||
function namespace(
|
function namespace(
|
||||||
tools: Record<string, Tool>,
|
tools: Record<string, Tool>,
|
||||||
serverName: string,
|
serverName: string,
|
||||||
): Record<string, Tool> {
|
): Array<{ full: string; raw: string; tool: Tool }> {
|
||||||
const prefix = namespacePrefix(serverName);
|
const prefix = namespacePrefix(serverName);
|
||||||
const out: Record<string, Tool> = {};
|
const out: Array<{ full: string; raw: string; tool: Tool }> = [];
|
||||||
|
const taken: Record<string, true> = {};
|
||||||
for (const [name, t] of Object.entries(tools)) {
|
for (const [name, t] of Object.entries(tools)) {
|
||||||
const safe = sanitizeName(name);
|
const safe = sanitizeName(name);
|
||||||
let full = capName(`${prefix}_${safe}`);
|
let full = capName(`${prefix}_${safe}`);
|
||||||
// Duplicate names within ONE server can still collide after sanitize/
|
// Duplicate names within ONE server can still collide after sanitize/
|
||||||
// truncate — suffix-disambiguate so the second tool is not overwritten.
|
// truncate — suffix-disambiguate so the second tool is not overwritten.
|
||||||
if (full in out) {
|
if (full in taken) {
|
||||||
full = disambiguate(full, '', (candidate) => candidate in out);
|
full = disambiguate(full, '', (candidate) => candidate in taken);
|
||||||
}
|
}
|
||||||
out[full] = t;
|
taken[full] = true;
|
||||||
|
// Keep the RAW (un-namespaced) name alongside the merged key so the per-run
|
||||||
|
// recovery wrapper (#489) can re-resolve the same tool on a fresh client.
|
||||||
|
out.push({ full, raw: name, tool: t });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -804,6 +1172,69 @@ export function wrapToolWithCallTimeout(tool: Tool, ms: number): Tool {
|
|||||||
return { ...tool, execute } as unknown as Tool;
|
return { ...tool, execute } as unknown as Tool;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* undici / Node network error CODES that mean the connection broke (not an
|
||||||
|
* application-level error) — a transient transport failure a readOnly call may
|
||||||
|
* safely retry after reconnecting. Matched against the REAL error shapes (#489):
|
||||||
|
* a socket reset surfaces as `TypeError: fetch failed` whose `.cause` is an
|
||||||
|
* undici `SocketError { code:'UND_ERR_SOCKET' }`; a body-timeout as
|
||||||
|
* `TypeError: terminated` whose `.cause` is `BodyTimeoutError`. Classifying by
|
||||||
|
* these real codes/names (not by mock errors) is essential — a mock-shaped
|
||||||
|
* predicate would leave eviction silently dead in production while CI is green.
|
||||||
|
*/
|
||||||
|
const RETRYABLE_TRANSPORT_ERROR_CODES: ReadonlySet<string> = new Set([
|
||||||
|
'ECONNRESET',
|
||||||
|
'ECONNREFUSED',
|
||||||
|
'ECONNABORTED',
|
||||||
|
'EPIPE',
|
||||||
|
'ETIMEDOUT',
|
||||||
|
'EAI_AGAIN',
|
||||||
|
'ENETUNREACH',
|
||||||
|
'EHOSTUNREACH',
|
||||||
|
'UND_ERR_SOCKET',
|
||||||
|
'UND_ERR_CONNECT_TIMEOUT',
|
||||||
|
'UND_ERR_HEADERS_TIMEOUT',
|
||||||
|
'UND_ERR_BODY_TIMEOUT',
|
||||||
|
'UND_ERR_CLOSED',
|
||||||
|
'UND_ERR_DESTROYED',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** undici error CLASS names for the same transport-break conditions. */
|
||||||
|
const RETRYABLE_TRANSPORT_ERROR_NAMES: ReadonlySet<string> = new Set([
|
||||||
|
'SocketError',
|
||||||
|
'BodyTimeoutError',
|
||||||
|
'HeadersTimeoutError',
|
||||||
|
'ConnectTimeoutError',
|
||||||
|
'ClientClosedError',
|
||||||
|
'ClientDestroyedError',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `err` is a retryable TRANSPORT break (a broken socket / body timeout),
|
||||||
|
* classified by the REAL undici/@ai-sdk error shapes (#489). undici surfaces a
|
||||||
|
* reset as `TypeError('fetch failed'|'terminated')` with the real error in
|
||||||
|
* `.cause`, and @ai-sdk/mcp may wrap it again in an `MCPClientError` (cause
|
||||||
|
* chain), so we walk `.cause` (bounded depth) checking `.code` and `.name`. An
|
||||||
|
* app-level tool error (a 4xx, a validation failure) is NOT retryable and returns
|
||||||
|
* false — only a connection-level failure heals with a reconnect.
|
||||||
|
*/
|
||||||
|
export function isRetryableConnectError(err: unknown, depth = 0): boolean {
|
||||||
|
if (!err || typeof err !== 'object' || depth > 6) return false;
|
||||||
|
const e = err as {
|
||||||
|
code?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
cause?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof e.code === 'string' && RETRYABLE_TRANSPORT_ERROR_CODES.has(e.code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof e.name === 'string' && RETRYABLE_TRANSPORT_ERROR_NAMES.has(e.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e.cause != null) return isRetryableConnectError(e.cause, depth + 1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** The signal's reason as an Error (informative thrown value on abort/timeout). */
|
/** The signal's reason as an Error (informative thrown value on abort/timeout). */
|
||||||
function abortReason(signal: AbortSignal): Error {
|
function abortReason(signal: AbortSignal): Error {
|
||||||
const r = signal.reason;
|
const r = signal.reason;
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ export class McpServersService {
|
|||||||
transport: dto.transport,
|
transport: dto.transport,
|
||||||
url: dto.url,
|
url: dto.url,
|
||||||
headersEnc,
|
headersEnc,
|
||||||
// undefined => unchanged; [] / value handled by repo (empty => null).
|
// undefined => unchanged; null => no restriction; `[]` is persisted
|
||||||
|
// verbatim and means deny-all (#476).
|
||||||
toolAllowlist: dto.toolAllowlist,
|
toolAllowlist: dto.toolAllowlist,
|
||||||
// undefined => unchanged; blank => cleared (null) by the repo.
|
// undefined => unchanged; blank => cleared (null) by the repo.
|
||||||
instructions: dto.instructions,
|
instructions: dto.instructions,
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
import {
|
||||||
|
resolveReplayBudget,
|
||||||
|
isContextOverflowError,
|
||||||
|
estimateMessagesTokens,
|
||||||
|
trimHistoryForReplay,
|
||||||
|
REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||||
|
REPLAY_TRUNCATION_MARKER,
|
||||||
|
REPLAY_TURN_COLLAPSED_MARKER,
|
||||||
|
} from './history-budget';
|
||||||
|
|
||||||
|
describe('resolveReplayBudget', () => {
|
||||||
|
it('uses floor(0.7 x window) for a configured window (no cap)', () => {
|
||||||
|
// 0.7 x 60k = 42k
|
||||||
|
expect(resolveReplayBudget(60_000)).toEqual({
|
||||||
|
thresholdTokens: 42_000,
|
||||||
|
usedDefault: false,
|
||||||
|
});
|
||||||
|
// 0.7 x 1M = 700k — NOT capped (anti-brick vs the window, not a cost limiter).
|
||||||
|
expect(resolveReplayBudget(1_000_000)).toEqual({
|
||||||
|
thresholdTokens: 700_000,
|
||||||
|
usedDefault: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the raw ::text stored form', () => {
|
||||||
|
expect(resolveReplayBudget('60000').thresholdTokens).toBe(42_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The crux (#490): a chat with NO context window configured must STILL be
|
||||||
|
// budgeted — those are exactly the installs that hit terminal overflow.
|
||||||
|
it('applies the flat default when the window is unset/empty', () => {
|
||||||
|
expect(resolveReplayBudget(undefined)).toEqual({
|
||||||
|
thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||||
|
usedDefault: true,
|
||||||
|
});
|
||||||
|
expect(resolveReplayBudget('')).toEqual({
|
||||||
|
thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||||
|
usedDefault: true,
|
||||||
|
});
|
||||||
|
expect(resolveReplayBudget(' ')).toEqual({
|
||||||
|
thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS,
|
||||||
|
usedDefault: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an explicit 0 as the off-switch (distinct from unset)', () => {
|
||||||
|
expect(resolveReplayBudget(0)).toEqual({
|
||||||
|
thresholdTokens: null,
|
||||||
|
usedDefault: false,
|
||||||
|
});
|
||||||
|
expect(resolveReplayBudget('0')).toEqual({
|
||||||
|
thresholdTokens: null,
|
||||||
|
usedDefault: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default on a negative/garbage value', () => {
|
||||||
|
expect(resolveReplayBudget(-5).usedDefault).toBe(true);
|
||||||
|
expect(resolveReplayBudget('abc').usedDefault).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isContextOverflowError', () => {
|
||||||
|
it('classifies a real provider 400 context-overflow shape', () => {
|
||||||
|
// OpenAI-compatible shape.
|
||||||
|
expect(
|
||||||
|
isContextOverflowError({
|
||||||
|
statusCode: 400,
|
||||||
|
message:
|
||||||
|
"This model's maximum context length is 128000 tokens. However, your messages resulted in 214000 tokens. Please reduce the length of the messages.",
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// Anthropic-style wording.
|
||||||
|
expect(
|
||||||
|
isContextOverflowError({
|
||||||
|
status: 400,
|
||||||
|
message: 'prompt is too long: 250000 tokens > 200000 maximum',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// Nested body + string status.
|
||||||
|
expect(
|
||||||
|
isContextOverflowError({
|
||||||
|
response: { status: '400' },
|
||||||
|
message: 'input is too long for the requested model',
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
// Error instance with the cause carrying the body.
|
||||||
|
const e = new Error('Bad request');
|
||||||
|
(e as any).statusCode = 400;
|
||||||
|
(e as any).cause = new Error('maximum context window exceeded');
|
||||||
|
expect(isContextOverflowError(e)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT classify unrelated 400s or auth/rate-limit errors', () => {
|
||||||
|
expect(
|
||||||
|
isContextOverflowError({ statusCode: 400, message: 'invalid tool schema' }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isContextOverflowError({
|
||||||
|
statusCode: 429,
|
||||||
|
message: 'context length exceeded but rate limited',
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(isContextOverflowError({ statusCode: 500, message: 'server error' })).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(isContextOverflowError(undefined)).toBe(false);
|
||||||
|
expect(isContextOverflowError('some random string')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helpers to build ModelMessage fixtures in the ai@6 shape.
|
||||||
|
const userMsg = (text: string): ModelMessage =>
|
||||||
|
({ role: 'user', content: [{ type: 'text', text }] }) as ModelMessage;
|
||||||
|
const assistantMsg = (
|
||||||
|
text: string,
|
||||||
|
toolCallId?: string,
|
||||||
|
toolName?: string,
|
||||||
|
): ModelMessage =>
|
||||||
|
({
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text },
|
||||||
|
...(toolCallId
|
||||||
|
? [{ type: 'tool-call', toolCallId, toolName, input: {} }]
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
}) as ModelMessage;
|
||||||
|
const toolMsg = (
|
||||||
|
toolCallId: string,
|
||||||
|
toolName: string,
|
||||||
|
value: unknown,
|
||||||
|
): ModelMessage =>
|
||||||
|
({
|
||||||
|
role: 'tool',
|
||||||
|
content: [
|
||||||
|
{ type: 'tool-result', toolCallId, toolName, output: { type: 'json', value } },
|
||||||
|
],
|
||||||
|
}) as ModelMessage;
|
||||||
|
|
||||||
|
describe('trimHistoryForReplay', () => {
|
||||||
|
it('null budget disables trimming (returns the same reference)', () => {
|
||||||
|
const msgs = [userMsg('hi'), assistantMsg('yo')];
|
||||||
|
const r = trimHistoryForReplay(msgs, null);
|
||||||
|
expect(r.trimmed).toBe(false);
|
||||||
|
expect(r.messages).toBe(msgs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves history under budget untouched (same reference)', () => {
|
||||||
|
const msgs = [userMsg('hi'), assistantMsg('a short answer')];
|
||||||
|
const r = trimHistoryForReplay(msgs, 100_000);
|
||||||
|
expect(r.trimmed).toBe(false);
|
||||||
|
expect(r.messages).toBe(msgs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates OLD tool outputs but keeps recent turns full', () => {
|
||||||
|
const big = 'X'.repeat(40_000); // ~16k tokens on its own
|
||||||
|
const msgs: ModelMessage[] = [];
|
||||||
|
// 6 OLD turns (indices 0..5), each with a huge tool output.
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
msgs.push(userMsg(`old q${i}`));
|
||||||
|
msgs.push(assistantMsg('looking', `c${i}`, 'getPage'));
|
||||||
|
msgs.push(toolMsg(`c${i}`, 'getPage', { body: big }));
|
||||||
|
msgs.push(assistantMsg(`old a${i}`));
|
||||||
|
}
|
||||||
|
// 3 small recent turns, then the CURRENT turn with its own huge tool output.
|
||||||
|
// With REPLAY_KEEP_RECENT_TURNS=4 the last 4 user-turns stay full, so only
|
||||||
|
// these small recent turns + the current big one are kept full; the 6 old
|
||||||
|
// turns above fall in the trim region.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
msgs.push(userMsg(`recent q${i}`));
|
||||||
|
msgs.push(assistantMsg(`recent a${i}`));
|
||||||
|
}
|
||||||
|
msgs.push(userMsg('current q'));
|
||||||
|
msgs.push(assistantMsg('looking', 'cR', 'getPage'));
|
||||||
|
msgs.push(toolMsg('cR', 'getPage', { body: big }));
|
||||||
|
msgs.push(assistantMsg('current a'));
|
||||||
|
|
||||||
|
// Budget large enough that phase-1 tool truncation alone brings it under.
|
||||||
|
const r = trimHistoryForReplay(msgs, 30_000);
|
||||||
|
expect(r.trimmed).toBe(true);
|
||||||
|
const flat = JSON.stringify(r.messages);
|
||||||
|
// The CURRENT turn's tool output survives in full.
|
||||||
|
expect(flat).toContain(big);
|
||||||
|
// Old outputs were truncated with the marker.
|
||||||
|
expect(flat).toContain(REPLAY_TRUNCATION_MARKER);
|
||||||
|
// Phase 1 sufficed: the oldest turns were NOT collapsed.
|
||||||
|
expect(flat).not.toContain(REPLAY_TURN_COLLAPSED_MARKER);
|
||||||
|
expect(estimateMessagesTokens(r.messages)).toBeLessThan(
|
||||||
|
estimateMessagesTokens(msgs),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses the oldest turns when tool truncation is not enough', () => {
|
||||||
|
// Many turns with LARGE assistant TEXT (not tool output) so phase 1 can't help.
|
||||||
|
const bigText = 'слово '.repeat(8_000); // large Cyrillic text per turn
|
||||||
|
const msgs: ModelMessage[] = [];
|
||||||
|
for (let i = 0; i < 12; i++) {
|
||||||
|
msgs.push(userMsg(`q${i}`));
|
||||||
|
msgs.push(assistantMsg(bigText));
|
||||||
|
}
|
||||||
|
const r = trimHistoryForReplay(msgs, 30_000);
|
||||||
|
expect(r.trimmed).toBe(true);
|
||||||
|
// Oldest turns collapsed; result fits (best-effort) and is much smaller.
|
||||||
|
expect(estimateMessagesTokens(r.messages)).toBeLessThan(
|
||||||
|
estimateMessagesTokens(msgs),
|
||||||
|
);
|
||||||
|
// The LAST turn's text is preserved in full (recent turns stay full).
|
||||||
|
expect(JSON.stringify(r.messages[r.messages.length - 1])).toContain(bigText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deterministic / byte-stable for identical inputs', () => {
|
||||||
|
const big = 'Y'.repeat(30_000);
|
||||||
|
const build = (): ModelMessage[] => {
|
||||||
|
const m: ModelMessage[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
m.push(userMsg(`q${i}`));
|
||||||
|
m.push(assistantMsg('t', `c${i}`, 'getPage'));
|
||||||
|
m.push(toolMsg(`c${i}`, 'getPage', { body: big }));
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
const a = trimHistoryForReplay(build(), 15_000);
|
||||||
|
const b = trimHistoryForReplay(build(), 15_000);
|
||||||
|
expect(JSON.stringify(a.messages)).toBe(JSON.stringify(b.messages));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaves an unpaired tool-call after collapsing (balanced history)', () => {
|
||||||
|
const big = 'Z'.repeat(40_000);
|
||||||
|
const msgs: ModelMessage[] = [];
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
msgs.push(userMsg(`q${i}`));
|
||||||
|
msgs.push(assistantMsg('t', `c${i}`, 'getPage'));
|
||||||
|
msgs.push(toolMsg(`c${i}`, 'getPage', { body: big }));
|
||||||
|
}
|
||||||
|
const r = trimHistoryForReplay(msgs, 8_000);
|
||||||
|
// Count tool-call vs tool-result parts in the trimmed output.
|
||||||
|
let calls = 0;
|
||||||
|
let results = 0;
|
||||||
|
for (const m of r.messages) {
|
||||||
|
if (!Array.isArray(m.content)) continue;
|
||||||
|
for (const p of m.content as Array<{ type?: string }>) {
|
||||||
|
if (p.type === 'tool-call') calls++;
|
||||||
|
if (p.type === 'tool-result' || p.type === 'tool-error') results++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Every surviving tool-call has a surviving result (collapsing drops BOTH).
|
||||||
|
expect(calls).toBe(results);
|
||||||
|
// Collapsed turns carry the marker.
|
||||||
|
expect(JSON.stringify(r.messages)).toContain(REPLAY_TURN_COLLAPSED_MARKER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects the provider fact: under-budget contextTokens skips trimming', () => {
|
||||||
|
const big = 'W'.repeat(60_000);
|
||||||
|
const msgs = [
|
||||||
|
userMsg('q'),
|
||||||
|
assistantMsg('t', 'c1', 'getPage'),
|
||||||
|
toolMsg('c1', 'getPage', { body: big }),
|
||||||
|
];
|
||||||
|
// char-estimate is high, but the provider says we are well under budget.
|
||||||
|
const r = trimHistoryForReplay(msgs, 100_000, 5_000);
|
||||||
|
expect(r.trimmed).toBe(false);
|
||||||
|
expect(r.messages).toBe(msgs);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
/**
|
||||||
|
* History-replay token budget (#490).
|
||||||
|
*
|
||||||
|
* The whole persisted conversation is replayed to the provider on EVERY turn, so
|
||||||
|
* a long chat eventually exceeds the model's context window and the provider 400s
|
||||||
|
* on every turn — terminally (the chat "bricks"). This module bounds the replayed
|
||||||
|
* history at REPLAY TIME only: it never mutates what is persisted (the DB stays
|
||||||
|
* the full record), and its output is a deterministic, byte-stable function of its
|
||||||
|
* input so the trimmed prefix is identical turn to turn (provider prompt-cache
|
||||||
|
* friendliness — real money on long chats).
|
||||||
|
*
|
||||||
|
* The PRIMARY signal is the provider's own fact: `metadata.contextTokens` from the
|
||||||
|
* last turn. The chars-based {@link estimateTokens} (shared with the client) is
|
||||||
|
* used only for the DELTA of not-yet-sent messages, to decide WHAT to trim, and as
|
||||||
|
* the fallback for chats with no usage yet.
|
||||||
|
*/
|
||||||
|
import type { ModelMessage } from 'ai';
|
||||||
|
import { estimateTokens } from '@docmost/token-estimate';
|
||||||
|
|
||||||
|
/** Flat default budget when no context window is configured (tokens). */
|
||||||
|
export const REPLAY_BUDGET_DEFAULT_TOKENS = 100_000;
|
||||||
|
/** Fraction of a configured context window used as the budget. */
|
||||||
|
export const REPLAY_BUDGET_WINDOW_FRACTION = 0.7;
|
||||||
|
/**
|
||||||
|
* Fraction of the normal budget used for the REACTIVE re-trim after a provider
|
||||||
|
* context-overflow 400 — the preventive estimate under-counted, so cut harder.
|
||||||
|
*/
|
||||||
|
export const REPLAY_AGGRESSIVE_FRACTION = 0.5;
|
||||||
|
/**
|
||||||
|
* Turns (a user message + its assistant/tool replies) kept FULL at the tail,
|
||||||
|
* including the current one — never trimmed. Older turns are compacted first.
|
||||||
|
*/
|
||||||
|
export const REPLAY_KEEP_RECENT_TURNS = 4;
|
||||||
|
/** Leading chars kept from a truncated old tool output. */
|
||||||
|
export const REPLAY_TOOL_OUTPUT_HEAD = 800;
|
||||||
|
/** Trailing chars kept from a truncated old tool output. */
|
||||||
|
export const REPLAY_TOOL_OUTPUT_TAIL = 300;
|
||||||
|
/** Marker inserted where an old tool output was truncated for replay. */
|
||||||
|
export const REPLAY_TRUNCATION_MARKER =
|
||||||
|
'[…truncated for replay; call the tool again to read the full output]';
|
||||||
|
/** Marker for a whole old turn collapsed to its text. */
|
||||||
|
export const REPLAY_TURN_COLLAPSED_MARKER =
|
||||||
|
'[earlier tool activity omitted for replay]';
|
||||||
|
|
||||||
|
export interface ReplayBudget {
|
||||||
|
/** Token threshold above which replay history is trimmed; `null` = OFF. */
|
||||||
|
thresholdTokens: number | null;
|
||||||
|
/** True when the flat default was used (no context window configured). */
|
||||||
|
usedDefault: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the replay budget from the RAW stored `chatContextWindow` (text/number).
|
||||||
|
* - a positive value -> `floor(fraction × window)` (NO cap — the budgeter is
|
||||||
|
* anti-brick protection against the window itself, not a cost/economy limiter,
|
||||||
|
* exactly as the codebase already treats maxOutputTokens; the reactive branch
|
||||||
|
* still guarantees anti-brick regardless of how high this budget is)
|
||||||
|
* - explicit `0` -> OFF (admin opt-out; `null` threshold)
|
||||||
|
* - unset/empty/invalid-> the flat default (still protects — the installations
|
||||||
|
* that hit terminal overflow are exactly the ones that never set a window)
|
||||||
|
*
|
||||||
|
* Note the raw value is needed because the parsed `chatContextWindow` collapses
|
||||||
|
* both `0` and unset to `undefined`, which would erase the explicit off-switch.
|
||||||
|
*/
|
||||||
|
export function resolveReplayBudget(rawContextWindow: unknown): ReplayBudget {
|
||||||
|
let n: number | undefined;
|
||||||
|
if (typeof rawContextWindow === 'number') {
|
||||||
|
n = rawContextWindow;
|
||||||
|
} else if (typeof rawContextWindow === 'string') {
|
||||||
|
const t = rawContextWindow.trim();
|
||||||
|
n = t === '' ? undefined : Number(t);
|
||||||
|
}
|
||||||
|
// Unset / empty / non-numeric / negative -> flat default (the protective case).
|
||||||
|
if (n === undefined || !Number.isFinite(n) || n < 0) {
|
||||||
|
return { thresholdTokens: REPLAY_BUDGET_DEFAULT_TOKENS, usedDefault: true };
|
||||||
|
}
|
||||||
|
// Explicit 0 -> off-switch.
|
||||||
|
if (n === 0) {
|
||||||
|
return { thresholdTokens: null, usedDefault: false };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
thresholdTokens: Math.floor(REPLAY_BUDGET_WINDOW_FRACTION * n),
|
||||||
|
usedDefault: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effective replay threshold for THIS turn, given the base budget and whether
|
||||||
|
* the PREVIOUS turn hit a context-overflow 400 (the reactive-recovery signal,
|
||||||
|
* `metadata.replayOverflow`). On recovery the base budget is scaled down by
|
||||||
|
* {@link REPLAY_AGGRESSIVE_FRACTION}: the overflowing turn produced no usage
|
||||||
|
* signal, so the preventive estimate under-counted and a normal-threshold trim may
|
||||||
|
* not shrink enough to fit — this harder cut is what un-bricks the chat.
|
||||||
|
*
|
||||||
|
* A `null` base budget (trimming OFF) is passed through unchanged: an explicit
|
||||||
|
* off-switch is never overridden by the recovery path.
|
||||||
|
*/
|
||||||
|
export function resolveEffectiveReplayThreshold(
|
||||||
|
thresholdTokens: number | null,
|
||||||
|
priorOverflowed: boolean,
|
||||||
|
): number | null {
|
||||||
|
if (!priorOverflowed || thresholdTokens == null) return thresholdTokens;
|
||||||
|
return Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a provider error is a CONTEXT-OVERFLOW rejection (the prompt exceeds
|
||||||
|
* the model's window). Providers surface this as an HTTP 400 with a recognizable
|
||||||
|
* message; match both the status and the message patterns robustly across
|
||||||
|
* OpenAI-compatible / Anthropic / Gemini wordings, since the exact shape varies.
|
||||||
|
*/
|
||||||
|
export function isContextOverflowError(error: unknown): boolean {
|
||||||
|
const status = extractStatus(error);
|
||||||
|
const msg = extractMessage(error).toLowerCase();
|
||||||
|
// Message patterns seen across providers for "prompt too long".
|
||||||
|
const overflowPattern =
|
||||||
|
/context (?:length|window)|maximum context|too many tokens|too large for|reduce the length|prompt is too long|input (?:is )?too long|exceeds? the (?:maximum )?(?:context|token)|maximum.*tokens|string too long/;
|
||||||
|
if (!overflowPattern.test(msg)) return false;
|
||||||
|
// A 400/413 with an overflow-shaped message is an overflow. Some providers
|
||||||
|
// omit/rewrite the status, so accept the message match when the status is
|
||||||
|
// unknown, but reject it for auth/rate-limit statuses that never mean overflow.
|
||||||
|
if (status === 400 || status === 413) return true;
|
||||||
|
if (status === 401 || status === 403 || status === 429) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractStatus(error: unknown): number | undefined {
|
||||||
|
if (!error || typeof error !== 'object') return undefined;
|
||||||
|
const e = error as Record<string, unknown>;
|
||||||
|
for (const k of ['statusCode', 'status']) {
|
||||||
|
const v = e[k];
|
||||||
|
if (typeof v === 'number') return v;
|
||||||
|
if (typeof v === 'string' && /^\d+$/.test(v)) return Number(v);
|
||||||
|
}
|
||||||
|
// Nested (e.g. { response: { status } } / { cause: { statusCode } }).
|
||||||
|
for (const k of ['response', 'cause', 'data']) {
|
||||||
|
const nested = e[k];
|
||||||
|
if (nested && typeof nested === 'object') {
|
||||||
|
const s = extractStatus(nested);
|
||||||
|
if (s !== undefined) return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(error: unknown): string {
|
||||||
|
if (error == null) return '';
|
||||||
|
if (typeof error === 'string') return error;
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Include nested causes (provider libs wrap the real body in `cause`).
|
||||||
|
const cause = (error as { cause?: unknown }).cause;
|
||||||
|
return `${error.message} ${cause ? extractMessage(cause) : ''}`;
|
||||||
|
}
|
||||||
|
if (typeof error === 'object') {
|
||||||
|
const e = error as Record<string, unknown>;
|
||||||
|
const parts: string[] = [];
|
||||||
|
for (const k of ['message', 'error', 'body', 'responseBody', 'data']) {
|
||||||
|
const v = e[k];
|
||||||
|
if (typeof v === 'string') parts.push(v);
|
||||||
|
else if (v && typeof v === 'object') parts.push(extractMessage(v));
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rough token size of a ModelMessage array via the shared chars estimator. */
|
||||||
|
export function estimateMessagesTokens(
|
||||||
|
messages: ReadonlyArray<ModelMessage>,
|
||||||
|
): number {
|
||||||
|
let total = 0;
|
||||||
|
for (const m of messages) {
|
||||||
|
total += estimateTokens(serializeContent(m.content));
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeContent(content: unknown): string {
|
||||||
|
if (typeof content === 'string') return content;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(content) ?? '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep JSON string of an arbitrary value, bounded so estimation never throws. */
|
||||||
|
function stringifyValue(value: unknown): string {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value) ?? String(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrimResult {
|
||||||
|
messages: ModelMessage[];
|
||||||
|
/** Whether any trimming was applied. */
|
||||||
|
trimmed: boolean;
|
||||||
|
/** Estimated tokens of the returned messages (chars-based). */
|
||||||
|
estimatedTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bound the replayed history to `budgetTokens`, deterministically. Returns the
|
||||||
|
* SAME array reference (no copy) when nothing needs trimming, so the common case
|
||||||
|
* is free and byte-identical. Trimming order (spec #490):
|
||||||
|
* 1. truncate OLD turns' tool outputs (head+tail + marker) — the bulk of the size
|
||||||
|
* 2. mechanically collapse the OLDEST turns to their text (concatenation, no LLM)
|
||||||
|
* 3. the current + last {@link REPLAY_KEEP_RECENT_TURNS} turns stay FULL
|
||||||
|
*
|
||||||
|
* `budgetTokens === null` disables trimming. `priorContextTokens` (the provider's
|
||||||
|
* fact from last turn) short-circuits the decision: when it is known and already
|
||||||
|
* under budget we skip trimming even if the char-estimate is higher (the provider
|
||||||
|
* count is authoritative). The char-estimate drives WHAT to cut.
|
||||||
|
*/
|
||||||
|
export function trimHistoryForReplay(
|
||||||
|
messages: ModelMessage[],
|
||||||
|
budgetTokens: number | null,
|
||||||
|
priorContextTokens?: number,
|
||||||
|
): TrimResult {
|
||||||
|
if (budgetTokens == null) {
|
||||||
|
return { messages, trimmed: false, estimatedTokens: 0 };
|
||||||
|
}
|
||||||
|
const estimated = estimateMessagesTokens(messages);
|
||||||
|
// Decision signal: prefer the provider's fact (last turn's contextTokens) plus
|
||||||
|
// the estimated delta of the messages appended since; fall back to the pure
|
||||||
|
// char-estimate for a chat with no usage yet.
|
||||||
|
const projected =
|
||||||
|
priorContextTokens != null
|
||||||
|
? Math.max(priorContextTokens, estimated)
|
||||||
|
: estimated;
|
||||||
|
if (projected <= budgetTokens) {
|
||||||
|
return { messages, trimmed: false, estimatedTokens: estimated };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tail we always keep full: from the Nth-from-last user message onward.
|
||||||
|
const boundary = recentBoundaryIndex(messages, REPLAY_KEEP_RECENT_TURNS);
|
||||||
|
const tail = messages.slice(boundary);
|
||||||
|
let head = messages.slice(0, boundary).map(cloneMessage);
|
||||||
|
|
||||||
|
// Phase 1: truncate old tool outputs.
|
||||||
|
for (const m of head) {
|
||||||
|
if (m.role === 'tool') truncateToolMessage(m);
|
||||||
|
}
|
||||||
|
let out = [...head, ...tail];
|
||||||
|
let est = estimateMessagesTokens(out);
|
||||||
|
if (est <= budgetTokens) {
|
||||||
|
return { messages: out, trimmed: true, estimatedTokens: est };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: collapse the oldest turns (in `head`) to their text, one at a time,
|
||||||
|
// from the oldest, until we fit or the whole head is collapsed.
|
||||||
|
const turns = splitTurns(head);
|
||||||
|
const collapsed: ModelMessage[] = [];
|
||||||
|
let i = 0;
|
||||||
|
for (; i < turns.length; i++) {
|
||||||
|
if (est <= budgetTokens) break;
|
||||||
|
collapsed.push(...collapseTurn(turns[i]));
|
||||||
|
// Re-estimate the whole prospective output.
|
||||||
|
const remaining = turns.slice(i + 1).flat();
|
||||||
|
out = [...collapsed, ...remaining, ...tail];
|
||||||
|
est = estimateMessagesTokens(out);
|
||||||
|
}
|
||||||
|
// Include any turns we didn't need to collapse.
|
||||||
|
const remaining = turns.slice(i).flat();
|
||||||
|
out = [...collapsed, ...remaining, ...tail];
|
||||||
|
est = estimateMessagesTokens(out);
|
||||||
|
return { messages: out, trimmed: true, estimatedTokens: est };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the first message of the Nth-from-last user turn (0 if fewer). */
|
||||||
|
function recentBoundaryIndex(
|
||||||
|
messages: ReadonlyArray<ModelMessage>,
|
||||||
|
keepTurns: number,
|
||||||
|
): number {
|
||||||
|
const userIdx: number[] = [];
|
||||||
|
for (let i = 0; i < messages.length; i++) {
|
||||||
|
if (messages[i].role === 'user') userIdx.push(i);
|
||||||
|
}
|
||||||
|
if (userIdx.length <= keepTurns) return 0;
|
||||||
|
return userIdx[userIdx.length - keepTurns];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Split a message list into turns; each turn starts at a `user` message. */
|
||||||
|
function splitTurns(messages: ModelMessage[]): ModelMessage[][] {
|
||||||
|
const turns: ModelMessage[][] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === 'user' || turns.length === 0) turns.push([m]);
|
||||||
|
else turns[turns.length - 1].push(m);
|
||||||
|
}
|
||||||
|
return turns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapse a whole turn to its plain text (mechanical concatenation, not an LLM
|
||||||
|
* summary). Keeps the user message; replaces the assistant/tool messages with a
|
||||||
|
* single assistant text message = the assistant's concatenated text + a marker
|
||||||
|
* when tool activity was dropped. Dropping BOTH the tool-call and tool-result
|
||||||
|
* parts together keeps the rebuilt history balanced (no unpaired calls).
|
||||||
|
*/
|
||||||
|
function collapseTurn(turn: ModelMessage[]): ModelMessage[] {
|
||||||
|
const out: ModelMessage[] = [];
|
||||||
|
let assistantText = '';
|
||||||
|
let hadTools = false;
|
||||||
|
for (const m of turn) {
|
||||||
|
if (m.role === 'user') {
|
||||||
|
out.push(m);
|
||||||
|
} else if (m.role === 'assistant') {
|
||||||
|
const { text, tools } = extractAssistantText(m.content);
|
||||||
|
assistantText += text;
|
||||||
|
hadTools = hadTools || tools;
|
||||||
|
} else if (m.role === 'tool') {
|
||||||
|
hadTools = true;
|
||||||
|
} else {
|
||||||
|
out.push(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const note =
|
||||||
|
(assistantText ? assistantText.trimEnd() : '') +
|
||||||
|
(hadTools
|
||||||
|
? `${assistantText ? '\n\n' : ''}${REPLAY_TURN_COLLAPSED_MARKER}`
|
||||||
|
: '');
|
||||||
|
if (note) out.push({ role: 'assistant', content: note } as ModelMessage);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAssistantText(content: unknown): {
|
||||||
|
text: string;
|
||||||
|
tools: boolean;
|
||||||
|
} {
|
||||||
|
if (typeof content === 'string') return { text: content, tools: false };
|
||||||
|
if (!Array.isArray(content)) return { text: '', tools: false };
|
||||||
|
let text = '';
|
||||||
|
let tools = false;
|
||||||
|
for (const part of content) {
|
||||||
|
const type = (part as { type?: string })?.type;
|
||||||
|
if (type === 'text') text += (part as { text?: string }).text ?? '';
|
||||||
|
else if (type === 'tool-call') tools = true;
|
||||||
|
}
|
||||||
|
return { text, tools };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Truncate every tool-result output in a `tool` message to head+tail+marker. */
|
||||||
|
function truncateToolMessage(message: ModelMessage): void {
|
||||||
|
const content = message.content;
|
||||||
|
if (!Array.isArray(content)) return;
|
||||||
|
for (const part of content) {
|
||||||
|
const p = part as { type?: string; output?: { type?: string; value?: unknown } };
|
||||||
|
if (p.type !== 'tool-result' && p.type !== 'tool-error') continue;
|
||||||
|
if (!p.output) continue;
|
||||||
|
const raw = stringifyValue(p.output.value);
|
||||||
|
const budget = REPLAY_TOOL_OUTPUT_HEAD + REPLAY_TOOL_OUTPUT_TAIL;
|
||||||
|
if (raw.length <= budget + REPLAY_TRUNCATION_MARKER.length) continue;
|
||||||
|
const truncated =
|
||||||
|
raw.slice(0, REPLAY_TOOL_OUTPUT_HEAD) +
|
||||||
|
`\n${REPLAY_TRUNCATION_MARKER}\n` +
|
||||||
|
raw.slice(raw.length - REPLAY_TOOL_OUTPUT_TAIL);
|
||||||
|
// Represent the shrunk output as a text output (a valid tool-result output).
|
||||||
|
p.output = { type: 'text', value: truncated };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shallow-ish clone so trimming never mutates the caller's (persisted-derived)
|
||||||
|
* message objects — only the OLD region is cloned before it is edited. */
|
||||||
|
function cloneMessage(m: ModelMessage): ModelMessage {
|
||||||
|
if (typeof m.content === 'string') return { ...m };
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
content: (m.content as unknown[]).map((p) =>
|
||||||
|
p && typeof p === 'object' ? { ...(p as object) } : p,
|
||||||
|
),
|
||||||
|
} as ModelMessage;
|
||||||
|
}
|
||||||
@@ -1,11 +1,88 @@
|
|||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { streamText } from 'ai';
|
||||||
import {
|
import {
|
||||||
hasRepeatedLineRun,
|
hasRepeatedLineRun,
|
||||||
hasPeriodicTail,
|
hasPeriodicTail,
|
||||||
isDegenerateOutput,
|
isDegenerateOutput,
|
||||||
truncateDegeneratedTail,
|
truncateDegeneratedTail,
|
||||||
|
shouldCheckDegeneration,
|
||||||
|
DEGENERATION_CHECK_STEP,
|
||||||
REPEATED_LINES_THRESHOLD,
|
REPEATED_LINES_THRESHOLD,
|
||||||
MIN_PERIOD_REPEATS,
|
MIN_PERIOD_REPEATS,
|
||||||
|
degenerationThresholds,
|
||||||
} from './output-degeneration';
|
} from './output-degeneration';
|
||||||
|
import { AiChatService } from './ai-chat.service';
|
||||||
|
|
||||||
|
// Part A (#495 iter10): the detector thresholds are env-tunable. These drive the
|
||||||
|
// resolver against real repeat-count shapes and mutation-verify that the env
|
||||||
|
// override actually changes the trigger point (not a vacuous read).
|
||||||
|
describe('degeneration thresholds are env-configurable', () => {
|
||||||
|
const VARS = [
|
||||||
|
'AI_CHAT_DEGENERATION_REPEATED_LINES',
|
||||||
|
'AI_CHAT_DEGENERATION_PERIOD_MAX_LEN',
|
||||||
|
'AI_CHAT_DEGENERATION_PERIOD_MIN_REPEATS',
|
||||||
|
'AI_CHAT_DEGENERATION_CHECK_STEP',
|
||||||
|
];
|
||||||
|
const saved: Record<string, string | undefined> = {};
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const v of VARS) saved[v] = process.env[v];
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
for (const v of VARS) {
|
||||||
|
if (saved[v] === undefined) delete process.env[v];
|
||||||
|
else process.env[v] = saved[v];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the compiled constants when unset', () => {
|
||||||
|
for (const v of VARS) delete process.env[v];
|
||||||
|
expect(degenerationThresholds()).toEqual({
|
||||||
|
repeatedLines: REPEATED_LINES_THRESHOLD,
|
||||||
|
maxPeriodLen: 150,
|
||||||
|
minPeriodRepeats: MIN_PERIOD_REPEATS,
|
||||||
|
checkStep: DEGENERATION_CHECK_STEP,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the default on blank / invalid / non-positive values', () => {
|
||||||
|
for (const bad of ['', ' ', 'abc', '0', '-3', '1.5']) {
|
||||||
|
process.env.AI_CHAT_DEGENERATION_REPEATED_LINES = bad;
|
||||||
|
// '1.5' floors to 1 (still ≥1, valid); every other bad value → default.
|
||||||
|
const expected = bad === '1.5' ? 1 : REPEATED_LINES_THRESHOLD;
|
||||||
|
expect(degenerationThresholds().repeatedLines).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a RAISED check-step suppresses a burst the default would have flagged', () => {
|
||||||
|
// A ~3.3KB periodic burst is periodic-degenerate, but shouldCheckDegeneration
|
||||||
|
// is the throttle gate. Default checkStep=2000 arms on it; raising the step
|
||||||
|
// above the burst size means the throttle never re-fires for it.
|
||||||
|
const burstLen = 'loadTools.\n'.repeat(300).length; // ~3300
|
||||||
|
delete process.env.AI_CHAT_DEGENERATION_CHECK_STEP;
|
||||||
|
expect(shouldCheckDegeneration(burstLen, 0)).toBe(true); // default 2000
|
||||||
|
process.env.AI_CHAT_DEGENERATION_CHECK_STEP = String(burstLen + 1);
|
||||||
|
expect(shouldCheckDegeneration(burstLen, 0)).toBe(false); // raised gate
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a LOWERED repeated-lines threshold trips on a shorter identical-line run', () => {
|
||||||
|
// 8 identical lines: below the default 25 (rule 1) and below the periodic
|
||||||
|
// rule's 20 repeats — so isDegenerateOutput is false by default.
|
||||||
|
const shortRun = 'x\n'.repeat(8);
|
||||||
|
delete process.env.AI_CHAT_DEGENERATION_REPEATED_LINES;
|
||||||
|
expect(isDegenerateOutput(shortRun)).toBe(false);
|
||||||
|
// Lower rule 1 to 5 → the 8-line run now trips.
|
||||||
|
process.env.AI_CHAT_DEGENERATION_REPEATED_LINES = '5';
|
||||||
|
expect(isDegenerateOutput(shortRun)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock ONLY streamText so we can capture the onChunk/onStepFinish callbacks the
|
||||||
|
// service registers and drive them by hand; every other `ai` export the service
|
||||||
|
// uses (convertToModelMessages, stepCountIs, …) stays real.
|
||||||
|
jest.mock('ai', () => {
|
||||||
|
const actual = jest.requireActual('ai');
|
||||||
|
return { ...actual, streamText: jest.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the token-degeneration detector (#444) — the sole anti-babble
|
* Unit tests for the token-degeneration detector (#444) — the sole anti-babble
|
||||||
@@ -180,3 +257,188 @@ describe('truncateDegeneratedTail', () => {
|
|||||||
expect(truncateDegeneratedTail(text)).toBe(text);
|
expect(truncateDegeneratedTail(text)).toBe(text);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle + step-boundary reset (#486). The stream keeps a watermark
|
||||||
|
* (`lastDegenerationCheckLen`) that is an OFFSET into the accumulated step text.
|
||||||
|
* On a step boundary the accumulator resets to '', so the watermark MUST reset to
|
||||||
|
* 0 too — otherwise the throttle goes silent for the whole next step. These tests
|
||||||
|
* pin the pure decision AND the reset property that ai-chat.service.onStepFinish
|
||||||
|
* now enforces.
|
||||||
|
*/
|
||||||
|
describe('shouldCheckDegeneration (throttle) + step-boundary reset (#486)', () => {
|
||||||
|
it('fires once the text grows a full DEGENERATION_CHECK_STEP past the mark', () => {
|
||||||
|
expect(shouldCheckDegeneration(DEGENERATION_CHECK_STEP, 0)).toBe(true);
|
||||||
|
expect(shouldCheckDegeneration(DEGENERATION_CHECK_STEP - 1, 0)).toBe(false);
|
||||||
|
expect(shouldCheckDegeneration(5000, 3000)).toBe(true); // grew 2000 since mark
|
||||||
|
expect(shouldCheckDegeneration(4000, 3000)).toBe(false); // grew only 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BUG (no reset): a stale large watermark silences the next step', () => {
|
||||||
|
// End of a long step: the watermark sits at 5000. The step ends and the
|
||||||
|
// accumulator resets to '' — but if the watermark is NOT reset, a fresh short
|
||||||
|
// degenerate burst (length 2000) never triggers a check: 2000 - 5000 < STEP.
|
||||||
|
const staleWatermark = 5000;
|
||||||
|
const nextStepLen = DEGENERATION_CHECK_STEP; // a fresh 2KB burst
|
||||||
|
expect(shouldCheckDegeneration(nextStepLen, staleWatermark)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FIX (reset to 0): the same short degenerate burst IS checked and detected', () => {
|
||||||
|
// onStepFinish now zeroes the watermark, so the fresh burst re-arms the check.
|
||||||
|
const resetWatermark = 0;
|
||||||
|
const degenerateBurst = 'loadTools.\n'.repeat(300); // real degeneration
|
||||||
|
expect(degenerateBurst.length).toBeGreaterThanOrEqual(DEGENERATION_CHECK_STEP);
|
||||||
|
// The throttle now fires...
|
||||||
|
expect(
|
||||||
|
shouldCheckDegeneration(degenerateBurst.length, resetWatermark),
|
||||||
|
).toBe(true);
|
||||||
|
// ...and the detector catches the loop that would otherwise stream unchecked.
|
||||||
|
expect(isDegenerateOutput(degenerateBurst)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BEHAVIOR guard for the ACTUAL fix (#486, ai-chat.service.onStepFinish resets
|
||||||
|
* lastDegenerationCheckLen to 0). The pure tests above use a hard-coded
|
||||||
|
* resetWatermark, so a REVERT of the real `lastDegenerationCheckLen = 0` line
|
||||||
|
* would not redden any of them. This drives the REAL onChunk/onStepFinish
|
||||||
|
* closures from stream() end to end and asserts the run is aborted when a fresh
|
||||||
|
* degenerate burst arrives in the step AFTER a long clean step — which only
|
||||||
|
* happens if the watermark was actually zeroed on the step boundary.
|
||||||
|
*/
|
||||||
|
describe('AiChatService: onStepFinish re-arms the degeneration watermark (#486)', () => {
|
||||||
|
const streamTextMock = streamText as unknown as jest.Mock;
|
||||||
|
|
||||||
|
function makeRes() {
|
||||||
|
return {
|
||||||
|
raw: {
|
||||||
|
writeHead: jest.fn(),
|
||||||
|
write: jest.fn(),
|
||||||
|
once: jest.fn(),
|
||||||
|
on: jest.fn(),
|
||||||
|
flushHeaders: jest.fn(),
|
||||||
|
writableEnded: false,
|
||||||
|
destroyed: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
const aiChatRepo = {
|
||||||
|
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
||||||
|
insert: jest.fn(),
|
||||||
|
};
|
||||||
|
const aiChatMessageRepo = {
|
||||||
|
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
findAllByChat: jest.fn(async () => []),
|
||||||
|
update: jest.fn(async () => ({ id: 'msg-1' })),
|
||||||
|
};
|
||||||
|
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
||||||
|
const tools = { forUser: jest.fn(async () => ({})) };
|
||||||
|
const mcpClients = {
|
||||||
|
toolsFor: jest.fn(async () => ({
|
||||||
|
tools: {},
|
||||||
|
clients: [],
|
||||||
|
outcomes: [],
|
||||||
|
instructions: [],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return new AiChatService(
|
||||||
|
{} as never, // ai
|
||||||
|
aiChatRepo as never,
|
||||||
|
aiChatMessageRepo as never,
|
||||||
|
{} as never, // aiChatPageSnapshotRepo
|
||||||
|
aiSettings as never,
|
||||||
|
tools as never,
|
||||||
|
mcpClients as never,
|
||||||
|
{} as never, // aiAgentRoleRepo
|
||||||
|
{} as never, // pageRepo
|
||||||
|
{} as never, // pageAccess
|
||||||
|
{
|
||||||
|
isAiChatDeferredToolsEnabled: () => false,
|
||||||
|
// Lockdown OFF -> the degeneration guard is the active anti-babble path.
|
||||||
|
isAiChatFinalStepLockdownEnabled: () => false,
|
||||||
|
} as never, // environment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
streamTextMock.mockReset();
|
||||||
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
|
||||||
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('aborts on a fresh degenerate burst in the NEXT step (reverting the reset line reddens this)', async () => {
|
||||||
|
let captured:
|
||||||
|
| {
|
||||||
|
onChunk?: (e: { chunk: { type: string; text: string } }) => void;
|
||||||
|
onStepFinish?: (step: unknown) => void;
|
||||||
|
abortSignal?: AbortSignal;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
streamTextMock.mockImplementation((opts: never) => {
|
||||||
|
captured = opts;
|
||||||
|
return {
|
||||||
|
consumeStream: jest.fn(),
|
||||||
|
pipeUIMessageStreamToResponse: jest.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const svc = makeService();
|
||||||
|
await svc.stream({
|
||||||
|
user: { id: 'user-1' } as never,
|
||||||
|
workspace: { id: 'ws-1' } as never,
|
||||||
|
sessionId: 'sess-1',
|
||||||
|
body: {
|
||||||
|
chatId: 'chat-1',
|
||||||
|
messages: [
|
||||||
|
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
||||||
|
],
|
||||||
|
} as never,
|
||||||
|
res: makeRes() as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: {} as never,
|
||||||
|
role: null,
|
||||||
|
// No runHooks -> legacy path (socket signal), degeneration guard active.
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
||||||
|
const onChunk = captured!.onChunk!;
|
||||||
|
const onStepFinish = captured!.onStepFinish!;
|
||||||
|
const abortSignal = captured!.abortSignal!;
|
||||||
|
expect(abortSignal.aborted).toBe(false);
|
||||||
|
|
||||||
|
// STEP 1: a LONG, non-degenerate first step. Distinct lines never trip the
|
||||||
|
// detector, but they advance the throttle watermark far past the burst size
|
||||||
|
// that follows (to ~5x the step). This is the stale watermark that, WITHOUT
|
||||||
|
// the reset, would silence step 2.
|
||||||
|
let counter = 0;
|
||||||
|
let accumulated = 0;
|
||||||
|
while (accumulated < DEGENERATION_CHECK_STEP * 5) {
|
||||||
|
const line = `unique clean line number ${counter++} with distinct words\n`;
|
||||||
|
accumulated += line.length;
|
||||||
|
onChunk({ chunk: { type: 'text-delta', text: line } });
|
||||||
|
}
|
||||||
|
expect(abortSignal.aborted).toBe(false); // clean step must not abort
|
||||||
|
|
||||||
|
// STEP BOUNDARY: the real onStepFinish resets inProgressText AND (the fix)
|
||||||
|
// zeroes lastDegenerationCheckLen.
|
||||||
|
onStepFinish({ text: 'a clean first step', toolCalls: [], toolResults: [] });
|
||||||
|
|
||||||
|
// STEP 2: a FRESH, short degenerate burst (~3.3KB). Its length is far below
|
||||||
|
// the step-1 stale watermark (~10KB), so WITHOUT the reset the throttle stays
|
||||||
|
// silent and this streams unchecked. WITH the reset (watermark 0) it re-arms,
|
||||||
|
// the detector fires, and the run aborts.
|
||||||
|
const burst = 'loadTools.\n'.repeat(300);
|
||||||
|
expect(burst.length).toBeGreaterThanOrEqual(DEGENERATION_CHECK_STEP);
|
||||||
|
expect(burst.length).toBeLessThan(DEGENERATION_CHECK_STEP * 5);
|
||||||
|
onChunk({ chunk: { type: 'text-delta', text: burst } });
|
||||||
|
|
||||||
|
// The decisive assertion: the composed abortSignal (unioned with the
|
||||||
|
// degeneration controller) is now aborted. Reverting `lastDegenerationCheckLen
|
||||||
|
// = 0` in onStepFinish makes this stay false.
|
||||||
|
expect(abortSignal.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,54 @@ export const MAX_PERIOD_LEN = 150;
|
|||||||
/** Rule 2: minimum number of consecutive block repeats to trigger. */
|
/** Rule 2: minimum number of consecutive block repeats to trigger. */
|
||||||
export const MIN_PERIOD_REPEATS = 20;
|
export const MIN_PERIOD_REPEATS = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a positive-integer threshold from an env var, falling back to `fallback`
|
||||||
|
* on unset/blank/invalid/non-positive. Mirrors the `AI_STREAM_PRE_RESPONSE_RETRIES`
|
||||||
|
* resolver in `ai-streaming-fetch.ts`: read the RAW string first so a blank value
|
||||||
|
* is treated as "unset" (→ fallback) rather than coercing to 0. Thresholds must
|
||||||
|
* stay ≥ 1 — a 0/negative would make the detector fire on any text (or never), so
|
||||||
|
* a bad value degrades to the safe compiled default instead. Env-tunable so an
|
||||||
|
* operator can retune the anti-babble guard (#444) without a redeploy, following
|
||||||
|
* the `AI_CHAT_FINAL_STEP_LOCKDOWN` toggle convention.
|
||||||
|
*/
|
||||||
|
function envThreshold(name: string, fallback: number): number {
|
||||||
|
const rawStr = process.env[name];
|
||||||
|
if (rawStr === undefined || rawStr.trim() === '') return fallback;
|
||||||
|
const raw = Number(rawStr);
|
||||||
|
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the degeneration-detector thresholds from the environment, each
|
||||||
|
* defaulting to the compiled constant above. Read fresh per call (not cached at
|
||||||
|
* import) so a test — or a runtime env change — takes effect deterministically.
|
||||||
|
*/
|
||||||
|
export function degenerationThresholds(): {
|
||||||
|
repeatedLines: number;
|
||||||
|
maxPeriodLen: number;
|
||||||
|
minPeriodRepeats: number;
|
||||||
|
checkStep: number;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
repeatedLines: envThreshold(
|
||||||
|
'AI_CHAT_DEGENERATION_REPEATED_LINES',
|
||||||
|
REPEATED_LINES_THRESHOLD,
|
||||||
|
),
|
||||||
|
maxPeriodLen: envThreshold(
|
||||||
|
'AI_CHAT_DEGENERATION_PERIOD_MAX_LEN',
|
||||||
|
MAX_PERIOD_LEN,
|
||||||
|
),
|
||||||
|
minPeriodRepeats: envThreshold(
|
||||||
|
'AI_CHAT_DEGENERATION_PERIOD_MIN_REPEATS',
|
||||||
|
MIN_PERIOD_REPEATS,
|
||||||
|
),
|
||||||
|
checkStep: envThreshold(
|
||||||
|
'AI_CHAT_DEGENERATION_CHECK_STEP',
|
||||||
|
DEGENERATION_CHECK_STEP,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rule 1 — ≥`REPEATED_LINES_THRESHOLD` consecutive IDENTICAL non-empty lines at
|
* Rule 1 — ≥`REPEATED_LINES_THRESHOLD` consecutive IDENTICAL non-empty lines at
|
||||||
* the tail. Catches the classic newline-delimited loop ("loadTools.\n" ×N).
|
* the tail. Catches the classic newline-delimited loop ("loadTools.\n" ×N).
|
||||||
@@ -128,7 +176,37 @@ export function hasPeriodicTail(
|
|||||||
* Pure — the caller owns the abort side effect.
|
* Pure — the caller owns the abort side effect.
|
||||||
*/
|
*/
|
||||||
export function isDegenerateOutput(text: string): boolean {
|
export function isDegenerateOutput(text: string): boolean {
|
||||||
return hasRepeatedLineRun(text) || hasPeriodicTail(text);
|
const cfg = degenerationThresholds();
|
||||||
|
return (
|
||||||
|
hasRepeatedLineRun(text, cfg.repeatedLines) ||
|
||||||
|
hasPeriodicTail(text, cfg.maxPeriodLen, cfg.minPeriodRepeats)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many bytes the in-progress text must grow before the (amortized) tail
|
||||||
|
* heuristics are re-run. Shared with ai-chat.service so the throttle the stream
|
||||||
|
* applies is the SAME one the unit test drives.
|
||||||
|
*/
|
||||||
|
export const DEGENERATION_CHECK_STEP = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throttle decision for the degeneration guard (#444/#486). Returns true when
|
||||||
|
* the accumulated text has grown at least DEGENERATION_CHECK_STEP bytes past the
|
||||||
|
* last-checked offset, so the pure rules only fire every ~2KB. Pure; the caller
|
||||||
|
* updates its watermark to `textLen` when this returns true.
|
||||||
|
*
|
||||||
|
* The watermark is an offset INTO the accumulator, so when the accumulator is
|
||||||
|
* reset to '' on a step boundary the caller MUST reset the watermark to 0 too
|
||||||
|
* (#486). Otherwise `textLen - lastCheckLen` goes negative after the reset and
|
||||||
|
* this returns false until a later step re-grows past the stale offset — a whole
|
||||||
|
* degenerate step could stream unchecked.
|
||||||
|
*/
|
||||||
|
export function shouldCheckDegeneration(
|
||||||
|
textLen: number,
|
||||||
|
lastCheckLen: number,
|
||||||
|
): boolean {
|
||||||
|
return textLen - lastCheckLen >= degenerationThresholds().checkStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
// Break the editor-ext import chain (share.service -> collaboration.util ->
|
||||||
|
// @docmost/editor-ext -> @tiptap/core) that is unresolvable in this jest env and
|
||||||
|
// pre-existingly breaks these specs. jsonToMarkdown is never reached in these
|
||||||
|
// tests (the tools fail before rendering markdown).
|
||||||
|
jest.mock('../../collaboration/collaboration.util', () => ({
|
||||||
|
jsonToMarkdown: () => '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
|
||||||
|
import { PublicShareChatService } from './public-share-chat.service';
|
||||||
|
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SECURITY integration guard for #394 (commit 5): a tool's or the provider's raw
|
||||||
|
* error text must NOT leak to an anonymous public-share reader.
|
||||||
|
*
|
||||||
|
* The render gate (ToolCallCard showErrors=false) hides the text in the DOM but
|
||||||
|
* NOT on the wire, so this test asserts on the RAW SSE BYTES the server writes —
|
||||||
|
* exactly the channel the render gate masks. We drive the real
|
||||||
|
* PublicShareChatService.stream() with a real share toolset (its underlying
|
||||||
|
* services mocked to fail) and a mock model, then inspect every byte piped to the
|
||||||
|
* fake socket.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// A minimal ServerResponse stand-in that records every written chunk.
|
||||||
|
class FakeSocket {
|
||||||
|
chunks: string[] = [];
|
||||||
|
statusCode = 200;
|
||||||
|
writableEnded = false;
|
||||||
|
destroyed = false;
|
||||||
|
headersSent = false;
|
||||||
|
writeHead(): this {
|
||||||
|
this.headersSent = true;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
setHeader(): void {}
|
||||||
|
removeHeader(): void {}
|
||||||
|
getHeader(): undefined {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
flushHeaders(): void {}
|
||||||
|
write(chunk: unknown): boolean {
|
||||||
|
this.chunks.push(
|
||||||
|
typeof chunk === 'string' ? chunk : Buffer.from(chunk as never).toString('utf8'),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
end(chunk?: unknown): void {
|
||||||
|
if (chunk) this.write(chunk);
|
||||||
|
this.writableEnded = true;
|
||||||
|
}
|
||||||
|
on(): this {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
once(): this {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
get body(): string {
|
||||||
|
return this.chunks.join('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock model that issues one getSharePage tool call, then finishes with text. */
|
||||||
|
function toolCallingModel(): MockLanguageModelV3 {
|
||||||
|
let call = 0;
|
||||||
|
return new MockLanguageModelV3({
|
||||||
|
doStream: async () => {
|
||||||
|
call++;
|
||||||
|
if (call === 1) {
|
||||||
|
return {
|
||||||
|
stream: simulateReadableStream({
|
||||||
|
chunks: [
|
||||||
|
{ type: 'stream-start' as const, warnings: [] },
|
||||||
|
{ type: 'tool-input-start' as const, id: 't1', toolName: 'getSharePage' },
|
||||||
|
{ type: 'tool-input-end' as const, id: 't1' },
|
||||||
|
{
|
||||||
|
type: 'tool-call' as const,
|
||||||
|
toolCallId: 't1',
|
||||||
|
toolName: 'getSharePage',
|
||||||
|
input: '{"pageId":"secret-page"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'finish' as const,
|
||||||
|
finishReason: { unified: 'tool-calls' as const, raw: 'tool_calls' },
|
||||||
|
usage: {
|
||||||
|
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||||
|
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
stream: simulateReadableStream({
|
||||||
|
chunks: [
|
||||||
|
{ type: 'stream-start' as const, warnings: [] },
|
||||||
|
{ type: 'text-start' as const, id: '1' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'Sorry.' },
|
||||||
|
{ type: 'text-end' as const, id: '1' },
|
||||||
|
{
|
||||||
|
type: 'finish' as const,
|
||||||
|
finishReason: { unified: 'stop' as const, raw: 'stop' },
|
||||||
|
usage: {
|
||||||
|
inputTokens: { total: 1, noCache: undefined, cacheRead: undefined, cacheWrite: undefined },
|
||||||
|
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mock model whose stream emits a provider error carrying an internal secret. */
|
||||||
|
function providerErrorModel(secret: string): MockLanguageModelV3 {
|
||||||
|
return new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({
|
||||||
|
stream: simulateReadableStream({
|
||||||
|
chunks: [
|
||||||
|
{ type: 'stream-start' as const, warnings: [] },
|
||||||
|
{
|
||||||
|
type: 'error' as const,
|
||||||
|
error: {
|
||||||
|
statusCode: 503,
|
||||||
|
message: 'Service Unavailable',
|
||||||
|
responseBody: `upstream ${secret} model=internal-gpt`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService(toolsService: PublicShareChatToolsService): {
|
||||||
|
svc: PublicShareChatService;
|
||||||
|
logSpy: jest.SpyInstance;
|
||||||
|
} {
|
||||||
|
const svc = Object.create(PublicShareChatService.prototype);
|
||||||
|
const logger = new Logger('test');
|
||||||
|
const logSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
|
||||||
|
jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
|
||||||
|
svc.tools = toolsService;
|
||||||
|
svc.logger = logger;
|
||||||
|
svc.tokenBudget = { record: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
return { svc, logSpy };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStream(
|
||||||
|
svc: PublicShareChatService,
|
||||||
|
model: MockLanguageModelV3,
|
||||||
|
): Promise<FakeSocket> {
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
await svc.stream({
|
||||||
|
workspaceId: 'ws1',
|
||||||
|
shareId: 'share1',
|
||||||
|
share: { id: 'share1', pageId: 'p1', sharedPage: { id: 'p1', title: 'Docs' } },
|
||||||
|
openedPage: null,
|
||||||
|
messages: [
|
||||||
|
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'read the page' }] } as never,
|
||||||
|
],
|
||||||
|
res: { raw: socket } as never,
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
model: model as never,
|
||||||
|
role: null,
|
||||||
|
});
|
||||||
|
// Let the piped stream drain fully.
|
||||||
|
await new Promise((r) => setTimeout(r, 300));
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('public share chat error leak (#394)', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
it('does NOT leak a tool\'s raw internal error to the SSE bytes (generic classified string instead)', async () => {
|
||||||
|
const SECRET = 'INTERNAL_baseUrl_http://provider.internal:8080/v1';
|
||||||
|
const shareService = {
|
||||||
|
// The canonical boundary throws a RAW internal error (with a secret).
|
||||||
|
resolveReadableSharePage: jest
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValue(new Error(`db failed at ${SECRET} stack@line42`)),
|
||||||
|
};
|
||||||
|
const tools = new PublicShareChatToolsService(
|
||||||
|
shareService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const { svc } = makeService(tools);
|
||||||
|
|
||||||
|
const socket = await runStream(svc, toolCallingModel());
|
||||||
|
|
||||||
|
// The tool-output-error frame is present on the wire...
|
||||||
|
expect(socket.body).toContain('tool-output-error');
|
||||||
|
// ...but it carries ONLY the generic classified string — never the secret,
|
||||||
|
// the raw driver message, or a stack fragment.
|
||||||
|
expect(socket.body).toContain('The tool could not complete the request.');
|
||||||
|
expect(socket.body).not.toContain(SECRET);
|
||||||
|
expect(socket.body).not.toContain('stack@line42');
|
||||||
|
expect(socket.body).not.toContain('db failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes a SAFE ShareToolError message (page not available) through to the bytes', async () => {
|
||||||
|
const shareService = {
|
||||||
|
// Not found in this share -> the tool throws the classified SAFE message.
|
||||||
|
resolveReadableSharePage: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
const tools = new PublicShareChatToolsService(
|
||||||
|
shareService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const { svc } = makeService(tools);
|
||||||
|
|
||||||
|
const socket = await runStream(svc, toolCallingModel());
|
||||||
|
expect(socket.body).toContain('tool-output-error');
|
||||||
|
expect(socket.body).toContain('not available in this share');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT leak a provider error (statusCode + response body) to the SSE bytes', async () => {
|
||||||
|
const SECRET = 'http://provider.internal:8080';
|
||||||
|
const tools = new PublicShareChatToolsService(
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
const { svc, logSpy } = makeService(tools);
|
||||||
|
|
||||||
|
const socket = await runStream(svc, providerErrorModel(SECRET));
|
||||||
|
|
||||||
|
// The anon sees a fixed classified string, not the provider body/baseUrl/model.
|
||||||
|
expect(socket.body).toContain('temporarily unavailable');
|
||||||
|
expect(socket.body).not.toContain(SECRET);
|
||||||
|
expect(socket.body).not.toContain('internal-gpt');
|
||||||
|
// The FULL provider detail is logged server-side only.
|
||||||
|
const logged = logSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||||
|
expect(logged).toContain(SECRET);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,7 +12,10 @@ import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles
|
|||||||
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
import { AiAgentRole } from '@docmost/db/types/entity.types';
|
||||||
import { AiService } from '../../integrations/ai/ai.service';
|
import { AiService } from '../../integrations/ai/ai.service';
|
||||||
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
|
||||||
import { PublicShareChatToolsService } from './tools/public-share-chat-tools.service';
|
import {
|
||||||
|
PublicShareChatToolsService,
|
||||||
|
ShareToolError,
|
||||||
|
} from './tools/public-share-chat-tools.service';
|
||||||
import { buildShareSystemPrompt } from './public-share-chat.prompt';
|
import { buildShareSystemPrompt } from './public-share-chat.prompt';
|
||||||
import { roleModelOverride } from './roles/role-model-config';
|
import { roleModelOverride } from './roles/role-model-config';
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +105,30 @@ export function filterShareTranscript(messages: UIMessage[]): UIMessage[] {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fixed, classified strings an ANONYMOUS share reader may see when the assistant
|
||||||
|
* stream fails (#394). These reveal NOTHING about the internal provider, its
|
||||||
|
* baseUrl, the model name, or the raw response body — unlike describeProviderError
|
||||||
|
* (which is for the server log / the authenticated operator only). We classify by
|
||||||
|
* HTTP status where available so the reader still gets a useful hint (retry vs.
|
||||||
|
* give up) without any internal detail.
|
||||||
|
*/
|
||||||
|
export function classifyAnonStreamError(error: unknown): string {
|
||||||
|
const status =
|
||||||
|
typeof error === 'object' && error !== null
|
||||||
|
? (error as { statusCode?: number }).statusCode
|
||||||
|
: undefined;
|
||||||
|
if (status === 429) {
|
||||||
|
return 'The assistant is receiving too many requests right now. Please try again shortly.';
|
||||||
|
}
|
||||||
|
if (typeof status === 'number' && status >= 500) {
|
||||||
|
return 'The assistant is temporarily unavailable. Please try again.';
|
||||||
|
}
|
||||||
|
// Any other failure (including a bare connection error with no status): a
|
||||||
|
// single neutral line. No provider identity, no config, no response body.
|
||||||
|
return 'The assistant could not complete your request. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Anonymous, read-only AI assistant for a single PUBLIC share tree.
|
* Anonymous, read-only AI assistant for a single PUBLIC share tree.
|
||||||
*
|
*
|
||||||
@@ -280,6 +307,10 @@ export class PublicShareChatService {
|
|||||||
system,
|
system,
|
||||||
messages: modelMessages,
|
messages: modelMessages,
|
||||||
tools,
|
tools,
|
||||||
|
// Pin the AI SDK per-request retry budget explicitly (matches the SDK
|
||||||
|
// default of 2). Connection arithmetic: (1 + maxRetries) × (1 +
|
||||||
|
// AI_STREAM_PRE_RESPONSE_RETRIES) worst-case connects per turn.
|
||||||
|
maxRetries: 2,
|
||||||
// Bound the agent loop for anonymous callers.
|
// Bound the agent loop for anonymous callers.
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
// Cap per-request output so one anonymous call cannot run up the provider
|
// Cap per-request output so one anonymous call cannot run up the provider
|
||||||
@@ -318,11 +349,28 @@ export class PublicShareChatService {
|
|||||||
result.pipeUIMessageStreamToResponse(res.raw, {
|
result.pipeUIMessageStreamToResponse(res.raw, {
|
||||||
headers: { 'X-Accel-Buffering': 'no' },
|
headers: { 'X-Accel-Buffering': 'no' },
|
||||||
onError: (error: unknown) => {
|
onError: (error: unknown) => {
|
||||||
// Reuse the shared formatter so provider error formatting stays
|
// SECURITY (#394): the string this returns is written verbatim into the
|
||||||
// unified between the log line and the streamed error message — a
|
// SSE error frame delivered to an ANONYMOUS reader (for a tool failure
|
||||||
// share reader sees 402/429/503 causes consistently with the
|
// it becomes the atomic `tool-output-error` frame's errorText; for a
|
||||||
// authenticated path.
|
// stream/provider failure, the terminal error frame).
|
||||||
return describeProviderError(error, 'AI stream error');
|
//
|
||||||
|
// A ShareToolError is already a classified, safe tool message (see
|
||||||
|
// PublicShareChatToolsService.wrapToolErrors) — pass it through so the
|
||||||
|
// reader still gets the useful "page not available in this share" hint.
|
||||||
|
if (error instanceof ShareToolError) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
// Anything else is a provider/stream error. describeProviderError
|
||||||
|
// bundles the provider statusCode AND response body, which can carry the
|
||||||
|
// internal baseUrl or model name — NEVER expose that to the public. Log
|
||||||
|
// the full detail server-side only and return a fixed classified string.
|
||||||
|
this.logger.error(
|
||||||
|
`Public share chat pipe error: ${describeProviderError(
|
||||||
|
error,
|
||||||
|
'AI stream error',
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
return classifyAnonStreamError(error);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user