fix(pi): validate user-entry conversation forks
This commit is contained in:
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user