这次回复未能完整显示
{request.intent !== 'check-in' && request.text.trim() &&
}
@@ -537,13 +539,12 @@ export function TeacherChatPanel({
?
我看看你最近做到了哪里…
: request.status === 'completed' && <>
-
- {request.suggestedQuestions?.map((question, index) => )}
-
>}
{['failed', 'cancelled', 'interrupted'].includes(request.status) &&
}
> : request.response ?
:
{['running', 'preparing'].includes(request.status) ? {label}正在想… : ['cancelled', 'interrupted'].includes(request.status) ? '这次先停在这里。想继续时可以再说说。' : ''}
}
- {request.intent !== 'suggestions' && request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
+
+
+ {request.status === 'completed' && !request.replyIncomplete && Boolean(request.suggestedQuestions?.length) &&
{request.suggestedQuestions?.map((question, index) => )}
}
{request.status === 'completed' && !request.replyIncomplete && request.replyParseError &&
回答正文已保留,部分附加内容未能显示。
}
diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts
index 706f2955..5e6d565e 100644
--- a/src/stores/coding-conversations.ts
+++ b/src/stores/coding-conversations.ts
@@ -227,6 +227,7 @@ function optimisticNode(
id: nodeId,
clientRequestId,
role: 'user',
+ timestamp: Date.now(),
status: 'optimistic',
blocks: [
...(draft.text ? [{
diff --git a/tests/e2e/fixtures/message-timestamps.tsx b/tests/e2e/fixtures/message-timestamps.tsx
new file mode 100644
index 00000000..a841c268
--- /dev/null
+++ b/tests/e2e/fixtures/message-timestamps.tsx
@@ -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(
+
= 768 ? 'minmax(0, 1fr) 420px' : 'minmax(0, 1fr)' }}>
+
操作对话
+
+
,
+);
diff --git a/tests/e2e/message-timestamps.spec.ts b/tests/e2e/message-timestamps.spec.ts
new file mode 100644
index 00000000..946c45c7
--- /dev/null
+++ b/tests/e2e/message-timestamps.spec.ts
@@ -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('
');
+ });
+ },
+ }],
+ });
+ 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);
+ });
+}
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts
index 75f468bb..99e14a72 100644
--- a/tests/e2e/pi-coding-first-chat.spec.ts
+++ b/tests/e2e/pi-coding-first-chat.spec.ts
@@ -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
[]).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);}
});
diff --git a/tests/unit/coding-conversation-timeline.test.tsx b/tests/unit/coding-conversation-timeline.test.tsx
index cc228f38..14d794ae 100644
--- a/tests/unit/coding-conversation-timeline.test.tsx
+++ b/tests/unit/coding-conversation-timeline.test.tsx
@@ -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();
+ 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');
diff --git a/tests/unit/coding-teacher.test.ts b/tests/unit/coding-teacher.test.ts
index bb290b47..34df5a95 100644
--- a/tests/unit/coding-teacher.test.ts
+++ b/tests/unit/coding-teacher.test.ts
@@ -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);
diff --git a/tests/unit/message-timestamp.test.tsx b/tests/unit/message-timestamp.test.tsx
new file mode 100644
index 00000000..9c52da4d
--- /dev/null
+++ b/tests/unit/message-timestamp.test.tsx
@@ -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();
+ expect(screen.getByText('回复中…')).toHaveAttribute('data-message-time', 'pending');
+ expect(view.container.querySelector('time')).toBeNull();
+ view.rerender();
+ expect(view.container.querySelector('time')).toHaveAttribute('datetime', now.toISOString());
+ expect(view.container.querySelector('time')).toHaveAttribute('title', '2026年9月29日 17:08:09');
+ view.rerender();
+ expect(screen.getByText('时间未记录')).toBeVisible();
+ expect(view.container.querySelector('time')).toBeNull();
+ });
+});
diff --git a/tests/unit/pi-event-projector.test.ts b/tests/unit/pi-event-projector.test.ts
index 4cbd9fa9..036457fa 100644
--- a/tests/unit/pi-event-projector.test.ts
+++ b/tests/unit/pi-event-projector.test.ts
@@ -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: [
diff --git a/tests/unit/pi-session-projector.test.ts b/tests/unit/pi-session-projector.test.ts
index d93020b9..4c30dddf 100644
--- a/tests/unit/pi-session-projector.test.ts
+++ b/tests/unit/pi-session-projector.test.ts
@@ -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,
diff --git a/tests/unit/teacher-conversation-panel.test.tsx b/tests/unit/teacher-conversation-panel.test.tsx
index 272cbf65..88093f22 100644
--- a/tests/unit/teacher-conversation-panel.test.tsx
+++ b/tests/unit/teacher-conversation-panel.test.tsx
@@ -41,6 +41,36 @@ const props = { projectId: 'project-a', projectName: '天气', sourceId: 'source
async function mount() { let view!: ReturnType; await act(async () => { view = render(); }); 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: '原项目代码' }] };
diff --git a/tests/unit/teacher-conversation.test.ts b/tests/unit/teacher-conversation.test.ts
index 82ed96b5..6d346a91 100644
--- a/tests/unit/teacher-conversation.test.ts
+++ b/tests/unit/teacher-conversation.test.ts
@@ -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');