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

@@ -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-'));