Compare commits

..

10 Commits

Author SHA1 Message Date
agent_coder 1644de87f7 fix(#370): F2 terminal skip reply for empty/missing page + F1 lifecycle tests
Reviewer changes-requested on #565.

F2 (real bug): a save on an empty page (or a missing page row) hung the client
for the full 20s ack window and then falsely reported the collab server as
unreachable + advised retry. Root cause: handleSaveVersion gated its
broadcastStateless on `if (result)`, and `result` stays undefined on the two
reachable early returns (isEmptyParagraphDoc and !page), so the server sent
NOTHING and the client waited out its timeout.
- SERVER (persistence.extension.ts): both early-return branches now record a
  skip reason and the tail broadcasts a terminal `version.skipped` reply
  (reason 'empty' | 'page-not-found'). Exactly one terminal reply per handled
  save. Added VERSION_SAVED/VERSION_SKIPPED message consts.
- CLIENT (collaboration.ts): the predicate now matches both version.saved and
  version.skipped; SaveVersionResult gains {saved, skipped, reason}. An empty
  skip resolves to a clean {saved:false, skipped:true, reason:'empty'} (no
  stall); page-not-found throws an immediate, truthful error; the genuine
  no-reply timeout path is unchanged (that is the real "unreachable" case). A
  terminal reply leaves the session cached (healthy connection); only a
  transport failure destroys it. Kept the message-literal in-sync comments.
- tool-specs.ts / pages.ts: description + docstring reflect the new result shape.

F1 (coverage): added tests for the 4 previously-uncovered sendStatelessAndAwait
branches — unrelated-then-real (predicate filters noise), teardown-mid-wait
(rejects + removes the stateless listener, no leak), concurrent-in-flight
guard — plus the F2 empty-skip / page-not-found client outcomes. Server spec
gains empty-page and page-not-found terminal-reply tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_coder 498a87f3c4 fix(#370): add savePageVersion to DocmostClientMethod union (server in-app tool parity)
The Stage-A save_page_version tool's client method must be listed in the
DocmostClientMethod union so Pick<DocmostClient, DocmostClientMethod> exposes it
to the in-app tool adapter; the shared-tool-specs contract spec asserts parity.
Mirrors the existing restorePageVersion entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_coder 67c94de6ef feat(mcp): agent save_page_version tool — collab stateless transport + role prompts (#370)
Stage A of #370: wire the MCP client transport for the already-shipped server
save-version handler (PR-1 #374). An agent can now pin an intentional named
version (kind='agent', derived server-side from the signed actor) of a page's
CURRENT live collaboration content.

- collab-session.ts: add CollabSession.sendStatelessAndAwait(payload, predicate,
  timeoutMs) — sends a stateless message over the live provider and resolves on
  the first matching reply, with a bounded timeout; lifecycle (inflightReject,
  ready-guard, concurrent fail-fast, idle re-arm) mirrors mutate(). Extend
  CollabProviderLike with sendStateless + the stateless on/off overloads.
- collaboration.ts: add savePageVersionRealtime() — under withPageLock, reuse the
  cached agent-authenticated CollabSession (#400), send {type:'save-version'},
  await {type:'version.saved', …}; no REST read (would race the stale page row).
- client/pages.ts: add savePageVersion(pageId) — resolvePageId +
  getCollabTokenWithReauth + writeWithCollabAuthRetry (#486 self-heal).
- tool-specs.ts: add the deferred savePageVersion spec (+ DocmostClientLike Pick);
  auto-registered on both hosts by the shared loops.
- server-instructions.ts: HISTORY family + routing-prose mention.
- ai-chat-tools.service.ts: contract type-assert for the new client method.
- agent-roles-catalog: tell the content-authoring roles (researcher,
  call-summarizer, ru+en) to save a version when the document is done; bump
  their catalog versions.
- test: unit coverage for savePageVersionRealtime (ack + bounded-timeout paths).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 19:23:07 +03:00
agent_vscode 6b825ad440 fix(client): anchor toasts to the top edge of the viewport
Move the top-anchored Mantine notification containers from top:96px to
top:8px so success toasts (e.g. "Comment resolved successfully") appear
at the very top of the page, above the header/search, instead of
covering page content. Update the accompanying comment to reflect the
new intent (toast renders over the header chrome while visible).
2026-07-12 19:09:30 +03:00
agent_vscode 0cee2cc9dc Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 19:09:13 +03:00
agent_vscode e4af672c2c feat(ui): add UI components for comments, history, and time
Introduce redesigned UI elements for the new design system:
- **CommentsPanel** – displays agent‑driven edits with diff view, batch actions, and filtering.
- **PageHistoryModal** – full‑screen modal for browsing page revisions, includes a mini‑calendar and version rendering.
- **TimeWorkedModal** – visualizes work and agent sessions per day with grid or phase axes.

These components replace legacy panels, use Mantine v7, support dark/light themes, and provide richer interactions.
2026-07-12 19:09:03 +03:00
vvzvlad ec8dd7d110 Merge pull request 'fix(prosemirror-markdown): литеральный <br> в тексте экранируется, не становится hardBreak (#554)' (#560) from fix/554-literal-br into develop
Reviewed-on: #560
2026-07-12 18:57:02 +03:00
vvzvlad 4423b19850 Merge pull request 'fix(ai-chat): реактивная рекавери — итеративная эскалация 0.5^k + пол вместо фикс. 0.5× (#520)' (#546) from fix/520-recovery-escalation into develop
Reviewed-on: #546
2026-07-12 18:56:49 +03:00
agent_coder 688cb54f26 fix(ai-chat): рекавери режет реплей НИЖЕ настроенного бюджета (#520 Опция B)
Решение владельца по #520 (эпик #497 итерация 5): при повторном overflow реактивная
рекавери ДОЛЖНА иметь право резать бюджет реплея ниже явно настроенного окна —
«чат не должен кирпичиться на overflow» это ИНВАРИАНТ реактивной ветки, а
настроенное окно — заявление о ёмкости модели, не обещание о размере реплея; когда
провайдер 400-ит, реальность бьёт конфиг. (Не конфликтует с #510 Опция A: та про
верхнюю границу НОРМАЛЬНОГО пути — уважать конфиг, пока он влезает.)

- Пол эскалации: max(scaled, min(REPLAY_MIN_FLOOR_TOKENS, floor(0.5×threshold))).
  Было min(REPLAY_MIN_FLOOR_TOKENS, threshold) — жёсткий пол на настроенном бюджете.
  Теперь БОЛЬШОЕ окно эскалирует НИЖЕ настроенного бюджета до фикс. пола 8k; МАЛОЕ
  окно (0.5×threshold < 8k) падает до floor(0.5×threshold) — минимум старый 0.5× cut
  (никогда не хуже, чем раньше), но и не поднимается выше самого бюджета.
  REPLAY_MIN_FLOOR_TOKENS=8k без изменений (константа уже была). Сброс k на чистом
  ходу сохранён (счётчик replayOverflowCount не пишется на чистом финализе).
- Наблюдаемость: warn-лог (настроенный бюджет не влезает, реплей ниже него, уровень
  k) + метка турна metadata.replayBelowConfiguredBudget=true — только когда реплей
  реально ниже настроенного бюджета (не на нормальном in-budget реплее).
- Тесты: перепиновка «не раздувает малый бюджет выше себя» (теперь МОЖЕТ резать ниже,
  Опция B) в обоих spec; новый тест эскалации ниже бюджета (окно 8000→бюджет 5600,
  k=1→2800, сходимость к полу, сброс на чистом ходу) + большое окно (140k→8k
  ступенями); тест метки метадаты. Мутации: пол=бюджет краснит below-budget тесты,
  снятие метки краснит observability-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:01:07 +03:00
agent_coder ec5416068b fix(ai-chat): итеративная эскалация реактивной рекавери при overflow (#520)
Остаточный кирпич (#490/#510): при незаданном chatContextWindow база = плоский
дефолт 100k, а реальное окно модели маленькое (<50k). Фиксированный одинарный
cut 0.5×100k=50k всё равно превышал реальное окно → провайдер снова 400,
строка переставлялась replayOverflow, но булев priorOverflowed уже был true →
второго ужатия не происходило. Чат навсегда застревал на 50k и не восстанавливался.

Фикс (без парсинга тел 400):
- Сигнал из булева переведён в счётчик `metadata.replayOverflowCount` = число
  ПОДРЯД идущих overflow-ходов: инкремент (prior+1) на каждом overflow, сброс в 0
  на любом чистом финализе (чистая строка не пишет поле → читается как 0).
  BACK-COMPAT: старая строка с булевым `replayOverflow:true` читается как k=1.
- resolveEffectiveReplayThreshold(threshold, k) = max(floor(threshold·0.5**k),
  min(REPLAY_MIN_FLOOR_TOKENS, threshold)). k=0 → база; k=1 → 0.5×; k=2 → 0.25×;
  большой k → упирается в пол 8k (сходимость). null-база (trimming OFF) не трогается;
  пол никогда не поднимает легитимно малый настроенный бюджет выше него самого.
- Пол REPLAY_MIN_FLOOR_TOKENS=8k: ниже него чат не несёт осмысленный недавний
  контекст, и даже малое реальное окно его вмещает; keep-recent-turns сверху.

Тесты: таблица эскалации (k=0/1/2/большой/null), регрессия остаточного кирпича
(база 100k, окно ~40k → сходится ниже 40k, чего фиксированный 0.5× никогда не мог),
жизненный цикл счётчика на реальном pg (инкремент/сброс/back-compat через jsonb),
мутация **k→**1 краснит convergence-тест.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 18:01:07 +03:00
23 changed files with 2333 additions and 90 deletions
+460
View File
@@ -0,0 +1,460 @@
/**
* CommentsPanel — редизайн панели комментариев с фокусом на агентские правки.
* Mantine v7. Узкая колонка ~340–400px, светлая/тёмная темы.
*
* Реализованные решения (см. README.md):
* - Дифф-первая карточка: цитата = старая строка диффа (без тройного дублирования)
* - Группировка серии прогона под одной шапкой ("Корректор · 28 правок")
* - Пакетное "Accept all" на фронте (по одной под капотом) с полоской прогресса
* - Метки важности/категории парсятся клиентом из тегов "[Корректура][Существенно]"
* - Фильтры по важности/категории
* - Безопасный Dismiss через undo-тост (удаление откладывается на клиенте)
* - Состояния: pending / applied / dismissed / conflict (потерян якорь)
* - Крайние диффы: вставка (␣), удаление, одна буква, дефис→тире, длинный абзац
* - Человеческий тред не деградирует (та же система, другое наполнение)
* - Вкладки Open/Resolved сохранены
*/
import { useMemo, useState, useCallback, useRef } from 'react';
import {
Box, Group, Stack, Text, Badge, Button, ActionIcon, Tabs, Progress,
Avatar, Tooltip, ScrollArea, useMantineColorScheme, useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
/* ─────────────────────────── Типы данных ─────────────────────────── */
export type Severity = 'critical' | 'major' | 'minor';
export type Category = string; // "Корректура" | "Факт" | "Стиль" | …
/** Один сегмент интра-диффа: изменённый фрагмент подсвечивается. */
export interface DiffSegment {
text: string;
changed: boolean;
}
/** Правка: сервер уже отдаёт посегментную разметку обеих строк. */
export interface SuggestedEdit {
before: DiffSegment[]; // "было" (пустой массив ⇒ чистая вставка)
after: DiffSegment[]; // "стало" (пустой массив ⇒ чистое удаление)
}
export type CommentStatus = 'pending' | 'applied' | 'dismissed' | 'conflict';
export interface Comment {
id: string;
runId?: string; // id прогона агента — для группировки серии
authorName: string; // "Корректор" | "Нарратор" | имя человека
authorKind: 'agent' | 'human';
triggeredBy?: string; // кто запустил агента ("vvzvlad")
createdAtLabel: string; // "15 ч", "2 дн" — форматируется вызывающим кодом
/** Сырой текст комментария от агента, метки в квадратных скобках. */
bodyRaw: string;
edit?: SuggestedEdit; // есть ⇒ агентская правка; нет ⇒ обычный тред
status: CommentStatus;
replyCount?: number;
anchorLost?: boolean; // сервер ответил конфликтом якоря
}
export interface CommentsPanelProps {
comments: Comment[];
/** Применить одну правку. Резолвит тред на сервере. */
onApply: (id: string) => Promise<void>;
/** Жёсткое удаление на сервере (для треда без ответов). Вызывается ПОСЛЕ окна undo. */
onDismiss: (id: string) => Promise<void>;
/** Резолв обычного треда. */
onResolve?: (id: string) => Promise<void>;
/** Клик по карточке/цитате — скролл документа к якорю и подсветка. */
onNavigateToAnchor: (id: string) => void;
onClose?: () => void;
/** Мс до фактического удаления после Dismiss (окно undo). По умолчанию 5000. */
dismissUndoMs?: number;
}
/* ───────────────────── Клиентский парсинг меток ───────────────────── */
const SEVERITY_WORDS: Record<string, Severity> = {
'существенно': 'major', 'критично': 'critical', 'критическая': 'critical',
'незначительно': 'minor', 'мелко': 'minor', 'major': 'major',
'minor': 'minor', 'critical': 'critical',
};
interface ParsedBody {
category?: Category;
severity: Severity;
text: string; // тело без скобочных тегов
}
/** "[Корректура] [Существенно] Пробел…" → {category, severity, text}. */
export function parseBody(raw: string): ParsedBody {
const tags: string[] = [];
const text = raw.replace(/\[([^\]]+)\]/g, (_, t) => { tags.push(t.trim()); return ''; }).trim();
let severity: Severity = 'minor';
let category: Category | undefined;
for (const t of tags) {
const sv = SEVERITY_WORDS[t.toLowerCase()];
if (sv) severity = sv; else if (!category) category = t;
}
return { category, severity, text };
}
const SEV_COLOR: Record<Severity, string> = {
critical: 'red', major: 'orange', minor: 'gray',
};
/* ──────────────────────────── Дифф ──────────────────────────── */
/** Делает невидимые символы видимыми в подсветке (пробел, таб). */
function visibleWhitespace(s: string): string {
return s.replace(/ /g, '␣').replace(/\t/g, '⇥');
}
function DiffLine({ segments, kind }: { segments: DiffSegment[]; kind: 'del' | 'ins' }) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const isDel = kind === 'del';
const sign = isDel ? '−' : '+';
const signColor = isDel ? theme.colors.red[dark ? 5 : 6] : theme.colors.green[dark ? 5 : 6];
const baseColor = isDel
? (dark ? theme.colors.gray[5] : theme.colors.gray[6])
: (dark ? theme.colors.gray[1] : theme.colors.dark[9]);
const markBg = isDel
? (dark ? 'rgba(224,49,49,.22)' : '#ffe3e3')
: (dark ? 'rgba(47,158,68,.22)' : '#d3f9d8');
const markFg = isDel
? (dark ? theme.colors.red[3] : theme.colors.red[8])
: (dark ? theme.colors.green[3] : theme.colors.green[9]);
return (
<Group gap={7} wrap="nowrap" align="flex-start">
<Text ff="monospace" fw={600} fz={12} c={signColor} w={11} ta="center" style={{ flex: 'none', lineHeight: 1.5 }}>{sign}</Text>
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
{segments.map((seg, i) =>
seg.changed ? (
<Box key={i} component="mark" px={3} fw={600}
style={{ background: markBg, color: markFg, borderRadius: 3,
textDecoration: isDel ? 'line-through' : 'none' }}>
{visibleWhitespace(seg.text)}
</Box>
) : (
<Box key={i} component="span" style={{ textDecoration: isDel ? 'line-through' : 'none' }}>{seg.text}</Box>
)
)}
</Text>
</Group>
);
}
function DiffBlock({ edit }: { edit: SuggestedEdit }) {
const pureInsert = edit.before.length === 0;
const pureDelete = edit.after.length === 0;
return (
<Stack gap={1}>
{!pureInsert && <DiffLine segments={edit.before} kind="del" />}
{!pureDelete && <DiffLine segments={edit.after} kind="ins" />}
</Stack>
);
}
/* ──────────────────────── Карточка правки ──────────────────────── */
function EditCard({ c, onApply, onDismiss, onNavigateToAnchor, dismissUndoMs = 5000 }: {
c: Comment;
onApply: CommentsPanelProps['onApply'];
onDismiss: CommentsPanelProps['onDismiss'];
onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor'];
dismissUndoMs?: number;
}) {
const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]);
const [busy, setBusy] = useState(false);
const timer = useRef<number>();
const apply = useCallback(async () => {
setBusy(true);
try { await onApply(c.id); } finally { setBusy(false); }
}, [c.id, onApply]);
// Безопасный Dismiss: прячем сразу, удаляем на сервере после окна undo.
const dismiss = useCallback(() => {
let undone = false;
const nid = notifications.show({
message: 'Edit dismissed',
color: 'gray',
autoClose: dismissUndoMs,
withCloseButton: false,
// Кнопка "Вернуть" — см. README про кастомный рендер экшена.
});
timer.current = window.setTimeout(() => {
if (!undone) onDismiss(c.id);
}, dismissUndoMs);
// undo вызывается из UI: clearTimeout(timer.current); undone = true;
return { nid, cancel: () => { undone = true; clearTimeout(timer.current); } };
}, [c.id, onDismiss, dismissUndoMs]);
const conflict = c.status === 'conflict' || c.anchorLost;
return (
<Box
p="10px 12px"
onClick={() => onNavigateToAnchor(c.id)}
style={(t) => ({
cursor: 'pointer',
borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`,
})}
>
<Stack gap={8}>
{c.edit && <DiffBlock edit={c.edit} />}
{conflict && (
<Group gap={7} wrap="nowrap" p="6px 8px"
style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(230,180,20,.12)' : '#fff9db', borderRadius: 7 })}>
<Text fz={12}></Text>
<Text fz={12} c="yellow.8" style={{ lineHeight: 1.4 }}>Text changed this edit cant be applied</Text>
</Group>
)}
{parsed.text && !conflict && (
<Text fz={12.5} c="dimmed" style={{ lineHeight: 1.45 }}>{parsed.text}</Text>
)}
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Box w={8} h={8} style={(t) => ({ flex: 'none', borderRadius: '50%', background: t.colors[SEV_COLOR[parsed.severity]][6] })} />
<Text fz={10} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.06em', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{[parsed.category, parsed.severity === 'major' ? 'Major' : parsed.severity === 'critical' ? 'Critical' : null].filter(Boolean).join(' · ')}
</Text>
{c.status === 'applied' ? (
<Badge color="green" variant="light" radius="xl" size="sm"> Applied</Badge>
) : c.status === 'dismissed' ? (
<Badge color="gray" variant="light" radius="xl" size="sm">Dismissed</Badge>
) : conflict ? (
<Button size="compact-sm" variant="default" onClick={() => onNavigateToAnchor(c.id)}>Go to text</Button>
) : (
<>
<Button size="compact-sm" variant="default" color="gray" onClick={dismiss}>Dismiss</Button>
<Button size="compact-sm" color="green" loading={busy} onClick={apply}>Apply</Button>
</>
)}
</Group>
</Stack>
</Box>
);
}
/* ──────────────────────── Человеческий тред ──────────────────────── */
function HumanThread({ c, onResolve, onNavigateToAnchor }: {
c: Comment; onResolve?: CommentsPanelProps['onResolve']; onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor'];
}) {
const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]);
return (
<Box p="12px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Stack gap={9}>
<Group gap={8} wrap="nowrap">
<Avatar size={26} radius="xl" color="orange">{c.authorName[0]}</Avatar>
<Text fz={12.5} fw={600}>{c.authorName}
<Text span c="dimmed" fw={400}> · {c.triggeredBy ? `${c.triggeredBy} · ` : ''}{c.createdAtLabel}</Text>
</Text>
<Box style={{ flex: 1 }} />
<Tooltip label="Resolve"><ActionIcon variant="default" size="md" onClick={() => onResolve?.(c.id)}></ActionIcon></Tooltip>
<ActionIcon variant="default" size="md"></ActionIcon>
</Group>
<Text fz={13.5} style={{ lineHeight: 1.5 }}>{parsed.text}</Text>
<Group gap={8}>
{c.replyCount ? <Text fz={12} c="dimmed">{c.replyCount} replies</Text> : null}
<Box style={{ flex: 1 }} />
<Button variant="subtle" size="compact-sm">Reply</Button>
</Group>
</Stack>
</Box>
);
}
/* ──────────────────── Шапка серии + прогресс ──────────────────── */
function RunHeader({ runComments, onAcceptAll, progress }: {
runComments: Comment[];
onAcceptAll: () => void;
progress?: { done: number; total: number } | null;
}) {
const head = runComments[0];
const majors = runComments.filter((c) => parseBody(c.bodyRaw).severity !== 'minor').length;
const applied = runComments.filter((c) => c.status === 'applied').length;
return (
<Box>
<Group gap={10} wrap="nowrap" p="11px 13px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Box style={{ position: 'relative', width: 30, height: 30, flex: 'none' }}>
<Avatar size={30} radius={8} color="teal">{head.authorName[0]}</Avatar>
{head.triggeredBy && (
<Avatar size={14} radius="xl" color="gray"
style={{ position: 'absolute', right: -4, bottom: -4, border: '2px solid var(--mantine-color-body)' }} />
)}
</Box>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text fz={13} fw={600}>{head.authorName}
<Text span c="dimmed" fw={400}> · {head.triggeredBy} · {head.createdAtLabel}</Text>
</Text>
<Text fz={11.5} c="dimmed">
{runComments.length} edits · <Text span c="orange.7" fw={600}>{majors} major</Text> · {applied} applied
</Text>
</Stack>
<Box style={{ flex: 1 }} />
{!progress && <Button size="compact-sm" color="green" onClick={onAcceptAll}>Accept all</Button>}
</Group>
{progress && (
<Box p="9px 13px 11px" style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(47,158,68,.08)' : '#f8fbf9', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
<Group gap={8} mb={6}>
<Text fz={12} fw={600} c="green.7">Applying {progress.done} of {progress.total}</Text>
<Box style={{ flex: 1 }} />
<Button variant="subtle" color="gray" size="compact-xs">Stop</Button>
</Group>
<Progress value={(progress.done / progress.total) * 100} color="green" size="sm" radius="xl" />
</Box>
)}
</Box>
);
}
/* ──────────────────────────── Панель ──────────────────────────── */
/** Роль-автор для фильтра: у агентов — имя роли, у людей — «Пользователь». */
function roleOf(c: Comment): string {
return c.authorKind === 'human' ? 'Пользователь' : c.authorName;
}
export function CommentsPanel(props: CommentsPanelProps) {
const { comments, onApply, onDismiss, onResolve, onNavigateToAnchor, onClose, dismissUndoMs } = props;
const [tab, setTab] = useState<'open' | 'resolved'>('open');
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const [progress, setProgress] = useState<Record<string, { done: number; total: number }>>({});
const open = comments.filter((c) => c.status === 'pending' || c.status === 'conflict');
const resolved = comments.filter((c) => c.status === 'applied' || c.status === 'dismissed');
const list = tab === 'open' ? open : resolved;
const filtered = list.filter((c) => !roleFilter || roleOf(c) === roleFilter);
// Роли и счётчики для чипов-фильтров (только роли: Корректор / Фактчекер / Пользователь …).
const roles = useMemo(() => {
const order: string[] = [];
const count: Record<string, number> = {};
for (const c of list) {
const r = roleOf(c);
if (!(r in count)) { count[r] = 0; order.push(r); }
count[r]++;
}
return order.map((r) => ({ role: r, count: count[r] }));
}, [list]);
// Группировка агентских правок по runId; человеческие треды — по одному.
const groups = useMemo(() => {
const map = new Map<string, Comment[]>();
const singles: Comment[] = [];
for (const c of filtered) {
if (c.authorKind === 'agent' && c.runId && c.edit) {
if (!map.has(c.runId)) map.set(c.runId, []);
map.get(c.runId)!.push(c);
} else singles.push(c);
}
return { runs: [...map.entries()], singles };
}, [filtered]);
// Пакетное "Accept all" — по одной на фронте, обновляем прогресс.
const acceptAll = useCallback(async (runId: string, items: Comment[]) => {
const minor = items.filter((c) => parseBody(c.bodyRaw).severity === 'minor' && c.status === 'pending');
for (let i = 0; i < minor.length; i++) {
setProgress((p) => ({ ...p, [runId]: { done: i, total: minor.length } }));
try { await onApply(minor[i].id); } catch { /* конфликт — пропускаем, копим для сводки */ }
}
setProgress((p) => { const n = { ...p }; delete n[runId]; return n; });
notifications.show({ color: 'green', message: `${minor.length} applied` });
}, [onApply]);
return (
<Stack gap={0} h="100%" style={{ width: 380, maxWidth: '100%', borderLeft: '1px solid var(--mantine-color-default-border)' }}>
{/* Вкладки — статусная ось. Open/Resolved сохранены. */}
<Group gap={4} p="10px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
<Tabs value={tab} onChange={(v) => setTab(v as 'open' | 'resolved')} variant="pills">
<Tabs.List>
<Tabs.Tab value="open" rightSection={<Badge size="sm" variant="light" color="blue">{open.length}</Badge>}>Open</Tabs.Tab>
<Tabs.Tab value="resolved" rightSection={<Badge size="sm" variant="light" color="gray">{resolved.length}</Badge>}>Resolved</Tabs.Tab>
</Tabs.List>
</Tabs>
<Box style={{ flex: 1 }} />
{onClose && <ActionIcon variant="subtle" color="gray" onClick={onClose}></ActionIcon>}
</Group>
{/* Фильтры-чипы: только по ролям (Корректор / Фактчекер / Пользователь). */}
{tab === 'open' && roles.length > 1 && (
<ScrollArea type="never" p="8px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Group gap={6} wrap="nowrap">
<FilterChip active={!roleFilter} onClick={() => setRoleFilter(null)} label={`All ${list.length}`} solid />
{roles.map(({ role, count }) => (
<FilterChip key={role} active={roleFilter === role} onClick={() => setRoleFilter(roleFilter === role ? null : role)} label={`${role} ${count}`} />
))}
</Group>
</ScrollArea>
)}
{/* Лента */}
<ScrollArea style={{ flex: 1 }} bg="var(--mantine-color-default-hover)">
{filtered.length === 0 ? (
<EmptyState tab={tab} />
) : (
<>
{groups.runs.map(([runId, items]) => (
<Box key={runId} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
<RunHeader runComments={items} progress={progress[runId] ?? null} onAcceptAll={() => acceptAll(runId, items)} />
{items.map((c) => (
<EditCard key={c.id} c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
))}
</Box>
))}
{groups.singles.map((c) => (
<Box key={c.id} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
{c.edit
? <EditCard c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
: <HumanThread c={c} onResolve={onResolve} onNavigateToAnchor={onNavigateToAnchor} />}
</Box>
))}
</>
)}
</ScrollArea>
</Stack>
);
}
/* ─────────────────────── Мелкие компоненты ─────────────────────── */
function FilterChip({ label, active, onClick, dot, solid }: {
label: string; active: boolean; onClick: () => void; dot?: string; solid?: boolean;
}) {
return (
<Button
onClick={onClick}
size="compact-sm"
radius="xl"
variant={active ? 'filled' : 'default'}
color={active ? (solid ? 'dark' : 'blue') : 'gray'}
leftSection={dot ? <Box w={7} h={7} style={(t) => ({ borderRadius: '50%', background: t.colors[dot][6] })} /> : undefined}
styles={{ root: { flex: 'none' }, label: { fontWeight: 600, fontSize: 12 } }}
>
{label}
</Button>
);
}
function EmptyState({ tab }: { tab: 'open' | 'resolved' }) {
const isOpen = tab === 'open';
return (
<Stack align="center" gap={6} p="40px 20px">
<Avatar size={40} radius="xl" color={isOpen ? 'green' : 'gray'}>{isOpen ? '✓' : '◌'}</Avatar>
<Text fw={600} fz={13}>{isOpen ? 'All caught up' : 'Nothing here yet'}</Text>
<Text fz={12} c="dimmed" ta="center" style={{ lineHeight: 1.45 }}>
{isOpen ? 'No edits waiting on you.' : 'Applied and closed items will appear here.'}
</Text>
</Stack>
);
}
export default CommentsPanel;
+362
View File
@@ -0,0 +1,362 @@
/**
* PageHistoryModal — редизайн окна «Page history».
* Mantine v7. Light/dark. Полноразмерное модальное окно.
*
* ─────────────────────────────────────────────────────────────────────────
* ЧТО ЭТО
* ─────────────────────────────────────────────────────────────────────────
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
*
* Ключевые решения:
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
* круглого аватара пользователя + «via <кто запустил>».
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
* - Текущая версия помечена; Restore для неё недоступен.
*
* ─────────────────────────────────────────────────────────────────────────
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
* ─────────────────────────────────────────────────────────────────────────
* import { PageHistoryModal, Revision } from './PageHistoryModal';
*
* <PageHistoryModal
* opened={open}
* onClose={() => setOpen(false)}
* revisions={revisions} // Revision[]
* selectedId={selId}
* onSelect={setSelId} // клик по ревизии → рендер справа
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
* highlightChanges={hl}
* onToggleHighlight={setHl}
* changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу
* onlySaved={onlySaved}
* onToggleOnlySaved={setOnlySaved}
* onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled
* />
*
* Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем).
*
* ─────────────────────────────────────────────────────────────────────────
* ФОРМАТ ДАННЫХ
* ─────────────────────────────────────────────────────────────────────────
* interface Revision {
* id: string;
* at: Date | string; // время ревизии; форматируется вызывающим/util-ом
* dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы
* saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа)
* author: { name: string } & (
* | { kind: 'human' }
* | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил
* );
* isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен
* }
*
* Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте.
* Никаких изменений API не требуется: агентское авторство берётся из author.kind.
*
* ─────────────────────────────────────────────────────────────────────────
* СОСТОЯНИЯ (нарисованы/поддержаны)
* ─────────────────────────────────────────────────────────────────────────
* - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая.
* - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика).
* - Highlight changes: on/off; при off навигация по изменениям неактивна.
* - Пустая история: одна версия / нет ревизий → EmptyState.
* - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)).
*/
import { useMemo, useState, useCallback } from 'react';
import {
Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon,
ScrollArea, useMantineTheme, useMantineColorScheme,
} from '@mantine/core';
/* ─────────────────────────── Типы ─────────────────────────── */
export type Author =
| { name: string; kind: 'human' }
| { name: string; kind: 'agent'; role: string; triggeredBy: string };
export interface Revision {
id: string;
at: Date | string;
atLabel: string; // предформатированное «5:35AM»
dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul»
saved: boolean;
author: Author;
isCurrent?: boolean;
}
export interface CalendarDay {
label: string; // «12»
inMonth: boolean;
count: number; // число ревизий за день (для heatmap)
selected?: boolean;
date: Date | string;
}
export interface PageHistoryModalProps {
opened: boolean;
onClose: () => void;
revisions: Revision[];
selectedId: string | null;
onSelect: (id: string) => void;
renderVersion: (rev: Revision | null) => React.ReactNode;
highlightChanges: boolean;
onToggleHighlight: (v: boolean) => void;
changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void };
onlySaved: boolean;
onToggleOnlySaved: (v: boolean) => void;
onRestore: (rev: Revision) => Promise<void>;
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
calendar?: {
monthLabel: string; // «July 2025»
weekdays: string[]; // ['Mon',…,'Sun']
days: CalendarDay[]; // обычно 35/42 ячейки
onPrevMonth: () => void;
onNextMonth: () => void;
onToday: () => void;
onPickDay: (d: CalendarDay) => void;
};
}
/* ─────────────────────────── Утилиты представления ─────────────────────────── */
const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678'];
function userColor(name: string) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return USER_PALETTE[h % USER_PALETTE.length];
}
const AGENT_GRAD: Record<string, string> = {
'Корректор': 'linear-gradient(135deg,#20c997,#12b886)',
'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)',
};
function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; }
/** heatmap: число ревизий → фон/текст ячейки календаря. */
function heat(n: number, dark: boolean) {
if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' };
if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' };
if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' };
return { bg: '#4c8dff', fg: '#ffffff' };
}
/* ─────────────────────────── Строка ревизии ─────────────────────────── */
function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const a = rev.author;
const isAgent = a.kind === 'agent';
return (
<Group
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
onClick={onSelect}
style={{
cursor: 'pointer', borderRadius: 7,
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
}}
>
{/* аватар/глиф */}
{isAgent ? (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.role[0]}
</Box>
) : (
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
{a.name[0].toUpperCase()}
</Box>
)}
{/* время */}
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
{rev.atLabel}
</Text>
{/* автор + via */}
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
{isAgent ? a.role : a.name}
</Text>
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
</Group>
{/* бейдж — только SAVED */}
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
</Group>
);
}
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
return (
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Group gap={6} mb={6}>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}></ActionIcon>
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}></ActionIcon>
<Box style={{ flex: 1 }} />
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
</Group>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
{cal.weekdays.map((w) => (
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
))}
</Box>
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
{cal.days.map((d, i) => {
const h = heat(d.count, dark);
return (
<Box
key={i} onClick={() => cal.onPickDay(d)}
style={{
position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: d.inMonth ? h.bg : 'transparent',
boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined,
}}
>
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
{d.label}
</Text>
</Box>
);
})}
</Box>
{/* легенда heatmap */}
<Group gap={6} mt={8} align="center">
<Text fz={9.5} c="dimmed">fewer</Text>
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
))}
<Text fz={9.5} c="dimmed">more revisions</Text>
</Group>
</Box>
);
}
/* ─────────────────────────── Окно ─────────────────────────── */
export function PageHistoryModal(props: PageHistoryModalProps) {
const {
opened, onClose, revisions, selectedId, onSelect, renderVersion,
highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved,
onRestore, calendar,
} = props;
const [restoring, setRestoring] = useState(false);
const selected = revisions.find((r) => r.id === selectedId) ?? null;
const isEmpty = revisions.length <= 1;
// группировка по dayGroup, с фильтром Only saved
const groups = useMemo(() => {
const list = onlySaved ? revisions.filter((r) => r.saved) : revisions;
const map: { head: string; items: Revision[] }[] = [];
for (const r of list) {
let g = map.find((x) => x.head === r.dayGroup);
if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); }
g.items.push(r);
}
return map;
}, [revisions, onlySaved]);
const restore = useCallback(async () => {
if (!selected || selected.isCurrent) return;
setRestoring(true);
try { await onRestore(selected); } finally { setRestoring(false); }
}, [selected, onRestore]);
return (
<Modal
opened={opened} onClose={onClose} withCloseButton={false}
size="80rem" radius="lg" padding={0}
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
>
{/* ── single-row toolbar ── */}
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Text fz={16} fw={600}>Page history</Text>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Stack gap={1} style={{ minWidth: 0 }}>
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
<Text fz={10.5} c="dimmed">selected version</Text>
</Stack>
<Box style={{ flex: 1 }} />
{/* diff cluster */}
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
Highlight changes
</Button>
{changeNav && (
<>
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
<Box style={{ display: 'flex' }}>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}></ActionIcon>
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}></ActionIcon>
</Box>
</>
)}
</Group>
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
Restore this version
</Button>
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}></ActionIcon>
</Group>
{/* ── body ── */}
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* left: nav panel */}
<Stack gap={0} style={(t) => ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}>
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
</Group>
{calendar && <MiniCalendar cal={calendar} />}
<ScrollArea style={{ flex: 1 }}>
{groups.map((g) => (
<Box key={g.head}>
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
{g.head}
</Text>
{g.items.map((r) => (
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
))}
</Box>
))}
</ScrollArea>
</Stack>
{/* right: rendered version */}
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
{isEmpty ? (
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
<Text fw={600} fz={14}>No earlier versions</Text>
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия сравнивать не с чем.</Text>
</Stack>
) : (
<Box p="26px 0" maw={660} mx="auto">
{renderVersion(selected)}
</Box>
)}
</ScrollArea>
</Box>
</Modal>
);
}
export default PageHistoryModal;
+234
View File
@@ -0,0 +1,234 @@
/**
* TimeWorkedModal — редизайн окна «Time worked on this article».
* Mantine v7. Light/dark.
*
* ─────────────────────────────────────────────────────────────────────────
* ЧТО ЭТО
* ─────────────────────────────────────────────────────────────────────────
* Сводка трудозатрат по дням в виде суточных таймлайнов. Главное отличие от
* старой версии: на барах ВИДНО время суток. Реализовано двумя способами
* (проп `axis`):
* - 'grid' — ось часов 00–06–12–18–24 сверху + вертикальные деления,
* ночные часы (0–6, 21–24) слегка затемнены. По умолчанию.
* - 'phases' — цветные полосы фаз дня за блоками (Ночь/Утро/День/Вечер),
* период суток читается сразу, без счёта делений.
*
* Блоки позиционируются по времени: left = start/24, width = dur/24.
* Интенсивность (opacity) блока = длительность/плотность работы, чтобы
* очень короткие сессии не терялись (минимальная видимая ширина задана).
* Hover-тултип на блоке: начало–конец · длительность.
*
* ─────────────────────────────────────────────────────────────────────────
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
* ─────────────────────────────────────────────────────────────────────────
* import { TimeWorkedModal, DaySummary } from './TimeWorkedModal';
*
* <TimeWorkedModal
* opened={open}
* onClose={() => setOpen(false)}
* totalLabel="≈ 34h"
* agentLabel="≈ 1h 20m" // undefined → строку agent не показываем
* days={days} // DaySummary[]
* axis="grid" // 'grid' | 'phases'
* tz="Europe/Moscow"
* inactivityGapMin={15}
* />
*
* Требует MantineProvider на корне.
*
* ─────────────────────────────────────────────────────────────────────────
* ФОРМАТ ДАННЫХ
* ─────────────────────────────────────────────────────────────────────────
* interface Block {
* start: number; // час начала в сутках, 0..24 (напр. 13.7 = 13:42)
* end: number; // час конца
* kind: 'work' | 'agent';
* }
* interface DaySummary {
* label: string; // «Mon 29 Jun»
* totalLabel: string; // «1h 24m» | «—» для пустого дня
* blocks: Block[]; // [] → пустой день (полупрозрачная дорожка, итог «—»)
* isToday?: boolean; // сегодня — неполные сутки (рисуем границу «сейчас»)
* nowFraction?: number;// 0..1 позиция «сейчас» для isToday
* }
*
* Данные уже есть в бэкенде трудозатрат: сессии с началом/концом + тип
* (work/agent). Часы = локальные к tz. Изменений API не требуется.
*
* ─────────────────────────────────────────────────────────────────────────
* СОСТОЯНИЯ (нарисованы/поддержаны)
* ─────────────────────────────────────────────────────────────────────────
* - День: с активностью (work+agent) / только work / только agent / пустой (—).
* - Сегодня: граница «сейчас» на дорожке (nowFraction).
* - Короткий блок: минимальная ширина, не исчезает.
* - Пустая панель целиком: нет трудозатрат → EmptyState.
* - Длинный период: вертикальный скролл списка дней, шапка/легенда липкие.
* - Тёмная тема: токены Mantine.
*/
import { Modal, Box, Group, Stack, Text, ActionIcon, ScrollArea, Tooltip, useMantineColorScheme } from '@mantine/core';
/* ─────────────────────────── Типы ─────────────────────────── */
export interface Block { start: number; end: number; kind: 'work' | 'agent'; }
export interface DaySummary {
label: string;
totalLabel: string;
blocks: Block[];
isToday?: boolean;
nowFraction?: number;
}
export interface TimeWorkedModalProps {
opened: boolean;
onClose: () => void;
totalLabel: string;
agentLabel?: string;
days: DaySummary[];
axis?: 'grid' | 'phases';
tz?: string;
inactivityGapMin?: number;
}
const WORK = '#3b82f6';
const AGENT = '#c026d3';
const PHASES = [
{ name: 'Ночь', s: 0, e: 6, bg: 'rgba(99,102,241,.10)', lg: '#e8eaff', fg: '#5b60c9' },
{ name: 'Утро', s: 6, e: 12, bg: 'rgba(245,159,0,.10)', lg: '#fff2dc', fg: '#b5820e' },
{ name: 'День', s: 12, e: 18, bg: 'rgba(56,178,172,.10)', lg: '#dcf5f2', fg: '#1a857d' },
{ name: 'Вечер', s: 18, e: 24, bg: 'rgba(139,92,246,.11)', lg: '#efe7ff', fg: '#6d43c0' },
];
/* ─────────────────────────── Блок активности ─────────────────────────── */
function fmtHour(h: number) {
const hh = Math.floor(h);
const mm = Math.round((h - hh) * 60);
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}`;
}
function fmtDur(h: number) {
const total = Math.round(h * 60);
const hh = Math.floor(total / 60), mm = total % 60;
return hh ? `${hh}h ${mm}m` : `${mm}m`;
}
function ActivityBlock({ b }: { b: Block }) {
const left = (b.start / 24) * 100;
const width = Math.max(((b.end - b.start) / 24) * 100, 0.6);
const dur = b.end - b.start;
const opacity = dur < 0.16 ? 0.5 : dur < 0.35 ? 0.8 : 1;
return (
<Tooltip label={`${fmtHour(b.start)}${fmtHour(b.end)} · ${fmtDur(dur)}`} withArrow openDelay={120} fz={11}>
<Box style={{
position: 'absolute', top: 5, height: 14, left: `${left}%`, width: `${width}%`,
borderRadius: 2, background: b.kind === 'agent' ? AGENT : WORK, opacity, cursor: 'default',
}} />
</Tooltip>
);
}
/* ─────────────────────────── Дорожка дня ─────────────────────────── */
function DayTrack({ d, axis, dark }: { d: DaySummary; axis: 'grid' | 'phases'; dark: boolean }) {
const trackBg = axis === 'grid'
? 'linear-gradient(90deg, rgba(90,100,130,.10) 0 25%, rgba(90,100,130,.02) 25% 87.5%, rgba(90,100,130,.10) 87.5% 100%)'
: (dark ? 'rgba(255,255,255,.04)' : '#f6f8fa');
return (
<Group gap={0} h={30} wrap="nowrap">
<Text style={{ flex: 'none', width: 92 }} fz={13} c="dimmed">{d.label}</Text>
<Box style={{ position: 'relative', flex: 1, height: 24, margin: '0 4px', borderRadius: 5, overflow: 'hidden', background: trackBg }}>
{/* фон: полосы фаз или деления сетки */}
{axis === 'phases'
? PHASES.map((ph) => (
<Box key={ph.name} style={{ position: 'absolute', top: 0, bottom: 0, left: `${(ph.s / 24) * 100}%`, width: `${((ph.e - ph.s) / 24) * 100}%`, background: ph.bg }} />
))
: [25, 50, 75].map((p) => (
<Box key={p} style={{ position: 'absolute', top: 0, bottom: 0, left: `${p}%`, width: 1, background: 'rgba(120,130,150,.16)' }} />
))}
{/* блоки */}
{d.blocks.map((b, i) => <ActivityBlock key={i} b={b} />)}
{/* граница «сейчас» для сегодняшнего дня */}
{d.isToday && d.nowFraction != null && (
<Box style={{ position: 'absolute', top: 0, bottom: 0, left: `${d.nowFraction * 100}%`, width: 2, background: '#fa5252' }} />
)}
</Box>
<Text style={{ flex: 'none', width: 64, textAlign: 'right' }} fz={12.5} fw={500} c={d.totalLabel === '—' ? 'dimmed' : undefined}>
{d.totalLabel}
</Text>
</Group>
);
}
/* ─────────────────────────── Окно ─────────────────────────── */
export function TimeWorkedModal(props: TimeWorkedModalProps) {
const { opened, onClose, totalLabel, agentLabel, days, axis = 'grid', tz = 'Europe/Moscow', inactivityGapMin = 15 } = props;
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const isEmpty = days.length === 0 || days.every((d) => d.blocks.length === 0);
return (
<Modal opened={opened} onClose={onClose} withCloseButton={false} size="46rem" radius="lg" padding={0}
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}>
<Box p="22px 24px 20px">
{/* header */}
<Group mb={14}>
<Text fz={17} fw={600}>Time worked on this article</Text>
<Box style={{ flex: 1 }} />
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}></ActionIcon>
</Group>
{/* summary */}
<Group align="baseline" gap={16} mb={axis === 'grid' ? 8 : 16}>
<Text fz={22} fw={700}>{totalLabel}</Text>
{agentLabel && <Text fz={13} c="dimmed">agent: {agentLabel}</Text>}
</Group>
{/* legend (grid only) */}
{axis === 'grid' && (
<Group gap={16} mb={16}>
<Group gap={6}><Box w={12} h={12} style={{ borderRadius: 3, background: WORK }} /><Text fz={12} fw={500} c="dimmed">Work</Text></Group>
<Group gap={6}><Box w={12} h={12} style={{ borderRadius: 3, background: AGENT }} /><Text fz={12} fw={500} c="dimmed">Agent</Text></Group>
</Group>
)}
{isEmpty ? (
<Stack align="center" gap={6} p="48px 20px">
<Text fw={600} fz={14}>No time tracked yet</Text>
<Text fz={12.5} c="dimmed" ta="center">По этой статье ещё нет трудозатрат.</Text>
</Stack>
) : (
<>
{/* axis header (sticky) */}
<Group gap={0} mb={5} wrap="nowrap" style={{ position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
<Box style={{ flex: 'none', width: 92 }} />
{axis === 'grid' ? (
<Box style={{ flex: 1, position: 'relative', height: 14, margin: '0 4px' }}>
{[['0%', '00', 'flex-start'], ['25%', '06', 'center'], ['50%', '12', 'center'], ['75%', '18', 'center'], ['100%', '24', 'flex-end']].map(([l, t, al]) => (
<Text key={t as string} fz={10} fw={500} c="dimmed" style={{ position: 'absolute', left: l as string, transform: al === 'center' ? 'translateX(-50%)' : al === 'flex-end' ? 'translateX(-100%)' : undefined }}>{t}</Text>
))}
</Box>
) : (
<Box style={{ flex: 1, display: 'flex', height: 16, margin: '0 4px', borderRadius: 4, overflow: 'hidden' }}>
{PHASES.map((ph) => (
<Box key={ph.name} style={{ flex: ph.e - ph.s, display: 'flex', alignItems: 'center', justifyContent: 'center', background: ph.lg, color: ph.fg, font: '600 9.5px system-ui' }}>{ph.name}</Box>
))}
</Box>
)}
<Box style={{ flex: 'none', width: 64 }} />
</Group>
{/* day rows */}
<ScrollArea.Autosize mah="60vh" type="hover">
{days.map((d, i) => <DayTrack key={i} d={d} axis={axis} dark={dark} />)}
</ScrollArea.Autosize>
</>
)}
<Text mt={16} fz={11.5} c="dimmed">Estimate · timezone {tz} · inactivity gap {inactivityGapMin} min</Text>
</Box>
</Modal>
);
}
export default TimeWorkedModal;
@@ -346,6 +346,9 @@ roles:
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
appendix at the end of the document or clearly separated from the report
body.
□ Once the report is complete, call save_page_version to pin the finished
document as a restorable named version (a checkpoint the reader can
return to). A repeat call after no further edits is a harmless no-op.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
autoStart: false
@@ -442,6 +445,7 @@ roles:
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
- **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op.
## Style example (excerpt)
@@ -345,6 +345,9 @@ roles:
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
an appendix at the end of the document or clearly separated from the
report body.
□ Once the report is complete, call save_page_version to pin the finished
document as a restorable named version (a checkpoint the reader can
return to). A repeat call after no further edits is a harmless no-op.
Be honest about gaps. If you couldn't find something, say so — don't disguise
a guess as a fact.
autoStart: false
@@ -441,6 +444,7 @@ roles:
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
- **Не оценивай участников** и не комментируй качество обсуждения.
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
## Пример стиля (фрагмент)
+2 -2
View File
@@ -33,6 +33,6 @@ bundles:
- en
roles:
- slug: researcher
version: 9
version: 10
- slug: call-summarizer
version: 1
version: 2
@@ -14,9 +14,11 @@
*/
/*
* Push the top-anchored toast containers below the top chrome (fixed 45px
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
* Anchor the top toast containers to the very top edge of the viewport (a small
* 8px gap), above the header/search chrome, per product request. The toast
* (z-index 10000) therefore renders over the header/toolbar (both z-index 99)
* for the few seconds it is visible — intentional, since it is the top-most,
* in-the-line-of-sight surface.
*
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
* containers simultaneously (`position` only routes toasts into one via the
@@ -34,7 +36,7 @@
* (the :where() contributes 0), regardless of stylesheet order.
*/
.mantine-Notifications-root[data-position^='top'] {
top: 96px;
top: 8px;
}
[data-mantine-color-scheme='light'] .mantine-Notification-root {
@@ -736,6 +736,53 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
});
// #370 F2 — an effectively-empty page is a REACHABLE no-op (agent calls
// save_page_version on a blank page): the version tx short-circuits with
// nothing to pin. The handler MUST still broadcast a TERMINAL reply
// (version.skipped, reason:'empty') so the client resolves at once instead of
// waiting out its 20s ack timeout and misreporting a healthy server as
// unreachable. MUTATION: drop the `else if (skipped)` broadcast → no terminal
// reply is sent → this reddens.
it('empty page → no version written, broadcasts a terminal version.skipped(empty)', async () => {
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
const document = ydocFor(emptyDoc);
pageRepo.findById.mockResolvedValue({
...persistedHumanPage('IGNORED'),
content: emptyDoc,
});
await emitSave(document, 'agent');
// Nothing pinned.
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
// But a terminal reply WAS sent so the client never times out. The flush
// (onStoreDocument) emits its own `page.updated`; the version.skipped is the
// LAST broadcast (dropping the skip branch leaves page.updated last → reds).
const calls = (document as any).broadcastStateless.mock.calls;
const msg = JSON.parse(calls[calls.length - 1][0]);
expect(msg).toEqual({ type: 'version.skipped', reason: 'empty' });
});
// #370 F2 — the page row is gone (deleted / never persisted). Same rule: a
// terminal reply MUST be sent (version.skipped, reason:'page-not-found') so the
// client surfaces a truthful "not found" immediately rather than a health
// timeout. onStoreDocument's own `!page` guard returns early without throwing,
// so the handler reaches the version tx and its `!page` skip branch.
it('page not found → broadcasts a terminal version.skipped(page-not-found)', async () => {
const document = ydocFor(doc('GONE'));
pageRepo.findById.mockResolvedValue(null);
await emitSave(document, 'agent');
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
const msg = JSON.parse(
(document as any).broadcastStateless.mock.calls[0][0],
);
expect(msg).toEqual({ type: 'version.skipped', reason: 'page-not-found' });
});
});
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
@@ -68,6 +68,19 @@ export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
*/
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
/**
* #370 F2 — wire format of the server→client REPLY to a save-version signal, sent
* over the same stateless channel. `version.saved` means a version was created or
* promoted; `version.skipped` is a TERMINAL "nothing was pinned" reply for the two
* reachable no-op cases (an effectively-empty page, or the page row is gone) so the
* client resolves at once instead of waiting out its ack timeout and misreporting a
* healthy server as unreachable. EXACTLY ONE of these is broadcast per handled
* save. The MCP client duplicates these literals (it cannot import server code) —
* keep the two in sync (see packages/mcp/src/lib/collaboration.ts).
*/
export const VERSION_SAVED_MESSAGE_TYPE = 'version.saved';
export const VERSION_SKIPPED_MESSAGE_TYPE = 'version.skipped';
/**
* #251 — how long an intentional-clear signal stays "pending" before it is
* ignored. The signal is set on the clearing keystroke but consumed by the
@@ -643,6 +656,10 @@ export class PersistenceExtension implements Extension {
let result:
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
| undefined;
// #370 F2 — set when there is nothing to version (empty page / page gone), so
// the tail broadcasts a terminal `version.skipped` instead of staying silent
// and forcing the client to time out. Mutually exclusive with `result`.
let skipped: 'empty' | 'page-not-found' | undefined;
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
// must be restored if the version row does not durably land. The inner
@@ -668,11 +685,20 @@ export class PersistenceExtension implements Extension {
includeContent: true,
trx,
});
if (!page) return;
if (!page) {
// The page row is gone (deleted/never persisted). Nothing to version —
// record it so the tail sends a terminal skip reply (#370 F2).
skipped = 'page-not-found';
return;
}
versionedPageId = page.id;
// Never version an effectively-empty page (mirrors the processor's
// first-history guard); there is nothing intentional to pin.
if (isEmptyParagraphDoc(page.content as any)) return;
// first-history guard); there is nothing intentional to pin. Record the
// skip so the client gets a terminal reply rather than a timeout (#370 F2).
if (isEmptyParagraphDoc(page.content as any)) {
skipped = 'empty';
return;
}
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
page.id,
@@ -747,15 +773,26 @@ export class PersistenceExtension implements Extension {
}
this.idleBurstStart.delete(documentName);
// EXACTLY ONE terminal reply per handled save (#370 F2): a real save/promote
// broadcasts `version.saved`; the two no-op early returns (empty page / page
// gone) broadcast `version.skipped` so the client never waits out its ack
// timeout. A genuine failure threw above and rejected before reaching here.
if (result) {
document.broadcastStateless(
JSON.stringify({
type: 'version.saved',
type: VERSION_SAVED_MESSAGE_TYPE,
historyId: result.historyId,
kind: result.kind,
alreadySaved: result.alreadySaved,
}),
);
} else if (skipped) {
document.broadcastStateless(
JSON.stringify({
type: VERSION_SKIPPED_MESSAGE_TYPE,
reason: skipped,
}),
);
}
}
@@ -370,10 +370,12 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
);
});
// #490 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is classified,
// records a distinguishable cause, and stamps metadata.replayOverflow so the NEXT
// turn's budgeter trims aggressively (the recovery that un-bricks the chat).
it('#490: a context-overflow 400 stamps replayOverflow on the finalized row', async () => {
// #490/#520 reactive branch: a provider CONTEXT-OVERFLOW 400 in onError is
// classified, records a distinguishable cause, and stamps the consecutive-overflow
// COUNTER (metadata.replayOverflowCount) so the NEXT turn's budgeter trims with
// escalating aggression (the recovery that un-bricks the chat). This is a fresh
// chat (empty history -> prior streak 0), so the first overflow stamps count 1.
it('#490/#520: a context-overflow 400 stamps replayOverflowCount=1 on the finalized row', async () => {
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
@@ -397,11 +399,14 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
metadata: Record<string, unknown>;
};
expect(patch.status).toBe('error');
expect(patch.metadata.replayOverflow).toBe(true);
// First overflow on a fresh chat -> k = prior(0) + 1 = 1.
expect(patch.metadata.replayOverflowCount).toBe(1);
// The legacy boolean is no longer written (the counter supersedes it).
expect('replayOverflow' in patch.metadata).toBe(false);
expect(patch.metadata.error).toContain('контекстное окно');
});
it('#490: a non-overflow error does NOT stamp replayOverflow', async () => {
it('#490/#520: a non-overflow error does NOT stamp the overflow counter', async () => {
jest
.spyOn(Logger.prototype, 'error')
.mockImplementation(() => undefined as never);
@@ -412,6 +417,7 @@ describe('AiChatService.stream — abortSignal wiring (#184 F3)', () => {
status: string;
metadata: Record<string, unknown>;
};
expect('replayOverflowCount' in patch.metadata).toBe(false);
expect('replayOverflow' in patch.metadata).toBe(false);
});
});
@@ -30,13 +30,16 @@ import {
STEP_LIMIT_NO_ANSWER_MARKER,
OUTPUT_DEGENERATION_ERROR,
lastAssistantContextTokens,
lastAssistantReplayOverflow,
lastAssistantReplayOverflowCount,
seedActivatedTools,
} from './ai-chat.service';
import type { AiChatMessage, Workspace } from '@docmost/db/types/entity.types';
import { buildSystemPrompt } from './ai-chat.prompt';
import type { McpClientsService } from './external-mcp/mcp-clients.service';
import { resolveEffectiveReplayThreshold } from './history-budget';
import {
resolveEffectiveReplayThreshold,
REPLAY_MIN_FLOOR_TOKENS,
} from './history-budget';
/**
* Unit tests for compactToolOutput: the pure helper that shrinks tool outputs
@@ -554,49 +557,129 @@ describe('seedActivatedTools', () => {
});
});
describe('lastAssistantReplayOverflow', () => {
describe('lastAssistantReplayOverflowCount', () => {
const row = (
role: string,
metadata: Record<string, unknown> | null,
): AiChatMessage => ({ role, metadata }) as unknown as AiChatMessage;
it('is true only when the LAST assistant turn overflowed', () => {
it('reads the consecutive-overflow count from the LAST assistant turn', () => {
expect(
lastAssistantReplayOverflow([
row('assistant', { replayOverflow: true }),
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflowCount: 3 }),
row('user', null),
]),
).toBe(true);
// A recovered (later, non-overflow) assistant turn clears it.
).toBe(3);
// A recovered (later, non-overflow) assistant turn resets it to 0 — the read
// stops at the most recent assistant row, which carries no count.
expect(
lastAssistantReplayOverflow([
row('assistant', { replayOverflow: true }),
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflowCount: 3 }),
row('user', null),
row('assistant', { contextTokens: 5 }),
]),
).toBe(false);
expect(lastAssistantReplayOverflow([])).toBe(false);
).toBe(0);
expect(lastAssistantReplayOverflowCount([])).toBe(0);
});
// #490 reactive recovery: a prior turn stamped `replayOverflow` must make the
// NEXT turn's effective budget the AGGRESSIVE 0.5x cut — that harder trim is
// what un-bricks a chat that just 400'd on the context window. This exercises
// the exact wiring the service uses: read the stamp, then scale the threshold.
it('#490: a prior replayOverflow drives the next turn to the 0.5x aggressive budget', () => {
// BACK-COMPAT (#520): an in-flight row written by the pre-#520 boolean stamp
// (`replayOverflow: true`, no count) reads as k=1 — the old single 0.5× behavior —
// so a chat mid-recovery across the deploy does not regress.
it('#520 back-compat: a legacy boolean replayOverflow reads as k=1', () => {
expect(
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflow: true }),
row('user', null),
]),
).toBe(1);
// A legacy row with the flag absent/false is k=0.
expect(
lastAssistantReplayOverflowCount([row('assistant', { contextTokens: 5 })]),
).toBe(0);
});
// A corrupt/negative persisted count never yields a negative k.
it('clamps a corrupt negative count to 0', () => {
expect(
lastAssistantReplayOverflowCount([
row('assistant', { replayOverflowCount: -4 }),
]),
).toBe(0);
});
// #490/#520 reactive recovery: the prior consecutive-overflow count `k` drives
// the next turn's effective budget to an ESCALATING cut (0.5**k) — each further
// consecutive 400 tightens it, which is what un-bricks a chat that keeps
// overflowing. This exercises the exact wiring the service uses: read the count,
// then scale the threshold.
it('#490/#520: the prior count drives the next turn to the escalating aggressive budget', () => {
const history = [
row('assistant', { replayOverflow: true }),
row('assistant', { replayOverflowCount: 1 }),
row('user', null),
];
const priorOverflowed = lastAssistantReplayOverflow(history);
expect(priorOverflowed).toBe(true);
// Base budget 100k -> aggressive recovery halves it to 50k this turn.
expect(resolveEffectiveReplayThreshold(100_000, priorOverflowed)).toBe(50_000);
const k = lastAssistantReplayOverflowCount(history);
expect(k).toBe(1);
// Base budget 100k -> first-overflow recovery halves it to 50k this turn.
expect(resolveEffectiveReplayThreshold(100_000, k)).toBe(50_000);
// A second consecutive overflow (k=2) quarters it.
expect(resolveEffectiveReplayThreshold(100_000, 2)).toBe(25_000);
// Odd base floors, not rounds.
expect(resolveEffectiveReplayThreshold(99_999, true)).toBe(49_999);
// No prior overflow -> the base budget is used verbatim (no aggressive cut).
expect(resolveEffectiveReplayThreshold(100_000, false)).toBe(100_000);
expect(resolveEffectiveReplayThreshold(99_999, 1)).toBe(49_999);
// No prior overflow (k=0) -> the base budget is used verbatim (no cut).
expect(resolveEffectiveReplayThreshold(100_000, 0)).toBe(100_000);
// An explicit off-switch (null) is never overridden, even on recovery.
expect(resolveEffectiveReplayThreshold(null, true)).toBeNull();
expect(resolveEffectiveReplayThreshold(null, 3)).toBeNull();
});
// #520 escalation table + convergence: the cut deepens each consecutive overflow
// and is CLAMPED at the floor so it converges (un-bricks even against a small
// real window), instead of the old fixed single 0.5× that stuck at 50k forever.
it('#520: escalates and converges to the floor, un-bricking a small real window', () => {
const base = 100_000;
expect(resolveEffectiveReplayThreshold(base, 0)).toBe(base);
expect(resolveEffectiveReplayThreshold(base, 1)).toBe(50_000);
expect(resolveEffectiveReplayThreshold(base, 2)).toBe(25_000);
expect(resolveEffectiveReplayThreshold(base, 3)).toBe(12_500);
// Residual-brick regression (#520): with the flat-default base (100k) and a real
// model window of ~40k, the OLD fixed 0.5× stuck at 50k forever (> 40k -> 400s
// again, never recovers). The escalating cut drops BELOW 40k after enough
// consecutive overflows -> the history finally fits -> the chat un-bricks.
const realWindow = 40_000;
// k=1 (50k) still exceeds the window — the old behavior's terminal state.
expect(resolveEffectiveReplayThreshold(base, 1)).toBeGreaterThan(realWindow);
// But escalation converges under the window within a couple more turns.
const converged = [2, 3, 4, 5].some(
(k) => (resolveEffectiveReplayThreshold(base, k) as number) < realWindow,
);
expect(converged).toBe(true);
// Convergence is bounded BELOW by the floor: a large k never trims below it.
for (const k of [4, 8, 20, 100]) {
expect(resolveEffectiveReplayThreshold(base, k)).toBe(REPLAY_MIN_FLOOR_TOKENS);
expect(
resolveEffectiveReplayThreshold(base, k) as number,
).toBeGreaterThanOrEqual(REPLAY_MIN_FLOOR_TOKENS);
}
});
// The floor never RAISES a legitimately small configured budget above itself —
// that would re-overflow the very window it was configured for. Under #520 Option B
// recovery MAY cut it BELOW itself (down to floor(0.5×base) = the old 0.5× cut).
it('#520: never inflates a small configured budget above itself (may cut below)', () => {
const small = 5_000; // below the floor
const floor = Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(small * 0.5)); // 2500
expect(resolveEffectiveReplayThreshold(small, 0)).toBe(small);
// Even under escalation the effective threshold never exceeds the base, and never
// drops below the absolute floor.
for (const k of [1, 2, 3, 10]) {
const t = resolveEffectiveReplayThreshold(small, k) as number;
expect(t).toBeLessThanOrEqual(small);
expect(t).toBeGreaterThanOrEqual(floor);
}
// Option B: on overflow it DOES cut below the configured budget (old floor==budget
// behavior would keep this at `small`).
expect(resolveEffectiveReplayThreshold(small, 1)).toBe(floor);
});
});
@@ -930,21 +1013,50 @@ describe('flushAssistant', () => {
expect(flushed.metadata.error).toBe('boom');
});
// #490 observability: the replay budgeter's decision is stamped on the turn.
it('records replayTrimmedToTokens + replayOverflow when provided', () => {
// #490/#520 observability: the replay budgeter's decision is stamped on the turn,
// now including the consecutive-overflow COUNTER (#520) the next turn escalates on.
it('records replayTrimmedToTokens + replayOverflowCount when provided', () => {
const f = flushAssistant([], '', 'error', {
error: 'ctx',
replayTrimmedToTokens: 42_000,
replayOverflow: true,
replayOverflowCount: 2,
});
expect(f.metadata.replayTrimmedToTokens).toBe(42_000);
expect(f.metadata.replayOverflow).toBe(true);
expect(f.metadata.replayOverflowCount).toBe(2);
});
it('omits the replay metadata when not provided', () => {
const f = flushAssistant([], '', 'completed', { finishReason: 'stop' });
expect('replayTrimmedToTokens' in f.metadata).toBe(false);
expect('replayOverflow' in f.metadata).toBe(false);
expect('replayOverflowCount' in f.metadata).toBe(false);
expect('replayBelowConfiguredBudget' in f.metadata).toBe(false);
});
// #520 Option B observability: the turn is marked when recovery replayed BELOW the
// admin-configured budget, so the UI/telemetry can surface "config too large".
it('stamps replayBelowConfiguredBudget when recovery cut below the configured budget', () => {
const f = flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: 2,
replayBelowConfiguredBudget: true,
});
// MUTATION SENTINEL: dropping the metadata stamp in flushAssistant reddens this.
expect(f.metadata.replayBelowConfiguredBudget).toBe(true);
});
it('omits replayBelowConfiguredBudget on a normal in-budget replay', () => {
// false/omitted must NOT leave the flag on the turn (only the below-budget case).
const off = flushAssistant([], '', 'completed', {
replayBelowConfiguredBudget: false,
});
expect('replayBelowConfiguredBudget' in off.metadata).toBe(false);
});
// A clean finalize (no overflow -> count 0/omitted) leaves NO counter, which the
// next turn reads as k=0 — the reset that ends a recovery streak.
it('omits replayOverflowCount for a zero/absent count (reset semantics)', () => {
const zero = flushAssistant([], '', 'completed', { replayOverflowCount: 0 });
expect('replayOverflowCount' in zero.metadata).toBe(false);
});
// #274 observability: the page-change diff the agent saw this turn is persisted
+96 -30
View File
@@ -141,9 +141,10 @@ const OUTPUT_DEGENERATION_ERROR =
// Prefix recorded on the assistant row when the provider rejected the turn for
// CONTEXT OVERFLOW (#490): the replayed history exceeded the model's window. The
// row is ALSO stamped `metadata.replayOverflow` so the NEXT turn's budgeter trims
// aggressively (the reactive recovery — the overflowing turn had no usage signal
// to trigger preventive trimming, so the classified 400 is what un-bricks it).
// row is ALSO stamped `metadata.replayOverflowCount` (the consecutive-overflow
// counter, #520) so the NEXT turn's budgeter trims with escalating aggression (the
// reactive recovery — the overflowing turn had no usage signal to trigger
// preventive trimming, so the classified 400 is what un-bricks it).
export const CONTEXT_OVERFLOW_ERROR_PREFIX =
'Диалог превысил контекстное окно модели; история будет агрессивно ' +
'сокращена на следующем ходу.';
@@ -1228,27 +1229,52 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
}
// Last turn's provider-reported context size (authoritative when present).
const priorContextTokens = lastAssistantContextTokens(oldHistory);
// Reactive recovery (#490): if the LAST turn was rejected for context
// overflow (stamped by onError), trim AGGRESSIVELY this turn — the
// overflowing turn produced no usage signal, so a normal-threshold trim may
// not shrink enough to fit. This is what un-bricks a chat that just 400'd.
const priorOverflowed = lastAssistantReplayOverflow(oldHistory);
// Reactive recovery (#490/#520): `k` = how many CONSECUTIVE preceding turns
// were rejected for context overflow (stamped by onError). Each consecutive
// overflow trims MORE aggressively (resolveEffectiveReplayThreshold scales the
// budget by 0.5**k, clamped at the floor) so recovery ESCALATES until the
// history fits — the overflowing turn produced no usage signal, so a single
// fixed cut may not shrink enough when the real model window is small. This is
// what un-bricks a chat that keeps 400'ing on the context window.
const priorOverflowCount = lastAssistantReplayOverflowCount(oldHistory);
const effectiveThreshold = resolveEffectiveReplayThreshold(
replayBudget.thresholdTokens,
priorOverflowed,
priorOverflowCount,
);
if (priorOverflowed) {
this.logger.warn(
`AI chat (chat ${chatId}): previous turn hit context overflow; ` +
`applying aggressive replay budget (${effectiveThreshold} tokens).`,
);
// #520 Option B: recovery MAY cut the replay budget BELOW the admin-configured
// window when the provider keeps proving non-fit (the anti-brick invariant of
// the reactive branch beats a config that a 400 has disproved). Surface it so
// the admin sees their window is factually too large, and mark the turn so the
// UI/telemetry can show it. Only true when actually below (not on in-budget
// escalation edge cases); k>0 implies below for any budget >= 2 tokens.
const replayBelowConfiguredBudget =
replayBudget.thresholdTokens != null &&
effectiveThreshold != null &&
effectiveThreshold < replayBudget.thresholdTokens;
if (priorOverflowCount > 0) {
if (replayBelowConfiguredBudget) {
this.logger.warn(
`AI chat (chat ${chatId}): configured replay budget ` +
`(${replayBudget.thresholdTokens} tokens) does not fit the model — ` +
`replaying BELOW it at ${effectiveThreshold} tokens after ` +
`${priorOverflowCount} consecutive context overflow(s) ` +
`(escalation level ${priorOverflowCount}). Lower chatContextWindow ` +
`to match the model's real context window.`,
);
} else {
this.logger.warn(
`AI chat (chat ${chatId}): ${priorOverflowCount} consecutive context ` +
`overflow(s); applying escalated aggressive replay budget ` +
`(${effectiveThreshold} tokens).`,
);
}
}
const preTrim = trimHistoryForReplay(
messages,
effectiveThreshold,
// A prior OVERFLOW means the provider count is stale/absent — force the
// char-estimate path by ignoring priorContextTokens on recovery.
priorOverflowed ? undefined : priorContextTokens,
priorOverflowCount > 0 ? undefined : priorContextTokens,
);
messages = preTrim.messages;
// Observability (#490): record the budgeter's decision on the turn so the UI
@@ -1903,6 +1929,10 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
pageChanged,
partsCache,
replayTrimmedToTokens,
// #520 Option B: mark the turn when recovery replayed BELOW the
// configured budget (only when it actually did).
replayBelowConfiguredBudget:
replayBelowConfiguredBudget || undefined,
}),
);
// #184/#487: the RUN is finalized ALWAYS (never gated on the message).
@@ -1981,7 +2011,16 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
pageChanged,
partsCache,
replayTrimmedToTokens,
replayOverflow: overflow || undefined,
// #520: escalate the consecutive-overflow counter so the NEXT turn
// trims MORE aggressively (0.5**k). k grows by 1 each consecutive
// overflow; a clean finalize omits the field, resetting it to 0.
replayOverflowCount: overflow
? priorOverflowCount + 1
: undefined,
// #520 Option B: mark the turn when THIS replay was already below the
// configured budget (only when it actually was).
replayBelowConfiguredBudget:
replayBelowConfiguredBudget || undefined,
}),
);
// #184: settle the RUN as failed, carrying the provider/transport cause.
@@ -2413,22 +2452,39 @@ export function seedActivatedTools(
}
/**
* Whether the most recent assistant turn was rejected for CONTEXT OVERFLOW
* (#490): its row carries `metadata.replayOverflow` (stamped by the stream's
* onError). The next turn's budgeter reads this to trim aggressively the
* reactive recovery. Only the LAST assistant turn matters (an older overflow was
* already recovered), so we stop at the first assistant row scanning backwards.
* How many CONSECUTIVE recent turns were rejected for CONTEXT OVERFLOW (#490/#520):
* `k`, read from the most recent assistant row's `metadata.replayOverflowCount`
* (stamped by the stream's onError, incremented each consecutive overflow and reset
* to 0 on any clean finalize). The next turn's budgeter feeds this to
* {@link resolveEffectiveReplayThreshold} to trim with ESCALATING aggression the
* reactive recovery. Only the LAST assistant turn matters (its count already carries
* the consecutive streak; an older overflow followed by a clean turn was recovered),
* so we stop at the first assistant row scanning backwards.
*
* BACK-COMPAT: a row written by the pre-#520 boolean stamp (`replayOverflow: true`,
* no count) is read as k=1 the old single 0.5× behavior so in-flight chats do
* not regress across the deploy.
*/
export function lastAssistantReplayOverflow(
export function lastAssistantReplayOverflowCount(
history: ReadonlyArray<AiChatMessage>,
): boolean {
): number {
for (let i = history.length - 1; i >= 0; i--) {
const row = history[i];
if (row.role !== 'assistant') continue;
const meta = (row.metadata ?? {}) as { replayOverflow?: unknown };
return meta.replayOverflow === true;
const meta = (row.metadata ?? {}) as {
replayOverflowCount?: unknown;
replayOverflow?: unknown;
};
if (typeof meta.replayOverflowCount === 'number') {
// Guard against a corrupt/negative persisted value.
return meta.replayOverflowCount > 0
? Math.floor(meta.replayOverflowCount)
: 0;
}
// Back-compat: legacy boolean stamp -> one overflow (0.5× cut).
return meta.replayOverflow === true ? 1 : 0;
}
return false;
return 0;
}
/** The last message with role 'user' from a useChat payload, if any. */
@@ -3084,9 +3140,16 @@ export function flushAssistant(
// the (estimated) token size it trimmed to — the UI can show "replay truncated
// at N tokens". Omitted when nothing was trimmed.
replayTrimmedToTokens?: number;
// #490 reactive branch: set when the provider rejected this turn for context
// overflow. Stamped into metadata so the NEXT turn's budgeter trims aggressively.
replayOverflow?: boolean;
// #490/#520 reactive branch: the consecutive context-overflow count for THIS
// turn (prior streak + 1) when the provider rejected it for context overflow.
// Stamped into metadata so the NEXT turn's budgeter trims with escalating
// aggression (0.5**k). Omitted (undefined) on a clean turn, which resets k to 0.
replayOverflowCount?: number;
// #520 Option B observability: true when recovery replayed this turn's history
// BELOW the admin-configured budget (the configured window did not fit and the
// anti-brick invariant cut past it). Omitted on a normal in-budget replay so the
// flag marks ONLY the "config is factually too large" case.
replayBelowConfiguredBudget?: boolean;
},
): AssistantFlush {
const finished = capturedSteps ?? [];
@@ -3139,7 +3202,10 @@ export function flushAssistant(
metadata.maxContextTokens = extra.maxContextTokens;
if (extra?.replayTrimmedToTokens)
metadata.replayTrimmedToTokens = extra.replayTrimmedToTokens;
if (extra?.replayOverflow) metadata.replayOverflow = true;
if (extra?.replayOverflowCount && extra.replayOverflowCount > 0)
metadata.replayOverflowCount = extra.replayOverflowCount;
if (extra?.replayBelowConfiguredBudget)
metadata.replayBelowConfiguredBudget = true;
if (extra?.error) metadata.error = extra.error;
// Persist the page-change diff the agent saw this turn (#274 observability),
// so history / the Markdown export can show what the user changed. Only when
@@ -1,6 +1,11 @@
import { randomBytes } from 'crypto';
import { Client } from 'pg';
import { flushAssistant, serializeSteps } from './ai-chat.service';
import {
flushAssistant,
serializeSteps,
lastAssistantReplayOverflowCount,
} from './ai-chat.service';
import type { AiChatMessage } from '@docmost/db/types/entity.types';
/**
* #490 write-volume regression an OBSERVABLE-PROPERTY test on a LIVE Postgres,
@@ -207,3 +212,111 @@ describe('#490 write-volume on a live Postgres (pg_current_wal_lsn delta)', () =
expect(v2).toBeLessThan(v1 * 0.75);
}, 120_000);
});
/**
* #520 reactive-recovery COUNTER lifecycle on a LIVE Postgres proves the
* consecutive-overflow count survives a real jsonb metadata round-trip (the persist
* path), not just an in-memory object. flushAssistant BUILDS the row metadata, we
* WRITE it to a jsonb column, READ it back, then reconstruct the assistant row and
* run lastAssistantReplayOverflowCount over it exactly the read the next turn does.
*
* The lifecycle proven end-to-end through pg:
* - consecutive overflows INCREMENT k (1 -> 2 -> 3);
* - a CLEAN finalize omits the field, which the reader treats as a RESET to 0;
* - a legacy boolean row (`replayOverflow: true`) reads back as k=1 (back-compat).
*/
describe('#520 overflow-counter lifecycle on a live Postgres (jsonb round-trip)', () => {
let client: Client | undefined;
let available = false;
beforeAll(async () => {
try {
client = new Client(CONN);
await client.connect();
await client.query('SELECT 1');
available = true;
} catch {
available = false;
client = undefined;
}
});
afterAll(async () => {
await client?.end().catch(() => undefined);
});
// Round-trip an arbitrary metadata object through a real jsonb column and read it
// back as the reconstructed assistant row the next turn would load.
async function roundTrip(
c: Client,
metadata: unknown,
): Promise<AiChatMessage> {
await c.query('UPDATE _wal_counter SET metadata=$1 WHERE id=1', [
JSON.stringify(metadata),
]);
const back = (await c.query('SELECT metadata FROM _wal_counter WHERE id=1'))
.rows[0].metadata as Record<string, unknown>;
return { role: 'assistant', metadata: back } as unknown as AiChatMessage;
}
it('increments across consecutive overflows, resets on a clean turn, and honors the legacy boolean', async () => {
if (!available || !client) {
console.warn('SKIP: gitmost-test-pg not reachable; skipping counter test.');
return;
}
const c = client;
await c.query('DROP TABLE IF EXISTS _wal_counter');
await c.query('CREATE TABLE _wal_counter(id int primary key, metadata jsonb)');
await c.query("INSERT INTO _wal_counter VALUES (1, '{}'::jsonb)");
// Turn 1 overflow: prior streak 0 -> stamp k=1 (as the service does: prior+1).
let prior = lastAssistantReplayOverflowCount([]); // fresh chat
expect(prior).toBe(0);
let row = await roundTrip(
c,
flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: prior + 1,
}).metadata,
);
prior = lastAssistantReplayOverflowCount([row]);
expect(prior).toBe(1);
// Turn 2 overflow: prior 1 -> stamp k=2.
row = await roundTrip(
c,
flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: prior + 1,
}).metadata,
);
prior = lastAssistantReplayOverflowCount([row]);
expect(prior).toBe(2);
// Turn 3 overflow: prior 2 -> stamp k=3.
row = await roundTrip(
c,
flushAssistant([], '', 'error', {
error: 'ctx',
replayOverflowCount: prior + 1,
}).metadata,
);
prior = lastAssistantReplayOverflowCount([row]);
expect(prior).toBe(3);
// Turn 4 CLEAN finalize: no overflow -> the field is omitted -> reset to 0.
row = await roundTrip(
c,
flushAssistant([], 'all good', 'completed', { finishReason: 'stop' })
.metadata,
);
expect('replayOverflowCount' in (row.metadata as object)).toBe(false);
expect(lastAssistantReplayOverflowCount([row])).toBe(0);
// Back-compat: a row persisted by the pre-#520 boolean stamp reads back as k=1.
row = await roundTrip(c, { replayOverflow: true });
expect(lastAssistantReplayOverflowCount([row])).toBe(1);
await c.query('DROP TABLE IF EXISTS _wal_counter');
}, 60_000);
});
@@ -1,14 +1,146 @@
import type { ModelMessage } from 'ai';
import {
resolveReplayBudget,
resolveEffectiveReplayThreshold,
isContextOverflowError,
estimateMessagesTokens,
trimHistoryForReplay,
REPLAY_BUDGET_DEFAULT_TOKENS,
REPLAY_BUDGET_WINDOW_FRACTION,
REPLAY_MIN_FLOOR_TOKENS,
REPLAY_TRUNCATION_MARKER,
REPLAY_TURN_COLLAPSED_MARKER,
} from './history-budget';
describe('resolveEffectiveReplayThreshold (#520 iterative escalation)', () => {
// The escalation table: each consecutive overflow (k) deepens the cut by 0.5×.
it('scales the base by 0.5**k, flooring (not rounding) fractional tokens', () => {
const base = 100_000;
expect(resolveEffectiveReplayThreshold(base, 0)).toBe(base); // k=0: unchanged
expect(resolveEffectiveReplayThreshold(base, 1)).toBe(50_000); // 0.5×
expect(resolveEffectiveReplayThreshold(base, 2)).toBe(25_000); // 0.25×
expect(resolveEffectiveReplayThreshold(base, 3)).toBe(12_500); // 0.125×
// Floors, not rounds.
expect(resolveEffectiveReplayThreshold(99_999, 1)).toBe(49_999);
});
it('passes a null base (trimming OFF) through unchanged for any k', () => {
for (const k of [0, 1, 2, 5, 100]) {
expect(resolveEffectiveReplayThreshold(null, k)).toBeNull();
}
});
// The crux of #520: convergence. A large k is clamped at REPLAY_MIN_FLOOR_TOKENS,
// so the escalation CONVERGES to a small-but-usable budget instead of trimming to
// zero — and, unlike the old fixed 0.5× that stuck at 50k, it drops far enough to
// fit a small real model window.
it('clamps a large k at the floor (converges, never below)', () => {
const base = 100_000;
for (const k of [4, 6, 10, 50, 200]) {
const t = resolveEffectiveReplayThreshold(base, k) as number;
expect(t).toBe(REPLAY_MIN_FLOOR_TOKENS);
expect(t).toBeGreaterThanOrEqual(REPLAY_MIN_FLOOR_TOKENS);
}
});
// Residual-brick regression (#520): flat-default base 100k, real window ~40k. The
// OLD fixed single 0.5× stuck at 50k > 40k forever (re-overflows every turn — the
// brick). The iterative cut drops BELOW 40k after a couple more consecutive
// overflows, so the history finally fits and the chat un-bricks.
it('un-bricks: escalation drops below a small real window the fixed 0.5× never could', () => {
const base = 100_000;
const realWindow = 40_000;
// The old terminal state: 0.5× = 50k, still above the window.
expect(resolveEffectiveReplayThreshold(base, 1)).toBeGreaterThan(realWindow);
// Escalation converges under the window.
const converged = [2, 3, 4, 5].some(
(k) => (resolveEffectiveReplayThreshold(base, k) as number) < realWindow,
);
expect(converged).toBe(true);
// MUTATION SENTINEL: reverting `** k` to `** 1` (fixed 0.5×) makes every k yield
// 50k, so `converged` above would be FALSE and this test reddens. Removing the
// floor reddens the clamp test instead.
});
// "Don't INFLATE above itself" is still an invariant: the floor never RAISES a
// legitimately small configured budget above itself (that would re-overflow the
// very window it was set for). Under #520 Option B the floor is min(FLOOR,
// floor(0.5×base)), so for a base BELOW the floor recovery MAY cut it further —
// down to floor(0.5×base), i.e. AT LEAST the old single 0.5× cut — but never above.
it('never inflates a small configured budget above itself (may cut below it, #520 Option B)', () => {
const small = 5_000; // below REPLAY_MIN_FLOOR_TOKENS
const floor = Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(small * 0.5)); // 2500
expect(resolveEffectiveReplayThreshold(small, 0)).toBe(small); // k=0 unchanged
for (const k of [1, 2, 3, 10]) {
const t = resolveEffectiveReplayThreshold(small, k) as number;
// Invariant: never RAISED above the configured budget.
expect(t).toBeLessThanOrEqual(small);
// Option B: never trimmed below the absolute floor (converges).
expect(t).toBeGreaterThanOrEqual(floor);
}
// The old single 0.5× cut is reached (and pinned) — recovery is never WORSE than
// before, and DOES cut below the configured budget on overflow (Option B). Under
// the old floor==budget behavior this would be `small` (5000), not `floor`.
expect(resolveEffectiveReplayThreshold(small, 1)).toBe(floor);
});
// #520 Option B: with a configured window (NOT the flat default), repeated overflow
// escalates the replay budget BELOW the configured budget, down to the absolute
// floor — the chat must not brick even when the configured window is too large for
// the model. A CLEAN turn (k back to 0) restores the full configured budget.
it('escalates below the configured budget down to the floor, and resets on a clean turn', () => {
// Configured context window 8000 -> budget = floor(0.7 × 8000) = 5600 (the code's
// window->budget ratio; compute it, do not hardcode).
const window = 8_000;
const budget = resolveReplayBudget(window).thresholdTokens as number;
expect(budget).toBe(Math.floor(REPLAY_BUDGET_WINDOW_FRACTION * window)); // 5600
// The absolute floor for this budget = min(FLOOR, floor(0.5 × budget)) = 2800
// (a "small window": 0.5×budget < REPLAY_MIN_FLOOR_TOKENS), which is BELOW the
// 5600 configured budget.
const floor = Math.min(
REPLAY_MIN_FLOOR_TOKENS,
Math.floor(budget * 0.5),
); // 2800
expect(floor).toBe(2_800);
expect(floor).toBeLessThan(budget); // below the configured budget
// k=0 -> full configured budget (no overflow yet).
expect(resolveEffectiveReplayThreshold(budget, 0)).toBe(budget);
// k=1 -> cut BELOW the configured budget to the 0.5× floor (2800). This is the
// MUTATION SENTINEL: with the OLD floor==configured-budget behavior this would be
// max(2800, 5600) = 5600 (never below budget), so the assertion reddens.
expect(resolveEffectiveReplayThreshold(budget, 1)).toBe(floor);
expect(resolveEffectiveReplayThreshold(budget, 1)).toBeLessThan(budget);
// k>=2 stays at the floor (converges, never below it).
for (const k of [2, 3, 5, 20]) {
expect(resolveEffectiveReplayThreshold(budget, k)).toBe(floor);
}
// A CLEAN turn resets k to 0 -> the full configured budget is restored (recovery
// is not sticky; it only bites while the provider keeps proving non-fit).
expect(resolveEffectiveReplayThreshold(budget, 0)).toBe(budget);
});
// #520 Option B, LARGE window: a big configured budget escalates in DISTINCT steps
// below itself, converging to the fixed absolute floor REPLAY_MIN_FLOOR_TOKENS.
it('a large configured budget escalates below itself down to REPLAY_MIN_FLOOR_TOKENS', () => {
const budget = resolveReplayBudget(200_000).thresholdTokens as number; // 140000
// The floor for a large window is the fixed REPLAY_MIN_FLOOR_TOKENS (8k), well
// BELOW the configured budget.
expect(
Math.min(REPLAY_MIN_FLOOR_TOKENS, Math.floor(budget * 0.5)),
).toBe(REPLAY_MIN_FLOOR_TOKENS);
const seq = [0, 1, 2, 3, 4, 5, 6].map(
(k) => resolveEffectiveReplayThreshold(budget, k) as number,
);
expect(seq).toEqual([140_000, 70_000, 35_000, 17_500, 8_750, 8_000, 8_000]);
// Every escalated step (k>=1) is below the configured budget; convergence floor.
for (const t of seq.slice(1)) expect(t).toBeLessThan(budget);
expect(seq[seq.length - 1]).toBe(REPLAY_MIN_FLOOR_TOKENS);
});
});
describe('resolveReplayBudget', () => {
it('uses floor(0.7 x window) for a configured window (no cap)', () => {
// 0.7 x 60k = 42k
+62 -11
View File
@@ -22,10 +22,35 @@ export const REPLAY_BUDGET_DEFAULT_TOKENS = 100_000;
/** Fraction of a configured context window used as the budget. */
export const REPLAY_BUDGET_WINDOW_FRACTION = 0.7;
/**
* Fraction of the normal budget used for the REACTIVE re-trim after a provider
* context-overflow 400 the preventive estimate under-counted, so cut harder.
* Per-step fraction of the normal budget applied on the REACTIVE re-trim after a
* provider context-overflow 400 the preventive estimate under-counted, so cut
* harder. This is now applied ITERATIVELY: with `k` consecutive overflow turns the
* budget is scaled by `fraction ** k` (k=1 -> 0.5×, k=2 -> 0.25×, ), so recovery
* ESCALATES turn over turn until the replayed history finally fits, instead of the
* old single fixed 0.5× cut that could never un-brick a chat whose real model
* window is smaller than 0.5 × the (unconfigured, flat-default) base budget (#520).
*/
export const REPLAY_AGGRESSIVE_FRACTION = 0.5;
/**
* Absolute lower bound (tokens) on the escalating reactive budget: the iterative
* cut is clamped here so it CONVERGES (a fixed floor, not an ever-shrinking value
* that would eventually trim everything). Rationale: below ~8k tokens a chat cannot
* carry meaningful recent context, and even a small real model window comfortably
* fits this much keep-recent-turns still applies on top, so a handful of recent
* turns survive.
*
* #520 Option B: for a LARGE configured window this floor sits BELOW the configured
* replay budget, and recovery is allowed to escalate PAST the configured budget down
* to it "the chat must not brick on overflow" is an INVARIANT of the reactive
* branch, and a configured window is a claim about model capacity, not a promise
* about replay size; once the provider proves non-fit with a 400, reality beats the
* config. This does NOT change the NORMAL path's upper bound (#510 Option A still
* respects the configured budget while it fits) it is survival once non-fit is
* proven. For a SMALL configured window the effective floor is instead the old
* single 0.5× cut (never worse than before, and never RAISED above the budget); see
* {@link resolveEffectiveReplayThreshold}.
*/
export const REPLAY_MIN_FLOOR_TOKENS = 8_000;
/**
* Turns (a user message + its assistant/tool replies) kept FULL at the tail,
* including the current one never trimmed. Older turns are compacted first.
@@ -85,22 +110,48 @@ export function resolveReplayBudget(rawContextWindow: unknown): ReplayBudget {
}
/**
* The effective replay threshold for THIS turn, given the base budget and whether
* the PREVIOUS turn hit a context-overflow 400 (the reactive-recovery signal,
* `metadata.replayOverflow`). On recovery the base budget is scaled down by
* {@link REPLAY_AGGRESSIVE_FRACTION}: the overflowing turn produced no usage
* signal, so the preventive estimate under-counted and a normal-threshold trim may
* not shrink enough to fit this harder cut is what un-bricks the chat.
* The effective replay threshold for THIS turn, given the base budget and `k` the
* number of CONSECUTIVE preceding turns that hit a context-overflow 400 (the
* reactive-recovery signal `metadata.replayOverflowCount`, read from the last
* assistant row). On recovery the base budget is scaled down ITERATIVELY by
* {@link REPLAY_AGGRESSIVE_FRACTION} ** k and clamped at {@link REPLAY_MIN_FLOOR_TOKENS}:
* - k=0 -> base unchanged (no overflow: nothing to recover from).
* - k=1 -> floor(0.5 × base); k=2 -> floor(0.25 × base); each further consecutive
* overflow tightens the cut, so recovery ESCALATES until the history fits.
* - the escalation is clamped at the floor so it CONVERGES this is what un-bricks
* a chat whose real model window is smaller than a single 0.5× cut of the base
* (e.g. an unconfigured window: flat-default base 100k, real window <50k) (#520).
*
* The overflowing turn produced no usage signal, so the preventive estimate
* under-counted and a normal-threshold (or single fixed 0.5×) trim may not shrink
* enough to fit; the escalating cut is what recovers such a chat.
*
* A `null` base budget (trimming OFF) is passed through unchanged: an explicit
* off-switch is never overridden by the recovery path.
*
* The absolute floor is `min(REPLAY_MIN_FLOOR_TOKENS, floor(0.5 × base))` (#520
* Option B):
* - LARGE window (floor(0.5 × base) >= REPLAY_MIN_FLOOR_TOKENS) -> the fixed
* REPLAY_MIN_FLOOR_TOKENS, which is BELOW the configured budget: escalation is
* ALLOWED to cut past the configured budget down to this absolute minimum, so a
* chat whose real model window is far smaller than the configured window still
* un-bricks (the invariant beats a config reality disproved with a 400).
* - SMALL window (floor(0.5 × base) < REPLAY_MIN_FLOOR_TOKENS) -> floor(0.5 ×
* base), i.e. AT LEAST the old single 0.5× cut: recovery is never WORSE than the
* pre-#520 behavior, and the floor is still never RAISED above the configured
* budget itself (that would re-overflow the very window it was set for).
*/
export function resolveEffectiveReplayThreshold(
thresholdTokens: number | null,
priorOverflowed: boolean,
k: number,
): number | null {
if (!priorOverflowed || thresholdTokens == null) return thresholdTokens;
return Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION);
if (thresholdTokens == null || k <= 0) return thresholdTokens;
const scaled = Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION ** k);
const floor = Math.min(
REPLAY_MIN_FLOOR_TOKENS,
Math.floor(thresholdTokens * REPLAY_AGGRESSIVE_FRACTION),
);
return Math.max(scaled, floor);
}
/**
@@ -106,6 +106,7 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.sharePage(s, true);
void client.unsharePage(s);
void client.restorePageVersion(s);
void client.savePageVersion(s);
void client.transformPage(s, s, { dryRun: true });
void client.stashPage(s);
// --- write (image / footnote), in-app since #410 ---
@@ -58,6 +58,7 @@ type DocmostClientMethod =
| 'sharePage'
| 'unsharePage'
| 'restorePageVersion'
| 'savePageVersion'
| 'transformPage'
| 'stashPage'
// --- write (image / footnote), in-app since #410 ---
+20
View File
@@ -11,6 +11,7 @@ import {
markdownToProseMirror,
markdownToProseMirrorCanonical,
mutatePageContent,
savePageVersionRealtime,
assertYjsEncodable,
MutationResult,
} from "../lib/collaboration.js";
@@ -55,6 +56,7 @@ export interface IPagesMixin {
listPageHistory(pageId: string, cursor?: string): any;
getPageHistory(historyId: string): any;
restorePageVersion(historyId: string): any;
savePageVersion(pageId: string): any;
diffPageVersions(pageId: string, from?: string, to?: string): any;
}
@@ -587,6 +589,24 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Bas
};
}
/**
* Save an intentional NAMED version of a page's CURRENT live content (#370).
* The write goes over the same agent-authenticated collab session content edits
* use, so the server derives kind='agent' from the signed actor. Resolves a
* SaveVersionResult: `{ saved:true, historyId, kind, alreadySaved }` on success,
* or `{ saved:false, skipped:true, reason }` when the server had nothing to pin
* (e.g. an empty page). A stale/missing pageId throws (not a benign skip).
*/
async savePageVersion(pageId: string) {
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
// Self-heal a rejected WS handshake once (#486): re-mint the collab token and
// retry, symmetric to the content-write path.
return this.writeWithCollabAuthRetry(collabToken, (token) =>
savePageVersionRealtime(pageUuid, token, this.apiUrl),
);
}
/**
* Diff two versions of a page and return a Docmost-equivalent change set.
* `from`/`to` each resolve to a ProseMirror doc:
+125
View File
@@ -139,7 +139,13 @@ export interface CollabProviderLike {
unsyncedChanges: number;
destroy(): void;
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
on(event: "stateless", handler: (data: { payload: string }) => void): void;
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
off(event: "stateless", handler: (data: { payload: string }) => void): void;
// Send a stateless (out-of-band, non-doc) message to the server. Used by the
// #370 explicit save-version path (payload `{type:'save-version'}`); the server
// replies with a broadcast `{type:'version.saved', …}` on the same channel.
sendStateless(payload: string): void;
}
/** The configuration object passed to the provider factory. */
@@ -564,6 +570,125 @@ export class CollabSession {
});
}
/**
* Send a stateless message over the live collaboration connection and resolve
* with the FIRST reply whose parsed payload satisfies `predicate`, rejecting on
* a bounded `timeoutMs`.
*
* Used by the #370 explicit save-version path: the client sends
* `{type:'save-version'}` and awaits the server's broadcast
* `{type:'version.saved', historyId, kind, alreadySaved}`. The stateless channel
* (not a REST read) is deliberate the server versions the LIVE in-memory ydoc,
* which the up-to-10s-stale page row would not yet reflect.
*
* `predicate` receives each incoming stateless message (already JSON-parsed) and
* returns the resolved value for a match or `undefined` to keep waiting; a
* malformed / unrelated payload is ignored. The listener is registered BEFORE
* the send so a fast reply is never missed.
*
* Lifecycle mirrors mutate(): it registers through `inflightReject` so a
* disconnect/close/auth-failure/teardown rejects THIS wait with the same
* connection-loss error, refuses to run on a non-ready session, fails fast on a
* concurrent in-flight op (the caller MUST hold the per-page lock), and re-arms
* the idle TTL (or self-destroys an ephemeral session) on completion.
*
* NOTE: the server broadcast carries no request-correlation id, so under a
* genuinely concurrent save on the SAME page (e.g. a human Cmd+S racing the
* agent) this resolves on whichever `version.saved` arrives first. The per-page
* lock serializes THIS process's saves; a cross-client race is inherent to the
* broadcast design and is benign (the result still describes a real save of this
* page's current content, and the server's save is promote-not-duplicate).
*/
sendStatelessAndAwait<T>(
payload: string,
predicate: (message: any) => T | undefined,
timeoutMs: number,
): Promise<T> {
// Belt-and-suspenders (acquire already validated): refuse to send on a
// session that is not in a live, synced, ready state.
if (
this.dead ||
this.state !== "ready" ||
this.connectionLost ||
!this.provider ||
this.provider.synced !== true
) {
return Promise.reject(
new Error("Collaboration session is not in a ready state"),
);
}
// Fail-fast on concurrent use: a second overlapping op would overwrite the
// first's inflightReject and hang it on disconnect (same guard as mutate).
if (this.inflightReject) {
return Promise.reject(
new Error(
"stateless op already in-flight; caller must serialize (hold the page lock)",
),
);
}
return new Promise<T>((resolve, reject) => {
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let statelessHandler: ((data: { payload: string }) => void) | undefined;
const localFinish = (err: Error | null, value?: T) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (statelessHandler && this.provider) {
try {
this.provider.off("stateless", statelessHandler);
} catch (e) {}
}
this.inflightReject = undefined;
if (err) reject(err);
else resolve(value as T);
// Post-settle lifecycle, identical to mutate's localFinish.
if (this.ephemeral) {
this.destroy("ephemeral op complete");
} else if (!this.dead) {
this.armIdle();
}
};
// Register so a disconnect/close/auth-failure/teardown rejects THIS op with
// the connection-loss error text (the `settled` guard makes a racing
// teardown + normal resolve safe — first one wins).
this.inflightReject = (e: Error) => localFinish(e);
// Register the reply listener BEFORE sending so a fast server broadcast is
// never missed.
statelessHandler = (data: { payload: string }) => {
if (settled) return;
let message: any;
try {
message = JSON.parse(data.payload);
} catch {
return; // unrelated / malformed stateless message — keep waiting
}
const matched = predicate(message);
if (matched !== undefined) localFinish(null, matched);
};
this.provider!.on("stateless", statelessHandler);
timer = setTimeout(() => {
localFinish(
new Error(
`Timeout waiting for a stateless reply from the collaboration server ${this.hint()}`,
),
);
}, timeoutMs);
try {
this.provider!.sendStateless(payload);
} catch (e) {
localFinish(e instanceof Error ? e : new Error(String(e)));
}
});
}
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
armIdle(): void {
if (this.dead || this.ephemeral) return;
+140
View File
@@ -324,6 +324,146 @@ export async function mutatePageContent(
});
}
/**
* Stateless-channel message types for the #370 explicit save-version handshake.
* These MUST match the server constants in
* apps/server/src/collaboration/extensions/persistence.extension.ts
* (SAVE_VERSION_MESSAGE_TYPE and the VERSION_SAVED / VERSION_SKIPPED replies)
* the mcp package cannot import server code, so the literals are duplicated here.
* KEEP THE TWO SIDES IN SYNC: the server broadcasts exactly ONE terminal reply per
* handled save (`version.saved` for a real save/promote, `version.skipped` for a
* reachable no-op an empty page or a missing page row); the client must match
* BOTH or it waits out the ack timeout on the skip case and misreports a healthy
* server as unreachable (#370 F2).
*/
const SAVE_VERSION_MESSAGE_TYPE = "save-version";
const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
const VERSION_SKIPPED_MESSAGE_TYPE = "version.skipped";
/**
* Bounded wait for the server's terminal reply after we ask it to save a version.
* The server flushes the live ydoc through its store path and writes a history row
* inside a DB transaction before broadcasting, so allow the same headroom as a
* persistence ack; reject (do NOT hang) past it. A timeout here means NO reply at
* all arrived the genuine "collab server unreachable/overloaded" case as
* distinct from a `version.skipped` reply, which resolves immediately.
*/
const SAVE_VERSION_ACK_TIMEOUT_MS = 20000;
/**
* The resolved outcome of an explicit save-version, surfaced to the tool caller.
* `saved:true` a version was created or promoted (historyId/kind/alreadySaved
* are present). `saved:false` the server had nothing to pin (a reachable no-op,
* e.g. an empty page); `skipped` + `reason` explain why. A page-not-found reply is
* NOT represented here it is surfaced as a thrown error (a bad/stale pageId).
*/
export interface SaveVersionResult {
saved: boolean;
historyId?: string;
kind?: string;
alreadySaved?: boolean;
skipped?: boolean;
reason?: string;
}
/**
* Parsed terminal reply the predicate hands back to savePageVersionRealtime. Kept
* internal: it carries the page-not-found case (mapped to a thrown error, never a
* returned result) that SaveVersionResult deliberately does not model.
*/
type SaveVersionReply =
| { outcome: "saved"; historyId: string; kind: string; alreadySaved: boolean }
| { outcome: "skipped"; reason: string };
/** Match the server's terminal reply (version.saved / version.skipped), ignoring
* any unrelated / cross-page stateless message so the wait is not resolved by
* noise. Returns `undefined` to keep waiting (#370 F1 predicate coverage). */
function matchSaveVersionReply(message: any): SaveVersionReply | undefined {
if (!message || typeof message !== "object") return undefined;
if (message.type === VERSION_SAVED_MESSAGE_TYPE) {
return {
outcome: "saved",
historyId: String(message.historyId),
kind: String(message.kind),
alreadySaved: !!message.alreadySaved,
};
}
if (message.type === VERSION_SKIPPED_MESSAGE_TYPE) {
return { outcome: "skipped", reason: String(message.reason ?? "unknown") };
}
return undefined;
}
/**
* Save an intentional version of a page's CURRENT live collaboration content
* (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the
* content writes use (#400) so it authenticates with the caller's agent collab
* token, and the server derives kind='agent' from that signed actor then sends a
* `{type:'save-version'}` stateless message and awaits the server's terminal reply
* (`version.saved` or `version.skipped`) on the same channel.
*
* Deliberately does NOT read `pages.content` over REST: the versioned content is
* the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would
* not yet reflect. The stateless round-trip is what makes the save exact.
*
* Outcomes:
* - a real save/promote `{ saved:true, historyId, kind, alreadySaved }`;
* - the server had nothing to pin (empty page) `{ saved:false, skipped:true,
* reason:'empty' }` — a clean, immediate no-op, NOT a stall;
* - the page row is gone (a stale/bad pageId) a thrown error, immediate and
* truthful (not the health-timeout path);
* - no reply within the timeout a thrown timeout error (the genuine "server
* unreachable/overloaded" case).
*
* On a TRANSPORT failure (timeout / disconnect) the session is destroyed so the
* next call reconnects fresh; a page-not-found is a healthy-connection terminal
* reply, so the session is left cached. A save is safe to retry the server
* promotes-not-duplicates an identical latest version so the caller (and its
* agent) may re-issue it without risking a duplicate heavy history row.
*/
export async function savePageVersionRealtime(
pageId: PageId,
collabToken: string,
baseUrl: string,
): Promise<SaveVersionResult> {
return withPageLock(pageId, async () => {
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
let reply: SaveVersionReply;
try {
reply = await session.sendStatelessAndAwait<SaveVersionReply>(
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
matchSaveVersionReply,
SAVE_VERSION_ACK_TIMEOUT_MS,
);
} catch (e) {
// TRANSPORT failure (no reply within the timeout, or a disconnect): drop the
// session so the next call reconnects fresh.
session.destroy("save-version failed");
throw e;
}
// A terminal reply arrived over a healthy connection — do NOT destroy the
// session; interpret the outcome.
if (reply.outcome === "skipped") {
if (reply.reason === "page-not-found") {
// A resolved pageId that the collab server no longer holds (deleted, or a
// stale id). Surface it immediately and truthfully, not as a health error.
throw new Error(
`savePageVersion: page ${pageId} was not found on the collaboration ` +
`server (it may have been deleted) — nothing was saved.`,
);
}
// Reachable benign no-op (e.g. an empty page): a clean result, not a throw.
return { saved: false, skipped: true, reason: reply.reason };
}
return {
saved: true,
historyId: reply.historyId,
kind: reply.kind,
alreadySaved: reply.alreadySaved,
};
});
}
/**
* Replace the live content of a page over the collaboration websocket.
* Accepts a ready ProseMirror JSON document; the caller controls whether
+2 -1
View File
@@ -44,7 +44,7 @@ export const ROUTING_PROSE =
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Pin the page's CURRENT content as a restorable named checkpoint when you finish a coherent editing pass -> savePageVersion (kind derived server-side as an agent version; an identical-to-last save is promoted/no-op'd, so a redundant call is harmless). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
/**
* Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE:
@@ -218,6 +218,7 @@ const TOOL_FAMILY: Record<string, Family> = {
diffPageVersions: "HISTORY",
listPageHistory: "HISTORY",
restorePageVersion: "HISTORY",
savePageVersion: "HISTORY",
exportPageMarkdown: "HISTORY",
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
// external MCP host, so it no longer appears in the generated inventory.
+25
View File
@@ -93,6 +93,7 @@ export type DocmostClientLike = Pick<
| 'sharePage'
| 'unsharePage'
| 'restorePageVersion'
| 'savePageVersion'
| 'stashPage'
| 'insertFootnote'
| 'insertImage'
@@ -743,6 +744,30 @@ export const SHARED_TOOL_SPECS = {
client.restorePageVersion(historyId as string),
},
savePageVersion: {
mcpName: 'savePageVersion',
inAppKey: 'savePageVersion',
writeClass: 'write',
description:
'Save an intentional, NAMED version (kind=agent) of the page\'s CURRENT ' +
'live content — a restorable checkpoint pinned into its history. Call it ' +
'when you have FINISHED a coherent editing pass (not after every small ' +
'edit), so the reader can see and roll back to the state you left. The ' +
'version type is derived SERVER-SIDE from your signed agent identity (you ' +
'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' +
'version is promoted/no-op\'d server-side, so a redundant call is harmless ' +
'and never duplicates a version. Returns { saved:true, historyId, kind, ' +
'alreadySaved } on success, or { saved:false, skipped:true, reason:\'empty\' } ' +
'when the page had nothing to pin (e.g. it was empty).',
tier: 'deferred',
catalogLine:
'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).',
buildShape: (z) => ({
pageId: z.string().min(1),
}),
execute: (client, { pageId }) => client.savePageVersion(pageId as string),
},
// --- markdown round-trip ---
importPageMarkdown: {
@@ -0,0 +1,300 @@
import { test, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert/strict";
import { EventEmitter } from "node:events";
import {
acquireCollabSession,
destroyAllSessions,
__setCollabProviderFactory,
} from "../../build/lib/collab-session.js";
import { savePageVersionRealtime } from "../../build/lib/collaboration.js";
// #370 — unit coverage for the MCP client transport of the explicit save-version.
// There is no collaboration server in the test env, so a fake HocuspocusProvider
// drives the stateless round-trip: it auto-completes the connect/sync handshake on
// a microtask (like the real provider), records sends, and either auto-broadcasts a
// configured reply (`opts.reply`) or lets the test emit stateless messages /
// disconnects by hand (`_emitStateless` / `_disconnect`) to exercise the predicate
// filter, the teardown-mid-wait path, and the timeout.
class FakeProvider extends EventEmitter {
static instances = [];
static reset() {
FakeProvider.instances = [];
}
static last() {
return FakeProvider.instances[FakeProvider.instances.length - 1];
}
constructor(config, opts = {}) {
super();
this.config = config;
this.synced = false;
this.unsyncedChanges = 0;
this.destroyed = false;
this.sent = [];
this.opts = opts;
FakeProvider.instances.push(this);
// Real HocuspocusProvider fires onSynced asynchronously after the handshake;
// a microtask reproduces that without depending on timers (so mock.timers on
// setTimeout does not stall the connect).
queueMicrotask(() => {
if (this.destroyed) return;
this.config.onConnect?.();
this.synced = true;
this.config.onSynced?.();
});
}
destroy() {
this.destroyed = true;
}
sendStateless(payload) {
this.sent.push(payload);
const reply = this.opts.reply;
if (!reply) return; // silent → the caller times out, or the test drives it
// Broadcast the configured server reply on a microtask, mirroring
// document.broadcastStateless landing back on this provider's `stateless` event.
queueMicrotask(() => {
if (this.destroyed) return;
this.emit("stateless", { payload: JSON.stringify(reply) });
});
}
// --- test drivers ---
_emitStateless(obj) {
this.emit("stateless", { payload: JSON.stringify(obj) });
}
_disconnect() {
this.config.onDisconnect?.();
}
}
const ENV_KEYS = [
"MCP_COLLAB_SESSION_IDLE_MS",
"MCP_COLLAB_SESSION_MAX_AGE_MS",
"MCP_COLLAB_SESSION_MAX_ENTRIES",
"MCP_COLLAB_TOKEN_TTL_MS",
];
let savedEnv;
beforeEach(() => {
savedEnv = {};
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
FakeProvider.reset();
});
afterEach(() => {
destroyAllSessions();
__setCollabProviderFactory(null);
mock.timers.reset();
for (const k of ENV_KEYS) {
if (savedEnv[k] === undefined) delete process.env[k];
else process.env[k] = savedEnv[k];
}
});
const PAGE_A = "11111111-1111-4111-8111-111111111111";
const PAGE_B = "22222222-2222-4222-8222-222222222222";
const PAGE_C = "33333333-3333-4333-8333-333333333333";
const PAGE_D = "44444444-4444-4444-8444-444444444444";
const PAGE_E = "55555555-5555-4555-8555-555555555555";
const PAGE_F = "66666666-6666-4666-8666-666666666666";
// --- happy paths (resolve with the server's terminal reply) -----------------
test("saved: resolves { saved:true, historyId, kind, alreadySaved }", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: {
type: "version.saved",
historyId: "hist-42",
kind: "agent",
alreadySaved: false,
},
}),
);
const result = await savePageVersionRealtime(PAGE_A, "collab-tok", "http://host/api");
assert.deepEqual(result, {
saved: true,
historyId: "hist-42",
kind: "agent",
alreadySaved: false,
});
// The client actually asked the server to save a version (the stateless send).
assert.deepEqual(
FakeProvider.last().sent.map((p) => JSON.parse(p)),
[{ type: "save-version" }],
);
});
test("saved: surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: {
type: "version.saved",
historyId: "hist-7",
kind: "manual",
alreadySaved: true,
},
}),
);
const result = await savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
assert.deepEqual(result, {
saved: true,
historyId: "hist-7",
kind: "manual",
alreadySaved: true,
});
});
// --- F2: terminal SKIP replies (no timeout, no false "unreachable") ---------
test("F2 skip: an empty-page reply resolves to a clean { saved:false, skipped:true, reason:'empty' } — no timeout", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: { type: "version.skipped", reason: "empty" },
}),
);
const result = await savePageVersionRealtime(PAGE_C, "collab-tok", "http://host/api");
assert.deepEqual(result, { saved: false, skipped: true, reason: "empty" });
});
test("F2 skip: a page-not-found reply throws an immediate, truthful error (not the health-timeout path)", async () => {
__setCollabProviderFactory(
(config) =>
new FakeProvider(config, {
reply: { type: "version.skipped", reason: "page-not-found" },
}),
);
await assert.rejects(
savePageVersionRealtime(PAGE_D, "collab-tok", "http://host/api"),
/was not found on the collaboration server/,
);
});
// --- F1 (a): predicate filters unrelated messages, resolves on the real reply ---
test("F1(a) unrelated-then-real: an unrelated stateless message does NOT resolve; the later version.saved does", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // manual drive
const p = savePageVersionRealtime(PAGE_E, "collab-tok", "http://host/api");
// Let acquire's handshake + the stateless send complete so the listener is armed.
await new Promise((r) => setImmediate(r));
const provider = FakeProvider.last();
// Noise first: an unrelated stateless type (e.g. another feature's broadcast).
provider._emitStateless({ type: "something-else", historyId: "NOPE" });
// The wait must still be pending — a microtask turn would have resolved it if the
// predicate wrongly matched.
let settledEarly = false;
p.then(() => (settledEarly = true)).catch(() => (settledEarly = true));
await new Promise((r) => setImmediate(r));
assert.equal(settledEarly, false, "resolved on an unrelated stateless message");
// Now the real reply arrives → it resolves with that payload.
provider._emitStateless({
type: "version.saved",
historyId: "h-real",
kind: "agent",
alreadySaved: false,
});
const result = await p;
assert.deepEqual(result, {
saved: true,
historyId: "h-real",
kind: "agent",
alreadySaved: false,
});
});
// --- F1 (b): teardown mid-wait rejects AND removes the stateless listener -----
test("F1(b) teardown mid-wait: a disconnect rejects the wait with the connection-loss error AND removes the stateless listener (no leak)", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
const session = await acquireCollabSession(PAGE_F, "collab-tok", "http://host/api");
const provider = FakeProvider.last();
const p = session.sendStatelessAndAwait(
JSON.stringify({ type: "save-version" }),
(m) => (m && m.type === "version.saved" ? m : undefined),
20000,
);
// Swallow the eventual rejection so it does not race the assertions below.
p.catch(() => {});
await new Promise((r) => setImmediate(r));
// The wait armed exactly one stateless listener.
assert.equal(
provider.listenerCount("stateless"),
1,
"the wait must register one stateless listener",
);
// A disconnect tears the session down mid-wait.
provider._disconnect();
await assert.rejects(p, /connection closed before the update was persisted/);
// The listener was removed on teardown — dropping provider.off() reds this.
assert.equal(
provider.listenerCount("stateless"),
0,
"the stateless listener leaked after teardown",
);
});
// --- F1 (c): concurrent-in-flight guard fails fast, no cross-wiring -----------
test("F1(c) concurrent guard: a second sendStatelessAndAwait while one is in flight fails fast", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
const session = await acquireCollabSession(PAGE_A, "collab-tok", "http://host/api");
const first = session.sendStatelessAndAwait(
JSON.stringify({ type: "save-version" }),
(m) => (m && m.type === "version.saved" ? m : undefined),
20000,
);
first.catch(() => {}); // it stays pending until afterEach destroys the session
await assert.rejects(
session.sendStatelessAndAwait(
JSON.stringify({ type: "save-version" }),
(m) => (m && m.type === "version.saved" ? m : undefined),
20000,
),
/already in-flight/,
);
});
// --- timeout: NO reply at all is the genuine "server unreachable" path --------
test("timeout: rejects on a bounded timeout when the server never replies", async () => {
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
mock.timers.enable({ apis: ["setTimeout"] });
const p = savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
// Swallow the eventual rejection so an unhandled-rejection does not race the tick.
p.catch(() => {});
// Flush the (microtask-driven) connect handshake + the stateless send so the
// ack timer is armed before we advance the clock.
await new Promise((r) => setImmediate(r));
// Advance well past the 20s ack window; the connect timer (25s) does not fire.
mock.timers.tick(20000 + 100);
await assert.rejects(p, /Timeout waiting for a stateless reply/);
});