fix(coding): reconcile snapshot after abort

This commit is contained in:
2026-09-01 15:17:45 +08:00
parent 143aaec3d6
commit d2ef37bc4d
6 changed files with 165 additions and 13 deletions

View File

@@ -63,6 +63,7 @@ async function installCodingFirstChatHost(
captured: CapturedRequest[];
conversationCreated: boolean;
interactionAnswered: boolean;
abortRequested: boolean;
releaseSnapshot: (() => void) | null;
snapshotPending: boolean;
};
@@ -73,6 +74,7 @@ async function installCodingFirstChatHost(
captured: [],
conversationCreated: false,
interactionAnswered: false,
abortRequested: false,
releaseSnapshot: null,
snapshotPending: false,
};
@@ -470,9 +472,44 @@ async function installCodingFirstChatHost(
state.snapshotPending = false;
}
return respond({
snapshot: state.interactionAnswered
? { ...snapshot, pendingInteractions: [] }
: snapshot,
snapshot: state.abortRequested
? {
...snapshot,
nodes: snapshot.nodes.map((node) => {
if (node.kind === 'message' && node.role === 'assistant') {
return {
...node,
status: 'aborted',
blocks: node.blocks.map((block) => ({ ...block, status: 'complete' })),
};
}
if (node.kind === 'subagent') {
return {
...node,
details: {
...node.details,
tasks: node.details.tasks.map((task) => (
task.status === 'running' ? { ...task, status: 'aborted' } : task
)),
},
};
}
return node;
}),
run: {
status: 'idle',
runId: 'run-e2e-feature',
mode: 'prompt',
settledAt: 12_000,
terminalReason: 'aborted',
},
queue: { items: [] },
pendingInteractions: [],
cursor: { workerGeneration: 1, seq: 1 },
}
: state.interactionAnswered
? { ...snapshot, pendingInteractions: [] }
: snapshot,
});
}
if (path === `/api/coding/conversations/${secondConversation.id}/snapshot`) {
@@ -489,7 +526,11 @@ async function installCodingFirstChatHost(
},
}, 202);
}
if (/^\/api\/coding\/conversations\/[^/]+\/(abort|compact|recover)$/.test(path) && method === 'POST') {
if (/^\/api\/coding\/conversations\/[^/]+\/abort$/.test(path) && method === 'POST') {
state.abortRequested = true;
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/(compact|recover)$/.test(path) && method === 'POST') {
return respond({});
}
if (/^\/api\/coding\/conversations\/[^/]+\/model$/.test(path) && method === 'POST') {
@@ -795,6 +836,9 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
await expect(runtimeSettings).not.toHaveClass(/bg-surface-subtle\/75/);
await expect(runtimeSettings).toBeDisabled();
await page.getByRole('button', { name: '中止', exact: true }).click();
await expect(page.getByRole('button', { name: '中止', exact: true })).toHaveCount(0);
await expect(composer.getByRole('button', { name: '中止生成' })).toHaveCount(0);
await expect(runtimeSettings).toBeEnabled();
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
await expect(builderConversations).toBeVisible();
@@ -806,6 +850,7 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
await expect(page.getByText('本地编程运行时暂时不可用')).toHaveCount(0);
await builderConversations.getByRole('button', { name: /^新对话/ }).click();
await expect(page.getByText('Durable user fork source')).toBeVisible();
await page.getByTestId('coding-process-group').locator('summary').first().click();
await expect(page.getByText('Durable assistant response')).toBeVisible();
await expect(page.getByRole('button', { name: '从这里创建新对话分支' })).toHaveCount(1);
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();

View File

@@ -695,6 +695,45 @@ describe('CodingChatPanel first Conversation', () => {
expect(screen.getByRole('button', { name: '发送' })).toBeEnabled();
});
it('refreshes the authoritative Snapshot after abort when the terminal SSE patch was missed', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });
projectApi.conversations.mockResolvedValue([conversation]);
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
conversationApi.recover.mockResolvedValue(undefined);
conversationApi.abort.mockResolvedValue(undefined);
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
const runningSnapshot: ConversationSnapshot = {
...createLocalConversationSnapshot(project.id, conversation),
worker: { status: 'ready', generation: 1 },
run: { status: 'running', runId: 'run-stale', mode: 'prompt' },
cursor: { workerGeneration: 1, seq: 7 },
};
const settledSnapshot: ConversationSnapshot = {
...runningSnapshot,
run: {
status: 'idle',
runId: 'run-stale',
mode: 'prompt',
settledAt: 12_000,
terminalReason: 'completed',
},
cursor: { workerGeneration: 1, seq: 8 },
};
conversationApi.snapshot
.mockResolvedValueOnce(runningSnapshot)
.mockResolvedValueOnce(settledSnapshot);
render(<CodingChatPanel />);
const abortButton = await screen.findByRole('button', { name: '中止生成' });
fireEvent.click(abortButton);
await waitFor(() => expect(conversationApi.abort).toHaveBeenCalledWith(conversation.id));
await waitFor(() => expect(conversationApi.snapshot).toHaveBeenCalledTimes(2));
expect(await screen.findByRole('button', { name: '发送' })).toBeInTheDocument();
});
it('caps one message at 16 images and uploads at most four concurrently', async () => {
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
projectApi.config.mockResolvedValue({ project, config });

View File

@@ -219,6 +219,7 @@ describe('PI-130 feature-complete Coding UI', () => {
conversation={null}
snapshot={null}
onRename={vi.fn(async () => undefined)}
onAbort={vi.fn(async () => undefined)}
onRecover={vi.fn(async () => undefined)}
/>
</>,
@@ -367,6 +368,7 @@ describe('PI-130 feature-complete Coding UI', () => {
interactionApi.thinking.mockResolvedValue({ model: null, modelResolution: 'required' });
const callbacks = {
rename: vi.fn(async () => undefined),
abort: vi.fn(async () => undefined),
refresh: vi.fn(async () => undefined),
recover: vi.fn(async () => undefined),
};
@@ -410,6 +412,7 @@ describe('PI-130 feature-complete Coding UI', () => {
conversation={conversation}
snapshot={snapshot}
onRename={callbacks.rename}
onAbort={callbacks.abort}
onRecover={callbacks.recover}
/>
<CodingComposer
@@ -635,6 +638,7 @@ describe('PI-130 feature-complete Coding UI', () => {
conversation={conversation}
snapshot={snapshot}
onRename={vi.fn(async () => undefined)}
onAbort={async () => interactionApi.abort('conversation-uncertain')}
onRecover={recover}
/>
<CodingComposerRuntimeControls