Show persistent timestamps for operation and teacher messages
This commit is contained in:
27
tests/e2e/fixtures/message-timestamps.tsx
Normal file
27
tests/e2e/fixtures/message-timestamps.tsx
Normal 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>,
|
||||
);
|
||||
79
tests/e2e/message-timestamps.spec.ts
Normal file
79
tests/e2e/message-timestamps.spec.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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);}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
30
tests/unit/message-timestamp.test.tsx
Normal file
30
tests/unit/message-timestamp.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '原项目代码' }] };
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user