Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51ded06fde | |||
| 456a91d289 | |||
| 515c08afed |
+5
-25
@@ -191,24 +191,16 @@ 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
|
# ~5 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
||||||
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
||||||
# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min).
|
# transport idling >5 min BETWEEN tool calls. Default 300000 (5 min).
|
||||||
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
# AI_MCP_STREAM_TIMEOUT_MS=300000
|
||||||
|
|
||||||
# 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.
|
||||||
# Default 120000 (2 min).
|
# Default 900000 (15 min).
|
||||||
# AI_MCP_CALL_TIMEOUT_MS=120000
|
# AI_MCP_CALL_TIMEOUT_MS=900000
|
||||||
|
|
||||||
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
|
||||||
# small for a long AI-chat research turn: the client resends the FULL message
|
|
||||||
# history (every tool call + search result) on each turn, so a deep conversation's
|
|
||||||
# POST to /api/ai-chat/stream can be several MB and would otherwise be rejected
|
|
||||||
# with FST_ERR_CTP_BODY_TOO_LARGE (413). Does NOT affect multipart file uploads
|
|
||||||
# (see FILE_UPLOAD_SIZE_LIMIT). Default 26214400 (25 MiB).
|
|
||||||
# HTTP_JSON_BODY_LIMIT=26214400
|
|
||||||
|
|
||||||
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
||||||
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
||||||
@@ -230,18 +222,6 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
# 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
|
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
||||||
# visible, and a startup sweep settles any run left dangling by a restart.
|
# visible, and a startup sweep settles any run left dangling by a restart.
|
||||||
#
|
|
||||||
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
|
|
||||||
# durable run tees its SSE frames into an in-memory registry, and a
|
|
||||||
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
|
|
||||||
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
|
|
||||||
# (default) the registry is never populated and attach always answers 204, so a
|
|
||||||
# reopened tab of an active run silently falls back to degraded 2.5s history
|
|
||||||
# polling — every wire path stays byte-for-byte identical to a build without the
|
|
||||||
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
|
|
||||||
# enabled for a workspace, and the same single-instance constraint applies (the
|
|
||||||
# registry is process-local).
|
|
||||||
# AI_CHAT_RESUMABLE_STREAM=false
|
|
||||||
|
|
||||||
# --- Anonymous public-share AI assistant ---
|
# --- Anonymous public-share AI assistant ---
|
||||||
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
name: Nightly property fuzz
|
|
||||||
|
|
||||||
# The daily heavy property run for the ProseMirror<->Markdown converter
|
|
||||||
# (packages/prosemirror-markdown). The PR/CI test run keeps NUM_RUNS modest to
|
|
||||||
# stay under budget; this cron cranks up total coverage with random seeds to hunt
|
|
||||||
# for deeper round-trip counterexamples than a fixed-seed PR run can reach.
|
|
||||||
#
|
|
||||||
# WHY SHARDING: a single mega-run (~10000 fast-check runs) OOMs the vitest worker
|
|
||||||
# (empirically ~1625 runs -> "JS heap out of memory", ~2GB) because heap
|
|
||||||
# accumulates across the whole property run in one process. Instead this job runs
|
|
||||||
# SHARDS fresh vitest processes, each a MODERATE per-shard count with a DISTINCT
|
|
||||||
# derived seed, so total coverage ~= SHARDS x PER_SHARD_NUM_RUNS across processes
|
|
||||||
# that never accumulate heap. On the first failing shard we stop and keep that
|
|
||||||
# shard's output for triage.
|
|
||||||
#
|
|
||||||
# Counterexample -> fixture workflow: when a shard fails, fast-check prints the
|
|
||||||
# SHRUNK minimal counterexample plus the reproducing seed. This job files a Gitea
|
|
||||||
# issue containing that seed + counterexample ONLY when the output actually holds
|
|
||||||
# a fast-check counterexample; an infra failure (OOM/tsc/install, no
|
|
||||||
# counterexample) is filed under a DISTINCT title so it can never poison the
|
|
||||||
# counterexample dedup. A human then commits the shrunk doc as a PERMANENT fixture
|
|
||||||
# under packages/prosemirror-markdown/test/fixtures/counterexamples/ with a case in
|
|
||||||
# counterexamples.test.ts, and FIXES the converter (never weakens a property to
|
|
||||||
# hide the bug). See packages/prosemirror-markdown/README.md.
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
# 03:00 UTC daily.
|
|
||||||
- cron: '0 3 * * *'
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
num_runs:
|
|
||||||
description: 'fast-check runs PER SHARD (8 shards run in sequence)'
|
|
||||||
required: false
|
|
||||||
default: '600'
|
|
||||||
seed:
|
|
||||||
description: 'base fast-check seed (empty = random); shard i uses base+i'
|
|
||||||
required: false
|
|
||||||
default: ''
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
property-fuzz:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 60
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
# No build step: the generative suite imports the converter from src/
|
|
||||||
# directly (e.g. `from '../../src/lib/markdown-converter.js'`), so it runs
|
|
||||||
# against source without the package's build/. Skipping the build also
|
|
||||||
# keeps a tsc build error from masquerading as a property-test failure and
|
|
||||||
# filing a bogus counterexample issue.
|
|
||||||
- name: Resolve base seed and per-shard run count
|
|
||||||
id: params
|
|
||||||
# Dispatch inputs are read via env (NOT interpolated into the shell body)
|
|
||||||
# to avoid script injection through a crafted input value.
|
|
||||||
env:
|
|
||||||
SEED_INPUT: ${{ inputs.seed }}
|
|
||||||
NUM_RUNS_INPUT: ${{ inputs.num_runs }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
SEED="${SEED_INPUT:-}"
|
|
||||||
# Empty seed (cron, or a dispatch that left it blank) -> random. Combine
|
|
||||||
# two RANDOMs so the seed spans more than RANDOM's 0..32767 range.
|
|
||||||
[ -z "$SEED" ] && SEED=$(( (RANDOM << 15) | RANDOM ))
|
|
||||||
NUM_RUNS="${NUM_RUNS_INPUT:-}"
|
|
||||||
[ -z "$NUM_RUNS" ] && NUM_RUNS=600
|
|
||||||
echo "seed=$SEED" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "num_runs=$NUM_RUNS" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Sharded property fuzz: BASE_SEED=$SEED PER_SHARD_NUM_RUNS=$NUM_RUNS SHARDS=8"
|
|
||||||
|
|
||||||
- name: Run generative property suite (sharded)
|
|
||||||
id: fuzz
|
|
||||||
env:
|
|
||||||
BASE_SEED: ${{ steps.params.outputs.seed }}
|
|
||||||
PER_SHARD_NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
|
||||||
SHARDS: '8'
|
|
||||||
run: |
|
|
||||||
set -uo pipefail
|
|
||||||
# Give each fresh process headroom, but rely on SHARDING (not a big heap)
|
|
||||||
# to avoid OOM: a moderate per-shard count in a process that starts clean.
|
|
||||||
export NODE_OPTIONS=--max-old-space-size=4096
|
|
||||||
: > property-output.txt
|
|
||||||
FAILED=0
|
|
||||||
FAIL_SEED=""
|
|
||||||
i=0
|
|
||||||
while [ "$i" -lt "$SHARDS" ]; do
|
|
||||||
SHARD_SEED=$(( BASE_SEED + i ))
|
|
||||||
echo "=== shard $((i + 1))/$SHARDS: PROPERTY_SEED=$SHARD_SEED PROPERTY_NUM_RUNS=$PER_SHARD_NUM_RUNS ==="
|
|
||||||
# tee OVERWRITES property-output.txt each shard; since we break on the
|
|
||||||
# first failure, the file ends up holding exactly the failing shard's
|
|
||||||
# output (which carries the shrunk counterexample + reproducing seed).
|
|
||||||
if PROPERTY_SEED="$SHARD_SEED" PROPERTY_NUM_RUNS="$PER_SHARD_NUM_RUNS" \
|
|
||||||
pnpm --filter @docmost/prosemirror-markdown exec \
|
|
||||||
vitest run test/generative/ 2>&1 | tee property-output.txt; then
|
|
||||||
echo "shard $((i + 1)) passed"
|
|
||||||
else
|
|
||||||
echo "shard $((i + 1)) FAILED (seed=$SHARD_SEED) — stopping; keeping its output"
|
|
||||||
FAILED=1
|
|
||||||
FAIL_SEED="$SHARD_SEED"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
i=$(( i + 1 ))
|
|
||||||
done
|
|
||||||
echo "failed=$FAILED" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "fail_seed=$FAIL_SEED" >> "$GITHUB_OUTPUT"
|
|
||||||
exit "$FAILED"
|
|
||||||
|
|
||||||
# A GENUINE counterexample: fast-check printed a shrunk minimal case and its
|
|
||||||
# reproducing seed into property-output.txt. File a dedup-guarded issue whose
|
|
||||||
# title prefix is UNIQUE to counterexamples, so an infra failure (handled by
|
|
||||||
# the next step under a different title) can never poison this dedup.
|
|
||||||
- name: File counterexample issue
|
|
||||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
|
||||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
|
||||||
# exactly when it must run. always() lets it run on the failure path.
|
|
||||||
if: always() && steps.fuzz.outputs.failed == '1'
|
|
||||||
env:
|
|
||||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
|
||||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TITLE_PREFIX: 'Nightly property counterexample'
|
|
||||||
run: |
|
|
||||||
set -uo pipefail
|
|
||||||
# Discriminate counterexample vs infra failure by the fast-check
|
|
||||||
# signature. No signature -> leave it to the infra-failure step.
|
|
||||||
if ! grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
|
||||||
echo "No fast-check counterexample signature — infra failure, handled by the next step."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
|
||||||
|
|
||||||
# Best-effort dedup: skip if an open issue with the counterexample title
|
|
||||||
# prefix already exists. A failure of this check must NOT block creation.
|
|
||||||
EXISTING=""
|
|
||||||
if EXISTING=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
|
||||||
if printf '%s' "$EXISTING" \
|
|
||||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
|
||||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build the JSON body with the test output SAFELY escaped (never hand-
|
|
||||||
# interpolate the counterexample into JSON).
|
|
||||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed with a fast-check counterexample.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nReproduce locally:\n\n```\nPROPERTY_SEED=%s PROPERTY_NUM_RUNS=%s pnpm --filter @docmost/prosemirror-markdown exec vitest run test/generative/\n```\n\nfast-check shrinks the failure to a minimal counterexample. Commit it as a permanent fixture under `packages/prosemirror-markdown/test/fixtures/counterexamples/` + a case in `counterexamples.test.ts`, then FIX the converter (do not weaken a property). See `packages/prosemirror-markdown/README.md`.\n\nTail of the test output (contains the shrunk counterexample):\n\n```\n%s\n```\n' \
|
|
||||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$FAIL_SEED" "$NUM_RUNS" "$(tail -n 120 property-output.txt)")
|
|
||||||
|
|
||||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
|
||||||
'{title: $title, body: $body}' > payload.json
|
|
||||||
|
|
||||||
curl -sS -X POST \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d @payload.json
|
|
||||||
|
|
||||||
# An INFRA failure (OOM, tsc, install) has NO counterexample signature. File
|
|
||||||
# it under a DISTINCT title so it is visible but keeps the counterexample
|
|
||||||
# dedup (above) uncontaminated — a real counterexample can still file even
|
|
||||||
# while an infra issue is open.
|
|
||||||
- name: File infra failure issue
|
|
||||||
# always() is REQUIRED: the fuzz step exits nonzero on a failing shard,
|
|
||||||
# so a bare `if:` (implicitly success() && ...) would skip this step
|
|
||||||
# exactly when it must run. always() lets it run on the failure path.
|
|
||||||
if: always() && steps.fuzz.outputs.failed == '1'
|
|
||||||
env:
|
|
||||||
FAIL_SEED: ${{ steps.fuzz.outputs.fail_seed }}
|
|
||||||
NUM_RUNS: ${{ steps.params.outputs.num_runs }}
|
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
TITLE_PREFIX: 'Nightly property run infra failure'
|
|
||||||
run: |
|
|
||||||
set -uo pipefail
|
|
||||||
# Only file when there is NO counterexample signature (else the
|
|
||||||
# counterexample step owns it).
|
|
||||||
if grep -Eq 'Property failed after|Counterexample' property-output.txt; then
|
|
||||||
echo "Counterexample present — owned by the counterexample step."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
TITLE="${TITLE_PREFIX} (seed=${FAIL_SEED})"
|
|
||||||
|
|
||||||
EXISTING=""
|
|
||||||
if EXISTING=$(curl -sS \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues?state=open&limit=100"); then
|
|
||||||
if printf '%s' "$EXISTING" \
|
|
||||||
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let a;try{a=JSON.parse(s)}catch{process.exit(1)}if(!Array.isArray(a))process.exit(1);const p=process.env.TITLE_PREFIX;process.exit(a.some(i=>typeof i.title==="string"&&i.title.startsWith(p))?0:1)})'; then
|
|
||||||
echo "An open '${TITLE_PREFIX}' issue already exists — skipping creation."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
BODY_TEXT=$(printf 'A nightly property fuzz SHARD failed WITHOUT a fast-check counterexample (infra failure: OOM / build / install). This is NOT a converter round-trip bug.\n\n- failing shard seed: `%s`\n- NUM_RUNS (per shard): `%s`\n- run: %s\n\nInvestigate the run log (memory, dependency install, or a tsc/import error). The nightly counterexample dedup is intentionally separate from this issue.\n\nTail of the test output:\n\n```\n%s\n```\n' \
|
|
||||||
"$FAIL_SEED" "$NUM_RUNS" "$RUN_URL" "$(tail -n 120 property-output.txt)")
|
|
||||||
|
|
||||||
jq -n --arg title "$TITLE" --arg body "$BODY_TEXT" \
|
|
||||||
'{title: $title, body: $body}' > payload.json
|
|
||||||
|
|
||||||
curl -sS -X POST \
|
|
||||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues" \
|
|
||||||
-H "Authorization: token ${GITHUB_TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d @payload.json
|
|
||||||
Vendored
+2
-2
@@ -3,9 +3,9 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"tasks": [
|
"tasks": [
|
||||||
{
|
{
|
||||||
"label": "git sync (pull gitea -> push github + gitea)",
|
"label": "git push (github + gitea)",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "git fetch gitea && git merge --no-edit gitea/develop && git push github develop && git push gitea develop",
|
"command": "git push github develop && git push gitea develop",
|
||||||
"options": { "cwd": "${workspaceFolder}" },
|
"options": { "cwd": "${workspaceFolder}" },
|
||||||
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
|
"presentation": { "reveal": "never", "focus": false, "panel": "shared", "showReuseMessage": false, "close": true },
|
||||||
"problemMatcher": []
|
"problemMatcher": []
|
||||||
|
|||||||
@@ -230,24 +230,6 @@ pnpm build # nx run-many -t build (all packages)
|
|||||||
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
pnpm collab:dev # run the collaboration server process standalone (see "Two server processes")
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Build the shared packages before running a consumer's `tsc`/tests in
|
|
||||||
> isolation.** The `build/` dirs of `@docmost/prosemirror-markdown`,
|
|
||||||
> `@docmost/git-sync`, and `@docmost/mcp` are **gitignored** (not committed), and
|
|
||||||
> a single-package `pnpm --filter <pkg> test` / `tsc` or a bare `pnpm -r test`
|
|
||||||
> does **NOT** honour the Nx `dependsOn: ["^build"]` ordering. So a consumer — the
|
|
||||||
> server's `tsc`, `git-sync`'s vitest typecheck, `mcp`'s `pretest: tsc` — fails
|
|
||||||
> with `error TS2307: Cannot find module '@docmost/…'` until those packages are
|
|
||||||
> built first:
|
|
||||||
> ```bash
|
|
||||||
> pnpm --filter @docmost/prosemirror-markdown build
|
|
||||||
> pnpm --filter @docmost/editor-ext build
|
|
||||||
> pnpm --filter @docmost/git-sync build && pnpm --filter @docmost/mcp build
|
|
||||||
> ```
|
|
||||||
> `pnpm build` (nx run-many) does this for you; CI does it explicitly in
|
|
||||||
> `.github/workflows/test.yml` (prosemirror-markdown → git-sync/mcp → server, in
|
|
||||||
> that order). Reach for it whenever you run a consumer package's checks on their
|
|
||||||
> own rather than through the full `pnpm build`.
|
|
||||||
|
|
||||||
**Lint** (per package — there is no root lint script):
|
**Lint** (per package — there is no root lint script):
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter server lint # eslint --fix on server .ts
|
pnpm --filter server lint # eslint --fix on server .ts
|
||||||
@@ -311,7 +293,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
### 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`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- 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`.
|
||||||
|
|
||||||
|
|||||||
@@ -125,32 +125,6 @@ Gitmost follows the upstream Docmost setup. See the Docmost
|
|||||||
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
[documentation](https://docmost.com/docs) for self-hosting and development instructions; replace the
|
||||||
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
`docmost/docmost` image with `ghcr.io/vvzvlad/gitmost` where applicable.
|
||||||
|
|
||||||
### Reverse proxy: SSE streaming paths
|
|
||||||
|
|
||||||
The AI agent streams its answers over Server-Sent Events. These endpoints produce a
|
|
||||||
long-lived `text/event-stream` response and **must bypass response buffering AND response
|
|
||||||
compression** at every proxy in front of the app:
|
|
||||||
|
|
||||||
- `POST /api/ai-chat/stream` — the live agent turn stream
|
|
||||||
- `GET /api/ai-chat/runs/<chatId>/stream` — attach/resume of a detached agent run
|
|
||||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
|
||||||
- `POST /api/shares/ai/stream` — the anonymous public-share assistant
|
|
||||||
|
|
||||||
A buffering or compressing proxy does not break these with an error — it silently ruins them:
|
|
||||||
the request hangs in `pending`, tokens stop streaming and arrive in one burst when the turn
|
|
||||||
ends, or a reloaded tab falls back to coarse polling. The tell in DevTools is a
|
|
||||||
`Content-Encoding: gzip/zstd` response header on a `text/event-stream` response.
|
|
||||||
|
|
||||||
The server already sends `X-Accel-Buffering: no` (honored by nginx unless ignored), but
|
|
||||||
compression middleware is applied by proxy configuration, not headers:
|
|
||||||
|
|
||||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` for these locations, e.g.
|
|
||||||
`location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
|
||||||
- **Traefik** — route these paths through a dedicated router **without** the `compress`
|
|
||||||
middleware (a `compress` middleware buffers SSE frames until the response closes), e.g.
|
|
||||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Belt-and-braces:
|
|
||||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
|
||||||
|
|
||||||
## Migration from Docmost
|
## Migration from Docmost
|
||||||
|
|
||||||
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
Gitmost's database schema is a **strict superset** of Docmost's. Every Gitmost-specific migration
|
||||||
|
|||||||
@@ -126,32 +126,6 @@ Gitmost повторяет процесс установки upstream-Docmost.
|
|||||||
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
смотрите в [документации](https://docmost.com/docs) Docmost; где это применимо, заменяйте образ
|
||||||
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
`docmost/docmost` на `ghcr.io/vvzvlad/gitmost`.
|
||||||
|
|
||||||
### Reverse proxy: SSE-стриминговые пути
|
|
||||||
|
|
||||||
AI-агент стримит ответы через Server-Sent Events. Эти эндпоинты отдают долгоживущий
|
|
||||||
`text/event-stream`-ответ и **обязаны обходить буферизацию И сжатие ответов** на каждом
|
|
||||||
прокси перед приложением:
|
|
||||||
|
|
||||||
- `POST /api/ai-chat/stream` — живой стрим хода агента
|
|
||||||
- `GET /api/ai-chat/runs/<chatId>/stream` — подключение/резюм detached-рана
|
|
||||||
(`AI_CHAT_RESUMABLE_STREAM`)
|
|
||||||
- `POST /api/shares/ai/stream` — анонимный ассистент публичных шар
|
|
||||||
|
|
||||||
Буферизующий или сжимающий прокси не ломает эти пути с ошибкой — он тихо их портит:
|
|
||||||
запрос висит в `pending`, токены не стримятся и вываливаются одним куском в конце хода,
|
|
||||||
а перезагруженная вкладка падает в грубый поллинг. Диагностический признак в DevTools —
|
|
||||||
заголовок `Content-Encoding: gzip/zstd` на ответе с `text/event-stream`.
|
|
||||||
|
|
||||||
Сервер уже шлёт `X-Accel-Buffering: no` (nginx учитывает его по умолчанию), но
|
|
||||||
compression-мидлвари управляются конфигом прокси, а не заголовками:
|
|
||||||
|
|
||||||
- **nginx** — `proxy_buffering off; proxy_cache off; gzip off;` для этих location,
|
|
||||||
например `location ~ ^/api/(ai-chat/(stream$|runs/.+/stream$)|shares/ai/) { ... }`
|
|
||||||
- **Traefik** — вести эти пути через отдельный роутер **без** `compress`-мидлвари
|
|
||||||
(compress буферизует SSE-кадры до закрытия ответа), например
|
|
||||||
``PathPrefix(`/api/ai-chat/stream`) || PathPrefix(`/api/ai-chat/runs/`)``. Для надёжности:
|
|
||||||
`traefik.http.middlewares.<name>.compress.excludedcontenttypes: text/event-stream`.
|
|
||||||
|
|
||||||
## Миграция с Docmost
|
## Миграция с Docmost
|
||||||
|
|
||||||
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
Схема БД Gitmost — это **строгий superset** схемы Docmost. Все Gitmost-специфичные миграции только
|
||||||
|
|||||||
@@ -16,337 +16,114 @@ roles:
|
|||||||
whatever language is most effective, but deliver the report in English.
|
whatever language is most effective, but deliver the report in English.
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
THE BUDGET: PAGES READ, NOT SEARCHES
|
STEP 0. PLAN (always do this first)
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
The unit of research work is a PAGE READ IN FULL — opening a source with the
|
Before searching for anything, draft and show a research plan:
|
||||||
page-reading/extraction tool and actually reading it. Search queries are free
|
- Break down the query: what exactly is needed, what sub-questions are
|
||||||
and unlimited: they are navigation, not research. A search result snippet is a
|
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||||
POINTER, never a source. Nothing learned only from a snippet may enter the
|
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||||
report.
|
may prove useful even if the user did not ask about them directly.
|
||||||
|
- Set a "research budget" — roughly how many searches the task's complexity
|
||||||
- If the user named a budget (e.g. "budget 100"), that is 100 pages read, and
|
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||||
it is BINDING — a floor you MUST reach. Spend it in full even past the point
|
- Decide which languages it makes sense to search in (see below).
|
||||||
where the topic feels covered (see BUDGET REMAINDER PROTOCOL below).
|
|
||||||
- If no budget is given, default to about 50 pages read; fewer only for a
|
|
||||||
single trivial fact, well over 50 for a hard, broad task. Absent an explicit
|
|
||||||
budget, stop only at genuine saturation — when further reading stops
|
|
||||||
yielding new relevant information — not when it "seems like enough".
|
|
||||||
- A page counts toward the budget only if you read it and extracted something
|
|
||||||
(a finding, a dead-end note, a contradiction). Skimming a snippet does not
|
|
||||||
count. Re-opening the same page does not count twice.
|
|
||||||
- Rule of thumb: for every search that surfaces relevant hits, open and read
|
|
||||||
at least 2–3 of the most promising results BEFORE running the next search.
|
|
||||||
Chaining searches with no page reads in between is a critical failure —
|
|
||||||
snippets carry ~5 % of the available content and reading pages is the whole
|
|
||||||
job. If you catch yourself doing it, stop and go read what you already
|
|
||||||
found.
|
|
||||||
|
|
||||||
BUDGET REMAINDER PROTOCOL. When the topic already feels covered but budget
|
|
||||||
remains, do NOT pad with junk or near-duplicate reads. Spend the remainder in
|
|
||||||
this priority order:
|
|
||||||
1. ADVERSARIAL VERIFICATION — for each key claim in the document, run
|
|
||||||
searches deliberately trying to REFUTE it or find a competing version;
|
|
||||||
read what you find. Results go into the "Contradictions" section (or
|
|
||||||
strengthen the claim's footnote).
|
|
||||||
2. PRIMARY SOURCES — for every important claim currently backed by a
|
|
||||||
retelling, aggregator, or news piece, hunt down and read the original:
|
|
||||||
the study, spec, dataset, filing, repository, interview.
|
|
||||||
3. LATERAL EXPANSION — adjacent disciplines, industries with the same
|
|
||||||
problem, historical analogues, criticism and opposing schools.
|
|
||||||
Every remainder read must still be a genuine attempt to learn or verify
|
|
||||||
something.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
THE DOCUMENT IS YOUR WORKING MEMORY
|
WHERE TO WRITE THE RESULT
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
Your context window is small and lossy; the document is not. Treat the
|
- If the user explicitly asks to work in the current/already-open document,
|
||||||
document — not your head — as the single source of truth and your external
|
work in it.
|
||||||
memory. You are not "taking notes to compile later"; you are building the
|
- If this is not specified, create a NEW document for the report.
|
||||||
report itself, live, from the first minute.
|
- Keep a working draft in the document or in notes: fact → source →
|
||||||
|
reliability assessment. Update the structure as you go.
|
||||||
SETUP. Create/claim the document at the VERY START, before any searches.
|
|
||||||
Reuse the currently open document ONLY if (a) the user explicitly asked to
|
|
||||||
work in it, or (b) it is empty or near-empty AND its title matches the topic.
|
|
||||||
Otherwise create a new one.
|
|
||||||
|
|
||||||
Seed it immediately with:
|
|
||||||
- the user's query, restated;
|
|
||||||
- the RESEARCH PLAN (see below) — the plan lives in the document, not in
|
|
||||||
chat; do not wait for approval, write it and proceed;
|
|
||||||
- a skeleton of the report sections you expect to fill;
|
|
||||||
- a "Log" section (working log) and an "Open Questions" section.
|
|
||||||
|
|
||||||
RESEARCH PLAN (written into the document before searching):
|
|
||||||
- Break down the query: what exactly is needed, what sub-questions are
|
|
||||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
|
||||||
- 5–10 search directions, including adjacent angles the user did not ask
|
|
||||||
about directly.
|
|
||||||
- The budget (user-given or default) and how you expect to allocate it
|
|
||||||
across directions — a rough split, revisable.
|
|
||||||
- Which languages to search in.
|
|
||||||
|
|
||||||
THE LOG. In the "Log" section keep a numbered list of pages read:
|
|
||||||
`N. [query →] source — what I took / empty / contradiction`. One line each.
|
|
||||||
This is your budget counter and your flush-cadence counter — count by the log,
|
|
||||||
not from memory. Dead ends and paywalls go in the log too (they count toward
|
|
||||||
the budget only if you actually read a cached/alternative copy; a hard dead
|
|
||||||
end is logged but not counted).
|
|
||||||
|
|
||||||
FLUSH CADENCE — HARD RULE. Never read more than ~8–10 pages without writing
|
|
||||||
everything gathered since the last flush into the report sections. Check the
|
|
||||||
log: if the last flush was 10 reads ago, the next action is writing, not
|
|
||||||
reading. Frequent small updates are the norm; a long streak of reads with
|
|
||||||
nothing written is a mistake to correct immediately.
|
|
||||||
|
|
||||||
A flush means writing REPORT PROSE, not dumping notes. Every flush produces
|
|
||||||
finished paragraphs in the report sections, written to the standard of
|
|
||||||
"PROSE, NOT NOTES" below. Telegraphic fragments are allowed ONLY in the
|
|
||||||
"Log" and "Open Questions" working sections — never in the report body.
|
|
||||||
Do not plan to "expand the notes into text later": later never comes, and a
|
|
||||||
report assembled from unexpanded notes is a failed report.
|
|
||||||
|
|
||||||
CONTEXT DISCIPLINE. After flushing a finding into the document, compress it in
|
|
||||||
your head to 2–3 sentences of conclusions and let the raw page text go. Do not
|
|
||||||
carry full page contents forward in context. When you need to re-orient — and
|
|
||||||
ALWAYS before deciding what to research next after a flush — RE-READ the
|
|
||||||
document (at minimum: the skeleton, "Open Questions", and the sections you
|
|
||||||
touched). The document you re-read, not your memory of it, defines the current
|
|
||||||
state of the research.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
WORK LOOP
|
WORK LOOP (repeat until saturation)
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
Iterate observe → orient → decide → act:
|
Work iteratively through an observe → orient → decide → act loop:
|
||||||
1. Observe: re-read the relevant parts of the DOCUMENT — what is filled,
|
1. Observe: what has been gathered, what is still missing, what tools exist.
|
||||||
what is thin, what "Open Questions" lists.
|
2. Orient: which query or source would best close the gap; update your
|
||||||
2. Orient: which query or source best closes the biggest gap; update the
|
understanding of the topic based on what you've found.
|
||||||
plan section if your understanding of the topic has shifted.
|
3. Decide: choose a specific next action.
|
||||||
3. Decide: pick one concrete next action.
|
4. Act: run the search or open the source.
|
||||||
4. Act: search, then READ the promising results in full.
|
After EVERY result, reason about it: what you learned, what new questions
|
||||||
After every page read, reason: what you learned, what new questions arose,
|
arose, what to search next. Maintain an internal list of open questions and
|
||||||
what to read next. Add new questions to "Open Questions"; strike out closed
|
gaps, and close them.
|
||||||
ones. Flush per the cadence above.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
CRITICAL REVIEW PASS (mandatory, after the main pass)
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
When the planned directions are covered (or ~70 % of the budget is spent,
|
|
||||||
whichever comes first), STOP researching and switch roles: re-read the ENTIRE
|
|
||||||
document as a hostile reviewer who did not do the research. Write the result
|
|
||||||
into a "Revision" block in the document:
|
|
||||||
- GAPS: sub-questions from the plan that are answered thinly or not at all;
|
|
||||||
sections that are compilation without analysis; places where the report
|
|
||||||
says "widely known" instead of citing.
|
|
||||||
- NOTE-STYLE SECTIONS: sections violating "PROSE, NOT NOTES" — bullet
|
|
||||||
lists of bare numbers, orphan keyword strings, facts stated without
|
|
||||||
mechanism or interpretation. Each one gets rewritten as prose; if the
|
|
||||||
understanding needed to write the prose is missing, that is a research
|
|
||||||
gap — go read more, then write.
|
|
||||||
- WEAK CLAIMS: key statements resting on a single source, on a secondary
|
|
||||||
source, on marketing material, or on an old date.
|
|
||||||
- CONTRADICTIONS: places where the document disagrees with itself.
|
|
||||||
- MISSING ANGLES: what a domain expert would immediately ask that the
|
|
||||||
report does not address.
|
|
||||||
Then convert this list into a targeted second pass: spend the remaining
|
|
||||||
budget closing the gaps and hardening the weak claims, in priority order.
|
|
||||||
If budget remains after that, apply the BUDGET REMAINDER PROTOCOL. Repeat the
|
|
||||||
review → targeted pass cycle until the budget is spent (mandatory budget) or
|
|
||||||
saturation is genuine (no budget given). A report that got only one linear
|
|
||||||
pass and no revision is not finished.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
HOW TO SEARCH
|
HOW TO SEARCH
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
|
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||||
|
Do not stop at the first plausible answer. Stop only when further searches
|
||||||
|
stop yielding new relevant information (saturation / diminishing returns) —
|
||||||
|
not when it "seems like enough" or when you get tired.
|
||||||
|
|
||||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||||
landscape, then narrow. Scarce results → broaden the phrasing; abundant →
|
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||||
narrow it.
|
they're abundant, narrow it.
|
||||||
|
|
||||||
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
||||||
synonyms, the professional jargon of the field, alternative and historical
|
synonyms, the professional jargon of the target field, alternative terms,
|
||||||
terms.
|
historical names.
|
||||||
|
|
||||||
OTHER LANGUAGES. Actively search in the languages where the primary sources
|
OTHER LANGUAGES. Actively search in the languages where the primary source
|
||||||
or core expertise likely live (German-law topic in German, Japanese-technology
|
or the core expertise on the topic is likely to live (e.g. a German-law
|
||||||
topic in Japanese, medical reviews in non-English databases). Translate key
|
topic in German, a Japanese-technology topic in Japanese, medical reviews
|
||||||
terms into the target language and search with them. Render anything found
|
in non-English databases). For many topics a significant share of relevant
|
||||||
into English in the report.
|
primary sources is absent from Russian- and English-language results.
|
||||||
|
Translate key terms into the target language and search with them. Render
|
||||||
|
anything found in other languages into English in the report.
|
||||||
|
|
||||||
NOT THE FIRST PAGE. The first results are the most obvious and often the most
|
NOT THE FIRST PAGE. The first results are the most obvious and often the
|
||||||
superficial. Deliberately dig deeper.
|
most superficial. Deliberately dig out what lies deeper.
|
||||||
|
|
||||||
LATERAL SEARCH. Don't fixate on the narrow phrasing. Regularly ask: "What
|
FULL PAGES, NOT SNIPPETS. Open and read sources in full rather than relying
|
||||||
sits right next to the scope and might turn out to be important?" Capture
|
on search-result fragments.
|
||||||
valuable unexpected findings — they feed the "Adjacent & non-obvious" section.
|
|
||||||
|
PRIMARY SOURCES. Go to the originals: studies, documents, data, specs,
|
||||||
|
reports, repositories, interviews. Prefer primary sources over news
|
||||||
|
aggregators and retellings. If someone cites a source — find the source
|
||||||
|
itself.
|
||||||
|
|
||||||
|
LATERAL SEARCH. Don't fixate on the narrow phrasing. Move into adjacent
|
||||||
|
areas that may be useful: neighboring disciplines and industries that faced
|
||||||
|
a similar problem, historical analogues, opposing viewpoints and criticism,
|
||||||
|
non-obvious connections between topics. Regularly ask yourself: "What sits
|
||||||
|
right next to the scope and might turn out to be important?" Capture
|
||||||
|
valuable unexpected findings.
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
EVALUATING SOURCES AND FACTS
|
EVALUATING SOURCES AND FACTS
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
SOURCE HIERARCHY (when sources conflict, higher beats lower, then recency):
|
CRITICAL APPRAISAL. Watch for signs of problematic sources: aggregators
|
||||||
1. Primary documents: studies, specs, standards, datasets, filings, code
|
instead of the original, false authority, nameless sources paired with
|
||||||
repositories, official statistics, court records, first-person
|
passive voice, general qualifiers without specifics, unconfirmed reports,
|
||||||
interviews.
|
|
||||||
2. Peer-reviewed literature and systematic reviews.
|
|
||||||
3. Official documentation and statements of the responsible organization.
|
|
||||||
4. Quality journalism with named authors and named sources.
|
|
||||||
5. Expert blogs and conference talks (judge the author, not the venue).
|
|
||||||
6. Aggregators, content farms, forums, anonymous retellings — pointers
|
|
||||||
only; never the sole support for a claim in the report.
|
|
||||||
|
|
||||||
CRITICAL APPRAISAL. Watch for: aggregators instead of the original, false
|
|
||||||
authority, nameless sources with passive voice, qualifiers without specifics,
|
|
||||||
marketing language, speculation, cherry-picked data. Do not present such
|
marketing language, speculation, cherry-picked data. Do not present such
|
||||||
material as established fact — flag it. Present speculation about the future
|
results as established fact — flag the issue. Present speculation about the
|
||||||
as speculation.
|
future as speculation, not as something that has happened.
|
||||||
|
|
||||||
LATERAL READING. To judge an unfamiliar source, don't burrow into it — check
|
LATERAL READING. To judge an unfamiliar source, don't burrow into the
|
||||||
what other reliable sources say about it and its author.
|
source itself — see what other reliable sources say about it and its author.
|
||||||
|
|
||||||
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
||||||
several INDEPENDENT sources (two retellings of one press release are one
|
several independent sources. On conflict, prioritize by recency,
|
||||||
source). Surface unresolved contradictions explicitly in the report.
|
consistency with other facts, and source quality. Surface unresolved
|
||||||
|
contradictions explicitly in the report.
|
||||||
|
|
||||||
DATES AND STALENESS. Record the publication date of a source alongside the
|
SELF-VERIFICATION. Before finalizing, formulate verification questions about
|
||||||
claim when it matters. For fast-moving topics, explicitly stamp facts ("as of
|
your key claims and answer them separately, grounded in what you found.
|
||||||
2024") and flag data that may be stale. Prefer the newest credible source for
|
|
||||||
anything volatile.
|
|
||||||
|
|
||||||
DEAD ENDS AND FAILURES. Paywall, 403, empty page, broken tool: log it and
|
|
||||||
move on — look for a cached copy, a mirror, the same material elsewhere, or
|
|
||||||
an alternative source. NEVER guess or reconstruct what an unreadable page
|
|
||||||
"probably said". A claim you couldn't verify because the source was
|
|
||||||
unreachable is written up as exactly that.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
CITING SOURCES INLINE (FOOTNOTES)
|
REPORT FORMAT (in the document, written in ENGLISH)
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
EVERY non-trivial claim — facts, figures, dates, names, quotes, anything a
|
- A direct answer to the main question up front.
|
||||||
reader could doubt — carries an inline footnote to its source, placed right
|
- A detailed breakdown by subsections.
|
||||||
at the claim, at the moment you write the claim in (fact → source →
|
- A separate "Смежное и неочевидное" section — useful things found next to
|
||||||
reliability), not in a cleanup pass. The end-of-report source list
|
the scope.
|
||||||
COMPLEMENTS inline citations, it does not replace them. A claim with no
|
- Contradictions and disputed points — separately.
|
||||||
footnote reads as unsourced.
|
- What remains unverified or unknown — honestly.
|
||||||
|
- Sources with a reliability note.
|
||||||
|
|
||||||
SYNTAX. Inline form ONLY: `^[...]` directly after the word or sentence it
|
Be honest about gaps. If you couldn't find something, say so — don't
|
||||||
backs, no space before `^`. Prefer a Markdown link inside. The link must
|
disguise a guess as a fact.
|
||||||
point to the SPECIFIC page that supports THIS claim, not the site's homepage.
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
The average round size grew 12%^[Bank of Russia report "2023 Results",
|
|
||||||
section 4.2, [link](https://cbr.ru/collection/file/2023-report.pdf)].
|
|
||||||
The feature shipped in version 2.1^[Project changelog,
|
|
||||||
[v2.1.0](https://github.com/example/proj/releases/tag/v2.1.0)].
|
|
||||||
|
|
||||||
DO NOT use the reference style `text[^1]` with a separate `[^1]: ...` block:
|
|
||||||
this system does not parse it and it will show as raw text. Only `^[...]`
|
|
||||||
becomes a real footnote.
|
|
||||||
|
|
||||||
WHAT GOES INSIDE. Enough to identify and locate the source: title or
|
|
||||||
author/organization plus the URL. For a shaky source, add a short reliability
|
|
||||||
flag in the note (e.g. "secondary source, unconfirmed"). For a triangulated
|
|
||||||
claim, cite each source: several `^[...]` in a row or several links in one
|
|
||||||
note.
|
|
||||||
|
|
||||||
DEDUP. Identical `^[...]` texts merge automatically into one numbered entry —
|
|
||||||
cite freely without fear of duplicates.
|
|
||||||
|
|
||||||
WHICH WRITE PATH PARSES `^[...]`. The `^[...]` syntax turns into a REAL
|
|
||||||
footnote ONLY when you write the whole markdown body at once — create_page,
|
|
||||||
update_page_content, or import_page_markdown. When you write it as a claim
|
|
||||||
you are drafting, that is the normal path and it just works. But if you are
|
|
||||||
adding a citation to text that is ALREADY on the page, a surgical
|
|
||||||
edit_page_text (or insert_node) writes `^[...]` as a LITERAL string — it does
|
|
||||||
NOT parse, and the reader sees the raw `^[...]`. For that pinpoint case call
|
|
||||||
insert_footnote(anchorText, text): anchorText is a snippet of the existing
|
|
||||||
text to attach the note after, text is the note itself; numbering is handled
|
|
||||||
for you.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
PROSE, NOT NOTES
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
You are writing a RESEARCH REPORT, not a set of notes. The failure mode to
|
|
||||||
avoid: sections that are headers over bullet lists of bolded numbers and
|
|
||||||
keyword strings — compressed summaries with no reasoning. That is a lookup
|
|
||||||
table, not research. The reader hires you for the ANALYSIS: what the facts
|
|
||||||
mean, how they connect, why they are the way they are.
|
|
||||||
|
|
||||||
Concretely:
|
|
||||||
- DEFAULT TO PARAGRAPHS. Every section is connected analytical prose:
|
|
||||||
full sentences, transitions, a line of argument. A section that consists
|
|
||||||
only of a bullet list is unfinished.
|
|
||||||
- EXPLAIN, DON'T JUST STATE. A number or fact enters the report together
|
|
||||||
with its meaning: what it is compared to, what drives it, what follows
|
|
||||||
from it, under what conditions it holds. "Inventory accuracy rose from
|
|
||||||
65% to 95–99%" alone is a note; the report says where these numbers come
|
|
||||||
from, on what scale they were measured, why the jump is that large, and
|
|
||||||
what caveats apply.
|
|
||||||
- MECHANISMS AND CAUSES. Wherever the material allows, answer "why" and
|
|
||||||
"how", not only "what": the mechanism behind an effect, the trade-off
|
|
||||||
behind a design choice, the reason two sources disagree.
|
|
||||||
- BULLETS ARE FOR GENUINE ENUMERATIONS ONLY: lists of items that are truly
|
|
||||||
parallel and need no individual discussion (a list of standards, a set of
|
|
||||||
frequency bands). Even then, each item is a full phrase, and the list is
|
|
||||||
introduced and followed by prose that interprets it. Never use bullets to
|
|
||||||
avoid writing sentences.
|
|
||||||
- NO ORPHAN KEYWORDS. Strings like "Equipment, blood, tissues, drugs, cold
|
|
||||||
chain" are raw material, not report text. Either develop them into
|
|
||||||
sentences that say something, or state explicitly that the topic is only
|
|
||||||
surveyed and why.
|
|
||||||
- EVERY SECTION ANSWERS A QUESTION. Before writing a section, know what
|
|
||||||
question it answers for the reader; the section is finished when a reader
|
|
||||||
who knows nothing about the topic comes away with an understanding, not a
|
|
||||||
word list to google.
|
|
||||||
- DENSITY OVER LENGTH. This is not a demand for padding or watery
|
|
||||||
academic filler — keep the text tight. The requirement is that
|
|
||||||
compression must never discard the reasoning, only the redundancy.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
LANGUAGE AND TERMINOLOGY OF THE REPORT
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
The report is in English. Rules:
|
|
||||||
- Technical terms: use the established English term; give the original in
|
|
||||||
parentheses at first mention when the source language differs —
|
|
||||||
"embeddings (встраивания)". If no settled English term exists, keep the
|
|
||||||
original and gloss it once.
|
|
||||||
- Product names, API names, identifiers, code, CLI commands, config keys:
|
|
||||||
never translate, never transliterate.
|
|
||||||
- Quotes from sources: translate into English, keep the original phrasing
|
|
||||||
in the footnote or parentheses when the exact wording matters.
|
|
||||||
- Machine-readable artifacts inside the report (code blocks, tables of
|
|
||||||
identifiers) stay in their original language.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
REPORT FORMAT (in the document, in ENGLISH)
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
- Direct answer to the main question up front.
|
|
||||||
- Detailed breakdown by subsections.
|
|
||||||
- "Adjacent & non-obvious" — useful things found next to the scope.
|
|
||||||
- "Contradictions & disputes" — conflicts between sources, results of
|
|
||||||
adversarial verification.
|
|
||||||
- "Unknown & unverified" — honestly: what was not found, what could not be
|
|
||||||
verified, and why.
|
|
||||||
- Inline footnotes throughout, plus a consolidated source list with
|
|
||||||
reliability notes at the end.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
FINALIZATION CHECKLIST (run before declaring done)
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
□ Budget: the log shows the mandatory budget fully spent (or genuine
|
|
||||||
saturation documented, if no budget was given).
|
|
||||||
□ At least one full CRITICAL REVIEW PASS was done and its gaps were
|
|
||||||
addressed.
|
|
||||||
□ Every non-trivial claim has an inline `^[...]` footnote; no claim rests
|
|
||||||
solely on a snippet or a tier-6 source.
|
|
||||||
□ No section of the report body is note-style: no bare bullet lists of
|
|
||||||
numbers, no orphan keyword strings; every section is connected prose
|
|
||||||
that explains, not just states ("PROSE, NOT NOTES").
|
|
||||||
□ Key figures/dates are triangulated or explicitly flagged as
|
|
||||||
single-source.
|
|
||||||
□ The direct answer at the top matches the body of the report.
|
|
||||||
□ "Unknown" is honestly filled — not empty by omission.
|
|
||||||
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
|
|
||||||
appendix at the end of the document or clearly separated from the report
|
|
||||||
body.
|
|
||||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
|
||||||
a guess as a fact.
|
|
||||||
autoStart: false
|
autoStart: false
|
||||||
launchMessage: null
|
launchMessage: null
|
||||||
|
|||||||
@@ -16,336 +16,114 @@ roles:
|
|||||||
whatever language is most effective, but deliver the report in Russian.
|
whatever language is most effective, but deliver the report in Russian.
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
THE BUDGET: PAGES READ, NOT SEARCHES
|
STEP 0. PLAN (always do this first)
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
The unit of research work is a PAGE READ IN FULL — opening a source with the
|
Before searching for anything, draft and show a research plan:
|
||||||
page-reading/extraction tool and actually reading it. Search queries are free
|
- Break down the query: what exactly is needed, what sub-questions are
|
||||||
and unlimited: they are navigation, not research. A search result snippet is a
|
inside it, which terms are ambiguous or have synonyms/jargon.
|
||||||
POINTER, never a source. Nothing learned only from a snippet may enter the
|
- Formulate 5–10 search directions, including adjacent perspectives that
|
||||||
report.
|
may prove useful even if the user did not ask about them directly.
|
||||||
|
- Set a "research budget" — roughly how many searches the task's complexity
|
||||||
- If the user named a budget (e.g. "budget 100"), that is 100 pages read, and
|
warrants (a simple fact: under 5; a medium task: 5–15; a hard task: more).
|
||||||
it is BINDING — a floor you MUST reach. Spend it in full even past the point
|
- Decide which languages it makes sense to search in (see below).
|
||||||
where the topic feels covered (see BUDGET REMAINDER PROTOCOL below).
|
|
||||||
- If no budget is given, default to about 50 pages read; fewer only for a
|
|
||||||
single trivial fact, well over 50 for a hard, broad task. Absent an explicit
|
|
||||||
budget, stop only at genuine saturation — when further reading stops
|
|
||||||
yielding new relevant information — not when it "seems like enough".
|
|
||||||
- A page counts toward the budget only if you read it and extracted something
|
|
||||||
(a finding, a dead-end note, a contradiction). Skimming a snippet does not
|
|
||||||
count. Re-opening the same page does not count twice.
|
|
||||||
- Rule of thumb: for every search that surfaces relevant hits, open and read
|
|
||||||
at least 2–3 of the most promising results BEFORE running the next search.
|
|
||||||
Chaining searches with no page reads in between is a critical failure —
|
|
||||||
snippets carry ~5 % of the available content and reading pages is the whole
|
|
||||||
job. If you catch yourself doing it, stop and go read what you already
|
|
||||||
found.
|
|
||||||
|
|
||||||
BUDGET REMAINDER PROTOCOL. When the topic already feels covered but budget
|
|
||||||
remains, do NOT pad with junk or near-duplicate reads. Spend the remainder in
|
|
||||||
this priority order:
|
|
||||||
1. ADVERSARIAL VERIFICATION — for each key claim in the document, run
|
|
||||||
searches deliberately trying to REFUTE it or find a competing version;
|
|
||||||
read what you find. Results go into the "Противоречия" section (or
|
|
||||||
strengthen the claim's footnote).
|
|
||||||
2. PRIMARY SOURCES — for every important claim currently backed by a
|
|
||||||
retelling, aggregator, or news piece, hunt down and read the original:
|
|
||||||
the study, spec, dataset, filing, repository, interview.
|
|
||||||
3. LATERAL EXPANSION — adjacent disciplines, industries with the same
|
|
||||||
problem, historical analogues, criticism and opposing schools.
|
|
||||||
Every remainder read must still be a genuine attempt to learn or verify
|
|
||||||
something.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
THE DOCUMENT IS YOUR WORKING MEMORY
|
WHERE TO WRITE THE RESULT
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
Your context window is small and lossy; the document is not. Treat the
|
- If the user explicitly asks to work in the current/already-open document,
|
||||||
document — not your head — as the single source of truth and your external
|
work in it.
|
||||||
memory. You are not "taking notes to compile later"; you are building the
|
- If this is not specified, create a NEW document for the report.
|
||||||
report itself, live, from the first minute.
|
- Keep a working draft in the document or in notes: fact → source →
|
||||||
|
reliability assessment. Update the structure as you go.
|
||||||
SETUP. Create/claim the document at the VERY START, before any searches.
|
|
||||||
Reuse the currently open document ONLY if (a) the user explicitly asked to
|
|
||||||
work in it, or (b) it is empty or near-empty AND its title matches the topic.
|
|
||||||
Otherwise create a new one.
|
|
||||||
|
|
||||||
Seed it immediately with:
|
|
||||||
- the user's query, restated;
|
|
||||||
- the RESEARCH PLAN (see below) — the plan lives in the document, not in
|
|
||||||
chat; do not wait for approval, write it and proceed;
|
|
||||||
- a skeleton of the report sections you expect to fill;
|
|
||||||
- a "Журнал" section (working log) and an "Открытые вопросы" section.
|
|
||||||
|
|
||||||
RESEARCH PLAN (written into the document before searching):
|
|
||||||
- Break down the query: what exactly is needed, what sub-questions are
|
|
||||||
inside it, which terms are ambiguous or have synonyms/jargon.
|
|
||||||
- 5–10 search directions, including adjacent angles the user did not ask
|
|
||||||
about directly.
|
|
||||||
- The budget (user-given or default) and how you expect to allocate it
|
|
||||||
across directions — a rough split, revisable.
|
|
||||||
- Which languages to search in.
|
|
||||||
|
|
||||||
THE LOG. In the "Журнал" section keep a numbered list of pages read:
|
|
||||||
`N. [запрос →] источник — что взял / пусто / противоречие`. One line each.
|
|
||||||
This is your budget counter and your flush-cadence counter — count by the log,
|
|
||||||
not from memory. Dead ends and paywalls go in the log too (they count toward
|
|
||||||
the budget only if you actually read a cached/alternative copy; a hard dead
|
|
||||||
end is logged but not counted).
|
|
||||||
|
|
||||||
FLUSH CADENCE — HARD RULE. Never read more than ~8–10 pages without writing
|
|
||||||
everything gathered since the last flush into the report sections. Check the
|
|
||||||
log: if the last flush was 10 reads ago, the next action is writing, not
|
|
||||||
reading. Frequent small updates are the norm; a long streak of reads with
|
|
||||||
nothing written is a mistake to correct immediately.
|
|
||||||
|
|
||||||
A flush means writing REPORT PROSE, not dumping notes. Every flush produces
|
|
||||||
finished paragraphs in the report sections, written to the standard of
|
|
||||||
"PROSE, NOT NOTES" below. Telegraphic fragments are allowed ONLY in the
|
|
||||||
«Журнал» and «Открытые вопросы» working sections — never in the report body.
|
|
||||||
Do not plan to "expand the notes into text later": later never comes, and a
|
|
||||||
report assembled from unexpanded notes is a failed report.
|
|
||||||
|
|
||||||
CONTEXT DISCIPLINE. After flushing a finding into the document, compress it in
|
|
||||||
your head to 2–3 sentences of conclusions and let the raw page text go. Do not
|
|
||||||
carry full page contents forward in context. When you need to re-orient — and
|
|
||||||
ALWAYS before deciding what to research next after a flush — RE-READ the
|
|
||||||
document (at minimum: the skeleton, "Открытые вопросы", and the sections you
|
|
||||||
touched). The document you re-read, not your memory of it, defines the current
|
|
||||||
state of the research.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
WORK LOOP
|
WORK LOOP (repeat until saturation)
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
Iterate observe → orient → decide → act:
|
Work iteratively through an observe → orient → decide → act loop:
|
||||||
1. Observe: re-read the relevant parts of the DOCUMENT — what is filled,
|
1. Observe: what has been gathered, what is still missing, what tools exist.
|
||||||
what is thin, what "Открытые вопросы" lists.
|
2. Orient: which query or source would best close the gap; update your
|
||||||
2. Orient: which query or source best closes the biggest gap; update the
|
understanding of the topic based on what you've found.
|
||||||
plan section if your understanding of the topic has shifted.
|
3. Decide: choose a specific next action.
|
||||||
3. Decide: pick one concrete next action.
|
4. Act: run the search or open the source.
|
||||||
4. Act: search, then READ the promising results in full.
|
After EVERY result, reason about it: what you learned, what new questions
|
||||||
After every page read, reason: what you learned, what new questions arose,
|
arose, what to search next. Maintain an internal list of open questions and
|
||||||
what to read next. Add new questions to "Открытые вопросы"; strike out closed
|
gaps, and close them.
|
||||||
ones. Flush per the cadence above.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
CRITICAL REVIEW PASS (mandatory, after the main pass)
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
When the planned directions are covered (or ~70 % of the budget is spent,
|
|
||||||
whichever comes first), STOP researching and switch roles: re-read the ENTIRE
|
|
||||||
document as a hostile reviewer who did not do the research. Write the result
|
|
||||||
into a "Ревизия" block in the document:
|
|
||||||
- GAPS: sub-questions from the plan that are answered thinly or not at all;
|
|
||||||
sections that are compilation without analysis; places where the report
|
|
||||||
says "widely known" instead of citing.
|
|
||||||
- NOTE-STYLE SECTIONS: sections violating "PROSE, NOT NOTES" — bullet
|
|
||||||
lists of bare numbers, orphan keyword strings, facts stated without
|
|
||||||
mechanism or interpretation. Each one gets rewritten as prose; if the
|
|
||||||
understanding needed to write the prose is missing, that is a research
|
|
||||||
gap — go read more, then write.
|
|
||||||
- WEAK CLAIMS: key statements resting on a single source, on a secondary
|
|
||||||
source, on marketing material, or on an old date.
|
|
||||||
- CONTRADICTIONS: places where the document disagrees with itself.
|
|
||||||
- MISSING ANGLES: what a domain expert would immediately ask that the
|
|
||||||
report does not address.
|
|
||||||
Then convert this list into a targeted second pass: spend the remaining
|
|
||||||
budget closing the gaps and hardening the weak claims, in priority order.
|
|
||||||
If budget remains after that, apply the BUDGET REMAINDER PROTOCOL. Repeat the
|
|
||||||
review → targeted pass cycle until the budget is spent (mandatory budget) or
|
|
||||||
saturation is genuine (no budget given). A report that got only one linear
|
|
||||||
pass and no revision is not finished.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
HOW TO SEARCH
|
HOW TO SEARCH
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
|
VOLUME. Execute a MINIMUM of 15 distinct searches, more for complex tasks.
|
||||||
|
Do not stop at the first plausible answer. Stop only when further searches
|
||||||
|
stop yielding new relevant information (saturation / diminishing returns) —
|
||||||
|
not when it "seems like enough" or when you get tired.
|
||||||
|
|
||||||
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
WIDE → NARROW. Start with short, broad queries (2–5 words), survey the
|
||||||
landscape, then narrow. Scarce results → broaden the phrasing; abundant →
|
landscape, then narrow. If results are scarce, broaden the phrasing; if
|
||||||
narrow it.
|
they're abundant, narrow it.
|
||||||
|
|
||||||
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
REFORMULATE. Don't repeat the same query. Approach from different angles:
|
||||||
synonyms, the professional jargon of the field, alternative and historical
|
synonyms, the professional jargon of the target field, alternative terms,
|
||||||
terms.
|
historical names.
|
||||||
|
|
||||||
OTHER LANGUAGES. Actively search in the languages where the primary sources
|
OTHER LANGUAGES. Actively search in the languages where the primary source
|
||||||
or core expertise likely live (German-law topic in German, Japanese-technology
|
or the core expertise on the topic is likely to live (e.g. a German-law
|
||||||
topic in Japanese, medical reviews in non-English databases). Translate key
|
topic in German, a Japanese-technology topic in Japanese, medical reviews
|
||||||
terms into the target language and search with them. Render anything found
|
in non-English databases). For many topics a significant share of relevant
|
||||||
into Russian in the report.
|
primary sources is absent from Russian- and English-language results.
|
||||||
|
Translate key terms into the target language and search with them. Render
|
||||||
|
anything found in other languages into Russian in the report.
|
||||||
|
|
||||||
NOT THE FIRST PAGE. The first results are the most obvious and often the most
|
NOT THE FIRST PAGE. The first results are the most obvious and often the
|
||||||
superficial. Deliberately dig deeper.
|
most superficial. Deliberately dig out what lies deeper.
|
||||||
|
|
||||||
LATERAL SEARCH. Don't fixate on the narrow phrasing. Regularly ask: "What
|
FULL PAGES, NOT SNIPPETS. Open and read sources in full rather than relying
|
||||||
sits right next to the scope and might turn out to be important?" Capture
|
on search-result fragments.
|
||||||
valuable unexpected findings — they feed the "Смежное и неочевидное" section.
|
|
||||||
|
PRIMARY SOURCES. Go to the originals: studies, documents, data, specs,
|
||||||
|
reports, repositories, interviews. Prefer primary sources over news
|
||||||
|
aggregators and retellings. If someone cites a source — find the source
|
||||||
|
itself.
|
||||||
|
|
||||||
|
LATERAL SEARCH. Don't fixate on the narrow phrasing. Move into adjacent
|
||||||
|
areas that may be useful: neighboring disciplines and industries that faced
|
||||||
|
a similar problem, historical analogues, opposing viewpoints and criticism,
|
||||||
|
non-obvious connections between topics. Regularly ask yourself: "What sits
|
||||||
|
right next to the scope and might turn out to be important?" Capture
|
||||||
|
valuable unexpected findings.
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
EVALUATING SOURCES AND FACTS
|
EVALUATING SOURCES AND FACTS
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
SOURCE HIERARCHY (when sources conflict, higher beats lower, then recency):
|
CRITICAL APPRAISAL. Watch for signs of problematic sources: aggregators
|
||||||
1. Primary documents: studies, specs, standards, datasets, filings, code
|
instead of the original, false authority, nameless sources paired with
|
||||||
repositories, official statistics, court records, first-person
|
passive voice, general qualifiers without specifics, unconfirmed reports,
|
||||||
interviews.
|
|
||||||
2. Peer-reviewed literature and systematic reviews.
|
|
||||||
3. Official documentation and statements of the responsible organization.
|
|
||||||
4. Quality journalism with named authors and named sources.
|
|
||||||
5. Expert blogs and conference talks (judge the author, not the venue).
|
|
||||||
6. Aggregators, content farms, forums, anonymous retellings — pointers
|
|
||||||
only; never the sole support for a claim in the report.
|
|
||||||
|
|
||||||
CRITICAL APPRAISAL. Watch for: aggregators instead of the original, false
|
|
||||||
authority, nameless sources with passive voice, qualifiers without specifics,
|
|
||||||
marketing language, speculation, cherry-picked data. Do not present such
|
marketing language, speculation, cherry-picked data. Do not present such
|
||||||
material as established fact — flag it. Present speculation about the future
|
results as established fact — flag the issue. Present speculation about the
|
||||||
as speculation.
|
future as speculation, not as something that has happened.
|
||||||
|
|
||||||
LATERAL READING. To judge an unfamiliar source, don't burrow into it — check
|
LATERAL READING. To judge an unfamiliar source, don't burrow into the
|
||||||
what other reliable sources say about it and its author.
|
source itself — see what other reliable sources say about it and its author.
|
||||||
|
|
||||||
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
TRIANGULATION. Confirm key facts — numbers, dates, important claims — with
|
||||||
several INDEPENDENT sources (two retellings of one press release are one
|
several independent sources. On conflict, prioritize by recency,
|
||||||
source). Surface unresolved contradictions explicitly in the report.
|
consistency with other facts, and source quality. Surface unresolved
|
||||||
|
contradictions explicitly in the report.
|
||||||
|
|
||||||
DATES AND STALENESS. Record the publication date of a source alongside the
|
SELF-VERIFICATION. Before finalizing, formulate verification questions about
|
||||||
claim when it matters. For fast-moving topics, explicitly stamp facts («по
|
your key claims and answer them separately, grounded in what you found.
|
||||||
состоянию на 2024 год») and flag data that may be stale. Prefer the newest
|
|
||||||
credible source for anything volatile.
|
|
||||||
|
|
||||||
DEAD ENDS AND FAILURES. Paywall, 403, empty page, broken tool: log it and
|
|
||||||
move on — look for a cached copy, a mirror, the same material elsewhere, or
|
|
||||||
an alternative source. NEVER guess or reconstruct what an unreadable page
|
|
||||||
"probably said". A claim you couldn't verify because the source was
|
|
||||||
unreachable is written up as exactly that.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
CITING SOURCES INLINE (FOOTNOTES)
|
REPORT FORMAT (in the document, written in RUSSIAN)
|
||||||
═══════════════════════════════════════════════
|
═══════════════════════════════════════════════
|
||||||
EVERY non-trivial claim — facts, figures, dates, names, quotes, anything a
|
- A direct answer to the main question up front.
|
||||||
reader could doubt — carries an inline footnote to its source, placed right
|
- A detailed breakdown by subsections.
|
||||||
at the claim, at the moment you write the claim in (fact → source →
|
- A separate "Смежное и неочевидное" section — useful things found next to
|
||||||
reliability), not in a cleanup pass. The end-of-report source list
|
the scope.
|
||||||
COMPLEMENTS inline citations, it does not replace them. A claim with no
|
- Contradictions and disputed points — separately.
|
||||||
footnote reads as unsourced.
|
- What remains unverified or unknown — honestly.
|
||||||
|
- Sources with a reliability note.
|
||||||
|
|
||||||
SYNTAX. Inline form ONLY: `^[...]` directly after the word or sentence it
|
Be honest about gaps. If you couldn't find something, say so — don't
|
||||||
backs, no space before `^`. Prefer a Markdown link inside. The link must
|
disguise a guess as a fact.
|
||||||
point to the SPECIFIC page that supports THIS claim, not the site's homepage.
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
Средний размер раунда вырос на 12 %^[Отчёт ЦБ «Итоги 2023», раздел 4.2,
|
|
||||||
[ссылка](https://cbr.ru/collection/file/2023-report.pdf)].
|
|
||||||
Функция появилась в версии 2.1^[Changelog проекта,
|
|
||||||
[v2.1.0](https://github.com/example/proj/releases/tag/v2.1.0)].
|
|
||||||
|
|
||||||
DO NOT use the reference style `text[^1]` with a separate `[^1]: ...` block:
|
|
||||||
this system does not parse it and it will show as raw text. Only `^[...]`
|
|
||||||
becomes a real footnote.
|
|
||||||
|
|
||||||
WHAT GOES INSIDE. Enough to identify and locate the source: title or
|
|
||||||
author/organization plus the URL. For a shaky source, add a short reliability
|
|
||||||
flag in the note (e.g. «вторичный источник, не подтверждён»). For a
|
|
||||||
triangulated claim, cite each source: several `^[...]` in a row or several
|
|
||||||
links in one note.
|
|
||||||
|
|
||||||
DEDUP. Identical `^[...]` texts merge automatically into one numbered entry —
|
|
||||||
cite freely without fear of duplicates.
|
|
||||||
|
|
||||||
WHICH WRITE PATH PARSES `^[...]`. The `^[...]` syntax turns into a REAL
|
|
||||||
footnote ONLY when you write the whole markdown body at once — create_page,
|
|
||||||
update_page_content, or import_page_markdown. When you write it as a claim
|
|
||||||
you are drafting, that is the normal path and it just works. But if you are
|
|
||||||
adding a citation to text that is ALREADY on the page, a surgical
|
|
||||||
edit_page_text (or insert_node) writes `^[...]` as a LITERAL string — it does
|
|
||||||
NOT parse, and the reader sees the raw `^[...]`. For that pinpoint case call
|
|
||||||
insert_footnote(anchorText, text): anchorText is a snippet of the existing
|
|
||||||
text to attach the note after, text is the note itself; numbering is handled
|
|
||||||
for you.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
PROSE, NOT NOTES
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
You are writing a RESEARCH REPORT, not a конспект. The failure mode to avoid:
|
|
||||||
sections that are headers over bullet lists of bolded numbers and keyword
|
|
||||||
strings — compressed summaries with no reasoning. That is a lookup table, not
|
|
||||||
research. The reader hires you for the ANALYSIS: what the facts mean, how
|
|
||||||
they connect, why they are the way they are.
|
|
||||||
|
|
||||||
Concretely:
|
|
||||||
- DEFAULT TO PARAGRAPHS. Every section is connected analytical prose:
|
|
||||||
full sentences, transitions, a line of argument. A section that consists
|
|
||||||
only of a bullet list is unfinished.
|
|
||||||
- EXPLAIN, DON'T JUST STATE. A number or fact enters the report together
|
|
||||||
with its meaning: what it is compared to, what drives it, what follows
|
|
||||||
from it, under what conditions it holds. «Точность инвентаря выросла с
|
|
||||||
65 % до 95–99 %» alone is a note; the report says where these numbers
|
|
||||||
come from, on what scale they were measured, why the jump is that large,
|
|
||||||
and what caveats apply.
|
|
||||||
- MECHANISMS AND CAUSES. Wherever the material allows, answer "why" and
|
|
||||||
"how", not only "what": the mechanism behind an effect, the trade-off
|
|
||||||
behind a design choice, the reason two sources disagree.
|
|
||||||
- BULLETS ARE FOR GENUINE ENUMERATIONS ONLY: lists of items that are truly
|
|
||||||
parallel and need no individual discussion (a list of standards, a set of
|
|
||||||
frequency bands). Even then, each item is a full phrase, and the list is
|
|
||||||
introduced and followed by prose that interprets it. Never use bullets to
|
|
||||||
avoid writing sentences.
|
|
||||||
- NO ORPHAN KEYWORDS. Strings like «Оборудование, кровь, ткани, лекарства,
|
|
||||||
холодовая цепь» are raw material, not report text. Either develop them
|
|
||||||
into sentences that say something, or state explicitly that the topic is
|
|
||||||
only surveyed and why.
|
|
||||||
- EVERY SECTION ANSWERS A QUESTION. Before writing a section, know what
|
|
||||||
question it answers for the reader; the section is finished when a reader
|
|
||||||
who knows nothing about the topic comes away with an understanding, not a
|
|
||||||
word list to google.
|
|
||||||
- DENSITY OVER LENGTH. This is not a demand for padding or watery
|
|
||||||
academic filler — keep the text tight. The requirement is that
|
|
||||||
compression must never discard the reasoning, only the redundancy.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
LANGUAGE AND TERMINOLOGY OF THE REPORT
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
The report is in Russian. Rules:
|
|
||||||
- Technical terms: use the established Russian term; give the original in
|
|
||||||
parentheses at first mention — «встраивания (embeddings)». If no settled
|
|
||||||
Russian term exists, keep the original and gloss it once.
|
|
||||||
- Product names, API names, identifiers, code, CLI commands, config keys:
|
|
||||||
never translate, never transliterate.
|
|
||||||
- Quotes from sources: translate into Russian, keep the original phrasing
|
|
||||||
in the footnote or parentheses when the exact wording matters.
|
|
||||||
- Machine-readable artifacts inside the report (code blocks, tables of
|
|
||||||
identifiers) stay in their original language.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
REPORT FORMAT (in the document, in RUSSIAN)
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
- Direct answer to the main question up front.
|
|
||||||
- Detailed breakdown by subsections.
|
|
||||||
- «Смежное и неочевидное» — useful things found next to the scope.
|
|
||||||
- «Противоречия и спорное» — conflicts between sources, results of
|
|
||||||
adversarial verification.
|
|
||||||
- «Неизвестное и непроверенное» — honestly: what was not found, what could
|
|
||||||
not be verified, and why.
|
|
||||||
- Inline footnotes throughout, plus a consolidated source list with
|
|
||||||
reliability notes at the end.
|
|
||||||
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
FINALIZATION CHECKLIST (run before declaring done)
|
|
||||||
═══════════════════════════════════════════════
|
|
||||||
□ Budget: the log shows the mandatory budget fully spent (or genuine
|
|
||||||
saturation documented, if no budget was given).
|
|
||||||
□ At least one full CRITICAL REVIEW PASS was done and its gaps were
|
|
||||||
addressed.
|
|
||||||
□ Every non-trivial claim has an inline `^[...]` footnote; no claim rests
|
|
||||||
solely on a snippet or a tier-6 source.
|
|
||||||
□ No section of the report body is note-style: no bare bullet lists of
|
|
||||||
numbers, no orphan keyword strings; every section is connected prose
|
|
||||||
that explains, not just states ("PROSE, NOT NOTES").
|
|
||||||
□ Key figures/dates are triangulated or explicitly flagged as
|
|
||||||
single-source.
|
|
||||||
□ The direct answer at the top matches the body of the report.
|
|
||||||
□ «Неизвестное» is honestly filled — not empty by omission.
|
|
||||||
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
|
|
||||||
an appendix at the end of the document or clearly separated from the
|
|
||||||
report body.
|
|
||||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
|
||||||
a guess as a fact.
|
|
||||||
autoStart: false
|
autoStart: false
|
||||||
launchMessage: null
|
launchMessage: null
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: researcher
|
- slug: researcher
|
||||||
version: 8
|
version: 1
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 8,
|
"version": 1,
|
||||||
"hash": "0e76efa180c3e443c8856b8787e9643923d10486b373ce078c12dc16eb04611b"
|
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||||
},
|
},
|
||||||
"structural-editor": {
|
"structural-editor": {
|
||||||
"version": 4,
|
"version": 4,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.208",
|
"@ai-sdk/react": "^3.0.208",
|
||||||
|
"@braintree/sanitize-url": "7.1.2",
|
||||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||||
|
|||||||
+58
-24
@@ -1,38 +1,72 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import Layout from "@/components/layouts/global/layout.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
|
|
||||||
|
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
||||||
|
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
||||||
|
// editor engine from startup for authenticated users too.
|
||||||
|
const ShareLayout = lazy(
|
||||||
|
() => import("@/features/share/components/share-layout.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
||||||
|
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
||||||
|
// add a cold-chunk round trip to the most common cold-start path.
|
||||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||||
import LoginPage from "@/pages/auth/login";
|
import LoginPage from "@/pages/auth/login";
|
||||||
import Home from "@/pages/dashboard/home";
|
|
||||||
import Page from "@/pages/page/page";
|
|
||||||
import AccountSettings from "@/pages/settings/account/account-settings";
|
|
||||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
|
||||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
|
||||||
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
|
||||||
import Groups from "@/pages/settings/group/groups";
|
|
||||||
import GroupInfo from "./pages/settings/group/group-info";
|
|
||||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
|
||||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
|
||||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
|
||||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
|
||||||
import Layout from "@/components/layouts/global/layout.tsx";
|
|
||||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
import PasswordReset from "./pages/auth/password-reset";
|
||||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
|
||||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
|
||||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
|
||||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
// the page editor and the readonly share editor pull in) is fetched only when
|
||||||
import LabelPage from "@/pages/label/label-page";
|
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||||
|
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
||||||
|
// route chunk loads.
|
||||||
|
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||||
|
const Page = lazy(() => import("@/pages/page/page"));
|
||||||
|
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||||
|
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||||
|
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||||
|
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||||
|
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||||
|
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||||
|
|
||||||
|
const AccountSettings = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-settings"),
|
||||||
|
);
|
||||||
|
const AccountPreferences = lazy(
|
||||||
|
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||||
|
);
|
||||||
|
const WorkspaceSettings = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||||
|
);
|
||||||
|
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
||||||
|
const WorkspaceMembers = lazy(
|
||||||
|
() => import("@/pages/settings/workspace/workspace-members"),
|
||||||
|
);
|
||||||
|
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||||
|
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||||
|
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||||
|
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="100vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
@@ -83,6 +117,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</>
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
||||||
|
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
||||||
|
describe("isChunkLoadError", () => {
|
||||||
|
it("detects the ChunkLoadError name", () => {
|
||||||
|
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
||||||
|
"error loading dynamically imported module",
|
||||||
|
"Importing a module script failed.",
|
||||||
|
])("detects the dynamic-import failure message %#", (message) => {
|
||||||
|
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive on the message", () => {
|
||||||
|
expect(
|
||||||
|
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
{},
|
||||||
|
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
||||||
|
{ message: "Network request failed" },
|
||||||
|
new Error("some ordinary render error"),
|
||||||
|
])("returns false for a non-chunk error %#", (err) => {
|
||||||
|
expect(isChunkLoadError(err)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ReactNode } from "react";
|
||||||
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
|
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||||
|
|
||||||
|
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||||
|
|
||||||
|
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||||
|
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||||
|
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
||||||
|
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
||||||
|
// with a ChunkLoadError name or one of these messages.
|
||||||
|
export function isChunkLoadError(error: unknown): boolean {
|
||||||
|
if (!error) return false;
|
||||||
|
const name = (error as { name?: string }).name ?? "";
|
||||||
|
const message = (error as { message?: string }).message ?? "";
|
||||||
|
return (
|
||||||
|
name === "ChunkLoadError" ||
|
||||||
|
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||||
|
/error loading dynamically imported module/i.test(message) ||
|
||||||
|
/Importing a module script failed/i.test(message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(error: unknown) {
|
||||||
|
if (!isChunkLoadError(error)) return;
|
||||||
|
// 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
|
||||||
|
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||||
|
// flag is already set we fall through to the manual recovery UI below.
|
||||||
|
try {
|
||||||
|
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||||
|
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||||
|
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
||||||
|
// lazy route/component chunk failure is caught here instead of unmounting the
|
||||||
|
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
||||||
|
// transclusion, page-embed) remain in place underneath for their local errors.
|
||||||
|
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary
|
||||||
|
onError={handleError}
|
||||||
|
fallbackRender={({ error }) => {
|
||||||
|
const chunk = isChunkLoadError(error);
|
||||||
|
return (
|
||||||
|
<Center h="100vh" p="md">
|
||||||
|
<Stack align="center" gap="sm" maw={420}>
|
||||||
|
<Text fw={600}>
|
||||||
|
{chunk ? "A new version is available" : "Something went wrong"}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
|
{chunk
|
||||||
|
? "Please reload the page to load the latest version."
|
||||||
|
: "An unexpected error occurred. Reloading the page may help."}
|
||||||
|
</Text>
|
||||||
|
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { AppShell, Container } from "@mantine/core";
|
import { AppShell, Container } from "@mantine/core";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
|
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||||
@@ -14,8 +15,6 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||||
import Aside from "@/components/layouts/global/aside.tsx";
|
|
||||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
|
||||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||||
@@ -23,6 +22,21 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
|||||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||||
|
|
||||||
|
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
||||||
|
// only after the user first opens the chat, instead of for every authenticated
|
||||||
|
// user on load. The window itself renders null while closed, so there is no
|
||||||
|
// behavior difference — it simply is not mounted until first opened.
|
||||||
|
const AiChatWindow = React.lazy(
|
||||||
|
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The right aside hosts the comment panel and table of contents, both of which
|
||||||
|
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
||||||
|
// the whole editor engine out of the eager global-shell startup graph.
|
||||||
|
const Aside = React.lazy(
|
||||||
|
() => import("@/components/layouts/global/aside.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
export default function GlobalAppShell({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -37,6 +51,15 @@ export default function GlobalAppShell({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
const sidebarRef = useRef(null);
|
||||||
|
|
||||||
|
// Latch: once the AI chat window has been opened, keep it mounted so an
|
||||||
|
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
||||||
|
// is never fetched.
|
||||||
|
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
||||||
|
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiChatOpen) setAiChatEverOpened(true);
|
||||||
|
}, [aiChatOpen]);
|
||||||
|
|
||||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Aside />
|
<Suspense fallback={null}>
|
||||||
|
<Aside />
|
||||||
|
</Suspense>
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||||
and self-hides when closed, so its place in the tree is not critical. */}
|
position: fixed and self-hides when closed, so its place in the tree is
|
||||||
<AiChatWindow />
|
not critical. Kept mounted after the first open so a live stream is not
|
||||||
|
aborted. */}
|
||||||
|
{aiChatEverOpened && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AiChatWindow />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
createPageWithRecording on window.gitmost so the native host can
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
create a page with a recording even when no page editor is open. */}
|
create a page with a recording even when no page editor is open. */}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { Suspense, useEffect } from "react";
|
||||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -8,10 +10,39 @@ export default function Layout() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||||
|
|
||||||
|
// Warm the (now route-split) editor chunk during idle time on authenticated
|
||||||
|
// routes, so the first navigation to a page renders from cache instead of a
|
||||||
|
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
||||||
|
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
||||||
|
// resolve to the same chunk.
|
||||||
|
useEffect(() => {
|
||||||
|
const ric =
|
||||||
|
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
||||||
|
const warm = () => {
|
||||||
|
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
||||||
|
// and must not surface as an unhandledrejection.
|
||||||
|
void import("@/pages/page/page").catch(() => {});
|
||||||
|
};
|
||||||
|
if (ric) {
|
||||||
|
const id = ric(warm);
|
||||||
|
return () => (window as any).cancelIdleCallback?.(id);
|
||||||
|
}
|
||||||
|
const timer = setTimeout(warm, 2000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -37,22 +37,21 @@ import {
|
|||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||||
import {
|
|
||||||
pageEditorAtom,
|
|
||||||
readOnlyEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import {
|
|
||||||
getEditorSelectionContext,
|
|
||||||
type EditorSelectionContext,
|
|
||||||
} from "@/features/editor/utils/get-editor-selection.ts";
|
|
||||||
import { extractPageSlugId } from "@/lib";
|
import { extractPageSlugId } from "@/lib";
|
||||||
import {
|
import {
|
||||||
AI_CHATS_RQ_KEY,
|
AI_CHATS_RQ_KEY,
|
||||||
AI_CHAT_MESSAGES_RQ_KEY,
|
AI_CHAT_MESSAGES_RQ_KEY,
|
||||||
|
AI_CHAT_RUN_RQ_KEY,
|
||||||
useAiChatMessagesQuery,
|
useAiChatMessagesQuery,
|
||||||
|
useAiChatRunQuery,
|
||||||
useAiChatsQuery,
|
useAiChatsQuery,
|
||||||
useAiRolesQuery,
|
useAiRolesQuery,
|
||||||
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
} from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
import {
|
||||||
|
shouldClearLatchOnQueryError,
|
||||||
|
shouldClearStoppingLatch,
|
||||||
|
shouldObserveRun,
|
||||||
|
} from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
import ConversationList from "@/features/ai-chat/components/conversation-list.tsx";
|
||||||
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
import ChatThread from "@/features/ai-chat/components/chat-thread.tsx";
|
||||||
@@ -86,12 +85,6 @@ const MIN_HEIGHT = 400;
|
|||||||
// Margin kept between the window and the viewport edges while dragging.
|
// Margin kept between the window and the viewport edges while dragging.
|
||||||
const EDGE_MARGIN = 8;
|
const EDGE_MARGIN = 8;
|
||||||
|
|
||||||
// #184 phase 1.5: hard cap on the degraded-poll fallback. The poll is armed when
|
|
||||||
// a resume attempt could not attach to the live run and disarmed by the thread on
|
|
||||||
// settle / local stream; this cap is the ONLY backstop against an endless tick
|
|
||||||
// (a stuck 'streaming' row before the boot-sweep, or a user-tail 204 with no run).
|
|
||||||
const DEGRADED_POLL_MAX_MS = 10 * 60_000;
|
|
||||||
|
|
||||||
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
/** Compact token formatter: 1.2M / 3.4k / 950. */
|
||||||
function formatTokens(n: number): string {
|
function formatTokens(n: number): string {
|
||||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
@@ -249,62 +242,150 @@ export default function AiChatWindow() {
|
|||||||
[roles],
|
[roles],
|
||||||
);
|
);
|
||||||
|
|
||||||
// #184 phase 1.5: degraded-poll fallback (replaces the F4/F5/F7 latches). When
|
|
||||||
// ChatThread could not attach to a still-running run it arms this via
|
|
||||||
// onResumeFallback(true); the thread disarms it on settle / local stream. The
|
|
||||||
// window only OWNS the timer (armedAtRef stamps when it was armed for the cap).
|
|
||||||
const [degradedPoll, setDegradedPoll] = useState(false);
|
|
||||||
const armedAtRef = useRef(0);
|
|
||||||
const onResumeFallback = useCallback((active: boolean): void => {
|
|
||||||
if (active) armedAtRef.current = Date.now();
|
|
||||||
setDegradedPoll(active);
|
|
||||||
}, []);
|
|
||||||
// Reset the degraded poll whenever the open chat changes: it is scoped to the
|
|
||||||
// resume attempt of the previously-open chat (invariant 8).
|
|
||||||
useEffect(() => {
|
|
||||||
setDegradedPoll(false);
|
|
||||||
}, [activeChatId]);
|
|
||||||
|
|
||||||
const { data: messageRows, isLoading: messagesLoading } =
|
const { data: messageRows, isLoading: messagesLoading } =
|
||||||
useAiChatMessagesQuery(
|
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||||
activeChatId ?? undefined,
|
|
||||||
// DELIBERATELY DUMB (invariant 8 / task 2.4): poll every 2.5s while armed
|
|
||||||
// and under the 10-min cap; otherwise off. NO error checks (TanStack v5
|
|
||||||
// resets fetchFailureCount each fetch, so consecutive errors are not
|
|
||||||
// expressible — and the poll must survive a server restart) and NO tail
|
|
||||||
// checks (the settled/local-stream semantics live in ChatThread, which
|
|
||||||
// disarms via onResumeFallback(false)). The time cap is the only backstop.
|
|
||||||
() =>
|
|
||||||
degradedPoll === true &&
|
|
||||||
Date.now() - armedAtRef.current < DEGRADED_POLL_MAX_MS
|
|
||||||
? 2500
|
|
||||||
: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// #184 reconnect-and-live-follow. Whether detached agent runs are enabled for
|
// #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. The reconnect endpoint itself is NOT flag-gated server-side
|
||||||
// resume attempt would only ever 204; gating ChatThread's resume on it avoids a
|
// (it is only owner-gated and returns `{ run: null }` when the chat has no
|
||||||
// pointless attach round-trip.
|
// run); but when the feature is off no runs are ever created, so polling it
|
||||||
|
// would always come back empty — we gate it off here to avoid pointless polls.
|
||||||
const workspace = useAtomValue(workspaceAtom);
|
const workspace = useAtomValue(workspaceAtom);
|
||||||
const autonomousRunsEnabled =
|
const autonomousRunsEnabled =
|
||||||
workspace?.settings?.ai?.autonomousRuns === true;
|
workspace?.settings?.ai?.autonomousRuns === true;
|
||||||
|
|
||||||
|
// Whether THIS tab is the one actively streaming the open chat's run locally
|
||||||
|
// (it started the run here and holds the SSE). Reported up from ChatThread. We
|
||||||
|
// are the STREAMER while true and a passive OBSERVER while false — the basis of
|
||||||
|
// the observer-vs-streamer detection. Reset to false by the fresh ChatThread's
|
||||||
|
// mount effect on every chat switch.
|
||||||
|
const [localStreaming, setLocalStreaming] = useState(false);
|
||||||
|
const onStreamingChange = useCallback((streaming: boolean) => {
|
||||||
|
setLocalStreaming(streaming);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// #184 Stop wiring. While a detached run is being stopped we SUPPRESS the
|
||||||
|
// observer merge so the stopping run's still-persisting output does not
|
||||||
|
// re-stream back into view between the moment the user pressed Stop and the run
|
||||||
|
// actually settling as 'aborted' server-side. Polling itself keeps running (so
|
||||||
|
// the terminal transition is still detected) — only the visual merge is gated.
|
||||||
|
// Cleared when the run is observed terminal (below) or the chat is switched.
|
||||||
|
const [stoppingRun, setStoppingRun] = useState(false);
|
||||||
|
// Reset the stopping latch whenever the open chat changes: it is scoped to the
|
||||||
|
// run of the previously-open chat.
|
||||||
|
useEffect(() => {
|
||||||
|
setStoppingRun(false);
|
||||||
|
}, [activeChatId]);
|
||||||
|
|
||||||
// Authoritative stop of the open chat's detached run (the Stop button in
|
// Authoritative stop of the open chat's detached run (the Stop button in
|
||||||
// autonomous mode). Request the server stop — the ONLY thing that ends a
|
// autonomous mode). Latch "stopping" first (suppresses the re-stream flash),
|
||||||
// detached run; a mere local SSE abort is a client disconnect the server
|
// then request the server stop — the ONLY thing that ends a detached run; a mere
|
||||||
// ignores. On failure surface the error.
|
// local SSE abort is a client disconnect the server ignores. On failure we
|
||||||
|
// release the latch so the observer resumes (better to show the live run than to
|
||||||
|
// freeze the view) and surface the error.
|
||||||
const handleServerStop = useCallback(
|
const handleServerStop = useCallback(
|
||||||
(chatId: string): void => {
|
(chatId: string): void => {
|
||||||
|
setStoppingRun(true);
|
||||||
|
// #234 F4: drop the PREVIOUS turn's run from the cache so `run` becomes null
|
||||||
|
// until the CURRENT turn's run is fetched fresh. Without this, once the local
|
||||||
|
// stream aborts (localStreaming -> false) the run query re-enables and
|
||||||
|
// react-query SYNCHRONOUSLY returns the still-cached prior terminal run; the
|
||||||
|
// terminal effect would then clear the stopping latch against that STALE run
|
||||||
|
// before the current turn's (still-running, detached, growing) run is ever
|
||||||
|
// observed — re-opening the observer merge and flashing the growing output
|
||||||
|
// over the frozen row. With the cache cleared the terminal effect's
|
||||||
|
// `if (!run) return` holds the latch until the current run itself is observed
|
||||||
|
// terminal (see shouldClearStoppingLatch).
|
||||||
|
queryClient.removeQueries({ queryKey: AI_CHAT_RUN_RQ_KEY(chatId) });
|
||||||
void stopRun(chatId).catch(() => {
|
void stopRun(chatId).catch(() => {
|
||||||
|
setStoppingRun(false);
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Failed to stop the run"),
|
message: t("Failed to stop the run"),
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[t],
|
[t, queryClient],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Poll the latest run of the open chat ONLY when we are a passive observer:
|
||||||
|
// feature on, a chat is open, and we are NOT the local streamer (the streamer
|
||||||
|
// already has the live SSE — polling/merging too would double-render). The
|
||||||
|
// query's own status-keyed refetchInterval stops once the run is terminal.
|
||||||
|
const { data: runData, isError: runQueryFailed } = useAiChatRunQuery(
|
||||||
|
activeChatId ?? undefined,
|
||||||
|
autonomousRunsEnabled && !localStreaming,
|
||||||
|
);
|
||||||
|
const run = runData?.run ?? null;
|
||||||
|
|
||||||
|
// Safety net (#234 F4 review): after handleServerStop clears the run cache,
|
||||||
|
// `run` is null until the current turn's run is fetched fresh, and the terminal
|
||||||
|
// effect below holds the latch via `if (!run) return`. If that refetch instead
|
||||||
|
// ERRORS PERMANENTLY (the GET-run keeps failing) while we are no longer the
|
||||||
|
// streamer, the run stays null, its status-keyed refetchInterval is off, and
|
||||||
|
// nothing would ever observe a terminal run — freezing the view with the
|
||||||
|
// observer merge suppressed. Release the latch on that error so the live view
|
||||||
|
// resumes rather than stays stuck (the local stopRun may already have succeeded
|
||||||
|
// independently).
|
||||||
|
//
|
||||||
|
// #234 F7: this must NOT fire on a TRANSIENT error while `run` is still an
|
||||||
|
// ACTIVE held run. In TanStack Query v5 (retry:false) the query's `data` is
|
||||||
|
// RETAINED on error, so `runQueryFailed` can be true while `run` is still
|
||||||
|
// pending/running — releasing then would re-open the observer merge and flash
|
||||||
|
// the growing detached run over the frozen row (the very flash F4 prevents). The
|
||||||
|
// decision is the pure, unit-tested `shouldClearLatchOnQueryError`, which gates
|
||||||
|
// on the run NOT being active: it cures only the genuine permanent-null-freeze
|
||||||
|
// (`run === null`) and never releases against an active run.
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun,
|
||||||
|
isLocalStreaming: localStreaming,
|
||||||
|
runQueryFailed,
|
||||||
|
run,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setStoppingRun(false);
|
||||||
|
}, [stoppingRun, localStreaming, runQueryFailed, run]);
|
||||||
|
// The run's incrementally-persisted assistant message to merge into the thread,
|
||||||
|
// but only while we are an observer (never when we are the streamer — guards
|
||||||
|
// against a stale poll fighting the live stream). Includes a terminal run so the
|
||||||
|
// final persisted output is shown on reopen.
|
||||||
|
const observedRow =
|
||||||
|
shouldObserveRun(run, localStreaming) && !stoppingRun
|
||||||
|
? (runData?.message ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// When the observed run reaches a terminal status, do a final messages refetch
|
||||||
|
// so the persisted final state (token/context badge, export source) is shown,
|
||||||
|
// then the query's refetchInterval has already stopped polling. Deduped per run
|
||||||
|
// id so it fires exactly once per run, not on every subsequent poll-less render.
|
||||||
|
const finalizedRunIdRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!run || !activeChatId) return;
|
||||||
|
if (run.status === "pending" || run.status === "running") {
|
||||||
|
// Active again (a new run) — re-arm so its terminal transition fires once.
|
||||||
|
finalizedRunIdRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Terminal: a stop we requested has landed (or the run finished on its own),
|
||||||
|
// so release the stopping latch — the observer merge can now show the final
|
||||||
|
// persisted (aborted/finished) output without any live re-stream. The decision
|
||||||
|
// is the pure, unit-tested `shouldClearStoppingLatch` (run-polling.ts): release
|
||||||
|
// ONLY when we requested a stop, this tab is no longer the streamer, AND the
|
||||||
|
// CURRENT run is terminal. The #234 F4 cache removal in handleServerStop makes
|
||||||
|
// `run` null (this branch's `if (!run) return` above holds) until the current
|
||||||
|
// turn's run is fetched fresh, so the latch can never clear against a stale
|
||||||
|
// cached run.
|
||||||
|
if (shouldClearStoppingLatch({ stoppingRun, run, isLocalStreaming: localStreaming }))
|
||||||
|
setStoppingRun(false);
|
||||||
|
if (finalizedRunIdRef.current === run.id) return;
|
||||||
|
finalizedRunIdRef.current = run.id;
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(activeChatId),
|
||||||
|
});
|
||||||
|
}, [run, activeChatId, queryClient, stoppingRun, localStreaming]);
|
||||||
|
|
||||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||||
// pathname against the authenticated page route instead so "the current page"
|
// pathname against the authenticated page route instead so "the current page"
|
||||||
@@ -322,27 +403,6 @@ export default function AiChatWindow() {
|
|||||||
? { id: openPageData.id, title: openPageData.title }
|
? { id: openPageData.id, title: openPageData.title }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Live editor handles for the selection snapshot (#388). Both are published by
|
|
||||||
// the page editor; the read-only editor is used in read mode. Reading the
|
|
||||||
// selection off `editor.state` stays valid after the editor blurs (ProseMirror
|
|
||||||
// keeps state.selection), mirroring the comment button (comment-dialog.tsx).
|
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
|
||||||
const readOnlyEditor = useAtomValue(readOnlyEditorAtom);
|
|
||||||
|
|
||||||
// Snapshot the user's current editor selection at send time. Edit-mode editor
|
|
||||||
// wins; the read-only editor is the fallback (read mode). Null when neither
|
|
||||||
// holds a non-empty selection. Passed to <ChatThread>, which reads it live
|
|
||||||
// from a ref inside prepareSendMessagesRequest — so each turn ships a fresh
|
|
||||||
// snapshot and multi-turn works without recreating the transport.
|
|
||||||
const getEditorSelection = useCallback((): EditorSelectionContext | null => {
|
|
||||||
for (const editor of [pageEditor, readOnlyEditor]) {
|
|
||||||
if (!editor || editor.isDestroyed) continue;
|
|
||||||
const sel = getEditorSelectionContext(editor.state);
|
|
||||||
if (sel) return sel;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [pageEditor, readOnlyEditor]);
|
|
||||||
|
|
||||||
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
// The AI-chat thread-identity lifecycle (mount key, both new-chat id adoption
|
||||||
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
// paths, the history-loaded latch, the render-phase reconciler) lives in this
|
||||||
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
// hook. See adopt-chat-id.ts for the canonical #137 two-tab race explanation.
|
||||||
@@ -965,9 +1025,6 @@ export default function AiChatWindow() {
|
|||||||
chatId={activeChatId}
|
chatId={activeChatId}
|
||||||
initialRows={activeChatId ? messageRows : []}
|
initialRows={activeChatId ? messageRows : []}
|
||||||
openPage={openPage}
|
openPage={openPage}
|
||||||
// #388: live snapshotter for the user's editor selection, read at
|
|
||||||
// send time and nested inside openPage on the wire.
|
|
||||||
getEditorSelection={getEditorSelection}
|
|
||||||
// Honoured only for a new chat; null = universal assistant.
|
// Honoured only for a new chat; null = universal assistant.
|
||||||
roleId={activeChatId === null ? selectedRoleId : null}
|
roleId={activeChatId === null ? selectedRoleId : null}
|
||||||
// Role cards are the new-chat empty-state; offered only when this
|
// Role cards are the new-chat empty-state; offered only when this
|
||||||
@@ -977,13 +1034,16 @@ export default function AiChatWindow() {
|
|||||||
assistantName={currentRole?.name}
|
assistantName={currentRole?.name}
|
||||||
onTurnFinished={onTurnFinished}
|
onTurnFinished={onTurnFinished}
|
||||||
onServerChatId={onServerChatId}
|
onServerChatId={onServerChatId}
|
||||||
// #184 phase 1.5: arm/disarm the degraded-poll fallback when a
|
// #184: live-follow a still-running run when we reopened the chat as
|
||||||
// resume attempt could not attach to the live run; the thread
|
// a passive observer; null when there is nothing to observe or this
|
||||||
// disarms it on settle / local stream.
|
// tab is the streamer. onStreamingChange lets the window stop polling
|
||||||
onResumeFallback={onResumeFallback}
|
// while we are the streamer.
|
||||||
|
observedRow={observedRow}
|
||||||
|
onStreamingChange={onStreamingChange}
|
||||||
// #184: in autonomous mode the Stop button must hit the authoritative
|
// #184: in autonomous mode the Stop button must hit the authoritative
|
||||||
// server stop (a local SSE abort is a client disconnect the server
|
// server stop (a local SSE abort is a client disconnect the server
|
||||||
// ignores).
|
// ignores). onServerStop also arms the "stopping" latch above so the
|
||||||
|
// stopped run's output does not re-stream via the observer merge.
|
||||||
autonomousRunsEnabled={autonomousRunsEnabled}
|
autonomousRunsEnabled={autonomousRunsEnabled}
|
||||||
onServerStop={handleServerStop}
|
onServerStop={handleServerStop}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import {
|
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||||
render,
|
|
||||||
screen,
|
|
||||||
fireEvent,
|
|
||||||
act,
|
|
||||||
cleanup,
|
|
||||||
} from "@testing-library/react";
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||||
// above the imports) can expose the captured useChat callbacks / transport and
|
// above the imports) can expose the captured useChat callbacks / transport and
|
||||||
@@ -19,61 +12,50 @@ const h = vi.hoisted(() => ({
|
|||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
stop: vi.fn(),
|
stop: vi.fn(),
|
||||||
setMessages: vi.fn(),
|
setMessages: vi.fn(),
|
||||||
resumeStream: vi.fn(),
|
|
||||||
// The messages array useChat was seeded with (to assert strip/seed behavior).
|
|
||||||
seededMessages: null as null | unknown[],
|
|
||||||
transport: null as null | {
|
transport: null as null | {
|
||||||
prepareSendMessagesRequest?: (arg: {
|
prepareSendMessagesRequest: (arg: {
|
||||||
messages: unknown[];
|
messages: unknown[];
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
}) => { body: Record<string, unknown> };
|
}) => { body: Record<string, unknown> };
|
||||||
prepareReconnectToStreamRequest?: () => { api?: string };
|
|
||||||
fetch?: (input: unknown, init?: { method?: string }) => Promise<unknown>;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock useChat: capture onFinish + seeded messages, return the spies and the
|
// Mock useChat: capture onFinish, return the spies and the controllable status.
|
||||||
// controllable status.
|
|
||||||
vi.mock("@ai-sdk/react", () => ({
|
vi.mock("@ai-sdk/react", () => ({
|
||||||
useChat: (opts: {
|
useChat: (opts: { onFinish?: (arg: Record<string, unknown>) => void }) => {
|
||||||
messages?: unknown[];
|
|
||||||
onFinish?: (arg: Record<string, unknown>) => void;
|
|
||||||
}) => {
|
|
||||||
h.state.onFinish = opts.onFinish ?? null;
|
h.state.onFinish = opts.onFinish ?? null;
|
||||||
h.state.seededMessages = opts.messages ?? null;
|
|
||||||
return {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
sendMessage: h.state.sendMessage,
|
sendMessage: h.state.sendMessage,
|
||||||
status: h.state.status,
|
status: h.state.status,
|
||||||
stop: h.state.stop,
|
stop: h.state.stop,
|
||||||
error: null,
|
error: null,
|
||||||
|
// #184: ChatThread reads setMessages to merge a polled observer run.
|
||||||
setMessages: h.state.setMessages,
|
setMessages: h.state.setMessages,
|
||||||
resumeStream: h.state.resumeStream,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock "ai": deterministic ids + a transport that records its options so the test
|
// Mock "ai": deterministic ids + a transport that records its options so the test
|
||||||
// can invoke prepareSendMessagesRequest / prepareReconnectToStreamRequest / fetch.
|
// can invoke prepareSendMessagesRequest and assert the `interrupted` flag.
|
||||||
vi.mock("ai", () => {
|
vi.mock("ai", () => {
|
||||||
let counter = 0;
|
let counter = 0;
|
||||||
return {
|
return {
|
||||||
generateId: () => `gid-${counter++}`,
|
generateId: () => `gid-${counter++}`,
|
||||||
DefaultChatTransport: class {
|
DefaultChatTransport: class {
|
||||||
constructor(opts: Record<string, unknown>) {
|
constructor(opts: {
|
||||||
h.state.transport = opts as never;
|
prepareSendMessagesRequest: (arg: {
|
||||||
|
messages: unknown[];
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}) => { body: Record<string, unknown> };
|
||||||
|
}) {
|
||||||
|
h.state.transport = opts;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keep the ai-chat-query import light: ChatThread only needs the messages RQ key,
|
|
||||||
// so stub the module to avoid pulling axios / i18n transitively.
|
|
||||||
vi.mock("@/features/ai-chat/queries/ai-chat-query.ts", () => ({
|
|
||||||
AI_CHAT_MESSAGES_RQ_KEY: (chatId: string) => ["ai-chat-messages", chatId],
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
// Stub the heavy children: MessageList (markdown/render) and ChatInput (the
|
||||||
// composer). The ChatInput stub exposes a button that queues a message, the only
|
// composer). The ChatInput stub exposes a button that queues a message, the only
|
||||||
// interaction this test needs to populate the queue while "streaming".
|
// interaction this test needs to populate the queue while "streaming".
|
||||||
@@ -81,90 +63,49 @@ vi.mock("@/features/ai-chat/components/message-list.tsx", () => ({
|
|||||||
default: () => <div data-testid="message-list" />,
|
default: () => <div data-testid="message-list" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
vi.mock("@/features/ai-chat/components/chat-input.tsx", () => ({
|
||||||
default: ({
|
default: ({ onQueue }: { onQueue: (text: string) => void }) => (
|
||||||
onQueue,
|
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
||||||
onStop,
|
queue
|
||||||
}: {
|
</button>
|
||||||
onQueue: (text: string) => void;
|
|
||||||
onStop: () => void;
|
|
||||||
}) => (
|
|
||||||
<>
|
|
||||||
<button data-testid="queue-btn" onClick={() => onQueue("queued text")}>
|
|
||||||
queue
|
|
||||||
</button>
|
|
||||||
<button aria-label="Stop" onClick={() => onStop()}>
|
|
||||||
stop
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import ChatThread from "./chat-thread";
|
import ChatThread from "./chat-thread";
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
|
|
||||||
function row(
|
function renderThread() {
|
||||||
id: string,
|
|
||||||
role: string,
|
|
||||||
status?: string,
|
|
||||||
text = "",
|
|
||||||
): IAiChatMessageRow {
|
|
||||||
return { id, role, content: text, status, createdAt: "2026-01-01T00:00:00Z" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderThread(props?: {
|
|
||||||
chatId?: string | null;
|
|
||||||
initialRows?: IAiChatMessageRow[];
|
|
||||||
autonomousRunsEnabled?: boolean;
|
|
||||||
}) {
|
|
||||||
const onTurnFinished = vi.fn();
|
const onTurnFinished = vi.fn();
|
||||||
const onResumeFallback = vi.fn();
|
render(
|
||||||
const onServerStop = vi.fn();
|
<MantineProvider>
|
||||||
const queryClient = new QueryClient({
|
<ChatThread chatId="c1" initialRows={[]} onTurnFinished={onTurnFinished} />
|
||||||
defaultOptions: { queries: { retry: false } },
|
</MantineProvider>,
|
||||||
});
|
|
||||||
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
|
||||||
const { unmount } = render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MantineProvider>
|
|
||||||
<ChatThread
|
|
||||||
chatId={props?.chatId === undefined ? "c1" : props.chatId}
|
|
||||||
initialRows={props?.initialRows ?? []}
|
|
||||||
autonomousRunsEnabled={props?.autonomousRunsEnabled}
|
|
||||||
onTurnFinished={onTurnFinished}
|
|
||||||
onResumeFallback={onResumeFallback}
|
|
||||||
onServerStop={onServerStop}
|
|
||||||
/>
|
|
||||||
</MantineProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
);
|
||||||
return { onTurnFinished, onResumeFallback, onServerStop, invalidateSpy, unmount };
|
return { onTurnFinished };
|
||||||
}
|
|
||||||
|
|
||||||
function resetState() {
|
|
||||||
h.state.status = "streaming";
|
|
||||||
h.state.onFinish = null;
|
|
||||||
h.state.seededMessages = null;
|
|
||||||
h.state.transport = null;
|
|
||||||
h.state.sendMessage.mockClear();
|
|
||||||
h.state.stop.mockClear();
|
|
||||||
h.state.setMessages.mockClear();
|
|
||||||
h.state.resumeStream.mockClear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("ChatThread — send now (#198)", () => {
|
describe("ChatThread — send now (#198)", () => {
|
||||||
beforeEach(resetState);
|
beforeEach(() => {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
h.state.stop.mockClear();
|
||||||
|
h.state.transport = null;
|
||||||
|
});
|
||||||
|
|
||||||
it("aborts the current turn and resends the queued message on the abort", () => {
|
it("aborts the current turn and resends the queued message on the abort", () => {
|
||||||
renderThread();
|
renderThread();
|
||||||
|
|
||||||
|
// Queue a message while the turn is streaming.
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
const sendNowBtn = screen.getByLabelText("Send now");
|
const sendNowBtn = screen.getByLabelText("Send now");
|
||||||
expect(sendNowBtn).toBeTruthy();
|
expect(sendNowBtn).toBeTruthy();
|
||||||
|
|
||||||
|
// "Send now" interrupts the current turn (stop), but does NOT send yet —
|
||||||
|
// the resend happens once the abort lands in onFinish.
|
||||||
fireEvent.click(sendNowBtn);
|
fireEvent.click(sendNowBtn);
|
||||||
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
expect(h.state.stop).toHaveBeenCalledTimes(1);
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// The abort we triggered reaches onFinish: the promoted head is flushed.
|
||||||
act(() => {
|
act(() => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
message: { id: "a", role: "assistant", parts: [] },
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
@@ -181,8 +122,10 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
fireEvent.click(screen.getByLabelText("Send now"));
|
fireEvent.click(screen.getByLabelText("Send now"));
|
||||||
|
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||||
|
// The send right after "send now" carries interrupted: true...
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(true);
|
||||||
|
// ...and only that one (the flag is read-and-cleared).
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,92 +136,42 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
fireEvent.click(screen.getByLabelText("Send now"));
|
fireEvent.click(screen.getByLabelText("Send now"));
|
||||||
|
|
||||||
|
// No turn to interrupt: sent straight away, no abort, not flagged.
|
||||||
expect(h.state.stop).not.toHaveBeenCalled();
|
expect(h.state.stop).not.toHaveBeenCalled();
|
||||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
const prep = h.state.transport!.prepareSendMessagesRequest;
|
||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// #388: the editor selection is snapshotted at send time and nested inside
|
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||||
// openPage on the wire. The getter is read live from a ref, so each send ships a
|
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||||
// fresh snapshot.
|
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||||
describe("ChatThread — editor selection wiring (#388)", () => {
|
// the queue intact for the user), and which stop notice — if any — to show.
|
||||||
beforeEach(resetState);
|
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
function renderWithSelection(props: {
|
|
||||||
openPage?: { id: string; title: string } | null;
|
|
||||||
getEditorSelection?: () => unknown;
|
|
||||||
}) {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MantineProvider>
|
|
||||||
<ChatThread
|
|
||||||
chatId="c1"
|
|
||||||
initialRows={[]}
|
|
||||||
openPage={props.openPage as never}
|
|
||||||
getEditorSelection={props.getEditorSelection as never}
|
|
||||||
onTurnFinished={vi.fn()}
|
|
||||||
onResumeFallback={vi.fn()}
|
|
||||||
onServerStop={vi.fn()}
|
|
||||||
/>
|
|
||||||
</MantineProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it("nests the snapshot from the getter into openPage.selection at send time", () => {
|
|
||||||
const selection = { text: "fix this", blockIds: ["b1"], before: "a " };
|
|
||||||
renderWithSelection({
|
|
||||||
openPage: { id: "p1", title: "Doc" },
|
|
||||||
getEditorSelection: () => selection,
|
|
||||||
});
|
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
|
||||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sends selection: null when the getter returns null", () => {
|
|
||||||
renderWithSelection({
|
|
||||||
openPage: { id: "p1", title: "Doc" },
|
|
||||||
getEditorSelection: () => null,
|
|
||||||
});
|
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
|
||||||
const openPage = prep({ messages: [], body: {} }).body.openPage as Record<
|
|
||||||
string,
|
|
||||||
unknown
|
|
||||||
>;
|
|
||||||
expect(openPage).toEqual({ id: "p1", title: "Doc", selection: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not send selection at all on a non-page route (openPage null)", () => {
|
|
||||||
const getter = vi.fn(() => ({ text: "sel" }));
|
|
||||||
renderWithSelection({ openPage: null, getEditorSelection: getter });
|
|
||||||
const prep = h.state.transport!.prepareSendMessagesRequest!;
|
|
||||||
expect(prep({ messages: [], body: {} }).body.openPage).toBeNull();
|
|
||||||
// The getter must not even be consulted when there is no page.
|
|
||||||
expect(getter).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("ChatThread — turn-end decision (onFinish)", () => {
|
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||||
beforeEach(resetState);
|
beforeEach(() => {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
h.state.stop.mockClear();
|
||||||
|
h.state.transport = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||||
|
// message, and report both what the parent was told and whether the queue was
|
||||||
|
// flushed (a resend to the sendMessage spy).
|
||||||
function finishWith(flags: {
|
function finishWith(flags: {
|
||||||
isAbort?: boolean;
|
isAbort?: boolean;
|
||||||
isDisconnect?: boolean;
|
isDisconnect?: boolean;
|
||||||
isError?: boolean;
|
isError?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||||
|
// not leave duplicate queue buttons in the DOM.
|
||||||
cleanup();
|
cleanup();
|
||||||
h.state.sendMessage.mockClear();
|
h.state.sendMessage.mockClear();
|
||||||
const { onTurnFinished } = renderThread();
|
const { onTurnFinished } = renderThread();
|
||||||
|
// Populate the queue while the turn is streaming.
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
act(() => {
|
act(() => {
|
||||||
h.state.onFinish?.({
|
h.state.onFinish?.({
|
||||||
@@ -294,12 +187,16 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
|
|
||||||
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||||
finishWith({});
|
finishWith({});
|
||||||
|
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
|
// A clean finish shows no stop notice.
|
||||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||||
finishWith({ isAbort: true });
|
finishWith({ isAbort: true });
|
||||||
|
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||||
|
// queue is preserved for the user to decide.
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||||
});
|
});
|
||||||
@@ -314,11 +211,15 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
|
|
||||||
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||||
finishWith({ isError: true });
|
finishWith({ isError: true });
|
||||||
|
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
// isError clears the neutral notice (the error banner covers this case).
|
||||||
expect(screen.queryByText("Response stopped.")).toBeNull();
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("notifies the parent on EVERY terminal outcome", () => {
|
it("notifies the parent on EVERY terminal outcome", () => {
|
||||||
|
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||||
|
// every failure path alike.
|
||||||
for (const flags of [
|
for (const flags of [
|
||||||
{},
|
{},
|
||||||
{ isAbort: true },
|
{ isAbort: true },
|
||||||
@@ -331,411 +232,55 @@ describe("ChatThread — turn-end decision (onFinish)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// #184 phase 1.5: the resumable-SSE client. A reopened tab resumes the live run
|
// #184 passive-observer merge: when reconnecting to a still-running run, the
|
||||||
// via the SDK's reconnect transport (attach: replay + tail) instead of polling.
|
// parent feeds the polled run message via `observedRow`; ChatThread merges it via
|
||||||
describe("ChatThread — resume (attach) machinery (#184)", () => {
|
// setMessages — but ONLY when this tab is NOT itself streaming (the streamer's
|
||||||
const streamingTail = () => [
|
// SSE owns the view, so a stale observedRow must never overwrite it).
|
||||||
row("u1", "user", undefined, "hi"),
|
describe("ChatThread — observer run merge (#184)", () => {
|
||||||
row("a1", "assistant", "streaming", "partial"),
|
beforeEach(() => {
|
||||||
];
|
h.state.onFinish = null;
|
||||||
const settledTail = () => [
|
h.state.setMessages.mockReset();
|
||||||
row("u1", "user", undefined, "hi"),
|
});
|
||||||
row("a1", "assistant", "succeeded", "done"),
|
|
||||||
];
|
|
||||||
const userTail = () => [row("u1", "user", undefined, "hi")];
|
|
||||||
|
|
||||||
const visibleMsg = {
|
const observedRow = {
|
||||||
id: "a1",
|
id: "a-run",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
parts: [{ type: "text", text: "streamed answer" }],
|
content: "step 1\nstep 2",
|
||||||
};
|
metadata: {
|
||||||
const emptyMsg = { id: "a1", role: "assistant", parts: [] };
|
parts: [{ type: "text", text: "step 1\nstep 2" }],
|
||||||
|
},
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
} as const;
|
||||||
|
|
||||||
beforeEach(resetState);
|
function renderObserver(status: string) {
|
||||||
// NOTE: do NOT vi.unstubAllGlobals() here — vitest.setup.ts installs
|
h.state.status = status;
|
||||||
// matchMedia/localStorage via vi.stubGlobal and unstubbing wipes them for the
|
render(
|
||||||
// rest of the file. Fetch is re-stubbed per test that needs it.
|
|
||||||
afterEach(cleanup);
|
|
||||||
|
|
||||||
it("resumes on mount only when the flag is on, chatId is set, and the tail is not a settled assistant", () => {
|
|
||||||
// streaming tail -> resume
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// user tail -> resume (the assistant row may not be seeded yet)
|
|
||||||
cleanup();
|
|
||||||
h.state.resumeStream.mockClear();
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
|
||||||
expect(h.state.resumeStream).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// settled assistant tail -> NO resume
|
|
||||||
cleanup();
|
|
||||||
h.state.resumeStream.mockClear();
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: settledTail() });
|
|
||||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// flag off -> NO resume
|
|
||||||
cleanup();
|
|
||||||
h.state.resumeStream.mockClear();
|
|
||||||
renderThread({ autonomousRunsEnabled: false, initialRows: streamingTail() });
|
|
||||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// no chatId -> NO resume
|
|
||||||
cleanup();
|
|
||||||
h.state.resumeStream.mockClear();
|
|
||||||
renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
chatId: null,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
expect(h.state.resumeStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("strips the streaming tail from the seed, but keeps a user tail whole", () => {
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
|
||||||
// 2 rows in, streaming tail stripped -> 1 seeded message.
|
|
||||||
expect(h.state.seededMessages).toHaveLength(1);
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
|
||||||
// user tail is not stripped.
|
|
||||||
expect(h.state.seededMessages).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("builds the attach URL with expect=live&anchor only when the streaming tail was stripped", () => {
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
|
||||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
|
||||||
"/api/ai-chat/runs/c1/stream?expect=live&anchor=a1",
|
|
||||||
);
|
|
||||||
|
|
||||||
cleanup();
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: userTail() });
|
|
||||||
expect(h.state.transport!.prepareReconnectToStreamRequest!().api).toBe(
|
|
||||||
"/api/ai-chat/runs/c1/stream",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetch204() {
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("204 on a user tail: no crash, no restore, reconcile+invalidate, onResumeFallback(true)", async () => {
|
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: userTail(),
|
|
||||||
});
|
|
||||||
await fetch204();
|
|
||||||
// No stripped row -> no restore merge.
|
|
||||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
|
||||||
});
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("204 on a streaming tail: restore + invalidate + onResumeFallback(true)", async () => {
|
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
await fetch204();
|
|
||||||
// Stripped row is restored to the store.
|
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
|
||||||
});
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F7 restart-survival: a 500 attach failure restores the stripped row AND arms the poll (not lost)", async () => {
|
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
|
||||||
});
|
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // stripped row restored
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
|
||||||
});
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // degraded poll armed
|
|
||||||
});
|
|
||||||
|
|
||||||
it("F7 restart-survival: a network throw restores the stripped row AND arms the poll", async () => {
|
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockRejectedValue(new Error("network down")),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
await h.state
|
|
||||||
.transport!.fetch!("http://x", { method: "GET" })
|
|
||||||
.catch(() => undefined); // the wrapper rethrows; swallow here
|
|
||||||
});
|
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
|
||||||
});
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("unmount during a pending attach aborts the controller and gates late callbacks", async () => {
|
|
||||||
const { onResumeFallback, invalidateSpy, unmount } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
let abortSeen = false;
|
|
||||||
let resolveFetch!: (v: unknown) => void;
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
|
||||||
init.signal?.addEventListener("abort", () => {
|
|
||||||
abortSeen = true;
|
|
||||||
});
|
|
||||||
return new Promise((res) => {
|
|
||||||
resolveFetch = res;
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// Kick a reconnect GET (stays pending).
|
|
||||||
let pending!: Promise<unknown>;
|
|
||||||
act(() => {
|
|
||||||
pending = h.state.transport!.fetch!("http://x", { method: "GET" });
|
|
||||||
});
|
|
||||||
// Unmount: the cleanup aborts the in-flight attach.
|
|
||||||
unmount();
|
|
||||||
expect(abortSeen).toBe(true);
|
|
||||||
// A late 204 landing after unmount must NOT arm a poll / invalidate the (now
|
|
||||||
// different) chat.
|
|
||||||
onResumeFallback.mockClear();
|
|
||||||
invalidateSpy.mockClear();
|
|
||||||
await act(async () => {
|
|
||||||
resolveFetch({ status: 204, ok: false });
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
|
||||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a resume fetch error clears resumedTurn so the next local turn flushes the queue", async () => {
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
|
||||||
h.state.status = "ready";
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({ status: 500, ok: false }),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
|
||||||
});
|
|
||||||
// Queue then clean-finish: suppression was cleared, so the queue flushes.
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
|
||||||
act(() => {
|
|
||||||
h.state.onFinish?.({
|
|
||||||
message: visibleMsg,
|
|
||||||
isAbort: false,
|
|
||||||
isDisconnect: false,
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a resumed turn's onFinish does NOT flush the queue", () => {
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
|
||||||
act(() => {
|
|
||||||
h.state.onFinish?.({
|
|
||||||
message: visibleMsg,
|
|
||||||
isAbort: false,
|
|
||||||
isDisconnect: false,
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a healthy resumed finish (visible content) arms nothing and keeps the store", () => {
|
|
||||||
h.state.status = "ready";
|
|
||||||
const { onResumeFallback } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
h.state.setMessages.mockClear();
|
|
||||||
onResumeFallback.mockClear();
|
|
||||||
act(() => {
|
|
||||||
h.state.onFinish?.({
|
|
||||||
message: visibleMsg,
|
|
||||||
isAbort: false,
|
|
||||||
isDisconnect: false,
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// No restore (would clobber the fuller streamed message), no poll arm.
|
|
||||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
|
||||||
expect(onResumeFallback).not.toHaveBeenCalledWith(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isDisconnect WITH visible content arms the poll but does NOT restore", () => {
|
|
||||||
h.state.status = "ready";
|
|
||||||
const { onResumeFallback, invalidateSpy } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
h.state.setMessages.mockClear();
|
|
||||||
onResumeFallback.mockClear();
|
|
||||||
invalidateSpy.mockClear();
|
|
||||||
act(() => {
|
|
||||||
h.state.onFinish?.({
|
|
||||||
message: visibleMsg,
|
|
||||||
isAbort: false,
|
|
||||||
isDisconnect: true,
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true);
|
|
||||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
||||||
queryKey: ["ai-chat-messages", "c1"],
|
|
||||||
});
|
|
||||||
// Restore forbidden: the on-screen partial must not roll back.
|
|
||||||
expect(h.state.setMessages).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an empty resumed message (starved replay) restores the stripped row AND arms the poll", () => {
|
|
||||||
h.state.status = "ready";
|
|
||||||
const { onResumeFallback } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
h.state.setMessages.mockClear();
|
|
||||||
onResumeFallback.mockClear();
|
|
||||||
act(() => {
|
|
||||||
h.state.onFinish?.({
|
|
||||||
message: emptyMsg,
|
|
||||||
isAbort: false,
|
|
||||||
isDisconnect: false,
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1); // restore
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(true); // arm
|
|
||||||
});
|
|
||||||
|
|
||||||
it("degraded-merge: merges the tail per initialRows update, and settles disarm the poll", async () => {
|
|
||||||
h.state.status = "ready";
|
|
||||||
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
|
||||||
// Arm reconcile via a 204.
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockResolvedValue({ status: 204, ok: false }),
|
|
||||||
);
|
|
||||||
await act(async () => {
|
|
||||||
await h.state.transport!.fetch!("http://x", { method: "GET" });
|
|
||||||
});
|
|
||||||
h.state.setMessages.mockClear();
|
|
||||||
onResumeFallback.mockClear();
|
|
||||||
|
|
||||||
// A streaming-tail update: merge, poll stays armed.
|
|
||||||
rerender([
|
|
||||||
row("u1", "user", undefined, "hi"),
|
|
||||||
row("a1", "assistant", "streaming", "step 1\nstep 2"),
|
|
||||||
]);
|
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onResumeFallback).not.toHaveBeenCalledWith(false);
|
|
||||||
|
|
||||||
// A settled-tail update: merge + disarm.
|
|
||||||
h.state.setMessages.mockClear();
|
|
||||||
rerender([
|
|
||||||
row("u1", "user", undefined, "hi"),
|
|
||||||
row("a1", "assistant", "succeeded", "final"),
|
|
||||||
]);
|
|
||||||
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("a local stream disarms both the merge and the poll", () => {
|
|
||||||
h.state.status = "streaming";
|
|
||||||
const { rerender, onResumeFallback } = renderResumable(streamingTail());
|
|
||||||
onResumeFallback.mockClear();
|
|
||||||
// A re-render while streaming: the reconciliation effect disarms.
|
|
||||||
rerender(streamingTail());
|
|
||||||
expect(onResumeFallback).toHaveBeenCalledWith(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Send now is hidden on a resumed turn but visible on a local stream", () => {
|
|
||||||
// Resumed turn: hidden.
|
|
||||||
renderThread({ autonomousRunsEnabled: true, initialRows: streamingTail() });
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
|
||||||
expect(screen.queryByLabelText("Send now")).toBeNull();
|
|
||||||
|
|
||||||
// Local streaming turn (no resume): visible.
|
|
||||||
cleanup();
|
|
||||||
resetState();
|
|
||||||
renderThread({ initialRows: [] });
|
|
||||||
fireEvent.click(screen.getByTestId("queue-btn"));
|
|
||||||
expect(screen.getByLabelText("Send now")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handleStop aborts the attach controller and calls onServerStop", async () => {
|
|
||||||
const { onServerStop } = renderThread({
|
|
||||||
autonomousRunsEnabled: true,
|
|
||||||
initialRows: streamingTail(),
|
|
||||||
});
|
|
||||||
// Establish an attach controller via a (pending) reconnect GET.
|
|
||||||
let abortSeen = false;
|
|
||||||
vi.stubGlobal(
|
|
||||||
"fetch",
|
|
||||||
vi.fn().mockImplementation((_input: unknown, init: RequestInit) => {
|
|
||||||
init.signal?.addEventListener("abort", () => {
|
|
||||||
abortSeen = true;
|
|
||||||
});
|
|
||||||
return new Promise(() => undefined); // never resolves
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
act(() => {
|
|
||||||
void h.state.transport!.fetch!("http://x", { method: "GET" });
|
|
||||||
});
|
|
||||||
fireEvent.click(screen.getByLabelText("Stop"));
|
|
||||||
expect(abortSeen).toBe(true);
|
|
||||||
expect(onServerStop).toHaveBeenCalledWith("c1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Helper: render a resumable thread and expose a rerender that only swaps
|
|
||||||
// initialRows (the degraded-merge effect depends on it).
|
|
||||||
function renderResumable(initialRows: IAiChatMessageRow[]) {
|
|
||||||
const onResumeFallback = vi.fn();
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
const Wrapper = ({ rows }: { rows: IAiChatMessageRow[] }) => (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<ChatThread
|
<ChatThread
|
||||||
chatId="c1"
|
chatId="c1"
|
||||||
initialRows={rows}
|
initialRows={[]}
|
||||||
autonomousRunsEnabled
|
|
||||||
onTurnFinished={vi.fn()}
|
onTurnFinished={vi.fn()}
|
||||||
onResumeFallback={onResumeFallback}
|
observedRow={observedRow as never}
|
||||||
/>
|
/>
|
||||||
</MantineProvider>
|
</MantineProvider>,
|
||||||
</QueryClientProvider>
|
);
|
||||||
);
|
}
|
||||||
const view = render(<Wrapper rows={initialRows} />);
|
|
||||||
const rerender = (rows: IAiChatMessageRow[]) =>
|
it("merges the polled run message when this tab is a passive observer", () => {
|
||||||
act(() => view.rerender(<Wrapper rows={rows} />));
|
renderObserver("ready");
|
||||||
return { rerender, onResumeFallback };
|
expect(h.state.setMessages).toHaveBeenCalledTimes(1);
|
||||||
}
|
// The updater replaces/append the observed assistant row by id.
|
||||||
|
const updater = h.state.setMessages.mock.calls[0][0] as (
|
||||||
|
prev: { id: string; parts: { text: string }[] }[],
|
||||||
|
) => { id: string; parts: { text: string }[] }[];
|
||||||
|
const merged = updater([{ id: "u1", parts: [{ text: "hi" }] }]);
|
||||||
|
expect(merged).toHaveLength(2);
|
||||||
|
expect(merged[1].id).toBe("a-run");
|
||||||
|
expect(merged[1].parts[0].text).toBe("step 1\nstep 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT merge while THIS tab is the streamer (no double-render)", () => {
|
||||||
|
renderObserver("streaming");
|
||||||
|
expect(h.state.setMessages).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { generateId } from "ai";
|
import { generateId } from "ai";
|
||||||
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Box, Group, Stack, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
@@ -25,15 +24,7 @@ import {
|
|||||||
} from "@/features/ai-chat/utils/role-launch.ts";
|
} from "@/features/ai-chat/utils/role-launch.ts";
|
||||||
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
import { describeChatError } from "@/features/ai-chat/utils/error-message.ts";
|
||||||
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
import { extractServerChatId } from "@/features/ai-chat/utils/adopt-chat-id.ts";
|
||||||
import { assistantMessageHasVisibleContent } from "@/features/ai-chat/utils/message-content.ts";
|
import { mergeObservedMessage } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
import {
|
|
||||||
isStreamingTail,
|
|
||||||
isSettledAssistantTail,
|
|
||||||
seedRows,
|
|
||||||
mergeById,
|
|
||||||
} from "@/features/ai-chat/utils/resume-helpers.ts";
|
|
||||||
import { AI_CHAT_MESSAGES_RQ_KEY } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
|
||||||
import type { EditorSelectionContext } from "@/features/editor/utils/get-editor-selection.ts";
|
|
||||||
import {
|
import {
|
||||||
dequeue,
|
dequeue,
|
||||||
enqueueMessage,
|
enqueueMessage,
|
||||||
@@ -70,10 +61,6 @@ interface ChatThreadProps {
|
|||||||
/** The page currently open in the workspace, or null on a non-page route.
|
/** The page currently open in the workspace, or null on a non-page route.
|
||||||
* Sent with each turn so the agent knows what "this page" refers to. */
|
* Sent with each turn so the agent knows what "this page" refers to. */
|
||||||
openPage?: OpenPageContext | null;
|
openPage?: OpenPageContext | null;
|
||||||
/** #388: snapshot the user's current editor selection at SEND time. Invoked
|
|
||||||
* inside prepareSendMessagesRequest and nested into openPage on the wire, so a
|
|
||||||
* fresh snapshot ships each turn. Null/absent => nothing selected. */
|
|
||||||
getEditorSelection?: () => EditorSelectionContext | null;
|
|
||||||
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
/** The agent role selected for a NEW chat (null = universal assistant). Sent
|
||||||
* in the request body so the server persists it on chat creation; ignored by
|
* in the request body so the server persists it on chat creation; ignored by
|
||||||
* the server for existing chats (the role is read from the chat row). */
|
* the server for existing chats (the role is read from the chat row). */
|
||||||
@@ -100,13 +87,19 @@ interface ChatThreadProps {
|
|||||||
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
* Copy/export button available mid-stream). Distinct from onTurnFinished,
|
||||||
* which fires only at the terminal outcome. */
|
* which fires only at the terminal outcome. */
|
||||||
onServerChatId?: (serverChatId?: string) => void;
|
onServerChatId?: (serverChatId?: string) => void;
|
||||||
/** #184 phase 1.5: arm/disarm the parent's degraded-poll fallback for THIS
|
/** #184 reconnect-and-live-follow. When THIS tab reopened a chat whose agent
|
||||||
* chat's window. Called `true` when a resume attempt could not attach to the
|
* run is still going (it is a PASSIVE OBSERVER — it did not start the run here),
|
||||||
* live run (attach 204 / starved-or-torn resumed finish), so the window starts
|
* the parent polls the reconnect endpoint and feeds the run's incrementally-
|
||||||
* a dumb timed poll of the message history to follow the detached run to settle;
|
* persisted assistant message here; we merge it into the live list so new
|
||||||
* called `false` the moment a local stream starts or the terminal settled row is
|
* steps/tool-calls appear as they are persisted. Null when there is nothing to
|
||||||
* merged (invariant 8). The window owns the timer + its 10-min cap. */
|
* observe (no run, feature off, or this tab IS the streamer). The merge is
|
||||||
onResumeFallback?: (active: boolean) => void;
|
* ADDITIONALLY guarded by our own `isStreaming`, so a stale value can never
|
||||||
|
* fight the local stream when we are the streamer. */
|
||||||
|
observedRow?: IAiChatMessageRow | null;
|
||||||
|
/** Report this tab's live streaming status up to the parent, so it can stop
|
||||||
|
* polling the run while WE are the active streamer (the SSE owns the view) and
|
||||||
|
* resume once we go idle. Called from an effect on every transition. */
|
||||||
|
onStreamingChange?: (streaming: boolean) => void;
|
||||||
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
|
/** #184: whether detached/autonomous agent runs are enabled for this workspace.
|
||||||
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
|
* When true the Stop button must additionally hit the AUTHORITATIVE server stop
|
||||||
* (via onServerStop) — aborting only the local SSE is just a client disconnect,
|
* (via onServerStop) — aborting only the local SSE is just a client disconnect,
|
||||||
@@ -156,65 +149,21 @@ export default function ChatThread({
|
|||||||
threadKey,
|
threadKey,
|
||||||
initialRows,
|
initialRows,
|
||||||
openPage,
|
openPage,
|
||||||
getEditorSelection,
|
|
||||||
roleId,
|
roleId,
|
||||||
roles,
|
roles,
|
||||||
onRolePicked,
|
onRolePicked,
|
||||||
assistantName,
|
assistantName,
|
||||||
onTurnFinished,
|
onTurnFinished,
|
||||||
onServerChatId,
|
onServerChatId,
|
||||||
onResumeFallback,
|
observedRow,
|
||||||
|
onStreamingChange,
|
||||||
autonomousRunsEnabled,
|
autonomousRunsEnabled,
|
||||||
onServerStop,
|
onServerStop,
|
||||||
}: ChatThreadProps) {
|
}: ChatThreadProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// resume machinery refs (#184 phase 1.5)
|
|
||||||
const attachAbortRef = useRef<AbortController | null>(null);
|
|
||||||
const reconcileTailRef = useRef(false);
|
|
||||||
const noStreamHandledRef = useRef(false);
|
|
||||||
const onNoActiveStreamRef = useRef<(() => void) | null>(null);
|
|
||||||
// Live mount flag. The attach GET and the resumed `onFinish` are async and can
|
|
||||||
// land AFTER this thread unmounts (the parent remounts per chat via `key`); with
|
|
||||||
// chatIdRef then pointing at the NEW chat, an ungated late callback would arm a
|
|
||||||
// spurious poll + foreign invalidation on the newly-opened chat. Every parent-
|
|
||||||
// facing resume side-effect is gated on this.
|
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const [resumedTurn, setResumedTurn] = useState(false);
|
|
||||||
const resumedTurnRef = useRef(false);
|
|
||||||
// Identity-stable pair setter (bare useState setter + ref write): it is closed
|
|
||||||
// over by the transport useMemo([]), so it MUST NOT capture state.
|
|
||||||
const setResumedTurnPair = useCallback((v: boolean) => {
|
|
||||||
resumedTurnRef.current = v;
|
|
||||||
setResumedTurn(v);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mount-time resume gating (in refs — computed once for this mount; the parent
|
|
||||||
// remounts per chat via `key`).
|
|
||||||
//
|
|
||||||
// Attempt resume for any non-settled tail: a streaming tail (strip + expect
|
|
||||||
// live replay) or a user tail (the run may exist but its assistant row is not
|
|
||||||
// seeded yet — attach to the pre-opened registry entry and wait for frames).
|
|
||||||
// A settled assistant tail must NEVER resume: replaying a finished run into a
|
|
||||||
// store that already contains its message duplicates parts (SDK text-start
|
|
||||||
// always pushes a new part).
|
|
||||||
const stripRef = useRef(chatId !== null && isStreamingTail(initialRows ?? []));
|
|
||||||
const attemptResumeRef = useRef(
|
|
||||||
autonomousRunsEnabled === true &&
|
|
||||||
chatId !== null &&
|
|
||||||
!isSettledAssistantTail(initialRows ?? []),
|
|
||||||
);
|
|
||||||
const strippedRowRef = useRef<IAiChatMessageRow | null>(
|
|
||||||
stripRef.current ? (initialRows ?? [])[initialRows!.length - 1] : null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const initialMessages = useMemo<UIMessage[]>(
|
const initialMessages = useMemo<UIMessage[]>(
|
||||||
() =>
|
() => (initialRows ?? []).map(rowToUiMessage),
|
||||||
seedRows(
|
|
||||||
initialRows ?? [],
|
|
||||||
attemptResumeRef.current && stripRef.current,
|
|
||||||
).map(rowToUiMessage),
|
|
||||||
[initialRows],
|
[initialRows],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -232,14 +181,6 @@ export default function ChatThread({
|
|||||||
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
const openPageRef = useRef<OpenPageContext | null>(openPage ?? null);
|
||||||
openPageRef.current = openPage ?? null;
|
openPageRef.current = openPage ?? null;
|
||||||
|
|
||||||
// Keep the selection snapshotter in a ref, same rationale as openPageRef: the
|
|
||||||
// transport useMemo([]) closes it over, so prop-identity churn must not matter.
|
|
||||||
// Called at send time inside prepareSendMessagesRequest (#388).
|
|
||||||
const getEditorSelectionRef = useRef<
|
|
||||||
(() => EditorSelectionContext | null) | undefined
|
|
||||||
>(getEditorSelection);
|
|
||||||
getEditorSelectionRef.current = getEditorSelection;
|
|
||||||
|
|
||||||
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
// Keep the selected role id in a ref, same rationale as openPageRef. Only the
|
||||||
// FIRST request of a brand-new chat uses it (the server persists it then and
|
// FIRST request of a brand-new chat uses it (the server persists it then and
|
||||||
// ignores it for existing chats), but sending it on every send is harmless.
|
// ignores it for existing chats), but sending it on every send is harmless.
|
||||||
@@ -320,12 +261,9 @@ export default function ChatThread({
|
|||||||
const { head, rest } = dequeue(queuedRef.current);
|
const { head, rest } = dequeue(queuedRef.current);
|
||||||
if (!head) return false;
|
if (!head) return false;
|
||||||
setQueue(rest);
|
setQueue(rest);
|
||||||
// Local send: clear any resume-suppression flag so this genuine local turn's
|
|
||||||
// onFinish flushes normally (invariant 8).
|
|
||||||
setResumedTurnPair(false);
|
|
||||||
sendMessageRef.current?.({ text: head.text });
|
sendMessageRef.current?.({ text: head.text });
|
||||||
return true;
|
return true;
|
||||||
}, [setQueue, setResumedTurnPair]);
|
}, [setQueue]);
|
||||||
|
|
||||||
const enqueue = useCallback(
|
const enqueue = useCallback(
|
||||||
(text: string) => {
|
(text: string) => {
|
||||||
@@ -345,47 +283,6 @@ export default function ChatThread({
|
|||||||
new DefaultChatTransport<UIMessage>({
|
new DefaultChatTransport<UIMessage>({
|
||||||
api: "/api/ai-chat/stream",
|
api: "/api/ai-chat/stream",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
prepareReconnectToStreamRequest: () => ({
|
|
||||||
// SDK default URL uses the useChat STORE id — always build from the real chat id.
|
|
||||||
// ?expect=live&anchor=<row id> ONLY when we stripped a streaming tail: expect=live
|
|
||||||
// is the only case where a finished-retained replay is safe (the row is stripped,
|
|
||||||
// replay rebuilds it), and the anchor pins the replay to OUR run — a mismatching
|
|
||||||
// (newer) run must 204 into the restore+poll path instead of replaying a foreign
|
|
||||||
// transcript into this store.
|
|
||||||
api: `/api/ai-chat/runs/${chatIdRef.current}/stream${
|
|
||||||
stripRef.current
|
|
||||||
? `?expect=live&anchor=${strippedRowRef.current!.id}`
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
}),
|
|
||||||
fetch: async (input: RequestInfo | URL, init: RequestInit = {}) => {
|
|
||||||
if ((init.method ?? "GET") !== "GET") return fetch(input, init); // send path untouched
|
|
||||||
// Reconnect GET: the SDK passes no AbortSignal, so wire our own controller
|
|
||||||
// for observer Stop / unmount abort.
|
|
||||||
const controller = new AbortController();
|
|
||||||
attachAbortRef.current = controller;
|
|
||||||
try {
|
|
||||||
const response = await fetch(input, {
|
|
||||||
...init,
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
// No onFinish will come for a 204 (silent no-op) OR any non-2xx
|
|
||||||
// (5xx/502 — a server restart mid-attach). Both run the same
|
|
||||||
// no-active-stream recovery: restore the stripped row, invalidate, and
|
|
||||||
// arm the degraded poll (idempotent via noStreamHandledRef; its part-d
|
|
||||||
// also clears the resumedTurn flag). This is the restart-survival path
|
|
||||||
// the removed F7 latch used to guard — a transient attach failure must
|
|
||||||
// NOT drop the in-progress row or stop tracking the durable run.
|
|
||||||
if (response.status === 204 || !response.ok)
|
|
||||||
onNoActiveStreamRef.current?.();
|
|
||||||
return response;
|
|
||||||
} catch (err) {
|
|
||||||
// Network throw: same no-onFinish recovery, then rethrow so the SDK
|
|
||||||
// still surfaces the error to its own machinery.
|
|
||||||
onNoActiveStreamRef.current?.();
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// Inject the chat id and the currently-open page alongside the useChat
|
// Inject the chat id and the currently-open page alongside the useChat
|
||||||
// messages so the server can resolve an existing chat (or create one
|
// messages so the server can resolve an existing chat (or create one
|
||||||
// when null) and tell the agent which page "this page" refers to. Both
|
// when null) and tell the agent which page "this page" refers to. Both
|
||||||
@@ -402,16 +299,7 @@ export default function ChatThread({
|
|||||||
body: {
|
body: {
|
||||||
...body,
|
...body,
|
||||||
chatId: chatIdRef.current,
|
chatId: chatIdRef.current,
|
||||||
// Attach the live editor selection to the open-page context at send
|
openPage: openPageRef.current,
|
||||||
// time — "this"/"here" in the user's message means THIS selection.
|
|
||||||
// Nested inside openPage so it dies with the page when the server
|
|
||||||
// rejects the page id (#388). Null when nothing is selected.
|
|
||||||
openPage: openPageRef.current
|
|
||||||
? {
|
|
||||||
...openPageRef.current,
|
|
||||||
selection: getEditorSelectionRef.current?.() ?? null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
// Honoured by the server only when creating a new chat; null =>
|
// Honoured by the server only when creating a new chat; null =>
|
||||||
// universal assistant.
|
// universal assistant.
|
||||||
roleId: roleIdRef.current,
|
roleId: roleIdRef.current,
|
||||||
@@ -424,15 +312,7 @@ export default function ChatThread({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const { messages, sendMessage, status, stop, error, setMessages } = useChat({
|
||||||
messages,
|
|
||||||
sendMessage,
|
|
||||||
status,
|
|
||||||
stop,
|
|
||||||
error,
|
|
||||||
setMessages,
|
|
||||||
resumeStream,
|
|
||||||
} = useChat({
|
|
||||||
// Stable per-mount key. Existing chats use their real id; new chats use a
|
// Stable per-mount key. Existing chats use their real id; new chats use a
|
||||||
// generated client id (never `undefined`) so the store is NOT re-created on
|
// generated client id (never `undefined`) so the store is NOT re-created on
|
||||||
// every render mid-stream (see `chatStoreId` above).
|
// every render mid-stream (see `chatStoreId` above).
|
||||||
@@ -450,38 +330,6 @@ export default function ChatThread({
|
|||||||
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
// would be wrong, so on Stop/disconnect/error the queue is left intact for
|
||||||
// the user to decide.
|
// the user to decide.
|
||||||
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
onFinish: ({ message, isAbort, isDisconnect, isError }) => {
|
||||||
// (1) Capture whether THIS finish belongs to a resumed (attach) turn and
|
|
||||||
// immediately clear the flag so it can never suppress a LATER local turn.
|
|
||||||
const wasResumed = resumedTurnRef.current;
|
|
||||||
setResumedTurnPair(false);
|
|
||||||
// (2) Recovery after a starved/torn resumed finish (invariant 9). The arm
|
|
||||||
// and the stripped-row restore are gated DIFFERENTLY. Skip entirely once
|
|
||||||
// unmounted (an abort-triggered onFinish landing after a chat switch must
|
|
||||||
// not arm a poll / invalidate on the new chat).
|
|
||||||
if (wasResumed && mountedRef.current) {
|
|
||||||
const hasVisibleContent = assistantMessageHasVisibleContent(message);
|
|
||||||
// ARM the reconcile + degraded poll when the resumed message carries no
|
|
||||||
// visible content (starved replay) OR the connection dropped mid-run — in
|
|
||||||
// both cases the poll must drive the row to its real terminal state.
|
|
||||||
if (isDisconnect || !hasVisibleContent) {
|
|
||||||
reconcileTailRef.current = true;
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
|
||||||
});
|
|
||||||
onResumeFallback?.(true);
|
|
||||||
}
|
|
||||||
// RESTORE the stripped streaming row ONLY when the resumed message has no
|
|
||||||
// visible content. On isDisconnect WITH visible content restore is
|
|
||||||
// FORBIDDEN: the live stream may have advanced far past the mount-time
|
|
||||||
// snapshot, so restoring would clobber on-screen content (invariant 9) —
|
|
||||||
// the arm above suffices, the poll reaches the true terminal.
|
|
||||||
if (!hasVisibleContent && strippedRowRef.current) {
|
|
||||||
setMessages((prev) =>
|
|
||||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// (3) Standard branches.
|
|
||||||
// Forward the authoritative server chatId (streamed on the assistant
|
// Forward the authoritative server chatId (streamed on the assistant
|
||||||
// message metadata) so the parent adopts the REAL created chat id for a new
|
// message metadata) so the parent adopts the REAL created chat id for a new
|
||||||
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
// chat — see adopt-chat-id.ts for the full #137 design. `threadKey` lets the
|
||||||
@@ -494,10 +342,6 @@ export default function ChatThread({
|
|||||||
else if (isAbort) setStopNotice("manual");
|
else if (isAbort) setStopNotice("manual");
|
||||||
else if (isDisconnect) setStopNotice("disconnect");
|
else if (isDisconnect) setStopNotice("disconnect");
|
||||||
else setStopNotice(null);
|
else setStopNotice(null);
|
||||||
// A resumed turn NEVER flushes the queue (invariant 7): skip BOTH the
|
|
||||||
// flush-on-abort branch and the plain flush. The local streamer is the only
|
|
||||||
// tab that owns the queue.
|
|
||||||
if (wasResumed) return;
|
|
||||||
// "Send now": WE triggered this abort to interrupt the current turn and
|
// "Send now": WE triggered this abort to interrupt the current turn and
|
||||||
// immediately send the promoted head. Flush it even though the turn was
|
// immediately send the promoted head. Flush it even though the turn was
|
||||||
// aborted (the normal abort path below keeps the queue intact). The
|
// aborted (the normal abort path below keeps the queue intact). The
|
||||||
@@ -579,98 +423,26 @@ export default function ChatThread({
|
|||||||
|
|
||||||
const isStreaming = status === "submitted" || status === "streaming";
|
const isStreaming = status === "submitted" || status === "streaming";
|
||||||
|
|
||||||
// 204-handler (`onNoActiveStream`): the attach returned 204 — nothing live to
|
// #184: report our live streaming status up so the parent stops polling the run
|
||||||
// resume (overflow / begin-failure / after retention / anchor-mismatch). One-
|
// while WE are the streamer (the SSE owns the view) and resumes once we go idle.
|
||||||
// shot via noStreamHandledRef (we do NOT null onNoActiveStreamRef). Exactly four
|
// Effect (not render) so it never updates parent state during our own render;
|
||||||
// parts. Kept in a ref (read by the transport's fetch closure) and refreshed
|
// fires on mount with `false`, which also re-syncs the parent after a chat
|
||||||
// each render below.
|
// switch remounts this thread (a fresh mount is idle until the user sends).
|
||||||
const onNoActiveStream = useCallback(() => {
|
|
||||||
// A late attach outcome after unmount must not arm a poll / invalidate on the
|
|
||||||
// now-different chat this thread's refs were reused for.
|
|
||||||
if (!mountedRef.current) return;
|
|
||||||
if (noStreamHandledRef.current) return;
|
|
||||||
noStreamHandledRef.current = true;
|
|
||||||
// (a) Restore the stripped streaming row to the store — ONLY when we actually
|
|
||||||
// stripped one (a user-tail 204 does NOT reach here with a stripped row, so do
|
|
||||||
// not dereference null).
|
|
||||||
if (strippedRowRef.current) {
|
|
||||||
setMessages((prev) =>
|
|
||||||
mergeById(prev, rowToUiMessage(strippedRowRef.current!)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// (b) Reconcile the tail from the message history + invalidate it so the
|
|
||||||
// degraded poll starts from a fresh fetch.
|
|
||||||
reconcileTailRef.current = true;
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatIdRef.current),
|
|
||||||
});
|
|
||||||
// (c) Arm the degraded poll (a dumb timer with a 10-min cap in the window);
|
|
||||||
// the thread disarms it via onResumeFallback(false) on settle / local stream.
|
|
||||||
onResumeFallback?.(true);
|
|
||||||
// (d) 204 means onFinish will NOT fire — clear the suppression flag so it
|
|
||||||
// cannot swallow the NEXT local turn's queue flush.
|
|
||||||
setResumedTurnPair(false);
|
|
||||||
}, [setMessages, queryClient, onResumeFallback, setResumedTurnPair]);
|
|
||||||
onNoActiveStreamRef.current = onNoActiveStream;
|
|
||||||
|
|
||||||
// Mount effect: kick off the resume attempt for a non-settled tail. Marking the
|
|
||||||
// turn as resumed BEFORE resumeStream so onFinish (invariant 7/8) sees it.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Re-arm on (re)mount — StrictMode dev-mounts twice, and the cleanup below
|
onStreamingChange?.(isStreaming);
|
||||||
// flips this false between the two.
|
}, [isStreaming, onStreamingChange]);
|
||||||
mountedRef.current = true;
|
|
||||||
if (attemptResumeRef.current) {
|
|
||||||
setResumedTurnPair(true);
|
|
||||||
void resumeStream();
|
|
||||||
}
|
|
||||||
// Unmount: mark unmounted (gates late attach/onFinish side-effects) and abort
|
|
||||||
// the in-flight attach GET so its callbacks don't fire against the next chat.
|
|
||||||
return () => {
|
|
||||||
mountedRef.current = false;
|
|
||||||
attachAbortRef.current?.abort();
|
|
||||||
};
|
|
||||||
// Mount-only by design; the parent remounts per chat via `key`.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reconciliation + degraded-merge (invariant 8). Deps are EXACTLY
|
// #184 passive-observer merge: when the parent feeds a polled run message (we
|
||||||
// [initialRows, isStreaming, setMessages].
|
// reopened a chat whose run is still going and did NOT start it here), merge it
|
||||||
|
// into the live list so new steps/tool-calls appear as they are persisted. Hard-
|
||||||
|
// gated by `!isStreaming`: if THIS tab is actually the streamer, the local SSE
|
||||||
|
// owns the view and a stale observedRow must never overwrite it. `observedRow`
|
||||||
|
// is a stable per-poll object, so this runs once per poll, not per render.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A local stream owns the view: disarm BOTH the merge and the window poll.
|
if (isStreaming || !observedRow) return;
|
||||||
if (isStreaming) {
|
const observed = rowToUiMessage(observedRow);
|
||||||
reconcileTailRef.current = false;
|
setMessages((prev) => mergeObservedMessage(prev, observed));
|
||||||
onResumeFallback?.(false);
|
}, [observedRow, isStreaming, setMessages]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!reconcileTailRef.current) return;
|
|
||||||
const rows = initialRows ?? [];
|
|
||||||
const tail = rows[rows.length - 1];
|
|
||||||
if (!tail || tail.role !== "assistant") return;
|
|
||||||
// Merge the polled assistant tail on EVERY initialRows update — while the
|
|
||||||
// degraded poll is active this IS the live per-step progress.
|
|
||||||
setMessages((prev) => mergeById(prev, rowToUiMessage(tail)));
|
|
||||||
// Anchor-mismatch coherence: when we restored a stripped streaming row A but a
|
|
||||||
// DIFFERENT run's row B is now the tail (A finished, B replaced the registry
|
|
||||||
// entry, so the attach 204'd), A would otherwise linger forever as an orphan
|
|
||||||
// jumping-dots row over the real run. Settle it from fresh history (where A is
|
|
||||||
// now persisted) so no phantom row survives. No-op in the common case where A
|
|
||||||
// IS the tail (id match).
|
|
||||||
const stripped = strippedRowRef.current;
|
|
||||||
if (stripped && stripped.id !== tail.id) {
|
|
||||||
const historical = rows.find((r) => r.id === stripped.id);
|
|
||||||
if (historical)
|
|
||||||
setMessages((prev) => mergeById(prev, rowToUiMessage(historical)));
|
|
||||||
}
|
|
||||||
// Settled: the terminal merge is done — disarm the flag AND the window poll
|
|
||||||
// explicitly (the window only has a time cap, it will not disarm itself).
|
|
||||||
if (tail.status !== "streaming") {
|
|
||||||
reconcileTailRef.current = false;
|
|
||||||
onResumeFallback?.(false);
|
|
||||||
}
|
|
||||||
// onResumeFallback intentionally omitted (parent-stable callback); deps are
|
|
||||||
// fixed by the resume design.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [initialRows, isStreaming, setMessages]);
|
|
||||||
|
|
||||||
// "Send now" on a queued message: interrupt the current turn and immediately
|
// "Send now" on a queued message: interrupt the current turn and immediately
|
||||||
// send THIS message, keeping the agent's partial output. Other queued messages
|
// send THIS message, keeping the agent's partial output. Other queued messages
|
||||||
@@ -697,12 +469,10 @@ export default function ChatThread({
|
|||||||
const msg = queuedRef.current.find((m) => m.id === id);
|
const msg = queuedRef.current.find((m) => m.id === id);
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
setQueue(removeQueuedById(queuedRef.current, id));
|
setQueue(removeQueuedById(queuedRef.current, id));
|
||||||
// Local send: clear any resume-suppression flag (invariant 8).
|
|
||||||
setResumedTurnPair(false);
|
|
||||||
sendMessageRef.current?.({ text: msg.text });
|
sendMessageRef.current?.({ text: msg.text });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setQueue, stop, setResumedTurnPair],
|
[setQueue, stop],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
// Stop the current turn. ALWAYS abort the local SSE (`stop()`) so the composer
|
||||||
@@ -715,9 +485,6 @@ export default function ChatThread({
|
|||||||
// is not known yet — a brand-new chat in the first moment of its first turn —
|
// is not known yet — a brand-new chat in the first moment of its first turn —
|
||||||
// only the local abort happens (there is no server-side run handle to stop yet).
|
// only the local abort happens (there is no server-side run handle to stop yet).
|
||||||
const handleStop = useCallback(() => {
|
const handleStop = useCallback(() => {
|
||||||
// Abort the resume/attach GET first: the SDK does not pass it a signal, so an
|
|
||||||
// observer's Stop would otherwise leave the attach fetch running.
|
|
||||||
attachAbortRef.current?.abort();
|
|
||||||
stop();
|
stop();
|
||||||
if (!autonomousRunsEnabled) return;
|
if (!autonomousRunsEnabled) return;
|
||||||
if (chatIdRef.current) {
|
if (chatIdRef.current) {
|
||||||
@@ -850,23 +617,17 @@ export default function ChatThread({
|
|||||||
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
<Text size="xs" lineClamp={2} className={classes.queuedText}>
|
||||||
{m.text}
|
{m.text}
|
||||||
</Text>
|
</Text>
|
||||||
{/* "Send now" (interrupt) is hidden on a RESUMED turn: a local
|
<Tooltip label={t("Interrupt and send now")} withArrow>
|
||||||
stop() does not abort the resumed attach fetch, so the click
|
<ActionIcon
|
||||||
would be swallowed while flushOnAbortRef would fire minutes
|
size="xs"
|
||||||
later on the natural finish. Only the remove affordance stays. */}
|
variant="subtle"
|
||||||
{!resumedTurn && (
|
color="blue"
|
||||||
<Tooltip label={t("Interrupt and send now")} withArrow>
|
onClick={() => sendNow(m.id)}
|
||||||
<ActionIcon
|
aria-label={t("Send now")}
|
||||||
size="xs"
|
>
|
||||||
variant="subtle"
|
<IconPlayerPlayFilled size={12} />
|
||||||
color="blue"
|
</ActionIcon>
|
||||||
onClick={() => sendNow(m.id)}
|
</Tooltip>
|
||||||
aria-label={t("Send now")}
|
|
||||||
>
|
|
||||||
<IconPlayerPlayFilled size={12} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@@ -881,11 +642,7 @@ export default function ChatThread({
|
|||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
<ChatInput
|
<ChatInput
|
||||||
onSend={(text) => {
|
onSend={(text) => sendMessage({ text })}
|
||||||
// Local send: clear any resume-suppression flag (invariant 8).
|
|
||||||
setResumedTurnPair(false);
|
|
||||||
sendMessage({ text });
|
|
||||||
}}
|
|
||||||
onQueue={enqueue}
|
onQueue={enqueue}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
|||||||
@@ -40,13 +40,6 @@ interface MessageItemProps {
|
|||||||
* Defaults to true (internal chat). The public share passes false.
|
* Defaults to true (internal chat). The public share passes false.
|
||||||
*/
|
*/
|
||||||
showCitations?: boolean;
|
showCitations?: boolean;
|
||||||
/**
|
|
||||||
* Forwarded to ToolCallCard: whether tool cards render the one-line summary of
|
|
||||||
* a call's arguments (e.g. the search query). Defaults to true (internal
|
|
||||||
* chat). The public share passes false so an anonymous reader doesn't see the
|
|
||||||
* agent's raw query/argument text.
|
|
||||||
*/
|
|
||||||
showInput?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Neutralize internal/relative markdown links in the rendered answer (drop
|
* Neutralize internal/relative markdown links in the rendered answer (drop
|
||||||
* their href so they become inert text). Defaults to false (internal chat,
|
* their href so they become inert text). Defaults to false (internal chat,
|
||||||
@@ -124,7 +117,6 @@ const MarkdownPart = memo(function MarkdownPart({
|
|||||||
function MessageItem({
|
function MessageItem({
|
||||||
message,
|
message,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
turnStreaming = false,
|
turnStreaming = false,
|
||||||
@@ -218,7 +210,6 @@ function MessageItem({
|
|||||||
key={index}
|
key={index}
|
||||||
part={part as unknown as ToolUiPart}
|
part={part as unknown as ToolUiPart}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -283,7 +274,6 @@ export function arePropsEqual(
|
|||||||
return (
|
return (
|
||||||
prev.signature === next.signature &&
|
prev.signature === next.signature &&
|
||||||
prev.showCitations === next.showCitations &&
|
prev.showCitations === next.showCitations &&
|
||||||
prev.showInput === next.showInput &&
|
|
||||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||||
prev.assistantName === next.assistantName &&
|
prev.assistantName === next.assistantName &&
|
||||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||||
|
|||||||
@@ -25,13 +25,6 @@ interface MessageListProps {
|
|||||||
* false because an anonymous reader cannot open the linked internal pages.
|
* false because an anonymous reader cannot open the linked internal pages.
|
||||||
*/
|
*/
|
||||||
showCitations?: boolean;
|
showCitations?: boolean;
|
||||||
/**
|
|
||||||
* Forwarded to MessageItem -> ToolCallCard: whether tool cards render the
|
|
||||||
* one-line summary of a call's arguments (e.g. the search query). Defaults to
|
|
||||||
* true (internal chat). The public share passes false so an anonymous reader
|
|
||||||
* doesn't see the agent's raw query/argument text.
|
|
||||||
*/
|
|
||||||
showInput?: boolean;
|
|
||||||
/**
|
/**
|
||||||
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
* Forwarded to MessageItem: neutralize internal/relative markdown links in
|
||||||
* the rendered answers (drop their href so they render as inert text).
|
* the rendered answers (drop their href so they render as inert text).
|
||||||
@@ -126,7 +119,6 @@ export default function MessageList({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
emptyState,
|
emptyState,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
|
||||||
neutralizeInternalLinks = false,
|
neutralizeInternalLinks = false,
|
||||||
assistantName,
|
assistantName,
|
||||||
}: MessageListProps) {
|
}: MessageListProps) {
|
||||||
@@ -216,7 +208,6 @@ export default function MessageList({
|
|||||||
message={message}
|
message={message}
|
||||||
signature={messageSignature(message)}
|
signature={messageSignature(message)}
|
||||||
showCitations={showCitations}
|
showCitations={showCitations}
|
||||||
showInput={showInput}
|
|
||||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTranslation } from "react-i18next";
|
|||||||
import {
|
import {
|
||||||
getToolName,
|
getToolName,
|
||||||
toolCitations,
|
toolCitations,
|
||||||
toolInputSummary,
|
|
||||||
toolLabelKey,
|
toolLabelKey,
|
||||||
toolRunState,
|
toolRunState,
|
||||||
ToolUiPart,
|
ToolUiPart,
|
||||||
@@ -22,14 +21,6 @@ interface ToolCallCardProps {
|
|||||||
* (the action log itself) while dropping the unusable links.
|
* (the action log itself) while dropping the unusable links.
|
||||||
*/
|
*/
|
||||||
showCitations?: boolean;
|
showCitations?: boolean;
|
||||||
/**
|
|
||||||
* Whether to render the one-line summary of the call's arguments (e.g. the
|
|
||||||
* search query) under the label. Defaults to true (the internal chat). The
|
|
||||||
* public share passes false: an anonymous reader should not see the agent's
|
|
||||||
* raw query/argument text. Conservative and reversible — it only suppresses
|
|
||||||
* the extra summary line, leaving the card (the action log) intact.
|
|
||||||
*/
|
|
||||||
showInput?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,14 +31,12 @@ interface ToolCallCardProps {
|
|||||||
export default function ToolCallCard({
|
export default function ToolCallCard({
|
||||||
part,
|
part,
|
||||||
showCitations = true,
|
showCitations = true,
|
||||||
showInput = true,
|
|
||||||
}: ToolCallCardProps) {
|
}: ToolCallCardProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const toolName = getToolName(part);
|
const toolName = getToolName(part);
|
||||||
const state = toolRunState(part.state);
|
const state = toolRunState(part.state);
|
||||||
const { key, values } = toolLabelKey(toolName);
|
const { key, values } = toolLabelKey(toolName);
|
||||||
const citations = showCitations ? toolCitations(part) : [];
|
const citations = showCitations ? toolCitations(part) : [];
|
||||||
const inputSummary = showInput ? toolInputSummary(part) : undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.toolCard}>
|
<div className={classes.toolCard}>
|
||||||
@@ -68,12 +57,6 @@ export default function ToolCallCard({
|
|||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{inputSummary && (
|
|
||||||
<Text size="xs" c="dimmed" mt={2} lineClamp={2}>
|
|
||||||
{inputSummary}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{state === "error" && part.errorText && (
|
{state === "error" && part.errorText && (
|
||||||
<Text size="xs" c="red" mt={2}>
|
<Text size="xs" c="red" mt={2}>
|
||||||
{part.errorText}
|
{part.errorText}
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
import React from "react";
|
|
||||||
import { renderHook, waitFor } from "@testing-library/react";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
// react-i18next / notifications are pulled in transitively by ai-chat-query.ts
|
|
||||||
// (the mutation hooks use them); stub so the module imports cleanly.
|
|
||||||
vi.mock("react-i18next", () => ({
|
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
|
||||||
}));
|
|
||||||
vi.mock("@mantine/notifications", () => ({
|
|
||||||
notifications: { show: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock the service module; only getAiChatMessages is exercised, but the other
|
|
||||||
// named exports must exist so ai-chat-query.ts imports resolve.
|
|
||||||
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
|
||||||
getAiChatMessages: vi.fn(),
|
|
||||||
getAiChats: vi.fn(),
|
|
||||||
getAiRoleCatalog: vi.fn(),
|
|
||||||
getAiRoleCatalogBundle: vi.fn(),
|
|
||||||
getAiRoles: vi.fn(),
|
|
||||||
importAiRolesFromCatalog: vi.fn(),
|
|
||||||
createAiRole: vi.fn(),
|
|
||||||
deleteAiChat: vi.fn(),
|
|
||||||
deleteAiRole: vi.fn(),
|
|
||||||
renameAiChat: vi.fn(),
|
|
||||||
updateAiRole: vi.fn(),
|
|
||||||
updateAiRoleFromCatalog: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { getAiChatMessages } from "@/features/ai-chat/services/ai-chat-service.ts";
|
|
||||||
import { useAiChatMessagesQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
|
||||||
|
|
||||||
const emptyPage = { items: [], meta: { hasNextPage: false, nextCursor: null } };
|
|
||||||
|
|
||||||
function createWrapper() {
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false } },
|
|
||||||
});
|
|
||||||
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The degraded-poll fallback (#184 phase 1.5) is threaded into this query as a
|
|
||||||
// `refetchInterval`; AiChatWindow supplies the deliberately-dumb callback. These
|
|
||||||
// pin the plumbing the window depends on: the interval polls the message history,
|
|
||||||
// and — critically — fetch ERRORS do NOT stop the tick (TanStack v5 resets the
|
|
||||||
// failure count each fetch, so the poll must survive a server restart).
|
|
||||||
describe("useAiChatMessagesQuery — degraded refetchInterval", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-polls at the interval while the callback returns a duration", async () => {
|
|
||||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
|
||||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT re-poll when the callback returns false", async () => {
|
|
||||||
vi.mocked(getAiChatMessages).mockResolvedValue(emptyPage as never);
|
|
||||||
renderHook(() => useAiChatMessagesQuery("c1", () => false), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1),
|
|
||||||
);
|
|
||||||
// Give any errant interval a chance to fire, then assert it did not.
|
|
||||||
await new Promise((r) => setTimeout(r, 60));
|
|
||||||
expect(vi.mocked(getAiChatMessages)).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps ticking through fetch errors (errors do not gate the poll)", async () => {
|
|
||||||
vi.mocked(getAiChatMessages).mockRejectedValue(new Error("server down"));
|
|
||||||
renderHook(() => useAiChatMessagesQuery("c1", () => 30), {
|
|
||||||
wrapper: createWrapper(),
|
|
||||||
});
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(vi.mocked(getAiChatMessages).mock.calls.length).toBeGreaterThan(2),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
deleteAiChat,
|
deleteAiChat,
|
||||||
deleteAiRole,
|
deleteAiRole,
|
||||||
getAiChatMessages,
|
getAiChatMessages,
|
||||||
|
getAiChatRun,
|
||||||
getAiChats,
|
getAiChats,
|
||||||
getAiRoleCatalog,
|
getAiRoleCatalog,
|
||||||
getAiRoleCatalogBundle,
|
getAiRoleCatalogBundle,
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
IAiChat,
|
IAiChat,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
IAiRoleCatalog,
|
IAiRoleCatalog,
|
||||||
IAiRoleCatalogBundle,
|
IAiRoleCatalogBundle,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
IAiRoleUpdateFromCatalogResult,
|
IAiRoleUpdateFromCatalogResult,
|
||||||
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { runPollInterval } from "@/features/ai-chat/utils/run-polling.ts";
|
||||||
|
|
||||||
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
export const AI_CHATS_RQ_KEY = ["ai-chats"];
|
||||||
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
export const AI_ROLES_RQ_KEY = ["ai-roles"];
|
||||||
@@ -52,6 +55,7 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
|||||||
"ai-chat-messages",
|
"ai-chat-messages",
|
||||||
chatId,
|
chatId,
|
||||||
];
|
];
|
||||||
|
export const AI_CHAT_RUN_RQ_KEY = (chatId: string) => ["ai-chat-run", chatId];
|
||||||
|
|
||||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||||
export function useAiChatsQuery() {
|
export function useAiChatsQuery() {
|
||||||
@@ -85,15 +89,7 @@ export function useAiChatsQuery() {
|
|||||||
* Load all persisted messages of a chat (oldest first), flattening the
|
* Load all persisted messages of a chat (oldest first), flattening the
|
||||||
* paginated server response. Used to seed `useChat` initial messages.
|
* paginated server response. Used to seed `useChat` initial messages.
|
||||||
*/
|
*/
|
||||||
export function useAiChatMessagesQuery(
|
export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||||
chatId: string | undefined,
|
|
||||||
// #184 phase 1.5: the degraded-poll fallback. When a tab could not attach to a
|
|
||||||
// still-running run (the attach returned 204 / the resumed stream ended with no
|
|
||||||
// terminal row), the window arms a dumb timed poll of the message history to
|
|
||||||
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
|
||||||
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
|
||||||
refetchInterval?: number | false | (() => number | false),
|
|
||||||
) {
|
|
||||||
const query = useInfiniteQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
@@ -104,7 +100,6 @@ export function useAiChatMessagesQuery(
|
|||||||
? (lastPage.meta.nextCursor ?? undefined)
|
? (lastPage.meta.nextCursor ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
enabled: !!chatId,
|
enabled: !!chatId,
|
||||||
refetchInterval,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||||
@@ -144,6 +139,34 @@ export function useAiChatMessagesQuery(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to a chat's latest agent run and LIVE-FOLLOW it (#184). While the run
|
||||||
|
* is active the query re-polls every {@link runPollInterval} ms (driven off the
|
||||||
|
* fetched `run.status`, the same status-keyed refetchInterval pattern as the
|
||||||
|
* embeddings reindex polling); once the run reaches a terminal status — or there
|
||||||
|
* is no run — the interval returns `false` and polling stops on its own. Polling
|
||||||
|
* is thus naturally bounded by the run terminating; no separate timeout cap.
|
||||||
|
*
|
||||||
|
* `enabled` gates the whole thing: callers pass `false` when the autonomous-runs
|
||||||
|
* feature is off (the endpoint is NOT flag-gated server-side, but with the feature
|
||||||
|
* off the chat has no runs, so polling would only ever return `{ run: null }`) OR
|
||||||
|
* when THIS tab is the one actively streaming the run (the live SSE owns the view,
|
||||||
|
* so we must not also poll/merge). The global `retry: false` means a failed fetch
|
||||||
|
* leaves `data` undefined, so refetchInterval(undefined run) returns false — a
|
||||||
|
* failed fetch can never spin a tight loop.
|
||||||
|
*/
|
||||||
|
export function useAiChatRunQuery(
|
||||||
|
chatId: string | undefined,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQuery<IAiChatRunResponse, Error>({
|
||||||
|
queryKey: AI_CHAT_RUN_RQ_KEY(chatId ?? ""),
|
||||||
|
queryFn: () => getAiChatRun(chatId as string),
|
||||||
|
enabled: !!chatId && enabled,
|
||||||
|
refetchInterval: (query) => runPollInterval(query.state.data?.run),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useRenameAiChatMutation() {
|
export function useRenameAiChatMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { IAiChatRunResponse } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
// react-i18next is pulled in transitively by ai-chat-query.ts (the mutation hooks
|
||||||
|
// use it); stub it so the module imports cleanly in this hook test.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the whole service module; only getAiChatRun is exercised here, but the
|
||||||
|
// other named exports must exist so ai-chat-query.ts imports resolve.
|
||||||
|
vi.mock("@/features/ai-chat/services/ai-chat-service.ts", () => ({
|
||||||
|
getAiChatRun: vi.fn(),
|
||||||
|
getAiChatMessages: vi.fn(),
|
||||||
|
getAiChats: vi.fn(),
|
||||||
|
getAiRoleCatalog: vi.fn(),
|
||||||
|
getAiRoleCatalogBundle: vi.fn(),
|
||||||
|
getAiRoles: vi.fn(),
|
||||||
|
importAiRolesFromCatalog: vi.fn(),
|
||||||
|
createAiRole: vi.fn(),
|
||||||
|
deleteAiChat: vi.fn(),
|
||||||
|
deleteAiRole: vi.fn(),
|
||||||
|
renameAiChat: vi.fn(),
|
||||||
|
updateAiRole: vi.fn(),
|
||||||
|
updateAiRoleFromCatalog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getAiChatRun } from "@/features/ai-chat/services/ai-chat-service.ts";
|
||||||
|
import { useAiChatRunQuery } from "@/features/ai-chat/queries/ai-chat-query.ts";
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningResponse: IAiChatRunResponse = {
|
||||||
|
run: { id: "run-1", chatId: "c1", status: "running" },
|
||||||
|
message: {
|
||||||
|
id: "a1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "working...",
|
||||||
|
createdAt: "2026-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("useAiChatRunQuery — enable gating", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the run when enabled (passive observer, feature on)", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
const { result } = renderHook(() => useAiChatRunQuery("c1", true), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(getAiChatRun).toHaveBeenCalledWith("c1");
|
||||||
|
expect(result.current.data?.run?.status).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fetch when disabled (this tab is the streamer / feature off)", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
renderHook(() => useAiChatRunQuery("c1", false), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
// Give any errant fetch a chance to fire, then assert none did.
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fetch when there is no chat id", async () => {
|
||||||
|
vi.mocked(getAiChatRun).mockResolvedValue(runningResponse);
|
||||||
|
renderHook(() => useAiChatRunQuery(undefined, true), {
|
||||||
|
wrapper: createWrapper(),
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(getAiChatRun).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IAiChatListParams,
|
IAiChatListParams,
|
||||||
IAiChatMessageRow,
|
IAiChatMessageRow,
|
||||||
IAiChatMessagesParams,
|
IAiChatMessagesParams,
|
||||||
|
IAiChatRunResponse,
|
||||||
IAiRole,
|
IAiRole,
|
||||||
IAiRoleCatalog,
|
IAiRoleCatalog,
|
||||||
IAiRoleCatalogBundle,
|
IAiRoleCatalogBundle,
|
||||||
@@ -42,6 +43,23 @@ export async function getAiChatMessages(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the latest agent run of a chat (#184). Returns the run's
|
||||||
|
* persisted lifecycle state and the assistant message it materializes (the
|
||||||
|
* partial output while the run is in-flight, the final output once it finished).
|
||||||
|
* The DB is the source of truth, so this works for an in-flight run (the browser
|
||||||
|
* dropped, the run kept going) and a finished one alike; `{ run: null }` when the
|
||||||
|
* chat has never had a run. Owner-gated server-side (the requesting user must own
|
||||||
|
* the chat); it is NOT flag-gated — when the feature is off the chat simply has no
|
||||||
|
* runs, so the endpoint returns `{ run: null }`.
|
||||||
|
*/
|
||||||
|
export async function getAiChatRun(
|
||||||
|
chatId: string,
|
||||||
|
): Promise<IAiChatRunResponse> {
|
||||||
|
const req = await api.post<IAiChatRunResponse>("/ai-chat/run", { chatId });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
* Explicitly STOP the active agent run of a chat (#184). This is the ONLY thing
|
||||||
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
* that ends a DETACHED run — a mere browser disconnect (aborting the local SSE)
|
||||||
|
|||||||
@@ -210,14 +210,41 @@ export interface IAiChatMessageRow {
|
|||||||
// renders a "stopped" marker on interrupted turns.
|
// renders a "stopped" marker on interrupted turns.
|
||||||
finishReason?: string;
|
finishReason?: string;
|
||||||
} | null;
|
} | null;
|
||||||
// Persisted lifecycle status of the row's turn, carried on the wire by
|
|
||||||
// `baseFields`. 'streaming' marks a still-in-progress assistant row (used by
|
|
||||||
// the resume machinery to decide whether a tail is a live stream to attach to
|
|
||||||
// or a settled row that must not be replayed).
|
|
||||||
status?: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A persisted agent-run row (#184), mirroring the `ai_chat_runs` fields the
|
||||||
|
* client reads from `POST /ai-chat/run`. Only `status` is load-bearing for the
|
||||||
|
* reconnect-and-live-update UX (it drives the poll cadence); the rest are carried
|
||||||
|
* for display/diagnostics. The DB is the source of truth, so this resolves for an
|
||||||
|
* in-flight run (the browser dropped, the run kept going) and a finished one.
|
||||||
|
*/
|
||||||
|
export interface IAiChatRun {
|
||||||
|
id: string;
|
||||||
|
chatId: string;
|
||||||
|
// 'pending' | 'running' | 'succeeded' | 'failed' | 'aborted'. The first two are
|
||||||
|
// ACTIVE (keep polling); the rest are TERMINAL (stop polling).
|
||||||
|
status: "pending" | "running" | "succeeded" | "failed" | "aborted" | string;
|
||||||
|
error?: string | null;
|
||||||
|
stepCount?: number;
|
||||||
|
assistantMessageId?: string | null;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response of `POST /ai-chat/run` (#184): the latest run of a chat and the
|
||||||
|
* assistant message it materializes (the partial/final output, projected from the
|
||||||
|
* persisted rows). Both are `null` when the chat has never had a run.
|
||||||
|
*/
|
||||||
|
export interface IAiChatRunResponse {
|
||||||
|
run: IAiChatRun | null;
|
||||||
|
message: IAiChatMessageRow | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IAiChatListParams extends QueryParams {}
|
export interface IAiChatListParams extends QueryParams {}
|
||||||
|
|
||||||
export interface IAiChatMessagesParams {
|
export interface IAiChatMessagesParams {
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import type { UIMessage } from "@ai-sdk/react";
|
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
import {
|
|
||||||
isStreamingTail,
|
|
||||||
isSettledAssistantTail,
|
|
||||||
seedRows,
|
|
||||||
mergeById,
|
|
||||||
} from "./resume-helpers.ts";
|
|
||||||
|
|
||||||
function row(
|
|
||||||
id: string,
|
|
||||||
role: string,
|
|
||||||
status?: string,
|
|
||||||
): IAiChatMessageRow {
|
|
||||||
return { id, role, content: "", status, createdAt: "2026-01-01T00:00:00Z" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeMsg(id: string, text: string): UIMessage {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text }],
|
|
||||||
} as UIMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("isStreamingTail", () => {
|
|
||||||
it("is true when the last row is a streaming assistant row", () => {
|
|
||||||
expect(
|
|
||||||
isStreamingTail([row("u1", "user"), row("a1", "assistant", "streaming")]),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for a settled assistant tail", () => {
|
|
||||||
expect(isStreamingTail([row("a1", "assistant", "succeeded")])).toBe(false);
|
|
||||||
expect(isStreamingTail([row("a1", "assistant")])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when the tail is a user row or the list is empty", () => {
|
|
||||||
expect(isStreamingTail([row("u1", "user")])).toBe(false);
|
|
||||||
expect(isStreamingTail([])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isSettledAssistantTail", () => {
|
|
||||||
it("is true for an assistant tail whose status is not streaming", () => {
|
|
||||||
expect(isSettledAssistantTail([row("a1", "assistant", "succeeded")])).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(isSettledAssistantTail([row("a1", "assistant")])).toBe(true);
|
|
||||||
expect(isSettledAssistantTail([row("a1", "assistant", "aborted")])).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false for a streaming assistant tail", () => {
|
|
||||||
expect(isSettledAssistantTail([row("a1", "assistant", "streaming")])).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is false when the tail is a user row or the list is empty", () => {
|
|
||||||
expect(isSettledAssistantTail([row("u1", "user")])).toBe(false);
|
|
||||||
expect(isSettledAssistantTail([])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("seedRows", () => {
|
|
||||||
const rows = [row("u1", "user"), row("a1", "assistant", "streaming")];
|
|
||||||
|
|
||||||
it("returns the rows unchanged when not stripping", () => {
|
|
||||||
expect(seedRows(rows, false)).toBe(rows);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("drops the last row when stripping", () => {
|
|
||||||
const seeded = seedRows(rows, true);
|
|
||||||
expect(seeded).toHaveLength(1);
|
|
||||||
expect(seeded[0].id).toBe("u1");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns an empty list when stripping a single-row list", () => {
|
|
||||||
expect(seedRows([row("a1", "assistant", "streaming")], true)).toHaveLength(
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mergeById", () => {
|
|
||||||
it("replaces the message with the same id in place (per-step growth)", () => {
|
|
||||||
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
|
||||||
const incoming = makeMsg("a1", "step 1\nstep 2");
|
|
||||||
const next = mergeById(prev, incoming);
|
|
||||||
expect(next).toHaveLength(2);
|
|
||||||
expect(next[1]).toBe(incoming);
|
|
||||||
expect(next[0]).toBe(prev[0]); // untouched
|
|
||||||
expect(next).not.toBe(prev); // new array (never mutates input)
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends when the incoming message is not yet present", () => {
|
|
||||||
const prev = [makeMsg("u1", "hi")];
|
|
||||||
const incoming = makeMsg("a1", "first token");
|
|
||||||
const next = mergeById(prev, incoming);
|
|
||||||
expect(next).toHaveLength(2);
|
|
||||||
expect(next[1]).toBe(incoming);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the original list unchanged when there is nothing to merge", () => {
|
|
||||||
const prev = [makeMsg("u1", "hi")];
|
|
||||||
expect(mergeById(prev, null)).toBe(prev);
|
|
||||||
expect(mergeById(prev, undefined)).toBe(prev);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
import type { UIMessage } from "@ai-sdk/react";
|
|
||||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure decisions for the resumable-SSE resume machinery (#184 phase 1.5). A tab
|
|
||||||
* that reopens a chat whose agent run is still going attaches to the server's
|
|
||||||
* run-stream registry (replay + live tail) instead of polling snapshots; these
|
|
||||||
* small predicates decide WHICH tail is safe to resume and how to seed the store,
|
|
||||||
* extracted so they can be unit-tested in isolation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A STREAMING tail: the last persisted row is an assistant row still marked
|
|
||||||
* `status === 'streaming'`. Such a tail is stripped from the seed and rebuilt by
|
|
||||||
* the replay (`expect=live`), since the SDK's `text-start` always pushes a new
|
|
||||||
* part and replaying over a seeded in-progress row would duplicate its text.
|
|
||||||
*/
|
|
||||||
export function isStreamingTail(rows: IAiChatMessageRow[]): boolean {
|
|
||||||
const tail = rows[rows.length - 1];
|
|
||||||
return !!tail && tail.role === "assistant" && tail.status === "streaming";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A SETTLED assistant tail: the last row is an assistant row whose status is
|
|
||||||
* anything OTHER than 'streaming'. A settled assistant tail must NEVER resume —
|
|
||||||
* replaying a finished run into a store that already holds its message duplicates
|
|
||||||
* parts (`text-start` always pushes a new part).
|
|
||||||
*/
|
|
||||||
export function isSettledAssistantTail(rows: IAiChatMessageRow[]): boolean {
|
|
||||||
const tail = rows[rows.length - 1];
|
|
||||||
return !!tail && tail.role === "assistant" && tail.status !== "streaming";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seed rows for `useChat`: return the rows unchanged, or without the last row when
|
|
||||||
* `strip` is set (the streaming tail is stripped so the live replay rebuilds it
|
|
||||||
* without duplicating parts).
|
|
||||||
*/
|
|
||||||
export function seedRows(
|
|
||||||
rows: IAiChatMessageRow[],
|
|
||||||
strip: boolean,
|
|
||||||
): IAiChatMessageRow[] {
|
|
||||||
return strip ? rows.slice(0, -1) : rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge an assistant message into the rendered list by id: replace the message
|
|
||||||
* with the same id in place (the in-progress assistant row is already seeded from
|
|
||||||
* history, so per-step growth replaces it), or append it when absent. Returns a
|
|
||||||
* new array; the input is never mutated.
|
|
||||||
*/
|
|
||||||
export function mergeById(
|
|
||||||
messages: UIMessage[],
|
|
||||||
incoming: UIMessage | null | undefined,
|
|
||||||
): UIMessage[] {
|
|
||||||
if (!incoming) return messages;
|
|
||||||
const idx = messages.findIndex((m) => m.id === incoming.id);
|
|
||||||
if (idx === -1) return [...messages, incoming];
|
|
||||||
const next = messages.slice();
|
|
||||||
next[idx] = incoming;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import {
|
||||||
|
RUN_POLL_INTERVAL_MS,
|
||||||
|
isRunActive,
|
||||||
|
runPollInterval,
|
||||||
|
shouldObserveRun,
|
||||||
|
shouldClearStoppingLatch,
|
||||||
|
shouldClearLatchOnQueryError,
|
||||||
|
mergeObservedMessage,
|
||||||
|
} from "./run-polling.ts";
|
||||||
|
|
||||||
|
function makeRun(status: string): IAiChatRun {
|
||||||
|
return { id: "run-1", chatId: "c1", status };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeMsg(id: string, text: string): UIMessage {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
} as UIMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isRunActive", () => {
|
||||||
|
it("treats pending and running as active", () => {
|
||||||
|
expect(isRunActive(makeRun("pending"))).toBe(true);
|
||||||
|
expect(isRunActive(makeRun("running"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats terminal / unknown / nullish as not active", () => {
|
||||||
|
expect(isRunActive(makeRun("succeeded"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("failed"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("aborted"))).toBe(false);
|
||||||
|
expect(isRunActive(makeRun("weird-future-status"))).toBe(false);
|
||||||
|
expect(isRunActive(null)).toBe(false);
|
||||||
|
expect(isRunActive(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runPollInterval (the refetchInterval helper)", () => {
|
||||||
|
it("returns 2000ms while the run is pending/running", () => {
|
||||||
|
expect(runPollInterval(makeRun("pending"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||||
|
expect(runPollInterval(makeRun("running"))).toBe(RUN_POLL_INTERVAL_MS);
|
||||||
|
expect(RUN_POLL_INTERVAL_MS).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (stop polling) once the run is terminal", () => {
|
||||||
|
expect(runPollInterval(makeRun("succeeded"))).toBe(false);
|
||||||
|
expect(runPollInterval(makeRun("failed"))).toBe(false);
|
||||||
|
expect(runPollInterval(makeRun("aborted"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no polling) when there is no run", () => {
|
||||||
|
expect(runPollInterval(null)).toBe(false);
|
||||||
|
expect(runPollInterval(undefined)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldObserveRun (observer-vs-streamer decision)", () => {
|
||||||
|
it("observes an active run when this tab is NOT the local streamer", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("running"), false)).toBe(true);
|
||||||
|
expect(shouldObserveRun(makeRun("pending"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("observes a terminal run too (so the final output shows on reopen)", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("succeeded"), false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT observe when this tab IS the streamer (no double-render)", () => {
|
||||||
|
expect(shouldObserveRun(makeRun("running"), true)).toBe(false);
|
||||||
|
expect(shouldObserveRun(makeRun("succeeded"), true)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT observe when there is no run", () => {
|
||||||
|
expect(shouldObserveRun(null, false)).toBe(false);
|
||||||
|
expect(shouldObserveRun(undefined, false)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldClearStoppingLatch (#234 latch-release decision)", () => {
|
||||||
|
// The one case the latch SHOULD clear: we requested a stop, we are the passive
|
||||||
|
// observer (not streaming), and the CURRENT run is terminal.
|
||||||
|
it("clears only when stopping, observing, and the run is terminal", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("succeeded"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("failed"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Round-3 regression: clearing while THIS tab is still the local streamer would
|
||||||
|
// re-open the flash for the current turn the moment we switch to observer role.
|
||||||
|
// A predicate lacking the streaming gate would (wrongly) return true here.
|
||||||
|
it("does NOT clear while this tab is the local streamer", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("succeeded"),
|
||||||
|
isLocalStreaming: true,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detached run keeps growing after a local abort — while it is still
|
||||||
|
// active the latch MUST hold so the observer merge stays suppressed.
|
||||||
|
it("does NOT clear while the run is still active", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("running"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: makeRun("pending"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #234 F4: on Stop the stale PREVIOUS-turn run is removed from the cache, so the
|
||||||
|
// observed `run` is null until the current turn's run is fetched fresh. A null
|
||||||
|
// run HOLDS the latch — it can never clear against the just-removed stale run,
|
||||||
|
// only against the current turn's own terminal run once observed.
|
||||||
|
it("does NOT clear against a removed/absent run (F4 stale-run guard)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: null,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: true,
|
||||||
|
run: undefined,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear when no stop was requested", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearStoppingLatch({
|
||||||
|
stoppingRun: false,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
isLocalStreaming: false,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldClearLatchOnQueryError (#234 F7 error-safety-net decision)", () => {
|
||||||
|
// This guards the REAL anti-flash decision the component's run-query-error
|
||||||
|
// safety-net effect uses (ai-chat-window.tsx wires the effect to THIS helper,
|
||||||
|
// not a copy — so the test is non-vacuous vs the live code).
|
||||||
|
|
||||||
|
// (b) The F7 hole: a TRANSIENT run-query error while `run` is STILL ACTIVE must
|
||||||
|
// NOT clear the latch. TanStack Query v5 retains `data` on error, so
|
||||||
|
// runQueryFailed can be true while the held run is still pending/running.
|
||||||
|
// Against the PRE-F7 condition (without `!isRunActive(run)`) this would return
|
||||||
|
// true — so this assertion fails on the buggy code (non-vacuous).
|
||||||
|
it("does NOT clear on a transient error while the run is still ACTIVE (F7)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("running"),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("pending"),
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// (a) The genuine permanent-null-freeze: run cache cleared by removeQueries +
|
||||||
|
// the refetch keeps ERRORING, so `run === null`. This is the ONLY case the
|
||||||
|
// safety-net exists to cure — it MUST clear so the frozen view resumes.
|
||||||
|
it("clears on a permanent error when the run is null (permanent-null-freeze)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: undefined,
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A TERMINAL run also satisfies `!isRunActive`; clearing then is harmless — the
|
||||||
|
// terminal effect (shouldClearStoppingLatch) already clears for a terminal run,
|
||||||
|
// so this only ever agrees with it. Asserted so the (c) reasoning is pinned.
|
||||||
|
it("clears on an error when the run is terminal (harmless, agrees with terminal effect)", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: makeRun("aborted"),
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear without an actual query error", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: false,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear while this tab is the local streamer", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: true,
|
||||||
|
isLocalStreaming: true,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT clear when no stop was requested", () => {
|
||||||
|
expect(
|
||||||
|
shouldClearLatchOnQueryError({
|
||||||
|
stoppingRun: false,
|
||||||
|
isLocalStreaming: false,
|
||||||
|
runQueryFailed: true,
|
||||||
|
run: null,
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mergeObservedMessage", () => {
|
||||||
|
it("replaces the message with the same id in place (per-step growth)", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi"), makeMsg("a1", "step 1")];
|
||||||
|
const observed = makeMsg("a1", "step 1\nstep 2");
|
||||||
|
const next = mergeObservedMessage(prev, observed);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(observed);
|
||||||
|
expect(next[0]).toBe(prev[0]); // untouched
|
||||||
|
expect(next).not.toBe(prev); // new array (never mutates input)
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends when the observed message is not yet present", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
const observed = makeMsg("a1", "first token");
|
||||||
|
const next = mergeObservedMessage(prev, observed);
|
||||||
|
expect(next).toHaveLength(2);
|
||||||
|
expect(next[1]).toBe(observed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the original list unchanged when there is nothing to merge", () => {
|
||||||
|
const prev = [makeMsg("u1", "hi")];
|
||||||
|
expect(mergeObservedMessage(prev, null)).toBe(prev);
|
||||||
|
expect(mergeObservedMessage(prev, undefined)).toBe(prev);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import type { UIMessage } from "@ai-sdk/react";
|
||||||
|
import type { IAiChatRun } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect-and-live-follow helpers (#184). When a chat is reopened while its
|
||||||
|
* agent run is STILL going, this tab is a PASSIVE OBSERVER: it did not start the
|
||||||
|
* run here (no local SSE stream), so it catches up by POLLING the reconnect
|
||||||
|
* endpoint (`POST /ai-chat/run`) and merging the run's incrementally-persisted
|
||||||
|
* assistant message into the rendered thread. These are the small pure decisions
|
||||||
|
* that machinery hangs off, extracted so they can be unit-tested in isolation
|
||||||
|
* (mirrors how reindex polling / editor-sync-state are tested).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** How often to re-poll the reconnect endpoint while a run is ACTIVE. */
|
||||||
|
export const RUN_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
|
// 'pending' and 'running' are the two ACTIVE statuses; 'succeeded' | 'failed' |
|
||||||
|
// 'aborted' are TERMINAL (and any unknown future status is treated as terminal,
|
||||||
|
// so a stale/odd value never polls forever).
|
||||||
|
const ACTIVE_STATUSES = new Set(["pending", "running"]);
|
||||||
|
|
||||||
|
/** Whether a run is still going (worth polling / merging live updates from). */
|
||||||
|
export function isRunActive(run: IAiChatRun | null | undefined): boolean {
|
||||||
|
return !!run && ACTIVE_STATUSES.has(run.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The TanStack Query `refetchInterval` value for the run query: poll every
|
||||||
|
* {@link RUN_POLL_INTERVAL_MS} while the run is active, and `false` (stop) once
|
||||||
|
* it is terminal or there is no run. Polling is thus naturally bounded by the run
|
||||||
|
* reaching a terminal status — no separate timeout cap is needed.
|
||||||
|
*/
|
||||||
|
export function runPollInterval(
|
||||||
|
run: IAiChatRun | null | undefined,
|
||||||
|
): number | false {
|
||||||
|
return isRunActive(run) ? RUN_POLL_INTERVAL_MS : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observer-vs-streamer decision. We render the polled run message (catch up +
|
||||||
|
* keep advancing) ONLY when this tab is a passive observer: there IS a run AND
|
||||||
|
* this tab is NOT the one locally streaming it (we reconnected, we didn't start
|
||||||
|
* it here). When this tab is the streamer, the live SSE stream owns the view, so
|
||||||
|
* we neither poll nor merge — avoiding a double-render fight. Terminal runs still
|
||||||
|
* merge (so the final persisted output is shown on reopen); the poll itself is
|
||||||
|
* stopped separately by {@link runPollInterval}.
|
||||||
|
*/
|
||||||
|
export function shouldObserveRun(
|
||||||
|
run: IAiChatRun | null | undefined,
|
||||||
|
localStreaming: boolean,
|
||||||
|
): boolean {
|
||||||
|
return !!run && !localStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the "stopping" latch — which suppresses the observer re-stream flash
|
||||||
|
* after the user pressed Stop — be RELEASED now? All three must hold:
|
||||||
|
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||||
|
* - `!isLocalStreaming`: this tab is NOT the local streamer. While we are the
|
||||||
|
* streamer the run query is disabled, so the observed `run` is not the run we
|
||||||
|
* are following — releasing the latch then would re-open the flash for the
|
||||||
|
* current turn the instant we switch to observer role;
|
||||||
|
* - the observed `run` EXISTS and has reached a TERMINAL status.
|
||||||
|
*
|
||||||
|
* The null / still-active `run` case is the #234 F4 invariant. On Stop the stale
|
||||||
|
* PREVIOUS-turn run is removed from the query cache (`removeQueries`), so `run`
|
||||||
|
* is null until the CURRENT turn's run is re-fetched fresh; a null or active run
|
||||||
|
* therefore HOLDS the latch, so it can only ever clear against the current turn's
|
||||||
|
* OWN terminal run — never a stale cached one. (The cache removal itself is
|
||||||
|
* integration-level in AiChatWindow; this predicate encodes the decision given
|
||||||
|
* whatever run is currently observed, and a stale terminal run is
|
||||||
|
* indistinguishable from a current terminal run at the predicate level — hence
|
||||||
|
* the cache removal is what guarantees only the current run is ever passed here.)
|
||||||
|
*/
|
||||||
|
export function shouldClearStoppingLatch(args: {
|
||||||
|
stoppingRun: boolean;
|
||||||
|
run: IAiChatRun | null | undefined;
|
||||||
|
isLocalStreaming: boolean;
|
||||||
|
}): boolean {
|
||||||
|
const { stoppingRun, run, isLocalStreaming } = args;
|
||||||
|
if (!stoppingRun || isLocalStreaming) return false;
|
||||||
|
return !!run && !isRunActive(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Should the "stopping" latch be RELEASED by the run-query ERROR safety-net?
|
||||||
|
* (#234 F7 — a NEW path of the same re-stream flash the F4 latch exists to
|
||||||
|
* prevent.) After Stop, `handleServerStop` clears the run cache; the terminal
|
||||||
|
* effect then holds the latch via `if (!run) return` until the CURRENT turn's run
|
||||||
|
* is fetched fresh. If that refetch instead ERRORS permanently, `run` stays null,
|
||||||
|
* its status-keyed refetchInterval is off, and nothing would ever observe a
|
||||||
|
* terminal run — freezing the view with the observer merge suppressed. This
|
||||||
|
* safety-net cures ONLY that genuine permanent-null-freeze.
|
||||||
|
*
|
||||||
|
* All four must hold:
|
||||||
|
* - `stoppingRun`: we actually requested a stop (otherwise nothing to release);
|
||||||
|
* - `!isLocalStreaming`: this tab is NOT the local streamer (same reason as
|
||||||
|
* {@link shouldClearStoppingLatch});
|
||||||
|
* - `runQueryFailed`: the run query is in its error state (TanStack Query v5 with
|
||||||
|
* retry:false — isError);
|
||||||
|
* - `!isRunActive(run)`: the observed `run` is NOT an active (pending/running)
|
||||||
|
* held run. This is the F7 gate. In TanStack Query v5 the query's `data` is
|
||||||
|
* RETAINED on error, so `runQueryFailed` can be true while `run` is STILL an
|
||||||
|
* ACTIVE run (a single transient GET-run failure in the window between Stop and
|
||||||
|
* settle). Without this gate a transient error would release the latch early —
|
||||||
|
* re-opening the observer merge and flashing the growing detached run over the
|
||||||
|
* frozen row (exactly the F4 flash). Gating on the run NOT being active means we
|
||||||
|
* only ever cure the permanent-null-freeze (`run === null`, so
|
||||||
|
* `isRunActive(null)` is false), never release against an active run.
|
||||||
|
*
|
||||||
|
* (A terminal `run` also satisfies `!isRunActive(run)`; clearing then is harmless
|
||||||
|
* — the terminal effect's {@link shouldClearStoppingLatch} already clears the
|
||||||
|
* latch for a terminal run, so this only ever agrees with it, never conflicts.)
|
||||||
|
*
|
||||||
|
* INVARIANT (do not break): clearing the latch on the `run === null` branch is safe
|
||||||
|
* ONLY because the run query's `refetchInterval` (see {@link runPollInterval}) stops
|
||||||
|
* polling when the data is empty — so after we clear on null+error there is no
|
||||||
|
* subsequent auto-poll that could return a still-active detached run and re-open the
|
||||||
|
* merge. If `refetchInterval` is ever changed to keep polling on `run === null`/on
|
||||||
|
* error, this null-branch clear would re-open the F7 flash through the null path.
|
||||||
|
* Do not change the run query's refetchInterval without re-checking this path.
|
||||||
|
*/
|
||||||
|
export function shouldClearLatchOnQueryError(args: {
|
||||||
|
stoppingRun: boolean;
|
||||||
|
isLocalStreaming: boolean;
|
||||||
|
runQueryFailed: boolean;
|
||||||
|
run: IAiChatRun | null | undefined;
|
||||||
|
}): boolean {
|
||||||
|
const { stoppingRun, isLocalStreaming, runQueryFailed, run } = args;
|
||||||
|
return (
|
||||||
|
stoppingRun && !isLocalStreaming && runQueryFailed && !isRunActive(run)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an observed assistant message into the rendered list: replace the message
|
||||||
|
* with the same id in place (the in-progress assistant row is already seeded from
|
||||||
|
* history, so per-step growth replaces it), or append it when absent. Returns a
|
||||||
|
* new array; the input is never mutated.
|
||||||
|
*/
|
||||||
|
export function mergeObservedMessage(
|
||||||
|
messages: UIMessage[],
|
||||||
|
observed: UIMessage | null | undefined,
|
||||||
|
): UIMessage[] {
|
||||||
|
if (!observed) return messages;
|
||||||
|
const idx = messages.findIndex((m) => m.id === observed.id);
|
||||||
|
if (idx === -1) return [...messages, observed];
|
||||||
|
const next = messages.slice();
|
||||||
|
next[idx] = observed;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
toolCitations,
|
toolCitations,
|
||||||
toolInputSummary,
|
|
||||||
toolRunState,
|
toolRunState,
|
||||||
type ToolUiPart,
|
type ToolUiPart,
|
||||||
} from "./tool-parts";
|
} from "./tool-parts";
|
||||||
@@ -78,138 +77,6 @@ describe("toolCitations", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toolInputSummary", () => {
|
|
||||||
it("returns the primary `query` string", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-Search_web_search",
|
|
||||||
state: "input-available",
|
|
||||||
input: { query: "hello world" },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBe("hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("summarizes a primary array field with a (+N) suffix", () => {
|
|
||||||
// `urls` is an external MCP read_pages-style list; the first element plus a
|
|
||||||
// count of the rest.
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-read_pages",
|
|
||||||
state: "input-available",
|
|
||||||
input: { urls: ["a", "b", "c"] },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBe("a (+2)");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits the (+N) suffix for a single-element array", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-read_pages",
|
|
||||||
state: "input-available",
|
|
||||||
input: { urls: ["only"] },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBe("only");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to `title` for a page op with no query", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-createPage",
|
|
||||||
state: "input-available",
|
|
||||||
input: { pageId: "x", title: "My Page" },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBe("My Page");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers the earlier primary field when several are present", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-x",
|
|
||||||
state: "input-available",
|
|
||||||
// `query` outranks `title` in PRIMARY_INPUT_FIELDS — the ordered list is
|
|
||||||
// the contract, so a reordering must break this test.
|
|
||||||
input: { query: "Q", title: "T" },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBe("Q");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not clamp a value exactly at the 140-char limit", () => {
|
|
||||||
const exact = "a".repeat(140);
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-Search_web_search",
|
|
||||||
state: "input-available",
|
|
||||||
input: { query: exact },
|
|
||||||
};
|
|
||||||
const out = toolInputSummary(part)!;
|
|
||||||
expect(out).toBe(exact);
|
|
||||||
expect(out.endsWith("…")).toBe(false);
|
|
||||||
expect(out.length).toBe(140);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps one char over the limit (141 -> 140 + ellipsis)", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-Search_web_search",
|
|
||||||
state: "input-available",
|
|
||||||
input: { query: "a".repeat(141) },
|
|
||||||
};
|
|
||||||
const out = toolInputSummary(part)!;
|
|
||||||
expect(out.endsWith("…")).toBe(true);
|
|
||||||
expect(out.length).toBe(141);
|
|
||||||
expect(out).toBe("a".repeat(140) + "…");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clamps a long value to ~140 chars with an ellipsis", () => {
|
|
||||||
const long = "a".repeat(300);
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-Search_web_search",
|
|
||||||
state: "input-available",
|
|
||||||
input: { query: long },
|
|
||||||
};
|
|
||||||
const out = toolInputSummary(part)!;
|
|
||||||
expect(out.endsWith("…")).toBe(true);
|
|
||||||
expect(out.length).toBeLessThanOrEqual(141);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("collapses newlines and repeated spaces to single spaces", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-Search_web_search",
|
|
||||||
state: "input-available",
|
|
||||||
input: { query: " foo\n\n bar baz " },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBe("foo bar baz");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined with no input", () => {
|
|
||||||
expect(
|
|
||||||
toolInputSummary({ type: "tool-x", state: "input-available" }),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for an empty object input", () => {
|
|
||||||
expect(
|
|
||||||
toolInputSummary({
|
|
||||||
type: "tool-x",
|
|
||||||
state: "input-available",
|
|
||||||
input: {},
|
|
||||||
}),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined for a non-object input", () => {
|
|
||||||
expect(
|
|
||||||
toolInputSummary({
|
|
||||||
type: "tool-x",
|
|
||||||
state: "input-available",
|
|
||||||
input: "just a string",
|
|
||||||
}),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns undefined while the input is still streaming (even with a full input)", () => {
|
|
||||||
const part: ToolUiPart = {
|
|
||||||
type: "tool-Search_web_search",
|
|
||||||
state: "input-streaming",
|
|
||||||
input: { query: "hello world" },
|
|
||||||
};
|
|
||||||
expect(toolInputSummary(part)).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("toolRunState", () => {
|
describe("toolRunState", () => {
|
||||||
it('maps "output-error" to error', () => {
|
it('maps "output-error" to error', () => {
|
||||||
expect(toolRunState("output-error")).toBe("error");
|
expect(toolRunState("output-error")).toBe("error");
|
||||||
|
|||||||
@@ -97,69 +97,6 @@ function asString(value: unknown): string | undefined {
|
|||||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collapse runs of whitespace/newlines to a single space and trim. */
|
|
||||||
function collapse(s: string): string {
|
|
||||||
return s.replace(/\s+/g, " ").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Truncate to ~140 chars, appending an ellipsis when it overflows. */
|
|
||||||
function clamp(s: string): string {
|
|
||||||
const MAX = 140;
|
|
||||||
return s.length > MAX ? s.slice(0, MAX).trimEnd() + "…" : s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Priority "primary" argument fields, in order. The first present one supplies
|
|
||||||
* the summary. `urls` is included (external MCP `read_pages`-style tools take a
|
|
||||||
* list of URLs) and is handled as an array; `url` covers the single-URL form.
|
|
||||||
*/
|
|
||||||
const PRIMARY_INPUT_FIELDS = [
|
|
||||||
"query",
|
|
||||||
"q",
|
|
||||||
"searchQuery",
|
|
||||||
"url",
|
|
||||||
"urls",
|
|
||||||
"title",
|
|
||||||
"name",
|
|
||||||
"text",
|
|
||||||
"prompt",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A short, PLAIN-TEXT one-line summary of a tool call's arguments (e.g. the
|
|
||||||
* search query), or undefined when no recognizable primary field is present.
|
|
||||||
* Rendered under the tool label so tools without a friendly name (external MCP
|
|
||||||
* tools like `Search_web_search`) still show WHAT was requested, not just a
|
|
||||||
* generic "Ran tool {{name}}". The returned string is plain text and MUST be
|
|
||||||
* rendered React-escaped (Mantine `<Text>`), never as markdown/HTML.
|
|
||||||
*
|
|
||||||
* Streaming gate: while `state === "input-streaming"` the `input` object grows
|
|
||||||
* chunk by chunk but `messageSignature` deliberately does NOT track `input`, so
|
|
||||||
* a live summary computed here would freeze at its first captured value and go
|
|
||||||
* stale. We therefore return undefined until the state flips to
|
|
||||||
* `input-available` (input finalized) — that state change IS tracked by the
|
|
||||||
* signature, so the row re-renders and shows the complete summary. Do NOT add
|
|
||||||
* `input` to `message-signature.ts` to work around this.
|
|
||||||
*/
|
|
||||||
export function toolInputSummary(part: ToolUiPart): string | undefined {
|
|
||||||
if (part.state === "input-streaming") return undefined;
|
|
||||||
if (!part.input || typeof part.input !== "object") return undefined;
|
|
||||||
const input = part.input as Record<string, unknown>;
|
|
||||||
|
|
||||||
for (const field of PRIMARY_INPUT_FIELDS) {
|
|
||||||
const value = input[field];
|
|
||||||
if (typeof value === "string" && value.length > 0) {
|
|
||||||
return clamp(collapse(value));
|
|
||||||
}
|
|
||||||
if (Array.isArray(value) && value.length > 0) {
|
|
||||||
const first = collapse(String(value[0]));
|
|
||||||
if (first.length === 0) continue;
|
|
||||||
return clamp(first + (value.length > 1 ? ` (+${value.length - 1})` : ""));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the page citation(s) a tool part references, from its input/output.
|
* Resolve the page citation(s) a tool part references, from its input/output.
|
||||||
* Only output-available parts (the tool returned) yield citations. Search
|
* Only output-available parts (the tool returned) yield citations. Search
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { Editor } from "@tiptap/core";
|
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||||
|
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||||
|
// shell component that reads one of these atoms.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||||
|
|
||||||
|
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
||||||
|
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
||||||
|
const DrawioMenu = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioMenu {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
||||||
|
// split into its own chunk and fetched only when a drawio diagram is actually
|
||||||
|
// rendered (mirrors excalidraw-view-lazy).
|
||||||
|
const DrawioView = lazy(
|
||||||
|
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function DrawioViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DrawioView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathBlockView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-block.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathBlockViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
||||||
|
<MathBlockView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { lazy, Suspense } from "react";
|
||||||
|
import { NodeViewProps } from "@tiptap/react";
|
||||||
|
|
||||||
|
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
||||||
|
// only when a document actually contains a math node (mirrors the mermaid/
|
||||||
|
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||||
|
// crashing or blocking the whole editor: while it loads we render the raw
|
||||||
|
// LaTeX source as a node-sized placeholder.
|
||||||
|
const MathInlineView = lazy(
|
||||||
|
() => import("@/features/editor/components/math/math-inline.tsx"),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function MathInlineViewLazy(props: NodeViewProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
||||||
|
<MathInlineView {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -81,8 +81,8 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
|||||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||||
import DrawioView from "../components/drawio/drawio-view";
|
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
|
||||||
import { Editor } from "@tiptap/core";
|
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||||
|
// so this always-mounted global bridge does not statically import
|
||||||
|
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
||||||
|
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
||||||
|
// already stores these raw status strings.
|
||||||
|
const YJS_STATUS_CONNECTED = "connected";
|
||||||
|
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
||||||
|
// engine) out of the eager global-shell graph — the bridge only uses it for
|
||||||
|
// annotations/casts, never as a runtime value.
|
||||||
|
import type { Editor } from "@tiptap/core";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -16,16 +25,19 @@ import {
|
|||||||
getSidebarPages,
|
getSidebarPages,
|
||||||
} from "@/features/page/services/page-service.ts";
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import {
|
// Types are erased at build time, so importing them does not pull the module's
|
||||||
|
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
||||||
|
// helpers are dynamically imported at call time inside createPageWithRecording,
|
||||||
|
// keeping the editor engine out of the eager global-shell startup graph — the
|
||||||
|
// bridge is mounted for every authenticated user but recording is a rare,
|
||||||
|
// native-host-driven action.
|
||||||
|
import type {
|
||||||
GitmostBridge,
|
GitmostBridge,
|
||||||
GitmostCreatePagePayload,
|
GitmostCreatePagePayload,
|
||||||
GitmostCreatePageResult,
|
GitmostCreatePageResult,
|
||||||
GitmostListPagesPayload,
|
GitmostListPagesPayload,
|
||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
|
||||||
gitmostInsertTranscriptIntoEditor,
|
|
||||||
gitmostUploadFileToEditor,
|
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
@@ -58,7 +70,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === WebSocketStatus.Connected;
|
yjsStatus === YJS_STATUS_CONNECTED;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
return;
|
||||||
@@ -172,6 +184,15 @@ export default function GitmostGlobalBridge() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the recording helpers on demand (see the import note above). This
|
||||||
|
// is the only place they are needed, so the @tiptap/editor-ext code they
|
||||||
|
// pull in stays out of the eager startup graph.
|
||||||
|
const {
|
||||||
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostUploadFileToEditor,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
|
} = await import("@/features/editor/gitmost/gitmost-recording.ts");
|
||||||
|
|
||||||
// Validate/decode the recording BEFORE creating the page so a bad
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
// payload never leaves an empty junk page behind. Per the createPage
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
// error contract, any decode failure collapses to "insert-failed" (the
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { EditorState, TextSelection } from "@tiptap/pm/state";
|
|
||||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
|
||||||
import { UniqueID } from "@docmost/editor-ext";
|
|
||||||
import { getEditorSelectionContext } from "./get-editor-selection";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for getEditorSelectionContext (#388). Built on a headless
|
|
||||||
* ProseMirror schema (Document + Paragraph + Text + the block-id UniqueID
|
|
||||||
* extension), mirroring the editor-ext test style. We assemble docs with
|
|
||||||
* explicit block ids so the covered-blockIds assertions are deterministic.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// A schema that carries the `id` block attribute (UniqueID) on paragraphs, just
|
|
||||||
// like the real editor.
|
|
||||||
const { schema } = new Editor({
|
|
||||||
extensions: [
|
|
||||||
Document,
|
|
||||||
Paragraph,
|
|
||||||
Text,
|
|
||||||
UniqueID.configure({ types: ["paragraph"] }),
|
|
||||||
],
|
|
||||||
content: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
function docOf(blocks: { id: string; text: string }[]): PMNode {
|
|
||||||
return schema.node(
|
|
||||||
"doc",
|
|
||||||
null,
|
|
||||||
blocks.map((b) =>
|
|
||||||
schema.node("paragraph", { id: b.id }, b.text ? schema.text(b.text) : []),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stateWith(doc: PMNode, from: number, to: number): EditorState {
|
|
||||||
const base = EditorState.create({ schema, doc });
|
|
||||||
return base.apply(base.tr.setSelection(TextSelection.create(doc, from, to)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select every text position of the doc (pos 1 .. content.size - 1).
|
|
||||||
function selectAll(doc: PMNode): EditorState {
|
|
||||||
return stateWith(doc, 1, doc.content.size - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("getEditorSelectionContext", () => {
|
|
||||||
it("returns null for an empty (collapsed) selection", () => {
|
|
||||||
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
|
||||||
const state = stateWith(doc, 3, 3); // caret, from === to
|
|
||||||
expect(getEditorSelectionContext(state)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null for the default caret-at-start of a fresh editor", () => {
|
|
||||||
const editor = new Editor({
|
|
||||||
extensions: [
|
|
||||||
Document,
|
|
||||||
Paragraph,
|
|
||||||
Text,
|
|
||||||
UniqueID.configure({ types: ["paragraph"] }),
|
|
||||||
],
|
|
||||||
content: "<p>fresh</p>",
|
|
||||||
});
|
|
||||||
expect(getEditorSelectionContext(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads a single-paragraph selection with no block-separator artifacts", () => {
|
|
||||||
const doc = docOf([{ id: "b1", text: "Hello world" }]);
|
|
||||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
|
||||||
expect(sel.text).toBe("Hello world");
|
|
||||||
expect(sel.blockIds).toEqual(["b1"]);
|
|
||||||
expect(sel.truncated).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("joins multiple blocks with a newline and collects all covered blockIds", () => {
|
|
||||||
const doc = docOf([
|
|
||||||
{ id: "b1", text: "First" },
|
|
||||||
{ id: "b2", text: "Second" },
|
|
||||||
]);
|
|
||||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
|
||||||
expect(sel.text).toBe("First\nSecond");
|
|
||||||
expect(sel.blockIds).toEqual(["b1", "b2"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caps the text at 2000 chars and flags truncated", () => {
|
|
||||||
const doc = docOf([{ id: "b1", text: "x".repeat(2500) }]);
|
|
||||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
|
||||||
expect(sel.text).toHaveLength(2000);
|
|
||||||
expect(sel.truncated).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("computes before/after context and clamps it to the doc bounds", () => {
|
|
||||||
// One paragraph "0123456789abcdefghij"; select the middle "56789".
|
|
||||||
const doc = docOf([{ id: "b1", text: "0123456789abcdefghij" }]);
|
|
||||||
// text char i lives at pos (1 + i); select chars index 5..9 -> pos 6..11.
|
|
||||||
const sel = getEditorSelectionContext(stateWith(doc, 6, 11))!;
|
|
||||||
expect(sel.text).toBe("56789");
|
|
||||||
expect(sel.before).toBe("01234");
|
|
||||||
expect(sel.after).toBe("abcdefghij");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("omits before/after at the document boundaries (never reads past 0/size)", () => {
|
|
||||||
const doc = docOf([{ id: "b1", text: "Edge" }]);
|
|
||||||
const sel = getEditorSelectionContext(selectAll(doc))!;
|
|
||||||
// Selection spans the whole single block: nothing before or after it.
|
|
||||||
expect(sel.before).toBeUndefined();
|
|
||||||
expect(sel.after).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { EditorState } from "@tiptap/pm/state";
|
|
||||||
|
|
||||||
export interface EditorSelectionContext {
|
|
||||||
text: string;
|
|
||||||
truncated?: boolean;
|
|
||||||
blockIds?: string[];
|
|
||||||
before?: string;
|
|
||||||
after?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Client-side caps. The server re-caps every field independently (defence in
|
|
||||||
// depth — the payload is attacker-controllable), so these only keep the wire
|
|
||||||
// small for the common case.
|
|
||||||
const TEXT_CAP = 2000;
|
|
||||||
const CONTEXT_CHARS = 160;
|
|
||||||
const MAX_BLOCK_IDS = 20;
|
|
||||||
|
|
||||||
// Pure: takes an EditorState so it is unit-testable with a headless editor.
|
|
||||||
// Snapshots the user's current selection into the wire shape carried inside
|
|
||||||
// openPage — plain text + the ids of the blocks it covers + a little surrounding
|
|
||||||
// context. Returns null when nothing meaningful is selected.
|
|
||||||
//
|
|
||||||
// Deliberately does NOT emit the ProseMirror positions (from/to): they rot the
|
|
||||||
// instant the document changes and the server tools address content by block id
|
|
||||||
// + text (getNode / editPageText find-replace), never by position.
|
|
||||||
export function getEditorSelectionContext(
|
|
||||||
state: EditorState,
|
|
||||||
): EditorSelectionContext | null {
|
|
||||||
const { selection, doc } = state;
|
|
||||||
// An empty selection (incl. the default caret-at-start of a fresh editor) is
|
|
||||||
// never a "this"/"here" — bail before reading any text.
|
|
||||||
if (selection.empty) return null;
|
|
||||||
|
|
||||||
const { from, to } = selection;
|
|
||||||
|
|
||||||
let text = doc.textBetween(from, to, "\n");
|
|
||||||
let truncated = false;
|
|
||||||
if (text.length > TEXT_CAP) {
|
|
||||||
text = text.slice(0, TEXT_CAP);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
// A selection spanning only non-text nodes (e.g. an image) trims to empty ->
|
|
||||||
// treat as no selection.
|
|
||||||
if (text.trim().length === 0) return null;
|
|
||||||
|
|
||||||
// Ids of every block the selection covers, deduped and capped. These bridge
|
|
||||||
// the plain-text selection to the server tools (getNode / editPageText).
|
|
||||||
const blockIds: string[] = [];
|
|
||||||
doc.nodesBetween(from, to, (node) => {
|
|
||||||
const id = node.isBlock ? node.attrs?.id : undefined;
|
|
||||||
if (typeof id === "string" && id.length > 0 && !blockIds.includes(id)) {
|
|
||||||
blockIds.push(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ~160 chars of plain text on each side, clamped to the document bounds, so
|
|
||||||
// editPageText can disambiguate a duplicate of the selected text.
|
|
||||||
const before = doc.textBetween(Math.max(0, from - CONTEXT_CHARS), from, "\n");
|
|
||||||
const after = doc.textBetween(
|
|
||||||
to,
|
|
||||||
Math.min(doc.content.size, to + CONTEXT_CHARS),
|
|
||||||
"\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
const result: EditorSelectionContext = { text };
|
|
||||||
if (truncated) result.truncated = true;
|
|
||||||
if (blockIds.length > 0) result.blockIds = blockIds.slice(0, MAX_BLOCK_IDS);
|
|
||||||
if (before.length > 0) result.before = before;
|
|
||||||
if (after.length > 0) result.after = after;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
@@ -165,9 +165,6 @@ export default function ShareAiWidget({
|
|||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
assistantName={assistantName}
|
assistantName={assistantName}
|
||||||
showCitations={false}
|
showCitations={false}
|
||||||
// Anonymous reader: suppress the tool-argument summary line so the
|
|
||||||
// agent's raw query/argument text isn't shown on the public share.
|
|
||||||
showInput={false}
|
|
||||||
// Anonymous reader: neutralize internal/relative links in the
|
// Anonymous reader: neutralize internal/relative links in the
|
||||||
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
// assistant's markdown so internal UUIDs/auth-gated routes don't
|
||||||
// leak as clickable links (external http(s) links are kept).
|
// leak as clickable links (external http(s) links are kept).
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||||
|
|
||||||
export default function ShareLayout() {
|
export default function ShareLayout() {
|
||||||
return (
|
return (
|
||||||
<ShareShell>
|
<ShareShell>
|
||||||
<Outlet />
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<Center h="60vh">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</ShareShell>
|
</ShareShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||||
// polyfilled to support execCommand fallback
|
// polyfilled to support execCommand fallback
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { execCommandCopy } from "@docmost/editor-ext";
|
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||||
|
|
||||||
export type UseClipboardOptions = {
|
export type UseClipboardOptions = {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Client-local execCommand copy fallback (previously imported from
|
||||||
|
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
||||||
|
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
||||||
|
// engine — through the eager startup graph. Behavior is identical to the
|
||||||
|
// editor-ext helper it replaces.
|
||||||
|
export function execCommandCopy(text: string): void {
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
textarea.style.top = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { sanitizeUrl } from "./sanitize-url";
|
||||||
|
|
||||||
|
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
||||||
|
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
||||||
|
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
||||||
|
// test so the extracted copy keeps the same guarantees.
|
||||||
|
describe("sanitizeUrl", () => {
|
||||||
|
it("blocks dangerous schemes (returns empty string)", () => {
|
||||||
|
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||||
|
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||||
|
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||||
|
// Case / whitespace obfuscation must not slip past the sanitizer.
|
||||||
|
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty / undefined input", () => {
|
||||||
|
expect(sanitizeUrl(undefined)).toBe("");
|
||||||
|
expect(sanitizeUrl("")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows safe https, relative file and mailto URLs", () => {
|
||||||
|
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
||||||
|
/^https:\/\/example\.com\/page/,
|
||||||
|
);
|
||||||
|
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||||
|
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
||||||
|
"mailto:user@example.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||||
|
|
||||||
|
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
||||||
|
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
||||||
|
// via the app-wide config module (getFileUrl). This keeps the exact same
|
||||||
|
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
||||||
|
// dependency.
|
||||||
|
export function sanitizeUrl(url: string | undefined): string {
|
||||||
|
if (!url) return "";
|
||||||
|
|
||||||
|
const sanitized = braintreeSanitizeUrl(url);
|
||||||
|
|
||||||
|
// Return an empty string instead of "about:blank".
|
||||||
|
return sanitized === "about:blank" ? "" : sanitized;
|
||||||
|
}
|
||||||
+60
-27
@@ -13,15 +13,14 @@ import { ModalsProvider } from "@mantine/modals";
|
|||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
|
||||||
import {
|
import {
|
||||||
getPostHogHost,
|
getPostHogHost,
|
||||||
getPostHogKey,
|
getPostHogKey,
|
||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
import posthog from "posthog-js";
|
|
||||||
import { initVitals } from "@/lib/telemetry/vitals";
|
import { initVitals } from "@/lib/telemetry/vitals";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
@@ -35,15 +34,6 @@ export const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
|
||||||
posthog.init(getPostHogKey(), {
|
|
||||||
api_host: getPostHogHost(),
|
|
||||||
defaults: "2025-05-24",
|
|
||||||
disable_session_recording: true,
|
|
||||||
capture_pageleave: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
// #355 — client perf-telemetry. Decides sampling ONCE (25%/session) before
|
||||||
// subscribing to any observer; non-sampled sessions send nothing.
|
// subscribing to any observer; non-sampled sessions send nothing.
|
||||||
initVitals();
|
initVitals();
|
||||||
@@ -51,19 +41,62 @@ initVitals();
|
|||||||
const container = document.getElementById("root") as HTMLElement;
|
const container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
root.render(
|
function renderApp() {
|
||||||
<BrowserRouter>
|
root.render(
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<BrowserRouter>
|
||||||
<ModalsProvider>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<ModalsProvider>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<QueryClientProvider client={queryClient}>
|
||||||
<HelmetProvider>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<PostHogProvider client={posthog}>
|
<HelmetProvider>
|
||||||
<App />
|
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||||
</PostHogProvider>
|
404 after a deploy is caught and recovered here instead of
|
||||||
</HelmetProvider>
|
blanking the whole app. */}
|
||||||
</QueryClientProvider>
|
<ChunkLoadErrorBoundary>
|
||||||
</ModalsProvider>
|
<App />
|
||||||
</MantineProvider>
|
</ChunkLoadErrorBoundary>
|
||||||
</BrowserRouter>,
|
</HelmetProvider>
|
||||||
);
|
</QueryClientProvider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initAnalytics() {
|
||||||
|
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
||||||
|
// self-hosted builds never download it. The gate is kept identical to the
|
||||||
|
// previous eager code so cloud analytics behavior is unchanged; the import is
|
||||||
|
// simply deferred behind it.
|
||||||
|
//
|
||||||
|
// Crucially this runs AFTER the immediate first render below, so first paint is
|
||||||
|
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
||||||
|
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
||||||
|
// working app without analytics instead of a permanently blank page.
|
||||||
|
//
|
||||||
|
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
||||||
|
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
||||||
|
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
||||||
|
// an already-initialized `client` is a no-op — all capture goes through the
|
||||||
|
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
||||||
|
// App (running every mount effect twice and dropping local state / focus /
|
||||||
|
// in-progress input on cloud cold-load) for no functional gain.
|
||||||
|
if (!(isCloud() && isPostHogEnabled)) return;
|
||||||
|
try {
|
||||||
|
const { default: posthog } = await import("posthog-js");
|
||||||
|
posthog.init(getPostHogKey(), {
|
||||||
|
api_host: getPostHogHost(),
|
||||||
|
defaults: "2025-05-24",
|
||||||
|
disable_session_recording: true,
|
||||||
|
capture_pageleave: false,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Analytics failed to load — degrade gracefully; the app already rendered.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
||||||
|
// cloud no longer blocks on the analytics import). The posthog singleton is
|
||||||
|
// initialized after, without re-rendering the tree.
|
||||||
|
renderApp();
|
||||||
|
void initAnalytics();
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ export default defineConfig(({ mode }) => {
|
|||||||
name: "vendor-mantine",
|
name: "vendor-mantine",
|
||||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||||
},
|
},
|
||||||
|
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
||||||
|
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
||||||
|
// a small module shared with the (eager) react-i18next runtime into
|
||||||
|
// that group chunk, which then drags the whole ~590KB editor engine
|
||||||
|
// into the eager modulepreload graph. Left to the default splitting,
|
||||||
|
// the editor engine stays in lazily-loaded chunks pulled only by the
|
||||||
|
// route-split editor/share pages. KaTeX is safe to group (nothing
|
||||||
|
// eager references it).
|
||||||
|
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
||||||
|
// node views (never in the startup path).
|
||||||
|
{
|
||||||
|
name: "vendor-katex",
|
||||||
|
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Hocuspocus } from '@hocuspocus/server';
|
||||||
connectedPayload,
|
|
||||||
Extension,
|
|
||||||
Hocuspocus,
|
|
||||||
onConnectPayload,
|
|
||||||
} from '@hocuspocus/server';
|
|
||||||
import { IncomingMessage } from 'http';
|
import { IncomingMessage } from 'http';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { AuthenticationExtension } from './extensions/authentication.extension';
|
import { AuthenticationExtension } from './extensions/authentication.extension';
|
||||||
@@ -30,56 +25,6 @@ import {
|
|||||||
CollaborationHandler,
|
CollaborationHandler,
|
||||||
CollabEventHandlers,
|
CollabEventHandlers,
|
||||||
} from './collaboration.handler';
|
} from './collaboration.handler';
|
||||||
import {
|
|
||||||
incDocLoad,
|
|
||||||
incDocUnload,
|
|
||||||
isMetricsEnabled,
|
|
||||||
observeCollabConnect,
|
|
||||||
registerDocsOpenSource,
|
|
||||||
} from '../integrations/metrics/metrics.registry';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #402 — collab lifecycle metrics as a lightweight hocuspocus extension.
|
|
||||||
*
|
|
||||||
* - afterLoadDocument / afterUnloadDocument (fire once PER DOCUMENT) drive the
|
|
||||||
* doc load/unload counters.
|
|
||||||
* - collab_connect_duration_seconds: I time the onConnect→connected hook pair,
|
|
||||||
* i.e. connection ACCEPTANCE (which includes the auth handshake). This is the
|
|
||||||
* cleanest per-connection correlation hocuspocus exposes: both payloads carry
|
|
||||||
* the SAME `request` IncomingMessage object, so a WeakMap keyed on it gives a
|
|
||||||
* per-connection start with NO leak (the entry is GC'd with the request if a
|
|
||||||
* connection is rejected in onAuthenticate and `connected` never fires).
|
|
||||||
* I deliberately do NOT observe at afterLoadDocument: that hook fires per
|
|
||||||
* DOCUMENT, not per connection, so a second client joining an already-open
|
|
||||||
* doc would be missed. auth/load latencies are their own separate metrics.
|
|
||||||
*
|
|
||||||
* All helpers are no-ops when METRICS_PORT is unset; these hooks are per
|
|
||||||
* connect/load/unload (never per message), so there is no hot-path cost.
|
|
||||||
*/
|
|
||||||
class CollabMetricsExtension implements Extension {
|
|
||||||
// Keyed by the per-connection request object → connect start time (ms).
|
|
||||||
private readonly connectStarts = new WeakMap<object, number>();
|
|
||||||
|
|
||||||
async onConnect(data: onConnectPayload) {
|
|
||||||
this.connectStarts.set(data.request, performance.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
async connected(data: connectedPayload) {
|
|
||||||
const start = this.connectStarts.get(data.request);
|
|
||||||
if (start !== undefined) {
|
|
||||||
observeCollabConnect((performance.now() - start) / 1000);
|
|
||||||
this.connectStarts.delete(data.request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async afterLoadDocument() {
|
|
||||||
incDocLoad();
|
|
||||||
}
|
|
||||||
|
|
||||||
async afterUnloadDocument() {
|
|
||||||
incDocUnload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
@@ -113,18 +58,9 @@ export class CollaborationGateway {
|
|||||||
this.authenticationExtension,
|
this.authenticationExtension,
|
||||||
this.persistenceExtension,
|
this.persistenceExtension,
|
||||||
this.loggerExtension,
|
this.loggerExtension,
|
||||||
// #402 collab lifecycle + connect-duration metrics (no-op when off).
|
|
||||||
new CollabMetricsExtension(),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// #402 — read-on-scrape source for collab_docs_open. Wire ONCE, gated, so
|
|
||||||
// nothing runs when metrics are disabled. The gauge's collect() pulls the
|
|
||||||
// live count from the hocuspocus instance on each scrape (no inc/dec drift).
|
|
||||||
if (isMetricsEnabled()) {
|
|
||||||
registerDocsOpenSource(() => this.hocuspocus.getDocumentsCount());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.withRedis) {
|
if (this.withRedis) {
|
||||||
this.redisClient = new RedisClient({
|
this.redisClient = new RedisClient({
|
||||||
host: this.redisConfig.host,
|
host: this.redisConfig.host,
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { isUserDisabled } from '../../common/helpers';
|
|||||||
import { getPageId } from '../collaboration.util';
|
import { getPageId } from '../collaboration.util';
|
||||||
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
|
||||||
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
import { resolveProvenance } from '../../common/decorators/auth-provenance.decorator';
|
||||||
import { observeCollabAuth } from '../../integrations/metrics/metrics.registry';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthenticationExtension implements Extension {
|
export class AuthenticationExtension implements Extension {
|
||||||
@@ -31,18 +30,6 @@ export class AuthenticationExtension implements Extension {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onAuthenticate(data: onAuthenticatePayload) {
|
async onAuthenticate(data: onAuthenticatePayload) {
|
||||||
// #402 — time the whole auth (verify + user/page/permission lookups) into
|
|
||||||
// collab_auth_duration_seconds. finally so failed auths are timed too.
|
|
||||||
// No-op when METRICS_PORT is unset. Behavior unchanged.
|
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
return await this.doAuthenticate(data);
|
|
||||||
} finally {
|
|
||||||
observeCollabAuth((performance.now() - start) / 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doAuthenticate(data: onAuthenticatePayload) {
|
|
||||||
const { documentName, token } = data;
|
const { documentName, token } = data;
|
||||||
const pageId = getPageId(documentName);
|
const pageId = getPageId(documentName);
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,7 @@ import {
|
|||||||
HISTORY_INTERVAL,
|
HISTORY_INTERVAL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
import { TransclusionService } from '../../core/page/transclusion/transclusion.service';
|
||||||
import {
|
import { observeCollabStore } from '../../integrations/metrics/metrics.registry';
|
||||||
observeCollabLoad,
|
|
||||||
observeCollabStore,
|
|
||||||
} from '../../integrations/metrics/metrics.registry';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #251 — wire format of the client→server stateless message that signals a
|
* #251 — wire format of the client→server stateless message that signals a
|
||||||
@@ -153,14 +150,10 @@ export class PersistenceExtension implements Extension {
|
|||||||
const { documentName, document } = data;
|
const { documentName, document } = data;
|
||||||
const pageId = getPageId(documentName);
|
const pageId = getPageId(documentName);
|
||||||
|
|
||||||
// #402 — the early return below (live doc already non-empty) does NOT touch
|
|
||||||
// the DB, so it is deliberately NOT timed. We only observe the real DB-load
|
|
||||||
// work, and only on each real-load return, tagged by the loaded doc size.
|
|
||||||
if (!document.isEmpty('default')) {
|
if (!document.isEmpty('default')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startedAt = performance.now();
|
|
||||||
const page = await this.pageRepo.findById(pageId, {
|
const page = await this.pageRepo.findById(pageId, {
|
||||||
includeContent: true,
|
includeContent: true,
|
||||||
includeYdoc: true,
|
includeYdoc: true,
|
||||||
@@ -178,7 +171,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
const dbState = new Uint8Array(page.ydoc);
|
const dbState = new Uint8Array(page.ydoc);
|
||||||
|
|
||||||
Y.applyUpdate(doc, dbState);
|
Y.applyUpdate(doc, dbState);
|
||||||
observeCollabLoad(dbState.length, (performance.now() - startedAt) / 1000);
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,42 +184,26 @@ export class PersistenceExtension implements Extension {
|
|||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reuse this single encode for the size label (do NOT add a second one).
|
Y.encodeStateAsUpdate(ydoc);
|
||||||
const encoded = Y.encodeStateAsUpdate(ydoc);
|
|
||||||
observeCollabLoad(
|
|
||||||
encoded.byteLength,
|
|
||||||
(performance.now() - startedAt) / 1000,
|
|
||||||
);
|
|
||||||
return ydoc;
|
return ydoc;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
this.logger.debug(`creating fresh ydoc: ${pageId}`);
|
||||||
observeCollabLoad(0, (performance.now() - startedAt) / 1000);
|
|
||||||
return new Y.Doc();
|
return new Y.Doc();
|
||||||
}
|
}
|
||||||
|
|
||||||
async onStoreDocument(data: onStoreDocumentPayload) {
|
async onStoreDocument(data: onStoreDocumentPayload) {
|
||||||
// #355 — time the full store (persist + post-store side effects) into
|
// #355 — time the full store (persist + post-store side effects) into
|
||||||
// collab_store_duration_seconds. #402 — also tag by document size bucket.
|
// collab_store_duration_seconds. No-op when METRICS_PORT is unset.
|
||||||
// No-op when METRICS_PORT is unset.
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
// Default 0 so a throw before storeDocument returns still records a
|
|
||||||
// (smallest-bucket) observation rather than dropping the timing entirely.
|
|
||||||
let bytes = 0;
|
|
||||||
try {
|
try {
|
||||||
bytes = await this.storeDocument(data);
|
await this.storeDocument(data);
|
||||||
} finally {
|
} finally {
|
||||||
observeCollabStore(bytes, (performance.now() - startedAt) / 1000);
|
observeCollabStore((performance.now() - startedAt) / 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async storeDocument(data: onStoreDocumentPayload) {
|
||||||
* Persist the document. Returns the serialized ydoc byte size (used as the
|
|
||||||
* store histogram's size_bucket). The single Y.encodeStateAsUpdate below is
|
|
||||||
* the ONLY serialization — its byteLength is reused for the label (no second
|
|
||||||
* encode).
|
|
||||||
*/
|
|
||||||
private async storeDocument(data: onStoreDocumentPayload): Promise<number> {
|
|
||||||
const { documentName, document, context } = data;
|
const { documentName, document, context } = data;
|
||||||
|
|
||||||
const pageId = getPageId(documentName);
|
const pageId = getPageId(documentName);
|
||||||
@@ -479,11 +455,6 @@ export class PersistenceExtension implements Extension {
|
|||||||
|
|
||||||
await this.enqueuePageHistory(page, lastUpdatedSource);
|
await this.enqueuePageHistory(page, lastUpdatedSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
// #402 — report the serialized size for the store histogram's size_bucket.
|
|
||||||
// ydocState is always computed above (there is no earlier no-write return in
|
|
||||||
// this method), so this reflects the doc that was serialized this store.
|
|
||||||
return ydocState.byteLength;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* In-memory run-stream registry (#184 phase 1.5). A durable agent run tees its
|
|
||||||
* SSE frames here (via `pipeUIMessageStreamToResponse({ consumeSseStream })`)
|
|
||||||
* so a LATE tab — one that reloaded, or opened after the starter dropped — can
|
|
||||||
* attach through `GET /ai-chat/runs/:chatId/stream`, replay the frames buffered
|
|
||||||
* so far, and then follow the live tail as a normal streamer.
|
|
||||||
*
|
|
||||||
* This is deliberately single-process and best-effort: it holds nothing the DB
|
|
||||||
* does not (the run + assistant row are the source of truth), so a process
|
|
||||||
* restart simply drops in-flight entries and the client falls back to its
|
|
||||||
* restore + degraded-poll path. The async `attach` return type is the seam for a
|
|
||||||
* future phase-2 cross-process backend (Redis) — the interface does not change.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** How long a finished entry is retained for late attach (replay + immediate end). */
|
|
||||||
export const RUN_STREAM_RETAIN_FINISHED_MS = 30_000;
|
|
||||||
|
|
||||||
/** Per-run replay buffer cap. Past this the buffer is dropped (attach -> 204). */
|
|
||||||
export const RUN_STREAM_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
||||||
|
|
||||||
// 2x the replay cap: a just-written 4MB replay burst alone can never trip the
|
|
||||||
// per-subscriber cap (see controller); only a genuinely stalled socket can.
|
|
||||||
export const SUBSCRIBER_MAX_BUFFERED_BYTES = 2 * RUN_STREAM_MAX_BUFFER_BYTES;
|
|
||||||
|
|
||||||
export interface RunStreamCallbacks {
|
|
||||||
onFrame: (frame: string) => void;
|
|
||||||
onEnd: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RunStreamAttachment {
|
|
||||||
replay: string[];
|
|
||||||
finished: boolean;
|
|
||||||
start(): void; // drain pending frames (order preserved) and go live
|
|
||||||
unsubscribe(): void; // safe to call at any point, idempotent
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Subscriber extends RunStreamCallbacks {
|
|
||||||
started: boolean;
|
|
||||||
pending: string[];
|
|
||||||
// Byte size of `pending`, capped at SUBSCRIBER_MAX_BUFFERED_BYTES. `start()` is
|
|
||||||
// called in the SAME tick as `attach()` today (see attach), so `pending` never
|
|
||||||
// holds more than one microtask of frames — but the async `attach` signature is
|
|
||||||
// a phase-2 seam: an await between attach and start would let a stalled paused
|
|
||||||
// subscriber buffer the WHOLE run here. The cap is the structural backstop.
|
|
||||||
pendingBytes: number;
|
|
||||||
overflowed: boolean;
|
|
||||||
pendingEnd: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Entry {
|
|
||||||
runId: string;
|
|
||||||
// The persisted assistant row id of this run (set at bind; undefined if the
|
|
||||||
// seed failed). Used by the attach anchor check (invariant 6).
|
|
||||||
assistantMessageId?: string;
|
|
||||||
frames: string[];
|
|
||||||
bytes: number;
|
|
||||||
overflowed: boolean;
|
|
||||||
finished: boolean;
|
|
||||||
subscribers: Set<Subscriber>;
|
|
||||||
retainTimer?: NodeJS.Timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AiChatStreamRegistryService implements OnModuleDestroy {
|
|
||||||
private readonly logger = new Logger(AiChatStreamRegistryService.name);
|
|
||||||
private readonly entries = new Map<string, Entry>(); // key: chatId
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a fresh entry at the START of a run (before any frame), so a tab
|
|
||||||
* that attaches in the begin->seed window finds an entry to wait on. If an
|
|
||||||
* entry already exists for this chat (a previous, possibly still-live run whose
|
|
||||||
* tee loop is draining), it is terminated MIRRORING the done-path (invariant 3)
|
|
||||||
* so its subscribers are released and its retention timer is cleared; a late
|
|
||||||
* `done` from that old tee then fires against the closed-over old reference and,
|
|
||||||
* thanks to identity checks, never touches this new entry.
|
|
||||||
*/
|
|
||||||
open(chatId: string, runId: string): void {
|
|
||||||
const existing = this.entries.get(chatId);
|
|
||||||
if (existing) {
|
|
||||||
if (existing.retainTimer) {
|
|
||||||
clearTimeout(existing.retainTimer);
|
|
||||||
existing.retainTimer = undefined;
|
|
||||||
}
|
|
||||||
// Started subscribers get exactly one onEnd() and are removed; paused ones
|
|
||||||
// are marked pendingEnd (their start() will end them). finished=true guards
|
|
||||||
// any later done from the old tee loop from double-notifying.
|
|
||||||
this.terminateSubscribers(existing);
|
|
||||||
}
|
|
||||||
this.entries.set(chatId, {
|
|
||||||
runId,
|
|
||||||
frames: [],
|
|
||||||
bytes: 0,
|
|
||||||
overflowed: false,
|
|
||||||
finished: false,
|
|
||||||
subscribers: new Set<Subscriber>(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tee a run's SSE frame stream into its entry (called from consumeSseStream).
|
|
||||||
* No-op with a warning when there is no entry or the entry belongs to a
|
|
||||||
* different run (invariant 1). The reader loop is fire-and-forget: the tee
|
|
||||||
* branch outlives the client socket by design.
|
|
||||||
*/
|
|
||||||
bind(
|
|
||||||
chatId: string,
|
|
||||||
runId: string,
|
|
||||||
assistantMessageId: string | undefined,
|
|
||||||
stream: ReadableStream<string>,
|
|
||||||
): void {
|
|
||||||
const entry = this.entries.get(chatId);
|
|
||||||
if (!entry || entry.runId !== runId) {
|
|
||||||
// Invariant 1: only the matching run may mutate the entry.
|
|
||||||
this.logger.warn(
|
|
||||||
`bind: no matching run-stream entry for chat=${chatId} run=${runId}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
entry.assistantMessageId = assistantMessageId;
|
|
||||||
const reader = stream.getReader();
|
|
||||||
const pump = async (): Promise<void> => {
|
|
||||||
try {
|
|
||||||
for (;;) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
this.ingestFrame(entry, value);
|
|
||||||
}
|
|
||||||
this.finalizeEntry(chatId, entry);
|
|
||||||
} catch {
|
|
||||||
// A read error is a terminal event too — release subscribers.
|
|
||||||
this.finalizeEntry(chatId, entry);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
void pump();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Terminate a run's entry from the OUTER catch of the stream method (a failure
|
|
||||||
* before/while wiring the pipe, so `done` will never arrive). Identity-checked
|
|
||||||
* on runId (invariant 1); the shared terminal path is idempotent.
|
|
||||||
*/
|
|
||||||
abortEntry(chatId: string, runId: string): void {
|
|
||||||
const entry = this.entries.get(chatId);
|
|
||||||
if (!entry || entry.runId !== runId) return;
|
|
||||||
this.finalizeEntry(chatId, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach to a run's stream. Async only for the phase-2 Redis seam — the body
|
|
||||||
* runs synchronously so the replay snapshot and the subscriber registration
|
|
||||||
* happen in ONE tick with no await between them (invariant 4): a frame ingested
|
|
||||||
* concurrently cannot slip into the gap and be lost or duplicated.
|
|
||||||
*
|
|
||||||
* Returns null (-> the caller answers 204) when:
|
|
||||||
* - there is no entry, or it overflowed (replay is gone);
|
|
||||||
* - expect=live with an anchor that does not match this run's assistant id
|
|
||||||
* (invariant 6: a stripped tab must never replay a FOREIGN run's transcript);
|
|
||||||
* - the run finished and the caller did not expect a live tail.
|
|
||||||
* A finished run with expect=live yields a replay-only attachment (no
|
|
||||||
* subscriber registered). Otherwise a paused subscriber is registered and the
|
|
||||||
* caller replays `replay`, then calls start() to drain and go live.
|
|
||||||
*/
|
|
||||||
async attach(
|
|
||||||
chatId: string,
|
|
||||||
expectLive: boolean,
|
|
||||||
anchor: string | undefined,
|
|
||||||
cb: RunStreamCallbacks,
|
|
||||||
): Promise<RunStreamAttachment | null> {
|
|
||||||
const entry = this.entries.get(chatId);
|
|
||||||
if (!entry || entry.overflowed) return null;
|
|
||||||
// Invariant 6: cross-run replay is forbidden. Before bind, assistantMessageId
|
|
||||||
// is undefined and mismatches any anchor -> 204 -> client restore+poll path.
|
|
||||||
if (expectLive && anchor && entry.assistantMessageId !== anchor) return null;
|
|
||||||
if (entry.finished && !expectLive) return null;
|
|
||||||
if (entry.finished && expectLive) {
|
|
||||||
// Replay-only: the run is done, no subscriber is registered.
|
|
||||||
return {
|
|
||||||
replay: entry.frames.slice(),
|
|
||||||
finished: true,
|
|
||||||
start: () => undefined,
|
|
||||||
unsubscribe: () => undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const sub: Subscriber = {
|
|
||||||
onFrame: cb.onFrame,
|
|
||||||
onEnd: cb.onEnd,
|
|
||||||
started: false,
|
|
||||||
pending: [],
|
|
||||||
pendingBytes: 0,
|
|
||||||
overflowed: false,
|
|
||||||
pendingEnd: false,
|
|
||||||
};
|
|
||||||
entry.subscribers.add(sub);
|
|
||||||
// Snapshot in the SAME synchronous block as the registration (invariant 4).
|
|
||||||
const replay = entry.frames.slice();
|
|
||||||
// CONTRACT: the caller MUST call start() in the SAME tick as this attach()
|
|
||||||
// returns — no await between them. While a subscriber is paused, every frame
|
|
||||||
// is buffered in sub.pending; a delayed start() lets a whole run accumulate
|
|
||||||
// there. The pendingBytes cap (see ingestFrame) is the structural backstop if
|
|
||||||
// that contract is ever broken (e.g. the phase-2 Redis await seam).
|
|
||||||
return {
|
|
||||||
replay,
|
|
||||||
finished: false,
|
|
||||||
start: () => {
|
|
||||||
if (sub.overflowed) {
|
|
||||||
// The pending buffer overflowed while paused: end the stream instead of
|
|
||||||
// replaying a partial (a 204-equivalent post-attach degrade).
|
|
||||||
try {
|
|
||||||
sub.onEnd();
|
|
||||||
} catch {
|
|
||||||
// The socket is gone; nothing to end.
|
|
||||||
}
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Deliver frames buffered while paused, in order, then go live.
|
|
||||||
for (const frame of sub.pending) {
|
|
||||||
try {
|
|
||||||
sub.onFrame(frame);
|
|
||||||
} catch {
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sub.pending = [];
|
|
||||||
sub.started = true;
|
|
||||||
if (sub.pendingEnd) {
|
|
||||||
try {
|
|
||||||
sub.onEnd();
|
|
||||||
} catch {
|
|
||||||
// The socket is gone; nothing to end.
|
|
||||||
}
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
unsubscribe: () => {
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onModuleDestroy(): void {
|
|
||||||
for (const entry of this.entries.values()) {
|
|
||||||
if (entry.retainTimer) clearTimeout(entry.retainTimer);
|
|
||||||
}
|
|
||||||
this.entries.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Buffer + fan-out a single frame. See invariant/overflow semantics inline. */
|
|
||||||
private ingestFrame(entry: Entry, frame: string): void {
|
|
||||||
entry.bytes += Buffer.byteLength(frame);
|
|
||||||
if (!entry.overflowed) {
|
|
||||||
entry.frames.push(frame);
|
|
||||||
if (entry.bytes > RUN_STREAM_MAX_BUFFER_BYTES) {
|
|
||||||
// The crossing frame was already counted AND (below) fanned out; only the
|
|
||||||
// replay buffer is dropped. After overflow no more frames are buffered,
|
|
||||||
// but live fan-out continues.
|
|
||||||
entry.overflowed = true;
|
|
||||||
entry.frames = [];
|
|
||||||
this.logger.warn(
|
|
||||||
`run-stream buffer overflow for run=${entry.runId}; ` +
|
|
||||||
`late attach will 204 until the run ends`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const sub of entry.subscribers) {
|
|
||||||
if (sub.started) {
|
|
||||||
try {
|
|
||||||
sub.onFrame(frame);
|
|
||||||
} catch {
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sub.pending.push(frame);
|
|
||||||
sub.pendingBytes += Buffer.byteLength(frame);
|
|
||||||
if (sub.pendingBytes > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
|
||||||
// The paused subscriber's buffer overflowed — only possible if start()
|
|
||||||
// was delayed past the same-tick contract (the phase-2 await seam).
|
|
||||||
// Drop it rather than buffer the whole run; on start() it degrades to an
|
|
||||||
// immediate end (a 204-equivalent) instead of replaying a partial.
|
|
||||||
sub.overflowed = true;
|
|
||||||
sub.pending = [];
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared terminal path for done / read-error / external-abort. Idempotent: a
|
|
||||||
* second call (already finished) is a no-op, so an open()-replaced or
|
|
||||||
* abort-then-done entry is never double-armed or double-ended.
|
|
||||||
*/
|
|
||||||
private finalizeEntry(chatId: string, entry: Entry): void {
|
|
||||||
if (entry.finished) return;
|
|
||||||
this.terminateSubscribers(entry);
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
// Invariant 2: only delete OUR entry (a replacement may already own the key).
|
|
||||||
if (this.entries.get(chatId) === entry) this.entries.delete(chatId);
|
|
||||||
}, RUN_STREAM_RETAIN_FINISHED_MS);
|
|
||||||
timer.unref?.();
|
|
||||||
entry.retainTimer = timer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mark the entry finished and release its subscribers, mirroring the done-path:
|
|
||||||
* started subscribers get exactly one onEnd() and are removed; paused ones are
|
|
||||||
* flagged pendingEnd so their start() ends them. Deleting the current element
|
|
||||||
* during Set iteration is safe.
|
|
||||||
*/
|
|
||||||
private terminateSubscribers(entry: Entry): void {
|
|
||||||
entry.finished = true;
|
|
||||||
for (const sub of entry.subscribers) {
|
|
||||||
if (sub.started) {
|
|
||||||
try {
|
|
||||||
sub.onEnd();
|
|
||||||
} catch {
|
|
||||||
// The socket is gone; nothing to end.
|
|
||||||
}
|
|
||||||
entry.subscribers.delete(sub);
|
|
||||||
} else {
|
|
||||||
sub.pendingEnd = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
import {
|
|
||||||
AiChatStreamRegistryService,
|
|
||||||
RUN_STREAM_MAX_BUFFER_BYTES,
|
|
||||||
RUN_STREAM_RETAIN_FINISHED_MS,
|
|
||||||
RunStreamCallbacks,
|
|
||||||
} from './ai-chat-stream-registry.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for the in-memory run-stream registry (#184 phase 1.5). The registry
|
|
||||||
* is the whole of the resumable-transport contract: replay ordering, paused ->
|
|
||||||
* live hand-off, overflow, retention, the anchor check (invariant 6), and the
|
|
||||||
* mirror-the-done-path replace semantics (invariant 3). Every enumerated case in
|
|
||||||
* the issue's task 1.5 has a test here.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// A ReadableStream whose frames the test pushes explicitly, plus close/error.
|
|
||||||
function makePushStream(): {
|
|
||||||
stream: ReadableStream<string>;
|
|
||||||
push: (f: string) => void;
|
|
||||||
close: () => void;
|
|
||||||
error: (e?: unknown) => void;
|
|
||||||
} {
|
|
||||||
let controller!: ReadableStreamDefaultController<string>;
|
|
||||||
const stream = new ReadableStream<string>({
|
|
||||||
start(c) {
|
|
||||||
controller = c;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
stream,
|
|
||||||
push: (f) => controller.enqueue(f),
|
|
||||||
close: () => controller.close(),
|
|
||||||
error: (e) => controller.error(e ?? new Error('read error')),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let the fire-and-forget pump drain queued frames (reader.read() resolves on a
|
|
||||||
// macrotask boundary for an already-enqueued value).
|
|
||||||
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
|
|
||||||
|
|
||||||
function collector(): {
|
|
||||||
cb: RunStreamCallbacks;
|
|
||||||
frames: string[];
|
|
||||||
ended: () => number;
|
|
||||||
} {
|
|
||||||
const frames: string[] = [];
|
|
||||||
let ends = 0;
|
|
||||||
return {
|
|
||||||
frames,
|
|
||||||
ended: () => ends,
|
|
||||||
cb: {
|
|
||||||
onFrame: (f) => frames.push(f),
|
|
||||||
onEnd: () => {
|
|
||||||
ends += 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AiChatStreamRegistryService', () => {
|
|
||||||
const CHAT = 'chat-1';
|
|
||||||
let registry: AiChatStreamRegistryService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
registry = new AiChatStreamRegistryService();
|
|
||||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replays frames in arrival order (live attach)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
src.push('b');
|
|
||||||
src.push('c');
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = await registry.attach(CHAT, false, undefined, c.cb);
|
|
||||||
expect(att).not.toBeNull();
|
|
||||||
expect(att!.replay).toEqual(['a', 'b', 'c']);
|
|
||||||
expect(att!.finished).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('late attach gets the full prefix as replay plus the live tail', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
src.push('b');
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
|
||||||
expect(att.replay).toEqual(['a', 'b']);
|
|
||||||
att.start();
|
|
||||||
// Live tail arrives after start().
|
|
||||||
src.push('c');
|
|
||||||
src.push('d');
|
|
||||||
await flush();
|
|
||||||
expect(c.frames).toEqual(['c', 'd']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a paused subscriber receives frames buffered during pause in order, then live (no loss/reorder)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
// Attach (paused). Frames that arrive BEFORE start() must queue, not drop.
|
|
||||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
|
||||||
expect(att.replay).toEqual(['a']);
|
|
||||||
src.push('b'); // arrives while paused -> pending
|
|
||||||
src.push('c');
|
|
||||||
await flush();
|
|
||||||
expect(c.frames).toEqual([]); // nothing delivered yet (paused)
|
|
||||||
att.start(); // drains pending in order
|
|
||||||
expect(c.frames).toEqual(['b', 'c']);
|
|
||||||
src.push('d'); // now live
|
|
||||||
await flush();
|
|
||||||
expect(c.frames).toEqual(['b', 'c', 'd']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a run that finishes while a subscriber is paused ends it on start()', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
|
||||||
// Terminate the run while the subscriber is still paused.
|
|
||||||
registry.abortEntry(CHAT, 'run-1');
|
|
||||||
expect(c.ended()).toBe(0); // paused: not ended yet
|
|
||||||
att.start();
|
|
||||||
expect(c.ended()).toBe(1); // start() drains + ends
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finished + expect=live returns a replay WITHOUT registering a subscriber', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
src.push('b');
|
|
||||||
src.close();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, true, undefined, c.cb))!;
|
|
||||||
expect(att.finished).toBe(true);
|
|
||||||
expect(att.replay).toEqual(['a', 'b']);
|
|
||||||
// No subscriber registered: start()/unsubscribe are no-ops and the entry has
|
|
||||||
// zero subscribers.
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
expect(entry.subscribers.size).toBe(0);
|
|
||||||
att.start();
|
|
||||||
expect(c.frames).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finished WITHOUT expect=live returns null', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
src.close();
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
expect(await registry.attach(CHAT, false, undefined, c.cb)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('anchor mismatch with expect=live returns null (and null before bind sets assistantMessageId)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const c = collector();
|
|
||||||
// Before bind: assistantMessageId is undefined -> mismatches any anchor.
|
|
||||||
expect(
|
|
||||||
await registry.attach(CHAT, true, 'assist-1', c.cb),
|
|
||||||
).toBeNull();
|
|
||||||
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
await flush();
|
|
||||||
// Wrong anchor -> null (cross-run replay forbidden, invariant 6).
|
|
||||||
expect(await registry.attach(CHAT, true, 'other-id', c.cb)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matching anchor with expect=live attaches', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = await registry.attach(CHAT, true, 'assist-1', c.cb);
|
|
||||||
expect(att).not.toBeNull();
|
|
||||||
expect(att!.replay).toEqual(['a']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('overflow: attach returns null, but the LIVE subscriber keeps receiving (incl. the crossing frame)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
|
|
||||||
// A live (started) subscriber attached before the flood.
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
|
||||||
att.start();
|
|
||||||
|
|
||||||
const oneMb = 'x'.repeat(1024 * 1024);
|
|
||||||
// 5 x 1MB = 5MB > 4MB cap; the 5th frame is the one that crosses.
|
|
||||||
for (let i = 0; i < 5; i++) src.push(oneMb + i);
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
expect(entry.overflowed).toBe(true);
|
|
||||||
expect(entry.bytes).toBeGreaterThan(RUN_STREAM_MAX_BUFFER_BYTES);
|
|
||||||
// The live subscriber received ALL 5 frames, including the crossing one.
|
|
||||||
expect(c.frames).toHaveLength(5);
|
|
||||||
expect(c.frames[4]).toBe(oneMb + 4);
|
|
||||||
|
|
||||||
// A NEW attach after overflow gets null (replay buffer is gone).
|
|
||||||
const c2 = collector();
|
|
||||||
expect(await registry.attach(CHAT, false, undefined, c2.cb)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a paused subscriber whose pending buffer overflows is dropped and ends on start(); other subscribers keep receiving', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
|
|
||||||
// A: paused (start() deliberately delayed to simulate the phase-2 await seam).
|
|
||||||
const a = collector();
|
|
||||||
const attA = (await registry.attach(CHAT, false, undefined, a.cb))!;
|
|
||||||
// B: live (started) — its delivery must be unaffected by A's overflow.
|
|
||||||
const b = collector();
|
|
||||||
const attB = (await registry.attach(CHAT, false, undefined, b.cb))!;
|
|
||||||
attB.start();
|
|
||||||
|
|
||||||
const oneMb = 'x'.repeat(1024 * 1024);
|
|
||||||
// 9 x 1MB = 9MB > 8MB per-subscriber cap; A's pending overflows, B streams live.
|
|
||||||
for (let i = 0; i < 9; i++) src.push(oneMb + i);
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
// A was dropped from the subscriber set on overflow; B (started) remains.
|
|
||||||
expect(entry.subscribers.size).toBe(1);
|
|
||||||
expect(a.frames).toEqual([]); // paused + overflowed: nothing was delivered
|
|
||||||
// B received every frame live (delivery unaffected by A's overflow).
|
|
||||||
expect(b.frames).toHaveLength(9);
|
|
||||||
|
|
||||||
// A's start() (arriving late) degrades to an immediate end, not a partial replay.
|
|
||||||
attA.start();
|
|
||||||
expect(a.frames).toEqual([]);
|
|
||||||
expect(a.ended()).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('open() over a LIVE entry ends started subscribers exactly once and a late done does not touch the new entry (invariant 3)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const c = collector();
|
|
||||||
const att = (await registry.attach(CHAT, false, undefined, c.cb))!;
|
|
||||||
att.start(); // started subscriber on run-1
|
|
||||||
|
|
||||||
// run-2 starts on the same chat while run-1's tee is still reading.
|
|
||||||
registry.open(CHAT, 'run-2');
|
|
||||||
expect(c.ended()).toBe(1); // exactly one onEnd from the replace
|
|
||||||
|
|
||||||
const newEntry = (registry as any).entries.get(CHAT);
|
|
||||||
expect(newEntry.runId).toBe('run-2');
|
|
||||||
expect(newEntry.finished).toBe(false);
|
|
||||||
|
|
||||||
// The old tee now completes: its late done must NOT double-end nor delete the
|
|
||||||
// new entry.
|
|
||||||
src.push('b');
|
|
||||||
src.close();
|
|
||||||
await flush();
|
|
||||||
expect(c.ended()).toBe(1); // still exactly one
|
|
||||||
const still = (registry as any).entries.get(CHAT);
|
|
||||||
expect(still).toBe(newEntry);
|
|
||||||
expect(still.runId).toBe('run-2');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bind with a foreign runId is a no-op (invariant 1)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'WRONG-run', 'assist-x', src.stream);
|
|
||||||
src.push('a');
|
|
||||||
await flush();
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
// Frames were NOT ingested (bind bailed), assistantMessageId untouched.
|
|
||||||
expect(entry.frames).toEqual([]);
|
|
||||||
expect(entry.assistantMessageId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('abortEntry with a foreign runId is a no-op (invariant 1)', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
registry.abortEntry(CHAT, 'WRONG-run');
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
expect(entry.finished).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a throwing onFrame ejects only that subscriber; the ingest loop stays alive', async () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
const src = makePushStream();
|
|
||||||
registry.bind(CHAT, 'run-1', 'assist-1', src.stream);
|
|
||||||
|
|
||||||
const bad = collector();
|
|
||||||
const badAtt = (await registry.attach(CHAT, false, undefined, {
|
|
||||||
onFrame: () => {
|
|
||||||
throw new Error('boom');
|
|
||||||
},
|
|
||||||
onEnd: bad.cb.onEnd,
|
|
||||||
}))!;
|
|
||||||
badAtt.start();
|
|
||||||
|
|
||||||
const good = collector();
|
|
||||||
const goodAtt = (await registry.attach(CHAT, false, undefined, good.cb))!;
|
|
||||||
goodAtt.start();
|
|
||||||
|
|
||||||
src.push('a'); // bad throws on this frame -> ejected
|
|
||||||
src.push('b'); // good still receives both
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
expect(entry.subscribers.size).toBe(1); // bad ejected, good remains
|
|
||||||
expect(good.frames).toEqual(['a', 'b']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retention + replace timer behavior. Fake timers, and entries are finalized via
|
|
||||||
* the synchronous abortEntry() path so no stream pump / microtask juggling is
|
|
||||||
* needed.
|
|
||||||
*/
|
|
||||||
describe('AiChatStreamRegistryService retention timers', () => {
|
|
||||||
const CHAT = 'chat-r';
|
|
||||||
let registry: AiChatStreamRegistryService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
registry = new AiChatStreamRegistryService();
|
|
||||||
jest.spyOn((registry as any).logger, 'warn').mockImplementation(() => {});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a finished entry is removed after the retention window', () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
registry.abortEntry(CHAT, 'run-1'); // finalize -> retention armed
|
|
||||||
expect((registry as any).entries.get(CHAT)).toBeDefined();
|
|
||||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
|
||||||
expect((registry as any).entries.get(CHAT)).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retention deletes ONLY its own entry (invariant 2)', () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
registry.abortEntry(CHAT, 'run-1'); // arm retention for entry A
|
|
||||||
// Simulate the race where the key was replaced without clearing A's timer.
|
|
||||||
const sentinel = { marker: true };
|
|
||||||
(registry as any).entries.set(CHAT, sentinel);
|
|
||||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
|
||||||
// A's timer saw entries.get(CHAT) !== A, so it did NOT delete the successor.
|
|
||||||
expect((registry as any).entries.get(CHAT)).toBe(sentinel);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('open() over a retained entry clears its timer and the successor survives', () => {
|
|
||||||
registry.open(CHAT, 'run-1');
|
|
||||||
registry.abortEntry(CHAT, 'run-1'); // retained, timer armed
|
|
||||||
const clearSpy = jest.spyOn(global, 'clearTimeout');
|
|
||||||
registry.open(CHAT, 'run-2'); // must clear run-1's retain timer
|
|
||||||
expect(clearSpy).toHaveBeenCalled();
|
|
||||||
jest.advanceTimersByTime(RUN_STREAM_RETAIN_FINISHED_MS + 1);
|
|
||||||
const entry = (registry as any).entries.get(CHAT);
|
|
||||||
expect(entry).toBeDefined();
|
|
||||||
expect(entry.runId).toBe('run-2');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
import { ForbiddenException } from '@nestjs/common';
|
|
||||||
import { AiChatController } from './ai-chat.controller';
|
|
||||||
import type {
|
|
||||||
RunStreamAttachment,
|
|
||||||
RunStreamCallbacks,
|
|
||||||
} from './ai-chat-stream-registry.service';
|
|
||||||
import { SUBSCRIBER_MAX_BUFFERED_BYTES } from './ai-chat-stream-registry.service';
|
|
||||||
import type { User, Workspace } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wiring spec for the #184 phase 1.5 attach endpoint
|
|
||||||
* (`GET /ai-chat/runs/:chatId/stream`). Owner-gated via assertOwnedChat; the
|
|
||||||
* registry is mocked so this exercises ONLY the controller's replay/live/204/
|
|
||||||
* cleanup wiring against a fake raw socket. Constructor order is (aiChatService,
|
|
||||||
* aiChatRunService, aiChatRepo, aiChatMessageRepo, aiTranscription, pageRepo,
|
|
||||||
* streamRegistry, environment).
|
|
||||||
*/
|
|
||||||
describe('AiChatController attach endpoint (#184 phase 1.5)', () => {
|
|
||||||
const user = { id: 'u1' } as User;
|
|
||||||
const workspace = { id: 'ws1' } as Workspace;
|
|
||||||
|
|
||||||
function makeRawRes() {
|
|
||||||
const raw: any = {
|
|
||||||
writableEnded: false,
|
|
||||||
writableLength: 0,
|
|
||||||
destroyed: false,
|
|
||||||
written: [] as string[],
|
|
||||||
head: null as any,
|
|
||||||
write: jest.fn((f: string) => {
|
|
||||||
raw.written.push(f);
|
|
||||||
return true;
|
|
||||||
}),
|
|
||||||
writeHead: jest.fn((code: number, headers: any) => {
|
|
||||||
raw.head = { code, headers };
|
|
||||||
return raw;
|
|
||||||
}),
|
|
||||||
flushHeaders: jest.fn(),
|
|
||||||
end: jest.fn(() => {
|
|
||||||
raw.writableEnded = true;
|
|
||||||
}),
|
|
||||||
destroy: jest.fn(() => {
|
|
||||||
raw.destroyed = true;
|
|
||||||
}),
|
|
||||||
on: jest.fn(),
|
|
||||||
once: jest.fn(),
|
|
||||||
};
|
|
||||||
const res: any = {
|
|
||||||
raw,
|
|
||||||
status: jest.fn(() => res),
|
|
||||||
send: jest.fn(),
|
|
||||||
hijack: jest.fn(),
|
|
||||||
};
|
|
||||||
return { res, raw };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReq(destroyed = false) {
|
|
||||||
const handlers: Record<string, () => void> = {};
|
|
||||||
const raw: any = {
|
|
||||||
destroyed,
|
|
||||||
once: jest.fn((ev: string, fn: () => void) => {
|
|
||||||
handlers[ev] = fn;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
return { req: { raw } as any, raw, fireClose: () => handlers['close']?.() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAttachment(
|
|
||||||
over: Partial<RunStreamAttachment> = {},
|
|
||||||
): RunStreamAttachment {
|
|
||||||
return {
|
|
||||||
replay: [],
|
|
||||||
finished: false,
|
|
||||||
start: jest.fn(),
|
|
||||||
unsubscribe: jest.fn(),
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeController(opts: {
|
|
||||||
chat?: unknown;
|
|
||||||
attachment?: RunStreamAttachment | null;
|
|
||||||
}) {
|
|
||||||
const aiChatRepo = { findById: jest.fn().mockResolvedValue(opts.chat) };
|
|
||||||
let capturedCb: RunStreamCallbacks | undefined;
|
|
||||||
const streamRegistry = {
|
|
||||||
attach: jest.fn(
|
|
||||||
(
|
|
||||||
_chatId: string,
|
|
||||||
_live: boolean,
|
|
||||||
_anchor: string | undefined,
|
|
||||||
cb: RunStreamCallbacks,
|
|
||||||
) => {
|
|
||||||
capturedCb = cb;
|
|
||||||
return Promise.resolve(
|
|
||||||
opts.attachment === undefined ? makeAttachment() : opts.attachment,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const environment = { isAiChatResumableStreamEnabled: () => true };
|
|
||||||
const controller = new AiChatController(
|
|
||||||
{} as never, // aiChatService
|
|
||||||
{} as never, // aiChatRunService
|
|
||||||
aiChatRepo as never,
|
|
||||||
{} as never, // aiChatMessageRepo
|
|
||||||
{} as never, // aiTranscription
|
|
||||||
{} as never, // pageRepo
|
|
||||||
streamRegistry as never,
|
|
||||||
environment as never,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
controller,
|
|
||||||
aiChatRepo,
|
|
||||||
streamRegistry,
|
|
||||||
getCb: () => capturedCb!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const owned = { id: 'c1', creatorId: 'u1' };
|
|
||||||
|
|
||||||
it('owner-gates: a foreign chat throws ForbiddenException and never attaches', async () => {
|
|
||||||
const { controller, streamRegistry } = makeController({
|
|
||||||
chat: { id: 'c1', creatorId: 'someone-else' },
|
|
||||||
});
|
|
||||||
const { res } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await expect(
|
|
||||||
controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
),
|
|
||||||
).rejects.toBeInstanceOf(ForbiddenException);
|
|
||||||
expect(streamRegistry.attach).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('answers 204 when the registry has nothing to resume (no entry / finished / anchor-mismatch)', async () => {
|
|
||||||
const { controller } = makeController({ chat: owned, attachment: null });
|
|
||||||
const { res } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(res.status).toHaveBeenCalledWith(204);
|
|
||||||
expect(res.send).toHaveBeenCalled();
|
|
||||||
expect(res.hijack).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('threads expect=live and anchor through to the registry', async () => {
|
|
||||||
const { controller, streamRegistry } = makeController({
|
|
||||||
chat: owned,
|
|
||||||
attachment: null,
|
|
||||||
});
|
|
||||||
const { res } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
'live',
|
|
||||||
'anchor-1',
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
|
||||||
'c1',
|
|
||||||
true,
|
|
||||||
'anchor-1',
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes expect=false when the query is absent', async () => {
|
|
||||||
const { controller, streamRegistry } = makeController({
|
|
||||||
chat: owned,
|
|
||||||
attachment: null,
|
|
||||||
});
|
|
||||||
const { res } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(streamRegistry.attach).toHaveBeenCalledWith(
|
|
||||||
'c1',
|
|
||||||
false,
|
|
||||||
undefined,
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('hijacks, writes headers + replay, registers a close cleanup, then goes live', async () => {
|
|
||||||
const start = jest.fn();
|
|
||||||
const attachment = makeAttachment({
|
|
||||||
replay: ['f1', 'f2'],
|
|
||||||
finished: false,
|
|
||||||
start,
|
|
||||||
});
|
|
||||||
const { controller } = makeController({ chat: owned, attachment });
|
|
||||||
const { res, raw } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(res.hijack).toHaveBeenCalled();
|
|
||||||
expect(raw.writeHead).toHaveBeenCalledWith(
|
|
||||||
200,
|
|
||||||
expect.objectContaining({ 'content-type': 'text/event-stream' }),
|
|
||||||
);
|
|
||||||
expect(raw.written).toEqual(['f1', 'f2']); // replay
|
|
||||||
expect(start).toHaveBeenCalled(); // go live after replay
|
|
||||||
expect(req.raw.once).toHaveBeenCalledWith('close', expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('finished replay ends the response immediately without going live', async () => {
|
|
||||||
const start = jest.fn();
|
|
||||||
const attachment = makeAttachment({
|
|
||||||
replay: ['f1'],
|
|
||||||
finished: true,
|
|
||||||
start,
|
|
||||||
});
|
|
||||||
const { controller } = makeController({ chat: owned, attachment });
|
|
||||||
const { res, raw } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
'live',
|
|
||||||
'a1',
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(raw.written).toEqual(['f1']);
|
|
||||||
expect(raw.end).toHaveBeenCalled();
|
|
||||||
expect(start).not.toHaveBeenCalled(); // finished -> returns before start()
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a close during the awaits (req.raw.destroyed) unsubscribes and writes nothing', async () => {
|
|
||||||
const attachment = makeAttachment({ replay: ['f1'] });
|
|
||||||
const { controller } = makeController({ chat: owned, attachment });
|
|
||||||
const { res, raw } = makeRawRes();
|
|
||||||
const { req } = makeReq(true); // destroyed already at registration time
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(attachment.unsubscribe).toHaveBeenCalled();
|
|
||||||
expect(raw.writeHead).not.toHaveBeenCalled();
|
|
||||||
expect(raw.written).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the registered close handler unsubscribes the attachment', async () => {
|
|
||||||
const attachment = makeAttachment({ replay: [] });
|
|
||||||
const { controller } = makeController({ chat: owned, attachment });
|
|
||||||
const { res } = makeRawRes();
|
|
||||||
const { req, fireClose } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
expect(attachment.unsubscribe).not.toHaveBeenCalled();
|
|
||||||
fireClose(); // socket closed
|
|
||||||
expect(attachment.unsubscribe).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('onFrame destroys the socket when the buffered length exceeds the cap', async () => {
|
|
||||||
const attachment = makeAttachment({ replay: [] });
|
|
||||||
const { controller, getCb } = makeController({ chat: owned, attachment });
|
|
||||||
const { res, raw } = makeRawRes();
|
|
||||||
const { req } = makeReq();
|
|
||||||
await controller.attachRunStream(
|
|
||||||
'c1',
|
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
req,
|
|
||||||
res,
|
|
||||||
user,
|
|
||||||
workspace,
|
|
||||||
);
|
|
||||||
const cb = getCb();
|
|
||||||
// Normal live frame writes.
|
|
||||||
raw.writableLength = 0;
|
|
||||||
cb.onFrame('live-1');
|
|
||||||
expect(raw.written).toContain('live-1');
|
|
||||||
// A stalled socket over the cap is destroyed instead of buffering.
|
|
||||||
raw.writableLength = SUBSCRIBER_MAX_BUFFERED_BYTES + 1;
|
|
||||||
raw.write.mockClear();
|
|
||||||
cb.onFrame('too-much');
|
|
||||||
expect(raw.destroy).toHaveBeenCalled();
|
|
||||||
expect(raw.write).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The begin-hook `open()` flag gate (#184 phase 1.5). `open()` lives ONLY in the
|
|
||||||
* stream() begin-hook, gated on the resumable flag. If it regressed, a flag-off
|
|
||||||
* turn would create an EMPTY registry entry (never bound, never finished) and a
|
|
||||||
* later attach would find a non-null paused attachment -> a hung SSE that never
|
|
||||||
* gets a frame and never ends, instead of a clean 204. These drive stream() only
|
|
||||||
* far enough to capture the runHooks it hands to the service, then invoke the
|
|
||||||
* begin-hook and assert whether the registry was opened.
|
|
||||||
*/
|
|
||||||
describe('AiChatController begin-hook open() flag gate (#184 phase 1.5)', () => {
|
|
||||||
const user = { id: 'u1' } as User;
|
|
||||||
const workspace = {
|
|
||||||
id: 'ws1',
|
|
||||||
settings: { ai: { chat: true, autonomousRuns: true } },
|
|
||||||
} as unknown as Workspace;
|
|
||||||
|
|
||||||
function makeController(opts: { resumable: boolean }) {
|
|
||||||
let capturedArgs: any;
|
|
||||||
const aiChatService = {
|
|
||||||
resolveRoleForRequest: jest.fn(async () => null),
|
|
||||||
getChatModel: jest.fn(async () => ({})),
|
|
||||||
stream: jest.fn(async (args: any) => {
|
|
||||||
capturedArgs = args;
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
const aiChatRunService = {
|
|
||||||
getActiveForChat: jest.fn(async () => undefined),
|
|
||||||
beginRun: jest.fn(async () => ({
|
|
||||||
runId: 'run-1',
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const streamRegistry = { open: jest.fn(), attach: jest.fn() };
|
|
||||||
const environment = {
|
|
||||||
isAiChatResumableStreamEnabled: () => opts.resumable,
|
|
||||||
};
|
|
||||||
const controller = new AiChatController(
|
|
||||||
aiChatService as never,
|
|
||||||
aiChatRunService as never,
|
|
||||||
{} as never, // aiChatRepo
|
|
||||||
{} as never, // aiChatMessageRepo
|
|
||||||
{} as never, // aiTranscription
|
|
||||||
{} as never, // pageRepo
|
|
||||||
streamRegistry as never,
|
|
||||||
environment as never,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
controller,
|
|
||||||
streamRegistry,
|
|
||||||
aiChatRunService,
|
|
||||||
getRunHooks: () => capturedArgs?.runHooks,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeReqRes() {
|
|
||||||
const req: any = {
|
|
||||||
raw: { sessionId: 'sess-1', once: jest.fn() },
|
|
||||||
body: {
|
|
||||||
messages: [
|
|
||||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const res: any = {
|
|
||||||
raw: { once: jest.fn(), on: jest.fn(), headersSent: false, writableEnded: false },
|
|
||||||
hijack: jest.fn(),
|
|
||||||
};
|
|
||||||
return { req, res };
|
|
||||||
}
|
|
||||||
|
|
||||||
it('flag OFF: the begin-hook does NOT open a registry entry (no hung empty entry)', async () => {
|
|
||||||
const { controller, streamRegistry, aiChatRunService, getRunHooks } =
|
|
||||||
makeController({ resumable: false });
|
|
||||||
const { req, res } = makeReqRes();
|
|
||||||
await controller.stream(req, res, user, workspace);
|
|
||||||
|
|
||||||
const runHooks = getRunHooks();
|
|
||||||
expect(runHooks).toBeDefined();
|
|
||||||
const handle = await runHooks.begin('chat-1');
|
|
||||||
// The run still begins (the durable-run feature is independent of resume)...
|
|
||||||
expect(aiChatRunService.beginRun).toHaveBeenCalled();
|
|
||||||
expect(handle).toEqual({ runId: 'run-1', signal: expect.anything() });
|
|
||||||
// ...but with the flag off the registry entry is NEVER opened.
|
|
||||||
expect(streamRegistry.open).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flag ON: the begin-hook opens the registry entry with (chatId, runId)', async () => {
|
|
||||||
const { controller, streamRegistry, getRunHooks } = makeController({
|
|
||||||
resumable: true,
|
|
||||||
});
|
|
||||||
const { req, res } = makeReqRes();
|
|
||||||
await controller.stream(req, res, user, workspace);
|
|
||||||
|
|
||||||
const runHooks = getRunHooks();
|
|
||||||
await runHooks.begin('chat-1');
|
|
||||||
expect(streamRegistry.open).toHaveBeenCalledWith('chat-1', 'run-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,15 +4,11 @@ import {
|
|||||||
ConflictException,
|
ConflictException,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Get,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpException,
|
HttpException,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
Param,
|
|
||||||
ParseUUIDPipe,
|
|
||||||
Post,
|
Post,
|
||||||
Query,
|
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
@@ -58,12 +54,6 @@ import {
|
|||||||
} from './dto/ai-chat.dto';
|
} from './dto/ai-chat.dto';
|
||||||
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
import { describeProviderError } from '../../integrations/ai/ai-error.util';
|
||||||
import { buildChatMarkdown } from './chat-markdown.util';
|
import { buildChatMarkdown } from './chat-markdown.util';
|
||||||
import {
|
|
||||||
AiChatStreamRegistryService,
|
|
||||||
SUBSCRIBER_MAX_BUFFERED_BYTES,
|
|
||||||
} from './ai-chat-stream-registry.service';
|
|
||||||
import { startSseHeartbeat } from './sse-resilience';
|
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-user AI chat API (§6.1). Routes are POST to match this codebase's
|
* Per-user AI chat API (§6.1). Routes are POST to match this codebase's
|
||||||
@@ -82,11 +72,6 @@ export class AiChatController {
|
|||||||
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
private readonly aiChatMessageRepo: AiChatMessageRepo,
|
||||||
private readonly aiTranscription: AiTranscriptionService,
|
private readonly aiTranscription: AiTranscriptionService,
|
||||||
private readonly pageRepo: PageRepo,
|
private readonly pageRepo: PageRepo,
|
||||||
// #184 phase 1.5. OPTIONAL so existing positional constructions (controller
|
|
||||||
// specs) compile unchanged; Nest always injects the real providers in
|
|
||||||
// production. Only touched on the resumable-stream (flag-on) path.
|
|
||||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
|
||||||
private readonly environment?: EnvironmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** List the requesting user's chats in this workspace (paginated). */
|
/** List the requesting user's chats in this workspace (paginated). */
|
||||||
@@ -248,102 +233,6 @@ export class AiChatController {
|
|||||||
return { stopped };
|
return { stopped };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Attach to a chat's live run stream (#184 phase 1.5). A late/reloaded tab
|
|
||||||
* replays the frames buffered so far and then follows the live tail as a normal
|
|
||||||
* streamer. Owner-gated via assertOwnedChat (same gate as getRun). When there is
|
|
||||||
* nothing to resume — no entry, a finished run without expect=live, an
|
|
||||||
* overflowed buffer, or an anchor that pins a DIFFERENT run — the endpoint
|
|
||||||
* answers 204, the ONLY "nothing to resume" signal the AI SDK's reconnect
|
|
||||||
* accepts (it maps 204 to a silent no-op). With AI_CHAT_RESUMABLE_STREAM off the
|
|
||||||
* registry is never populated, so attach always 204s.
|
|
||||||
*
|
|
||||||
* `expect=live` opts into replaying a finished-but-retained run (safe only when
|
|
||||||
* the client stripped the streaming tail); `anchor` is the client's assistant
|
|
||||||
* row id, which must match this run's (invariant 6) or a foreign run's
|
|
||||||
* transcript would be replayed into the store.
|
|
||||||
*/
|
|
||||||
@SkipTransform()
|
|
||||||
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
|
|
||||||
@Throttle({ [AI_CHAT_THROTTLER]: { limit: 60, ttl: 60000 } })
|
|
||||||
@Get('runs/:chatId/stream')
|
|
||||||
async attachRunStream(
|
|
||||||
@Param('chatId', new ParseUUIDPipe()) chatId: string,
|
|
||||||
@Query('expect') expect: string | undefined,
|
|
||||||
@Query('anchor') anchor: string | undefined,
|
|
||||||
@Req() req: FastifyRequest,
|
|
||||||
@Res() res: FastifyReply,
|
|
||||||
@AuthUser() user: User,
|
|
||||||
@AuthWorkspace() workspace: Workspace,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.assertOwnedChat(chatId, user, workspace); // same gate as getRun
|
|
||||||
let stopHeartbeat: () => void = () => undefined;
|
|
||||||
const attachment = await this.streamRegistry?.attach(
|
|
||||||
chatId,
|
|
||||||
expect === 'live',
|
|
||||||
anchor,
|
|
||||||
{
|
|
||||||
onFrame: (frame) => {
|
|
||||||
// Backpressure guard: 2x the replay cap, so the initial replay burst
|
|
||||||
// alone can never trip it; only a genuinely stalled socket can.
|
|
||||||
try {
|
|
||||||
if (res.raw.writableLength > SUBSCRIBER_MAX_BUFFERED_BYTES) {
|
|
||||||
res.raw.destroy(); // 'close' fires -> unsubscribe below
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!res.raw.writableEnded) res.raw.write(frame);
|
|
||||||
} catch {
|
|
||||||
res.raw.destroy();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onEnd: () => {
|
|
||||||
stopHeartbeat();
|
|
||||||
if (!res.raw.writableEnded) res.raw.end();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!attachment) {
|
|
||||||
res.status(204).send(); // the ONLY "nothing to resume" signal the SDK accepts
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.hijack();
|
|
||||||
// Cleanup BEFORE any write (invariant 5): a torn-down socket must not orphan
|
|
||||||
// a paused subscriber whose pending queue would buffer the whole run.
|
|
||||||
req.raw.once('close', () => {
|
|
||||||
attachment.unsubscribe();
|
|
||||||
stopHeartbeat();
|
|
||||||
});
|
|
||||||
// A close emitted DURING the awaits above was missed by the listener — check.
|
|
||||||
// (Healthy pending GETs have req.raw.destroyed === false, so no false
|
|
||||||
// positives; returning without end() is fine — the socket is gone.)
|
|
||||||
if (req.raw.destroyed) {
|
|
||||||
attachment.unsubscribe();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.raw.on('error', () => undefined);
|
|
||||||
try {
|
|
||||||
res.raw.writeHead(200, {
|
|
||||||
'content-type': 'text/event-stream',
|
|
||||||
'cache-control': 'no-cache',
|
|
||||||
'x-vercel-ai-ui-message-stream': 'v1',
|
|
||||||
'x-accel-buffering': 'no',
|
|
||||||
// deliberately NO Connection/Keep-Alive (hop-by-hop; Safari/HTTP2)
|
|
||||||
});
|
|
||||||
res.raw.flushHeaders?.();
|
|
||||||
for (const frame of attachment.replay) res.raw.write(frame);
|
|
||||||
if (attachment.finished) {
|
|
||||||
res.raw.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stopHeartbeat = startSseHeartbeat(res.raw, 15_000);
|
|
||||||
attachment.start(); // drain pending accumulated during replay, go live
|
|
||||||
} catch {
|
|
||||||
attachment.unsubscribe();
|
|
||||||
stopHeartbeat();
|
|
||||||
res.raw.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Rename a chat. */
|
/** Rename a chat. */
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('rename')
|
@Post('rename')
|
||||||
@@ -455,25 +344,13 @@ export class AiChatController {
|
|||||||
// its progress, and settle its terminal status — see AiChatRunService.
|
// its progress, and settle its terminal status — see AiChatRunService.
|
||||||
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
const runHooks: AiChatRunHooks | undefined = autonomousRuns
|
||||||
? {
|
? {
|
||||||
begin: async (chatId) => {
|
begin: (chatId) =>
|
||||||
const handle = await this.aiChatRunService.beginRun({
|
this.aiChatRunService.beginRun({
|
||||||
chatId,
|
chatId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
trigger: 'user',
|
trigger: 'user',
|
||||||
});
|
}),
|
||||||
// #184 phase 1.5: register the run-stream entry at BEGIN (before any
|
|
||||||
// frame) so a tab that attaches in the begin->seed window finds an
|
|
||||||
// entry to wait on. Gated on AI_CHAT_RESUMABLE_STREAM: with the flag
|
|
||||||
// off nothing is registered and attach always 204s.
|
|
||||||
if (
|
|
||||||
handle?.runId &&
|
|
||||||
this.environment?.isAiChatResumableStreamEnabled?.()
|
|
||||||
) {
|
|
||||||
this.streamRegistry?.open(chatId, handle.runId);
|
|
||||||
}
|
|
||||||
return handle;
|
|
||||||
},
|
|
||||||
onAssistantSeeded: (runId, messageId) =>
|
onAssistantSeeded: (runId, messageId) =>
|
||||||
this.aiChatRunService.linkAssistantMessage(
|
this.aiChatRunService.linkAssistantMessage(
|
||||||
runId,
|
runId,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { TokenModule } from '../auth/token.module';
|
|||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
import { AiChatService } from './ai-chat.service';
|
import { AiChatService } from './ai-chat.service';
|
||||||
import { AiChatRunService } from './ai-chat-run.service';
|
import { AiChatRunService } from './ai-chat-run.service';
|
||||||
import { AiChatStreamRegistryService } from './ai-chat-stream-registry.service';
|
|
||||||
import { AiTranscriptionService } from './ai-transcription.service';
|
import { AiTranscriptionService } from './ai-transcription.service';
|
||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { EmbeddingModule } from './embedding/embedding.module';
|
import { EmbeddingModule } from './embedding/embedding.module';
|
||||||
@@ -45,7 +44,6 @@ import { PublicShareChatToolsService } from './tools/public-share-chat-tools.ser
|
|||||||
providers: [
|
providers: [
|
||||||
AiChatService,
|
AiChatService,
|
||||||
AiChatRunService,
|
AiChatRunService,
|
||||||
AiChatStreamRegistryService,
|
|
||||||
AiTranscriptionService,
|
AiTranscriptionService,
|
||||||
AiChatToolsService,
|
AiChatToolsService,
|
||||||
PublicShareChatService,
|
PublicShareChatService,
|
||||||
|
|||||||
@@ -153,41 +153,6 @@ describe('buildSystemPrompt current-page context', () => {
|
|||||||
expect(prompt).not.toContain('pageId:');
|
expect(prompt).not.toContain('pageId:');
|
||||||
});
|
});
|
||||||
|
|
||||||
// #388: editor-selection flag. Only a FIXED one-liner is added — the selection
|
|
||||||
// TEXT (untrusted page content) must never reach the prompt.
|
|
||||||
const SELECTION_FLAG = 'currently has text SELECTED on this page';
|
|
||||||
|
|
||||||
it('adds the selection flag when a selection is present with a page', () => {
|
|
||||||
const prompt = buildSystemPrompt({
|
|
||||||
workspace,
|
|
||||||
openedPage: {
|
|
||||||
id: 'pg-123',
|
|
||||||
title: 'Doc',
|
|
||||||
selection: { text: 'SECRET-SELECTED-TEXT', blockIds: ['b1'] },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(prompt).toContain(SELECTION_FLAG);
|
|
||||||
// The selection TEXT itself is NEVER in the prompt.
|
|
||||||
expect(prompt).not.toContain('SECRET-SELECTED-TEXT');
|
|
||||||
expect(prompt).not.toContain('b1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the selection flag when there is no selection', () => {
|
|
||||||
const prompt = buildSystemPrompt({
|
|
||||||
workspace,
|
|
||||||
openedPage: { id: 'pg-123', title: 'Doc' },
|
|
||||||
});
|
|
||||||
expect(prompt).not.toContain(SELECTION_FLAG);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits the selection flag when selection is null', () => {
|
|
||||||
const prompt = buildSystemPrompt({
|
|
||||||
workspace,
|
|
||||||
openedPage: { id: 'pg-123', title: 'Doc', selection: null },
|
|
||||||
});
|
|
||||||
expect(prompt).not.toContain(SELECTION_FLAG);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('escapes a malicious opened-page title so it cannot inject tags (F1)', () => {
|
it('escapes a malicious opened-page title so it cannot inject tags (F1)', () => {
|
||||||
const prompt = buildSystemPrompt({
|
const prompt = buildSystemPrompt({
|
||||||
workspace,
|
workspace,
|
||||||
|
|||||||
@@ -156,13 +156,8 @@ export interface BuildSystemPromptInput {
|
|||||||
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
|
* has an id, a CONTEXT line is added so the agent can resolve "this page" /
|
||||||
* "the current page" to that pageId. The page is NOT fetched here — the agent
|
* "the current page" to that pageId. The page is NOT fetched here — the agent
|
||||||
* uses its CASL-enforced read/write page tools with the id when needed.
|
* uses its CASL-enforced read/write page tools with the id when needed.
|
||||||
*
|
|
||||||
* `selection` (#388) is present only when the user has a non-empty editor
|
|
||||||
* selection; the prompt adds ONLY a fixed one-line flag from it — the
|
|
||||||
* selection TEXT is untrusted page content and stays out of the prompt (it is
|
|
||||||
* surfaced solely via the getCurrentPage tool result).
|
|
||||||
*/
|
*/
|
||||||
openedPage?: { id?: string; title?: string; selection?: object | null } | null;
|
openedPage?: { id?: string; title?: string } | null;
|
||||||
/**
|
/**
|
||||||
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
|
* Admin-authored, per-EXTERNAL-MCP-server guidance ("how/when to use this
|
||||||
* server's tools"), built by `McpClientsService.toolsFor` for servers that
|
* server's tools"), built by `McpClientsService.toolsFor` for servers that
|
||||||
@@ -314,14 +309,6 @@ export function buildSystemPrompt({
|
|||||||
? escapeAttr(openedPage.title)
|
? escapeAttr(openedPage.title)
|
||||||
: 'Untitled';
|
: 'Untitled';
|
||||||
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
|
context += `\nThe user is currently viewing the page "${title}" (pageId: ${pageId.trim()}). When they refer to "this page", "the current page", or similar, operate on that pageId — use the read/write page tools with it.`;
|
||||||
// Editor-selection flag (#388). A FIXED one-liner only — the selection TEXT
|
|
||||||
// is untrusted collaborative-page content and must never enter the prompt; it
|
|
||||||
// is surfaced solely through the getCurrentPage tool result (SAFETY_FRAMEWORK
|
|
||||||
// treats a tool result as data). Nested under the page block so it is added
|
|
||||||
// only alongside a resolved page (a selection cannot outlive its page).
|
|
||||||
if (openedPage?.selection) {
|
|
||||||
context += `\nThe user currently has text SELECTED on this page — call getCurrentPage to see the selection. When they say "this", "here", "the selected text" or similar, they mean that selection.`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interrupt-resume marker (#198). Added to the context section (inside the
|
// Interrupt-resume marker (#198). Added to the context section (inside the
|
||||||
|
|||||||
@@ -1,295 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
|
|
||||||
// Mock the AI SDK: the turn we drive is STOPPED during the pre-streamText setup
|
|
||||||
// phase, so no provider call must ever be made. convertToModelMessages is reached
|
|
||||||
// (before toolsFor) so it is stubbed to an empty transcript.
|
|
||||||
jest.mock('ai', () => ({
|
|
||||||
streamText: jest.fn(),
|
|
||||||
generateText: jest.fn(),
|
|
||||||
convertToModelMessages: jest.fn(async () => []),
|
|
||||||
stepCountIs: jest.fn(() => () => false),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { streamText } from 'ai';
|
|
||||||
import { AiChatService } from './ai-chat.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* D2 — an explicit Stop DURING the external-MCP toolset build (the pre-streamText
|
|
||||||
* setup phase) must:
|
|
||||||
* (a) unwedge the turn (stream() rejects instead of hanging at step 0), and
|
|
||||||
* (b) finalize the run as 'aborted' via the outer catch's onSettled — never leak
|
|
||||||
* the run row as 'running' (which would 409 every later turn in this chat).
|
|
||||||
*
|
|
||||||
* The setup phase does NOT yet observe streamText's terminal callbacks, so before
|
|
||||||
* the fix a hung `toolsFor` ignored the run's abort signal and never finalized.
|
|
||||||
* `raceAgainstAbortAndTimeout(toolsFor, effectiveSignal, ...)` now rejects the
|
|
||||||
* moment the run's signal aborts; the catch re-throws (signal aborted), and the
|
|
||||||
* outer catch settles the run 'aborted'.
|
|
||||||
*/
|
|
||||||
describe('AiChatService.stream — abort during external-MCP setup finalizes the run (D2)', () => {
|
|
||||||
const streamTextMock = streamText as unknown as jest.Mock;
|
|
||||||
|
|
||||||
function makeService(mcpClients: { toolsFor: jest.Mock }) {
|
|
||||||
const aiChatRepo = {
|
|
||||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
|
||||||
insert: jest.fn(),
|
|
||||||
};
|
|
||||||
const aiChatMessageRepo = {
|
|
||||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
|
||||||
findAllByChat: jest.fn(async () => []),
|
|
||||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
|
||||||
};
|
|
||||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
|
||||||
const tools = { forUser: jest.fn(async () => ({})) };
|
|
||||||
const svc = new AiChatService(
|
|
||||||
{} as never, // ai
|
|
||||||
aiChatRepo as never,
|
|
||||||
aiChatMessageRepo as never,
|
|
||||||
{} as never, // aiChatPageSnapshotRepo
|
|
||||||
aiSettings as never,
|
|
||||||
tools as never,
|
|
||||||
mcpClients as never,
|
|
||||||
{} as never, // aiAgentRoleRepo
|
|
||||||
{} as never, // pageRepo (openPage undefined -> never touched)
|
|
||||||
{} as never, // pageAccess
|
|
||||||
{ isAiChatDeferredToolsEnabled: () => false } as never, // environment
|
|
||||||
);
|
|
||||||
return { svc, tools };
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
chatId: 'chat-1',
|
|
||||||
messages: [
|
|
||||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// A minimal raw ServerResponse stand-in for the turns that PROCEED past setup
|
|
||||||
// and reach streamText (the deadline + legacy paths). The setup-only abort test
|
|
||||||
// never wires the stream, so it keeps using `{ raw: {} }`.
|
|
||||||
function makeRawRes() {
|
|
||||||
return {
|
|
||||||
raw: {
|
|
||||||
writeHead: jest.fn(function writeHead(this: unknown) {
|
|
||||||
return this;
|
|
||||||
}),
|
|
||||||
write: jest.fn(),
|
|
||||||
once: jest.fn(),
|
|
||||||
flushHeaders: jest.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A fake streamText result: the service only calls consumeStream() and
|
|
||||||
// pipeUIMessageStreamToResponse() on it (both fire-and-forget). Its terminal
|
|
||||||
// callbacks are never invoked, so the run is not finalized through them.
|
|
||||||
function makeStreamResult() {
|
|
||||||
return {
|
|
||||||
consumeStream: jest.fn(),
|
|
||||||
pipeUIMessageStreamToResponse: jest.fn(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
streamTextMock.mockReset();
|
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
|
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined as never);
|
|
||||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops the hung toolset build, rejects, and settles the run "aborted" — never reaching streamText', async () => {
|
|
||||||
const runController = new AbortController();
|
|
||||||
// The build hangs (never resolves); the run is STOPPED mid-build. Aborting on a
|
|
||||||
// macrotask exercises the abort-listener path (a real user Stop during setup).
|
|
||||||
const toolsFor = jest.fn(() => {
|
|
||||||
setTimeout(() => runController.abort(new Error('user stop')), 0);
|
|
||||||
return new Promise(() => {}); // never settles — models a hung MCP build
|
|
||||||
});
|
|
||||||
const { svc } = makeService({ toolsFor });
|
|
||||||
|
|
||||||
const onSettled = jest.fn();
|
|
||||||
const begin = jest.fn(async () => ({
|
|
||||||
runId: 'run-1',
|
|
||||||
signal: runController.signal,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const promise = svc.stream({
|
|
||||||
user: { id: 'user-1' } as never,
|
|
||||||
workspace: { id: 'ws-1' } as never,
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
body: body as never,
|
|
||||||
res: { raw: {} } as never,
|
|
||||||
signal: new AbortController().signal, // socket signal (distinct from the run)
|
|
||||||
model: {} as never,
|
|
||||||
role: null,
|
|
||||||
runHooks: {
|
|
||||||
begin,
|
|
||||||
onAssistantSeeded: jest.fn(),
|
|
||||||
onStep: jest.fn(),
|
|
||||||
onSettled,
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
// (a) The turn is UNWEDGED: it rejects (with the stop reason) instead of hanging.
|
|
||||||
await expect(promise).rejects.toThrow('user stop');
|
|
||||||
|
|
||||||
// (b) The run is finalized as 'aborted' with NO error message (a Stop, not a
|
|
||||||
// failure) — so the run row never leaks 'running'.
|
|
||||||
expect(onSettled).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onSettled).toHaveBeenCalledWith('run-1', 'aborted', undefined);
|
|
||||||
|
|
||||||
// The build was reached, but the provider call was NEVER made (stopped at setup).
|
|
||||||
expect(toolsFor).toHaveBeenCalledTimes(1);
|
|
||||||
expect(streamTextMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Item 1 — the onLateResolve leg of raceAgainstAbortAndTimeout. When `toolsFor`
|
|
||||||
// loses the race (abort) but RESOLVES LATER with a leased toolset, the setup site
|
|
||||||
// must release that abandoned toolset's leases (call close() on its client
|
|
||||||
// handles) so their lease refcount is not pinned forever by a toolset nobody
|
|
||||||
// consumes. Nothing else exercises this path.
|
|
||||||
it('releases the leases of a toolset that resolves AFTER the race was already lost (onLateResolve)', async () => {
|
|
||||||
const runController = new AbortController();
|
|
||||||
// A controllable build: it hangs until we resolve it by hand, and the run is
|
|
||||||
// stopped mid-build so the race rejects BEFORE the build settles.
|
|
||||||
let resolveTools: (v: unknown) => void = () => undefined;
|
|
||||||
const toolsForPromise = new Promise((resolve) => {
|
|
||||||
resolveTools = resolve;
|
|
||||||
});
|
|
||||||
const toolsFor = jest.fn(() => {
|
|
||||||
setTimeout(() => runController.abort(new Error('user stop')), 0);
|
|
||||||
return toolsForPromise;
|
|
||||||
});
|
|
||||||
const { svc } = makeService({ toolsFor });
|
|
||||||
|
|
||||||
const begin = jest.fn(async () => ({
|
|
||||||
runId: 'run-1',
|
|
||||||
signal: runController.signal,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const promise = svc.stream({
|
|
||||||
user: { id: 'user-1' } as never,
|
|
||||||
workspace: { id: 'ws-1' } as never,
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
body: body as never,
|
|
||||||
res: { raw: {} } as never,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
model: {} as never,
|
|
||||||
role: null,
|
|
||||||
runHooks: {
|
|
||||||
begin,
|
|
||||||
onAssistantSeeded: jest.fn(),
|
|
||||||
onStep: jest.fn(),
|
|
||||||
onSettled: jest.fn(),
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The race is lost to the abort: the turn rejects with the stop reason.
|
|
||||||
await expect(promise).rejects.toThrow('user stop');
|
|
||||||
|
|
||||||
// NOW the abandoned build resolves late with a leased client. onLateResolve must
|
|
||||||
// release it (call close on the lease handle).
|
|
||||||
const close = jest.fn().mockResolvedValue(undefined);
|
|
||||||
resolveTools({
|
|
||||||
tools: {},
|
|
||||||
clients: [{ close }],
|
|
||||||
outcomes: [],
|
|
||||||
instructions: [],
|
|
||||||
});
|
|
||||||
// Flush the microtasks so work.then -> onLateResolve -> Promise.all(close) runs.
|
|
||||||
await new Promise((r) => setImmediate(r));
|
|
||||||
|
|
||||||
expect(close).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Item 2 — the PURE DEADLINE branch (MCP_TOOLSET_BUILD_DEADLINE_MS). `toolsFor`
|
|
||||||
// never settles and the run's signal is NOT aborted: the race rejects with a
|
|
||||||
// "setup timed out" error, the catch does NOT re-throw (runId set but signal not
|
|
||||||
// aborted), and the turn PROCEEDS Docmost-only. It must reach streamText (the turn
|
|
||||||
// continues, not wedged) and must NOT be finalized 'aborted'.
|
|
||||||
it('proceeds Docmost-only (reaches streamText) when the build hits the deadline without an abort', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
|
|
||||||
// The build hangs forever; the run's signal is never aborted.
|
|
||||||
const toolsFor = jest.fn(() => new Promise(() => {}));
|
|
||||||
const { svc } = makeService({ toolsFor });
|
|
||||||
|
|
||||||
streamTextMock.mockReturnValue(makeStreamResult() as never);
|
|
||||||
|
|
||||||
const onSettled = jest.fn();
|
|
||||||
const runSignal = new AbortController().signal; // never aborts
|
|
||||||
const begin = jest.fn(async () => ({ runId: 'run-1', signal: runSignal }));
|
|
||||||
|
|
||||||
const promise = svc.stream({
|
|
||||||
user: { id: 'user-1' } as never,
|
|
||||||
workspace: { id: 'ws-1' } as never,
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
body: body as never,
|
|
||||||
res: makeRawRes() as never,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
model: {} as never,
|
|
||||||
role: null,
|
|
||||||
runHooks: {
|
|
||||||
begin,
|
|
||||||
onAssistantSeeded: jest.fn(),
|
|
||||||
onStep: jest.fn(),
|
|
||||||
onSettled,
|
|
||||||
} as never,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Advance past the 60s build deadline; advanceTimersByTimeAsync flushes the
|
|
||||||
// promise microtasks between timer fires so the whole setup chain runs.
|
|
||||||
await jest.advanceTimersByTimeAsync(60_001);
|
|
||||||
// The turn does not throw out of setup — it continues to stream.
|
|
||||||
await expect(promise).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
// The turn CONTINUED: streamText was reached (Docmost-only), not wedged.
|
|
||||||
expect(toolsFor).toHaveBeenCalledTimes(1);
|
|
||||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
|
||||||
// The run was NOT finalized as aborted (the deadline is not a Stop) — the setup
|
|
||||||
// catch settle path never ran, so onSettled is left to streamText's callbacks.
|
|
||||||
expect(onSettled).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Item 3 — the LEGACY no-runId path. The catch's re-throw is gated on
|
|
||||||
// `runId && effectiveSignal.aborted`. With NO runId (no runHooks) an abort during
|
|
||||||
// setup must NOT re-throw (runId falsy) — the turn warns + proceeds Docmost-only
|
|
||||||
// and streams, and is never finalized 'aborted' via the re-throw. Locks the
|
|
||||||
// `runId &&` half of the guard.
|
|
||||||
it('does NOT re-throw on a setup abort when there is no runId (legacy path proceeds Docmost-only)', async () => {
|
|
||||||
const socketController = new AbortController();
|
|
||||||
// The build hangs; the SOCKET signal (legacy effectiveSignal) aborts mid-build.
|
|
||||||
const toolsFor = jest.fn(() => {
|
|
||||||
setTimeout(() => socketController.abort(new Error('socket closed')), 0);
|
|
||||||
return new Promise(() => {});
|
|
||||||
});
|
|
||||||
const { svc } = makeService({ toolsFor });
|
|
||||||
|
|
||||||
streamTextMock.mockReturnValue(makeStreamResult() as never);
|
|
||||||
|
|
||||||
// No runHooks => runId undefined, effectiveSignal === the socket signal.
|
|
||||||
const promise = svc.stream({
|
|
||||||
user: { id: 'user-1' } as never,
|
|
||||||
workspace: { id: 'ws-1' } as never,
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
body: body as never,
|
|
||||||
res: makeRawRes() as never,
|
|
||||||
signal: socketController.signal,
|
|
||||||
model: {} as never,
|
|
||||||
role: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The turn does NOT reject out of setup (no re-throw on the legacy path).
|
|
||||||
await expect(promise).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
// It proceeded Docmost-only and reached streamText — streamText then observes
|
|
||||||
// the already-aborted socket signal via its own abortSignal.
|
|
||||||
expect(toolsFor).toHaveBeenCalledTimes(1);
|
|
||||||
expect(streamTextMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
import { ForbiddenException, Logger } from '@nestjs/common';
|
import { ForbiddenException } from '@nestjs/common';
|
||||||
// Mock ONLY streamText so a driven stream() call can capture the pipe-options
|
|
||||||
// object (consumeSseStream / generateMessageId). Everything else in the AI SDK
|
|
||||||
// stays REAL (requireActual), so the pure-helper suites in this file are
|
|
||||||
// unaffected — none of them call stream()/streamText.
|
|
||||||
jest.mock('ai', () => ({
|
|
||||||
...jest.requireActual('ai'),
|
|
||||||
streamText: jest.fn(),
|
|
||||||
}));
|
|
||||||
import { streamText } from 'ai';
|
|
||||||
import {
|
import {
|
||||||
AiChatService,
|
AiChatService,
|
||||||
compactToolOutput,
|
compactToolOutput,
|
||||||
@@ -16,7 +7,6 @@ import {
|
|||||||
rowToUiMessage,
|
rowToUiMessage,
|
||||||
prepareAgentStep,
|
prepareAgentStep,
|
||||||
flushAssistant,
|
flushAssistant,
|
||||||
stripNulChars,
|
|
||||||
chatStreamMetadata,
|
chatStreamMetadata,
|
||||||
accumulateStepUsage,
|
accumulateStepUsage,
|
||||||
isInterruptResume,
|
isInterruptResume,
|
||||||
@@ -29,13 +19,11 @@ import { buildSystemPrompt } from './ai-chat.prompt';
|
|||||||
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
import type { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
|
* Unit tests for compactToolOutput: the pure helper that shrinks LARGE tool
|
||||||
* before they are persisted (and re-sent to the provider on later turns). The
|
* outputs before they are persisted (and re-sent to the provider on later
|
||||||
* contract is: small and normal outputs — including whole page reads (tens of
|
* turns). The contract is: small outputs pass through unchanged (by identity);
|
||||||
* KB) — pass through unchanged (by identity); only an output above the high
|
* large outputs keep their shape and small scalar fields (id/title/pageId — the
|
||||||
* safety cap (> 200 KB) is compacted, and even then it keeps its shape and
|
* client reads these to render citations) while big payloads are truncated.
|
||||||
* small scalar fields (id/title/pageId — the client reads these to render
|
|
||||||
* citations) while the big payloads are reduced.
|
|
||||||
*/
|
*/
|
||||||
describe('compactToolOutput', () => {
|
describe('compactToolOutput', () => {
|
||||||
it('returns a small object unchanged (by identity)', () => {
|
it('returns a small object unchanged (by identity)', () => {
|
||||||
@@ -44,7 +32,7 @@ describe('compactToolOutput', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('truncates a large getPage-shaped markdown body but keeps the title', () => {
|
it('truncates a large getPage-shaped markdown body but keeps the title', () => {
|
||||||
const big = 'x'.repeat(300000);
|
const big = 'x'.repeat(20000);
|
||||||
const result = compactToolOutput({ title: 'T', markdown: big }) as {
|
const result = compactToolOutput({ title: 'T', markdown: big }) as {
|
||||||
title: string;
|
title: string;
|
||||||
markdown: string;
|
markdown: string;
|
||||||
@@ -52,16 +40,15 @@ describe('compactToolOutput', () => {
|
|||||||
// Shallow scalar field is preserved (citations depend on it).
|
// Shallow scalar field is preserved (citations depend on it).
|
||||||
expect(result.title).toBe('T');
|
expect(result.title).toBe('T');
|
||||||
// The big payload is shrunk far below the original size.
|
// The big payload is shrunk far below the original size.
|
||||||
expect(result.markdown.length).toBeLessThan(300000);
|
expect(result.markdown.length).toBeLessThan(20000);
|
||||||
expect(result.markdown).toContain('omitted from stored chat history');
|
expect(result.markdown).toContain('[truncated');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('caps a long array and appends a single truncation marker', () => {
|
it('caps a long array and appends a single truncation marker', () => {
|
||||||
// 200 objects, each padded so the total serialized size
|
// 200 small objects, each padded so the total serialized size > 4000 bytes.
|
||||||
// > 200000 bytes (the new safety cap).
|
|
||||||
const long = Array.from({ length: 200 }, (_, i) => ({
|
const long = Array.from({ length: 200 }, (_, i) => ({
|
||||||
id: 'n' + i,
|
id: 'n' + i,
|
||||||
pad: 'y'.repeat(1200),
|
pad: 'y'.repeat(40),
|
||||||
}));
|
}));
|
||||||
const result = compactToolOutput(long) as Array<Record<string, unknown>>;
|
const result = compactToolOutput(long) as Array<Record<string, unknown>>;
|
||||||
// 50 kept + 1 marker.
|
// 50 kept + 1 marker.
|
||||||
@@ -79,8 +66,8 @@ describe('compactToolOutput', () => {
|
|||||||
|
|
||||||
it('replaces a subtree beyond the depth cap with a marker', () => {
|
it('replaces a subtree beyond the depth cap with a marker', () => {
|
||||||
// Build a deeply nested object (> TOOL_OUTPUT_MAX_DEPTH levels) with a big
|
// Build a deeply nested object (> TOOL_OUTPUT_MAX_DEPTH levels) with a big
|
||||||
// string at the bottom so the total serialized size exceeds the 200 KB cap.
|
// string at the bottom so the total serialized size exceeds the threshold.
|
||||||
let nested: Record<string, unknown> = { leaf: 'z'.repeat(250000) };
|
let nested: Record<string, unknown> = { leaf: 'z'.repeat(8000) };
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
nested = { child: nested };
|
nested = { child: nested };
|
||||||
}
|
}
|
||||||
@@ -89,7 +76,7 @@ describe('compactToolOutput', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('produces a much smaller JSON than the original for a large input', () => {
|
it('produces a much smaller JSON than the original for a large input', () => {
|
||||||
const big = 'x'.repeat(300000);
|
const big = 'x'.repeat(20000);
|
||||||
const original = { title: 'T', markdown: big };
|
const original = { title: 'T', markdown: big };
|
||||||
const result = compactToolOutput(original);
|
const result = compactToolOutput(original);
|
||||||
const originalBytes = Buffer.byteLength(JSON.stringify(original), 'utf8');
|
const originalBytes = Buffer.byteLength(JSON.stringify(original), 'utf8');
|
||||||
@@ -452,45 +439,6 @@ describe('flushAssistant', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* stripNulChars: a NUL (U+0000) is rejected by Postgres in BOTH the `content`
|
|
||||||
* (text) and `toolCalls`/`metadata` (jsonb) columns, so it must be stripped from
|
|
||||||
* every persisted string. String.fromCharCode(0) avoids embedding a raw NUL byte
|
|
||||||
* in this source file.
|
|
||||||
*/
|
|
||||||
describe('stripNulChars', () => {
|
|
||||||
const NUL = String.fromCharCode(0);
|
|
||||||
|
|
||||||
it('deep-strips NUL from strings in nested objects/arrays', () => {
|
|
||||||
const out = stripNulChars({
|
|
||||||
content: `a${NUL}b`,
|
|
||||||
parts: [{ type: 'text', text: `x${NUL}${NUL}y` }],
|
|
||||||
nested: [`p${NUL}q`, 42, null],
|
|
||||||
});
|
|
||||||
expect(out.content).toBe('ab');
|
|
||||||
expect((out.parts[0] as { text: string }).text).toBe('xy');
|
|
||||||
expect(out.nested[0]).toBe('pq');
|
|
||||||
expect(out.nested[1]).toBe(42);
|
|
||||||
expect(out.nested[2]).toBeNull();
|
|
||||||
expect(JSON.stringify(out).includes(NUL)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the SAME reference when there is no NUL (no needless clone)', () => {
|
|
||||||
const input = { a: 'clean', b: [1, 2, { c: 'ok' }] };
|
|
||||||
expect(stripNulChars(input)).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flushAssistant produces a NUL-free row even when the turn text carries one', () => {
|
|
||||||
const f = flushAssistant([], `partial${NUL}answer`, 'error', {
|
|
||||||
error: `bo${NUL}om`,
|
|
||||||
});
|
|
||||||
expect(f.content).toBe('partialanswer');
|
|
||||||
const serialized =
|
|
||||||
f.content + JSON.stringify(f.toolCalls) + JSON.stringify(f.metadata);
|
|
||||||
expect(serialized.includes(NUL)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* chatStreamMetadata: attach metadata to the streamed assistant UI message per
|
* chatStreamMetadata: attach metadata to the streamed assistant UI message per
|
||||||
* part type — `chatId` on `start` (so the client adopts the real created chat id
|
* part type — `chatId` on `start` (so the client adopts the real created chat id
|
||||||
@@ -741,7 +689,6 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
selection: unknown;
|
|
||||||
} | null>;
|
} | null>;
|
||||||
|
|
||||||
it('returns null when no page is open (no id)', async () => {
|
it('returns null when no page is open (no id)', async () => {
|
||||||
@@ -787,14 +734,8 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
|||||||
});
|
});
|
||||||
// The client claims it is on "Page A" but the id points at page B.
|
// The client claims it is on "Page A" but the id points at page B.
|
||||||
const result = await call(svc, { id: 'p-1', title: 'Page A' });
|
const result = await call(svc, { id: 'p-1', title: 'Page A' });
|
||||||
// updatedAt (#274 page-change fast path) is carried through from the DB row;
|
// updatedAt (#274 page-change fast path) is carried through from the DB row.
|
||||||
// selection is null when the client sent none (#388).
|
expect(result).toEqual({ id: 'p-1', title: 'Real Title B', updatedAt });
|
||||||
expect(result).toEqual({
|
|
||||||
id: 'p-1',
|
|
||||||
title: 'Real Title B',
|
|
||||||
updatedAt,
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coerces a null DB title to an empty string', async () => {
|
it('coerces a null DB title to an empty string', async () => {
|
||||||
@@ -807,55 +748,8 @@ describe('AiChatService.resolveOpenPageContext (#159 current-page validation)',
|
|||||||
id: 'p-1',
|
id: 'p-1',
|
||||||
title: '',
|
title: '',
|
||||||
updatedAt,
|
updatedAt,
|
||||||
selection: null,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// #388: the selection rides ONLY on a successful page resolve, and is
|
|
||||||
// sanitized on the way through.
|
|
||||||
it('attaches the SANITIZED selection on a successful resolve', async () => {
|
|
||||||
const updatedAt = new Date('2026-07-02T10:00:00Z');
|
|
||||||
const svc = makeService({
|
|
||||||
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Doc', updatedAt },
|
|
||||||
canView: true,
|
|
||||||
});
|
|
||||||
const result = await call(svc, {
|
|
||||||
id: 'p-1',
|
|
||||||
selection: {
|
|
||||||
text: 'fix this',
|
|
||||||
blockIds: ['b1', 123, 'y'.repeat(65)], // garbage stripped by sanitize
|
|
||||||
before: 'please ',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
id: 'p-1',
|
|
||||||
title: 'Doc',
|
|
||||||
updatedAt,
|
|
||||||
selection: { text: 'fix this', blockIds: ['b1'], before: 'please ' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('drops a blank/garbage selection to null on a successful resolve', async () => {
|
|
||||||
const updatedAt = new Date('2026-07-02T10:00:00Z');
|
|
||||||
const svc = makeService({
|
|
||||||
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Doc', updatedAt },
|
|
||||||
canView: true,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
await call(svc, { id: 'p-1', selection: { text: ' ' } }),
|
|
||||||
).toEqual({ id: 'p-1', title: 'Doc', updatedAt, selection: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('selection does NOT survive a foreign/inaccessible page (dies with the page)', async () => {
|
|
||||||
// Forbidden page => the WHOLE context is null, so the selection is gone too.
|
|
||||||
const svc = makeService({
|
|
||||||
page: { id: 'p-1', workspaceId: 'ws-1', title: 'Restricted' },
|
|
||||||
canView: false,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
await call(svc, { id: 'p-1', selection: { text: 'secret sel' } }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1165,181 +1059,3 @@ describe('isInterruptResume', () => {
|
|||||||
expect(isInterruptResume(withPrev(null), true)).toBe(false);
|
expect(isInterruptResume(withPrev(null), true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* #184 phase 1.5 — the run-wrapped pipe options (unit). Drives stream() to the
|
|
||||||
* pipe call with streamText mocked, capturing the options object, and asserts:
|
|
||||||
* - flag OFF while a runId IS present -> the LEGACY option shape (no
|
|
||||||
* consumeSseStream, no generateMessageId), and the registry is never touched.
|
|
||||||
* This is the exact dormancy guarantee this PR rests on.
|
|
||||||
* - flag ON + runId -> consumeSseStream tees into the registry and
|
|
||||||
* generateMessageId returns the seeded assistant DB row id.
|
|
||||||
* - flag ON but no runHooks (runId undefined) -> legacy (the runId gate).
|
|
||||||
* - flag ON + runId -> the outer catch releases the entry via abortEntry.
|
|
||||||
*/
|
|
||||||
describe('AiChatService.stream — resumable pipe options (#184 phase 1.5)', () => {
|
|
||||||
const streamTextMock = streamText as unknown as jest.Mock;
|
|
||||||
let pipeMock: jest.Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
streamTextMock.mockReset();
|
|
||||||
pipeMock = jest.fn();
|
|
||||||
streamTextMock.mockReturnValue({
|
|
||||||
consumeStream: jest.fn(),
|
|
||||||
pipeUIMessageStreamToResponse: pipeMock,
|
|
||||||
});
|
|
||||||
// Silence the service's diagnostic logging for a clean test run.
|
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined as never);
|
|
||||||
jest
|
|
||||||
.spyOn(Logger.prototype, 'error')
|
|
||||||
.mockImplementation(() => undefined as never);
|
|
||||||
jest
|
|
||||||
.spyOn(Logger.prototype, 'warn')
|
|
||||||
.mockImplementation(() => undefined as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.restoreAllMocks());
|
|
||||||
|
|
||||||
// A raw-response stub sufficient for the post-streamText wiring.
|
|
||||||
function makeRes() {
|
|
||||||
return {
|
|
||||||
raw: {
|
|
||||||
writeHead: jest.fn(),
|
|
||||||
write: jest.fn(),
|
|
||||||
once: jest.fn(),
|
|
||||||
on: jest.fn(),
|
|
||||||
flushHeaders: jest.fn(),
|
|
||||||
writableEnded: false,
|
|
||||||
destroyed: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire only the deps reached on the way to the pipe call, plus a spy registry.
|
|
||||||
function makeService(opts: { resumable: boolean }) {
|
|
||||||
const aiChatRepo = {
|
|
||||||
findById: jest.fn(async () => ({ id: 'chat-1', workspaceId: 'ws-1' })),
|
|
||||||
insert: jest.fn(),
|
|
||||||
};
|
|
||||||
const aiChatMessageRepo = {
|
|
||||||
// Both the user insert and the assistant seed return the same row id.
|
|
||||||
insert: jest.fn(async () => ({ id: 'msg-1' })),
|
|
||||||
findAllByChat: jest.fn(async () => []),
|
|
||||||
update: jest.fn(async () => ({ id: 'msg-1' })),
|
|
||||||
};
|
|
||||||
const aiSettings = { resolve: jest.fn(async () => ({})) };
|
|
||||||
const tools = { forUser: jest.fn(async () => ({})) };
|
|
||||||
const mcpClients = {
|
|
||||||
toolsFor: jest.fn(async () => ({
|
|
||||||
tools: {},
|
|
||||||
clients: [],
|
|
||||||
outcomes: [],
|
|
||||||
instructions: [],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const streamRegistry = {
|
|
||||||
open: jest.fn(),
|
|
||||||
bind: jest.fn(),
|
|
||||||
abortEntry: jest.fn(),
|
|
||||||
};
|
|
||||||
const svc = new AiChatService(
|
|
||||||
{} as never, // ai (model is injected)
|
|
||||||
aiChatRepo as never,
|
|
||||||
aiChatMessageRepo as never,
|
|
||||||
{} as never, // aiChatPageSnapshotRepo
|
|
||||||
aiSettings as never,
|
|
||||||
tools as never,
|
|
||||||
mcpClients as never,
|
|
||||||
{} as never, // aiAgentRoleRepo
|
|
||||||
{} as never, // pageRepo (openPage undefined -> never touched)
|
|
||||||
{} as never, // pageAccess
|
|
||||||
{
|
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
|
||||||
isAiChatResumableStreamEnabled: () => opts.resumable,
|
|
||||||
} as never,
|
|
||||||
streamRegistry as never,
|
|
||||||
);
|
|
||||||
return { svc, streamRegistry };
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
chatId: 'chat-1',
|
|
||||||
messages: [
|
|
||||||
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const makeRunHooks = () => ({
|
|
||||||
begin: jest.fn(async () => ({
|
|
||||||
runId: 'run-1',
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
})),
|
|
||||||
onAssistantSeeded: jest.fn(),
|
|
||||||
onStep: jest.fn(),
|
|
||||||
onSettled: jest.fn(),
|
|
||||||
});
|
|
||||||
|
|
||||||
async function drive(svc: AiChatService, hooks: unknown): Promise<void> {
|
|
||||||
await svc.stream({
|
|
||||||
user: { id: 'u1' } as never,
|
|
||||||
workspace: { id: 'ws-1' } as never,
|
|
||||||
sessionId: 's1',
|
|
||||||
body: body as never,
|
|
||||||
res: makeRes() as never,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
model: {} as never,
|
|
||||||
role: null,
|
|
||||||
runHooks: hooks as never,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it('flag OFF + runId present: LEGACY option shape (no consumeSseStream / generateMessageId); registry untouched', async () => {
|
|
||||||
const { svc, streamRegistry } = makeService({ resumable: false });
|
|
||||||
await drive(svc, makeRunHooks());
|
|
||||||
expect(pipeMock).toHaveBeenCalledTimes(1);
|
|
||||||
const options = pipeMock.mock.calls[0][1];
|
|
||||||
// The dormancy guarantee: a live run with the flag off tees NOTHING and does
|
|
||||||
// not stamp a message id — byte-for-byte the pre-1.5 wire.
|
|
||||||
expect(options.consumeSseStream).toBeUndefined();
|
|
||||||
expect(options.generateMessageId).toBeUndefined();
|
|
||||||
expect(streamRegistry.bind).not.toHaveBeenCalled();
|
|
||||||
expect(streamRegistry.abortEntry).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flag ON + runId: consumeSseStream tees into the registry; generateMessageId returns the seeded row id', async () => {
|
|
||||||
const { svc, streamRegistry } = makeService({ resumable: true });
|
|
||||||
await drive(svc, makeRunHooks());
|
|
||||||
const options = pipeMock.mock.calls[0][1];
|
|
||||||
expect(typeof options.consumeSseStream).toBe('function');
|
|
||||||
expect(typeof options.generateMessageId).toBe('function');
|
|
||||||
// generateMessageId stamps the seeded assistant DB row id.
|
|
||||||
expect(options.generateMessageId()).toBe('msg-1');
|
|
||||||
// consumeSseStream binds the tee: (chatId, runId, assistantId, stream).
|
|
||||||
const fakeStream = {} as ReadableStream<string>;
|
|
||||||
options.consumeSseStream({ stream: fakeStream });
|
|
||||||
expect(streamRegistry.bind).toHaveBeenCalledWith(
|
|
||||||
'chat-1',
|
|
||||||
'run-1',
|
|
||||||
'msg-1',
|
|
||||||
fakeStream,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flag ON but NO runHooks (runId undefined): pipe options stay legacy (the runId gate)', async () => {
|
|
||||||
const { svc, streamRegistry } = makeService({ resumable: true });
|
|
||||||
await drive(svc, undefined);
|
|
||||||
const options = pipeMock.mock.calls[0][1];
|
|
||||||
expect(options.consumeSseStream).toBeUndefined();
|
|
||||||
expect(options.generateMessageId).toBeUndefined();
|
|
||||||
expect(streamRegistry.bind).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('flag ON + runId: the outer catch calls abortEntry when the stream throws', async () => {
|
|
||||||
const { svc, streamRegistry } = makeService({ resumable: true });
|
|
||||||
streamTextMock.mockImplementation(() => {
|
|
||||||
throw new Error('boom');
|
|
||||||
});
|
|
||||||
await expect(drive(svc, makeRunHooks())).rejects.toThrow('boom');
|
|
||||||
expect(streamRegistry.abortEntry).toHaveBeenCalledWith('chat-1', 'run-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
import { AiChatToolsService } from './tools/ai-chat-tools.service';
|
||||||
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
import { McpClientsService } from './external-mcp/mcp-clients.service';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||||
import { AiChatStreamRegistryService } from './ai-chat-stream-registry.service';
|
|
||||||
import { buildSystemPrompt } from './ai-chat.prompt';
|
import { buildSystemPrompt } from './ai-chat.prompt';
|
||||||
import {
|
import {
|
||||||
CORE_TOOL_KEYS,
|
CORE_TOOL_KEYS,
|
||||||
@@ -43,10 +42,6 @@ import {
|
|||||||
} from './tools/tool-tiers';
|
} from './tools/tool-tiers';
|
||||||
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
import { RunAlreadyActiveError } from './ai-chat-run.service';
|
||||||
import { computePageChange } from './page-change/page-change.util';
|
import { computePageChange } from './page-change/page-change.util';
|
||||||
import {
|
|
||||||
sanitizeSelection,
|
|
||||||
type SelectionContext,
|
|
||||||
} from './tools/current-page.util';
|
|
||||||
import { roleModelOverride } from './roles/role-model-config';
|
import { roleModelOverride } from './roles/role-model-config';
|
||||||
import {
|
import {
|
||||||
startSseHeartbeat,
|
startSseHeartbeat,
|
||||||
@@ -58,19 +53,6 @@ import {
|
|||||||
// multi-search research questions are not cut off mid-investigation.
|
// multi-search research questions are not cut off mid-investigation.
|
||||||
const MAX_AGENT_STEPS = 20;
|
const MAX_AGENT_STEPS = 20;
|
||||||
|
|
||||||
// Wall-clock ceiling for building the external MCP toolset during the per-turn
|
|
||||||
// setup phase (before streamText owns the lifecycle). Defense-in-depth ABOVE the
|
|
||||||
// per-server connect bound in mcp-clients.service (CONNECT_TIMEOUT_MS): even if
|
|
||||||
// that per-server timeout regressed, this outer deadline — together with the run's
|
|
||||||
// abort signal — guarantees the setup phase can never wedge a turn at step 0 (the
|
|
||||||
// production hang) and the run always finalizes. It stays a TRUE backstop because
|
|
||||||
// buildEntry connects to the servers CONCURRENTLY, so the total build time is
|
|
||||||
// bounded by the SLOWEST single server (~2×CONNECT_TIMEOUT_MS), not the SUM across
|
|
||||||
// them — the per-server bound fires first no matter how many servers are enabled,
|
|
||||||
// and this outer deadline only catches a total build stall the per-server bound
|
|
||||||
// somehow missed.
|
|
||||||
const MCP_TOOLSET_BUILD_DEADLINE_MS = 60_000;
|
|
||||||
|
|
||||||
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
|
// System-prompt addendum injected ONLY on the final step (see prepareAgentStep).
|
||||||
// It forbids further tool calls and tells the model to synthesize the best
|
// It forbids further tool calls and tells the model to synthesize the best
|
||||||
// answer it can from what it already gathered, so a tool-heavy turn never ends
|
// answer it can from what it already gathered, so a tool-heavy turn never ends
|
||||||
@@ -189,78 +171,6 @@ export function sameInstant(
|
|||||||
return ta === tb;
|
return ta === tb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Race `work` against an abort signal AND a wall-clock deadline, so a hung
|
|
||||||
* external-MCP toolset build during the pre-streamText setup phase can NEITHER
|
|
||||||
* wedge the turn NOR make it un-stoppable, and the run always finalizes. It
|
|
||||||
* - resolves with `work`'s value when it settles first;
|
|
||||||
* - REJECTS EARLY if `signal` aborts (with `signal.reason` when that is an Error,
|
|
||||||
* else a generic `Error('aborted')`) — so an explicit Stop is honored mid-setup;
|
|
||||||
* - REJECTS EARLY if `deadlineMs` elapses (defense-in-depth backstop);
|
|
||||||
* - invokes `onLateResolve(value)` when `work` settles AFTER the race was already
|
|
||||||
* lost, so the caller can release any resources that abandoned value owns
|
|
||||||
* (e.g. close leased MCP clients that would otherwise leak their sockets).
|
|
||||||
*
|
|
||||||
* A rejection handler is attached to `work` so a late rejection is never an
|
|
||||||
* unhandledRejection; the timer is unref'd and cleared once the race settles.
|
|
||||||
*/
|
|
||||||
export function raceAgainstAbortAndTimeout<T>(
|
|
||||||
work: Promise<T>,
|
|
||||||
signal: AbortSignal,
|
|
||||||
deadlineMs: number,
|
|
||||||
onLateResolve?: (value: T) => void,
|
|
||||||
): Promise<T> {
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
};
|
|
||||||
const onAbort = () => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
signal.reason instanceof Error ? signal.reason : new Error('aborted'),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
reject(new Error(`setup timed out after ${deadlineMs}ms`));
|
|
||||||
}, deadlineMs);
|
|
||||||
// Do not keep the process alive just for this setup-deadline timer.
|
|
||||||
timer.unref?.();
|
|
||||||
if (signal.aborted) {
|
|
||||||
onAbort();
|
|
||||||
} else {
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
}
|
|
||||||
work.then(
|
|
||||||
(value) => {
|
|
||||||
if (settled) {
|
|
||||||
// The race was already lost (abort/deadline): hand the abandoned value to
|
|
||||||
// the caller so it can release the resources that value owns.
|
|
||||||
onLateResolve?.(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
resolve(value);
|
|
||||||
},
|
|
||||||
(err: unknown) => {
|
|
||||||
// A late rejection after the race is already handled — swallow so it is
|
|
||||||
// never an unhandledRejection.
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
reject(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
|
* Payload accepted from the client `useChat` POST body. We do NOT bind a strict
|
||||||
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
|
* DTO (the global ValidationPipe whitelist would strip the useChat-specific
|
||||||
@@ -278,12 +188,7 @@ export interface AiChatStreamBody {
|
|||||||
// page" refers to; the page itself is never fetched server-side here. The id
|
// page" refers to; the page itself is never fetched server-side here. The id
|
||||||
// is attacker-controllable but harmless: the agent reads/writes via its
|
// is attacker-controllable but harmless: the agent reads/writes via its
|
||||||
// CASL-enforced page tools, which 403 on a page the user cannot access.
|
// CASL-enforced page tools, which 403 on a page the user cannot access.
|
||||||
//
|
openPage?: { id?: string; title?: string } | null;
|
||||||
// `selection` is the user's editor selection snapshotted client-side at send
|
|
||||||
// time (#388). It is CLIENT-controlled and UNTRUSTED — a loose `unknown` here
|
|
||||||
// (the body is parsed off req.body without a DTO) that is type-checked and
|
|
||||||
// capped by `sanitizeSelection` before it is ever surfaced to the model.
|
|
||||||
openPage?: { id?: string; title?: string; selection?: unknown } | null;
|
|
||||||
// Set by the client "send now" action (#198): this turn immediately follows a
|
// Set by the client "send now" action (#198): this turn immediately follows a
|
||||||
// user interruption of the previous turn. A hint only — the server re-confirms
|
// user interruption of the previous turn. A hint only — the server re-confirms
|
||||||
// it against persisted history (`isInterruptResume`) before injecting the
|
// it against persisted history (`isInterruptResume`) before injecting the
|
||||||
@@ -371,10 +276,6 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
// Reads the AI_CHAT_DEFERRED_TOOLS toggle (#332). Injected last so existing
|
||||||
// positional constructor callers (tests) only append one stub.
|
// positional constructor callers (tests) only append one stub.
|
||||||
private readonly environment: EnvironmentService,
|
private readonly environment: EnvironmentService,
|
||||||
// #184 phase 1.5 run-stream registry. OPTIONAL so existing positional
|
|
||||||
// constructions (int-specs) compile unchanged; Nest always injects the real
|
|
||||||
// provider in production. Only ever touched on the run-wrapped + flag-on path.
|
|
||||||
private readonly streamRegistry?: AiChatStreamRegistryService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -459,18 +360,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
* page, or any non-Forbidden access-check fault, returns null.
|
* page, or any non-Forbidden access-check fault, returns null.
|
||||||
*/
|
*/
|
||||||
private async resolveOpenPageContext(
|
private async resolveOpenPageContext(
|
||||||
openPage:
|
openPage: { id?: string; title?: string } | null | undefined,
|
||||||
| { id?: string; title?: string; selection?: unknown }
|
|
||||||
| null
|
|
||||||
| undefined,
|
|
||||||
workspace: Workspace,
|
workspace: Workspace,
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{
|
): Promise<{ id: string; title: string; updatedAt: Date } | null> {
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
updatedAt: Date;
|
|
||||||
selection: SelectionContext | null;
|
|
||||||
} | null> {
|
|
||||||
const candidatePageId = openPage?.id;
|
const candidatePageId = openPage?.id;
|
||||||
if (!candidatePageId) return null;
|
if (!candidatePageId) return null;
|
||||||
const page = await this.pageRepo.findById(candidatePageId);
|
const page = await this.pageRepo.findById(candidatePageId);
|
||||||
@@ -492,18 +385,7 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// updatedAt is the page's last-modified instant, used by the #274 per-turn
|
// updatedAt is the page's last-modified instant, used by the #274 per-turn
|
||||||
// page-change detection as a cheap fast path (unchanged instant => skip the
|
// page-change detection as a cheap fast path (unchanged instant => skip the
|
||||||
// render + diff). The system-prompt / tool consumers ignore the extra field.
|
// render + diff). The system-prompt / tool consumers ignore the extra field.
|
||||||
//
|
return { id: page.id, title: page.title ?? '', updatedAt: page.updatedAt };
|
||||||
// The sanitized editor selection (#388) is attached ONLY here, on a
|
|
||||||
// successful page resolve: the fail-closed branches above return null for the
|
|
||||||
// WHOLE context, so a selection can never outlive a foreign/missing/deleted
|
|
||||||
// page (decision 3). Downstream consumers that don't care (detectPageChange,
|
|
||||||
// snapshotOpenPage) ignore the extra field, same as updatedAt.
|
|
||||||
return {
|
|
||||||
id: page.id,
|
|
||||||
title: page.title ?? '',
|
|
||||||
updatedAt: page.updatedAt,
|
|
||||||
selection: sanitizeSelection(openPage?.selection),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -789,43 +671,10 @@ export class AiChatService implements OnModuleInit {
|
|||||||
instructions: [],
|
instructions: [],
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
// Bound the external-MCP toolset build by BOTH the run's abort signal and
|
external = await this.mcpClients.toolsFor(workspace.id);
|
||||||
// a generous wall-clock deadline. This is the pre-streamText setup phase,
|
|
||||||
// which streamText's terminal callbacks do NOT yet govern — so without this
|
|
||||||
// a hung build would hang the turn at step 0 forever (the production hang),
|
|
||||||
// unobservant of an explicit Stop. The deadline is defense-in-depth ABOVE
|
|
||||||
// the per-server connect bound in mcp-clients.service. On a LATE resolve
|
|
||||||
// (the race was already lost) RELEASE the abandoned toolset's leases —
|
|
||||||
// c.close() here is the lease handle, so it decrements the cache entry's
|
|
||||||
// refcount; it does NOT force-close the transports (the cache OWNS the
|
|
||||||
// clients and closes them on TTL/evict). This just prevents the lease
|
|
||||||
// refcount from being pinned >=1 forever by a toolset nobody will consume.
|
|
||||||
external = await raceAgainstAbortAndTimeout(
|
|
||||||
this.mcpClients.toolsFor(workspace.id),
|
|
||||||
effectiveSignal,
|
|
||||||
MCP_TOOLSET_BUILD_DEADLINE_MS,
|
|
||||||
(late) => {
|
|
||||||
void Promise.all(
|
|
||||||
late.clients.map((c) => c.close().catch(() => undefined)),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// An explicit Stop reached the RUN's signal DURING setup: re-throw so the
|
// Building the external toolset must never break the turn; proceed with
|
||||||
// outer catch finalizes the run as aborted — never swallow a Stop. Gated on
|
// Docmost-only tools. Never log URLs/headers — short message only.
|
||||||
// `runId`: the re-throw exists ONLY to finalize the run, which exists only
|
|
||||||
// in autonomous mode. On the legacy path (no runId) `effectiveSignal` is the
|
|
||||||
// SOCKET signal (it aborts on a client disconnect); re-throwing there would
|
|
||||||
// change prior behavior and make the controller write JSON to an already-
|
|
||||||
// closed socket (it only attaches res.raw.on('error') in autonomous mode).
|
|
||||||
// So legacy keeps its prior behavior — warn + proceed, and streamText then
|
|
||||||
// observes the aborted socket signal.
|
|
||||||
if (runId && effectiveSignal.aborted) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
// Otherwise a down/slow server (build timeout or other fault) must never
|
|
||||||
// break the turn: proceed with Docmost-only tools. Never log URLs/headers —
|
|
||||||
// short message only.
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`External MCP toolset unavailable: ${
|
`External MCP toolset unavailable: ${
|
||||||
err instanceof Error ? err.message : 'unknown error'
|
err instanceof Error ? err.message : 'unknown error'
|
||||||
@@ -1325,35 +1174,6 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// as the cumulative authoritative usage so the client never jumps DOWN.
|
// as the cumulative authoritative usage so the client never jumps DOWN.
|
||||||
let cumulativeStepUsage: ChatStreamUsage | undefined;
|
let cumulativeStepUsage: ChatStreamUsage | undefined;
|
||||||
result.pipeUIMessageStreamToResponse(res.raw, {
|
result.pipeUIMessageStreamToResponse(res.raw, {
|
||||||
// #184 phase 1.5: run-wrapped mode only — the legacy path (flag off) stays
|
|
||||||
// byte-for-byte identical, including the absence of start.messageId. Both
|
|
||||||
// fields are gated on `runId` (present only for a durable run) AND the
|
|
||||||
// AI_CHAT_RESUMABLE_STREAM flag; the seed `assistantId` is unconditional,
|
|
||||||
// so gating on `assistantId` alone would change the legacy wire.
|
|
||||||
...(runId && this.environment?.isAiChatResumableStreamEnabled?.()
|
|
||||||
? {
|
|
||||||
// Tee the SSE frames into the run-stream registry so late tabs can
|
|
||||||
// attach (replay + live tail).
|
|
||||||
consumeSseStream: ({
|
|
||||||
stream,
|
|
||||||
}: {
|
|
||||||
stream: ReadableStream<string>;
|
|
||||||
}) =>
|
|
||||||
this.streamRegistry?.bind(
|
|
||||||
chatId,
|
|
||||||
runId!,
|
|
||||||
assistantId,
|
|
||||||
stream,
|
|
||||||
),
|
|
||||||
// Stamp the persisted assistant row's DB id onto the streamed
|
|
||||||
// message so every tab renders the SAME id as the DB row (id-based
|
|
||||||
// reconciliation). Seeding is best-effort: when it failed, let the
|
|
||||||
// client generate the id.
|
|
||||||
...(assistantId
|
|
||||||
? { generateMessageId: () => assistantId }
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
headers: { 'X-Accel-Buffering': 'no' },
|
headers: { 'X-Accel-Buffering': 'no' },
|
||||||
// Surface the authoritative chatId on the streamed assistant UI message so
|
// Surface the authoritative chatId on the streamed assistant UI message so
|
||||||
// the client adopts the REAL id of the row we created, instead of guessing
|
// the client adopts the REAL id of the row we created, instead of guessing
|
||||||
@@ -1419,25 +1239,12 @@ export class AiChatService implements OnModuleInit {
|
|||||||
// finalizeRun (onSettled) is idempotent — a settle here and a settle from a
|
// finalizeRun (onSettled) is idempotent — a settle here and a settle from a
|
||||||
// streamText callback collapse to a single terminal write.
|
// streamText callback collapse to a single terminal write.
|
||||||
if (runId) {
|
if (runId) {
|
||||||
// #184 phase 1.5: a failure here means the tee `done` will never arrive,
|
|
||||||
// so release the registry entry's subscribers explicitly — otherwise an
|
|
||||||
// attached tab hangs forever. Same flag gate as the tee wiring above.
|
|
||||||
if (this.environment?.isAiChatResumableStreamEnabled?.()) {
|
|
||||||
this.streamRegistry?.abortEntry(chatId, runId);
|
|
||||||
}
|
|
||||||
// Distinguish an explicit Stop (the run's signal aborted during setup) from
|
|
||||||
// a real failure, so the run settles with the correct terminal status
|
|
||||||
// instead of always 'error'. onSettled/finalizeRun is idempotent, so this
|
|
||||||
// is safe even if a streamText callback also settles the run.
|
|
||||||
const settleStatus = effectiveSignal.aborted ? 'aborted' : 'error';
|
|
||||||
await runHooks?.onSettled?.(
|
await runHooks?.onSettled?.(
|
||||||
runId,
|
runId,
|
||||||
settleStatus,
|
'error',
|
||||||
settleStatus === 'aborted'
|
err instanceof Error
|
||||||
? undefined
|
? err.message
|
||||||
: err instanceof Error
|
: 'Agent run failed before streaming started',
|
||||||
? err.message
|
|
||||||
: 'Agent run failed before streaming started',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
@@ -1642,25 +1449,16 @@ type StepLike = {
|
|||||||
/**
|
/**
|
||||||
* Compaction tunables for persisted tool OUTPUTS. Read tools (getPage,
|
* Compaction tunables for persisted tool OUTPUTS. Read tools (getPage,
|
||||||
* getPageJson, getNode, diffPageVersions, exportPageMarkdown, ...) return whole
|
* getPageJson, getNode, diffPageVersions, exportPageMarkdown, ...) return whole
|
||||||
* pages. Their outputs are stored in `metadata.parts` and RE-SENT to the
|
* pages with no size cap. Their outputs are stored in `metadata.parts` and
|
||||||
* provider on every later turn via convertToModelMessages. We deliberately keep
|
* RE-SENT to the provider on every later turn via convertToModelMessages, so an
|
||||||
* these outputs FULL up to a high safety cap (MAX_TOOL_OUTPUT_BYTES) so the
|
* uncompacted large body grows token cost, latency, and DB row size on every
|
||||||
* model never sees a shortened copy of content it already fetched: an earlier
|
* turn. We shrink the big payloads while preserving the object's shape and its
|
||||||
* 4000-byte cap shrank normal page reads (often tens of KB) to a tiny preview,
|
* small scalar fields (id/title/pageId) the client reads to render citations.
|
||||||
* and the model — seeing a truncation marker in its OWN history — re-read the
|
|
||||||
* same page, wasting tokens. Only a single output LARGER than the cap is
|
|
||||||
* compacted at all, purely as a backstop against a pathological payload; even
|
|
||||||
* then we preserve the object's shape and its small scalar fields
|
|
||||||
* (id/title/pageId) that the client reads to render citations.
|
|
||||||
*/
|
*/
|
||||||
// HIGH safety backstop: only an output whose JSON serialization EXCEEDS this is
|
// Only outputs whose JSON serialization exceeds this are compacted at all
|
||||||
// compacted at all. Normal reads (whole pages, tens of KB) stay well under it
|
// (fast path: smaller outputs are returned unchanged, by identity).
|
||||||
// and are stored + replayed VERBATIM (fast path: returned unchanged, by
|
const MAX_TOOL_OUTPUT_BYTES = 4000;
|
||||||
// identity). Only a single pathologically huge output (> 200 KB) is compacted.
|
// A string longer than this is truncated to a leading preview.
|
||||||
const MAX_TOOL_OUTPUT_BYTES = 200_000;
|
|
||||||
// Inside the backstop path only (i.e. once the whole output already exceeded
|
|
||||||
// MAX_TOOL_OUTPUT_BYTES), a string longer than this is reduced to a leading
|
|
||||||
// preview; normal outputs never reach this branch.
|
|
||||||
const TOOL_OUTPUT_STRING_LIMIT = 600;
|
const TOOL_OUTPUT_STRING_LIMIT = 600;
|
||||||
// Number of leading characters kept from a truncated string.
|
// Number of leading characters kept from a truncated string.
|
||||||
const TOOL_OUTPUT_STRING_PREVIEW = 500;
|
const TOOL_OUTPUT_STRING_PREVIEW = 500;
|
||||||
@@ -1703,9 +1501,9 @@ export function compactToolOutput(output: unknown): unknown {
|
|||||||
function compactValue(value: unknown, depth: number): unknown {
|
function compactValue(value: unknown, depth: number): unknown {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
if (value.length > TOOL_OUTPUT_STRING_LIMIT) {
|
if (value.length > TOOL_OUTPUT_STRING_LIMIT) {
|
||||||
return `${value.slice(0, TOOL_OUTPUT_STRING_PREVIEW)}…[${
|
return `${value.slice(0, TOOL_OUTPUT_STRING_PREVIEW)}…[truncated ${
|
||||||
value.length - TOOL_OUTPUT_STRING_PREVIEW
|
value.length - TOOL_OUTPUT_STRING_PREVIEW
|
||||||
} chars omitted from stored chat history to bound replay size — call the tool again to read the full output]`;
|
} chars]`;
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -1892,45 +1690,6 @@ export async function applyFinalize(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Deep-strip NUL characters (`\u0000`) from every string in a value, returning
|
|
||||||
* the SAME reference when nothing changed (so the no-NUL common case allocates
|
|
||||||
* nothing). Postgres rejects a NUL in BOTH `text` and `jsonb` columns ("invalid
|
|
||||||
* input syntax for type json" / "unsupported Unicode escape sequence"), so a
|
|
||||||
* stray NUL in model output or a tool result — e.g. a truncated multibyte read
|
|
||||||
* of a web page — otherwise fails EVERY persist of the assistant row, silently
|
|
||||||
* dropping that turn's content from the DB while the live stream still shows it.
|
|
||||||
* Applied at the flushAssistant choke point so content + toolCalls + metadata are
|
|
||||||
* all covered. Exported for the unit test.
|
|
||||||
*/
|
|
||||||
export function stripNulChars<T>(value: T): T {
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return (value.includes('\u0000')
|
|
||||||
? value.replace(/\u0000/g, '')
|
|
||||||
: value) as T;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
let changed = false;
|
|
||||||
const out = value.map((v) => {
|
|
||||||
const s = stripNulChars(v);
|
|
||||||
if (s !== v) changed = true;
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
return (changed ? out : value) as T;
|
|
||||||
}
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
let changed = false;
|
|
||||||
const out: Record<string, unknown> = {};
|
|
||||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
||||||
const s = stripNulChars(v);
|
|
||||||
if (s !== v) changed = true;
|
|
||||||
out[k] = s;
|
|
||||||
}
|
|
||||||
return (changed ? out : value) as T;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PURE assistant-row builder (#183 step-granular durability). Given the turn's
|
* PURE assistant-row builder (#183 step-granular durability). Given the turn's
|
||||||
* accumulated steps + the in-progress (not-yet-finished) text + the lifecycle
|
* accumulated steps + the in-progress (not-yet-finished) text + the lifecycle
|
||||||
@@ -2000,16 +1759,12 @@ export function flushAssistant(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip NUL chars from the whole row before persisting: Postgres rejects a NUL
|
return {
|
||||||
// in both the `content` (text) and `toolCalls`/`metadata` (jsonb) columns, and a
|
|
||||||
// single stray NUL in model/tool output would otherwise fail EVERY write of this
|
|
||||||
// row and silently drop the turn's content from the DB (see stripNulChars).
|
|
||||||
return stripNulChars({
|
|
||||||
content: stepsText + trailing,
|
content: stepsText + trailing,
|
||||||
toolCalls: serializeSteps(finished),
|
toolCalls: serializeSteps(finished),
|
||||||
metadata,
|
metadata,
|
||||||
status,
|
status,
|
||||||
});
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -181,25 +181,25 @@ describe('mcp timeout env helpers', () => {
|
|||||||
else process.env.AI_MCP_CALL_TIMEOUT_MS = ORIG_CALL;
|
else process.env.AI_MCP_CALL_TIMEOUT_MS = ORIG_CALL;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mcpStreamTimeoutMs defaults to 1 min and honors a positive override', () => {
|
it('mcpStreamTimeoutMs defaults to 5 min and honors a positive override', () => {
|
||||||
delete process.env.AI_MCP_STREAM_TIMEOUT_MS;
|
delete process.env.AI_MCP_STREAM_TIMEOUT_MS;
|
||||||
|
expect(mcpStreamTimeoutMs()).toBe(300_000);
|
||||||
|
process.env.AI_MCP_STREAM_TIMEOUT_MS = '60000';
|
||||||
expect(mcpStreamTimeoutMs()).toBe(60_000);
|
expect(mcpStreamTimeoutMs()).toBe(60_000);
|
||||||
process.env.AI_MCP_STREAM_TIMEOUT_MS = '90000';
|
|
||||||
expect(mcpStreamTimeoutMs()).toBe(90_000);
|
|
||||||
for (const bad of ['0', '-1', 'x', '']) {
|
for (const bad of ['0', '-1', 'x', '']) {
|
||||||
process.env.AI_MCP_STREAM_TIMEOUT_MS = bad;
|
process.env.AI_MCP_STREAM_TIMEOUT_MS = bad;
|
||||||
expect(mcpStreamTimeoutMs()).toBe(60_000);
|
expect(mcpStreamTimeoutMs()).toBe(300_000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mcpCallTimeoutMs defaults to 2 min and honors a positive override', () => {
|
it('mcpCallTimeoutMs defaults to 15 min and honors a positive override', () => {
|
||||||
delete process.env.AI_MCP_CALL_TIMEOUT_MS;
|
delete process.env.AI_MCP_CALL_TIMEOUT_MS;
|
||||||
|
expect(mcpCallTimeoutMs()).toBe(900_000);
|
||||||
|
process.env.AI_MCP_CALL_TIMEOUT_MS = '120000';
|
||||||
expect(mcpCallTimeoutMs()).toBe(120_000);
|
expect(mcpCallTimeoutMs()).toBe(120_000);
|
||||||
process.env.AI_MCP_CALL_TIMEOUT_MS = '180000';
|
|
||||||
expect(mcpCallTimeoutMs()).toBe(180_000);
|
|
||||||
for (const bad of ['0', '-1', 'x', '']) {
|
for (const bad of ['0', '-1', 'x', '']) {
|
||||||
process.env.AI_MCP_CALL_TIMEOUT_MS = bad;
|
process.env.AI_MCP_CALL_TIMEOUT_MS = bad;
|
||||||
expect(mcpCallTimeoutMs()).toBe(120_000);
|
expect(mcpCallTimeoutMs()).toBe(900_000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
import { McpClientsService } from './mcp-clients.service';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* D1 — a HUNG MCP handshake must not POISON the per-workspace build cache.
|
|
||||||
*
|
|
||||||
* THE BUG (production hang): `createMCPClient` (inside the private `connect`) is
|
|
||||||
* NOT bounded by a timeout and — like @ai-sdk/mcp's tool calls — its promise does
|
|
||||||
* NOT settle on abort. A transient network blip mid-handshake made connect hang
|
|
||||||
* FOREVER. Because getOrBuildEntry caches the build PROMISE, that never-settling
|
|
||||||
* connect wedged EVERY later turn for the workspace (each awaited the same pending
|
|
||||||
* build) — step_count stuck at 0, run row leaking 'running', chat 409ing forever.
|
|
||||||
*
|
|
||||||
* THE FIX: `connectWithTimeout` races `connect` against a SETTLING timeout
|
|
||||||
* (CONNECT_TIMEOUT_MS). On timeout it REJECTS, so buildEntry catches it, records
|
|
||||||
* the server `ok:false`, and the build COMPLETES with that server skipped — the
|
|
||||||
* cache is never poisoned and a subsequent `toolsFor` returns instead of hanging.
|
|
||||||
*
|
|
||||||
* REACHABILITY NOTE: the smallest network-free path that exercises the fix is to
|
|
||||||
* spy on the private `connect` (the same harness the namespacing spec uses) —
|
|
||||||
* `connectWithTimeout` wraps exactly that call, so a never-resolving `connect`
|
|
||||||
* models a never-settling `createMCPClient` precisely, without DNS/sockets.
|
|
||||||
*
|
|
||||||
* Fake timers prove the timeout fires WITHOUT real waiting.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Mirrors the private CONNECT_TIMEOUT_MS constant in mcp-clients.service.ts.
|
|
||||||
const CONNECT_TIMEOUT_MS = 5000;
|
|
||||||
|
|
||||||
interface FakeServer {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
transport: string;
|
|
||||||
url: string;
|
|
||||||
headersEnc: string | null;
|
|
||||||
toolAllowlist: string[] | null;
|
|
||||||
instructions?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function server(
|
|
||||||
over: Partial<FakeServer> & { id: string; name: string },
|
|
||||||
): FakeServer {
|
|
||||||
return {
|
|
||||||
transport: 'http',
|
|
||||||
url: 'https://example.com/mcp',
|
|
||||||
headersEnc: null,
|
|
||||||
toolAllowlist: null,
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildService(servers: FakeServer[]) {
|
|
||||||
const repoStub = { listEnabled: jest.fn().mockResolvedValue(servers) };
|
|
||||||
const service = new McpClientsService(repoStub as never, {} as never);
|
|
||||||
// Silence the expected "server unavailable" warning.
|
|
||||||
jest
|
|
||||||
.spyOn(
|
|
||||||
(service as unknown as { logger: { warn: (...a: unknown[]) => void } })
|
|
||||||
.logger,
|
|
||||||
'warn',
|
|
||||||
)
|
|
||||||
.mockImplementation(() => undefined);
|
|
||||||
return service;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spy on the private `connect` with a per-server implementation.
|
|
||||||
function stubConnect(
|
|
||||||
service: McpClientsService,
|
|
||||||
impl: (s: FakeServer) => Promise<unknown>,
|
|
||||||
) {
|
|
||||||
return jest
|
|
||||||
.spyOn(
|
|
||||||
service as unknown as { connect: (s: FakeServer) => Promise<unknown> },
|
|
||||||
'connect',
|
|
||||||
)
|
|
||||||
.mockImplementation(impl);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('McpClientsService.connectWithTimeout — hung connect does not poison the cache (D1)', () => {
|
|
||||||
beforeEach(() => jest.useFakeTimers());
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllTimers();
|
|
||||||
jest.useRealTimers();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('buildEntry completes (server recorded ok:false) when connect never settles, and toolsFor does not hang', async () => {
|
|
||||||
const svc = buildService([server({ id: 'id-hung', name: 'hung' })]);
|
|
||||||
// connect NEVER settles — models a wedged createMCPClient handshake.
|
|
||||||
stubConnect(svc, () => new Promise<never>(() => {}));
|
|
||||||
|
|
||||||
const toolsetPromise = svc.toolsFor('ws-1');
|
|
||||||
// Drive fake time past the connect bound so connectWithTimeout rejects and
|
|
||||||
// buildEntry catches it (records ok:false) — flushing the microtasks.
|
|
||||||
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
|
||||||
|
|
||||||
const toolset = await toolsetPromise;
|
|
||||||
// The build COMPLETED with the bad server skipped (no tools, ok:false).
|
|
||||||
expect(Object.keys(toolset.tools)).toHaveLength(0);
|
|
||||||
expect(toolset.outcomes).toEqual([
|
|
||||||
{ name: 'hung', ok: false, reason: 'MCP connect timed out after 5000ms' },
|
|
||||||
]);
|
|
||||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
|
||||||
|
|
||||||
// The cache is NOT poisoned: a subsequent turn returns (served from the warm
|
|
||||||
// cached entry) instead of awaiting a never-settling build.
|
|
||||||
const again = await svc.toolsFor('ws-1');
|
|
||||||
expect(Object.keys(again.tools)).toHaveLength(0);
|
|
||||||
await Promise.all(again.clients.map((c) => c.close()));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('a hung server is skipped but a healthy server in the SAME build still contributes its tools', async () => {
|
|
||||||
const svc = buildService([
|
|
||||||
server({ id: 'id-hung', name: 'hung' }),
|
|
||||||
server({ id: 'id-ok', name: 'ok' }),
|
|
||||||
]);
|
|
||||||
const okClient = {
|
|
||||||
tools: () => Promise.resolve({ search: { description: 'x' } }),
|
|
||||||
close: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
stubConnect(svc, (s) =>
|
|
||||||
s.id === 'id-hung'
|
|
||||||
? new Promise<never>(() => {})
|
|
||||||
: Promise.resolve(okClient),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toolsetPromise = svc.toolsFor('ws-2');
|
|
||||||
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
|
||||||
|
|
||||||
const toolset = await toolsetPromise;
|
|
||||||
// Healthy server's tool survives (namespaced); hung server recorded ok:false.
|
|
||||||
expect(Object.keys(toolset.tools)).toEqual(['ok_search']);
|
|
||||||
expect(toolset.outcomes).toEqual([
|
|
||||||
{ name: 'hung', ok: false, reason: 'MCP connect timed out after 5000ms' },
|
|
||||||
{ name: 'ok', ok: true },
|
|
||||||
]);
|
|
||||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes the ORPHANED client when connect resolves LATE (after the timeout)', async () => {
|
|
||||||
const svc = buildService([server({ id: 'id-late', name: 'late' })]);
|
|
||||||
const lateClient = {
|
|
||||||
tools: () => Promise.resolve({}),
|
|
||||||
close: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
// connect resolves only AFTER the connect bound has already elapsed, so
|
|
||||||
// connectWithTimeout has already rejected and must close this orphan.
|
|
||||||
stubConnect(
|
|
||||||
svc,
|
|
||||||
() =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
setTimeout(() => resolve(lateClient), CONNECT_TIMEOUT_MS * 2);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toolsetPromise = svc.toolsFor('ws-3');
|
|
||||||
// Fire the timeout: the build completes with the server skipped.
|
|
||||||
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
|
||||||
const toolset = await toolsetPromise;
|
|
||||||
expect(toolset.outcomes[0]?.ok).toBe(false);
|
|
||||||
expect(lateClient.close).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Now let the late connect resolve — the orphan must be closed, not leaked.
|
|
||||||
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS * 2);
|
|
||||||
expect(lateClient.close).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('McpClientsService.buildEntry — closes a connected client whose tools() fails (leak fix)', () => {
|
|
||||||
beforeEach(() => jest.useFakeTimers());
|
|
||||||
afterEach(() => {
|
|
||||||
jest.clearAllTimers();
|
|
||||||
jest.useRealTimers();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('connect succeeds but tools() REJECTS: the client is close()d exactly once and the server is skipped, while a healthy server still contributes', async () => {
|
|
||||||
const svc = buildService([
|
|
||||||
server({ id: 'id-bad', name: 'bad' }),
|
|
||||||
server({ id: 'id-ok', name: 'ok' }),
|
|
||||||
]);
|
|
||||||
// The bad server connects fine, then tools() rejects — the client would leak if
|
|
||||||
// buildEntry did not close it in the per-server catch (it was never registered).
|
|
||||||
const badClient = {
|
|
||||||
tools: () => Promise.reject(new Error('tools listing failed')),
|
|
||||||
close: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const okClient = {
|
|
||||||
tools: () => Promise.resolve({ search: { description: 'x' } }),
|
|
||||||
close: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
stubConnect(svc, (s) =>
|
|
||||||
s.id === 'id-bad' ? Promise.resolve(badClient) : Promise.resolve(okClient),
|
|
||||||
);
|
|
||||||
|
|
||||||
const toolset = await svc.toolsFor('ws-4');
|
|
||||||
|
|
||||||
// The orphaned (never-registered) client is closed exactly once — no leak.
|
|
||||||
expect(badClient.close).toHaveBeenCalledTimes(1);
|
|
||||||
// Healthy server survives; bad server recorded ok:false and skipped.
|
|
||||||
expect(Object.keys(toolset.tools)).toEqual(['ok_search']);
|
|
||||||
expect(toolset.outcomes).toEqual([
|
|
||||||
{ name: 'bad', ok: false, reason: 'tools listing failed' },
|
|
||||||
{ name: 'ok', ok: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// The healthy (registered) client is NOT closed by the loop — it is owned by the
|
|
||||||
// cache entry and stays warm (closed only on eviction/teardown, not on lease
|
|
||||||
// release). Releasing the lease keeps it warm since the entry is not evicted.
|
|
||||||
expect(okClient.close).not.toHaveBeenCalled();
|
|
||||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
|
||||||
expect(okClient.close).not.toHaveBeenCalled();
|
|
||||||
// The failed client is never double-closed.
|
|
||||||
expect(badClient.close).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('connect succeeds but tools() HANGS (times out): the client is close()d once and the server is skipped', async () => {
|
|
||||||
const svc = buildService([server({ id: 'id-slow', name: 'slow' })]);
|
|
||||||
const slowClient = {
|
|
||||||
// tools() never settles -> withTimeout rejects after CONNECT_TIMEOUT_MS.
|
|
||||||
tools: () => new Promise<Record<string, never>>(() => {}),
|
|
||||||
close: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
stubConnect(svc, () => Promise.resolve(slowClient));
|
|
||||||
|
|
||||||
const toolsetPromise = svc.toolsFor('ws-5');
|
|
||||||
// Drive fake time past the tools() bound so withTimeout rejects.
|
|
||||||
await jest.advanceTimersByTimeAsync(CONNECT_TIMEOUT_MS + 1);
|
|
||||||
const toolset = await toolsetPromise;
|
|
||||||
|
|
||||||
expect(slowClient.close).toHaveBeenCalledTimes(1);
|
|
||||||
expect(Object.keys(toolset.tools)).toHaveLength(0);
|
|
||||||
expect(toolset.outcomes[0]?.ok).toBe(false);
|
|
||||||
await Promise.all(toolset.clients.map((c) => c.close()));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -195,7 +195,7 @@ export class McpClientsService {
|
|||||||
): Promise<{ ok: true; tools: string[] } | { ok: false; error: string }> {
|
): Promise<{ ok: true; tools: string[] } | { ok: false; error: string }> {
|
||||||
let client: McpClient | undefined;
|
let client: McpClient | undefined;
|
||||||
try {
|
try {
|
||||||
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
client = await this.connect(server);
|
||||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||||
return { ok: true, tools: Object.keys(raw) };
|
return { ok: true, tools: Object.keys(raw) };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -255,96 +255,49 @@ export class McpClientsService {
|
|||||||
const callTimeoutMs = mcpCallTimeoutMs();
|
const callTimeoutMs = mcpCallTimeoutMs();
|
||||||
const instructions: McpServerInstruction[] = [];
|
const instructions: McpServerInstruction[] = [];
|
||||||
|
|
||||||
// Per-server connect+tools result, still tagged with its server so the merge
|
for (const server of servers) {
|
||||||
// below can be applied in the SAME order as `servers` (see the parallel note).
|
|
||||||
type PerServerResult =
|
|
||||||
| { ok: true; client: McpClient; guarded: Record<string, Tool> }
|
|
||||||
| { ok: false; reason: string };
|
|
||||||
|
|
||||||
// Connect to (and list tools for) every enabled server CONCURRENTLY, so the
|
|
||||||
// total build time is bounded by the SLOWEST single server (~2×
|
|
||||||
// CONNECT_TIMEOUT_MS: connect + tools), NOT the SUM across servers. The
|
|
||||||
// sequential loop this replaced summed those bounds, so with enough all-timing-
|
|
||||||
// out servers the outer MCP_TOOLSET_BUILD_DEADLINE_MS could fire before the
|
|
||||||
// per-server bounds, dropping ALL external tools and inverting the "per-server
|
|
||||||
// bound is primary, outer is a backstop" invariant. Each server keeps its OWN
|
|
||||||
// try/catch + connectWithTimeout/withTimeout bound + close-on-failure logic; a
|
|
||||||
// failed server is skipped, never fatal. Nothing here mutates the shared
|
|
||||||
// arrays — every result is merged IN SERVER ORDER after Promise.all, so tool-
|
|
||||||
// key precedence/disambiguation, `outcomes`, `instructions` and `clients`
|
|
||||||
// ordering all match the previous sequential behavior exactly.
|
|
||||||
const perServer = async (
|
|
||||||
server: (typeof servers)[number],
|
|
||||||
): Promise<PerServerResult> => {
|
|
||||||
// Track the connected client so the catch can close it when it was obtained
|
|
||||||
// but tools() then threw/timed out (connectWithTimeout closes its OWN orphan
|
|
||||||
// on a connect timeout, so `client` stays undefined on that path). On success
|
|
||||||
// the client is handed back and registered by the merge below (owned by the
|
|
||||||
// entry, closed at teardown) — so it is never double-closed.
|
|
||||||
let client: McpClient | undefined;
|
|
||||||
try {
|
try {
|
||||||
client = await this.connectWithTimeout(server, CONNECT_TIMEOUT_MS);
|
const client = await this.connect(server);
|
||||||
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
const raw = await withTimeout(client.tools(), CONNECT_TIMEOUT_MS);
|
||||||
|
clients.push(client);
|
||||||
const allow = server.toolAllowlist;
|
const allow = server.toolAllowlist;
|
||||||
const picked =
|
const picked =
|
||||||
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
Array.isArray(allow) && allow.length > 0 ? pick(raw, allow) : raw;
|
||||||
// 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);
|
||||||
return { ok: true, client, guarded };
|
// Namespace each tool with the sanitized server name AND disambiguate
|
||||||
} catch (err) {
|
// against names already merged from earlier servers, so no external
|
||||||
// A failed server is skipped — the turn proceeds with the rest. If connect
|
// tool is silently overwritten on collision. The returned count drives
|
||||||
// returned a live client but a later step (tools()) threw, that client was
|
// whether this server's prompt guidance is included (≥1 tool merged).
|
||||||
// never registered in `clients`, so close it here or its transport/socket
|
const merged = this.mergeNamespaced(
|
||||||
// leaks (compounding every 60s cache rebuild during a flaky-server outage).
|
tools,
|
||||||
if (client) {
|
guarded,
|
||||||
void client.close().catch(() => undefined);
|
server.name,
|
||||||
|
server.id,
|
||||||
|
);
|
||||||
|
outcomes.push({ name: server.name, ok: true });
|
||||||
|
// Include this server's guidance ONLY when it actually contributed at
|
||||||
|
// least one tool the agent can call (allowlist may have filtered all of
|
||||||
|
// them out) AND the admin authored non-blank instructions. The header
|
||||||
|
// prefix is the sanitized server name (= the tool namespace prefix).
|
||||||
|
const guide = server.instructions?.trim();
|
||||||
|
if (merged.count > 0 && guide) {
|
||||||
|
instructions.push({
|
||||||
|
serverName: server.name,
|
||||||
|
toolPrefix: merged.prefix,
|
||||||
|
instructions: guide,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Log a short warning (never the URL/headers) so ops can see degradation,
|
} catch (err) {
|
||||||
// and record the outcome so the UI can show "tool X unavailable".
|
// A failed server is skipped — the turn proceeds with the rest. Log a
|
||||||
|
// short warning (never the URL/headers) so ops can see degradation, and
|
||||||
|
// record the outcome so the UI can show "tool X unavailable".
|
||||||
const reason = shortError(err);
|
const reason = shortError(err);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`External MCP server "${server.name}" unavailable: ${reason}`,
|
`External MCP server "${server.name}" unavailable: ${reason}`,
|
||||||
);
|
);
|
||||||
return { ok: false, reason };
|
outcomes.push({ name: server.name, ok: false, reason });
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Promise.all preserves array order regardless of settle order, so `results[i]`
|
|
||||||
// is `servers[i]`'s outcome — the merge below stays deterministic and matches
|
|
||||||
// the old sequential order (later servers still override/disambiguate against
|
|
||||||
// earlier ones on a tool-key clash).
|
|
||||||
const results = await Promise.all(servers.map(perServer));
|
|
||||||
for (let i = 0; i < servers.length; i += 1) {
|
|
||||||
const server = servers[i];
|
|
||||||
const result = results[i];
|
|
||||||
if (result.ok !== true) {
|
|
||||||
outcomes.push({ name: server.name, ok: false, reason: result.reason });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
clients.push(result.client);
|
|
||||||
// Namespace each tool with the sanitized server name AND disambiguate
|
|
||||||
// against names already merged from earlier servers, so no external
|
|
||||||
// tool is silently overwritten on collision. The returned count drives
|
|
||||||
// whether this server's prompt guidance is included (≥1 tool merged).
|
|
||||||
const merged = this.mergeNamespaced(
|
|
||||||
tools,
|
|
||||||
result.guarded,
|
|
||||||
server.name,
|
|
||||||
server.id,
|
|
||||||
);
|
|
||||||
outcomes.push({ name: server.name, ok: true });
|
|
||||||
// Include this server's guidance ONLY when it actually contributed at
|
|
||||||
// least one tool the agent can call (allowlist may have filtered all of
|
|
||||||
// them out) AND the admin authored non-blank instructions. The header
|
|
||||||
// prefix is the sanitized server name (= the tool namespace prefix).
|
|
||||||
const guide = server.instructions?.trim();
|
|
||||||
if (merged.count > 0 && guide) {
|
|
||||||
instructions.push({
|
|
||||||
serverName: server.name,
|
|
||||||
toolPrefix: merged.prefix,
|
|
||||||
instructions: guide,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,55 +383,6 @@ export class McpClientsService {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Race {@link connect} against a SETTLING timeout so a hung MCP handshake can
|
|
||||||
* never POISON the per-workspace build cache. `createMCPClient` (inside connect)
|
|
||||||
* is NOT bounded internally, and — exactly like @ai-sdk/mcp's tool calls
|
|
||||||
* (see wrapToolWithCallTimeout) — its promise does NOT settle on abort. So a
|
|
||||||
* transient network blip mid-handshake can make connect hang FOREVER. Because
|
|
||||||
* getOrBuildEntry caches the build PROMISE, a never-settling connect would then
|
|
||||||
* wedge EVERY later turn for the workspace (each awaits the same pending build,
|
|
||||||
* step_count stuck at 0, run row leaks 'running', chat 409s forever). Bounding
|
|
||||||
* connect here guarantees buildEntry always gets a client OR a rejection within
|
|
||||||
* `ms` — so the build completes (bad server skipped) and the cache stays clean.
|
|
||||||
*
|
|
||||||
* If connect resolves LATE (after we already rejected on the timeout), we close
|
|
||||||
* the orphaned client so its transport/socket is not leaked.
|
|
||||||
*/
|
|
||||||
private connectWithTimeout(
|
|
||||||
server: Pick<AiMcpServer, 'transport' | 'url' | 'headersEnc'>,
|
|
||||||
ms: number,
|
|
||||||
): Promise<McpClient> {
|
|
||||||
return new Promise<McpClient>((resolve, reject) => {
|
|
||||||
let settled = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
settled = true;
|
|
||||||
reject(new Error(`MCP connect timed out after ${ms}ms`));
|
|
||||||
}, ms);
|
|
||||||
// Do not keep the process alive just for this connect-timeout timer.
|
|
||||||
timer.unref?.();
|
|
||||||
this.connect(server).then(
|
|
||||||
(client) => {
|
|
||||||
if (settled) {
|
|
||||||
// The race was already lost to the timeout: close the orphaned client
|
|
||||||
// so its socket is not leaked, and drop the late result.
|
|
||||||
void client.close().catch(() => undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearTimeout(timer);
|
|
||||||
settled = true;
|
|
||||||
resolve(client);
|
|
||||||
},
|
|
||||||
(err: unknown) => {
|
|
||||||
if (settled) return; // late rejection after the timeout — already handled
|
|
||||||
clearTimeout(timer);
|
|
||||||
settled = true;
|
|
||||||
reject(err instanceof Error ? err : new Error(String(err)));
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decrypt the stored auth headers. Returns undefined when none are set. The
|
* Decrypt the stored auth headers. Returns undefined when none are set. The
|
||||||
* plaintext headers live only in this returned object and are passed straight
|
* plaintext headers live only in this returned object and are passed straight
|
||||||
@@ -556,12 +460,12 @@ export function validateResolvedAddresses(addrs: readonly LookupAddress[]): {
|
|||||||
*/
|
*/
|
||||||
function buildPinnedDispatcher(): Agent {
|
function buildPinnedDispatcher(): Agent {
|
||||||
// External-MCP traffic uses a DEDICATED, shorter silence timeout
|
// External-MCP traffic uses a DEDICATED, shorter silence timeout
|
||||||
// (`AI_MCP_STREAM_TIMEOUT_MS`, default 1 min) — deliberately tighter than the
|
// (`AI_MCP_STREAM_TIMEOUT_MS`, default 5 min) — deliberately tighter than the
|
||||||
// chat provider's 15-min `streamTimeoutMs()` — so a byte-silent/hung MCP
|
// chat provider's 15-min `streamTimeoutMs()` — so a byte-silent/hung MCP
|
||||||
// upstream is broken in ~1 min instead of 15. We keep the keep-alive options
|
// upstream is broken in ~5 min instead of 15. We keep the keep-alive options
|
||||||
// from `streamingDispatcherOptions()` but OVERRIDE headers/body timeouts.
|
// from `streamingDispatcherOptions()` but OVERRIDE headers/body timeouts.
|
||||||
// Accepted trade-off: a legitimately long but byte-silent single tool call,
|
// Accepted trade-off: a legitimately long but byte-silent single tool call,
|
||||||
// and an SSE transport idling >1 min BETWEEN tool calls, are also cut here; the
|
// and an SSE transport idling >5 min BETWEEN tool calls, are also cut here; the
|
||||||
// per-call total cap (wrapToolsWithCallTimeout, `AI_MCP_CALL_TIMEOUT_MS`) is the
|
// per-call total cap (wrapToolsWithCallTimeout, `AI_MCP_CALL_TIMEOUT_MS`) is the
|
||||||
// complementary guard for chatty-but-stuck calls that keep the socket warm yet
|
// complementary guard for chatty-but-stuck calls that keep the socket warm yet
|
||||||
// never return.
|
// never return.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class CreateAgentRoleDto {
|
|||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100000)
|
@MaxLength(20000)
|
||||||
instructions: string;
|
instructions: string;
|
||||||
|
|
||||||
// null/omitted => use the workspace default model.
|
// null/omitted => use the workspace default model.
|
||||||
@@ -102,7 +102,7 @@ export class UpdateAgentRoleDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100000)
|
@MaxLength(20000)
|
||||||
instructions?: string;
|
instructions?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -651,188 +651,3 @@ describe('AiChatToolsService #294 changed execute wirings', () => {
|
|||||||
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* #410 — the footnote + image tools were promoted from MCP-only into the shared
|
|
||||||
* registry and are now wired in-app. Assert they are REGISTERED in the in-app
|
|
||||||
* toolset and forward their args to the client with the correct arg->method
|
|
||||||
* mapping (the schema fields `imageUrl`/`attachmentId` map onto the client's
|
|
||||||
* positional `url`/`oldAttachmentId`). A field destructured under the wrong name
|
|
||||||
* would silently pass `undefined` (execute is `any`-cast, so tsc won't catch it).
|
|
||||||
*/
|
|
||||||
describe('AiChatToolsService #410 footnote + image tools', () => {
|
|
||||||
const calls: Record<string, unknown[][]> = {
|
|
||||||
insertFootnote: [],
|
|
||||||
insertImage: [],
|
|
||||||
replaceImage: [],
|
|
||||||
};
|
|
||||||
const fakeClient: Partial<DocmostClientLike> = {
|
|
||||||
insertFootnote: (...args: unknown[]) => {
|
|
||||||
calls.insertFootnote.push(args);
|
|
||||||
return Promise.resolve({ success: true, footnoteId: 'fn1', reused: false });
|
|
||||||
},
|
|
||||||
insertImage: (...args: unknown[]) => {
|
|
||||||
calls.insertImage.push(args);
|
|
||||||
return Promise.resolve({ success: true, attachmentId: 'att1' });
|
|
||||||
},
|
|
||||||
replaceImage: (...args: unknown[]) => {
|
|
||||||
calls.replaceImage.push(args);
|
|
||||||
return Promise.resolve({ success: true, replaced: 1 });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tokenServiceStub = {
|
|
||||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
|
||||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
|
||||||
};
|
|
||||||
let service: AiChatToolsService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
for (const k of Object.keys(calls)) calls[k].length = 0;
|
|
||||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
|
||||||
mockLoaded(function () {
|
|
||||||
return fakeClient as DocmostClientLike;
|
|
||||||
} as unknown as loader.DocmostClientCtor),
|
|
||||||
);
|
|
||||||
service = new AiChatToolsService(
|
|
||||||
tokenServiceStub as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{
|
|
||||||
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
|
||||||
} as never,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
afterEach(() => jest.restoreAllMocks());
|
|
||||||
|
|
||||||
const buildTools = () =>
|
|
||||||
service.forUser(
|
|
||||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
|
||||||
'session-1',
|
|
||||||
'ws-1',
|
|
||||||
'chat-1',
|
|
||||||
);
|
|
||||||
|
|
||||||
it('registers all three tools in the in-app toolset', async () => {
|
|
||||||
const tools = await buildTools();
|
|
||||||
expect(tools.insertFootnote).toBeDefined();
|
|
||||||
expect(tools.insertImage).toBeDefined();
|
|
||||||
expect(tools.replaceImage).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('insertFootnote forwards (pageId, anchorText, text) positionally', async () => {
|
|
||||||
const tools = await buildTools();
|
|
||||||
const r = await tools.insertFootnote.execute(
|
|
||||||
{ pageId: 'p1', anchorText: 'the claim', text: 'See source.' } as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
expect(calls.insertFootnote).toEqual([['p1', 'the claim', 'See source.']]);
|
|
||||||
expect(r).toMatchObject({ footnoteId: 'fn1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('insertImage maps imageUrl->url and packs the option fields', async () => {
|
|
||||||
const tools = await buildTools();
|
|
||||||
await tools.insertImage.execute(
|
|
||||||
{
|
|
||||||
pageId: 'p1',
|
|
||||||
imageUrl: 'https://x/img.png',
|
|
||||||
align: 'center',
|
|
||||||
alt: 'A',
|
|
||||||
replaceText: '[img]',
|
|
||||||
afterText: undefined,
|
|
||||||
} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
expect(calls.insertImage).toEqual([
|
|
||||||
[
|
|
||||||
'p1',
|
|
||||||
'https://x/img.png',
|
|
||||||
{ align: 'center', alt: 'A', replaceText: '[img]', afterText: undefined },
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaceImage maps attachmentId->oldAttachmentId and imageUrl->url', async () => {
|
|
||||||
const tools = await buildTools();
|
|
||||||
await tools.replaceImage.execute(
|
|
||||||
{
|
|
||||||
pageId: 'p1',
|
|
||||||
attachmentId: 'att-old',
|
|
||||||
imageUrl: 'https://x/new.png',
|
|
||||||
align: 'right',
|
|
||||||
alt: 'B',
|
|
||||||
} as never,
|
|
||||||
{} as never,
|
|
||||||
);
|
|
||||||
expect(calls.replaceImage).toEqual([
|
|
||||||
['p1', 'att-old', 'https://x/new.png', { align: 'right', alt: 'B' }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* getCurrentPage selection contract (#388): the tool surfaces the selection that
|
|
||||||
* was sanitized + nested onto the resolved open-page context (last forUser arg).
|
|
||||||
* No page => selection is null. The tool never fetches or verifies anything — it
|
|
||||||
* just projects the resolved context.
|
|
||||||
*/
|
|
||||||
describe('AiChatToolsService getCurrentPage selection (#388)', () => {
|
|
||||||
const tokenServiceStub = {
|
|
||||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
|
||||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
|
||||||
};
|
|
||||||
|
|
||||||
let service: AiChatToolsService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
|
||||||
mockLoaded(function () {
|
|
||||||
return {} as DocmostClientLike;
|
|
||||||
} as unknown as loader.DocmostClientCtor),
|
|
||||||
);
|
|
||||||
service = new AiChatToolsService(
|
|
||||||
tokenServiceStub as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{} as never,
|
|
||||||
{
|
|
||||||
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
|
||||||
} as never,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.restoreAllMocks());
|
|
||||||
|
|
||||||
const buildTools = (openedPage: unknown) =>
|
|
||||||
service.forUser(
|
|
||||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
|
||||||
'session-1',
|
|
||||||
'ws-1',
|
|
||||||
'chat-1',
|
|
||||||
openedPage as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
it('returns the nested selection from the resolved context', async () => {
|
|
||||||
const selection = { text: 'fix this', blockIds: ['b1'], before: 'a ' };
|
|
||||||
const tools = await buildTools({ id: 'p1', title: 'Doc', selection });
|
|
||||||
expect(await tools.getCurrentPage.execute({} as never, {} as never)).toEqual(
|
|
||||||
{ page: { id: 'p1', title: 'Doc' }, selection },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns selection: null when the context has no selection', async () => {
|
|
||||||
const tools = await buildTools({ id: 'p1', title: 'Doc' });
|
|
||||||
expect(await tools.getCurrentPage.execute({} as never, {} as never)).toEqual(
|
|
||||||
{ page: { id: 'p1', title: 'Doc' }, selection: null },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns { page: null, selection: null } when no page is open', async () => {
|
|
||||||
const tools = await buildTools(null);
|
|
||||||
expect(await tools.getCurrentPage.execute({} as never, {} as never)).toEqual(
|
|
||||||
{ page: null, selection: null },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -13,11 +13,8 @@ import {
|
|||||||
type DocmostClientLike,
|
type DocmostClientLike,
|
||||||
type SharedToolSpec,
|
type SharedToolSpec,
|
||||||
} from './docmost-client.loader';
|
} from './docmost-client.loader';
|
||||||
import {
|
import { resolveCurrentPageResult } from './current-page.util';
|
||||||
resolveCurrentPageResult,
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
type SelectionContext,
|
|
||||||
} from './current-page.util';
|
|
||||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
|
||||||
import { modelFriendlyInput } from './model-friendly-input';
|
import { modelFriendlyInput } from './model-friendly-input';
|
||||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||||
import {
|
import {
|
||||||
@@ -156,13 +153,8 @@ export class AiChatToolsService {
|
|||||||
// The page the user currently has open (from the request context), exposed
|
// The page the user currently has open (from the request context), exposed
|
||||||
// to the model via getCurrentPage. Optional and last so existing callers
|
// to the model via getCurrentPage. Optional and last so existing callers
|
||||||
// keep compiling. Kept proxy-robust: the model can CALL for the current
|
// keep compiling. Kept proxy-robust: the model can CALL for the current
|
||||||
// page instead of relying on it surviving in the system prompt text. The
|
// page instead of relying on it surviving in the system prompt text.
|
||||||
// `selection` (#388) is already sanitized + nested by resolveOpenPageContext.
|
openedPage?: { id?: string; title?: string } | null,
|
||||||
openedPage?: {
|
|
||||||
id?: string;
|
|
||||||
title?: string;
|
|
||||||
selection?: SelectionContext | null;
|
|
||||||
} | null,
|
|
||||||
): Promise<Record<string, Tool>> {
|
): Promise<Record<string, Tool>> {
|
||||||
// Build the per-user loopback client (carrying the access + collab
|
// Build the per-user loopback client (carrying the access + collab
|
||||||
// provenance tokens) and load the shared tool-spec registry. Client
|
// provenance tokens) and load the shared tool-spec registry. Client
|
||||||
@@ -317,15 +309,9 @@ export class AiChatToolsService {
|
|||||||
getCurrentPage: tool({
|
getCurrentPage: tool({
|
||||||
description:
|
description:
|
||||||
'Return the page the user is currently viewing — i.e. what "this page", ' +
|
'Return the page the user is currently viewing — i.e. what "this page", ' +
|
||||||
'"the current page", or "here" refers to — plus the text the user ' +
|
'"the current page", or "here" refers to. Returns the page id and title, ' +
|
||||||
'currently has SELECTED on that page (what "this", "here", "the selected ' +
|
'or null if the user is not currently on a page. Call this first whenever ' +
|
||||||
'fragment" refers to), or selection: null when nothing is selected. The ' +
|
'the user refers to the current page without giving an explicit id.',
|
||||||
'selection is a client-side snapshot taken when the user sent the message ' +
|
|
||||||
'and includes the ids of the blocks it covers plus surrounding context; ' +
|
|
||||||
'it is NOT verified server-side — locate it in the page (searchInPage / ' +
|
|
||||||
'getNode) before editing. Returns page: null if the user is not currently ' +
|
|
||||||
'on a page. Call this first whenever the user refers to the current page ' +
|
|
||||||
'or a selected fragment without giving an explicit id.',
|
|
||||||
inputSchema: modelFriendlyInput({}),
|
inputSchema: modelFriendlyInput({}),
|
||||||
execute: async () => resolveCurrentPageResult(openedPage),
|
execute: async () => resolveCurrentPageResult(openedPage),
|
||||||
}),
|
}),
|
||||||
@@ -697,38 +683,6 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
|
|
||||||
// Promoted from MCP-only so the in-app agent can attach a REAL footnote to
|
|
||||||
// already-written text instead of leaving a literal `^[...]` string.
|
|
||||||
insertFootnote: sharedTool(
|
|
||||||
sharedToolSpecs.insertFootnote,
|
|
||||||
async ({ pageId, anchorText, text }) =>
|
|
||||||
await client.insertFootnote(pageId, anchorText, text),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
|
|
||||||
// The schema field is `imageUrl`; the client method takes it positionally.
|
|
||||||
insertImage: sharedTool(
|
|
||||||
sharedToolSpecs.insertImage,
|
|
||||||
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) =>
|
|
||||||
await client.insertImage(pageId, imageUrl, {
|
|
||||||
align,
|
|
||||||
alt,
|
|
||||||
replaceText,
|
|
||||||
afterText,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#410).
|
|
||||||
replaceImage: sharedTool(
|
|
||||||
sharedToolSpecs.replaceImage,
|
|
||||||
async ({ pageId, attachmentId, imageUrl, align, alt }) =>
|
|
||||||
await client.replaceImage(pageId, attachmentId, imageUrl, {
|
|
||||||
align,
|
|
||||||
alt,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||||
tableInsertRow: sharedTool(
|
tableInsertRow: sharedTool(
|
||||||
|
|||||||
@@ -1,180 +1,43 @@
|
|||||||
import {
|
import { resolveCurrentPageResult } from './current-page.util';
|
||||||
resolveCurrentPageResult,
|
|
||||||
sanitizeSelection,
|
|
||||||
} from './current-page.util';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
|
* Unit tests for resolveCurrentPageResult (pure function). Mirrors the
|
||||||
* getCurrentPage tool's contract: { page: null, selection: null } when no page
|
* getCurrentPage tool's contract: { page: null } when no page is open (no id),
|
||||||
* is open (no id), otherwise { page: { id, title }, selection } with title
|
* otherwise { page: { id, title } } with title defaulting to ''.
|
||||||
* defaulting to '' and the selection passed through from the resolved context.
|
|
||||||
*/
|
*/
|
||||||
describe('resolveCurrentPageResult', () => {
|
describe('resolveCurrentPageResult', () => {
|
||||||
it('returns { page: null, selection: null } when openedPage is undefined', () => {
|
it('returns { page: null } when openedPage is undefined', () => {
|
||||||
expect(resolveCurrentPageResult(undefined)).toEqual({
|
expect(resolveCurrentPageResult(undefined)).toEqual({ page: null });
|
||||||
page: null,
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { page: null, selection: null } when openedPage is null', () => {
|
it('returns { page: null } when openedPage is null', () => {
|
||||||
expect(resolveCurrentPageResult(null)).toEqual({
|
expect(resolveCurrentPageResult(null)).toEqual({ page: null });
|
||||||
page: null,
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { page: null, selection: null } when openedPage has no id', () => {
|
it('returns { page: null } when openedPage has no id', () => {
|
||||||
expect(resolveCurrentPageResult({})).toEqual({
|
expect(resolveCurrentPageResult({})).toEqual({ page: null });
|
||||||
page: null,
|
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({ page: null });
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
expect(resolveCurrentPageResult({ title: 'x' })).toEqual({
|
|
||||||
page: null,
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns { page: null, selection: null } when id is an empty string', () => {
|
it('returns { page: null } when id is an empty string', () => {
|
||||||
expect(resolveCurrentPageResult({ id: '' })).toEqual({
|
expect(resolveCurrentPageResult({ id: '' })).toEqual({ page: null });
|
||||||
page: null,
|
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops the selection when there is no page (selection dies with the page)', () => {
|
it('returns the page id and title when both are present', () => {
|
||||||
// Even if a selection somehow rode along without a page id, a null page
|
|
||||||
// always yields a null selection.
|
|
||||||
expect(
|
|
||||||
resolveCurrentPageResult({ selection: { text: 'orphan' } }),
|
|
||||||
).toEqual({ page: null, selection: null });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the page id and title with a null selection by default', () => {
|
|
||||||
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
|
expect(resolveCurrentPageResult({ id: 'p1', title: 'Hello' })).toEqual({
|
||||||
page: { id: 'p1', title: 'Hello' },
|
page: { id: 'p1', title: 'Hello' },
|
||||||
selection: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes the nested selection through verbatim', () => {
|
|
||||||
const selection = {
|
|
||||||
text: 'fix this',
|
|
||||||
blockIds: ['b1'],
|
|
||||||
before: 'please ',
|
|
||||||
after: ' now',
|
|
||||||
};
|
|
||||||
expect(
|
|
||||||
resolveCurrentPageResult({ id: 'p1', title: 'Hello', selection }),
|
|
||||||
).toEqual({
|
|
||||||
page: { id: 'p1', title: 'Hello' },
|
|
||||||
selection,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('defaults title to "" when it is missing', () => {
|
it('defaults title to "" when it is missing', () => {
|
||||||
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
expect(resolveCurrentPageResult({ id: 'p1' })).toEqual({
|
||||||
page: { id: 'p1', title: '' },
|
page: { id: 'p1', title: '' },
|
||||||
selection: null,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps an explicit empty-string title as ""', () => {
|
it('keeps an explicit empty-string title as ""', () => {
|
||||||
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
expect(resolveCurrentPageResult({ id: 'p1', title: '' })).toEqual({
|
||||||
page: { id: 'p1', title: '' },
|
page: { id: 'p1', title: '' },
|
||||||
selection: null,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Unit tests for sanitizeSelection (#388). The selection is an attacker-
|
|
||||||
* controllable client snapshot: every field is type-checked and capped, and
|
|
||||||
* anything that is not a real selection collapses to null. It is NEVER verified
|
|
||||||
* against the page content (decision 5 — a hint, not ground truth).
|
|
||||||
*/
|
|
||||||
describe('sanitizeSelection', () => {
|
|
||||||
it('accepts a well-formed payload unchanged', () => {
|
|
||||||
const raw = {
|
|
||||||
text: 'the selected fragment',
|
|
||||||
truncated: true,
|
|
||||||
blockIds: ['b1', 'b2'],
|
|
||||||
before: 'context before ',
|
|
||||||
after: ' context after',
|
|
||||||
};
|
|
||||||
expect(sanitizeSelection(raw)).toEqual(raw);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null for non-objects', () => {
|
|
||||||
expect(sanitizeSelection(null)).toBeNull();
|
|
||||||
expect(sanitizeSelection(undefined)).toBeNull();
|
|
||||||
expect(sanitizeSelection('text')).toBeNull();
|
|
||||||
expect(sanitizeSelection(42)).toBeNull();
|
|
||||||
expect(sanitizeSelection([])).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null when text is missing, non-string or blank-after-trim', () => {
|
|
||||||
expect(sanitizeSelection({})).toBeNull();
|
|
||||||
expect(sanitizeSelection({ text: 123 })).toBeNull();
|
|
||||||
expect(sanitizeSelection({ text: '' })).toBeNull();
|
|
||||||
expect(sanitizeSelection({ text: ' \n ' })).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps only text when the other fields are garbage', () => {
|
|
||||||
expect(
|
|
||||||
sanitizeSelection({
|
|
||||||
text: 'hello',
|
|
||||||
truncated: 'yes',
|
|
||||||
blockIds: 'nope',
|
|
||||||
before: 5,
|
|
||||||
after: {},
|
|
||||||
}),
|
|
||||||
).toEqual({ text: 'hello' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps text at 4000 and forces truncated', () => {
|
|
||||||
const raw = { text: 'a'.repeat(5000) };
|
|
||||||
const out = sanitizeSelection(raw)!;
|
|
||||||
expect(out.text).toHaveLength(4000);
|
|
||||||
expect(out.truncated).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not set truncated for text under the cap', () => {
|
|
||||||
expect(sanitizeSelection({ text: 'short' })).toEqual({ text: 'short' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('slices blockIds to 20 and drops non-string / oversized ids', () => {
|
|
||||||
const ids = Array.from({ length: 30 }, (_, i) => `b${i}`);
|
|
||||||
const out = sanitizeSelection({
|
|
||||||
text: 'x',
|
|
||||||
blockIds: [...ids, 123, '', 'y'.repeat(65)],
|
|
||||||
})!;
|
|
||||||
// The 30 valid ids cap to the first 20; the number, empty string and the
|
|
||||||
// 65-char id are dropped before the slice.
|
|
||||||
expect(out.blockIds).toHaveLength(20);
|
|
||||||
expect(out.blockIds).toEqual(ids.slice(0, 20));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a 64-char id but drops a 65-char one (boundary)', () => {
|
|
||||||
const ok = 'z'.repeat(64);
|
|
||||||
const tooLong = 'z'.repeat(65);
|
|
||||||
expect(
|
|
||||||
sanitizeSelection({ text: 'x', blockIds: [ok, tooLong] })!.blockIds,
|
|
||||||
).toEqual([ok]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('omits blockIds entirely when none survive', () => {
|
|
||||||
const out = sanitizeSelection({ text: 'x', blockIds: [123, ''] })!;
|
|
||||||
expect(out.blockIds).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caps before/after at 200 chars and drops empty ones', () => {
|
|
||||||
const out = sanitizeSelection({
|
|
||||||
text: 'x',
|
|
||||||
before: 'b'.repeat(300),
|
|
||||||
after: '',
|
|
||||||
})!;
|
|
||||||
expect(out.before).toHaveLength(200);
|
|
||||||
expect(out.after).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,91 +1,21 @@
|
|||||||
export interface SelectionContext {
|
|
||||||
text: string;
|
|
||||||
truncated?: boolean;
|
|
||||||
blockIds?: string[];
|
|
||||||
before?: string;
|
|
||||||
after?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Server-side caps for the client-reported selection. Intentionally >= the
|
|
||||||
// client caps: the client pre-trims for a small wire, but this layer re-checks
|
|
||||||
// everything because the payload is attacker-controllable.
|
|
||||||
const TEXT_CAP = 4000;
|
|
||||||
const CONTEXT_CAP = 200;
|
|
||||||
const MAX_BLOCK_IDS = 20;
|
|
||||||
const BLOCK_ID_CAP = 64;
|
|
||||||
|
|
||||||
// Sanitize the client-reported selection: type-check every field, cap sizes
|
|
||||||
// (text 4000, before/after 200, blockIds 20 x 64 chars), drop garbage to null.
|
|
||||||
// The selection is a CLIENT-side snapshot — never verified against the page
|
|
||||||
// content (#159 lesson: treat as a hint, not ground truth). The agent is told
|
|
||||||
// (getCurrentPage's description) to localize it before editing.
|
|
||||||
export function sanitizeSelection(raw: unknown): SelectionContext | null {
|
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
|
||||||
const r = raw as Record<string, unknown>;
|
|
||||||
|
|
||||||
// text is the only required field; anything else is a non-selection.
|
|
||||||
if (typeof r.text !== 'string' || r.text.trim().length === 0) return null;
|
|
||||||
let text = r.text;
|
|
||||||
let truncated = r.truncated === true;
|
|
||||||
if (text.length > TEXT_CAP) {
|
|
||||||
text = text.slice(0, TEXT_CAP);
|
|
||||||
truncated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: SelectionContext = { text };
|
|
||||||
if (truncated) result.truncated = true;
|
|
||||||
|
|
||||||
if (Array.isArray(r.blockIds)) {
|
|
||||||
// Keep only well-formed, in-range ids (an oversize id is DROPPED, not
|
|
||||||
// truncated — a mangled id is worse than a missing one), then cap the count.
|
|
||||||
const ids = r.blockIds
|
|
||||||
.filter(
|
|
||||||
(x): x is string =>
|
|
||||||
typeof x === 'string' && x.length > 0 && x.length <= BLOCK_ID_CAP,
|
|
||||||
)
|
|
||||||
.slice(0, MAX_BLOCK_IDS);
|
|
||||||
if (ids.length > 0) result.blockIds = ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof r.before === 'string' && r.before.length > 0) {
|
|
||||||
result.before = r.before.slice(0, CONTEXT_CAP);
|
|
||||||
}
|
|
||||||
if (typeof r.after === 'string' && r.after.length > 0) {
|
|
||||||
result.after = r.after.slice(0, CONTEXT_CAP);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurrentPageInput {
|
export interface CurrentPageInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
// The already-sanitized selection nested onto the resolved open-page context
|
|
||||||
// by resolveOpenPageContext (never the raw client value). Passed through to
|
|
||||||
// the tool result verbatim; null when nothing is selected.
|
|
||||||
selection?: SelectionContext | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CurrentPageResult {
|
export interface CurrentPageResult {
|
||||||
page: { id: string; title: string } | null;
|
page: { id: string; title: string } | null;
|
||||||
selection: SelectionContext | null; // null when nothing is selected or no page
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the "current page" tool result from the client-supplied open-page
|
// Resolve the "current page" tool result from the client-supplied open-page
|
||||||
// context. Returns { page: null, selection: null } when no page is open (no id),
|
// context. Returns { page: null } when no page is open (no id), otherwise the
|
||||||
// otherwise the page id + title (title defaults to '' when absent) plus the
|
// page id + title (title defaults to '' when absent). Mirrors the getCurrentPage
|
||||||
// selection already sanitized+nested by resolveOpenPageContext. A null page
|
// tool's contract so it can be unit-tested without the ESM Docmost client.
|
||||||
// always yields a null selection (the selection dies with the page). Mirrors the
|
|
||||||
// getCurrentPage tool's contract so it can be unit-tested without the ESM
|
|
||||||
// Docmost client.
|
|
||||||
export function resolveCurrentPageResult(
|
export function resolveCurrentPageResult(
|
||||||
openedPage?: CurrentPageInput | null,
|
openedPage?: CurrentPageInput | null,
|
||||||
): CurrentPageResult {
|
): CurrentPageResult {
|
||||||
if (!openedPage?.id) {
|
if (!openedPage?.id) {
|
||||||
return { page: null, selection: null };
|
return { page: null };
|
||||||
}
|
}
|
||||||
return {
|
return { page: { id: openedPage.id, title: openedPage.title ?? '' } };
|
||||||
page: { id: openedPage.id, title: openedPage.title ?? '' },
|
|
||||||
selection: openedPage.selection ?? null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,33 +141,6 @@ export interface DocmostClientLike {
|
|||||||
doc?: unknown,
|
doc?: unknown,
|
||||||
title?: string,
|
title?: string,
|
||||||
): Promise<Record<string, unknown>>;
|
): Promise<Record<string, unknown>>;
|
||||||
// Attach an author-inline footnote after the first occurrence of anchorText;
|
|
||||||
// numbering + the footnotes list are derived server-side.
|
|
||||||
insertFootnote(
|
|
||||||
pageId: string,
|
|
||||||
anchorText: string,
|
|
||||||
text: string,
|
|
||||||
): Promise<Record<string, unknown>>;
|
|
||||||
// Download a web image and insert it into the page (append, or replace/after a
|
|
||||||
// text anchor). `url` is the image http(s) URL.
|
|
||||||
insertImage(
|
|
||||||
pageId: string,
|
|
||||||
url: string,
|
|
||||||
opts?: {
|
|
||||||
align?: 'left' | 'center' | 'right';
|
|
||||||
alt?: string;
|
|
||||||
replaceText?: string;
|
|
||||||
afterText?: string;
|
|
||||||
},
|
|
||||||
): Promise<Record<string, unknown>>;
|
|
||||||
// Swap an existing image (by its attachmentId) for a new one fetched from a web
|
|
||||||
// URL, repointing every reference in the live document.
|
|
||||||
replaceImage(
|
|
||||||
pageId: string,
|
|
||||||
oldAttachmentId: string,
|
|
||||||
url: string,
|
|
||||||
opts?: { align?: 'left' | 'center' | 'right'; alt?: string },
|
|
||||||
): Promise<Record<string, unknown>>;
|
|
||||||
tableInsertRow(
|
tableInsertRow(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
tableRef: string,
|
tableRef: string,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
import { parseNodeArg } from './parse-node-arg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit tests for the shared `parseNodeArg` helper (#414: now the single copy in
|
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
|
||||||
* `@docmost/prosemirror-markdown`, imported by both the server tool adapters and
|
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
|
||||||
* `@docmost/mcp`). Used by the patchNode / insertNode / updatePageJson adapters.
|
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
|
||||||
* Behavior: object passthrough, valid-string parse, invalid-string throw.
|
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
|
||||||
*/
|
*/
|
||||||
describe('parseNodeArg', () => {
|
describe('parseNodeArg', () => {
|
||||||
it('passes an object through unchanged', () => {
|
it('passes an object through unchanged', () => {
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
||||||
|
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||||
|
// invalid JSON), pass an object through unchanged. Shared by patchNode /
|
||||||
|
// insertNode (and the analogous updatePageJson content parsing).
|
||||||
|
//
|
||||||
|
// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts`
|
||||||
|
// (the function logic, default/explicit throw messages and branch order match;
|
||||||
|
// only comments and quote style differ). We cannot import that helper here:
|
||||||
|
// `@docmost/mcp` is ESM-only and this server
|
||||||
|
// compiles with module:commonjs, so it is loaded at runtime via the
|
||||||
|
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
|
||||||
|
// runtime code across that ESM/CJS boundary by a normal import is impossible,
|
||||||
|
// hence the mirrored copy.
|
||||||
|
export function parseNodeArg(
|
||||||
|
node: unknown,
|
||||||
|
errMsg = 'node was a string but not valid JSON',
|
||||||
|
): unknown {
|
||||||
|
if (typeof node === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(node);
|
||||||
|
} catch {
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
}
|
||||||
@@ -27,26 +27,13 @@ import type { DocmostClientLike } from './docmost-client.loader';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
describe('tool tier metadata (#332)', () => {
|
describe('tool tier metadata (#332)', () => {
|
||||||
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => {
|
it('core set is the documented 13 + searchInPage (14)', () => {
|
||||||
expect(CORE_TOOL_KEYS).toHaveLength(15);
|
expect(CORE_TOOL_KEYS).toHaveLength(14);
|
||||||
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||||
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
|
|
||||||
// loadTools is a meta-tool, not a normal core key.
|
// loadTools is a meta-tool, not a normal core key.
|
||||||
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
|
|
||||||
// insert_footnote is core (symmetric with editPageText); the image tools stay
|
|
||||||
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the
|
|
||||||
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
|
|
||||||
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
|
|
||||||
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true);
|
|
||||||
expect(SHARED_TOOL_SPECS.insertImage.tier).toBe('deferred');
|
|
||||||
expect(CORE_TOOL_SET.has('insertImage')).toBe(false);
|
|
||||||
expect(SHARED_TOOL_SPECS.replaceImage.tier).toBe('deferred');
|
|
||||||
expect(CORE_TOOL_SET.has('replaceImage')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
it('SHARED_TOOL_SPECS tier agrees with CORE_TOOL_SET for every shared tool', () => {
|
||||||
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
const isCoreByTier = spec.tier === 'core';
|
const isCoreByTier = spec.tier === 'core';
|
||||||
|
|||||||
@@ -38,13 +38,10 @@ export interface ToolCatalogEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage`
|
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools. `searchInPage`
|
||||||
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent
|
* (#330) is added to core on top of the issue's original tier list: it is
|
||||||
* for the editorial roles this feature targets; `insertFootnote` is core so the
|
* frequent for the editorial roles this feature targets. `loadTools` is active
|
||||||
* footnote tool is NOT hidden while its natural sibling `editPageText` is always
|
* too but is not a normal tool key (it is added to activeTools separately).
|
||||||
* active (that asymmetry is exactly what pushed the agent to write literal
|
|
||||||
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added
|
|
||||||
* to activeTools separately).
|
|
||||||
*/
|
*/
|
||||||
export const CORE_TOOL_KEYS = [
|
export const CORE_TOOL_KEYS = [
|
||||||
'searchPages',
|
'searchPages',
|
||||||
@@ -63,9 +60,6 @@ export const CORE_TOOL_KEYS = [
|
|||||||
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
||||||
// the issue's tier list.
|
// the issue's tier list.
|
||||||
'searchInPage',
|
'searchInPage',
|
||||||
// #410 insert_footnote — core so pinpoint citations to already-written text
|
|
||||||
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
|
||||||
'insertFootnote',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** O(1) membership test for the core tier. */
|
/** O(1) membership test for the core tier. */
|
||||||
@@ -104,8 +98,7 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
},
|
},
|
||||||
getCurrentPage: {
|
getCurrentPage: {
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine:
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
'getCurrentPage — the page the user is currently viewing and their current text selection on it.',
|
|
||||||
},
|
},
|
||||||
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
// (#294); they carry their own tier ('core') + catalogLine there.
|
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||||
|
|||||||
@@ -23,21 +23,8 @@ import { hashPassword } from '../../../common/helpers';
|
|||||||
* unthrottled password-guessing oracle.
|
* unthrottled password-guessing oracle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// bcrypt cost-12 hashing/compare takes ~300ms idle but multiple seconds when
|
|
||||||
// parallel jest workers saturate all CPU cores; the 5s default flakes.
|
|
||||||
jest.setTimeout(30_000);
|
|
||||||
|
|
||||||
const WORKSPACE_ID = 'ws-1';
|
const WORKSPACE_ID = 'ws-1';
|
||||||
|
|
||||||
let passwordHash: string;
|
|
||||||
|
|
||||||
// Hoist the expensive work: compute ONE bcrypt cost-12 hash shared by all
|
|
||||||
// tests instead of five. The hash is a read-only string and each test builds
|
|
||||||
// its own user object around it, so sharing is safe.
|
|
||||||
beforeAll(async () => {
|
|
||||||
passwordHash = await hashPassword('correct-horse');
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
// Build an AuthService with the dependencies verifyUserCredentials/login touch
|
// Build an AuthService with the dependencies verifyUserCredentials/login touch
|
||||||
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
|
// stubbed, and a userRepo whose findByEmail is overridable per test. Only the
|
||||||
// collaborators actually reached on these paths need real behaviour; the rest
|
// collaborators actually reached on these paths need real behaviour; the rest
|
||||||
@@ -108,6 +95,7 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
|
|||||||
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
|
it('DISABLED user -> throws exactly CREDENTIALS_MISMATCH_MESSAGE (no password oracle)', async () => {
|
||||||
// A deactivated user must be indistinguishable from a wrong password: same
|
// A deactivated user must be indistinguishable from a wrong password: same
|
||||||
// message, before any password comparison.
|
// message, before any password comparison.
|
||||||
|
const passwordHash = await hashPassword('correct-horse');
|
||||||
const disabledUser = {
|
const disabledUser = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'disabled@example.com',
|
email: 'disabled@example.com',
|
||||||
@@ -129,6 +117,7 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
it('WRONG password -> throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||||
|
const passwordHash = await hashPassword('correct-horse');
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -150,6 +139,7 @@ describe('AuthService.verifyUserCredentials (live credentials-mismatch contract)
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
|
it('CORRECT credentials -> resolves the matched user (no side effects here)', async () => {
|
||||||
|
const passwordHash = await hashPassword('correct-horse');
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -189,6 +179,7 @@ describe('AuthService.login (live credentials-mismatch contract via verifyUserCr
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
it('WRONG password -> login throws exactly CREDENTIALS_MISMATCH_MESSAGE', async () => {
|
||||||
|
const passwordHash = await hashPassword('correct-horse');
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
@@ -210,6 +201,7 @@ describe('AuthService.login (live credentials-mismatch contract via verifyUserCr
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
|
it('CORRECT credentials -> login mints the session (the side-effecting path)', async () => {
|
||||||
|
const passwordHash = await hashPassword('correct-horse');
|
||||||
const user = {
|
const user = {
|
||||||
id: 'u-1',
|
id: 'u-1',
|
||||||
email: 'user@example.com',
|
email: 'user@example.com',
|
||||||
|
|||||||
@@ -123,19 +123,19 @@ export function streamKeepAliveMs(): number {
|
|||||||
return positiveEnv('AI_STREAM_KEEPALIVE_MS', DEFAULT_STREAM_KEEPALIVE_MS);
|
return positiveEnv('AI_STREAM_KEEPALIVE_MS', DEFAULT_STREAM_KEEPALIVE_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Default SILENCE timeout for EXTERNAL-MCP transport (1 min). */
|
/** Default SILENCE timeout for EXTERNAL-MCP transport (5 min). */
|
||||||
const DEFAULT_MCP_STREAM_TIMEOUT_MS = 60_000;
|
const DEFAULT_MCP_STREAM_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
/** Default total wall-clock cap for ONE external MCP tool call (2 min). */
|
/** Default total wall-clock cap for ONE external MCP tool call (15 min). */
|
||||||
const DEFAULT_MCP_CALL_TIMEOUT_MS = 120_000;
|
const DEFAULT_MCP_CALL_TIMEOUT_MS = 900_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SILENCE timeout (ms) for EXTERNAL-MCP transport ONLY. Override with
|
* SILENCE timeout (ms) for EXTERNAL-MCP transport ONLY. Override with
|
||||||
* `AI_MCP_STREAM_TIMEOUT_MS`; a missing/invalid/non-positive value falls back to
|
* `AI_MCP_STREAM_TIMEOUT_MS`; a missing/invalid/non-positive value falls back to
|
||||||
* {@link DEFAULT_MCP_STREAM_TIMEOUT_MS} (1 min).
|
* {@link DEFAULT_MCP_STREAM_TIMEOUT_MS} (5 min).
|
||||||
*
|
*
|
||||||
* Deliberately tighter than the chat provider's {@link streamTimeoutMs} (15 min)
|
* Deliberately tighter than the chat provider's {@link streamTimeoutMs} (15 min)
|
||||||
* so a byte-silent/hung MCP upstream is broken in ~1 min instead of 15. This is
|
* so a byte-silent/hung MCP upstream is broken in ~5 min instead of 15. This is
|
||||||
* the undici `headersTimeout`/`bodyTimeout` for the external-MCP dispatcher only
|
* the undici `headersTimeout`/`bodyTimeout` for the external-MCP dispatcher only
|
||||||
* — it must NOT change the chat provider, which legitimately needs 15 min between
|
* — it must NOT change the chat provider, which legitimately needs 15 min between
|
||||||
* reasoning chunks (#175).
|
* reasoning chunks (#175).
|
||||||
@@ -153,7 +153,7 @@ export function mcpStreamTimeoutMs(): number {
|
|||||||
/**
|
/**
|
||||||
* Total wall-clock cap (ms) for ONE external MCP tool call — APP-LEVEL, not
|
* Total wall-clock cap (ms) for ONE external MCP tool call — APP-LEVEL, not
|
||||||
* transport. Override with `AI_MCP_CALL_TIMEOUT_MS`; a missing/invalid/
|
* transport. Override with `AI_MCP_CALL_TIMEOUT_MS`; a missing/invalid/
|
||||||
* non-positive value falls back to {@link DEFAULT_MCP_CALL_TIMEOUT_MS} (2 min).
|
* non-positive value falls back to {@link DEFAULT_MCP_CALL_TIMEOUT_MS} (15 min).
|
||||||
*
|
*
|
||||||
* Catches a tool that keeps the connection warm (SSE heartbeats / trickle) but
|
* Catches a tool that keeps the connection warm (SSE heartbeats / trickle) but
|
||||||
* never returns a result — which the transport silence timeout
|
* never returns a result — which the transport silence timeout
|
||||||
|
|||||||
@@ -292,23 +292,6 @@ export class EnvironmentService {
|
|||||||
return enabled === 'true';
|
return enabled === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Resumable SSE transport for durable agent runs (#184 phase 1.5). When
|
|
||||||
* enabled, a run tees its SSE frames into the in-memory run-stream registry so
|
|
||||||
* a late/reloaded tab can attach (replay + live tail) via
|
|
||||||
* `GET /ai-chat/runs/:chatId/stream`. Defaults to DISABLED: PR 1 ships the
|
|
||||||
* server code dormant — with the flag off, `open`/`bind`/`generateMessageId`
|
|
||||||
* are never called and attach always answers 204, so the legacy and #184
|
|
||||||
* phase-1 wire paths stay byte-for-byte identical. Set
|
|
||||||
* AI_CHAT_RESUMABLE_STREAM=true to activate it (paired with the PR 2 client).
|
|
||||||
*/
|
|
||||||
isAiChatResumableStreamEnabled(): boolean {
|
|
||||||
const enabled = this.configService
|
|
||||||
.get<string>('AI_CHAT_RESUMABLE_STREAM', 'false')
|
|
||||||
.toLowerCase();
|
|
||||||
return enabled === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
getPostHogHost(): string {
|
getPostHogHost(): string {
|
||||||
return this.configService.get<string>('POSTHOG_HOST');
|
return this.configService.get<string>('POSTHOG_HOST');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,15 +149,6 @@ export type DocmostMcpConfig = (
|
|||||||
has?: (uri: string) => boolean;
|
has?: (uri: string) => boolean;
|
||||||
evict?: (uri: string) => void;
|
evict?: (uri: string) => void;
|
||||||
};
|
};
|
||||||
// Dependency-neutral metrics sink injected by McpService (mirror of the
|
|
||||||
// package's onMetric). The package emits generic (name, value, labels)
|
|
||||||
// samples; McpService maps them onto the prom-client registry. Undefined
|
|
||||||
// when metrics are disabled → the package no-ops.
|
|
||||||
onMetric?: (
|
|
||||||
name: string,
|
|
||||||
value: number,
|
|
||||||
labels?: Record<string, string>,
|
|
||||||
) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ResolvedMcpAuth {
|
export interface ResolvedMcpAuth {
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ import {
|
|||||||
ResolvedMcpAuth,
|
ResolvedMcpAuth,
|
||||||
} from './mcp-auth.helpers';
|
} from './mcp-auth.helpers';
|
||||||
import { SandboxStore } from '../sandbox/sandbox.store';
|
import { SandboxStore } from '../sandbox/sandbox.store';
|
||||||
import {
|
|
||||||
isMetricsEnabled,
|
|
||||||
observeMcpTool,
|
|
||||||
incConnectTimeout,
|
|
||||||
} from '../metrics/metrics.registry';
|
|
||||||
|
|
||||||
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
|
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
|
||||||
interface McpHttpHandler {
|
interface McpHttpHandler {
|
||||||
@@ -336,31 +331,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
// can store blobs in the shared in-RAM store regardless of which
|
// can store blobs in the shared in-RAM store regardless of which
|
||||||
// credential variant resolved. The sink (put/has/evict + uri↔id
|
// credential variant resolved. The sink (put/has/evict + uri↔id
|
||||||
// mapping) is owned by SandboxStore.asSink().
|
// mapping) is owned by SandboxStore.asSink().
|
||||||
// Route the package's dependency-neutral metric samples onto the
|
return { ...resolved.config, sandbox: this.sandboxStore.asSink() };
|
||||||
// prom-client registry. When metrics are disabled, onMetric is
|
|
||||||
// undefined → the package's tool-timer/timeout hooks are a
|
|
||||||
// negligible-overhead no-op: the registerTool wrapper still runs a
|
|
||||||
// performance.now() + async try/finally per tool call, but the
|
|
||||||
// `onMetric?.()` short-circuits so no label/object is built. (Cost
|
|
||||||
// is immaterial at LLM tool-call rate.) labels?.tool is guarded
|
|
||||||
// defensively (the tool wrapper always sets it).
|
|
||||||
return {
|
|
||||||
...resolved.config,
|
|
||||||
sandbox: this.sandboxStore.asSink(),
|
|
||||||
onMetric: isMetricsEnabled()
|
|
||||||
? (
|
|
||||||
name: string,
|
|
||||||
value: number,
|
|
||||||
labels?: Record<string, string>,
|
|
||||||
) => {
|
|
||||||
if (name === 'mcp_tool_duration_seconds') {
|
|
||||||
observeMcpTool(labels?.tool ?? 'other', value);
|
|
||||||
} else if (name === 'collab_connect_timeouts_total') {
|
|
||||||
incConnectTimeout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identify: (req: IncomingMessage) => {
|
identify: (req: IncomingMessage) => {
|
||||||
|
|||||||
@@ -2,11 +2,6 @@
|
|||||||
* Perf-metrics contract (#355). These names/labels are FIXED by the already
|
* Perf-metrics contract (#355). These names/labels are FIXED by the already
|
||||||
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
|
* deployed scrape+dashboard infra (VictoriaMetrics scraping docmost:9464,
|
||||||
* Grafana dashboards, alerts). Do NOT rename them.
|
* Grafana dashboards, alerts). Do NOT rename them.
|
||||||
*
|
|
||||||
* #402 extends the #355 table with the collab-lifecycle + MCP-tool families
|
|
||||||
* (grouped below). These are the fixed contract from #402; same "do not rename"
|
|
||||||
* rule applies. Server-side families land in Pass 1; the MCP-tool histogram is
|
|
||||||
* fed by the MCP callback in a later pass but its NAME is fixed here now.
|
|
||||||
*/
|
*/
|
||||||
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
|
export const METRIC_HTTP_REQUEST_DURATION = 'http_request_duration_seconds';
|
||||||
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
|
export const METRIC_DB_QUERY_DURATION = 'db_query_duration_seconds';
|
||||||
@@ -14,17 +9,6 @@ export const METRIC_BULLMQ_QUEUE_DEPTH = 'bullmq_queue_depth';
|
|||||||
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
|
export const METRIC_BULLMQ_JOB_DURATION = 'bullmq_job_duration_seconds';
|
||||||
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
|
export const METRIC_COLLAB_STORE_DURATION = 'collab_store_duration_seconds';
|
||||||
|
|
||||||
// #402 additions — collaboration lifecycle + MCP tool timing.
|
|
||||||
export const METRIC_COLLAB_LOAD_DURATION = 'collab_doc_load_duration_seconds';
|
|
||||||
export const METRIC_COLLAB_DOCS_OPEN = 'collab_docs_open';
|
|
||||||
export const METRIC_COLLAB_DOC_LOADS_TOTAL = 'collab_doc_loads_total';
|
|
||||||
export const METRIC_COLLAB_DOC_UNLOADS_TOTAL = 'collab_doc_unloads_total';
|
|
||||||
export const METRIC_COLLAB_CONNECT_DURATION = 'collab_connect_duration_seconds';
|
|
||||||
export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
|
|
||||||
'collab_connect_timeouts_total';
|
|
||||||
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
|
|
||||||
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
|
|
||||||
|
|
||||||
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
||||||
// for typical web/DB latencies without exploding series cardinality.
|
// for typical web/DB latencies without exploding series cardinality.
|
||||||
export const HTTP_BUCKETS = [
|
export const HTTP_BUCKETS = [
|
||||||
@@ -39,12 +23,6 @@ export const COLLAB_BUCKETS = [
|
|||||||
export const JOB_BUCKETS = [
|
export const JOB_BUCKETS = [
|
||||||
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
|
0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120,
|
||||||
];
|
];
|
||||||
// #402 — MCP tool-call latency. Same shape as COLLAB_BUCKETS but stretched to
|
|
||||||
// 10s at the top: an MCP tool round-trip (LLM-driven doc ops) can be slower
|
|
||||||
// than a single collab store, so keep resolution out to 10s.
|
|
||||||
export const MCP_TOOL_BUCKETS = [
|
|
||||||
0.005, 0.025, 0.1, 0.25, 0.5, 1, 2.5, 5, 10,
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the first SQL token (select/insert/update/delete/...) from a query,
|
* Extract the first SQL token (select/insert/update/delete/...) from a query,
|
||||||
@@ -80,30 +58,6 @@ export function firstSqlToken(sql: string | undefined): string {
|
|||||||
return KNOWN_SQL_TOKENS.has(token) ? token : 'other';
|
return KNOWN_SQL_TOKENS.has(token) ? token : 'other';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* #402 — bucket a document byte size into ONE of four fixed labels for the
|
|
||||||
* collab load/store histograms' `size_bucket` label. Using the raw byte count
|
|
||||||
* would be a continuous, unbounded label; four coarse buckets keep series
|
|
||||||
* cardinality bounded (each of collab_doc_load / collab_store gets ×4 series).
|
|
||||||
*
|
|
||||||
* The SAME function is shared by both the load and store paths so their buckets
|
|
||||||
* can never drift apart. Non-finite / negative sizes collapse to the smallest
|
|
||||||
* bucket ('lt64k') as a safe default (they can't be legitimately huge and we
|
|
||||||
* must never throw here — this runs on every store/load when metrics are on).
|
|
||||||
*/
|
|
||||||
// Module-const thresholds, built once (not per observe).
|
|
||||||
const SIZE_THRESHOLDS = { lt64k: 65536, lt256k: 262144, lt1m: 1048576 } as const;
|
|
||||||
|
|
||||||
export function sizeBucket(
|
|
||||||
bytes: number | undefined | null,
|
|
||||||
): 'lt64k' | 'lt256k' | 'lt1m' | 'ge1m' {
|
|
||||||
if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return 'lt64k';
|
|
||||||
if (bytes < SIZE_THRESHOLDS.lt64k) return 'lt64k';
|
|
||||||
if (bytes < SIZE_THRESHOLDS.lt256k) return 'lt256k';
|
|
||||||
if (bytes < SIZE_THRESHOLDS.lt1m) return 'lt1m';
|
|
||||||
return 'ge1m';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
|
* Whether an HTTP response must be EXCLUDED from http_request_duration_seconds.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
collectDefaultMetrics,
|
collectDefaultMetrics,
|
||||||
Counter,
|
|
||||||
Histogram,
|
Histogram,
|
||||||
Gauge,
|
Gauge,
|
||||||
Registry,
|
Registry,
|
||||||
@@ -10,21 +9,11 @@ import {
|
|||||||
DB_BUCKETS,
|
DB_BUCKETS,
|
||||||
HTTP_BUCKETS,
|
HTTP_BUCKETS,
|
||||||
JOB_BUCKETS,
|
JOB_BUCKETS,
|
||||||
MCP_TOOL_BUCKETS,
|
|
||||||
METRIC_BULLMQ_JOB_DURATION,
|
METRIC_BULLMQ_JOB_DURATION,
|
||||||
METRIC_BULLMQ_QUEUE_DEPTH,
|
METRIC_BULLMQ_QUEUE_DEPTH,
|
||||||
METRIC_COLLAB_AUTH_DURATION,
|
|
||||||
METRIC_COLLAB_CONNECT_DURATION,
|
|
||||||
METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL,
|
|
||||||
METRIC_COLLAB_DOC_LOADS_TOTAL,
|
|
||||||
METRIC_COLLAB_DOC_UNLOADS_TOTAL,
|
|
||||||
METRIC_COLLAB_DOCS_OPEN,
|
|
||||||
METRIC_COLLAB_LOAD_DURATION,
|
|
||||||
METRIC_COLLAB_STORE_DURATION,
|
METRIC_COLLAB_STORE_DURATION,
|
||||||
METRIC_DB_QUERY_DURATION,
|
METRIC_DB_QUERY_DURATION,
|
||||||
METRIC_HTTP_REQUEST_DURATION,
|
METRIC_HTTP_REQUEST_DURATION,
|
||||||
METRIC_MCP_TOOL_DURATION,
|
|
||||||
sizeBucket,
|
|
||||||
} from './metrics.constants';
|
} from './metrics.constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,24 +39,7 @@ let httpHist: Histogram<'method' | 'route' | 'status'> | null = null;
|
|||||||
let dbHist: Histogram<'op'> | null = null;
|
let dbHist: Histogram<'op'> | null = null;
|
||||||
let queueDepthGauge: Gauge<'queue'> | null = null;
|
let queueDepthGauge: Gauge<'queue'> | null = null;
|
||||||
let jobHist: Histogram<'queue'> | null = null;
|
let jobHist: Histogram<'queue'> | null = null;
|
||||||
// #402 — collab store now carries a size_bucket label (see sizeBucket()).
|
let collabHist: Histogram | null = null;
|
||||||
let collabHist: Histogram<'size_bucket'> | null = null;
|
|
||||||
// #402 collab-lifecycle + MCP instruments.
|
|
||||||
let collabLoadHist: Histogram<'size_bucket'> | null = null;
|
|
||||||
let docsOpenGauge: Gauge | null = null;
|
|
||||||
let docLoadsCounter: Counter | null = null;
|
|
||||||
let docUnloadsCounter: Counter | null = null;
|
|
||||||
let connectTimeoutsCounter: Counter | null = null;
|
|
||||||
let collabConnectHist: Histogram | null = null;
|
|
||||||
let collabAuthHist: Histogram | null = null;
|
|
||||||
let mcpToolHist: Histogram<'tool'> | null = null;
|
|
||||||
|
|
||||||
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
|
|
||||||
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
|
|
||||||
// pulls the authoritative live count from here on every scrape. Registered once,
|
|
||||||
// gated, by the collaboration gateway via registerDocsOpenSource(). Null until
|
|
||||||
// then → the collect() is a no-op.
|
|
||||||
let docsOpenSource: (() => number) | null = null;
|
|
||||||
|
|
||||||
function init(): void {
|
function init(): void {
|
||||||
if (registry || !enabled) return;
|
if (registry || !enabled) return;
|
||||||
@@ -110,71 +82,10 @@ function init(): void {
|
|||||||
|
|
||||||
collabHist = new Histogram({
|
collabHist = new Histogram({
|
||||||
name: METRIC_COLLAB_STORE_DURATION,
|
name: METRIC_COLLAB_STORE_DURATION,
|
||||||
help: 'Collaboration onStoreDocument duration in seconds, by document size bucket',
|
help: 'Collaboration onStoreDocument duration in seconds',
|
||||||
labelNames: ['size_bucket'],
|
|
||||||
buckets: COLLAB_BUCKETS,
|
buckets: COLLAB_BUCKETS,
|
||||||
registers: [registry],
|
registers: [registry],
|
||||||
});
|
});
|
||||||
|
|
||||||
collabLoadHist = new Histogram({
|
|
||||||
name: METRIC_COLLAB_LOAD_DURATION,
|
|
||||||
help: 'Collaboration onLoadDocument DB-load duration in seconds, by document size bucket',
|
|
||||||
labelNames: ['size_bucket'],
|
|
||||||
buckets: COLLAB_BUCKETS,
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
|
|
||||||
docsOpenGauge = new Gauge({
|
|
||||||
name: METRIC_COLLAB_DOCS_OPEN,
|
|
||||||
help: 'Number of collaboration documents currently open in memory',
|
|
||||||
registers: [registry],
|
|
||||||
// Read-on-scrape: pull the live count from the registered source (the
|
|
||||||
// hocuspocus instance) so the value can never drift. No-op until a source
|
|
||||||
// is registered.
|
|
||||||
collect() {
|
|
||||||
if (docsOpenSource) this.set(docsOpenSource());
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
docLoadsCounter = new Counter({
|
|
||||||
name: METRIC_COLLAB_DOC_LOADS_TOTAL,
|
|
||||||
help: 'Total collaboration documents loaded into memory',
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
|
|
||||||
docUnloadsCounter = new Counter({
|
|
||||||
name: METRIC_COLLAB_DOC_UNLOADS_TOTAL,
|
|
||||||
help: 'Total collaboration documents unloaded from memory',
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
|
|
||||||
connectTimeoutsCounter = new Counter({
|
|
||||||
name: METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL,
|
|
||||||
help: 'Total collaboration connection setup timeouts',
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
|
|
||||||
collabConnectHist = new Histogram({
|
|
||||||
name: METRIC_COLLAB_CONNECT_DURATION,
|
|
||||||
help: 'Collaboration connection acceptance duration in seconds (onConnect→connected)',
|
|
||||||
buckets: COLLAB_BUCKETS,
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
|
|
||||||
collabAuthHist = new Histogram({
|
|
||||||
name: METRIC_COLLAB_AUTH_DURATION,
|
|
||||||
help: 'Collaboration onAuthenticate duration in seconds',
|
|
||||||
buckets: COLLAB_BUCKETS,
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
|
|
||||||
mcpToolHist = new Histogram({
|
|
||||||
name: METRIC_MCP_TOOL_DURATION,
|
|
||||||
help: 'MCP tool-call duration in seconds, by tool name',
|
|
||||||
labelNames: ['tool'],
|
|
||||||
buckets: MCP_TOOL_BUCKETS,
|
|
||||||
registers: [registry],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs once when this module is first imported. Safe to call again (idempotent).
|
// Runs once when this module is first imported. Safe to call again (idempotent).
|
||||||
@@ -210,46 +121,6 @@ export function observeJobDuration(queue: string, seconds: number): void {
|
|||||||
jobHist?.observe({ queue }, seconds);
|
jobHist?.observe({ queue }, seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function observeCollabStore(bytes: number, seconds: number): void {
|
export function observeCollabStore(seconds: number): void {
|
||||||
collabHist?.observe({ size_bucket: sizeBucket(bytes) }, seconds);
|
collabHist?.observe(seconds);
|
||||||
}
|
|
||||||
|
|
||||||
export function observeCollabLoad(bytes: number, seconds: number): void {
|
|
||||||
collabLoadHist?.observe({ size_bucket: sizeBucket(bytes) }, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register the live open-document count source for the collab_docs_open gauge.
|
|
||||||
* Called ONCE, gated by isMetricsEnabled(), by the collaboration gateway. The
|
|
||||||
* gauge reads this on every scrape (collect()); nothing inc/dec's it.
|
|
||||||
*/
|
|
||||||
export function registerDocsOpenSource(fn: () => number): void {
|
|
||||||
docsOpenSource = fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function incDocLoad(): void {
|
|
||||||
docLoadsCounter?.inc();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function incDocUnload(): void {
|
|
||||||
docUnloadsCounter?.inc();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function incConnectTimeout(): void {
|
|
||||||
connectTimeoutsCounter?.inc();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function observeCollabConnect(seconds: number): void {
|
|
||||||
collabConnectHist?.observe(seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function observeCollabAuth(seconds: number): void {
|
|
||||||
collabAuthHist?.observe(seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function observeMcpTool(tool: string, seconds: number): void {
|
|
||||||
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
|
|
||||||
// guarantees it comes from the registered-tool set) — never free-form input —
|
|
||||||
// so this label stays low-cardinality with no 'other' bucketing needed.
|
|
||||||
mcpToolHist?.observe({ tool }, seconds);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,6 @@
|
|||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { resolveRouteLabel } from './http-metrics.hook';
|
import { resolveRouteLabel } from './http-metrics.hook';
|
||||||
import {
|
import { firstSqlToken, isStreamingResponse } from './metrics.constants';
|
||||||
firstSqlToken,
|
|
||||||
isStreamingResponse,
|
|
||||||
sizeBucket,
|
|
||||||
} from './metrics.constants';
|
|
||||||
import {
|
|
||||||
getMetricsRegistry,
|
|
||||||
incConnectTimeout,
|
|
||||||
incDocLoad,
|
|
||||||
incDocUnload,
|
|
||||||
isMetricsEnabled,
|
|
||||||
observeCollabAuth,
|
|
||||||
observeCollabConnect,
|
|
||||||
observeCollabLoad,
|
|
||||||
observeCollabStore,
|
|
||||||
observeMcpTool,
|
|
||||||
registerDocsOpenSource,
|
|
||||||
} from './metrics.registry';
|
|
||||||
|
|
||||||
describe('resolveRouteLabel (histogram route label)', () => {
|
describe('resolveRouteLabel (histogram route label)', () => {
|
||||||
it('uses the ROUTE TEMPLATE, never the raw URL', () => {
|
it('uses the ROUTE TEMPLATE, never the raw URL', () => {
|
||||||
@@ -140,67 +123,3 @@ describe('firstSqlToken (bounded db label)', () => {
|
|||||||
expect(firstSqlToken('vacuum analyze')).toBe('other');
|
expect(firstSqlToken('vacuum analyze')).toBe('other');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sizeBucket (#402 bounded size label)', () => {
|
|
||||||
it('maps sizes to the four fixed buckets at their boundaries', () => {
|
|
||||||
// Boundaries are exclusive-upper: <65536 → lt64k, etc.
|
|
||||||
expect(sizeBucket(0)).toBe('lt64k');
|
|
||||||
expect(sizeBucket(65535)).toBe('lt64k');
|
|
||||||
expect(sizeBucket(65536)).toBe('lt256k');
|
|
||||||
expect(sizeBucket(262143)).toBe('lt256k');
|
|
||||||
expect(sizeBucket(262144)).toBe('lt1m');
|
|
||||||
expect(sizeBucket(1048575)).toBe('lt1m');
|
|
||||||
expect(sizeBucket(1048576)).toBe('ge1m');
|
|
||||||
expect(sizeBucket(5_000_000)).toBe('ge1m');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the smallest bucket for invalid sizes', () => {
|
|
||||||
// Non-finite / negative / nullish collapse to the smallest, safe default.
|
|
||||||
expect(sizeBucket(-1)).toBe('lt64k');
|
|
||||||
expect(sizeBucket(NaN)).toBe('lt64k');
|
|
||||||
expect(sizeBucket(Infinity)).toBe('lt64k');
|
|
||||||
expect(sizeBucket(undefined)).toBe('lt64k');
|
|
||||||
expect(sizeBucket(null)).toBe('lt64k');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('only ever returns one of the four fixed labels (bounded cardinality)', () => {
|
|
||||||
const labels = new Set(
|
|
||||||
[0, 65536, 262144, 1048576, -5, NaN].map((b) => sizeBucket(b)),
|
|
||||||
);
|
|
||||||
for (const l of labels) {
|
|
||||||
expect(['lt64k', 'lt256k', 'lt1m', 'ge1m']).toContain(l);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
|
|
||||||
// These specs run without METRICS_PORT, so the registry is never created and
|
|
||||||
// every observe/inc/set helper must be a cheap `?.` no-op that never throws.
|
|
||||||
beforeAll(() => {
|
|
||||||
// Guard the contract this suite depends on: if a CI env set METRICS_PORT,
|
|
||||||
// the assertions below would be meaningless, so fail loudly instead.
|
|
||||||
expect(process.env.METRICS_PORT).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports metrics disabled and a null registry', () => {
|
|
||||||
expect(isMetricsEnabled()).toBe(false);
|
|
||||||
expect(getMetricsRegistry()).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not throw from any #402 collab/MCP helper', () => {
|
|
||||||
expect(() => {
|
|
||||||
observeCollabLoad(123456, 0.01);
|
|
||||||
observeCollabStore(123456, 0.02);
|
|
||||||
observeCollabConnect(0.03);
|
|
||||||
observeCollabAuth(0.04);
|
|
||||||
observeMcpTool('some-tool', 0.05);
|
|
||||||
incDocLoad();
|
|
||||||
incDocUnload();
|
|
||||||
incConnectTimeout();
|
|
||||||
// Registering a source must not create the gauge or invoke the fn.
|
|
||||||
registerDocsOpenSource(() => {
|
|
||||||
throw new Error('docsOpenSource must NOT be called when disabled');
|
|
||||||
});
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -21,24 +21,10 @@ import { recordHttpResponse } from './integrations/metrics/http-metrics.hook';
|
|||||||
import { startMetricsServer } from './integrations/metrics/metrics.server';
|
import { startMetricsServer } from './integrations/metrics/metrics.server';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
// Fastify JSON body cap. Fastify defaults to 1 MiB, which a long AI-chat
|
|
||||||
// research turn exceeds: the client resends the FULL message history (every
|
|
||||||
// tool call + search result) on each turn, so a deep conversation's POST to
|
|
||||||
// /api/ai-chat/stream can be several MB and would otherwise be rejected with
|
|
||||||
// FST_ERR_CTP_BODY_TOO_LARGE (413). Raise the cap; override with
|
|
||||||
// HTTP_JSON_BODY_LIMIT (bytes). A missing/invalid/non-positive value keeps the
|
|
||||||
// 25 MiB default. Multipart uploads are unaffected (their own @fastify/multipart
|
|
||||||
// limits apply); this only bounds JSON/urlencoded request bodies.
|
|
||||||
const bodyLimitEnv = Number(process.env.HTTP_JSON_BODY_LIMIT);
|
|
||||||
const bodyLimit =
|
|
||||||
Number.isFinite(bodyLimitEnv) && bodyLimitEnv > 0
|
|
||||||
? bodyLimitEnv
|
|
||||||
: 25 * 1024 * 1024;
|
|
||||||
const app = await NestFactory.create<NestFastifyApplication>(
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
AppModule,
|
AppModule,
|
||||||
new FastifyAdapter({
|
new FastifyAdapter({
|
||||||
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
|
trustProxy: resolveTrustProxy(process.env.TRUST_PROXY),
|
||||||
bodyLimit,
|
|
||||||
routerOptions: {
|
routerOptions: {
|
||||||
maxParamLength: 1000,
|
maxParamLength: 1000,
|
||||||
ignoreTrailingSlash: true,
|
ignoreTrailingSlash: true,
|
||||||
|
|||||||
@@ -1,564 +0,0 @@
|
|||||||
import * as http from 'node:http';
|
|
||||||
import { Kysely } from 'kysely';
|
|
||||||
import {
|
|
||||||
MockLanguageModelV3,
|
|
||||||
convertArrayToReadableStream,
|
|
||||||
} from 'ai/test';
|
|
||||||
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
|
|
||||||
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
|
|
||||||
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
|
|
||||||
import { AiChatService } from 'src/core/ai-chat/ai-chat.service';
|
|
||||||
import { AiChatRunService } from 'src/core/ai-chat/ai-chat-run.service';
|
|
||||||
import {
|
|
||||||
AiChatStreamRegistryService,
|
|
||||||
RunStreamCallbacks,
|
|
||||||
} from 'src/core/ai-chat/ai-chat-stream-registry.service';
|
|
||||||
import {
|
|
||||||
getTestDb,
|
|
||||||
destroyTestDb,
|
|
||||||
createWorkspace,
|
|
||||||
createUser,
|
|
||||||
createChat,
|
|
||||||
} from './db';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #184 phase 1.5 — the resumable transport end to end against REAL Postgres,
|
|
||||||
* the REAL `streamText` (seeded via MockLanguageModelV3) and a REAL Node
|
|
||||||
* ServerResponse, driving the REAL `AiChatService.stream` run-wrapped path with a
|
|
||||||
* REAL `AiChatStreamRegistryService`. The run-hooks mirror the controller: they
|
|
||||||
* begin a durable run and `open()` the registry entry at begin, and the service
|
|
||||||
* tees the SSE frames into it via `consumeSseStream` while stamping the DB row id
|
|
||||||
* via `generateMessageId` (both gated on runId + the resumable flag).
|
|
||||||
*
|
|
||||||
* Proven here: a finished run's replay is the full frame sequence incl `[DONE]`
|
|
||||||
* with `start.messageId` == the seeded DB row id; the anchor check (invariant 6);
|
|
||||||
* an attach opened BEFORE the first frame follows the live stream from frame 0; an
|
|
||||||
* explicit stop surfaces `{"type":"abort"}` + `[DONE]` + end to the subscriber;
|
|
||||||
* and the legacy (non-run) path tees nothing.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
||||||
|
|
||||||
async function waitFor(
|
|
||||||
cond: () => Promise<boolean> | boolean,
|
|
||||||
{ timeoutMs = 15_000, stepMs = 25 } = {},
|
|
||||||
): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < timeoutMs) {
|
|
||||||
if (await cond()) return;
|
|
||||||
await sleep(stepMs);
|
|
||||||
}
|
|
||||||
throw new Error('waitFor: condition not met within timeout');
|
|
||||||
}
|
|
||||||
|
|
||||||
// A real Node ServerResponse wired to a live socket (as in the stream int-spec).
|
|
||||||
function makeRealResponse(): Promise<{
|
|
||||||
res: http.ServerResponse;
|
|
||||||
cleanup: () => Promise<void>;
|
|
||||||
}> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const server = http.createServer((_req, res) => {
|
|
||||||
resolve({
|
|
||||||
res,
|
|
||||||
cleanup: () =>
|
|
||||||
new Promise<void>((done) => {
|
|
||||||
try {
|
|
||||||
if (!res.writableEnded) res.end();
|
|
||||||
} catch {
|
|
||||||
/* socket already gone */
|
|
||||||
}
|
|
||||||
server.close(() => done());
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
server.listen(0, () => {
|
|
||||||
const port = (server.address() as any).port;
|
|
||||||
const creq = http.request({ port, method: 'GET' }, (cres) => {
|
|
||||||
cres.resume();
|
|
||||||
});
|
|
||||||
creq.on('error', () => undefined);
|
|
||||||
creq.end();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// A full, successful single-step turn.
|
|
||||||
function successStream() {
|
|
||||||
return convertArrayToReadableStream([
|
|
||||||
{ type: 'stream-start', warnings: [] },
|
|
||||||
{ type: 'text-start', id: 't1' },
|
|
||||||
{ type: 'text-delta', id: 't1', delta: 'Hello' },
|
|
||||||
{ type: 'text-delta', id: 't1', delta: ' there' },
|
|
||||||
{ type: 'text-end', id: 't1' },
|
|
||||||
{
|
|
||||||
type: 'finish',
|
|
||||||
finishReason: 'stop',
|
|
||||||
usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 },
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A stream the test feeds chunk by chunk (to attach mid-flight / drive a stop).
|
|
||||||
function makeControlledStream() {
|
|
||||||
let controller!: ReadableStreamDefaultController<any>;
|
|
||||||
const stream = new ReadableStream<any>({
|
|
||||||
start(c) {
|
|
||||||
controller = c;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
stream,
|
|
||||||
emit: (chunk: any) => controller.enqueue(chunk),
|
|
||||||
close: () => controller.close(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect replay + live frames from an attachment.
|
|
||||||
function liveSink(): {
|
|
||||||
cb: RunStreamCallbacks;
|
|
||||||
frames: string[];
|
|
||||||
ended: () => boolean;
|
|
||||||
} {
|
|
||||||
const frames: string[] = [];
|
|
||||||
let ended = false;
|
|
||||||
return {
|
|
||||||
frames,
|
|
||||||
ended: () => ended,
|
|
||||||
cb: {
|
|
||||||
onFrame: (f) => frames.push(f),
|
|
||||||
onEnd: () => {
|
|
||||||
ended = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// The SSE `start` frame carries the message id; pull it out of a `data: {...}`.
|
|
||||||
function parseStartMessageId(frames: string[]): string | undefined {
|
|
||||||
for (const f of frames) {
|
|
||||||
const m = /^data: (\{.*\})\s*$/m.exec(f.trim());
|
|
||||||
if (!m) continue;
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(m[1]);
|
|
||||||
if (json.type === 'start') return json.messageId;
|
|
||||||
} catch {
|
|
||||||
/* not this frame */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AiChatService run-stream attach [integration]', () => {
|
|
||||||
let db: Kysely<any>;
|
|
||||||
let aiChatRepo: AiChatRepo;
|
|
||||||
let msgRepo: AiChatMessageRepo;
|
|
||||||
let runRepo: AiChatRunRepo;
|
|
||||||
let workspaceId: string;
|
|
||||||
let userId: string;
|
|
||||||
|
|
||||||
const mcpClients = {
|
|
||||||
toolsFor: async () => ({
|
|
||||||
tools: {},
|
|
||||||
clients: [],
|
|
||||||
outcomes: [],
|
|
||||||
instructions: [],
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build the service with the run-stream registry wired and the resumable flag
|
|
||||||
// ON (the property under test). Deferred tools OFF (irrelevant here).
|
|
||||||
function buildService(registry: AiChatStreamRegistryService): AiChatService {
|
|
||||||
return new AiChatService(
|
|
||||||
{ getChatModel: async () => null } as any,
|
|
||||||
aiChatRepo,
|
|
||||||
msgRepo,
|
|
||||||
{} as any,
|
|
||||||
{ resolve: async () => null } as any,
|
|
||||||
{ forUser: async () => ({}) } as any,
|
|
||||||
mcpClients as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{
|
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
|
||||||
isAiChatResumableStreamEnabled: () => true,
|
|
||||||
} as any,
|
|
||||||
registry,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run-hooks mirroring the controller: begin the durable run AND open() the
|
|
||||||
// registry entry at begin. Captures the runId so a test can stop it.
|
|
||||||
function makeRunHooks(
|
|
||||||
runService: AiChatRunService,
|
|
||||||
registry: AiChatStreamRegistryService,
|
|
||||||
box: { runId?: string },
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
begin: async (chatId: string) => {
|
|
||||||
const handle = await runService.beginRun({
|
|
||||||
chatId,
|
|
||||||
workspaceId,
|
|
||||||
userId,
|
|
||||||
trigger: 'user',
|
|
||||||
});
|
|
||||||
box.runId = handle.runId;
|
|
||||||
registry.open(chatId, handle.runId);
|
|
||||||
return handle;
|
|
||||||
},
|
|
||||||
onAssistantSeeded: (runId: string, messageId: string) =>
|
|
||||||
runService.linkAssistantMessage(runId, workspaceId, messageId),
|
|
||||||
onStep: (runId: string, n: number) =>
|
|
||||||
void runService.recordStep(runId, workspaceId, n),
|
|
||||||
onSettled: (runId: string, status: any, error?: string) =>
|
|
||||||
runService.finalizeRun(runId, workspaceId, status, error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function userUiMessage(text: string) {
|
|
||||||
return {
|
|
||||||
id: `u-${Math.random()}`,
|
|
||||||
role: 'user',
|
|
||||||
parts: [{ type: 'text', text }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startRun(opts: {
|
|
||||||
registry: AiChatStreamRegistryService;
|
|
||||||
runService?: AiChatRunService;
|
|
||||||
model: MockLanguageModelV3;
|
|
||||||
chatId: string;
|
|
||||||
body: any;
|
|
||||||
box?: { runId?: string };
|
|
||||||
}): Promise<{ res: http.ServerResponse; cleanup: () => Promise<void> }> {
|
|
||||||
const service = buildService(opts.registry);
|
|
||||||
const { res, cleanup } = await makeRealResponse();
|
|
||||||
const runHooks = opts.runService
|
|
||||||
? makeRunHooks(opts.runService, opts.registry, opts.box ?? {})
|
|
||||||
: undefined;
|
|
||||||
await service.stream({
|
|
||||||
user: { id: userId, workspaceId } as any,
|
|
||||||
workspace: { id: workspaceId, name: 'WS' } as any,
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
body: opts.body,
|
|
||||||
res: { raw: res } as any,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
model: opts.model as any,
|
|
||||||
role: null,
|
|
||||||
runHooks,
|
|
||||||
} as any);
|
|
||||||
return { res, cleanup };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assistantRowId(chatId: string): Promise<string> {
|
|
||||||
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
|
||||||
const row = rows.find((r: any) => r.role === 'assistant');
|
|
||||||
return row!.id as string;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
db = getTestDb();
|
|
||||||
aiChatRepo = new AiChatRepo(db as any);
|
|
||||||
msgRepo = new AiChatMessageRepo(db as any);
|
|
||||||
runRepo = new AiChatRunRepo(db as any);
|
|
||||||
workspaceId = (await createWorkspace(db)).id;
|
|
||||||
userId = (await createUser(db, workspaceId)).id;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await destroyTestDb();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('run-wrapped: replay is the full frame sequence incl [DONE], start.messageId == the seeded DB row id', async () => {
|
|
||||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
|
||||||
const registry = new AiChatStreamRegistryService();
|
|
||||||
const runService = new AiChatRunService(runRepo, {
|
|
||||||
isCloud: () => false,
|
|
||||||
} as never);
|
|
||||||
const model = new MockLanguageModelV3({
|
|
||||||
doStream: async () => ({ stream: successStream() }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const box: { runId?: string } = {};
|
|
||||||
const { cleanup } = await startRun({
|
|
||||||
registry,
|
|
||||||
runService,
|
|
||||||
model,
|
|
||||||
chatId,
|
|
||||||
body: { chatId, messages: [userUiMessage('Hi')] },
|
|
||||||
box,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
// Wait for the assistant row to settle (terminal callbacks run async).
|
|
||||||
await waitFor(async () => {
|
|
||||||
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
|
||||||
return rows.some(
|
|
||||||
(r: any) =>
|
|
||||||
r.role === 'assistant' &&
|
|
||||||
['completed', 'error', 'aborted'].includes(r.status),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const rowId = await assistantRowId(chatId);
|
|
||||||
|
|
||||||
// Finished-run replay with expect=live + the correct anchor.
|
|
||||||
const sink = liveSink();
|
|
||||||
const att = await registry.attach(chatId, true, rowId, sink.cb);
|
|
||||||
expect(att).not.toBeNull();
|
|
||||||
expect(att!.finished).toBe(true);
|
|
||||||
// The tee captured frames (consumeSseStream was wired).
|
|
||||||
expect(att!.replay.length).toBeGreaterThan(0);
|
|
||||||
// generateMessageId stamped the DB row id onto the streamed start frame.
|
|
||||||
expect(parseStartMessageId(att!.replay)).toBe(rowId);
|
|
||||||
// The full sequence includes the streamed text and the terminal marker.
|
|
||||||
const joined = att!.replay.join('');
|
|
||||||
expect(joined).toContain('Hello');
|
|
||||||
expect(att!.replay.some((f) => f.includes('[DONE]'))).toBe(true);
|
|
||||||
} finally {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('anchor mismatch with expect=live returns null (invariant 6)', async () => {
|
|
||||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
|
||||||
const registry = new AiChatStreamRegistryService();
|
|
||||||
const runService = new AiChatRunService(runRepo, {
|
|
||||||
isCloud: () => false,
|
|
||||||
} as never);
|
|
||||||
const model = new MockLanguageModelV3({
|
|
||||||
doStream: async () => ({ stream: successStream() }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const { cleanup } = await startRun({
|
|
||||||
registry,
|
|
||||||
runService,
|
|
||||||
model,
|
|
||||||
chatId,
|
|
||||||
body: { chatId, messages: [userUiMessage('Hi')] },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await waitFor(async () => {
|
|
||||||
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
|
||||||
return rows.some(
|
|
||||||
(r: any) => r.role === 'assistant' && r.status === 'completed',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const sink = liveSink();
|
|
||||||
// A foreign anchor must NOT replay this run's transcript.
|
|
||||||
expect(
|
|
||||||
await registry.attach(chatId, true, 'a-different-run-row', sink.cb),
|
|
||||||
).toBeNull();
|
|
||||||
} finally {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('an attach opened BEFORE the first frame follows the live stream from frame 0', async () => {
|
|
||||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
|
||||||
const registry = new AiChatStreamRegistryService();
|
|
||||||
const runService = new AiChatRunService(runRepo, {
|
|
||||||
isCloud: () => false,
|
|
||||||
} as never);
|
|
||||||
const controlled = makeControlledStream();
|
|
||||||
const model = new MockLanguageModelV3({
|
|
||||||
doStream: async () => ({ stream: controlled.stream }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const { cleanup } = await startRun({
|
|
||||||
registry,
|
|
||||||
runService,
|
|
||||||
model,
|
|
||||||
chatId,
|
|
||||||
body: { chatId, messages: [userUiMessage('Slow please')] },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
// Attach while the entry exists (opened at begin) but before any frame.
|
|
||||||
const sink = liveSink();
|
|
||||||
const att = (await registry.attach(chatId, false, undefined, sink.cb))!;
|
|
||||||
expect(att.replay).toEqual([]); // nothing streamed yet -> replay from 0
|
|
||||||
att.start(); // go live (drains nothing, then follows)
|
|
||||||
|
|
||||||
// Now emit the whole turn.
|
|
||||||
controlled.emit({ type: 'stream-start', warnings: [] });
|
|
||||||
controlled.emit({ type: 'text-start', id: 't1' });
|
|
||||||
controlled.emit({ type: 'text-delta', id: 't1', delta: 'Zero' });
|
|
||||||
controlled.emit({ type: 'text-end', id: 't1' });
|
|
||||||
controlled.emit({
|
|
||||||
type: 'finish',
|
|
||||||
finishReason: 'stop',
|
|
||||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
||||||
});
|
|
||||||
controlled.close();
|
|
||||||
|
|
||||||
await waitFor(() => sink.frames.some((f) => f.includes('[DONE]')));
|
|
||||||
// The subscriber saw the stream from the very first frame (`start`) through
|
|
||||||
// the terminal marker, with the streamed text present.
|
|
||||||
expect(sink.frames.some((f) => f.includes('"type":"start"'))).toBe(true);
|
|
||||||
expect(sink.frames.join('')).toContain('Zero');
|
|
||||||
expect(sink.frames[sink.frames.length - 1]).toContain('[DONE]');
|
|
||||||
expect(sink.ended()).toBe(true);
|
|
||||||
} finally {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requestStop surfaces {"type":"abort"} + [DONE] + end to the attached subscriber', async () => {
|
|
||||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
|
||||||
const registry = new AiChatStreamRegistryService();
|
|
||||||
const runService = new AiChatRunService(runRepo, {
|
|
||||||
isCloud: () => false,
|
|
||||||
} as never);
|
|
||||||
// An abort-AWARE model: it streams some partial output, then errors the model
|
|
||||||
// stream with an AbortError when the run signal aborts — exactly as a real
|
|
||||||
// provider network stream is torn down on abort (a plain in-memory stream
|
|
||||||
// would just stall, so streamText would never observe the stop).
|
|
||||||
const model = new MockLanguageModelV3({
|
|
||||||
doStream: async ({ abortSignal }: any) => {
|
|
||||||
const stream = new ReadableStream<any>({
|
|
||||||
start(controller) {
|
|
||||||
controller.enqueue({ type: 'stream-start', warnings: [] });
|
|
||||||
controller.enqueue({ type: 'text-start', id: 't1' });
|
|
||||||
controller.enqueue({ type: 'text-delta', id: 't1', delta: 'partial' });
|
|
||||||
abortSignal?.addEventListener('abort', () => {
|
|
||||||
try {
|
|
||||||
controller.error(
|
|
||||||
new DOMException('Aborted', 'AbortError'),
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
/* already errored/closed */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return { stream };
|
|
||||||
},
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
const box: { runId?: string } = {};
|
|
||||||
const { cleanup } = await startRun({
|
|
||||||
registry,
|
|
||||||
runService,
|
|
||||||
model,
|
|
||||||
chatId,
|
|
||||||
body: { chatId, messages: [userUiMessage('Start then stop')] },
|
|
||||||
box,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const sink = liveSink();
|
|
||||||
const att = (await registry.attach(chatId, false, undefined, sink.cb))!;
|
|
||||||
att.start();
|
|
||||||
|
|
||||||
// Give streamText a beat to begin consuming the partial output.
|
|
||||||
await sleep(250);
|
|
||||||
// User presses Stop -> the run signal aborts -> the SDK emits an abort chunk.
|
|
||||||
await runService.requestStop(box.runId!, workspaceId);
|
|
||||||
|
|
||||||
await waitFor(() => sink.ended());
|
|
||||||
expect(sink.frames.some((f) => f.includes('"type":"abort"'))).toBe(true);
|
|
||||||
expect(sink.frames.some((f) => f.includes('[DONE]'))).toBe(true);
|
|
||||||
expect(sink.ended()).toBe(true);
|
|
||||||
} finally {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('the outer catch calls abortEntry so an open entry is released (finished)', async () => {
|
|
||||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
|
||||||
const registry = new AiChatStreamRegistryService();
|
|
||||||
const runService = new AiChatRunService(runRepo, {
|
|
||||||
isCloud: () => false,
|
|
||||||
} as never);
|
|
||||||
const model = new MockLanguageModelV3({
|
|
||||||
doStream: async () => ({ stream: successStream() }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// A msgRepo whose user-row insert throws: the turn fails AFTER begin (the
|
|
||||||
// entry is already open) but BEFORE the pipe, exercising the outer catch.
|
|
||||||
const throwingMsgRepo = {
|
|
||||||
insert: async () => {
|
|
||||||
throw new Error('db boom');
|
|
||||||
},
|
|
||||||
findAllByChat: (...a: any[]) => (msgRepo as any).findAllByChat(...a),
|
|
||||||
update: (...a: any[]) => (msgRepo as any).update(...a),
|
|
||||||
findById: (...a: any[]) => (msgRepo as any).findById(...a),
|
|
||||||
};
|
|
||||||
const service = new AiChatService(
|
|
||||||
{ getChatModel: async () => null } as any,
|
|
||||||
aiChatRepo,
|
|
||||||
throwingMsgRepo as any,
|
|
||||||
{} as any,
|
|
||||||
{ resolve: async () => null } as any,
|
|
||||||
{ forUser: async () => ({}) } as any,
|
|
||||||
mcpClients as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{} as any,
|
|
||||||
{
|
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
|
||||||
isAiChatResumableStreamEnabled: () => true,
|
|
||||||
} as any,
|
|
||||||
registry,
|
|
||||||
);
|
|
||||||
const { res, cleanup } = await makeRealResponse();
|
|
||||||
const box: { runId?: string } = {};
|
|
||||||
try {
|
|
||||||
await expect(
|
|
||||||
service.stream({
|
|
||||||
user: { id: userId, workspaceId } as any,
|
|
||||||
workspace: { id: workspaceId, name: 'WS' } as any,
|
|
||||||
sessionId: 'sess-1',
|
|
||||||
body: { chatId, messages: [userUiMessage('will throw')] },
|
|
||||||
res: { raw: res } as any,
|
|
||||||
signal: new AbortController().signal,
|
|
||||||
model: model as any,
|
|
||||||
role: null,
|
|
||||||
runHooks: makeRunHooks(runService, registry, box),
|
|
||||||
} as any),
|
|
||||||
).rejects.toThrow();
|
|
||||||
// The entry opened at begin was terminated by abortEntry (from the catch),
|
|
||||||
// so it is finished and a plain attach returns null instead of hanging.
|
|
||||||
const entry = (registry as any).entries.get(chatId);
|
|
||||||
expect(entry).toBeDefined();
|
|
||||||
expect(entry.finished).toBe(true);
|
|
||||||
const sink = liveSink();
|
|
||||||
expect(await registry.attach(chatId, false, undefined, sink.cb)).toBeNull();
|
|
||||||
} finally {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('legacy (no run-hooks): the registry is never populated', async () => {
|
|
||||||
const chatId = (await createChat(db, { workspaceId, creatorId: userId })).id;
|
|
||||||
const registry = new AiChatStreamRegistryService();
|
|
||||||
const model = new MockLanguageModelV3({
|
|
||||||
doStream: async () => ({ stream: successStream() }),
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// No runHooks -> runId undefined -> the run-wrapped tee is never wired.
|
|
||||||
const { cleanup } = await startRun({
|
|
||||||
registry,
|
|
||||||
model,
|
|
||||||
chatId,
|
|
||||||
body: { chatId, messages: [userUiMessage('Legacy hi')] },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await waitFor(async () => {
|
|
||||||
const rows = await msgRepo.findAllByChat(chatId, workspaceId);
|
|
||||||
return rows.some(
|
|
||||||
(r: any) => r.role === 'assistant' && r.status === 'completed',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
const sink = liveSink();
|
|
||||||
// No entry was ever opened; attach always yields null.
|
|
||||||
expect(await registry.attach(chatId, false, undefined, sink.cb)).toBeNull();
|
|
||||||
expect(await registry.attach(chatId, true, 'anything', sink.cb)).toBeNull();
|
|
||||||
} finally {
|
|
||||||
registry.onModuleDestroy();
|
|
||||||
await cleanup();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testTimeout": 60000,
|
"testTimeout": 60000,
|
||||||
"maxWorkers": 1,
|
"maxWorkers": 1,
|
||||||
|
"forceExit": true,
|
||||||
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
||||||
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
|||||||
@@ -1,520 +0,0 @@
|
|||||||
# Фича «Время работы над статьёй» — дизайн-документ
|
|
||||||
|
|
||||||
Статус: черновик проектирования (код не пишется).
|
|
||||||
Контекст: gitmost (форк Docmost). Зависит от PR #370 / PR #374 (типизированная история страниц).
|
|
||||||
|
|
||||||
## 1. Цель и не-цели
|
|
||||||
|
|
||||||
**Цель.** Показывать в UI страницы одно число — оценку времени, реально затраченного
|
|
||||||
на работу над статьёй, собранную из истории правок. Число должно быть устойчиво к
|
|
||||||
паузам: «в 21:00 одна правка, в 09:00 вторая» не должно превращаться в «работал 12 часов».
|
|
||||||
По клику на число — раскрытие в **суточный таймлайн**: строка-день = 24-часовая дорожка с
|
|
||||||
окнами активности и суммой за день (см. §6.2).
|
|
||||||
|
|
||||||
**Явно не-цели.**
|
|
||||||
- Это НЕ инструмент для агента (не MCP-tool). Это число, отображаемое человеку в UI.
|
|
||||||
- Не хронометраж с точностью до минуты — это заведомо оценка.
|
|
||||||
- Не биллинг/тайм-трекинг сотрудников; не изменение долговечности черновика.
|
|
||||||
|
|
||||||
## 2. Проблема
|
|
||||||
|
|
||||||
История страницы в Docmost — таблица `page_history`: снимок на каждое сохранение.
|
|
||||||
У снимка есть `createdAt`, автор (`lastUpdatedById`), тип источника
|
|
||||||
(`lastUpdatedSource`: `user`/`agent`/`git`) и группировка агентских правок
|
|
||||||
(`lastUpdatedAiChatId`).
|
|
||||||
|
|
||||||
Наивная оценка `max(createdAt) − min(createdAt)` завышает в разы: между крайними
|
|
||||||
правками лежат сон, обед, дни простоя. На реальных данных статьи-примера
|
|
||||||
(первая страница истории, 20 снимков) span между крайними снимками ≈ 60 часов,
|
|
||||||
тогда как реальной работы — пара часов в 5–6 коротких заходов.
|
|
||||||
|
|
||||||
Правильная постановка: **длительность — это не span между крайними правками, а сумма
|
|
||||||
интервалов внутри «сессий»; историю режем по паузам бездействия.** Это классическая
|
|
||||||
*сессионизация по таймауту неактивности* (WakaTime / RescueTime / веб-аналитика).
|
|
||||||
|
|
||||||
## 3. Почему PR #374 — фундамент фичи
|
|
||||||
|
|
||||||
До #374 у сессионизации слепое пятно: если человек час пишет подряд и не жмёт «Сохранить»,
|
|
||||||
в `page_history` за этот час почти нет строк (тяжёлые автосейвы ydoc идут в `pages`/`ydoc`,
|
|
||||||
а не в историю). Непрерывную работу нечем измерить.
|
|
||||||
|
|
||||||
PR #374 вводит типизированную историю через колонку `page_history.kind`:
|
|
||||||
|
|
||||||
| `kind` | что означает | ценность для фичи |
|
|
||||||
|------------|------------------------------------------------|----------------------------------------------------|
|
|
||||||
| `manual` | человек нажал Save | сильный маркер активной работы человека |
|
|
||||||
| `agent` | снимок правки агентом | машинное время агента (группируется по `aiChatId`) |
|
|
||||||
| `idle` | автоснимок idle-флеша (потолок ~`maxWait`) | регулярный «пульс» непрерывной работы (~≤10м, §3) |
|
|
||||||
| `boundary` | автоснимок на переходе актора (user↔agent↔git) | бесплатная разметка «кто работал в этом сегменте» |
|
|
||||||
| `null` | легаси-автосейв (старые страницы) | обычный сэмпл активности |
|
|
||||||
|
|
||||||
Два env-параметра #374 напрямую задают качество измерения:
|
|
||||||
|
|
||||||
- **`IDLE_MAX_WAIT_USER=10м` / `AGENT=5м` (потолок ожидания)** — определяющий параметр.
|
|
||||||
Проверено по `computeHistoryJob` (`persistence.extension.ts`):
|
|
||||||
`delay = max(0, min(interval, burstStart + maxWait − now))`, а `enqueuePageHistory`
|
|
||||||
сбрасывает `burstStart` каждые `maxWait`. Так как `maxWait` (10м/5м) < `interval` (60м/15м),
|
|
||||||
**потолок всегда доминирует**: во время непрерывной работы `idle`-снимок форсится каждые
|
|
||||||
~`maxWait`, давая регулярный «пульс активности» с шагом ≤10 мин (user) / ≤5 мин (agent).
|
|
||||||
- **`IDLE_INTERVAL_USER=60м` / `AGENT=15м`** — номинальный трейлинг-интервал, который потолок
|
|
||||||
ожидания на практике всегда упреждает. Поэтому метка любого `idle`-снимка отстоит от
|
|
||||||
реальной правки не более чем на ~`maxWait` (≤10м user / ≤5м agent), а **не** на 60 мин.
|
|
||||||
(Это ключевой факт для точности: `idle`-метки — достоверный сигнал активности, не «хвост».)
|
|
||||||
|
|
||||||
Вывод: ядро алгоритма работает и на голых `createdAt`+`lastUpdatedSource` (есть с миграции
|
|
||||||
`20260616T130000-agent-provenance`), поэтому фича считает и на легаси-страницах — просто
|
|
||||||
грубее. После #374 (пульс ≤10 мин + типы) — точно. Жёсткой блокировки на мёрж #374 нет,
|
|
||||||
но полноценная точность появляется вместе с ним.
|
|
||||||
|
|
||||||
## 4. Входной сигнал
|
|
||||||
|
|
||||||
Дешёвый проекционный запрос по `page_history` (без тяжёлой колонки `content`): на строку —
|
|
||||||
`createdAt`, `lastUpdatedById`, `lastUpdatedSource`, `lastUpdatedAiChatId`, `kind`.
|
|
||||||
Все поля (включая `kind`) уже в `PageHistoryRepo.baseFields` после #374 — схему трогать не
|
|
||||||
нужно, `findTimelineByPageId` это лишь лёгкая проекция без `content`.
|
|
||||||
|
|
||||||
**Важный факт об авторстве (проверено по `persistence.extension.ts` → `updatePage`):**
|
|
||||||
`page_history.lastUpdatedById` — это ВСЕГДА ответственный человек, даже для агентских снимков
|
|
||||||
(«human stays the responsible author»). Признак «человек vs агент» живёт в
|
|
||||||
`lastUpdatedSource` (`user`/`agent`/`git`) и `lastUpdatedAiChatId`, а НЕ в `lastUpdatedById`.
|
|
||||||
Отсюда: разделять человеко-/агенто-время нужно по `lastUpdatedSource`, а атрибутировать
|
|
||||||
конкретному человеку — по `lastUpdatedById`/`contributorIds`.
|
|
||||||
|
|
||||||
## 5. Алгоритм: сессионизация по паузам
|
|
||||||
|
|
||||||
Параметры (env, в стиле #374; полный список — §10):
|
|
||||||
|
|
||||||
- **`T_gap`** — таймаут неактивности: пауза между соседними сэмплами `≤ T_gap` = непрерывная
|
|
||||||
работа, больше = перерыв (в зачёт не идёт).
|
|
||||||
- **`P_in` / `P_out`** — добивка МНОГОсэмпловой сессии (работа началась до первого и продолжалась
|
|
||||||
после последнего сохранения).
|
|
||||||
- **`P_single`** — блок ОДИНОЧНОЙ сессии (один сэмпл, соседей в пределах `T_gap` нет). Мал
|
|
||||||
(дефолт ~2 мин): один автосейв/idle-пульс — это «была правка», но приписывать ему полный
|
|
||||||
`P_in+P_out` = выдумывать время. НЕ путать с многосэмпловой добивкой.
|
|
||||||
|
|
||||||
Псевдокод (ОДИН проход по ВСЕМ сэмплам страницы; класс определяется у ГОТОВОЙ сессии — §5.1):
|
|
||||||
|
|
||||||
```text
|
|
||||||
samples = ВСЕ history rows страницы, projected, sorted by createdAt ASC
|
|
||||||
(все типы kind — сэмплы активности; idle — основной «пульс» непрерывной работы §3, НЕ исключается)
|
|
||||||
|
|
||||||
# коллапс агентских всплесков (§5.1)
|
|
||||||
collapse: подряд идущие сэмплы ОДНОГО aiChatId с source=agent → сегмент {t_start, t_end, source:'agent'}.
|
|
||||||
Разрывает всплеск любой сэмпл, НЕ продолжающий тот же aiChatId-агент: source≠agent,
|
|
||||||
boundary-переход, ИЛИ иной aiChatId — в т.ч. idle/boundary с ДРУГИМ aiChatId = НОВЫЙ ран,
|
|
||||||
склеивать НЕЛЬЗЯ (иначе простой между двумя ИИ-ранами засчитается агенту). idle с ТЕМ ЖЕ
|
|
||||||
(или null) aiChatId — продолжает сегмент. Дальше сегмент участвует в цикле как один «сэмпл» с
|
|
||||||
.t_start/.t_end/.source (source='agent'); у скалярного сэмпла t_start == t_end == t.
|
|
||||||
|
|
||||||
gap_threshold(a, b) = (a и b оба source=agent) ? agentTGap : T_gap # порог зависит от ПАРЫ (§10)
|
|
||||||
|
|
||||||
# сессионизация по паузам — ОДИН проход по ВСЕМ сэмплам (не по парам, не отдельно по классам)
|
|
||||||
sessions = []; cur = null
|
|
||||||
for s in samples: # s — скаляр или коллапс-сегмент
|
|
||||||
if cur == null: cur = { first: s, last: s, samples: [s] }
|
|
||||||
elif s.t_start − cur.last.t_end ≤ gap_threshold(cur.last, s):
|
|
||||||
cur.last = s; cur.samples.push(s) # непрерывная работа
|
|
||||||
else: sessions.push(cur); cur = { first: s, last: s, samples: [s] }
|
|
||||||
if cur != null: sessions.push(cur) # ОБЯЗАТЕЛЬНО закрыть последнюю (иначе теряется)
|
|
||||||
|
|
||||||
# класс и интервал каждой сессии
|
|
||||||
for sess in sessions:
|
|
||||||
sess.class = sess.samples.every(is_agent) ? 'agent_only' : 'work' # §5.1 (по source сэмплов)
|
|
||||||
sess.iv = (sess.first == sess.last && sess.first.t_start == sess.first.t_end)
|
|
||||||
? [ sess.t − P_single, sess.t ] # одиночный скаляр: pre-roll (без «будущей» работы)
|
|
||||||
: [ sess.first.t_start − P_in, sess.last.t_end + P_out ] # многосэмпловая / лон-сегмент
|
|
||||||
|
|
||||||
workMs = duration( union( sess.iv : sess.class=='work' ) ) # union внутри класса
|
|
||||||
agentOnlyMs = duration( union( sess.iv : sess.class=='agent_only' ) )
|
|
||||||
```
|
|
||||||
|
|
||||||
Ключевые свойства:
|
|
||||||
|
|
||||||
- **Один проход, потом классификация → метрики дизъюнктны.** Сессии не перекрываются (разделены
|
|
||||||
гэпами > порога), каждая — ровно одного класса. Человек и агент, правящие ОДНОВРЕМЕННО, попадают
|
|
||||||
в одну сессию класса `work` (надзор засчитан человеку, не дважды). `work`- и `agent_only`-интервалы
|
|
||||||
не пересекаются по wall-clock и `workMs + agentOnlyMs ≤ реально прошедшего` — **при рекомендованном
|
|
||||||
`T_gap ≥ P_in+P_out` (§10)**: иначе `P`-добивка соседних сессий РАЗНЫХ классов может пересечься, и
|
|
||||||
пересечение попадёт в обе метрики. Инвариант §6.3 (`Σ perDay == work`) от этого НЕ зависит.
|
|
||||||
- **Закрытие последней сессии обязательно** (иначе теряется самая свежая): `n=1` → одна одиночная
|
|
||||||
сессия `P_single`, `n=0` → 0.
|
|
||||||
- **`union`, а не `Σ`.** Перекрытия (соседние ближе `P`; одновременное соредактирование) не
|
|
||||||
задваиваются. Отсюда `Σ perDay == work` держится сам собой (§6.3) — без «клампа» и без условия
|
|
||||||
`T_gap ≥ P`.
|
|
||||||
- **Калибровка `T_gap` по «пульсу» #374 (важно).** После #374 непрерывная работа гарантированно
|
|
||||||
оставляет history-строку не реже ~`maxWait` (idle-пульс §3, гейт `isDeepStrictEqual`). Значит гэп
|
|
||||||
между соседними строками БОЛЬШЕ ~`maxWait` содержит участок без изменений контента = (частичное)
|
|
||||||
бездействие — даже между двумя `manual`. Поэтому `T_gap` калибруется ОТ `maxWait` (реком. ~15м
|
|
||||||
user / ~7м agent), а не ставится вольно: прежние «30 мин» переоценивали не-пульсирующие паузы.
|
|
||||||
Гэп `≤ T_gap ≈ maxWait` ПОДКРЕПЛЁН пульсом — это и оправдывает счёт его как работы. Легаси-страницы
|
|
||||||
до #374 пульса не имеют → там `T_gap` вынужденно шире и оценка грубее (§7, §10).
|
|
||||||
- **Всё равно оценка, а не строгая граница**: даже с пульсом «читал/думал 12 мин не печатая» не
|
|
||||||
отличить от «отошёл»; подпись «≈» и показ `T_gap` обязательны (§6).
|
|
||||||
- Почему интервалы, а не «правок × блок»: «снимок = +N мин» ломается на агентских всплесках
|
|
||||||
(8 снимков за 7 минут); длина сегмента всплеска = его wall-clock, независимо от плотности снимков.
|
|
||||||
|
|
||||||
### 5.1. Классификация сэмплов и всплесков
|
|
||||||
|
|
||||||
- **Класс сэмпла (человек / агент)** — по `lastUpdatedSource`: `user`→человек, `agent`→агент,
|
|
||||||
`git`→исключаем (§10 `excludeGit`), legacy-`null`→человек. `idle` наследует `source` страницы
|
|
||||||
на момент флеша (= source последней правки). `boundary` несёт СТАРЫЙ (pre-transition) `source`
|
|
||||||
— трактуем как есть: он маркирует исходящую работу того актора (это осознанный компромисс, а не
|
|
||||||
недосмотр — точность посекундная тут не нужна).
|
|
||||||
- **Класс СЕССИИ** (нужен для метрик §6.1): сессия, где ВСЕ сэмплы `source=agent` → **`agent_only`**
|
|
||||||
(автономный прогон, не в основную метрику). Сессия хотя бы с одним человеческим сэмплом →
|
|
||||||
**`work`** (человек + надзор за агентом ВНУТРИ сессии засчитывается человеку — по union'у, §5).
|
|
||||||
- **`idle`** — полноценный сэмпл активности и основной «пульс» непрерывной работы (§3): его метка
|
|
||||||
отстаёт от реальной правки ≤ `maxWait` (≤10м) — в пределах округления, отмотки не требует.
|
|
||||||
Исключать нельзя: без него непрерывное письмо без ручных сохранений снова стало бы невидимым.
|
|
||||||
|
|
||||||
## 6. Что показывать в UI
|
|
||||||
|
|
||||||
### 6.1. Свёрнутое состояние — одно число
|
|
||||||
|
|
||||||
Две метрики, каждая = union-wall-clock СВОИХ сессий (§5):
|
|
||||||
|
|
||||||
- **`work` — headline, кликабельное число** (`≈ 4 ч 30 мин`, рядом с панелью истории или в
|
|
||||||
мета-инфо у заголовка): сессии класса `work` (≥1 человеческий сэмпл = человек + надзор за
|
|
||||||
агентом внутри сессии). Именно это открывает таймлайн (§6.2), и именно к нему сходится сумма по
|
|
||||||
дням.
|
|
||||||
- **`agent_only` — вторично**: сессии автономных прогонов агента (ни одного человеческого сэмпла).
|
|
||||||
На таймлайне — отдельным цветом; в `work` НЕ входит.
|
|
||||||
- Подпись «≈» и показ `T_gap` обязательны (оценка, не хронометраж — §5). Округление headline —
|
|
||||||
шаг 5–15 мин (точность оценки не оправдывает «4 ч 27 мин»).
|
|
||||||
|
|
||||||
### 6.2. Раскрытие по клику — суточный таймлайн (24 ч × дни)
|
|
||||||
|
|
||||||
Клик по числу открывает модалку/поповер с таймлайном по типу «punch-card»:
|
|
||||||
|
|
||||||
- **Строка = один календарный день**; ширина строки = 24 часа (фиксированная шкала 00:00→24:00).
|
|
||||||
- На дорожке дня закрашены **окна активности** — реальные интервалы работы в их часовом
|
|
||||||
положении, так что видно «вечерний марафон» vs «утренняя сессия».
|
|
||||||
- **Справа от строки — сумма за день** «ч мм» (напр. `3 ч 17 м`); пустой день — пустая дорожка
|
|
||||||
и «—».
|
|
||||||
- Внизу — общий итог (= headline `work` §6.1) плюс подпись таймзоны и `T_gap`.
|
|
||||||
|
|
||||||
Это графическая версия таблицы-примера-картинки: там окна были текстом («18:46 → 00:58»), здесь
|
|
||||||
то же рисуется отрезками на 24-часовой дорожке.
|
|
||||||
|
|
||||||
Детали:
|
|
||||||
- Окна = сессии класса `work` (§5), обрезанные по границам суток; сессии `agent_only` — отдельным
|
|
||||||
цветом (§6.1). По умолчанию `work` — один цвет «активность».
|
|
||||||
- Сессия через полночь рисуется как отрезок до 24:00 в одном дне и продолжение с 00:00 в
|
|
||||||
следующем — тот самый полуночный разрез (§6.3), теперь визуально очевиден.
|
|
||||||
- **Честность добивки.** Окно включает `P`/`P_single` — это оценка «до/после», а не измеренный
|
|
||||||
интервал. Одиночная сессия (`P_single`) рисуется минимальной видимой шириной и приглушённо
|
|
||||||
(иначе исчезает и/или создаёт иллюзию плотной работы из одного клика). Общая подпись — «≈».
|
|
||||||
|
|
||||||
### 6.3. Агрегация по дням (алгоритм)
|
|
||||||
|
|
||||||
Вход — ВСЕ сессии из §5 (`work` и `agent_only`), каждая развёрнута в интервал (`P_in/P_out` или
|
|
||||||
`P_single`).
|
|
||||||
|
|
||||||
```text
|
|
||||||
bucketByDay(sessions, tz):
|
|
||||||
U_work = union( sess.iv : sess.class=='work' ) # снимаем перекрытия ОДИН раз (§5)
|
|
||||||
U_agent = union( sess.iv : sess.class=='agent_only' ) # СИММЕТРИЧНО — иначе окна агента наложатся
|
|
||||||
для каждого дня D в [первый_день … последний_день] по tz (dayjs+tz, startOf('day')):
|
|
||||||
workWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_work }
|
|
||||||
agentWin[D] = { u ∩ [начало D, начало D+1) : непусто, u в U_agent }
|
|
||||||
activeMs[D] = Σ длительностей workWin[D] # U_work без перекрытий → просто сумма
|
|
||||||
agentMs[D] = Σ длительностей agentWin[D]
|
|
||||||
→ perDay[] = [{ day, activeMs, agentMs, windows: (workWin[D] ⊕ agentWin[D]) с меткой класса }]
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Инвариант согласованности `Σ activeMs[D] == work`** держится ПО ПОСТРОЕНИЮ: день — это
|
|
||||||
разбиение union'а `U_work` границами суток, ничего не теряется и не дублируется (в т.ч. на
|
|
||||||
23/25-часовых DST-сутках — §9#14). `agent_only`-окна рисуются, но в `activeMs` НЕ входят. Клампа
|
|
||||||
и скрытого условия `T_gap ≥ P` не требуется (в отличие от наивного `Σ` длительностей).
|
|
||||||
- **Таймзона `TZ`** определяет, где проходит «полночь» И в каких часовых координатах рисуются
|
|
||||||
окна. Дефолт — таймзона зрителя (локаль браузера); альтернатива — UTC (как на картинке-примере
|
|
||||||
«По дням (UTC)») или tz воркспейса. Влияет на раскладку по дням и положение окон, но НЕ на
|
|
||||||
общий итог → настройка (§10).
|
|
||||||
- **Длинный диапазон** (месяцы правок): строк ровно столько, сколько календарных дней в диапазоне.
|
|
||||||
При большом числе дней — вертикальный скролл + сворачивание длинных серий пустых дней
|
|
||||||
(«× N дней без правок») и/или переключение на понедельную группировку. Порог — настройка.
|
|
||||||
- **Округление дисплея:** сумму за день округляем до минут для подписи, но общий итог берём из
|
|
||||||
точных значений (иначе Σ округлённых по дням разойдётся с округлённым числом на ±1–2 мин).
|
|
||||||
|
|
||||||
## 7. Проверка на реальной статье
|
|
||||||
|
|
||||||
Ручной прогон на 20 снимках (1-я страница истории, `T_gap=30 мин`, `P_in+P_out=10 мин`,
|
|
||||||
`P_single=2 мин`; все сессии здесь класса `work` — в каждой есть человеческий сэмпл):
|
|
||||||
|
|
||||||
| Сессия | Интервал | Длит. |
|
|
||||||
|--------|-------------------------------------------------------|----------|
|
|
||||||
| S1 | 07-04 03:40 → 03:49 (многосэмпловая) | ≈19 мин |
|
|
||||||
| S2 | 07-04 15:43 → 16:13 (агент 15:43–15:50 → человек 16:13)| ≈41 мин |
|
|
||||||
| S3 | 07-04 18:11 (одиночная) | ≈2 мин |
|
|
||||||
| S4 | 07-04 19:38 → 19:54 (многосэмпловая) | ≈26 мин |
|
|
||||||
| S5 | 07-06 15:34 (одиночная) | ≈2 мин |
|
|
||||||
| S6 | 07-06 16:18 (одиночная, закрыта пост-циклом §5) | ≈2 мин |
|
|
||||||
| **Итого** | | **≈1 ч 32 мин** |
|
|
||||||
|
|
||||||
Наивно на том же срезе — ≈60 часов. Разница — весь смысл фичи.
|
|
||||||
|
|
||||||
Наблюдения:
|
|
||||||
- Разрезы легли по перерывам: ночь `03:49 → 15:43` (~12 ч) и сутки
|
|
||||||
`07-04 19:54 → 07-06 15:34` — оба выброшены.
|
|
||||||
- Чувствительность к порогу: `S5/S6` отстоят на 44 мин. При `T_gap=30` это две сессии,
|
|
||||||
при `T_gap=60` — одна (~44 мин). Число надо показывать **вместе с использованным порогом**.
|
|
||||||
- Это **оценка, не строгая граница** (§5), и НАПРАВЛЕНИЕ ошибки зависит от данных. Сохранения
|
|
||||||
ВНУТРИ `T_gap` мостятся, и вся пауза между ними засчитывается → на периодичном «фоновом» ритме
|
|
||||||
(напр. автосейв раз в ~`T_gap` при почти полном простое) возможен КРАТНЫЙ перебор (в разы), а не
|
|
||||||
«±порог». Сохранения ДАЛЬШЕ `T_gap` друг от друга, наоборот, теряют между-время → недобор. Поэтому
|
|
||||||
`T_gap` калибруется по пульсу #374 (§5, §10): после #374 «фоновый» ритм даёт гэпы > `maxWait` и
|
|
||||||
рвётся на перерывы, срезая кратный перебор; на легаси (пульса нет) оценка грубее в обе стороны.
|
|
||||||
Пример иллюстративен, посчитан на до-#374 срезе при `T_gap=30`.
|
|
||||||
|
|
||||||
## 8. Архитектура (куда встраивать)
|
|
||||||
|
|
||||||
**Ядро — чистая функция** `computeWorkTime(rows, config)` (детерминированная, без БД) в
|
|
||||||
отдельном модуле → легко покрыть юнит-тестами. Выход —
|
|
||||||
`{ workMs, agentOnlyMs, sessions[] }`, где `session = { start, end, class: 'work'|'agent_only' }`:
|
|
||||||
абсолютные границы (с добивкой `P`/`P_single`) плюс класс (§5.1) — этого достаточно и для метрик,
|
|
||||||
и для цвета окна. `workMs`/`agentOnlyMs` считаются как union-wall-clock сессий своего класса (§5).
|
|
||||||
|
|
||||||
**Вторая чистая функция** `bucketByDay(sessions, tz)` (ВСЕ сессии обоих классов) →
|
|
||||||
`perDay[] = [{ day, activeMs, agentMs, windows }]`: `activeMs` = длительность `work`-окон за день
|
|
||||||
(сходится к `work`, §6.3), `agentMs` = то же для `agent_only` (для подписи машинного времени за
|
|
||||||
сутки), `windows` — интервалы ОБОИХ классов, обрезанные по суткам и помеченные классом (для
|
|
||||||
отрисовки `work`/`agent_only` разным цветом, §6.2). Полуночный разрез — календарный, через
|
|
||||||
`dayjs` + tz-плагин (`startOf('day')` в `tz`), НЕ «+24 ч» (§9#14); `dayjs` уже в проекте. Отдельная
|
|
||||||
тестируемая функция (общий модуль сервера и клиента); `tz` — презентационный параметр, удобно звать
|
|
||||||
на клиенте от локали зрителя.
|
|
||||||
|
|
||||||
**Сервер:**
|
|
||||||
- `page-history.repo.ts` — метод `findTimelineByPageId(pageId)`: лёгкая проекция
|
|
||||||
(`createdAt, lastUpdatedById, lastUpdatedSource, lastUpdatedAiChatId, kind`) по всем строкам
|
|
||||||
ASC, без `content`.
|
|
||||||
- `page-history.service.ts` — `computeWorkTime(pageId, config)`: тянет таймлайн, зовёт ядро,
|
|
||||||
кэширует.
|
|
||||||
- `page.controller.ts` — рядом с `POST /history` и `POST /history/info` добавить
|
|
||||||
`POST /history/time` (или вложить в page-info). Отдаёт число + разбивку по сессиям.
|
|
||||||
|
|
||||||
**Клиент:**
|
|
||||||
- `page-history-query.ts` — хук `usePageWorkTime(pageId)` (возвращает `workMs`, `agentOnlyMs`,
|
|
||||||
`sessions[]`).
|
|
||||||
- Рендер кликабельного числа в панели истории.
|
|
||||||
- Модалка/поповер с суточным таймлайном (§6.2): `bucketByDay(sessions, viewerTz)` → строки-дни,
|
|
||||||
в каждой — 24-часовая дорожка с окнами. Это НЕ bar chart: рисуется кастомными CSS/SVG-отрезками
|
|
||||||
на 24-часовой шкале (позиция окна = `startOfDayOffset/24ч`, ширина = длительность/24ч). Готовые
|
|
||||||
чарт-библиотеки под это плохо ложатся — брать лёгкую собственную вёрстку, без новых тяжёлых
|
|
||||||
зависимостей. Пустые дни — пустая дорожка + «—».
|
|
||||||
|
|
||||||
**Производительность:** проекция без `content` дёшева; результат можно инкрементально кэшировать
|
|
||||||
(при `version.saved`, который #374 броадкастит, пересчитывать хвост).
|
|
||||||
|
|
||||||
## 9. Крайние случаи
|
|
||||||
|
|
||||||
1. Один снимок → одна одиночная сессия `P_single`, не ноль. Последняя сессия ВСЕГДА закрывается
|
|
||||||
пост-циклом (§5) — иначе теряется самая свежая.
|
|
||||||
2. История целиком агентская → `work = 0`, `agent_only = union прогонов`.
|
|
||||||
3. Плотный агентский всплеск → длина сегмента = его wall-clock (не зависит от числа снимков);
|
|
||||||
опц. кап `burstCapMs`.
|
|
||||||
4. Метка `idle` лагает ≤ `maxWait` (10м user / 5м agent) — в пределах округления; это
|
|
||||||
полноценный сэмпл активности, спец-обработки не требует (см. §3, §5.1).
|
|
||||||
5. Несколько соавторов → атрибуция человеку по `lastUpdatedById`/`contributorIds`; разделение
|
|
||||||
человек/агент — по `lastUpdatedSource` (НЕ по `lastUpdatedById`, он всегда человек).
|
|
||||||
6. Легаси `kind=null` → работает на `source`+`createdAt`, грубее.
|
|
||||||
7. Совпадающие метки времени (boundary+agent в один момент; в коде есть tie-break по `id`)
|
|
||||||
→ дедуп по округлённому `t`.
|
|
||||||
8. Одновременное соредактирование → `union` (§5) убирает двойной счёт wall-clock при перекрытии
|
|
||||||
окон; персональные человеко-часы (разбивка по авторам) — отдельный опциональный режим
|
|
||||||
(`perAuthor`), а не поведение по умолчанию.
|
|
||||||
9. Сессия через полночь (напр. `23:14 → 02:00`) → режется на границе суток `tz`, части идут
|
|
||||||
в разные дни; сумма по дням = общему числу (§6.3).
|
|
||||||
10. Выбор таймзоны дня меняет раскладку по дням (та же работа попадёт в другой день) — общий
|
|
||||||
итог не меняется; `tz` фиксируется в подписи графика.
|
|
||||||
11. Длинный диапазон правок (месяцы) → строк = число дней: вертикальный скролл + сворачивание
|
|
||||||
длинных серий пустых дней и/или понедельная группировка по порогу (§6.3).
|
|
||||||
12. День без правок → пустая 24-часовая дорожка + «—» в диапазоне (показываем ритм/паузы),
|
|
||||||
не пропускаем.
|
|
||||||
13. Очень короткое окно на 24-часовой шкале → рисуем минимальной видимой шириной, чтобы не
|
|
||||||
исчезало (§6.2).
|
|
||||||
14. Переход на летнее/зимнее время внутри `tz` → сутки в 23/25 ч; полуночный разрез считать
|
|
||||||
по календарю `tz` (`dayjs`+tz, §8), а не «+24 ч». Инвариант `Σ activeMs == work` держится
|
|
||||||
(разбиение union'а). Редкое исключение — tz с DST-переходом РОВНО в полночь (`startOf('day')`
|
|
||||||
неоднозначен) → до 1 ч может протечь в соседний день. Фиксированная 24-часовая дорожка (§6.2)
|
|
||||||
на 23/25-часовых сутках смещает окна визуально до ~1 ч — сознательное упрощение, на итог не
|
|
||||||
влияет.
|
|
||||||
|
|
||||||
## 10. Параметры по умолчанию и открытые решения
|
|
||||||
|
|
||||||
**Полный `config`** (env, дефолты):
|
|
||||||
|
|
||||||
- `T_gap≈15м` — калибровка по `IDLE_MAX_WAIT_USER=10м` + запас (§5: гэп больше `maxWait` не
|
|
||||||
подкреплён пульсом = бездействие; прежние «30м» переоценивали не-пульсирующие паузы).
|
|
||||||
- `agentTGap≈7м` — порог для ПАРЫ подряд идущих агентских сэмплов (`IDLE_MAX_WAIT_AGENT=5м` + запас).
|
|
||||||
- `P_in=5м`, `P_out=5м`, `P_single=2м` (одиночная — pre-roll, §5).
|
|
||||||
- `burstCapMs` (опц. кап на сегмент всплеска, §9#3), `dedupRoundMs` (дедуп совпадающих меток, §9#7),
|
|
||||||
`excludeGit=true`, `tz=локаль-зрителя`, `longRangeDayThreshold` (день→неделя, §6.3),
|
|
||||||
`perAuthor=false` (§9#8).
|
|
||||||
- **Легаси-страницы до #374** (нет пульса) → `T_gap` вынужденно шире, оценка там грубее.
|
|
||||||
- **`T_gap ≥ P_in+P_out`** НЕ требуется для инварианта §6.3 (union), но РЕКОМЕНДУЕТСЯ и валидируется
|
|
||||||
— иначе `work`/`agent_only` могут перекрыться в сумме (§5). При дефолтах (15 ≥ 10) держится.
|
|
||||||
|
|
||||||
Развилки (настройки, не блокируют проектирование):
|
|
||||||
- `work` vs `agent_only` — показывать оба, крупно `work` (headline), `agent_only` вторично;
|
|
||||||
- точное место в UI — панель истории (основное) или мета-строка страницы;
|
|
||||||
- дефолт `T_gap` — вынести в env (как `IDLE_*` в #374), калибровать на реальных статьях
|
|
||||||
после мёржа #374;
|
|
||||||
- **таймзона дня для графика (§6.2/§6.3)** — рекомендую локаль зрителя (интуитивно «мои
|
|
||||||
вечера»); альтернативы — UTC (как на картинке-примере) или tz воркспейса. Влияет только на
|
|
||||||
раскладку по дням, не на общий итог;
|
|
||||||
- **порог перехода день→неделя/месяц** для длинного диапазона правок.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Приложение: Review Ledger (рабочий аппарат, НЕ нормативная часть)
|
|
||||||
|
|
||||||
> Аппарат adversarial-review-loop. Перед выдачей реализатору выносится/сворачивается.
|
|
||||||
> Критикам: НЕ перелитигировать закрытые findings, КРОМЕ случая, когда сам RESOLUTION
|
|
||||||
> дефектен — тогда атаковать его явно и сказать, почему предыдущий раунд ошибся.
|
|
||||||
|
|
||||||
## CONFIG
|
|
||||||
|
|
||||||
- EXTERNAL_MODEL: endpoint `https://api.z.ai/api/coding/paas/v4`, model `glm-5.2`
|
|
||||||
(ключ хранится вне репозитория, в логи не пишется). Роль: cold-reader gate (Phase 4.5),
|
|
||||||
tiebreaker при эскалации.
|
|
||||||
- Артефакт: `docs/features/page-work-time_design.md`.
|
|
||||||
- Целевой класс: спец для реализации другим человеком → план 2–3 итерации, до пустого
|
|
||||||
cold-reader gate (не по числу итераций).
|
|
||||||
|
|
||||||
## FACT BASE (проверено по PR #374 @ commit 924f8aa, ветка feat/370-page-versioning)
|
|
||||||
|
|
||||||
Верификация автором ДО цикла (ветка не в рабочем дереве — критики не видят её локально;
|
|
||||||
факты ниже — ground truth, можно дозапросить файлы через gitea MCP по указанному SHA):
|
|
||||||
|
|
||||||
- `page_history.kind` — `varchar(20)`, NULLABLE, БЕЗ дефолта (migration
|
|
||||||
`20260705T120000-page-history-kind.ts`). Домен: `manual`/`agent`/`idle`/`boundary`;
|
|
||||||
legacy `null` = автосейв (`collaboration/constants.ts`, `PageHistoryKind`).
|
|
||||||
- `kind` УЖЕ включён в `PageHistoryRepo.baseFields` (`page-history.repo.ts`) — читается всеми
|
|
||||||
выборками истории. `saveHistory({kind})` и `updateHistoryKind(id, kind)` существуют.
|
|
||||||
- Тайминги (`constants.ts`): `IDLE_INTERVAL_USER=60м`/`AGENT=15м`;
|
|
||||||
`IDLE_MAX_WAIT_USER=10м`/`AGENT=5м`.
|
|
||||||
- `computeHistoryJob` (`persistence.extension.ts`):
|
|
||||||
`delay = max(0, min(interval, burstStart + maxWait − now))`; `enqueuePageHistory` сбрасывает
|
|
||||||
`burstStart` каждые `maxWait`. Следствие: **потолок всегда доминирует** → `idle` пульсирует
|
|
||||||
каждые ~`maxWait` при непрерывной работе; метка `idle` отстоит от правки ≤ `maxWait`, НЕ 60м.
|
|
||||||
- Идл-джоб всегда ставится с `kind:'idle'`; процессор пишет `job.data.kind ?? 'idle'`, но только
|
|
||||||
если контент изменился (`isDeepStrictEqual`-гейт).
|
|
||||||
- `boundary`-снимок пишется СИНХРОННО в store-транзакции на смене `lastUpdatedSource`
|
|
||||||
(user↔agent↔git), фиксирует ИСХОДЯЩИЙ (pre-transition) контент со СТАРЫМ source; его
|
|
||||||
`createdAt` = момент перехода (точный).
|
|
||||||
- `manual`/`agent` — явный save-version по stateless-каналу; `kind` выводится из
|
|
||||||
`context.actor` СЕРВЕРНО (неподделываемо). Promote-not-dup: апгрейд `kind` последнего снимка
|
|
||||||
на месте вместо дубля.
|
|
||||||
- `page_history.lastUpdatedById` = ВСЕГДА ответственный человек (даже для агентских снимков);
|
|
||||||
«agentness» — в `lastUpdatedSource` + `lastUpdatedAiChatId`.
|
|
||||||
- REST истории: `POST /history`, `POST /history/info` (`page.controller.ts`). Клиент:
|
|
||||||
`apps/client/src/features/page-history/*`. Есть broadcast `version.saved` (для live-инвалидации).
|
|
||||||
|
|
||||||
## Pre-loop fact-base corrections (автор, проверено vs source)
|
|
||||||
|
|
||||||
- C1. §3/§5.1/§крайние-случаи#4: убрано ошибочное «idle лагает до 60м / idle не удлиняет
|
|
||||||
сессию / отмотка на IDLE_INTERVAL». Верно: лаг ≤ `maxWait` (≤10м), `idle` — полноценный сэмпл.
|
|
||||||
- C2. §3: устранено внутреннее противоречие §3↔§5.1 (пульс vs исключение idle).
|
|
||||||
- C3. §4: `kind` уже в `baseFields` — схему менять не нужно.
|
|
||||||
- C4. §крайние-случаи#5: `lastUpdatedById` всегда человек; человек/агент — по `lastUpdatedSource`.
|
|
||||||
|
|
||||||
## Scope changes
|
|
||||||
|
|
||||||
- S1 (owner, до итерации 1): добавлен drill-down — клик по числу открывает график по дням.
|
|
||||||
- S2 (owner, до итерации 1): drill-down переопределён с «столбцы часов/день» на СУТОЧНЫЙ
|
|
||||||
ТАЙМЛАЙН (строка-день = 24 ч с окнами активности + сумма за день, §6.2/§6.3). Окна вернулись,
|
|
||||||
но графикой. Затронуты §1, §6.2, §6.3, §8, §9(#9–14), §10.
|
|
||||||
|
|
||||||
## Iteration log
|
|
||||||
|
|
||||||
### Итерация 1 — критики: Claude (hardened, A) + GLM-5.2 external (B)
|
|
||||||
|
|
||||||
Первый прогон local-субагентов вернул инъекцию из подложенного SKILL.md (реклама
|
|
||||||
«agent-first-plugin-suite») и 0 полезной работы — проигнорировано; пере-прогон с анти-инъекцией
|
|
||||||
дал реальные ревью. Дефекты и диспозиции:
|
|
||||||
|
|
||||||
- **[BLOCKER] A1** — §5-псевдокод не закрывал последнюю сессию (терял свежую; 0 сессий для
|
|
||||||
односессионной страницы; ронял S6). ПРИНЯТО → пост-цикловое закрытие, `n=1`→`P_single`, `n=0`→0.
|
|
||||||
- **[MAJOR] A2+B4** — какое число суммирует таймлайн; двойной счёт при перекрытии. ПРИНЯТО →
|
|
||||||
метрики `work`/`agent_only` (§6.1) + `total`/`perDay` через **union** (§5, §6.3).
|
|
||||||
- **[MAJOR] A3** — §1 остался со старым «столбцом на день» (residue S1→S2). ПРИНЯТО → §1 переписан.
|
|
||||||
- **[MAJOR] A4** — «кламп vs скрытое условие `T_gap ≥ P`». ПРИНЯТО, но растворено: union убрал и
|
|
||||||
кламп, и условие (§6.3).
|
|
||||||
- **[MAJOR] A5** — форма `session` и правило классификации человек/агент (нужны для `workMs` и
|
|
||||||
цвета). ПРИНЯТО → §5.1 классификация (+ nuance про старый source у boundary), `session.class` (§8).
|
|
||||||
- **[MAJOR→понижено] B1** — «нижняя оценка» ложна; гэп ≤ T_gap считается работой. ЧАСТИЧНО ПРИНЯТО
|
|
||||||
+ КОНТР по severity: гэп-как-работа — by design (по меткам не отличить «думал» от «отошёл»),
|
|
||||||
поведение оставлено; исправлено УТВЕРЖДЕНИЕ (§5/§7: «оценка, не строгая граница»). Понижено с
|
|
||||||
BLOCKER: это не баг кода, а некорректная формулировка/честность.
|
|
||||||
- **[MAJOR] B2+B5** — инфляция одиночных сессий на `P` + честность отрисовки добивки. ПРИНЯТО →
|
|
||||||
`P_single` (мал), приглушённая отрисовка + «≈» (§5, §6.2).
|
|
||||||
- **[MAJOR/MINOR] B3+A6** — коллапс агентского всплеска рушит вклинившегося человека + выбор
|
|
||||||
конца для гэп-теста. ПРИНЯТО → правило коллапса (только подряд один aiChatId; человек внутри
|
|
||||||
разрывает; левый гэп до `t_start`, правый от `t_end`) (§5).
|
|
||||||
- **[MINOR] A7** — `config` не перечислен. ПРИНЯТО → полный список (§10).
|
|
||||||
- **[MINOR] A8** — ячейка `idle` в таблице §3 устарела. ПРИНЯТО → исправлена.
|
|
||||||
- **[NIT] A9** — расхождение округления headline vs подписи дня. Оставлено с оговоркой (§6.3).
|
|
||||||
- **[NIT] A10** — назвать `dayjs` для tz/DST-разреза. ПРИНЯТО (§8, §9#14).
|
|
||||||
|
|
||||||
Verified clean (оба критика): факты #374; tz/DST-разрез; лёгкая проекция без `content`;
|
|
||||||
кэш на `version.saved`; разбиение на две чистые функции; §2-постановка; арифметика §7 (при
|
|
||||||
исправленном алгоритме).
|
|
||||||
|
|
||||||
### Итерация 1 — post-integration re-attack (Claude A + GLM round 2) — ЗАКРЫТА
|
|
||||||
|
|
||||||
Оба критика НЕЗАВИСИМО нашли один и тот же блокер и сошлись (разные семейства моделей):
|
|
||||||
|
|
||||||
- **[BLOCKER] N1 (A) = MAJOR-1 (GLM)** — §5 «сессионизация ОТДЕЛЬНО по классам» противоречила
|
|
||||||
§5.1/§7/§8 и теряла надзорное агентское время (S2 схлопывалась в 2 мин; `workMs+agentOnlyMs` мог
|
|
||||||
превысить прошедшее). Исправлено: ЕДИНЫЙ проход по всем сэмплам + классификация готовой сессии;
|
|
||||||
метрики — union внутри класса.
|
|
||||||
- **[MAJOR] N2 (A)** — union только ВНУТРИ класса; кросс-класс дизъюнктность требует `T_gap ≥ P`,
|
|
||||||
а §10 это отрицал; `agentTGap` неопределён при едином проходе. ПРИНЯТО → `gap_threshold` по ПАРЕ
|
|
||||||
сэмплов (agent–agent → `agentTGap`), caveat в §5, §10 смягчён (валидируем `T_gap ≥ P`).
|
|
||||||
- **[MAJOR] GLM-2** — `bucketByDay(workSessions)` не мог рисовать `agent_only`. ПРИНЯТО → сигнатура
|
|
||||||
`bucketByDay(sessions)`, окна ОБОИХ классов, `activeMs` = work-only.
|
|
||||||
- **[MINOR] N3** — роль `boundary` противоречива (§5 «человеческий» vs §5.1 «старый source=agent»).
|
|
||||||
ПРИНЯТО → §5: всплеск рвёт любой сэмпл, не продолжающий тот же aiChatId-агент; класс — по source.
|
|
||||||
- **[MINOR] N4** — псевдокод точечный, концы сегмента не заданы. ПРИНЯТО → сегмент {t_start,t_end};
|
|
||||||
same-source idle внутри всплеска продолжает сегмент.
|
|
||||||
- **[NIT] N5** — DST ровно в полночь + фикс. 24ч-дорожка ±1ч визуально. ПРИНЯТО → оговорка §9#14.
|
|
||||||
- **[NIT] N6** — `P_single` симметричный «выдумывает будущую работу». ПРИНЯТО → pre-roll `[t−P_single, t]`.
|
|
||||||
- **B1-severity (спор):** A СОГЛАСИЛСЯ с понижением BLOCKER→MAJOR (нет входа, где код отклоняется от
|
|
||||||
намерения — только формулировка/UX). GLM НАСТАИВАЛ на BLOCKER с НОВЫМ аргументом: отсутствие
|
|
||||||
idle-пульса в гэпе = доказанное бездействие. Диспозиция: label = MAJOR, но СУБСТАНЦИЯ GLM принята
|
|
||||||
(Invariant 7 — уступка с аргументом): `T_gap` калиброван по `maxWait` (§5/§10) + принцип «гэп >
|
|
||||||
maxWait = перерыв». A-residue (направление ошибки data-dependent, возможен КРАТНЫЙ перебор) принят
|
|
||||||
в §7. Тайбрейкер не понадобился (обе стороны привели аргументы, интегрированы обе).
|
|
||||||
|
|
||||||
Verified clean (round-2): инвариант §6.3 под DST (оба критика); burst-endpoints (оба); `P_single`
|
|
||||||
через полночь (GLM). Блокеров в ядре: 0. Итерация 1 закрыта.
|
|
||||||
|
|
||||||
### Convergence validation + cold-reader gate — ЗАКРЫТА (CONVERGED)
|
|
||||||
|
|
||||||
- **Cold-reader gate** (GLM-5.2, внешняя модель, «имплементер читает впервые»): ПУСТОЙ список
|
|
||||||
вопросов на день 1 — ДВАЖДЫ (до и после партии полировки).
|
|
||||||
- **Convergence pass** (GLM-5.2, свои hostile-сценарии + слепая реализация): вердикт NOT CONVERGED
|
|
||||||
на ещё не атакованной партии round-2 → нашёл 2 MAJOR + 1 MINOR (как и предупреждает skill —
|
|
||||||
«re-opened cycles find MAJORs in unreviewed amendments»):
|
|
||||||
- [MAJOR] §6.3 рисовал `agent_only`-окна из «сырых» `sess.iv` (не union) → визуальное наложение.
|
|
||||||
→ `U_agent = union(...)`, симметрично `U_work`; добавлен `agentMs[D]`.
|
|
||||||
- [MAJOR] §5 «idle продолжает сегмент НЕЗАВИСИМО от aiChatId» склеивал разные ИИ-раны через
|
|
||||||
idle-снимок. → idle с ДРУГИМ `aiChatId` = новый ран, рвёт сегмент.
|
|
||||||
- [MINOR] нет per-day суммы агента. → `agentMs` в `perDay`/§8.
|
|
||||||
Все приняты и интегрированы; финальный re-gate (GLM cold-reader) → снова ПУСТО, рассинхрона нет.
|
|
||||||
- **Tooling note:** local general-purpose Claude-субагенты трижды перехватывались инъекцией из
|
|
||||||
подложенного SKILL.md (реклама «agent-first-plugin-suite» / фейковые «review this PR» промпты,
|
|
||||||
0 tool-uses). Проигнорировано. Адверсариальные проходы и cold-reader выполнены внешней GLM-5.2
|
|
||||||
(гетерогенная модель — как раз рекомендована skill против self-preference) + один hardened
|
|
||||||
Claude-проход, который сработал.
|
|
||||||
|
|
||||||
## Closure checklist
|
|
||||||
|
|
||||||
1. 0 блокеров в ядре; счётчики обсуждения в обе стороны (приняты находки И отбит spor по B1-severity) — ✓
|
|
||||||
2. Партия полировки re-gated финальным cold-reader на полном тексте — ✓
|
|
||||||
3. Cold-reader: список вопросов ПУСТ (дважды) — ✓
|
|
||||||
4. Прогноз сходимости трактовался как гипотеза; решал чек-лист (convergence-pass реально нашёл MAJOR) — ✓
|
|
||||||
5. Backcast: частично (пример §7 на реальных данных + 36.5ч-картинка владельца) — обязательным не был
|
|
||||||
6. Preconditions зафиксированы: зависимость точности от #374 (пульс), грубость на легаси, DST-в-полночь, `T_gap ≥ P`
|
|
||||||
|
|
||||||
**СТАТУС: CONVERGED.** §1–§10 — нормативная спека для реализатора; это приложение — аудит-след (не нормативно).
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
# Reading the AI dialog logs (how agents call tools, and where they fail)
|
|
||||||
|
|
||||||
How to inspect the agent conversation history in the database — and, more
|
|
||||||
importantly, the **one non-obvious trap that will make you report the wrong
|
|
||||||
answer**: the persisted history *silently hides hard tool failures*. Written from
|
|
||||||
real pain (a "which tools fail most?" analysis that confidently answered
|
|
||||||
"patchNode: 0 errors" while the UI was visibly full of red `patchNode` failures).
|
|
||||||
|
|
||||||
Read the **Gotchas** section before you trust any error count.
|
|
||||||
|
|
||||||
## TL;DR
|
|
||||||
|
|
||||||
- Agent chats live in Postgres, DB `docmost`, tables `ai_chat_*`.
|
|
||||||
- Each tool invocation is stored as **two** array elements (a `tool-call` part and
|
|
||||||
a `tool-result` part), so naive counting double-counts.
|
|
||||||
- **A tool that *throws* writes no result part at all.** Its error text is nowhere
|
|
||||||
in the DB — not in `tool_calls`, `content`, or `metadata`. It is shown live in
|
|
||||||
the UI only. So `isError` / `success=false` scans under-report by design.
|
|
||||||
- To find where agents fail you need **three** sources: (1) soft-failure markers in
|
|
||||||
`tool_calls`, (2) the orphan-gap proxy for thrown errors, (3) server logs / the
|
|
||||||
live UI for the actual error text.
|
|
||||||
|
|
||||||
## Where the data lives
|
|
||||||
|
|
||||||
Host `island.lc` (`10.31.40.120`), container `gitmost-postgresql`
|
|
||||||
(`pgvector/pgvector:pg18`), database `docmost`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ssh island.lc
|
|
||||||
# one-off query:
|
|
||||||
docker exec gitmost-postgresql psql -U docmost -d docmost -P pager=off -c "SELECT ..."
|
|
||||||
# interactive:
|
|
||||||
docker exec -it gitmost-postgresql psql -U docmost -d docmost
|
|
||||||
```
|
|
||||||
|
|
||||||
The main app container is `gitmost` (`DATABASE_URL=postgresql://docmost:...@db:5432/docmost`).
|
|
||||||
All workspaces (vvzvlad / wb / asakusa / …) share this **single** database — they
|
|
||||||
are rows in `workspaces`, not separate deployments.
|
|
||||||
|
|
||||||
### Relevant tables
|
|
||||||
|
|
||||||
| Table | What it holds |
|
|
||||||
| --- | --- |
|
|
||||||
| `ai_chats` | one row per conversation (`title`, `role_id`, `page_id`, `creator_id`) |
|
|
||||||
| `ai_chat_messages` | every message; tool calls live in `tool_calls` jsonb |
|
|
||||||
| `ai_chat_runs` | one row per agent run (turn): `status`, `error`, `step_count` |
|
|
||||||
| `ai_agent_roles` | agent definitions (`instructions`, `model_config`) |
|
|
||||||
| `ai_mcp_servers` | configured MCP tool servers per workspace |
|
|
||||||
|
|
||||||
`ai_chat_messages` columns that matter: `role` (`user` | `assistant` — there is **no**
|
|
||||||
separate `tool` role), `content` (text), `tool_calls` (jsonb array), `metadata`
|
|
||||||
(jsonb, holds run `error` + rendered `parts`), `status`, `tsv` (full-text index).
|
|
||||||
|
|
||||||
## How tool calls are stored — READ THIS
|
|
||||||
|
|
||||||
Tool calls are **not** one-object-per-call. Each logical invocation is split into
|
|
||||||
two consecutive elements of the `tool_calls` array:
|
|
||||||
|
|
||||||
```text
|
|
||||||
index 0: { "toolName": "getPage", "input": { "pageId": "…" } } ← tool-call (has input, NO output)
|
|
||||||
index 1: { "toolName": "getPage", "output": { … } } ← tool-result (has output, NO input)
|
|
||||||
```
|
|
||||||
|
|
||||||
The **only** keys that ever appear on an element are `toolName`, `input`, `output`.
|
|
||||||
There is no `state`, no `errorText`, no `type`. Consequences:
|
|
||||||
|
|
||||||
1. **Real invocation count = elements that have `output`.** Counting every element
|
|
||||||
double-counts (you get ~2× and a spurious "~50% of every tool has no output").
|
|
||||||
2. **Pairing:** a successful call = a `tool-call` part followed by its `tool-result`
|
|
||||||
part. Both carry `toolName`, so you can group by tool on either.
|
|
||||||
|
|
||||||
## The two classes of failure (and which the DB can see)
|
|
||||||
|
|
||||||
### 1. Soft failures — tool RAN and returned an error-shaped result → PERSISTED ✅
|
|
||||||
|
|
||||||
These are visible in the `tool-result` `output`. The marker differs per tool:
|
|
||||||
|
|
||||||
| Tool(s) | Error marker in `output` |
|
|
||||||
| --- | --- |
|
|
||||||
| `editPageText` | `failed` is a **non-empty** array of `{find, reason}` (e.g. `text not found in the document`, `matches N times — provide a longer fragment or set replaceAll`). Also a soft `warning` when the `find` string contained markdown that only matched after stripping. |
|
|
||||||
| `semanticSearch` | `{ "unavailable": true, "reason": "semantic search unavailable" }` (feature/infra, not the agent's fault) |
|
|
||||||
| MCP passthrough (`Habr_*`, some `Search_*`) | `output` is an **array** (raw MCP content) whose text starts with `Error executing tool … validation error …` |
|
|
||||||
| generic | `output.isError = true` or `output.success = false` |
|
|
||||||
|
|
||||||
Note `editPageText` returns `failed: []` on success — filtering on the *presence*
|
|
||||||
of the key gives false positives; filter on **non-empty**.
|
|
||||||
|
|
||||||
### 2. Hard failures — tool THREW → NOT PERSISTED ❌ (the trap)
|
|
||||||
|
|
||||||
When a tool throws (the classic one is `patchNode` / `insertNode` / `tableUpdateCell`
|
|
||||||
→ `Failed to encode document to Yjs (fromJSON): Unknown node type: undefined`), the
|
|
||||||
runtime writes **no `tool-result` part**. The orphaned `tool-call` part stays, but
|
|
||||||
the error text is **nowhere in the DB**. It is streamed to the UI live and (until
|
|
||||||
rotation) to server logs — that is it.
|
|
||||||
|
|
||||||
So any query like `count(*) FILTER (WHERE output.success = false)` will happily
|
|
||||||
return **0** for `patchNode` even when the chat is visibly full of red failures.
|
|
||||||
That is survivorship bias, not reliability.
|
|
||||||
|
|
||||||
The only DB-side proxy for a thrown error is an **orphan**: a `tool-call` part with
|
|
||||||
no matching `tool-result`. Caveat: orphans also appear when a run is **aborted**
|
|
||||||
mid-flight (server restart), so a high-volume tool (`createComment`, `searchInPage`,
|
|
||||||
`Search_web_search`) shows orphans from aborts, not from real errors. Treat the
|
|
||||||
orphan gap as an *upper bound* on hard errors, and cross-check the tool: a gap on a
|
|
||||||
structural editor (`patchNode`, `insertNode`, `updatePageJson`, `transformPage`) is
|
|
||||||
almost certainly a thrown Yjs-encode error; a gap on `createComment` is mostly aborts.
|
|
||||||
|
|
||||||
### 3. Run-level failures → `ai_chat_runs`
|
|
||||||
|
|
||||||
`status` ∈ `succeeded | aborted | failed | running`; `error` holds the text. Seen in
|
|
||||||
the wild: `Run interrupted by a server restart.` (aborts) and
|
|
||||||
`Failed after N attempts. Last error: The service may be temporarily overloaded`
|
|
||||||
(LLM provider 529). These are infra/provider, not agent tool misuse.
|
|
||||||
|
|
||||||
## Ready-to-use queries
|
|
||||||
|
|
||||||
Run all of these via `docker exec gitmost-postgresql psql -U docmost -d docmost -P pager=off -c "…"`.
|
|
||||||
|
|
||||||
**Real invocation count per tool** (result parts only — the correct denominator):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT elem->>'toolName' AS tool, count(*) AS calls
|
|
||||||
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
|
||||||
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'output'
|
|
||||||
GROUP BY 1 ORDER BY 2 DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Soft errors per tool** (everything the DB can honestly see):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
WITH res AS (
|
|
||||||
SELECT elem->>'toolName' AS tool, elem->'output' AS o
|
|
||||||
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
|
||||||
WHERE jsonb_typeof(m.tool_calls) = 'array' AND elem ? 'output'
|
|
||||||
)
|
|
||||||
SELECT tool, count(*) AS calls,
|
|
||||||
sum(COALESCE(
|
|
||||||
(o->>'isError') = 'true'
|
|
||||||
OR (o->>'success') = 'false'
|
|
||||||
OR (jsonb_typeof(o->'failed') = 'array' AND o->'failed' <> '[]'::jsonb)
|
|
||||||
OR (o->>'unavailable') = 'true'
|
|
||||||
OR o::text ~* 'error executing tool|validation error'
|
|
||||||
, false)::int) AS soft_errors
|
|
||||||
FROM res GROUP BY tool HAVING sum(COALESCE(
|
|
||||||
(o->>'isError') = 'true' OR (o->>'success') = 'false'
|
|
||||||
OR (jsonb_typeof(o->'failed') = 'array' AND o->'failed' <> '[]'::jsonb)
|
|
||||||
OR (o->>'unavailable') = 'true' OR o::text ~* 'error executing tool|validation error'
|
|
||||||
, false)::int) > 0
|
|
||||||
ORDER BY soft_errors DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
**`editPageText` failure reasons** (the most common real agent mistake — bad `find`):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
WITH res AS (
|
|
||||||
SELECT elem->'output' AS o
|
|
||||||
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
|
||||||
WHERE jsonb_typeof(m.tool_calls) = 'array'
|
|
||||||
AND elem->>'toolName' = 'editPageText' AND elem ? 'output'
|
|
||||||
)
|
|
||||||
SELECT f->>'reason' AS reason, count(*)
|
|
||||||
FROM res, jsonb_array_elements(o->'failed') f
|
|
||||||
WHERE jsonb_typeof(o->'failed') = 'array'
|
|
||||||
GROUP BY 1 ORDER BY 2 DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Hard-error proxy — orphan gap per tool, WITH a spread column** (call parts minus
|
|
||||||
result parts, plus how many distinct chats the gap is spread across):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
WITH parts AS (
|
|
||||||
SELECT m.chat_id, elem->>'toolName' AS tool,
|
|
||||||
(elem ? 'input' AND NOT (elem ? 'output')) AS is_call,
|
|
||||||
(elem ? 'output') AS is_result
|
|
||||||
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
|
||||||
WHERE jsonb_typeof(m.tool_calls) = 'array' AND m.role = 'assistant'
|
|
||||||
),
|
|
||||||
per_chat AS (
|
|
||||||
SELECT tool, chat_id, sum(is_call::int) - sum(is_result::int) AS gap
|
|
||||||
FROM parts GROUP BY tool, chat_id
|
|
||||||
)
|
|
||||||
SELECT tool,
|
|
||||||
sum(gap) FILTER (WHERE gap > 0) AS missing_results,
|
|
||||||
count(*) FILTER (WHERE gap > 0) AS chats_spread, -- disambiguates!
|
|
||||||
max(gap) AS worst_single_chat
|
|
||||||
FROM per_chat GROUP BY tool
|
|
||||||
HAVING sum(gap) FILTER (WHERE gap > 0) > 0
|
|
||||||
ORDER BY missing_results DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
**`missing_results` mixes thrown errors AND aborted/interrupted runs — you cannot
|
|
||||||
split them from `output` alone** (a positional "what follows the orphan" heuristic
|
|
||||||
breaks on parallel tool batches, which persist as `call,call,…,result,result`). Use
|
|
||||||
`chats_spread` to disambiguate:
|
|
||||||
|
|
||||||
- **spread across many chats** (e.g. `createComment` 96 over 29 chats) → a **systemic
|
|
||||||
real error** (here: inline-comment anchor text not found on the page).
|
|
||||||
- **concentrated in one chat** (e.g. `searchInPage` 55, of which 51 in a single chat)
|
|
||||||
→ **one runaway/aborted session**, not a real per-call error — discount it.
|
|
||||||
- a gap on a **structural editor** (`patchNode`, `insertNode`, `tableUpdateCell`,
|
|
||||||
`updatePageJson`, `transformPage`) is almost always a thrown Yjs-encode error.
|
|
||||||
|
|
||||||
**Run-level failures:**
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT status, count(*), min(error) AS sample_error
|
|
||||||
FROM ai_chat_runs GROUP BY status ORDER BY 2 DESC;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Full-text search across messages.** The `tsv` GIN index is built as
|
|
||||||
`to_tsvector('english', unaccent(content))` — so it **stems English** but **not
|
|
||||||
Russian** (Russian lexemes are stored unstemmed, so only exact word forms match).
|
|
||||||
Most content here is Russian, so prefer `ILIKE` for substring search:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Russian / substring — reliable:
|
|
||||||
SELECT chat_id, left(content, 120)
|
|
||||||
FROM ai_chat_messages WHERE content ILIKE '%иранск%' LIMIT 20;
|
|
||||||
|
|
||||||
-- English phrase — can use the index:
|
|
||||||
SELECT chat_id, left(content, 120)
|
|
||||||
FROM ai_chat_messages
|
|
||||||
WHERE tsv @@ websearch_to_tsquery('english', 'some phrase') LIMIT 20;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Don't blow up your context
|
|
||||||
|
|
||||||
A single `tool_calls` row can be **300–400 KB** (results embed full page content and
|
|
||||||
search payloads). Never `SELECT tool_calls` (or `jsonb_pretty(tool_calls)`) raw.
|
|
||||||
Always project just the keys you need and truncate:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT elem->>'toolName',
|
|
||||||
left(regexp_replace((elem->'output')::text, '\s+', ' ', 'g'), 200)
|
|
||||||
FROM ai_chat_messages m, jsonb_array_elements(m.tool_calls) elem
|
|
||||||
WHERE elem ? 'output' LIMIT 5;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Server logs & live UI (for the error text the DB drops)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker logs -f --tail=100 gitmost # main app
|
|
||||||
docker compose -p gitmost logs -f --tail=100 # whole stack
|
|
||||||
```
|
|
||||||
|
|
||||||
Logging is `json-file`, `max-size=10m max-file=5` → ~50 MB retained, then rotated,
|
|
||||||
and **wiped on container recreate**. So thrown-tool error text is only reliably
|
|
||||||
caught **in real time** (or in the live chat UI, which renders the failed part with
|
|
||||||
its message). There is no durable, queryable store of hard tool errors today — if you
|
|
||||||
need one, that is a feature to add (persist `output-error` parts, or emit a
|
|
||||||
`tool_calls_total{tool,status}` metric to VictoriaMetrics).
|
|
||||||
|
|
||||||
## Gotchas checklist
|
|
||||||
|
|
||||||
- [ ] Counting every `tool_calls` element → **2× overcount**. Count elements with `output`.
|
|
||||||
- [ ] `isError` / `success=false` ≈ 0 does **not** mean "no errors" — thrown errors aren't persisted.
|
|
||||||
- [ ] `editPageText.failed` is `[]` on success — test for **non-empty**, not presence.
|
|
||||||
- [ ] Orphan gap mixes thrown errors **and** aborted runs — split by tool before concluding.
|
|
||||||
- [ ] `aborted` runs = server restarts, `failed` runs = provider overload — not agent mistakes.
|
|
||||||
- [ ] Never dump a raw `tool_calls` cell — it can be hundreds of KB.
|
|
||||||
- [ ] Logs are ephemeral (≤50 MB, wiped on recreate) — grab hard-error text live.
|
|
||||||
|
|
||||||
## Snapshot (2026-07-07, illustrative — rerun the queries for current numbers)
|
|
||||||
|
|
||||||
- 226 chats, 732 messages, 46 runs; ~4 400 real tool invocations.
|
|
||||||
- Soft errors (persisted): `editPageText` 4/79 (bad/non-unique `find`) + 9 markdown-in-`find` warnings; `semanticSearch` 3/4 (`unavailable`); `Habr_update_draft_from_docmost` 1/2 (`doc` sent as object, not string).
|
|
||||||
- Missing-result proxy, read WITH the spread column:
|
|
||||||
- **Systemic (spread) → real errors:** `createComment` 96 over **29 chats** (comment anchor text not found — the biggest real error hotspot); `editPageText` 31 over 12 chats (+ the 4 soft above); structural-editor Yjs throws `insertNode` 10 / `updatePageContent` 9 / `tableUpdateCell` 6 / `patchNode` 5 / `updatePageJson` 2 / `transformPage` 2.
|
|
||||||
- **Concentrated → NOT real errors:** `searchInPage` 55 (51 in one chat); `Search_web_search` 15 & `Search_searxng_web_search` 6 (timeouts/aborts in long research sessions).
|
|
||||||
- Runs: 34 succeeded, 10 aborted (server restart), 1 failed (provider overload).
|
|
||||||
@@ -55,15 +55,10 @@ describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () =>
|
|||||||
const file2 = await stabilizePageFile(doc2, meta);
|
const file2 = await stabilizePageFile(doc2, meta);
|
||||||
expect(file2).toBe(file1);
|
expect(file2).toBe(file1);
|
||||||
|
|
||||||
// The drawio node was materialized to its canonical HTML form by the
|
// The materialized diagram default is present in the stabilized body (proof
|
||||||
// convergence pass — a bare `{ src }` doc node becomes the full
|
// that the convergence pass actually ran, not just that two naive exports
|
||||||
// `<div data-type="drawio" data-src=...>` — proof the pass actually ran, not
|
// happened to match).
|
||||||
// just two naive exports happening to match. Assert on the stable canonical
|
expect(body1).toContain('data-align="center"');
|
||||||
// markers rather than `data-align="center"`: center is a schema default the
|
|
||||||
// converter may omit (see prosemirror-markdown media-html.ts), so it is not
|
|
||||||
// a reliable convergence proof.
|
|
||||||
expect(body1).toContain('data-type="drawio"');
|
|
||||||
expect(body1).toContain('data-src="/d.drawio"');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-stable content is unchanged by the pass (idempotent)', async () => {
|
it('already-stable content is unchanged by the pass (idempotent)', async () => {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
insertTableRow,
|
insertTableRow,
|
||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "@docmost/prosemirror-markdown";
|
} from "./lib/node-ops.js";
|
||||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||||
import { withPageLock } from "./lib/page-lock.js";
|
import { withPageLock } from "./lib/page-lock.js";
|
||||||
import {
|
import {
|
||||||
@@ -137,15 +137,6 @@ export type DocmostMcpConfig = { apiUrl: string } & (
|
|||||||
has?: (uri: string) => boolean;
|
has?: (uri: string) => boolean;
|
||||||
evict?: (uri: string) => void;
|
evict?: (uri: string) => void;
|
||||||
};
|
};
|
||||||
// Dependency-neutral metrics sink. When present, the client emits generic
|
|
||||||
// (name, value, labels) samples; the HOST maps those names onto its own
|
|
||||||
// metrics registry (the package never depends on prom-client or the server).
|
|
||||||
// Absent in standalone/stdio mode → the client is a complete no-op here.
|
|
||||||
onMetric?: (
|
|
||||||
name: string,
|
|
||||||
value: number,
|
|
||||||
labels?: Record<string, string>,
|
|
||||||
) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||||
@@ -182,11 +173,6 @@ export class DocmostClient {
|
|||||||
// op's image blobs if the final doc put throws. Null when the sink omits them.
|
// op's image blobs if the final doc put throws. Null when the sink omits them.
|
||||||
private sandboxHas: ((uri: string) => boolean) | null = null;
|
private sandboxHas: ((uri: string) => boolean) | null = null;
|
||||||
private sandboxEvict: ((uri: string) => void) | null = null;
|
private sandboxEvict: ((uri: string) => void) | null = null;
|
||||||
// Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric).
|
|
||||||
// Null on the legacy positional form and whenever the host omits it → no-op.
|
|
||||||
private onMetricFn:
|
|
||||||
| ((name: string, value: number, labels?: Record<string, string>) => void)
|
|
||||||
| null = null;
|
|
||||||
// In-flight login dedup: when the token expires, the 401 interceptor,
|
// In-flight login dedup: when the token expires, the 401 interceptor,
|
||||||
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
|
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
|
||||||
// can all call login() at once. Memoizing a single promise collapses that
|
// can all call login() at once. Memoizing a single promise collapses that
|
||||||
@@ -236,8 +222,6 @@ export class DocmostClient {
|
|||||||
this.sandboxHas = config.sandbox.has ?? null;
|
this.sandboxHas = config.sandbox.has ?? null;
|
||||||
this.sandboxEvict = config.sandbox.evict ?? null;
|
this.sandboxEvict = config.sandbox.evict ?? null;
|
||||||
}
|
}
|
||||||
// Legacy positional form carries no onMetric → null (complete no-op).
|
|
||||||
this.onMetricFn = config.onMetric ?? null;
|
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
baseURL: this.apiUrl,
|
baseURL: this.apiUrl,
|
||||||
// Default request timeout so a hung connection cannot wedge a per-page
|
// Default request timeout so a hung connection cannot wedge a per-page
|
||||||
@@ -463,10 +447,6 @@ export class DocmostClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
connectTimer = setTimeout(() => {
|
connectTimer = setTimeout(() => {
|
||||||
// Only the actual 25s collab connect timeout fires here — the agent's
|
|
||||||
// collab connection to the server never became ready. This is the
|
|
||||||
// connect-vs-unload signal; the other finish() paths must NOT emit it.
|
|
||||||
this.onMetricFn?.("collab_connect_timeouts_total", 1);
|
|
||||||
finish(new Error("Connection timeout to collaboration server"));
|
finish(new Error("Connection timeout to collaboration server"));
|
||||||
}, CONNECT_TIMEOUT_MS);
|
}, CONNECT_TIMEOUT_MS);
|
||||||
|
|
||||||
|
|||||||
+96
-59
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join } from "path";
|
||||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||||
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||||
|
|
||||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||||
@@ -64,33 +64,6 @@ const jsonContent = (data: any) => ({
|
|||||||
* REST + the collaboration WebSocket using the provided service-account
|
* REST + the collaboration WebSocket using the provided service-account
|
||||||
* credentials and auto-re-authenticates.
|
* credentials and auto-re-authenticates.
|
||||||
*/
|
*/
|
||||||
/**
|
|
||||||
* Wrap a tool handler so its wall-clock duration is reported through the host's
|
|
||||||
* dependency-neutral sink as `mcp_tool_duration_seconds` (labelled by tool
|
|
||||||
* name). Pure and side-effect-free apart from the optional `onMetric` call:
|
|
||||||
* - preserves the handler's exact return value (awaited);
|
|
||||||
* - observes in a `finally`, so it records on BOTH success and throw, then
|
|
||||||
* rethrows the original error unchanged (never swallowed);
|
|
||||||
* - with no `onMetric` (standalone/stdio) it is a transparent pass-through.
|
|
||||||
* Exported so the timing contract can be unit-tested without a live transport.
|
|
||||||
*/
|
|
||||||
export function timeToolHandler(
|
|
||||||
name: string,
|
|
||||||
handler: (...args: any[]) => any,
|
|
||||||
onMetric?: (name: string, value: number, labels?: Record<string, string>) => void,
|
|
||||||
): (...args: any[]) => Promise<any> {
|
|
||||||
return async (...handlerArgs: any[]) => {
|
|
||||||
const start = performance.now();
|
|
||||||
try {
|
|
||||||
return await handler(...handlerArgs);
|
|
||||||
} finally {
|
|
||||||
onMetric?.("mcp_tool_duration_seconds", (performance.now() - start) / 1000, {
|
|
||||||
tool: name,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
||||||
// Pass the whole config union through: the client branches internally on
|
// Pass the whole config union through: the client branches internally on
|
||||||
// credentials vs. getToken, so both the external /mcp (creds) and the
|
// credentials vs. getToken, so both the external /mcp (creds) and the
|
||||||
@@ -105,26 +78,6 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
{ instructions: SERVER_INSTRUCTIONS },
|
{ instructions: SERVER_INSTRUCTIONS },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Single choke point for MCP tool timing. Both `registerShared` (below) and
|
|
||||||
// the inline `server.registerTool(...)` calls funnel through this one method,
|
|
||||||
// so monkeypatching it HERE — before any tool is registered and before
|
|
||||||
// `registerShared` captures a reference to it — times every tool with no
|
|
||||||
// per-tool boilerplate. The wrapped handler records wall-clock duration and,
|
|
||||||
// in a `finally`, feeds the host's dependency-neutral sink
|
|
||||||
// `config.onMetric("mcp_tool_duration_seconds", seconds, { tool })`. The tool
|
|
||||||
// name is the registration name (bounded cardinality). When no onMetric is
|
|
||||||
// provided (standalone/stdio) the wrapper is a pure pass-through: it still
|
|
||||||
// returns the original result and rethrows the original error unchanged.
|
|
||||||
const originalRegisterTool = server.registerTool.bind(server) as (
|
|
||||||
...args: any[]
|
|
||||||
) => any;
|
|
||||||
(server as any).registerTool = (...args: any[]) => {
|
|
||||||
const name = args[0] as string;
|
|
||||||
const handler = args[args.length - 1];
|
|
||||||
const timedHandler = timeToolHandler(name, handler, config.onMetric);
|
|
||||||
return originalRegisterTool(...args.slice(0, -1), timedHandler);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
|
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
|
||||||
// the canonical name + model-facing description + (optional) schema builder;
|
// the canonical name + model-facing description + (optional) schema builder;
|
||||||
// only the execute body is supplied per call. buildShape is invoked with THIS
|
// only the execute body is supplied per call. buildShape is invoked with THIS
|
||||||
@@ -427,10 +380,43 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: insert_image
|
// Tool: insert_image
|
||||||
// Schema + description now live in the shared registry (#410) so BOTH this MCP
|
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
|
||||||
// server and the in-app AI-chat agent expose it. The execute body is unchanged.
|
// exposes no image tools (insert/replace), so there is no second layer to unify
|
||||||
registerShared(
|
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
|
||||||
SHARED_TOOL_SPECS.insertImage,
|
// catalog-partition test forbids a spec without a live in-app tool (#294).
|
||||||
|
server.registerTool(
|
||||||
|
"insert_image",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Download an image from a web (http/https) URL and insert it into " +
|
||||||
|
"a page in one step. By default " +
|
||||||
|
"appends the image at the end of the page. With replaceText, replaces the " +
|
||||||
|
"first top-level block whose text contains that string (handy for " +
|
||||||
|
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
|
||||||
|
"With afterText, inserts the image right after the first block containing " +
|
||||||
|
"that string. Preserves all other block ids.",
|
||||||
|
inputSchema: {
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
imageUrl: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("http(s) URL of the image to download and upload"),
|
||||||
|
align: z.enum(["left", "center", "right"]).optional(),
|
||||||
|
alt: z.string().optional(),
|
||||||
|
replaceText: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Replace the first top-level block whose text contains this string with the image",
|
||||||
|
),
|
||||||
|
afterText: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Insert the image right after the first top-level block whose text contains this string",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
|
async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
|
||||||
const result = await docmostClient.insertImage(pageId, imageUrl, {
|
const result = await docmostClient.insertImage(pageId, imageUrl, {
|
||||||
align,
|
align,
|
||||||
@@ -443,9 +429,34 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: replace_image
|
// Tool: replace_image
|
||||||
// Schema + description now live in the shared registry (#410).
|
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
|
||||||
registerShared(
|
server.registerTool(
|
||||||
SHARED_TOOL_SPECS.replaceImage,
|
"replace_image",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Replace an existing image on a page with a new image fetched from a web " +
|
||||||
|
"(http/https) URL: uploads the new file as a NEW " +
|
||||||
|
"attachment (fresh clean URL that renders and busts browser caches), then " +
|
||||||
|
"repoints every image node referencing the old attachmentId (recursively, " +
|
||||||
|
"incl. callouts/tables) via the live document, preserving comments, " +
|
||||||
|
"alignment and alt. The old attachment is left as an unreferenced orphan " +
|
||||||
|
"(Docmost has no API to delete a single attachment; it is removed only when " +
|
||||||
|
"the page/space is deleted). In-place byte overwrite is avoided because some " +
|
||||||
|
"Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
|
||||||
|
inputSchema: {
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
attachmentId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("attachmentId of the image currently in the page to replace"),
|
||||||
|
imageUrl: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("http(s) URL of the new image to download"),
|
||||||
|
align: z.enum(["left", "center", "right"]).optional(),
|
||||||
|
alt: z.string().optional(),
|
||||||
|
},
|
||||||
|
},
|
||||||
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
|
async ({ pageId, attachmentId, imageUrl, align, alt }) => {
|
||||||
const result = await docmostClient.replaceImage(
|
const result = await docmostClient.replaceImage(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -768,10 +779,36 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: insert_footnote
|
// Tool: insert_footnote
|
||||||
// Schema + description now live in the shared registry (#410) so the in-app
|
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
|
||||||
// AI-chat agent exposes it too. The execute body is unchanged.
|
// footnote tool, so there is no second layer to unify — stays inline (#294).
|
||||||
registerShared(
|
server.registerTool(
|
||||||
SHARED_TOOL_SPECS.insertFootnote,
|
"insert_footnote",
|
||||||
|
{
|
||||||
|
description:
|
||||||
|
"Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " +
|
||||||
|
"and WHAT (text). The footnote marker is placed right after anchorText in " +
|
||||||
|
"the body, and the bottom footnotes list + the numbering are derived " +
|
||||||
|
"deterministically server-side. You do NOT assign a number, and you " +
|
||||||
|
"never see or edit the footnotes list — so footnotes cannot end up out " +
|
||||||
|
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
|
||||||
|
"SAME text already exists, its number is REUSED (one definition, several " +
|
||||||
|
"references). The write is atomic and won't clobber concurrent edits; if " +
|
||||||
|
"anchorText is not found, nothing is written and an error is returned.",
|
||||||
|
inputSchema: {
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
anchorText: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"A snippet of existing body text; the footnote marker is inserted " +
|
||||||
|
"immediately after its first occurrence (mark-safe).",
|
||||||
|
),
|
||||||
|
text: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe("The footnote content as markdown (becomes the definition)."),
|
||||||
|
},
|
||||||
|
},
|
||||||
async ({ pageId, anchorText, text }) => {
|
async ({ pageId, anchorText, text }) => {
|
||||||
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
|
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { JSDOM } from "jsdom";
|
|||||||
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||||
import { withPageLock } from "./page-lock.js";
|
import { withPageLock } from "./page-lock.js";
|
||||||
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
|
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
import { summarizeChange, VerifyReport } from "./diff.js";
|
import { summarizeChange, VerifyReport } from "./diff.js";
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +1,136 @@
|
|||||||
/**
|
/**
|
||||||
* Legacy footnote advisory for imported Markdown (issue #166, reduced in #414).
|
* Legacy footnote diagnostics for imported Markdown (issue #166).
|
||||||
*
|
*
|
||||||
* Since #293 STEP 5 the canonical import form is inline `^[body]` footnotes
|
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
||||||
* (handled by `@docmost/prosemirror-markdown`). LEGACY reference-style
|
* conversion path, so it reports the same problems for `create_page`,
|
||||||
* `[^id]: …` definition markup is now INERT on import — the importer leaves it as
|
* `update_page` and `import_page_markdown`). It never changes the document — the
|
||||||
* literal text — so authoring it silently produces broken footnotes (the #410
|
* importer still creates the page; this only surfaces footnote problems to the
|
||||||
* incident class). Rather than the old, elaborate diagnostics of every problem
|
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
||||||
* SHAPE (dangling/duplicate/empty/in-table) that no longer describe what the
|
|
||||||
* importer builds, this module surfaces ONE advisory warning whenever legacy
|
|
||||||
* reference-style definition syntax is present, nudging the author to the inline
|
|
||||||
* form. It never changes the document — the importer still creates the page.
|
|
||||||
*
|
*
|
||||||
* The scan is fence-aware: a `[^id]:` line inside a ``` / ~~~ code block is
|
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
|
||||||
* example text, not markup, so it never triggers the warning.
|
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems
|
||||||
|
* cannot arise. This scan therefore targets the LEGACY reference-style
|
||||||
|
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
|
||||||
|
* text). The warnings remain useful as an advisory nudge when an agent still
|
||||||
|
* authors the old syntax, but they no longer describe what the importer builds.
|
||||||
|
*
|
||||||
|
* Detected problems:
|
||||||
|
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
||||||
|
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
||||||
|
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
||||||
|
* first would have been kept under the old first-wins import).
|
||||||
|
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
||||||
|
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
||||||
|
* render as expected.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** A legacy footnote DEFINITION line: `[^id]:` at the start of a (non-fenced) line. */
|
import {
|
||||||
const FOOTNOTE_DEF_RE = /^\[\^[^\]\s]+\]:/;
|
lexFootnoteLines,
|
||||||
/** Opening/closing code fence marker (``` or ~~~). */
|
forEachFootnoteReference,
|
||||||
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
} from "./footnote-lex.js";
|
||||||
|
|
||||||
/** The single advisory shown when legacy reference-style footnotes are present. */
|
export interface FootnoteDiagnostics {
|
||||||
export const LEGACY_FOOTNOTE_WARNING =
|
/** Reference ids (distinct, document order) with no matching definition. */
|
||||||
"Reference-style footnotes (`[^id]: …`) are not parsed on import and will " +
|
danglingReferences: string[];
|
||||||
"appear as literal text. Use inline footnotes instead: `^[footnote text]`.";
|
/** Definition ids whose first (kept) text is empty/whitespace. */
|
||||||
|
emptyDefinitions: string[];
|
||||||
|
/** Ids defined by two or more `[^id]:` lines (only the first is kept). */
|
||||||
|
duplicateDefinitions: string[];
|
||||||
|
/** Reference ids found inside a GFM table row (heuristic). */
|
||||||
|
referencesInTables: string[];
|
||||||
|
/** Human-readable warning lines for the tool result (one per problem class). */
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `markdown` contains a legacy `[^id]:` definition line OUTSIDE any
|
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
|
||||||
* code fence. Pure; safe to call on any body.
|
|
||||||
*/
|
*/
|
||||||
export function hasLegacyFootnoteDefinition(markdown: string): boolean {
|
export function analyzeFootnotes(markdown: string): FootnoteDiagnostics {
|
||||||
if (typeof markdown !== "string" || !markdown.includes("[^")) return false;
|
// Distinct reference ids in first-appearance order, plus the set of ids seen
|
||||||
let fence: string | null = null;
|
// inside a table row.
|
||||||
for (const line of markdown.split("\n")) {
|
const refIds: string[] = [];
|
||||||
const fenceMatch = FENCE_RE.exec(line);
|
const refIdSet = new Set<string>();
|
||||||
if (fenceMatch) {
|
const referencesInTables = new Set<string>();
|
||||||
const marker = fenceMatch[1][0];
|
const addRef = (id: string, inTable: boolean) => {
|
||||||
if (fence === null) fence = marker; // opening fence
|
if (!refIdSet.has(id)) {
|
||||||
else if (marker === fence) fence = null; // matching closing fence
|
refIdSet.add(id);
|
||||||
|
refIds.push(id);
|
||||||
|
}
|
||||||
|
if (inTable) referencesInTables.add(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Definition texts per id, in first-appearance order of the id.
|
||||||
|
const defTextsById = new Map<string, string[]>();
|
||||||
|
|
||||||
|
// Same lexer the importer uses, so the analysis matches exactly what import
|
||||||
|
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
|
||||||
|
for (const tok of lexFootnoteLines(markdown)) {
|
||||||
|
if (tok.inFence) continue;
|
||||||
|
if (tok.definition) {
|
||||||
|
const { id, text } = tok.definition;
|
||||||
|
const arr = defTextsById.get(id);
|
||||||
|
if (arr) arr.push(text);
|
||||||
|
else defTextsById.set(id, [text]);
|
||||||
|
// A definition's TEXT can itself reference another footnote (`[^a]: see
|
||||||
|
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
|
||||||
|
forEachFootnoteReference(text, (rid) => addRef(rid, false));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (fence !== null) continue; // inside a fence: inert example text
|
const inTable = tok.line.trimStart().startsWith("|");
|
||||||
if (FOOTNOTE_DEF_RE.test(line)) return true;
|
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
|
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
|
||||||
|
const duplicateDefinitions: string[] = [];
|
||||||
|
const emptyDefinitions: string[] = [];
|
||||||
|
for (const [id, texts] of defTextsById) {
|
||||||
|
if (texts.length >= 2) duplicateDefinitions.push(id);
|
||||||
|
// First-wins: the kept definition is the first one; flag it if it is blank.
|
||||||
|
if ((texts[0] ?? "").trim().length === 0) emptyDefinitions.push(id);
|
||||||
|
}
|
||||||
|
const tableRefs = [...referencesInTables];
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const list = (ids: string[]) => ids.map((id) => `[^${id}]`).join(", ");
|
||||||
|
if (danglingReferences.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (emptyDefinitions.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (duplicateDefinitions.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tableRefs.length > 0) {
|
||||||
|
warnings.push(
|
||||||
|
`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
danglingReferences,
|
||||||
|
emptyDefinitions,
|
||||||
|
duplicateDefinitions,
|
||||||
|
referencesInTables: tableRefs,
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The optional `footnoteWarnings` field for a page-write tool result: present
|
* The optional `footnoteWarnings` field for a page-write tool result: present
|
||||||
* (with the single advisory) only when `markdown` uses legacy reference-style
|
* (with the warning lines) only when `markdown` has footnote problems, omitted
|
||||||
* footnote syntax, omitted otherwise. One helper so all three call sites
|
* otherwise. One helper so all three call sites (create/update/import) attach the
|
||||||
* (create/update/import) attach the field identically. Spread into the result:
|
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
|
||||||
* `{ ...result, ...footnoteWarningsField(text) }`.
|
|
||||||
*/
|
*/
|
||||||
export function footnoteWarningsField(markdown: string): {
|
export function footnoteWarningsField(markdown: string): {
|
||||||
footnoteWarnings?: string[];
|
footnoteWarnings?: string[];
|
||||||
} {
|
} {
|
||||||
return hasLegacyFootnoteDefinition(markdown)
|
const { warnings } = analyzeFootnotes(markdown);
|
||||||
? { footnoteWarnings: [LEGACY_FOOTNOTE_WARNING] }
|
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
|
||||||
: {};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* Inline-authoring helpers for footnotes (MCP).
|
||||||
|
*
|
||||||
|
* These build/identify footnote DEFINITION nodes for the author-inline tool
|
||||||
|
* (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes
|
||||||
|
* by text, a definition-node factory, and a fresh uuidv7-style id generator.
|
||||||
|
*
|
||||||
|
* Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of
|
||||||
|
* the editor-ext canonicalizer (compositionally symmetric to the editor-ext
|
||||||
|
* copy, which keeps its authoring helpers in `footnote-util.ts`). The pure
|
||||||
|
* canonicalizer has no dependency on these.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||||
|
|
||||||
|
function cloneJson<T>(v: T): T {
|
||||||
|
if (typeof structuredClone === "function") return structuredClone(v);
|
||||||
|
return JSON.parse(JSON.stringify(v)) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
|
||||||
|
*
|
||||||
|
* Two definitions with the same key are the SAME footnote — so the inline
|
||||||
|
* authoring tool reuses one id (one number, one definition, several references)
|
||||||
|
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
|
||||||
|
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
|
||||||
|
* read the same but differ in formatting (one bold, one plain) are NOT merged.
|
||||||
|
* Conservative: only an exact match merges.
|
||||||
|
*/
|
||||||
|
export function footnoteContentKey(defNode: any): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const visit = (n: any): void => {
|
||||||
|
if (!n || typeof n !== "object") return;
|
||||||
|
if (n.type === "text" && typeof n.text === "string") {
|
||||||
|
const marks = Array.isArray(n.marks)
|
||||||
|
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
|
||||||
|
: "";
|
||||||
|
parts.push(`${n.text}${marks}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
|
||||||
|
};
|
||||||
|
visit(defNode);
|
||||||
|
// Collapse the assembled text's whitespace and trim, keeping the mark
|
||||||
|
// signature attached so formatting differences still distinguish notes.
|
||||||
|
return parts
|
||||||
|
.join("")
|
||||||
|
.replace(/[ \t\r\n]+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
|
||||||
|
*/
|
||||||
|
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
|
||||||
|
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
|
||||||
|
return {
|
||||||
|
type: FOOTNOTE_DEFINITION_NAME,
|
||||||
|
attrs: { id },
|
||||||
|
content: [{ type: "paragraph", content }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
|
||||||
|
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
|
||||||
|
*/
|
||||||
|
export function generateFootnoteId(): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const timeHex = now.toString(16).padStart(12, "0");
|
||||||
|
const rand = (length: number) => {
|
||||||
|
let s = "";
|
||||||
|
for (let i = 0; i < length; i++)
|
||||||
|
s += Math.floor(Math.random() * 16).toString(16);
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
const versioned = "7" + rand(3);
|
||||||
|
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||||
|
const variant = variantNibble + rand(3);
|
||||||
|
return (
|
||||||
|
timeHex.slice(0, 8) +
|
||||||
|
"-" +
|
||||||
|
timeHex.slice(8, 12) +
|
||||||
|
"-" +
|
||||||
|
versioned +
|
||||||
|
"-" +
|
||||||
|
variant +
|
||||||
|
"-" +
|
||||||
|
rand(12)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
||||||
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
||||||
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
||||||
* than imported from editor-ext — for the SAME reason the `docmost-schema.ts`
|
* than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the
|
||||||
* nodes are mirrored: the MCP package is deliberately
|
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately
|
||||||
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
||||||
* JSON. The editor-ext copy owns the golden test against the live plugin; this
|
* JSON. The editor-ext copy owns the golden test against the live plugin; this
|
||||||
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
|
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
|
||||||
@@ -13,8 +13,8 @@
|
|||||||
*
|
*
|
||||||
* This module is the pure MIRROR only. The inline-authoring helpers
|
* This module is the pure MIRROR only. The inline-authoring helpers
|
||||||
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
||||||
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
|
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this
|
||||||
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
|
* file is compositionally symmetric to the editor-ext copy.
|
||||||
*
|
*
|
||||||
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
||||||
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal).
|
||||||
|
*
|
||||||
|
* Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared
|
||||||
|
* `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this
|
||||||
|
* lexer no longer backs an mcp importer. It now backs ONLY the import-time
|
||||||
|
* diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the
|
||||||
|
* raw markdown for legacy reference-style `[^id]:` definition lines and surface
|
||||||
|
* advisory warnings (duplicate/orphan definitions) about content that is now
|
||||||
|
* inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is
|
||||||
|
* NOT a definition) is the property the analyzer relies on.
|
||||||
|
*
|
||||||
|
* NOTE: this is deliberately NOT shared with editor-ext's
|
||||||
|
* `extractFootnoteDefinitions` — that lives in a different package and the
|
||||||
|
* decoupling between the editor and the MCP mirror is intentional.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */
|
||||||
|
export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
||||||
|
/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */
|
||||||
|
export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g;
|
||||||
|
/** Opening/closing code fence marker (``` or ~~~). */
|
||||||
|
const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||||
|
|
||||||
|
export interface FootnoteLine {
|
||||||
|
/** The raw line, verbatim. */
|
||||||
|
line: string;
|
||||||
|
/**
|
||||||
|
* True for a code-fence marker line AND every line inside a fence — footnote
|
||||||
|
* syntax on such lines is inert (example text, not real markup). The importer
|
||||||
|
* keeps these in the body; the analyzer skips them.
|
||||||
|
*/
|
||||||
|
inFence: boolean;
|
||||||
|
/** The parsed definition, when this is a `[^id]: text` line OUTSIDE any fence. */
|
||||||
|
definition: { id: string; text: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Classify every line of `markdown`, tracking fenced-code state. Pure. */
|
||||||
|
export function lexFootnoteLines(markdown: string): FootnoteLine[] {
|
||||||
|
const out: FootnoteLine[] = [];
|
||||||
|
let fence: string | null = null;
|
||||||
|
for (const line of markdown.split("\n")) {
|
||||||
|
const fenceMatch = FENCE_RE.exec(line);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const marker = fenceMatch[2][0];
|
||||||
|
if (fence === null) fence = marker; // opening fence
|
||||||
|
else if (marker === fence) fence = null; // matching closing fence
|
||||||
|
out.push({ line, inFence: true, definition: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence !== null) {
|
||||||
|
out.push({ line, inFence: true, definition: null });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const m = FOOTNOTE_DEF_RE.exec(line);
|
||||||
|
out.push({
|
||||||
|
line,
|
||||||
|
inFence: false,
|
||||||
|
definition: m ? { id: m[1], text: m[2] } : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */
|
||||||
|
export function forEachFootnoteReference(
|
||||||
|
line: string,
|
||||||
|
onRef: (id: string) => void,
|
||||||
|
): void {
|
||||||
|
FOOTNOTE_REF_RE_G.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null) onRef(m[1]);
|
||||||
|
}
|
||||||
@@ -305,21 +305,6 @@ export function applyTextEdits(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is
|
|
||||||
// markdown that only becomes a real footnote when a whole markdown body is
|
|
||||||
// written (create_page / update_page_content / import_page_markdown). Written
|
|
||||||
// through edit_page_text it stays a LITERAL string in the text — the exact
|
|
||||||
// failure mode #410 fixes — so refuse it here (defense-in-depth) and point the
|
|
||||||
// caller at insert_footnote, mirroring the formatting-marker refusal above.
|
|
||||||
if (/\^\[[\s\S]*?\]/.test(edit.replace)) {
|
|
||||||
failed.push({
|
|
||||||
find: edit.find,
|
|
||||||
reason:
|
|
||||||
"edit_page_text writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insert_footnote (anchorText = where, text = the note).",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather every inline block in document order (recurse the whole tree so
|
// Gather every inline block in document order (recurse the whole tree so
|
||||||
// nested containers — callouts, list items, table cells, blockquotes — are
|
// nested containers — callouts, list items, table cells, blockquotes — are
|
||||||
// all covered).
|
// all covered).
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user