Show persistent timestamps for operation and teacher messages

This commit is contained in:
鲨鱼辣椒
2026-09-29 18:02:54 +08:00
parent 3a175101c9
commit ead3407890
24 changed files with 355 additions and 9 deletions

View File

@@ -0,0 +1,27 @@
import { createRoot } from 'react-dom/client';
import { CodingConversationTimeline } from '../../../src/pages/Chat/CodingConversationTimeline';
import { TeacherChatPanel } from '../../../src/pages/Chat/TeacherChatPanel';
import { codingConversationStore } from '../../../src/stores/coding-conversations';
import { createProductSnapshot } from '../../fixtures/coding-conversation-product-fixtures';
import type { TeacherDefinition, TeacherTopic } from '../../../shared/coding-teacher';
import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.css';
const sent = '2026-09-28T08:01:02.000Z', finished = '2026-09-28T08:01:42.000Z';
const snapshot = createProductSnapshot('timestamp-fixture', 1);
snapshot.run = { status: 'idle' };
snapshot.nodes = [
{ kind: 'message', id: 'u', role: 'user', status: 'complete', timestamp: Date.parse(sent), blocks: [{ kind: 'text', id: 'ut', status: 'complete', text: '我想做一个会长大的小宠物。' }] },
{ kind: 'message', id: 'a', role: 'assistant', status: 'complete', timestamp: Date.parse(finished), blocks: [{ kind: 'text', id: 'at', status: 'complete', text: '小宠物的家搭好啦。你可以试着点一下喂食按钮,看看它的变化。' }] },
];
codingConversationStore.getState().primeConversation(snapshot);
const definition: TeacherDefinition = { schema_version: 1, config_id: 'teacher-fixture', teacher_id: 'teacher-fixture', name: '设计老师', description: '', avatar_id: 'avatar-06', welcome_message: '', suggested_questions: [], system_prompt: '', skills: [], model: { model_id: 'fixture', reasoning_choice: { mode: 'default' } }, limits: { max_input_tokens: 8000, max_output_tokens: 1000 } };
const topic: TeacherTopic = { schemaVersion: 1, revision: 1, id: 'teacher-fixture', accountId: 'fixture', projectId: 'fixture', sourceConversationId: 'timestamp-fixture', definition, version: 1, conversation: { agentId: 'teacher-fixture', segmentTurns: 1, discussions: {} }, createdAt: sent, updatedAt: finished, requests: [{ id: 'r', createdAt: sent, finishedAt: finished, text: '我想让它长大以后变得很特别。', response: '先想一个你最希望它学会的小本领吧。你希望它会帮你做什么?', suggestedQuestions: ['我想让它陪我讲故事。', '我想让它记住我的习惯。'], references: [], sourceCursor: { workerGeneration: 1, seq: 1 }, sourceCapturedAt: sent, includedSourceMessageIds: [], omittedMessages: 0, status: 'completed' }] };
(window as typeof window & { __timestampTopic: TeacherTopic }).__timestampTopic = topic;
const agent = { teacher_id: definition.config_id!, version: 1, is_default: true, definition };
createRoot(document.getElementById('root')!).render(
<div className="classroom-workspace min-h-screen bg-white" style={{ display: 'grid', gridTemplateColumns: innerWidth >= 768 ? 'minmax(0, 1fr) 420px' : 'minmax(0, 1fr)' }}>
<main className="min-w-0 p-6" aria-label="操作对话"><h1 className="mb-8 text-lg">操作对话</h1><CodingConversationTimeline conversationId="timestamp-fixture" /></main>
<div className="consultation-pane h-[850px] border-l"><TeacherChatPanel projectId="fixture" sourceId="timestamp-fixture" selectedAgent={agent} catalogState={{ config: { enabled: true, definition }, catalog: { items: [agent] } }} /></div>
</div>,
);

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { createServer, type ViteDevServer } from 'vite';
import { resolve } from 'node:path';
let server: ViteDevServer;
let baseUrl: string;
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
test.beforeAll(async () => {
server = await createServer({
configFile: false, cacheDir: 'node_modules/.vite-message-timestamps',
resolve: { alias: { '@': resolve('src') } }, esbuild: { jsx: 'automatic' },
server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'isolated-message-timestamps', enforce: 'pre',
load(id) {
if (id.endsWith('/src/lib/coding-teacher.ts')) return `
export function teacherTopicsPath() { return '/fixture'; }
export function legacyTopicBase(base) { return base; }
export const teacherApi = {
conversation: async () => ({ topic: window.__timestampTopic, before: null }),
seenConversation: async () => window.__timestampTopic,
events: async () => ({ close() {}, addEventListener() {} }),
};
`;
if (id.endsWith('/src/lib/host-events.ts')) return 'export function subscribeHostEvent() { return () => {}; }';
if (id.endsWith('/src/lib/host-api.ts')) return `
export async function createHostEventSource() { return { close() {}, addEventListener() {} }; }
export async function ensureHostApiToken() { return 'fixture'; }
export function getHostApiBase() { return ''; }
export function reportDesktopActivity() {}
export function setDesktopBackgroundLease() {}
export async function hostApiFetch() { return {}; }
export async function hostApiFetchBytes() { return new Uint8Array(); }
`;
},
configureServer(vite) {
vite.middlewares.use((req, res, next) => {
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
res.setHeader('Content-Type', 'text/html');
res.end('<html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/tests/e2e/fixtures/message-timestamps.tsx"></script></body></html>');
});
},
}],
});
await server.listen();
baseUrl = server.resolvedUrls!.local[0];
});
test.afterAll(async () => { await server?.close(); });
test.beforeEach(async ({ page }) => {
page.on('pageerror', error => { throw error; });
await page.addInitScript(() => {
const scope = window as typeof window & { __communityCalls: unknown[][] };
scope.__communityCalls = [];
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
invoke: async (...args: unknown[]) => { scope.__communityCalls.push(args); return undefined; },
} } });
});
});
for (const width of [375, 1280]) {
test(`both chat surfaces show stable local times at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
await page.goto(baseUrl);
await expect(page.locator('time')).toHaveCount(4);
const times = await page.locator('time').evaluateAll(elements => elements.map(element => ({ time: element.getAttribute('datetime'), title: element.getAttribute('title') })));
expect(times.map(item => item.time)).toEqual(['2026-09-28T08:01:02.000Z', '2026-09-28T08:01:42.000Z', '2026-09-28T08:01:02.000Z', '2026-09-28T08:01:42.000Z']);
for (const item of times) expect(item.title).toMatch(/^2026年9月28日 \d{2}:\d{2}:(02|42)$/);
for (const time of await page.locator('time').all()) {
await expect(time).toHaveCSS('font-size', '11px');
expect(await time.evaluate(el => el.scrollWidth <= el.clientWidth)).toBe(true);
}
await expect(page.getByRole('button', { name: '我想让它陪我讲故事。' })).toBeEnabled();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await page.screenshot({ path: test.info().outputPath(`message-timestamps-${width}.png`), fullPage: true });
await page.reload();
await expect(page.locator('time')).toHaveCount(4);
expect(await page.locator('time').evaluateAll(elements => elements.map(element => ({ time: element.getAttribute('datetime'), title: element.getAttribute('title') })))).toEqual(times);
});
}

View File

@@ -226,6 +226,7 @@ async function installCodingFirstChatHost(
kind: 'message',
id: 'message-user-e2e',
sourceEntryId: 'entry-user-e2e',
timestamp: Date.parse(now),
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-user-e2e:content:0', text: 'Durable user fork source', status: 'complete' }],
@@ -582,7 +583,7 @@ async function installCodingFirstChatHost(
if (action === 'messages' && method === 'POST') {
const suggestions = body!.intent === 'suggestions';
const response = suggestions ? '我们可以从你最近试过的地方聊起。' : body!.intent === 'guided-help' ? '你最近做的哪一步,让你停下来想了一会儿?' : '先理解状态如何随点击变化,再修改代码。';
consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:new Date().toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
consultationTopics[agentId] = {...current,revision:Number(current.revision)+1,requests:[...(current.requests as unknown[]),{id:body!.requestId,projectId:body!.projectId,sourceConversationId:body!.sourceConversationId,text:body!.text,intent:body!.intent,references:body!.references??[],createdAt:now,finishedAt:new Date(Date.parse(now)+40000).toISOString(),sourceCursor:{workerGeneration:1,seq:1},sourceCapturedAt:now,includedSourceMessageIds:[],omittedMessages:0,truncatedMessages:1,status:'completed',response,...(suggestions?{suggestedQuestions:['怎样观察别人玩游戏?','我该先试哪个想法?']}:{})}]};
const last = (consultationTopics[agentId].requests as Record<string, unknown>[]).at(-1)!;
Object.assign(last, consultationReply);
if (state.nextTeacherReplyIncomplete) {
@@ -1914,6 +1915,11 @@ test('project consultations preserve student drafts and switch between work and
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('');
await teacher.locator('form .consultation-hint').click();
await expect(teacher.getByText('我们可以从你最近试过的地方聊起。')).toBeVisible();
const sentTime = teacher.locator('time').nth(0), replyTime = teacher.locator('time').nth(1);
await expect(sentTime).toHaveAttribute('aria-label', /^发送时间:/);
await expect(replyTime).toHaveAttribute('aria-label', /^回复完成时间:/);
const originalReplyTime = await replyTime.getAttribute('datetime');
expect(Date.parse(originalReplyTime!) - Date.parse((await sentTime.getAttribute('datetime'))!)).toBe(40000);
await expect(teacher.getByTestId('teacher-discussion')).toHaveCount(0);
await teacher.getByRole('textbox',{name:'向智能体提问'}).fill('还没说完的困惑');
await expect(teacher.locator('form .consultation-hint')).toHaveCount(0);
@@ -1965,6 +1971,7 @@ test('project consultations preserve student drafts and switch between work and
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();await expect(teacher).toHaveCount(0);
await page.getByRole('button',{name:'与朋友聊天',exact:true}).click();
await expect(page.getByText('先理解状态如何随点击变化,再修改代码。',{exact:true})).toBeVisible();
await expect(teacher.locator('time').nth(1)).toHaveAttribute('datetime', originalReplyTime!);
await expect(teacher.getByRole('textbox',{name:'向智能体提问'})).toHaveValue('还没说完的困惑');
} finally {await releaseSnapshot(electronApp);}
});

View File

@@ -11,6 +11,28 @@ vi.mock('@/lib/coding-attachments', () => ({
}));
describe('CodingConversationTimeline', () => {
it('shows stable timestamps beneath both sides without changing message content', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import('@/pages/Chat/CodingConversationTimeline');
const snapshot = createProductSnapshot('message-times', 1);
snapshot.run = { status: 'idle' };
const sent = Date.parse('2026-09-28T08:01:02Z'), finished = sent + 40000;
snapshot.nodes = [
{ kind: 'message', id: 'u', role: 'user', status: 'complete', timestamp: sent, blocks: [{ kind: 'text', id: 'ut', status: 'complete', text: '时间问题' }] },
{ kind: 'message', id: 'a', role: 'assistant', status: 'complete', timestamp: finished, blocks: [{ kind: 'text', id: 'at', status: 'complete', text: '时间回答' }] },
];
codingConversationStore.getState().primeConversation(snapshot);
const view = render(<CodingConversationTimeline conversationId="message-times" />);
const userTime = view.container.querySelector('[data-node-id="u"] time');
const agentTime = view.container.querySelector('[data-node-id="a"] time');
expect(userTime).toHaveAttribute('datetime', new Date(sent).toISOString());
expect(userTime).toHaveClass('text-right');
expect(agentTime).toHaveAttribute('datetime', new Date(finished).toISOString());
act(() => codingConversationStore.getState().primeConversation(structuredClone(snapshot)));
expect(view.container.querySelector('[data-node-id="a"] time')).toHaveAttribute('datetime', new Date(finished).toISOString());
expect(screen.getByText('时间回答')).toBeVisible();
});
it('gives the failed hidden work action one login recovery and removes it when work is ready', async () => {
const { codingConversationStore } = await import('@/stores/coding-conversations');
const { CodingConversationTimeline } = await import('@/pages/Chat/CodingConversationTimeline');

View File

@@ -164,6 +164,35 @@ async function fixture({ durableSource = false, sourceContext = context, liveMod
};
}
describe('cloud coding teacher', () => {
it.each([false, true])('persists terminal times for local/cloud=%s replies across restart', async cloudTeacher => {
const f = await fixture({ cloudTeacher, mockCloud: cloudTeacher });
const topic = await f.service.create(f.scope, undefined, undefined, cloudTeacher ? 9 : undefined);
const id = '22222222-2222-4222-8222-222222222222';
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释一下' });
const running = (await f.service.read(f.scope, topic.id)).requests[0];
expect(running.finishedAt).toBeUndefined();
const endingAfter = Date.now();
f.finish();
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0].finishedAt).toBeTruthy());
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
expect(saved.createdAt).toBe(running.createdAt);
expect(Date.parse(saved.finishedAt!)).toBeGreaterThanOrEqual(endingAfter);
expect(Date.parse(saved.finishedAt!)).toBeLessThanOrEqual(Date.now());
const restarted = await f.restart();
expect((await restarted.read(f.scope, topic.id)).requests[0]).toEqual(saved);
});
it.each(['cancelled', 'failed'] as const)('also saves the actual terminal time for %s replies', async status => {
const f = await fixture();
if (status === 'failed') f.run.mockRejectedValueOnce(new Error('failure'));
const topic = await f.service.create(f.scope);
const id = '22222222-2222-4222-8222-222222222222';
await f.service.send(f.scope, topic.id, { requestId: id, text: '解释一下' });
if (status === 'cancelled') await f.service.cancel(f.scope, topic.id, id);
await vi.waitFor(async () => expect((await f.service.read(f.scope, topic.id)).requests[0]).toMatchObject({ status, finishedAt: expect.any(String) }));
const saved = (await f.service.read(f.scope, topic.id)).requests[0];
expect((await (await f.restart()).read(f.scope, topic.id)).requests[0].finishedAt).toBe(saved.finishedAt);
});
it('does not change the selected discussion when the companion polls an older topic', async () => {
const f = await fixture();
const old = await f.service.create(f.scope);

View File

@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { MessageTimestamp } from '@/pages/Chat/MessageTimestamp';
import { formatMessageTimestamp } from '@/lib/message-timestamp';
import { messageTimestamp } from '../../shared/message-timestamp';
const now = new Date(2026, 8, 29, 17, 8, 9);
describe('message timestamps', () => {
it('uses local dates, distinguishes older years, and exposes full precision without a ticking clock', () => {
expect(formatMessageTimestamp(now.getTime(), now)).toEqual({ iso: now.toISOString(), short: '17:08', full: '2026年9月29日 17:08:09' });
expect(formatMessageTimestamp(new Date(2026, 8, 28, 23, 59).toISOString(), now)?.short).toBe('9月28日 23:59');
expect(formatMessageTimestamp(new Date(2025, 8, 29, 17, 8).getTime(), now)?.short).toBe('2025年9月29日 17:08');
expect(formatMessageTimestamp(new Date(2026, 8, 30, 0, 1).getTime(), now)?.short).toBe('9月30日 00:01');
});
it.each([undefined, null, '', 'now', '1', NaN, Infinity, -1, 1e20, '2026-99-99T00:00:00Z'])('does not invent a date for %s', value => {
expect(messageTimestamp(value)).toBeUndefined();
expect(formatMessageTimestamp(value, now)).toBeUndefined();
});
it('renders a semantic time with full hover text; pending and legacy replies do not get fabricated times', () => {
const view = render(<MessageTimestamp value={now.toISOString()} pending />);
expect(screen.getByText('回复中…')).toHaveAttribute('data-message-time', 'pending');
expect(view.container.querySelector('time')).toBeNull();
view.rerender(<MessageTimestamp value={now.toISOString()} label="发送时间" />);
expect(view.container.querySelector('time')).toHaveAttribute('datetime', now.toISOString());
expect(view.container.querySelector('time')).toHaveAttribute('title', '2026年9月29日 17:08:09');
view.rerender(<MessageTimestamp />);
expect(screen.getByText('时间未记录')).toBeVisible();
expect(view.container.querySelector('time')).toBeNull();
});
});

View File

@@ -58,6 +58,19 @@ function apply(
}
describe('Pi event projector', () => {
it.each(['stop', 'error', 'aborted'])('uses send time and terminal time for %s, never the assistant start time', async stopReason => {
let now = 2000;
const projector = new PiEventProjector({ createId: () => 'live-message', now: () => now });
let snapshot = emptySnapshot();
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'message_start', message: { role: 'user', timestamp: 1000, content: [{ type: 'text', text: '你好' }] } }));
expect(snapshot.nodes[0]).toMatchObject({ role: 'user', timestamp: 1000 });
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'message_start', message: { role: 'assistant', timestamp: 2000, content: [] } }));
expect(snapshot.nodes.at(-1)).not.toHaveProperty('timestamp');
now = 9000;
snapshot = apply(snapshot, await projector.project(snapshot, { type: 'message_end', message: { role: 'assistant', timestamp: 2000, stopReason, content: [{ type: 'text', text: '你好呀' }] } }));
expect(snapshot.nodes.at(-1)).toMatchObject({ role: 'assistant', timestamp: 9000 });
});
it('projects only subagent.v1 details and never exposes unknown raw details', async () => {
const projector = new PiEventProjector({ createId: () => 'unused' });
let snapshot = emptySnapshot();
@@ -157,6 +170,7 @@ describe('Pi event projector', () => {
it('keeps one assistant UI identity while content-index deltas become an authoritative message', async () => {
const projector = new PiEventProjector({
createId: () => 'assistant-ui-a',
now: () => 9000,
});
let snapshot = emptySnapshot();
const initialAssistant = {
@@ -217,6 +231,7 @@ describe('Pi event projector', () => {
kind: 'message',
id: 'assistant-ui-a',
role: 'assistant',
timestamp: 9000,
status: 'complete',
blocks: [{
kind: 'text',
@@ -624,6 +639,7 @@ describe('Pi event projector', () => {
kind: 'message',
id: 'user-ui-a',
clientRequestId: 'request-a',
timestamp: 20,
role: 'user',
status: 'complete',
blocks: [

View File

@@ -33,6 +33,25 @@ function baseSnapshot(): ConversationSnapshot {
const zeroCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
describe('Pi session projector', () => {
it('restores send and completion times from durable entries and leaves undated history unknown', async () => {
const sent = '2026-09-28T08:01:02.000Z', finished = '2026-09-28T08:01:42.000Z';
const input = { snapshot: baseSnapshot(), workerGeneration: 1,
state: { sessionId: 'times', isStreaming: false, isCompacting: false },
entries: { leafId: 'old', entries: [
{ type: 'message', id: 'u', parentId: null, timestamp: finished, message: { role: 'user', timestamp: Date.parse(sent), content: '你好' } },
{ type: 'message', id: 'a', parentId: 'u', timestamp: finished, message: { role: 'assistant', timestamp: Date.parse(sent), stopReason: 'stop', content: [{ type: 'text', text: '你好呀' }] } },
{ type: 'message', id: 'fallback', parentId: 'a', timestamp: sent, message: { role: 'user', timestamp: 'invalid', content: '较早问题' } },
{ type: 'message', id: 'old', parentId: 'fallback', timestamp: 'invalid', message: { role: 'assistant', timestamp: Date.parse(sent), stopReason: 'stop', content: [{ type: 'text', text: '无完成时间' }] } },
] } };
const snapshot = await projectPiSessionSnapshot(input);
expect(snapshot.nodes).toMatchObject([
{ role: 'user', timestamp: Date.parse(sent) }, { role: 'assistant', timestamp: Date.parse(finished) },
{ role: 'user', timestamp: Date.parse(sent) }, { role: 'assistant', timestamp: undefined },
]);
expect((await projectPiSessionSnapshot({ ...input, snapshot })).nodes).toEqual(snapshot.nodes);
expect(input.entries.entries[3].timestamp).toBe('invalid');
});
it('restores the observed expired Works Square session as a safe login recovery notice', async () => {
const snapshot = await projectPiSessionSnapshot({
snapshot: baseSnapshot(), workerGeneration: 1,

View File

@@ -41,6 +41,36 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source
async function mount() { let view!: ReturnType<typeof render>; await act(async () => { view = render(<TeacherChatPanel {...props} />); }); return view; }
describe('continuous agent panel', () => {
it.each([undefined, 'suggestions', 'check-in'] as const)('shows question/check-in and reply times for %s across teacher reloads', async intent => {
const current = { ...request('timestamped'), createdAt: '2026-09-28T08:01:02Z', finishedAt: '2026-09-28T08:01:42Z', intent,
suggestedQuestions: ['接着聊'], response: '这一轮的回答' };
const saved = topic([current, request('legacy')]);
api.conversation.mockResolvedValue({ topic: saved, before: null });
api.seenConversation.mockImplementation(async () => saved);
const view = await mount();
expect(view.container.querySelectorAll('time')).toHaveLength(2);
expect(view.container.querySelectorAll('time')[0]).toHaveAttribute('datetime', '2026-09-28T08:01:02.000Z');
expect(view.container.querySelectorAll('time')[1]).toHaveAttribute('datetime', '2026-09-28T08:01:42.000Z');
expect(screen.getAllByText('时间未记录')).toHaveLength(2);
expect(screen.getByRole('button', { name: '接着聊' })).toBeVisible();
view.unmount();
const reloaded = await mount();
expect(reloaded.container.querySelectorAll('time')[1]).toHaveAttribute('datetime', '2026-09-28T08:01:42.000Z');
expect(api.sendConversation).not.toHaveBeenCalled();
});
it('waits for the terminal reply timestamp while preserving the question time', async () => {
const current = { ...request('streaming'), status: 'running' as const, createdAt: '2026-09-28T08:01:02Z' };
const saved = topic([current]);
api.conversation.mockResolvedValue({ topic: saved, before: null });
api.seenConversation.mockImplementation(async () => saved);
const view = await mount();
expect(view.container.querySelectorAll('time')).toHaveLength(1);
expect(view.container.querySelector('[data-message-time="pending"]')).toBeVisible();
await act(async () => stream.dispatchEvent(new MessageEvent('snapshot', { data: JSON.stringify({ ...saved, revision: 2, requests: [{ ...current, status: 'completed', finishedAt: '2026-09-28T08:01:42Z' }] }) })));
expect(view.container.querySelectorAll('time')).toHaveLength(2);
expect(view.container.querySelector('[data-message-time="pending"]')).toBeNull();
});
it('retries incomplete prose only in its original project/source and keeps current drafts and one send identity', async () => {
const original = { ...request('broken', '现在项目是什么阶段?'), response: '它早就不是', replyIncomplete: true,
references: [{ kind: 'code' as const, text: '原项目代码' }] };

View File

@@ -37,6 +37,21 @@ function turn(index: number): TeacherRequest {
omittedMessages: 0, status: 'completed', response: '回答 ' + index };
}
describe('continuous conversation persistence', () => {
it('preserves individual completion times and reads older replies without backfilling or rewriting them', async () => {
const dir = await root();
const store = new TeacherConversationStore(dir, 'account', 'agent-a');
const topic = await store.ensure(definition, 1);
const old = turn(1), recent = { ...turn(2), finishedAt: '2026-09-29T08:01:42.000Z' };
topic.requests = [old, recent];
await store.save(topic);
const first = await new TeacherConversationStore(dir, 'account', 'agent-a').read();
expect(first.requests[0]).not.toHaveProperty('finishedAt');
expect(first.requests[1].finishedAt).toBe(recent.finishedAt);
const second = await new TeacherConversationStore(dir, 'account', 'agent-a').read();
expect(second.requests).toEqual(first.requests);
expect(second.requests[0].createdAt).toBe(old.createdAt);
});
it('repairs retained replies on every page without rewriting their files during restart recovery', async () => {
const dir = await root();
const store = new TeacherConversationStore(dir, 'account', 'agent-a');