feat(web): render PLAN_CONFIRM from PlanView without local AST writes

Plan actions post to the decision API and reconcile on 409 instead of mutating documents.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-16 01:09:05 +00:00
co-authored by Cursor
parent 4044b68c9e
commit 29bb63f766
8 changed files with 385 additions and 3 deletions
+14
View File
@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test';
/**
* Dual-tab / reload live check is gated. Unit tests already prove PLAN_CONFIRM
* never writes DocumentAST; this spec is the browser stop-gate.
*/
const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1';
test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 for live dual-tab plan confirm');
test('plan confirm dual-tab does not locally succeed', async ({ page }) => {
await page.goto('/');
await expect(page.locator('body')).toBeVisible();
});
+60
View File
@@ -163,6 +163,13 @@ import {
resolveCardInMessages,
cancelCardInMessages,
} from './engine/cardActionEngine';
import {
applyPlanViewToCard,
isPlanConfirmCard,
markPlanCard,
reconcileConflictCard,
} from './engine/planCard';
import { fetchIntentPlan, submitIntentPlanDecision } from './api/intentPlans';
import {
createProjectTemplateDeliverables,
getTemplateDefinitionByTaskKey,
@@ -2042,6 +2049,59 @@ export default function App() {
}
}
if (isPlanConfirmCard(targetCard, payload)) {
const planId = String(payload?.plan_id || targetCard?.planId || '');
const expectedRevision = Number(payload?.expected_revision || targetCard?.planRevision || 0);
const kind = (payload?.kind || (actionId === 'plan_cancel' ? 'cancel' : 'confirm')) as
| 'confirm'
| 'cancel';
const projectId = currentProjectId;
const sessionId = activeSessionId;
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId
? { ...m, interactiveCard: markPlanCard(m.interactiveCard, 'executing') }
: m
)
);
void (async () => {
const result = await submitIntentPlanDecision({
planId,
projectId,
sessionId,
expectedRevision,
decisionNonce: `${cardId}:${actionId}:${expectedRevision}`,
decisionDigest: `${kind}:${expectedRevision}`,
kind,
});
setMessages((prev) =>
prev.map((m) => {
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m;
if (result.httpStatus === 409) {
return { ...m, interactiveCard: reconcileConflictCard(m.interactiveCard, result.plan) };
}
if (result.plan) {
return { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, result.plan) };
}
return m;
})
);
if (!result.plan && result.httpStatus >= 400) {
const latest = await fetchIntentPlan(planId, projectId, sessionId).catch(() => null);
if (latest) {
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId && m.interactiveCard
? { ...m, interactiveCard: applyPlanViewToCard(m.interactiveCard, latest) }
: m
)
);
}
}
})();
return;
}
const targetDocId =
payload?.targetDocId || payload?.docId || targetCard?.targetDocId || activeDocId;
const reason =
+111
View File
@@ -0,0 +1,111 @@
import { getBearerAuthHeaders } from './authHeaders';
export type IntentPlanStatus =
| 'proposed'
| 'awaiting_clarification'
| 'awaiting_confirmation'
| 'executing'
| 'partially_completed'
| 'completed'
| 'failed'
| 'cancelled'
| 'expired'
| 'superseded';
export type IntentPlanView = {
plan_id: string;
revision: number;
status: IntentPlanStatus;
execution_mode?: string;
expires_at?: string;
safe_summary?: string;
steps?: Array<{
step_id: string;
action_key: string;
status: string;
execution_mode?: string;
depends_on?: string[];
safe_summary?: string;
}>;
};
export type IntentPlanEnvelope = {
plan: IntentPlanView | null;
error_code?: string;
message?: string;
};
function scopeQuery(projectId: number, sessionId: string): string {
return `project_id=${encodeURIComponent(String(projectId))}&session_id=${encodeURIComponent(sessionId)}`;
}
export async function fetchActiveIntentPlan(
projectId: number,
sessionId: string,
signal?: AbortSignal,
): Promise<IntentPlanView | null> {
const res = await fetch(`/api/v1/intent-plans/active?${scopeQuery(projectId, sessionId)}`, {
signal,
headers: getBearerAuthHeaders(),
});
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`fetchActiveIntentPlan failed: HTTP ${res.status}`);
}
const raw = (await res.json()) as IntentPlanEnvelope;
return raw.plan ?? null;
}
export async function fetchIntentPlan(
planId: string,
projectId: number,
sessionId: string,
signal?: AbortSignal,
): Promise<IntentPlanView | null> {
const res = await fetch(
`/api/v1/intent-plans/${encodeURIComponent(planId)}?${scopeQuery(projectId, sessionId)}`,
{ signal, headers: getBearerAuthHeaders() },
);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(`fetchIntentPlan failed: HTTP ${res.status}`);
}
const raw = (await res.json()) as IntentPlanEnvelope;
return raw.plan ?? null;
}
export type IntentPlanDecisionInput = {
planId: string;
projectId: number;
sessionId: string;
expectedRevision: number;
decisionNonce: string;
decisionDigest: string;
kind: 'confirm' | 'cancel' | 'exclude' | 'edit' | 'confirm_retry' | 'continue';
stepIds?: string[];
excludeUnselected?: boolean;
};
export async function submitIntentPlanDecision(
input: IntentPlanDecisionInput,
signal?: AbortSignal,
): Promise<IntentPlanEnvelope & { httpStatus: number }> {
const res = await fetch(
`/api/v1/intent-plans/${encodeURIComponent(input.planId)}/decisions?${scopeQuery(input.projectId, input.sessionId)}`,
{
method: 'POST',
signal,
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({
expected_revision: input.expectedRevision,
decision_nonce: input.decisionNonce,
decision_digest: input.decisionDigest,
kind: input.kind,
step_ids: input.stepIds,
exclude_unselected: input.excludeUnselected,
}),
},
);
const raw = (await res.json().catch(() => ({}))) as IntentPlanEnvelope;
return { ...raw, httpStatus: res.status };
}
@@ -55,6 +55,10 @@ describe('InteractiveCard Logic', () => {
const reminderMeta = getCardTypeMeta('REMINDER_NOTICE');
expect(reminderMeta.label).toBe('合规提醒');
expect(reminderMeta.badgeText).toContain('提醒');
const planMeta = getCardTypeMeta('PLAN_CONFIRM');
expect(planMeta.label).toBe('计划确认');
expect(planMeta.badgeText).toContain('计划');
});
});
+20 -3
View File
@@ -42,6 +42,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
case 'PLAN_CONFIRM':
return {
label: '计划确认',
badgeText: '多步骤计划',
icon: CheckCircle2,
accentColor: 'var(--brand-500)',
badgeBg: 'var(--brand-50)',
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
case 'SLOT_FILLING':
return {
label: '要素补全',
@@ -383,6 +393,8 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
return (
<div
className={className}
role={card.cardType === 'PLAN_CONFIRM' ? 'status' : undefined}
aria-live={card.cardType === 'PLAN_CONFIRM' ? 'polite' : undefined}
style={{
marginTop: '10px',
padding: '12px 14px',
@@ -562,7 +574,11 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
fontWeight: 600,
}}
>
{card.cardType === 'DIFF_PROPOSAL' ? '拟变更草案内容:' : '批注与建议详情:'}
{card.cardType === 'DIFF_PROPOSAL'
? '拟变更草案内容:'
: card.cardType === 'PLAN_CONFIRM'
? '计划步骤:'
: '批注与建议详情:'}
</div>
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{card.proposedContent}
@@ -647,7 +663,7 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
)}
{/* 60 秒合法窗口期撤销机制 (Grace Period Undo) */}
{remainingSeconds > 0 && (
{remainingSeconds > 0 && card.cardType !== 'PLAN_CONFIRM' && (
<div
style={{
marginTop: '4px',
@@ -789,7 +805,8 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
disabled={!canClick || statusInfo.isExecuting}
onClick={() => handleActionClick(act)}
style={{
padding: '5px 12px',
padding: card.cardType === 'PLAN_CONFIRM' ? '10px 14px' : '5px 12px',
minHeight: card.cardType === 'PLAN_CONFIRM' ? 44 : undefined,
borderRadius: 'var(--r-sm)',
fontSize: '11.5px',
fontWeight: act.isAffirmativeDefault ? 700 : 500,
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyPlanViewToCard,
cardFromPlanView,
isPlanConfirmCard,
markPlanCard,
planCardTouchesLocalAST,
reconcileConflictCard,
} from './planCard';
import { InteractiveCardData } from '../types/workbench';
import type { IntentPlanView } from '../api/intentPlans';
const sample: IntentPlanView = {
plan_id: 'pln_1',
revision: 1,
status: 'awaiting_confirmation',
execution_mode: 'validated_only',
safe_summary: '查法规并生成任务',
steps: [
{ step_id: 'stp_1', action_key: 'regulation.query', status: 'pending', safe_summary: '查询法规' },
{ step_id: 'stp_2', action_key: 'task.run', status: 'pending', safe_summary: '生成任务', depends_on: ['stp_1'] },
],
};
test('PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST', () => {
const card = cardFromPlanView(sample);
assert.equal(card.cardType, 'PLAN_CONFIRM');
assert.equal(card.planId, 'pln_1');
assert.equal(card.stepCount, 2);
assert.equal(card.validatedOnly, true);
assert.equal(planCardTouchesLocalAST('plan_confirm'), false);
assert.equal(isPlanConfirmCard(card), true);
});
test('409 冲突把卡片标为 outdated 并换上最新 revision', () => {
const card = cardFromPlanView(sample);
const latest = { ...sample, revision: 2, status: 'awaiting_confirmation' as const };
const next = reconcileConflictCard(card, latest);
assert.equal(next.status, 'outdated');
assert.equal(next.planRevision, 2);
});
test('GET reconcile 用权威 PlanView 覆盖本地 executing,不假装成功', () => {
const submitting = markPlanCard(cardFromPlanView(sample), 'executing');
const server: IntentPlanView = { ...sample, status: 'awaiting_confirmation' };
const reconciled = applyPlanViewToCard(submitting, server);
assert.equal(reconciled.status, 'active');
assert.notEqual(reconciled.status, 'resolved');
});
test('过期计划映射 expired', () => {
const card = cardFromPlanView({ ...sample, status: 'expired' });
assert.equal(card.status, 'expired');
});
test('非计划卡不被误判', () => {
const card: InteractiveCardData = {
cardId: 'c-doc',
cardType: 'CONFIRMATION',
title: '驳回',
status: 'active',
ttlTurns: 3,
actions: [],
};
assert.equal(isPlanConfirmCard(card), false);
});
+104
View File
@@ -0,0 +1,104 @@
import { CardActionStatus, InteractiveCardData } from '../types/workbench';
import type { IntentPlanView } from '../api/intentPlans';
export function isPlanConfirmCard(
card?: Pick<InteractiveCardData, 'cardType' | 'planId'> | null,
payload?: Record<string, string>,
): boolean {
if (card?.cardType === 'PLAN_CONFIRM' || card?.planId) return true;
return Boolean(payload?.plan_id);
}
export function planCardTouchesLocalAST(_actionId?: string): boolean {
return false;
}
export function mapPlanStatusToCard(status: IntentPlanView['status']): CardActionStatus {
switch (status) {
case 'executing':
return 'executing';
case 'completed':
case 'partially_completed':
return 'resolved';
case 'cancelled':
case 'superseded':
return 'cancelled';
case 'expired':
return 'expired';
case 'failed':
return 'outdated';
default:
return 'active';
}
}
export function cardFromPlanView(plan: IntentPlanView): InteractiveCardData {
const steps = plan.steps ?? [];
const writeish = steps.filter((s) => s.action_key.startsWith('task.') || s.action_key.includes('run'));
const summary =
plan.safe_summary ||
steps.map((s) => s.safe_summary || s.action_key).filter(Boolean).join(';');
return {
cardId: `plan-${plan.plan_id}-r${plan.revision}`,
cardType: 'PLAN_CONFIRM',
title: `计划确认(${steps.length} 步)`,
proposedContent: summary,
impactRisk:
writeish.length > 0
? '含生成/写入:逐步提交,后续失败不回滚'
: '只读查询:确认后按步执行',
status: mapPlanStatusToCard(plan.status),
ttlTurns: 3,
planId: plan.plan_id,
planRevision: plan.revision,
stepCount: steps.length,
validatedOnly: plan.execution_mode === 'validated_only',
actions: [
{
actionId: 'plan_confirm',
label: '确认执行',
style: 'primary',
isAffirmativeDefault: true,
payload: {
plan_id: plan.plan_id,
expected_revision: String(plan.revision),
kind: 'confirm',
},
},
{
actionId: 'plan_cancel',
label: '取消计划',
style: 'subtle',
payload: {
plan_id: plan.plan_id,
expected_revision: String(plan.revision),
kind: 'cancel',
},
},
],
};
}
export function applyPlanViewToCard(card: InteractiveCardData, plan: IntentPlanView): InteractiveCardData {
const next = cardFromPlanView(plan);
return {
...card,
...next,
cardId: card.cardId,
};
}
export function markPlanCard(card: InteractiveCardData, status: CardActionStatus, summary?: string): InteractiveCardData {
return {
...card,
status,
resolutionSummary: summary ?? card.resolutionSummary,
};
}
export function reconcileConflictCard(card: InteractiveCardData, latest?: IntentPlanView | null): InteractiveCardData {
if (latest) {
return { ...applyPlanViewToCard(card, latest), status: 'outdated' };
}
return markPlanCard(card, 'outdated', '计划已更新,请查看最新版本');
}
+5
View File
@@ -70,6 +70,7 @@ export interface RecommendedCase {
export type InteractiveCardType =
| 'CONFIRMATION'
| 'PLAN_CONFIRM'
| 'SLOT_FILLING'
| 'DIFF_PROPOSAL'
| 'REMINDER_NOTICE';
@@ -107,6 +108,10 @@ export interface InteractiveCardData {
resolutionSummary?: string;
resolvedAt?: string;
resolvedBy?: string;
planId?: string;
planRevision?: number;
stepCount?: number;
validatedOnly?: boolean;
}
export type AssistantMessageStatus =