feat(coding): add conversation archive and first-message titles

This commit is contained in:
2026-09-20 12:43:51 +08:00
parent d61226532a
commit fd0293fe3d
22 changed files with 698 additions and 69 deletions

View File

@@ -205,6 +205,60 @@ describe('CodingChatPanel first Conversation', () => {
vi.resetModules();
});
it('archives the last conversation without creating another, then restores the same running conversation', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
projectApi.patch.mockImplementation(async (_id, patch) => ({
...conversation, archivedAt: patch.archived ? '2026-09-20T00:00:00Z' : null,
}));
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue({ ...createLocalConversationSnapshot(project.id, conversation),
run: { status: 'running', runId: 'run-1' } });
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { codingConversationStore } = await import('@/stores/coding-conversations');
render(<CodingChatPanel />);
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
fireEvent.keyDown(menu, { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' }));
await screen.findByText('没有最近会话,可新建对话或查看归档');
expect(projectApi.create).not.toHaveBeenCalled();
expect(conversationApi.abort).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: '已归档1' }));
fireEvent.click(within(screen.getByTestId('coding-conversation-sidebar')).getByRole('button', { name: '新对话', exact: true }));
await screen.findByText('此对话已归档,任务仍在运行。');
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '恢复对话' }));
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false });
});
it('renames an unselected conversation from its menu without preparing that conversation', async () => {
const other = { ...conversation, id: 'unselected', title: '旧标题', updatedAt: '2025-01-01T00:00:00Z' };
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation, other]);
projectApi.patch.mockImplementation(async (_id, patch) => ({ ...other, ...patch }));
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
render(<CodingChatPanel />);
fireEvent.keyDown(await screen.findByRole('button', { name: '对话操作:旧标题' }), { key: 'Enter' });
fireEvent.click(await screen.findByRole('menuitem', { name: '重命名' }));
const input = screen.getByRole('textbox', { name: '对话标题' });
expect(input).toHaveValue('旧标题');
expect(input).toHaveAttribute('maxlength', '200');
fireEvent.change(input, { target: { value: ' 手动新标题 ' } });
fireEvent.submit(input.closest('form')!);
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(projectApi.patch).toHaveBeenCalledWith(other.id, { title: '手动新标题' });
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id);
});
it('keeps a project with no Agent accessible and offers optional Agent setup', async () => {
const openProjectSettings = vi.fn();
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { firstMessageTitle } from '../../electron/coding-runtime/conversation-title';
import type { ConversationMessageNode } from '../../shared/coding-conversation-contracts';
function message(text: string, overrides: Partial<ConversationMessageNode> = {}): ConversationMessageNode {
return { kind: 'message', id: 'user', role: 'user', status: 'complete',
blocks: [{ kind: 'text', id: 'text', status: 'complete', text }], ...overrides };
}
describe('first user message title', () => {
it('uses the first line, collapses whitespace, and bounds long text by characters', () => {
expect(firstMessageTitle(message(' 做一个 登录页\n支持短信登录'))).toBe('做一个 登录页');
expect(firstMessageTitle(message('🦊'.repeat(40)))).toBe('🦊'.repeat(32));
});
it('ignores optimistic, assistant, command and empty content', () => {
expect(firstMessageTitle(message('draft', { status: 'optimistic' }))).toBeNull();
expect(firstMessageTitle(message('reply', { role: 'assistant' }))).toBeNull();
expect(firstMessageTitle(message('/compact'))).toBeNull();
expect(firstMessageTitle(message(' '))).toBeNull();
});
it('names image-only conversations without inspecting the attachment', () => {
expect(firstMessageTitle(message('', { blocks: [{ kind: 'image', id: 'img', attachmentId: 'attachment', mime: 'image/png' }] }))).toBe('附件对话');
});
});

View File

@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { useStore } from 'zustand';
import { codingWorkspaceStore } from '@/stores/coding-workspace';
import { describe, expect, it, vi } from 'vitest';
import { AppError } from '@/lib/error-model';
import {
@@ -29,7 +30,7 @@ class FakeEventSource {
this.listeners.set(type, listeners);
}
emit(type: 'snapshot' | 'patch-batch', value: unknown): void {
emit(type: 'snapshot' | 'patch-batch' | 'conversation.metadata-changed', value: unknown): void {
const event = new MessageEvent(type, { data: JSON.stringify(value) });
for (const listener of this.listeners.get(type) ?? []) {
if (typeof listener === 'function') listener(event);
@@ -138,6 +139,27 @@ function ids() {
}
describe('coding Conversation store', () => {
it('refreshes only metadata on title events and EventSource reconnect without preparing workers', async () => {
const previous = codingWorkspaceStore.getState();
const refresh = vi.fn(async () => undefined);
codingWorkspaceStore.setState({ activeProjectId: 'project-metadata', refreshConversations: refresh });
const source = new FakeEventSource();
const getSnapshot = vi.fn();
const store = createCodingConversationStore({ openEvents: async () => source as unknown as EventSource, getSnapshot });
try {
await store.getState().connectEvents();
source.emit('conversation.metadata-changed', { projectId: 'project-metadata', conversationId: 'hidden' });
source.open();
source.fail();
source.open();
expect(refresh.mock.calls).toEqual([['project-metadata'], ['project-metadata'], ['project-metadata']]);
expect(getSnapshot).not.toHaveBeenCalled();
} finally {
store.getState().disconnectEvents();
codingWorkspaceStore.setState(previous, true);
}
});
it('queues observation sync only for a live completed ordinary prompt', () => {
const queueSettledSessionSync = vi.fn();
const store = createCodingConversationStore({

View File

@@ -100,6 +100,60 @@ async function createConversation(
}
describe('PI-100 coding core Host contract', () => {
it('names new conversations from the first real user event without a Renderer stream', async () => {
const result = await setup();
result.conversations.dispose();
let publish!: (event: ConversationPatchEnvelope) => void;
vi.spyOn(result.runtime, 'subscribe').mockImplementation((listener) => { publish = listener; return () => undefined; });
const service = new CodingConversationService(result.projects, result.runtime);
const conversation = await service.createConversation({ agentId: 'builder', title: '新对话' });
await service.getSnapshot(conversation.id);
const sendUser = (text: string, status: 'complete' | 'optimistic' = 'complete') => publish({
conversationId: conversation.id, workerGeneration: 1, seq: 1, at: 1,
patch: { op: 'message.upsert', node: { kind: 'message', id: text, role: 'user', status,
blocks: [{ kind: 'text', id: text, text, status: 'complete' }] } },
});
sendUser('未被接受的草稿', 'optimistic');
expect((await service.getConversation(conversation.id)).title).toBe('新对话');
sendUser('修复登录页\n这是第二行');
sendUser('后续消息');
await vi.waitFor(async () => expect((await service.getConversation(conversation.id)).title).toBe('修复登录页'));
await service.patchConversation(conversation.id, { title: '我的标题' });
sendUser('再次更新');
expect((await service.getConversation(conversation.id)).title).toBe('我的标题');
expect(await service.getConversation(conversation.id)).not.toHaveProperty('titleMode');
service.dispose();
});
it('archives without stopping work, emits metadata, and guards only new prompts and forks', async () => {
const result = await setup();
const conversation = await createConversation(result.conversations);
await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'first',
mode: 'prompt', text: 'Build' });
const dispose = vi.spyOn(result.runtime, 'dispose');
const abort = vi.spyOn(result.runtime, 'abort');
const stream = await result.conversations.openEventStream();
await result.conversations.patchConversation(conversation.id, { archived: true });
expect((await stream.events[Symbol.asyncIterator]().next()).value).toEqual({
type: 'conversation.metadata-changed', projectId: 'project-a', conversationId: conversation.id,
});
expect(dispose).not.toHaveBeenCalled();
expect(abort).not.toHaveBeenCalled();
expect((await result.runtime.getSnapshot(conversation.id)).run.status).toBe('running');
for (const mode of ['prompt', 'steer', 'follow-up']) {
await expect(result.conversations.acceptPrompt({ conversationId: conversation.id,
clientRequestId: mode, mode, text: 'New work' })).rejects.toMatchObject({ code: 'CODING_CONVERSATION_ARCHIVED' });
}
await expect(result.conversations.fork(conversation.id, 'user-entry')).rejects.toMatchObject({ code: 'CODING_CONVERSATION_ARCHIVED' });
await expect(result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'first',
mode: 'prompt', text: 'Build' })).resolves.toMatchObject({ accepted: true });
await result.conversations.abort(conversation.id);
expect(abort).toHaveBeenCalledOnce();
await result.conversations.patchConversation(conversation.id, { archived: false });
expect((await result.conversations.getConversation(conversation.id)).archivedAt).toBeNull();
stream.close();
});
it('uses one vendor-neutral Main composition without spawning on create', async () => {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-project-'));
const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-user-'));

View File

@@ -55,6 +55,38 @@ afterEach(async () => {
});
describe('coding project schema v2', () => {
it('assigns the first title once without moving the conversation, and manual names win', async () => {
const root = await makeProjectPath();
let timestamp = NOW;
const store = createCodingConversationStore(root, { now: () => timestamp });
const created = await store.create({ agentId: 'builder', title: '新对话', titleMode: 'automatic', model: MODEL, modelResolution: 'resolved' });
timestamp = NEXT;
await store.setFirstMessageTitle(created.id, '制作登录页面');
await store.setFirstMessageTitle(created.id, '第二句话不能覆盖');
expect(await store.get(created.id)).toMatchObject({ title: '制作登录页面', updatedAt: NOW, autoTitleSet: true });
await store.patchMetadata(created.id, { title: '新对话' });
await store.setFirstMessageTitle(created.id, '也不能覆盖手动占位标题');
expect(await store.get(created.id)).toMatchObject({ title: '新对话', titleMode: 'manual', updatedAt: NOW });
const archived = await store.patchMetadata(created.id, { archivedAt: NEXT });
expect(archived).toMatchObject({ archivedAt: NEXT, updatedAt: NEXT });
const restored = await store.patchMetadata(created.id, { archivedAt: null });
expect(restored.id).toBe(created.id);
expect(restored.title).toBe('新对话');
});
it('leaves historical titles alone and never resurrects a deleted conversation', async () => {
const root = await makeProjectPath();
const store = createCodingConversationStore(root);
const created = await store.create({ agentId: 'builder', title: '旧标题', model: MODEL, modelResolution: 'resolved' });
const { titleMode: _mode, autoTitleSet: _set, ...legacy } = created;
await atomicWriteJson(path.join(root, '.makelore/conversations.json'), { schemaVersion: 2, conversations: [legacy] });
expect(await store.setFirstMessageTitle(created.id, '替换标题')).toBeNull();
expect(await store.get(created.id)).toMatchObject({ title: '旧标题', titleMode: 'manual', autoTitleSet: true });
await store.delete(created.id);
expect(await store.setFirstMessageTitle(created.id, '不能复活')).toBeNull();
expect((await store.read()).conversations).toEqual([]);
});
it('accepts the native max thinking level in a project model reference', () => {
expect(normalizeProductModelRef({
accountId: 'account-local',

View File

@@ -79,6 +79,47 @@ function deferred<T>() {
}
describe('coding workspace store', () => {
it('discards a stale list read and refreshes after a manual rename', async () => {
const old = conversation('conversation-a', 'agent-a');
const stale = deferred<CodingConversationMetadata[]>();
const latest = { ...old, title: '手动标题' };
const listConversations = vi.fn().mockResolvedValueOnce([old])
.mockReturnValueOnce(stale.promise).mockResolvedValue([latest]);
const store = createCodingWorkspaceStore({
listProjects: async () => ({ projects: [project], activeProjectId: project.id }),
getConfig: async () => ({ project, config: config([agent('agent-a')]) }),
listConversations, patchConversation: async () => latest,
});
await store.getState().load();
const refresh = store.getState().refreshConversations(project.id);
await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
await store.getState().patchConversation(old.id, { title: latest.title });
stale.resolve([old]);
await refresh;
expect(store.getState().conversations[0].title).toBe(latest.title);
expect(listConversations).toHaveBeenCalledTimes(3);
expect(store.getState().loadState).toBe('ready');
});
it('does a follow-up read when metadata changes during an in-flight refresh', async () => {
const old = conversation('conversation-a', 'agent-a');
const stale = deferred<CodingConversationMetadata[]>();
const latest = { ...old, archivedAt: '2026-09-20T00:00:00Z' };
const listConversations = vi.fn().mockResolvedValueOnce([old])
.mockReturnValueOnce(stale.promise).mockResolvedValue([latest]);
const store = createCodingWorkspaceStore({
listProjects: async () => ({ projects: [project], activeProjectId: project.id }),
getConfig: async () => ({ project, config: config([agent('agent-a')]) }), listConversations,
});
await store.getState().load();
const refresh = store.getState().refreshConversations(project.id);
await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2));
const followup = store.getState().refreshConversations(project.id);
stale.resolve([old]);
await Promise.all([refresh, followup]);
expect(store.getState().conversations).toEqual([latest]);
});
it('loads local project metadata and selects the pinned Agent without touching runtime APIs', async () => {
const listProjects = vi.fn(async () => ({ projects: [project], activeProjectId: project.id }));
const getConfig = vi.fn(async () => ({

View File

@@ -82,7 +82,8 @@ describe('real managed Pi user-entry fork', () => {
});
const source = await store.create({
agentId: 'agent-real-fork',
title: 'Persisted source',
title: '新对话',
titleMode: 'automatic',
model,
modelResolution: 'resolved',
});
@@ -185,6 +186,7 @@ describe('real managed Pi user-entry fork', () => {
try {
const sourceSnapshot = await service.getSnapshot(source.id);
expect((await service.getConversation(source.id)).title).toBe('Persisted user fork source');
expect(processOptions[0]?.sessionDir).toBe(sessionDirectory);
expect(processOptions[0]?.cwd).toBe(project.path);
expect(sourceSnapshot.nodes).toEqual(expect.arrayContaining([
@@ -204,6 +206,7 @@ describe('real managed Pi user-entry fork', () => {
store.get(forked.id),
]);
expect(forked.agentId).toBe(source.agentId);
expect(forkedBinding).toMatchObject({ titleMode: 'manual', autoTitleSet: true });
expect(forkedSnapshot).toMatchObject({
conversation: { id: forked.id, agentId: source.agentId },
nodes: [],
@@ -221,6 +224,7 @@ describe('real managed Pi user-entry fork', () => {
expect(forkedBinding?.sessionKey).not.toBe(sourceBinding?.sessionKey);
expect(runtime.getDiagnostics().workers).toHaveLength(2);
} finally {
service.dispose();
await runtime.shutdown();
await extensionHost.close();
}