fix(pi): retain ownership for uncertain mutations

This commit is contained in:
2026-08-25 23:53:54 +08:00
parent 621ebb1781
commit 019cbb115a
21 changed files with 1113 additions and 67 deletions

View File

@@ -764,7 +764,7 @@ describe('PI-100 coding core Host contract', () => {
uncertainCalls = 0;
override async prompt(input: PromptConversationInput) {
if (input.clientRequestId === 'request-uncertain') {
if (input.clientRequestId.startsWith('request-uncertain')) {
this.uncertainCalls += 1;
throw new CodingRuntimeContractError(
'CODING_REQUEST_UNCERTAIN',
@@ -794,6 +794,25 @@ describe('PI-100 coding core Host contract', () => {
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
code: 'CODING_REQUEST_UNCERTAIN',
});
const routeResponse = await dispatchHostApiRequest(context(result), {
path: `/api/coding/conversations/${conversation.id}/prompt`,
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
clientRequestId: 'request-uncertain-route',
mode: 'prompt',
text: 'Still running',
attachments: [],
}),
});
expect(routeResponse).toMatchObject({
status: 409,
json: {
code: 'CODING_REQUEST_UNCERTAIN',
error: '请求确认延迟,可能仍在执行。请等待结果,或中止/恢复后再重试。',
},
});
expect(JSON.stringify(routeResponse.json)).not.toContain('本地编程运行时暂时不可用');
for (let index = 0; index < 512; index += 1) {
await result.conversations.acceptPrompt({
conversationId: conversation.id,
@@ -806,9 +825,78 @@ describe('PI-100 coding core Host contract', () => {
await expect(result.conversations.acceptPrompt(input)).rejects.toMatchObject({
code: 'CODING_REQUEST_UNCERTAIN',
});
expect(runtime.uncertainCalls).toBe(1);
expect(runtime.uncertainCalls).toBe(2);
}, 15_000);
it('rejects model and fork mutations before persistence while confirmation is uncertain', async () => {
class UncertainMutationRuntime 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-uncertain',
sourceEntryId: 'entry-user-uncertain',
role: 'user',
status: 'complete',
blocks: [{ kind: 'text', id: 'text-user-uncertain', text: 'Fork later', status: 'complete' }],
}],
run: {
status: 'running',
runId: 'run-uncertain',
mode: 'prompt',
startedAt: 1_000,
error: {
code: 'CODING_REQUEST_UNCERTAIN',
message: '请求确认延迟,可能仍在执行。',
recoverable: true,
},
},
};
}
override async fork(input: Parameters<InMemoryConversationRuntime['fork']>[0]) {
this.forkInputs.push(structuredClone(input));
return await super.fork(input);
}
}
const runtime = new UncertainMutationRuntime();
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');
const setModelState = vi.spyOn(store, 'setModelState');
create.mockClear();
setModelState.mockClear();
await expect(result.conversations.setModel(source.id, {
...MODEL,
modelId: 'model-next',
})).rejects.toMatchObject({
status: 409,
code: 'CODING_REQUEST_UNCERTAIN',
});
await expect(result.conversations.fork(source.id, 'entry-user-uncertain')).rejects.toMatchObject({
status: 409,
code: 'CODING_REQUEST_UNCERTAIN',
});
expect(setModelState).not.toHaveBeenCalled();
expect(create).not.toHaveBeenCalled();
expect(runtime.forkInputs).toEqual([]);
await expect(store.read()).resolves.toMatchObject({
conversations: [expect.objectContaining({ id: source.id, model: MODEL })],
});
});
it('disposes and archives a partially created fork before metadata rollback', async () => {
let bindFork: ((conversationId: string) => Promise<void>) | undefined;
let forkTargetId = '';