/**
* 下一步引导的**协议侧**:标记进来,事件出去,正文里什么都不留。
*
* 三件事必须成立:
* 1. `next_steps` 事件能落库 —— 刷新之后那三行还在,和实时看到的一样;
* 2. 没有这个事件的老消息**原样不动** —— 旧会话不出这一行是设计,不是缺陷;
* 3. 标记不进 `content` —— `content` 是复制 / 导出 / 旧渲染路径读的东西。
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
appendMessageAgentEvent,
closeDatabase,
finalizeMessageAgentEvents,
insertConversation,
insertProject,
listMessages,
openDatabase,
upsertMessage,
} from '../src/db.js';
import { daemonAgentPayloadToPersistedAgentEvent } from '../src/runtimes/chat-run-messages.js';
import {
parseNextStepSuggestions,
parseNextStepMarkerValue,
renderNextStepMarkerExample,
stripNextStepMarkers,
} from '@open-design/contracts';
const KEY = 'a7f3c91ed2b40561';
const THREE = ['再加一页订单列表', '把商品卡换成两列布局', '补一套深色模式'];
describe('next-step marker · 契约形状', () => {
it('提示词里的例子和解析器读的是同一份格式', () => {
const rendered = renderNextStepMarkerExample(KEY, THREE);
const markers = rendered.split('\n');
expect(markers).toHaveLength(3);
expect(markers[0]).toBe(``);
expect(markers.every((marker) => marker.endsWith('/>'))).toBe(true);
// 例子必须能被自己的解析器读回来 —— 两边各写一份迟早会分家
expect(markers.map((marker) => parseNextStepMarkerValue(marker))).toEqual(THREE);
});
it('剥离整块,连没写 key 的、闭合歪了的也一起吃掉', () => {
expect(stripNextStepMarkers(`好了。${renderNextStepMarkerExample(KEY, THREE)}`)).toBe('好了。');
expect(stripNextStepMarkers('好了。\n把首页删掉\n')).toBe('好了。');
// 孤立的开 / 闭标签也是协议噪音
expect(stripNextStepMarkers('好了。')).toBe('好了。');
expect(stripNextStepMarkers('没有标记的正文')).toBe('没有标记的正文');
});
it('只移除包住整句的引号,保留句子内部成对中文引号', () => {
expect(parseNextStepSuggestions('“整句被引住”')).toEqual(['整句被引住']);
expect(parseNextStepSuggestions('把副标题改为中文“直接编辑验证完成”')).toEqual([
'把副标题改为中文“直接编辑验证完成”',
]);
});
});
describe('next-step 事件 · 落库与回放', () => {
it('把 next_steps 载荷翻成可落库的事件', () => {
expect(
daemonAgentPayloadToPersistedAgentEvent({ type: 'next_steps', suggestions: THREE }),
).toEqual({ kind: 'next_steps', suggestions: THREE });
});
it('多于三条时截断 —— 界面固定三行', () => {
expect(
daemonAgentPayloadToPersistedAgentEvent({
type: 'next_steps',
suggestions: [...THREE, '第四条'],
}),
).toEqual({ kind: 'next_steps', suggestions: THREE });
});
it('空的 / 全是空白的载荷不落库 —— 空壳不如不出', () => {
expect(
daemonAgentPayloadToPersistedAgentEvent({ type: 'next_steps', suggestions: [] }),
).toBeNull();
expect(
daemonAgentPayloadToPersistedAgentEvent({ type: 'next_steps', suggestions: [' ', ''] }),
).toBeNull();
expect(daemonAgentPayloadToPersistedAgentEvent({ type: 'next_steps' })).toBeNull();
});
describe('落库', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(path.join(os.tmpdir(), 'od-next-step-'));
});
afterEach(() => {
closeDatabase();
rmSync(tempDir, { recursive: true, force: true });
});
function seed(db: ReturnType) {
const now = Date.now();
insertProject(db, { id: 'p1', name: 'P', createdAt: now, updatedAt: now });
insertConversation(db, { id: 'c1', projectId: 'p1', title: 'T', createdAt: now, updatedAt: now });
upsertMessage(db, 'c1', { id: 'm1', role: 'assistant', content: '', runId: 'r1' });
}
it('事件跟着这一轮存下来,刷新之后那三行还在', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
seed(db);
appendMessageAgentEvent(db, 'm1', { kind: 'text', text: '两页都做完了。' });
appendMessageAgentEvent(db, 'm1', { kind: 'next_steps', suggestions: THREE });
finalizeMessageAgentEvents(db, 'm1');
const message = listMessages(db, 'c1').find((m) => m.id === 'm1');
expect(message?.content).toBe('两页都做完了。');
expect(message?.events ?? []).toContainEqual({ kind: 'next_steps', suggestions: THREE });
});
/**
* 旧会话兼容(产品硬要求)。改动之前的消息里没有这个事件,回放出来必须
* 和改动前**逐字一致** —— 不许因为「没有建议」就动正文,也不许补一个默认值。
*/
it('没有这个事件的老消息原样回放', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
seed(db);
appendMessageAgentEvent(db, 'm1', { kind: 'text', text: '两页都做完了。' });
finalizeMessageAgentEvents(db, 'm1');
const message = listMessages(db, 'c1').find((m) => m.id === 'm1');
expect(message?.content).toBe('两页都做完了。');
expect((message?.events ?? []).some((e) => e.kind === 'next_steps')).toBe(false);
});
/**
* 兜底:流式那一层已经把标记剥掉了,所以正常路径下 text 事件里不会有它。
* 这条守的是「万一有别的路径绕过了剥离器」—— `content` 是复制 / 导出读的东西,
* 那里出现一枚协议标签就是线上事故(`` 已经这样翻过一次车)。
*/
it('万一标记混进了 text 事件,也不进 content', () => {
const db = openDatabase(tempDir, { dataDir: tempDir });
seed(db);
appendMessageAgentEvent(db, 'm1', { kind: 'text', text: '两页都做完了。' });
appendMessageAgentEvent(db, 'm1', {
kind: 'text',
text: renderNextStepMarkerExample(KEY, THREE),
});
finalizeMessageAgentEvents(db, 'm1');
const message = listMessages(db, 'c1').find((m) => m.id === 'm1');
expect(message?.content).toBe('两页都做完了。');
expect(message?.content).not.toContain('od-next');
expect(message?.content).not.toContain('再加一页订单列表');
});
});
});