feat(coding): add conversation archive and first-message titles
This commit is contained in:
@@ -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 });
|
||||
|
||||
24
tests/unit/coding-conversation-title.test.ts
Normal file
24
tests/unit/coding-conversation-title.test.ts
Normal 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('附件对话');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user