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

@@ -152,6 +152,22 @@ async function installCodingFirstChatHost(
model: { model: configuredModel, modelResolution: 'resolved' },
},
nodes: featureComplete ? [
{
kind: 'message',
id: 'message-user-e2e',
sourceEntryId: 'entry-user-e2e',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-user-e2e:content:0', text: 'Durable user fork source', status: 'complete' }],
},
{
kind: 'message',
id: 'message-assistant-e2e',
sourceEntryId: 'entry-assistant-e2e',
role: 'assistant',
status: 'complete',
blocks: [{ kind: 'text', id: 'message-assistant-e2e:content:0', text: 'Durable assistant response', status: 'complete' }],
},
{
kind: 'subagent',
id: 'subagent-e2e',
@@ -567,6 +583,16 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
await expect(builderConversations).toBeVisible();
const forkActions = page.getByRole('button', { name: '从这里创建新对话分支' });
await expect(forkActions).toHaveCount(1);
await forkActions.click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Feature UI branch');
await expect(builderConversations.getByRole('button', { name: 'Feature UI branch' })).toBeVisible();
await expect(page.getByText('本地编程运行时暂时不可用')).toHaveCount(0);
await builderConversations.getByRole('button', { name: /^新对话/ }).click();
await expect(page.getByText('Durable user fork source')).toBeVisible();
await expect(page.getByText('Durable assistant response')).toBeVisible();
await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1);
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();
await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
await expect(page.getByRole('combobox', { name: '当前对话模型' })).toHaveValue(
@@ -589,6 +615,11 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
expect(state.captured.some((request) => request.path.endsWith('/changes'))).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/recover') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => request.path.endsWith('/compact') && request.method === 'POST')).toBe(true);
expect(state.captured.some((request) => (
request.path === '/api/coding/conversations/conversation-pi-first-chat/fork'
&& request.method === 'POST'
&& request.body?.sourceEntryId === 'entry-user-e2e'
))).toBe(true);
expect(state.captured.some((request) => request.path.includes('legacy-conversation-notice'))).toBe(true);
expect(state.captured.every((request) => !request.path.includes('/api/opencode/share'))).toBe(true);
} finally {

View File

@@ -169,14 +169,38 @@ describe('CodingConversationTimeline', () => {
const snapshot = {
...base,
nodes: [
{
kind: 'message' as const,
id: 'message-user-feature',
sourceEntryId: 'entry-user-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-user-feature', text: 'Build it', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-feature',
sourceEntryId: 'entry-feature',
sourceEntryId: 'entry-assistant-feature',
role: 'assistant' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-feature', text: 'Ready', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-optimistic-feature',
sourceEntryId: 'entry-optimistic-feature',
clientRequestId: 'request-optimistic-feature',
role: 'user' as const,
status: 'optimistic' as const,
blocks: [{ kind: 'text' as const, id: 'text-optimistic-feature', text: 'Pending', status: 'complete' as const }],
},
{
kind: 'message' as const,
id: 'message-without-entry-feature',
role: 'user' as const,
status: 'complete' as const,
blocks: [{ kind: 'text' as const, id: 'text-without-entry-feature', text: 'Local only', status: 'complete' as const }],
},
{
kind: 'tool' as const,
id: 'tool-feature',
@@ -249,8 +273,10 @@ describe('CodingConversationTimeline', () => {
render(<CodingConversationTimeline conversationId="conversation-feature-ui" onFork={onFork} />);
fireEvent.click(screen.getByRole('button', { name: '从这里创建新对话分支' }));
expect(onFork).toHaveBeenCalledWith('entry-feature');
const forkActions = screen.getAllByRole('button', { name: '从这里创建新对话分支' });
expect(forkActions).toHaveLength(1);
fireEvent.click(forkActions[0]!);
expect(onFork).toHaveBeenCalledWith('entry-user-feature');
expect(screen.getByText('并行子任务')).toBeInTheDocument();
expect(screen.getByText('单个子任务')).toBeInTheDocument();
expect(screen.getByText('串行子任务')).toBeInTheDocument();

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',
});
});
});

View File

@@ -0,0 +1,229 @@
// @vitest-environment node
import { realpathSync } from 'node:fs';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store';
import { createCodingProjectAgent } from '../../electron/coding-projects/project-config';
import { CodingProjectService } from '../../electron/coding-projects/project-service';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
import { CodingConversationService } from '../../electron/coding-runtime/conversation-service';
import { PiManagedExtensionHost } from '../../electron/coding-runtime/pi/extension-host';
import {
buildPiProviderCatalog,
selectPiProviderModel,
} from '../../electron/coding-runtime/pi/provider-config';
import {
createPiManagedWorkerOpener,
PiConversationRuntime,
} from '../../electron/coding-runtime/pi/runtime';
import { PiSessionRegistry } from '../../electron/coding-runtime/pi/session-registry';
import { PiWorkerProcess, type PiWorkerProcessOptions } from '../../electron/coding-runtime/pi/worker-process';
import { PiWorkerPool } from '../../electron/coding-runtime/pi/worker-pool';
import type { ProviderAccount } from '../../electron/shared/providers/types';
const roots: string[] = [];
const NOW = '2026-08-25T08:00:00.000Z';
const SOURCE_SESSION_ID = 'c67b7c7b-8364-4dcb-a7f7-913316e8d735';
function electronExecutable(): string {
const requireFromProject = createRequire(path.resolve('package.json'));
return requireFromProject('electron') as string;
}
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
maxRetries: 3,
})));
});
describe('real managed Pi user-entry fork', () => {
it('forks a persisted Pi 0.84.2 session through the product service and hydrates an independent binding', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-real-fork-'));
roots.push(root);
const projectPath = path.join(root, 'project');
const userDataDir = path.join(root, 'user-data');
const projectStore = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-real-fork',
now: () => NOW,
});
const { project } = await createLocalCodingProject({ projectPath, now: NOW }, projectStore);
const model = {
accountId: 'account-real-fork',
modelId: 'model-real-fork',
thinkingLevel: 'off' as const,
};
await createCodingProjectAgent(projectPath, {
id: 'agent-real-fork',
avatarId: 'avatar-01',
roleName: 'Fork verifier',
name: 'Fork verifier',
model,
modelResolution: 'resolved',
responsibility: {
mission: 'Verify a persisted user-entry fork',
owns: [], boundaries: [], collaborators: [], principles: [],
},
skillIds: [],
}, { now: NOW });
const store = createCodingConversationStore(projectPath, {
createId: () => '4ad88774-c373-421b-9c97-5facd368ba66',
now: () => NOW,
});
const source = await store.create({
agentId: 'agent-real-fork',
title: 'Persisted source',
model,
modelResolution: 'resolved',
});
await store.ensureSessionBinding(source.id, async () => ({
piSessionId: SOURCE_SESSION_ID,
sessionKey: SOURCE_SESSION_ID,
}));
const sessionDirectory = path.join(
userDataDir,
'coding-runtime',
'pi',
'sessions',
project.id,
);
await mkdir(sessionDirectory, { recursive: true });
const sessionEntries = [
{ type: 'session', version: 3, id: SOURCE_SESSION_ID, timestamp: NOW, cwd: project.path },
{
type: 'message', id: 'entry-user-real-fork', parentId: null, timestamp: NOW,
message: { role: 'user', content: 'Persisted user fork source', timestamp: 1 },
},
{
type: 'message', id: 'entry-assistant-real-fork', parentId: 'entry-user-real-fork', timestamp: NOW,
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Persisted assistant response' }],
usage: {
input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: 'stop',
timestamp: 2,
},
},
];
await writeFile(
path.join(sessionDirectory, `2026-08-25T08-00-00-000Z_${SOURCE_SESSION_ID}.jsonl`),
`${sessionEntries.map((entry) => JSON.stringify(entry)).join('\n')}\n`,
);
const packageRoot = realpathSync(path.resolve(
'node_modules', '@earendil-works', 'pi-coding-agent',
));
const { SessionManager } = await import(pathToFileURL(
path.join(packageRoot, 'dist', 'core', 'session-manager.js'),
).href) as {
SessionManager: { list(cwd: string, sessionDir: string): Promise<Array<{ id: string }>> };
};
expect(await SessionManager.list(project.path, sessionDirectory)).toEqual([
expect.objectContaining({ id: SOURCE_SESSION_ID }),
]);
const account: ProviderAccount = {
id: model.accountId,
vendorId: 'custom',
label: 'Local fork proof account',
authMode: 'local',
apiProtocol: 'openai-completions',
baseUrl: 'http://127.0.0.1:9/v1',
model: model.modelId,
enabled: true,
isDefault: true,
createdAt: NOW,
updatedAt: NOW,
};
const providerInput = { accounts: [account], modelSummaries: [] };
const registry = new PiSessionRegistry({ projectStore });
const extensionHost = new PiManagedExtensionHost();
const processOptions: PiWorkerProcessOptions[] = [];
const pool = new PiWorkerPool({
maxIdle: 2,
openWorker: createPiManagedWorkerOpener({
registry,
executablePath: electronExecutable(),
cliPath: path.join(packageRoot, 'dist', 'cli.js'),
userDataDir,
bundledSkillsDir: path.resolve('resources/coding-skills'),
extensionHost,
loadProviderInput: async () => providerInput,
resolveCredential: async () => null,
createProcess: (options) => {
processOptions.push(options);
return new PiWorkerProcess(options);
},
}),
});
const runtime = new PiConversationRuntime({
pool,
registry,
extensionHost,
resolveModel: async (candidate) => selectPiProviderModel(
buildPiProviderCatalog(providerInput),
candidate,
),
});
const service = new CodingConversationService(
new CodingProjectService(projectStore),
runtime,
);
try {
const sourceSnapshot = await service.getSnapshot(source.id);
expect(processOptions[0]?.sessionDir).toBe(sessionDirectory);
expect(processOptions[0]?.cwd).toBe(project.path);
expect(sourceSnapshot.nodes).toEqual(expect.arrayContaining([
expect.objectContaining({
kind: 'message', role: 'user', sourceEntryId: 'entry-user-real-fork', status: 'complete',
}),
expect.objectContaining({
kind: 'message', role: 'assistant', sourceEntryId: 'entry-assistant-real-fork', status: 'complete',
}),
]));
const forked = await service.fork(source.id, 'entry-user-real-fork');
const [sourceAfterFork, forkedSnapshot, sourceBinding, forkedBinding] = await Promise.all([
service.getSnapshot(source.id),
service.getSnapshot(forked.id),
store.get(source.id),
store.get(forked.id),
]);
expect(forked.agentId).toBe(source.agentId);
expect(forkedSnapshot).toMatchObject({
conversation: { id: forked.id, agentId: source.agentId },
nodes: [],
run: { status: 'idle' },
worker: { status: 'ready' },
});
expect(sourceAfterFork.nodes).toEqual(sourceSnapshot.nodes);
expect(sourceBinding).toMatchObject({
piSessionId: SOURCE_SESSION_ID,
sessionKey: SOURCE_SESSION_ID,
});
expect(forkedBinding?.piSessionId).toBeTruthy();
expect(forkedBinding?.sessionKey).toBeTruthy();
expect(forkedBinding?.piSessionId).not.toBe(sourceBinding?.piSessionId);
expect(forkedBinding?.sessionKey).not.toBe(sourceBinding?.sessionKey);
expect(runtime.getDiagnostics().workers).toHaveLength(2);
} finally {
await runtime.shutdown();
await extensionHost.close();
}
expect(runtime.getDiagnostics().workers).toHaveLength(0);
}, 30_000);
});