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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user