fix: preserve complete consultation replies and recover retained history
This commit is contained in:
1 parent
dda2f0a3be
commit
d530abe580
14 files changed
+643
-38
No files matched your search
@@ -1,5 +1,6 @@
|
||||
// Deterministic responses for layout/interaction tests only; never imported by the app.
|
||||
import type { TeacherDefinition, TeacherTopic, TeacherSend } from '../../../shared/coding-teacher';
|
||||
import { parseTeacherReply } from '../../../shared/teacher-reply';
|
||||
const definition: TeacherDefinition = { schema_version: 1, teacher_id: 'coding-teacher', name: '麦洛智能体', description: '', avatar_id: 'avatar-01', welcome_message: '', suggested_questions: [], system_prompt: '测试', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1500 } };
|
||||
const samples: Record<string, unknown> = {
|
||||
ideas: { kind: 'ideas', title: '我的小狗世界', items: [{ id: 'dog', text: '养一只自己的小狗', state: 'kept' }, { id: 'breed', parentId: 'dog', text: '是一只柯基', state: 'kept' }, { id: 'garden', text: '有一片可以种东西的花园', state: 'kept' }, { id: 'food', parentId: 'garden', text: '种出来的东西可以喂小狗', state: 'suggested' }] },
|
||||
@@ -10,6 +11,17 @@ const samples: Record<string, unknown> = {
|
||||
const scenario = new URLSearchParams(location.search).get('kind') ?? 'ideas';
|
||||
let current: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'discussion-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'project', definition, version: 1, createdAt: 'now', updatedAt: 'now', requests: Array.from({ length: 8 }, (_, i) => ({ id: `r${i}`, text: i === 7 ? '我还没想好,我们边聊边想。' : '我希望这个游戏更有意思。', references: [], createdAt: 'now', sourceCursor: { workerGeneration: 0, seq: i }, sourceCapturedAt: 'now', includedSourceMessageIds: [], omittedMessages: 0, status: 'completed', response: i === 7 ? '可以,我们先把已经想到的放在一起。哪里还不确定,就留着继续聊。' : '先从你最在意的一个小地方开始想。', ...(i === 7 ? { suggestedQuestions: ['有什么好玩的想法?'] } : {}) })), ...(samples[scenario] ? { discussion: { id: 'tool', revision: 1, status: 'active', content: structuredClone(samples[scenario]) } } : {}) };
|
||||
const stream = Object.assign(new EventTarget(), { close() {}, onerror: null, onopen: null });
|
||||
if (scenario === 'recovered' || scenario === 'incomplete') {
|
||||
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。'
|
||||
+ (scenario === 'recovered' ? '","quickReplies":[],"tool":{"kind":"structure","nodes":[]}}' : '');
|
||||
const parsed = parseTeacherReply(raw);
|
||||
current.requests = [{
|
||||
...current.requests[0], text: '现在项目是什么阶段?', sourceConversationId: 'original-source',
|
||||
references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
|
||||
response: parsed.reply, replyIncomplete: parsed.incomplete, replyParseError: parsed.parseError,
|
||||
suggestedQuestions: parsed.quickReplies, unparsedResponse: raw,
|
||||
}];
|
||||
}
|
||||
if (scenario === 'retry') {
|
||||
const status = new URLSearchParams(location.search).get('status') === 'cancelled' ? 'cancelled' : 'failed';
|
||||
current.requests = [{
|
||||
|
||||
@@ -22,6 +22,7 @@ type HostConnection = {
|
||||
};
|
||||
|
||||
type TeacherCatalogFixture = {
|
||||
nextTeacherReplyIncomplete?: boolean;
|
||||
teacherCatalog: TeacherCatalog;
|
||||
teacherCatalogFailure: boolean;
|
||||
holdTeacherCatalog: boolean;
|
||||
@@ -575,6 +576,12 @@ async function installCodingFirstChatHost(
|
||||
const suggestionRequest = body!.intent === 'suggestions';
|
||||
const teacherResponse = suggestionRequest ? '我们可以从你最近试过的地方聊起。' : '先理解状态如何随点击变化,再修改代码。';
|
||||
consultationTopics[consultationPath]={...currentTopic,revision:Number(currentTopic?.revision??0)+1,requests:[...(currentTopic?.requests as unknown[]??[]),{id:body!.requestId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response:teacherResponse,...(suggestionRequest?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
|
||||
if (state.nextTeacherReplyIncomplete) {
|
||||
state.nextTeacherReplyIncomplete = false;
|
||||
const reply = (consultationTopics[consultationPath].requests as Record<string, unknown>[]).at(-1)!;
|
||||
Object.assign(reply, { response: '它早就不是', replyIncomplete: true, replyParseError: '格式不完整',
|
||||
sourceConversationId: body!.sourceConversationId, unparsedResponse: '{"reply":"它早就不是"刚搭好架子' });
|
||||
}
|
||||
return respond(consultationTopics[consultationPath],202);
|
||||
}
|
||||
if (path.endsWith(`${topicBase}/${topicId}`)) return respond(currentTopic);
|
||||
@@ -1894,6 +1901,50 @@ test('project consultations preserve student drafts and switch between work and
|
||||
});
|
||||
|
||||
|
||||
test('incomplete consultation replies retry only on click and preserve both drafts', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(app);
|
||||
const connection = await page.evaluate(async () => ({
|
||||
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
|
||||
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
|
||||
}));
|
||||
await installCodingFirstChatHost(app, connection, true);
|
||||
await settleSnapshot(app);
|
||||
await disableCodingEventSource(page);
|
||||
await app.evaluate(() => {
|
||||
(globalThis as typeof globalThis & { __makelorePiFirstChatE2E: TeacherCatalogFixture })
|
||||
.__makelorePiFirstChatE2E.nextTeacherReplyIncomplete = true;
|
||||
});
|
||||
try {
|
||||
await page.reload();
|
||||
page = await getStableWindow(app);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const mainDraft = page.getByTestId('coding-message-composer').getByRole('textbox', { includeHidden: true });
|
||||
await mainDraft.fill('主对话草稿');
|
||||
await page.getByRole('button', { name: '与代码智能体聊天', exact: true }).click();
|
||||
const teacher = page.getByTestId('teacher-chat-panel');
|
||||
const draft = teacher.getByRole('textbox', { name: '向智能体提问' });
|
||||
await draft.fill('现在项目是什么阶段?');
|
||||
await teacher.getByRole('button', { name: '提问', exact: true }).click();
|
||||
await expect(teacher.getByRole('status')).toHaveText('这次回复未能完整显示');
|
||||
await expect(teacher.getByTestId('teacher-reply')).toHaveCount(0);
|
||||
await expect(teacher.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
|
||||
const sends = async () => (await readState(app)).captured.filter(item => item.path.endsWith('/messages') && item.method === 'POST');
|
||||
expect(await sends()).toHaveLength(1);
|
||||
await draft.fill('咨询里还没写完的草稿');
|
||||
await teacher.getByRole('button', { name: '重新回答' }).click();
|
||||
await expect(teacher.getByText('先理解状态如何随点击变化,再修改代码。')).toBeVisible();
|
||||
await expect(draft).toHaveValue('咨询里还没写完的草稿');
|
||||
await expect(mainDraft).toHaveValue('主对话草稿');
|
||||
const inputs = await sends();
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[1].path).toBe(inputs[0].path);
|
||||
expect(inputs[1].body).toEqual({ ...inputs[0].body, requestId: expect.any(String) });
|
||||
expect(inputs[1].body!.requestId).not.toBe(inputs[0].body!.requestId);
|
||||
} finally { await releaseSnapshot(app); }
|
||||
});
|
||||
|
||||
test('manual agent refresh updates the catalog and retries failures without replacing the open topic or drafts', async ({ launchElectronApp }) => {
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(electronApp);
|
||||
|
||||
@@ -47,6 +47,40 @@ test('retry stickers stay readable and preserve drafts in a 319px consultation',
|
||||
});
|
||||
|
||||
for (const width of [319, 508]) {
|
||||
test(`quoted replies recover fully and incomplete replies retry without losing the draft at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 800 });
|
||||
const sent = () => page.evaluate(() => (window as unknown as { __teacherReplyInputs: unknown[] }).__teacherReplyInputs);
|
||||
await page.goto(`${url}?kind=recovered&width=${width}`);
|
||||
const panel = page.getByTestId('teacher-chat-panel');
|
||||
const body = page.getByTestId('teacher-reply');
|
||||
await expect(body).toContainText('它早就不是"刚搭好架子"那种阶段了');
|
||||
await expect(body).toContainText('现在的阶段是「能玩,正在调手感」。');
|
||||
await expect(page.getByText('查看收到的原始内容').locator('..')).not.toHaveAttribute('open');
|
||||
await expect(page.getByRole('button', { name: '重新回答' })).toHaveCount(0);
|
||||
expect(await sent()).toEqual([]);
|
||||
expect(await body.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
await panel.screenshot({ path: test.info().outputPath(`recovered-${width}.png`) });
|
||||
|
||||
await page.goto(`${url}?kind=incomplete&width=${width}`);
|
||||
await expect(page.getByRole('status')).toHaveText('这次回复未能完整显示');
|
||||
await expect(page.getByTestId('teacher-reply')).toHaveCount(0);
|
||||
expect(await sent()).toEqual([]);
|
||||
const input = page.getByRole('textbox', { name: '向智能体提问' });
|
||||
await input.fill('我还在写的新想法');
|
||||
const retry = page.getByRole('button', { name: '重新回答' });
|
||||
const bounds = (await retry.boundingBox())!;
|
||||
expect(bounds.height).toBeGreaterThanOrEqual(44);
|
||||
expect(await panel.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
|
||||
await panel.screenshot({ path: test.info().outputPath(`incomplete-${width}.png`) });
|
||||
await retry.click();
|
||||
await expect(page.getByText('好,我们就从这一个问题聊起。')).toBeVisible();
|
||||
await expect(input).toHaveValue('我还在写的新想法');
|
||||
expect(await sent()).toEqual([{
|
||||
requestId: expect.any(String), text: '现在项目是什么阶段?',
|
||||
sourceConversationId: 'original-source', references: [{ messageId: 'original-message', text: '原来的项目记录', role: 'assistant' }],
|
||||
}]);
|
||||
});
|
||||
|
||||
test(`empty-composer hint stays readable and keeps the send position stable at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1180, height: 800 });
|
||||
await page.goto(`${url}?kind=ordinary&width=${width}`);
|
||||
|
||||
@@ -158,6 +158,122 @@ describe('teacher side chat', () => {
|
||||
expect(details?.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('marks an incomplete completed reply instead of displaying a half sentence as a finished answer', async () => {
|
||||
const raw = '{"reply":"它早就不是"刚搭好架子"那种阶段了——还有后面的回答';
|
||||
const bringBack = vi.fn();
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
text: '现在项目是什么阶段?', response: '它早就不是', replyIncomplete: true,
|
||||
replyParseError: 'format error', unparsedResponse: raw, suggestedQuestions: ['尚未确认的建议'],
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" onBringBack={bringBack} />);
|
||||
await ready();
|
||||
expect(screen.getByRole('status')).toHaveTextContent('这次回复未能完整显示');
|
||||
expect(screen.queryByTestId('teacher-reply')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '尚未确认的建议' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '带回主会话草稿' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeEnabled();
|
||||
const details = screen.getByText('查看收到的原始内容').closest('details');
|
||||
expect(details).not.toHaveAttribute('open');
|
||||
expect(details?.querySelector('pre')?.textContent).toBe(raw);
|
||||
expect(api.create).not.toHaveBeenCalled();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
expect(bringBack).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps complete Markdown visible when only auxiliary fields could not be recovered', async () => {
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '**它早就不是"刚搭好架子"那种阶段了。** 后面的回答也已经收到。',
|
||||
replyParseError: 'invalid quickReplies', unparsedResponse: '{"reply":"完整回答","quickReplies":',
|
||||
})] });
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getByTestId('teacher-reply')).toHaveTextContent('它早就不是"刚搭好架子"那种阶段了。 后面的回答也已经收到。');
|
||||
expect(screen.getByText('回答正文已保留,部分附加内容未能显示。')).toBeVisible();
|
||||
expect(screen.queryByText('这次回复未能完整显示')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '重新回答' })).not.toBeInTheDocument();
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['question', 'suggestions', 'guided-help'] as const)('retries an incomplete %s only on click with its original input while preserving the current draft', async intent => {
|
||||
const originalReferences = [{ messageId: 'original-message', text: '原来的引用', role: 'assistant' as const }];
|
||||
const currentQuote = { messageId: 'current-message', text: '草稿里的新引用', role: 'assistant' as const };
|
||||
const incomplete = request({
|
||||
intent, text: '现在项目是什么阶段?', sourceConversationId: 'original-source', references: originalReferences,
|
||||
response: '它早就不是', replyIncomplete: true,
|
||||
});
|
||||
const before = { ...first, requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
let finish!: (topic: TeacherTopic) => void;
|
||||
api.send.mockImplementation(() => new Promise<TeacherTopic>(resolve => { finish = resolve; }));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="different-source" quote={currentQuote} />);
|
||||
await ready();
|
||||
const input = screen.getByLabelText('向智能体提问');
|
||||
fireEvent.change(input, { target: { value: '还没说完的新想法' } });
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
const retry = screen.getByRole('button', { name: '重新回答' });
|
||||
fireEvent.click(retry);
|
||||
fireEvent.click(retry);
|
||||
expect(api.send).toHaveBeenCalledTimes(1);
|
||||
const sent = api.send.mock.calls[0][2] as TeacherSend;
|
||||
expect(sent).toEqual({ requestId: expect.any(String), text: incomplete.text, intent, references: originalReferences, sourceConversationId: 'original-source' });
|
||||
expect(sent.requestId).not.toBe(incomplete.id);
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: '等待时又补充了想法' } });
|
||||
await act(async () => finish({ ...before, revision: 2, requests: [...before.requests, request({ id: sent.requestId, text: sent.text, response: '这次的完整回答。' })] }));
|
||||
expect(screen.getByText('这次的完整回答。')).toBeVisible();
|
||||
expect(input).toHaveValue('等待时又补充了想法');
|
||||
expect(screen.getByText('草稿里的新引用')).toBeVisible();
|
||||
expect(JSON.parse(localStorage.getItem('makelore-consultation-draft:signed-out:p:teacher')!)).toMatchObject({ text: '等待时又补充了想法', references: [currentQuote] });
|
||||
});
|
||||
|
||||
it('keeps the retry id and draft after an uncertain send failure and uses the original topic source for older replies', async () => {
|
||||
const incomplete = request({ text: '原来的问题', response: '', replyIncomplete: true });
|
||||
const before = { ...first, sourceConversationId: 'older-source', requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
api.send.mockRejectedValueOnce(new Error('暂时没连上')).mockImplementation(async (_base, _id, sent: TeacherSend) => ({
|
||||
...before, revision: 2, requests: [incomplete, request({ id: sent.requestId, text: sent.text, response: '完整回答' })],
|
||||
}));
|
||||
render(<TeacherChatPanel projectId="p" sourceId="new-source" />);
|
||||
await ready();
|
||||
fireEvent.change(screen.getByLabelText('向智能体提问'), { target: { value: '草稿不能丢' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await screen.findByText('完整回答');
|
||||
expect(api.send).toHaveBeenCalledTimes(2);
|
||||
expect(api.send.mock.calls[0][2]).toEqual(api.send.mock.calls[1][2]);
|
||||
expect(api.send.mock.calls[1][2]).toMatchObject({ text: '原来的问题', sourceConversationId: 'older-source', references: [] });
|
||||
expect(screen.getByLabelText('向智能体提问')).toHaveValue('草稿不能丢');
|
||||
});
|
||||
|
||||
it('retries a project-only historical question without attaching another conversation or the project sentinel', async () => {
|
||||
const incomplete = request({ text: '原来的问题', replyIncomplete: true });
|
||||
const before = { ...first, sourceConversationId: 'project', requests: [incomplete] };
|
||||
api.read.mockResolvedValue(before);
|
||||
api.send.mockResolvedValue(before);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="different-source" />);
|
||||
await ready();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
await waitFor(() => expect(api.send).toHaveBeenCalledTimes(1));
|
||||
expect(api.send.mock.calls[0][2]).not.toHaveProperty('sourceConversationId');
|
||||
});
|
||||
|
||||
it('does not retry an incomplete reply from a disabled Agent or turn a check-in into a normal question', async () => {
|
||||
const archived = { ...first, definition: { ...definition, config_id: 'unavailable-agent' }, requests: [
|
||||
request({ response: '', replyIncomplete: true }),
|
||||
request({ id: 'check-in', intent: 'check-in', text: '', response: '', replyIncomplete: true }),
|
||||
] };
|
||||
api.read.mockResolvedValue(archived);
|
||||
render(<TeacherChatPanel projectId="p" sourceId="c" />);
|
||||
await ready();
|
||||
expect(screen.getAllByText('这次回复未能完整显示')).toHaveLength(2);
|
||||
expect(screen.getAllByRole('button', { name: '重新回答' })).toHaveLength(1);
|
||||
expect(screen.getByRole('button', { name: '重新回答' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '重新回答' }));
|
||||
expect(api.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders formulas and preserves code escapes while rejecting executable links and HTML', async () => {
|
||||
api.read.mockResolvedValue({ ...first, requests: [request({
|
||||
response: '$E=mc^2$\n\n```js\nconst value = "\\n";\n```\n\n[危险](javascript:alert)\n\n<script>alert(1)</script>\n\n\n\n',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { TeacherTopicStore } from '../../electron/coding-teacher/store';
|
||||
import { applyTeacherReply } from '../../electron/coding-teacher/reply';
|
||||
import { compileTeacherContext, teacherHistoryMessages } from '../../electron/coding-teacher/context';
|
||||
import { createTeacherReadTools } from '../../electron/coding-teacher/read-tools';
|
||||
import type { TeacherDefinition, TeacherRequest, TeacherSourceContext, TeacherTopic } from '../../shared/coding-teacher';
|
||||
@@ -206,3 +207,162 @@ describe('passive teacher reply history compatibility', () => {
|
||||
await f.unchanged();
|
||||
});
|
||||
});
|
||||
|
||||
describe('local repair of retained teacher replies', () => {
|
||||
const completeReply = '它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在更需要打磨细节。';
|
||||
const quoteRaw = '{"reply":"' + completeReply
|
||||
+ '","quickReplies":["看看接下来做什么"],"tool":{"kind":"structure","nodes":[{"id":"core","label":"完整结构"}]}}';
|
||||
|
||||
it('repairs the screenshot-style quoted prose in a cached read without rewriting or replaying history', async () => {
|
||||
const original = topic(archivedCases[4]);
|
||||
original.requests[0] = request({ ...original.requests[0], response: '它早就不是',
|
||||
unparsedResponse: quoteRaw, replyParseError: '旧解析错误', replyIncomplete: true,
|
||||
usage: { inputTokens: 20, outputTokens: 40, cacheReadTokens: 5, cacheWriteTokens: 3 } });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
const repaired = loaded.requests[0];
|
||||
expect(repaired).toEqual({ ...original.requests[0], response: completeReply,
|
||||
suggestedQuestions: ['看看接下来做什么'], discussionError: undefined,
|
||||
replyParseError: undefined, replyIncomplete: undefined });
|
||||
expect(loaded).toEqual({ ...original, requests: [repaired] });
|
||||
expect(repaired.unparsedResponse).toBe(quoteRaw);
|
||||
expect(repaired.status).toBe('completed');
|
||||
expect(repaired.discussionContext).toEqual(original.requests[0].discussionContext);
|
||||
expect(repaired.discussionSnapshot).toEqual(original.requests[0].discussionSnapshot);
|
||||
expect(await f.store.read(original.id)).toBe(loaded);
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded);
|
||||
await f.unchanged();
|
||||
|
||||
// A later ordinary save can retain the repaired projection, with the exact
|
||||
// original response and all opaque data still available for diagnosis.
|
||||
await f.store.save(loaded);
|
||||
const persisted = JSON.parse(await readFile(f.file, 'utf8'));
|
||||
expect(persisted.requests[0].unparsedResponse).toBe(quoteRaw);
|
||||
expect(persisted.requests[0].response).toBe(completeReply);
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(persisted);
|
||||
});
|
||||
|
||||
it('keeps complete prose when auxiliary suggestions or legacy payloads remain invalid', async () => {
|
||||
const original = topic();
|
||||
const raw = '{"reply":"这是一段完整回答。","quickReplies":[42],"tool":';
|
||||
original.requests[0] = request({ response: '这是一段完整回答。', unparsedResponse: raw,
|
||||
replyParseError: '旧解析错误', replyIncomplete: true });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toMatchObject({ status: 'completed', response: '这是一段完整回答。',
|
||||
suggestedQuestions: [], unparsedResponse: raw, replyParseError: expect.any(String) });
|
||||
expect(loaded.requests[0].replyIncomplete).toBeUndefined();
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('repairs display after saving restart recovery without rewriting a completed reply on disk', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '它早就不是', unparsedResponse: quoteRaw });
|
||||
original.requests.push(request({ id: 'running-turn', status: 'running', response: '' }));
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0].response).toBe(completeReply);
|
||||
expect(loaded.requests[1].status).toBe('interrupted');
|
||||
const persisted = JSON.parse(await readFile(f.file, 'utf8'));
|
||||
expect(persisted.requests[0]).toEqual(original.requests[0]);
|
||||
expect(persisted.requests[1].status).toBe('interrupted');
|
||||
expect(persisted.revision).toBe(original.revision + 1);
|
||||
expect((await new TeacherTopicStore(f.root).read(original.id)).requests[0].response).toBe(completeReply);
|
||||
});
|
||||
|
||||
it('keeps the question but excludes incomplete prose and suggestions from model context and read tools', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '它早就不是',
|
||||
unparsedResponse: '{"reply":"它早就不是"刚搭好架子', suggestedQuestions: ['尚未确认的建议'] });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0].replyIncomplete).toBe(true);
|
||||
expect(teacherHistoryMessages(loaded.requests)).toEqual([
|
||||
{ id: 'teacher:completed-turn:user', role: 'user', text: original.requests[0].text },
|
||||
]);
|
||||
const context = JSON.stringify(compileTeacherContext(definition, source, loaded.requests, '重新看看', []).messages);
|
||||
expect(context).toContain(original.requests[0].text);
|
||||
expect(context).not.toMatch(/它早就不是|尚未确认的建议/);
|
||||
const tools = createTeacherReadTools({ projectPath: f.root, source, history: loaded.requests, assertCurrent: vi.fn() });
|
||||
const signal = new AbortController().signal;
|
||||
const index = await tools.execute('read_conversation', '{}', signal);
|
||||
expect(index).toContain('teacher:completed-turn:user');
|
||||
expect(index).not.toContain('teacher:completed-turn:assistant');
|
||||
const assistant = await tools.executeResult('read_conversation', JSON.stringify({ message_id: 'teacher:completed-turn:assistant' }), signal);
|
||||
expect(assistant.status).toBe('error');
|
||||
expect(assistant.content).not.toContain('它早就不是');
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('marks truly truncated prose incomplete without replacing the saved text or raw evidence', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '它早就不是',
|
||||
unparsedResponse: '{"reply":"它早就不是"刚搭好架子', replyParseError: '旧解析错误' });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toEqual({ ...original.requests[0], replyIncomplete: true });
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toEqual(loaded);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it.each(['failed', 'cancelled', 'interrupted'] as const)('does not salvage a %s request as success', async status => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ status, response: '它早就不是', unparsedResponse: quoteRaw,
|
||||
error: '该请求未完成', replyParseError: '旧解析错误' });
|
||||
const f = await fixture(original);
|
||||
expect(await f.store.read(original.id)).toEqual(original);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it.each(['preparing', 'running'] as const)('keeps restart recovery for %s without accepting its received bytes', async status => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ status, response: '它早就不是', unparsedResponse: quoteRaw,
|
||||
replyParseError: '旧解析错误' });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toEqual({ ...original.requests[0], status: 'interrupted',
|
||||
error: '应用已重启,本次回复中断。' });
|
||||
expect(loaded.revision).toBe(original.revision + 1);
|
||||
expect(await new TeacherTopicStore(f.root).read(original.id)).toMatchObject({ requests: loaded.requests });
|
||||
});
|
||||
|
||||
it('does not replace useful prose with different content from a stale raw archive', async () => {
|
||||
const original = topic();
|
||||
original.requests[0] = request({ response: '已经保存的完整回答。', unparsedResponse: quoteRaw });
|
||||
const f = await fixture(original);
|
||||
expect(await f.store.read(original.id)).toEqual(original);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('also repairs the legacy intro/questions envelope while leaving its tool snapshot opaque', async () => {
|
||||
const original = topic();
|
||||
const raw = '{"intro":"它早就不是"刚搭好架子"了。","questions":["继续"],"tool":{"kind":"ideas"}}';
|
||||
original.requests[0] = request({ response: '它早就不是', unparsedResponse: raw });
|
||||
const f = await fixture(original);
|
||||
const loaded = await f.store.read(original.id);
|
||||
expect(loaded.requests[0]).toMatchObject({ response: '它早就不是"刚搭好架子"了。',
|
||||
suggestedQuestions: ['继续'], unparsedResponse: raw });
|
||||
expect(loaded.requests[0].discussionError).toBeUndefined();
|
||||
expect(loaded.requests[0].discussionSnapshot).toEqual(original.requests[0].discussionSnapshot);
|
||||
await f.unchanged();
|
||||
});
|
||||
|
||||
it('propagates incomplete results and retains diagnostics for recovered new replies', () => {
|
||||
const current = request({ replyIncomplete: true, replyParseError: '旧解析错误' });
|
||||
applyTeacherReply(current, '{"reply":"还没有说完');
|
||||
expect(current.replyIncomplete).toBe(true);
|
||||
expect(current.replyParseError).toBeTruthy();
|
||||
expect(current.unparsedResponse).toBe('{"reply":"还没有说完');
|
||||
|
||||
applyTeacherReply(current, quoteRaw);
|
||||
expect(current.response).toBe(completeReply);
|
||||
expect(current.replyIncomplete).toBeUndefined();
|
||||
expect(current.replyParseError).toBeUndefined();
|
||||
expect(current.discussionError).toBeUndefined();
|
||||
expect(current.unparsedResponse).toBe(quoteRaw);
|
||||
const recovered = structuredClone(current);
|
||||
applyTeacherReply(current, quoteRaw);
|
||||
expect(current).toEqual(recovered);
|
||||
});
|
||||
});
|
||||
@@ -122,11 +122,61 @@ describe('teacher text and suggestions transport', () => {
|
||||
|
||||
it('marks multiple envelopes or duplicate fields as ambiguous without showing legacy JSON', () => {
|
||||
const parsed = parseTeacherReply(JSON.stringify(envelope) + '\n{"reply":"第二份","tool":{"kind":"ideas"}}');
|
||||
expect(parsed).toMatchObject({ ...envelope, parseError: expect.any(String) });
|
||||
expect(parsed).toMatchObject({ incomplete: true, quickReplies: [], parseError: expect.any(String) });
|
||||
expect(parsed.reply).not.toContain('tool');
|
||||
expect(parseTeacherReply('{"reply":"第一份","reply":"第二份"}').parseError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('recovers the complete screenshot reply around internal unescaped quotes', () => {
|
||||
const reply = '它早就不是"刚搭好架子"那种阶段了——骨架、玩法、成长线都在跑。\n现在的阶段是「能玩,正在调手感」。';
|
||||
const raw = '{"reply":"' + reply + '","quickReplies":[],"tool":{"kind":"structure","nodes":[{"id":"core","label":"旧结构"}]}}';
|
||||
expect(parseTeacherReply(raw)).toEqual({ reply, quickReplies: [], recovered: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
'他说"再看一下",然后按 "继续"。',
|
||||
'第一行\n第二行\r\n制表符\t与 "引号" 一起保留。',
|
||||
'示例代码:\n```js\nconst text = "hello";\nif (ready) { show(text); }\n```\n然后继续说明。',
|
||||
])('repairs only prose escaping without rewriting its content: %s', reply => {
|
||||
expect(parseTeacherReply('{"reply":"' + reply + '","quickReplies":["继续"]}'))
|
||||
.toEqual({ reply, quickReplies: ['继续'], recovered: true });
|
||||
});
|
||||
|
||||
it('leaves correctly encoded quotes, backslashes, code and Unicode untouched', () => {
|
||||
const reply = '它早就不是"刚搭好架子"了。\\路径\\\n```json\n{"reply":"代码例子"}\n```\n🍎 继续。';
|
||||
expect(parseTeacherReply(JSON.stringify({ reply, quickReplies: [] })))
|
||||
.toEqual({ reply, quickReplies: [] });
|
||||
});
|
||||
|
||||
it('does not label a quoted prefix or a genuinely interrupted string as complete', () => {
|
||||
for (const raw of [
|
||||
'{"reply":"它早就不是"刚搭好架子',
|
||||
'{"reply":"它早就不是"刚搭好架子"那种阶段了',
|
||||
'{"reply":"它早就不是"',
|
||||
'{"reply":"半句", ordinary words are still part of broken prose',
|
||||
'{"reply":"引号 \\q 无效","quickReplies":[]}',
|
||||
]) {
|
||||
expect(parseTeacherReply(raw)).toMatchObject({ incomplete: true, quickReplies: [], parseError: expect.any(String) });
|
||||
expect(parseTeacherReply(raw).reply).not.toMatch(/它早就不是|半句|ordinary words|无效/);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps complete prose when suggestions fail without swallowing their bytes', () => {
|
||||
for (const tail of ['["断在按钮里', 'null}', '[13]}', '["坏" }']) {
|
||||
const parsed = parseTeacherReply('{"reply":"完整回答,包含 \\"正确引号\\"。","quickReplies":' + tail);
|
||||
expect(parsed).toMatchObject({ reply: '完整回答,包含 "正确引号"。', quickReplies: [], parseError: expect.any(String) });
|
||||
expect(parsed.incomplete).toBeUndefined();
|
||||
expect(parsed.reply).not.toContain('quickReplies');
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to guess across ambiguous prose/auxiliary boundaries or duplicate bodies', () => {
|
||||
const raw = '{"reply":"代码里有 "text", "tool": {"reply":"不能冒充正文"} 后面还有正文。","quickReplies":[]}';
|
||||
expect(parseTeacherReply(raw)).toMatchObject({ incomplete: true });
|
||||
expect(parseTeacherReply('{"reply":"第一句","reply":"第二句"}')).toMatchObject({ incomplete: true });
|
||||
expect(parseTeacherReply(raw).reply).not.toContain('不能冒充正文');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'{"reply":"正文保留。"},"tool":{"kind":"ideas","items":[]}',
|
||||
'{"reply":"正文保留。"}}',
|
||||
|
||||
Reference in new issue
Block a user