fix(pi): validate user-entry conversation forks

This commit is contained in:
2026-08-25 17:48:31 +08:00
parent 941b015330
commit 8ba2a6055c
10 changed files with 767 additions and 13 deletions

View File

@@ -28,6 +28,7 @@ import {
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import type {
ConversationSnapshot,
ConversationPatchEnvelope,
PromptConversationInput,
} from '../../electron/coding-runtime/contracts';
@@ -802,6 +803,27 @@ describe('PI-100 coding core Host contract', () => {
let bindFork: ((conversationId: string) => Promise<void>) | undefined;
let forkTargetId = '';
class FailingForkRuntime extends InMemoryConversationRuntime {
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
const snapshot = await super.getSnapshot(conversationId);
return {
...snapshot,
nodes: [{
kind: 'message',
id: 'node-user-fork-cleanup',
sourceEntryId: 'entry-user-fork-cleanup',
role: 'user',
status: 'complete',
blocks: [{
kind: 'text',
id: 'text-user-fork-cleanup',
text: 'Fork before hydration fails',
status: 'complete',
}],
}],
cursor: { ...snapshot.cursor, leafEntryId: 'entry-user-fork-cleanup' },
};
}
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]): Promise<never> {
forkTargetId = input.conversation.conversationId;
await bindFork?.(forkTargetId);
@@ -827,7 +849,7 @@ describe('PI-100 coding core Host contract', () => {
await conversations.getSnapshot(source.id);
const dispose = vi.spyOn(runtime, 'dispose');
await expect(conversations.fork(source.id)).rejects.toMatchObject({
await expect(conversations.fork(source.id, 'entry-user-fork-cleanup')).rejects.toMatchObject({
code: 'CODING_SESSION_UNREADABLE',
});
expect(dispose).toHaveBeenCalledWith(forkTargetId);
@@ -837,4 +859,110 @@ describe('PI-100 coding core Host contract', () => {
});
await expect(store.get(forkTargetId)).resolves.toBeNull();
});
it('rejects non-user or inactive fork entries before creating target resources', async () => {
class ForkSourceRuntime extends InMemoryConversationRuntime {
sourceConversationId = '';
readonly forkInputs: Parameters<InMemoryConversationRuntime['fork']>[0][] = [];
override async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
const snapshot = await super.getSnapshot(conversationId);
if (conversationId !== this.sourceConversationId) return snapshot;
return {
...snapshot,
nodes: [
{
kind: 'message',
id: 'node-user-active',
sourceEntryId: 'entry-user-active',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'text-user-active', text: 'Fork here', status: 'complete' }],
},
{
kind: 'message',
id: 'node-assistant-active',
sourceEntryId: 'entry-assistant-active',
role: 'assistant',
status: 'complete',
blocks: [{ kind: 'text', id: 'text-assistant-active', text: 'Do not fork here', status: 'complete' }],
},
],
cursor: { ...snapshot.cursor, leafEntryId: 'entry-assistant-active' },
};
}
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]) {
this.forkInputs.push(structuredClone(input));
await this.prepare(input.conversation);
return {
conversationId: input.conversation.conversationId,
snapshot: await super.getSnapshot(input.conversation.conversationId),
};
}
}
const runtime = new ForkSourceRuntime();
const result = await setup(runtime);
const source = await createConversation(result.conversations);
runtime.sourceConversationId = source.id;
await result.conversations.getSnapshot(source.id);
const store = result.projects.conversationStore(result.root);
const create = vi.spyOn(store, 'create');
create.mockClear();
const dispose = vi.spyOn(runtime, 'dispose');
const archiveSession = vi.fn(async () => undefined);
const conversations = new CodingConversationService(result.projects, runtime, { archiveSession });
for (const sourceEntryId of [
'entry-assistant-active',
'entry-unknown',
'entry-user-stale',
]) {
await expect(conversations.fork(source.id, sourceEntryId)).rejects.toMatchObject({
status: 400,
code: 'CODING_CONVERSATION_REQUEST_INVALID',
});
}
expect(create).not.toHaveBeenCalled();
expect(runtime.forkInputs).toEqual([]);
expect(dispose).not.toHaveBeenCalled();
expect(archiveSession).not.toHaveBeenCalled();
await expect(store.read()).resolves.toMatchObject({
conversations: [expect.objectContaining({ id: source.id })],
});
const invalidResponse = await dispatchHostApiRequest(context({
...result,
conversations,
}), {
path: `/api/coding/conversations/${source.id}/fork`,
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sourceEntryId: 'entry-assistant-active' }),
});
expect(invalidResponse).toMatchObject({
status: 400,
json: {
code: 'CODING_CONVERSATION_REQUEST_INVALID',
error: '对话请求无效,请检查输入。',
},
});
expect(create).not.toHaveBeenCalled();
const forked = await conversations.fork(source.id, 'entry-user-active');
expect(forked.agentId).toBe(source.agentId);
await expect(store.read()).resolves.toMatchObject({
conversations: [
expect.objectContaining({ id: forked.id, agentId: source.agentId }),
expect.objectContaining({ id: source.id, agentId: source.agentId }),
],
});
expect(runtime.forkInputs).toHaveLength(1);
expect(runtime.forkInputs[0]).toMatchObject({
sourceConversationId: source.id,
sourceEntryId: 'entry-user-active',
});
});
});