Approve-with-comments re-review; no blockers. All 7 actionable points (8 is a forward-looking architecture note — recommendation A, keep as-is): 1. chat-markdown.util spec: restore parity coverage of the removed client spec — tool error state (+ errorText), unknown-tool fallback (`Ran tool <name>` en / `Выполнил инструмент <name>` ru), and the circular-output stringify catch. 2. findAllByChat row cap is now testable (injectable limit) + an int-spec proves truncation on a modest volume. 3. Stability: the per-step durability updates are SERIALIZED via a promise chain (stepUpdateChain) so they commit in step order — onlyIfStreaming already closed the finalize race, this closes inter-step ordering. 4. findAllByChat keeps the NEWEST messages on truncation (order DESC + reverse, like findRecent) and logs a warning with chatId, instead of silently dropping the newest tail. 5. The LABELS parity comment already references the real path (tool-parts.tsx / toolLabelKey) — confirmed accurate. 6. Removed the redundant 'off-by-one boundary' test (strict subset of the two adjacent prepareAgentStep cases). 7. Extracted the terminal-finalize dispatch into a shared `applyFinalize`, used by BOTH the service's finalizeAssistant and its test — the test now exercises the real path, not a copy, so a production drift fails it. Verified: server build + 325 ai-chat unit + 6 integration; prettier clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
296 lines
8.7 KiB
TypeScript
296 lines
8.7 KiB
TypeScript
import { buildChatMarkdown, normalizeLang } from './chat-markdown.util';
|
|
import type { AiChatMessage } from '@docmost/db/types/entity.types';
|
|
|
|
/**
|
|
* normalizeLang: the client sends `i18n.language` — a FULL locale tag like
|
|
* 'en-US' / 'ru-RU', NOT a bare 'en'/'ru'. A `@IsIn(['en','ru'])` DTO rejected
|
|
* that with a 400 (caught in real-browser testing); the export now accepts any
|
|
* string and normalizes here. Guards that regression.
|
|
*/
|
|
describe('normalizeLang', () => {
|
|
it("maps any 'ru…' locale tag to ru", () => {
|
|
expect(normalizeLang('ru')).toBe('ru');
|
|
expect(normalizeLang('ru-RU')).toBe('ru');
|
|
expect(normalizeLang('RU-ru')).toBe('ru');
|
|
});
|
|
|
|
it('maps everything else (incl. region-qualified English) to en', () => {
|
|
expect(normalizeLang('en')).toBe('en');
|
|
expect(normalizeLang('en-US')).toBe('en');
|
|
expect(normalizeLang('fr-FR')).toBe('en');
|
|
expect(normalizeLang(undefined)).toBe('en');
|
|
expect(normalizeLang('')).toBe('en');
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Unit tests for the SERVER Markdown export (#183). Mirrors the coverage of the
|
|
* (now-removed) client chat-markdown tests: heading/metadata, role labels, text
|
|
* + tool blocks, token footers, the interrupted-turn note, and NULL-status
|
|
* (legacy) rows. The export embeds a live `new Date().toISOString()` timestamp;
|
|
* we never assert it, only the deterministic structure.
|
|
*/
|
|
|
|
function row(partial: Partial<AiChatMessage>): AiChatMessage {
|
|
return {
|
|
id: partial.id ?? 'id',
|
|
chatId: partial.chatId ?? 'chat-1',
|
|
workspaceId: partial.workspaceId ?? 'ws-1',
|
|
userId: partial.userId ?? null,
|
|
role: partial.role ?? 'user',
|
|
content: partial.content ?? null,
|
|
toolCalls: partial.toolCalls ?? null,
|
|
metadata: partial.metadata ?? null,
|
|
status: partial.status ?? null,
|
|
createdAt: partial.createdAt ?? ('2026-06-21T00:00:00.000Z' as never),
|
|
updatedAt: partial.updatedAt ?? ('2026-06-21T00:00:00.000Z' as never),
|
|
deletedAt: partial.deletedAt ?? null,
|
|
} as AiChatMessage;
|
|
}
|
|
|
|
describe('buildChatMarkdown (server) — structure', () => {
|
|
it('emits the title heading, chat id and message count', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'My chat',
|
|
chatId: 'chat-123',
|
|
rows: [],
|
|
});
|
|
expect(md).toContain('# My chat');
|
|
expect(md).toContain('- Chat ID: `chat-123`');
|
|
expect(md).toContain('- Messages: 0');
|
|
});
|
|
|
|
it('falls back to "Untitled chat" with no title (en)', () => {
|
|
const md = buildChatMarkdown({ title: null, chatId: 'c', rows: [] });
|
|
expect(md).toContain('# Untitled chat');
|
|
});
|
|
|
|
it('localizes fixed labels with lang=ru (structure stays English)', () => {
|
|
const md = buildChatMarkdown({
|
|
title: null,
|
|
chatId: 'c',
|
|
lang: 'ru',
|
|
rows: [row({ role: 'assistant', content: 'hi' })],
|
|
});
|
|
expect(md).toContain('# Без названия');
|
|
expect(md).toContain('## 1. ИИ-агент');
|
|
// Structural words remain English.
|
|
expect(md).toContain('- Chat ID:');
|
|
});
|
|
|
|
it('numbers messages and labels roles (You / AI agent)', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({ role: 'user', content: 'question' }),
|
|
row({ role: 'assistant', content: 'answer' }),
|
|
],
|
|
});
|
|
expect(md).toContain('## 1. You');
|
|
expect(md).toContain('question');
|
|
expect(md).toContain('## 2. AI agent');
|
|
expect(md).toContain('answer');
|
|
});
|
|
|
|
it('renders a tool part with fenced input/output and the friendly label', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({
|
|
role: 'assistant',
|
|
content: 'done',
|
|
metadata: {
|
|
parts: [
|
|
{
|
|
type: 'tool-getPage',
|
|
state: 'output-available',
|
|
input: { id: 'p1' },
|
|
output: { title: 'Hello' },
|
|
},
|
|
{ type: 'text', text: 'done' },
|
|
],
|
|
} as never,
|
|
}),
|
|
],
|
|
});
|
|
expect(md).toContain('**Tool: Read page** (`getPage`) — done');
|
|
expect(md).toContain('Input:');
|
|
expect(md).toContain('"id": "p1"');
|
|
expect(md).toContain('Output:');
|
|
expect(md).toContain('"title": "Hello"');
|
|
});
|
|
|
|
// #186 re-review pt 1: restore the parity coverage of the removed client spec —
|
|
// error state, unknown-tool fallback (en + ru), and the circular-stringify catch.
|
|
it('renders a tool part in the error state with its errorText', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({
|
|
role: 'assistant',
|
|
metadata: {
|
|
parts: [
|
|
{
|
|
type: 'tool-getPage',
|
|
state: 'output-error',
|
|
input: { id: 'p1' },
|
|
errorText: 'page not found',
|
|
},
|
|
],
|
|
} as never,
|
|
}),
|
|
],
|
|
});
|
|
expect(md).toContain('**Tool: Read page** (`getPage`) — error');
|
|
expect(md).toContain('**Error:** page not found');
|
|
});
|
|
|
|
it('falls back to "Ran tool <name>" for an unknown tool (en) and the ru variant', () => {
|
|
const parts = [
|
|
{
|
|
type: 'tool-mysteryTool',
|
|
state: 'output-available',
|
|
output: { ok: 1 },
|
|
},
|
|
];
|
|
const en = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [row({ role: 'assistant', metadata: { parts } as never })],
|
|
});
|
|
expect(en).toContain('**Tool: Ran tool mysteryTool** (`mysteryTool`)');
|
|
const ru = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
lang: 'ru',
|
|
rows: [row({ role: 'assistant', metadata: { parts } as never })],
|
|
});
|
|
expect(ru).toContain('Выполнил инструмент mysteryTool');
|
|
});
|
|
|
|
it('does not throw on a circular tool output (falls back to String)', () => {
|
|
const circular: Record<string, unknown> = {};
|
|
circular.self = circular;
|
|
expect(() =>
|
|
buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({
|
|
role: 'assistant',
|
|
metadata: {
|
|
parts: [
|
|
{
|
|
type: 'tool-getPage',
|
|
state: 'output-available',
|
|
output: circular,
|
|
},
|
|
],
|
|
} as never,
|
|
}),
|
|
],
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('emits a token footer + total when usage is present', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({
|
|
role: 'assistant',
|
|
content: 'a',
|
|
metadata: {
|
|
usage: {
|
|
inputTokens: 100,
|
|
outputTokens: 20,
|
|
totalTokens: 120,
|
|
reasoningTokens: 8,
|
|
},
|
|
} as never,
|
|
}),
|
|
],
|
|
});
|
|
expect(md).toContain('- Total tokens: 120');
|
|
expect(md).toContain(
|
|
'_Tokens — in: 100, out: 20, reasoning: 8, total: 120_',
|
|
);
|
|
});
|
|
|
|
it('flags a still-streaming (interrupted) row', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({ role: 'assistant', content: 'partial', status: 'streaming' }),
|
|
],
|
|
});
|
|
expect(md).toContain('still being generated');
|
|
});
|
|
|
|
it('does NOT flag a completed row', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [row({ role: 'assistant', content: 'final', status: 'completed' })],
|
|
});
|
|
expect(md).not.toContain('still being generated');
|
|
});
|
|
|
|
it('renders a legacy NULL-status row (no parts) from plain content', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({ role: 'assistant', content: 'legacy answer', status: null }),
|
|
],
|
|
});
|
|
expect(md).toContain('legacy answer');
|
|
expect(md).not.toContain('still being generated');
|
|
});
|
|
|
|
it('renders a persisted error', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({
|
|
role: 'assistant',
|
|
content: '',
|
|
status: 'error',
|
|
metadata: { error: '401: Unauthorized' } as never,
|
|
}),
|
|
],
|
|
});
|
|
expect(md).toContain('**⚠️ Error:** 401: Unauthorized');
|
|
});
|
|
|
|
it('escapes embedded triple-backtick fences with a longer delimiter', () => {
|
|
const md = buildChatMarkdown({
|
|
title: 'T',
|
|
chatId: 'c',
|
|
rows: [
|
|
row({
|
|
role: 'assistant',
|
|
content: 'x',
|
|
metadata: {
|
|
parts: [
|
|
{
|
|
type: 'tool-getPage',
|
|
state: 'output-available',
|
|
output: '```inner```',
|
|
},
|
|
],
|
|
} as never,
|
|
}),
|
|
],
|
|
});
|
|
// A 4-backtick fence wraps content that itself contains a 3-backtick run.
|
|
expect(md).toContain('````');
|
|
});
|
|
});
|