From 29bb63f766f19cef1545492779e9498ec1541060 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:09:05 +0000 Subject: [PATCH 1/2] 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 --- web/e2e/intent-plan-cards.spec.ts | 14 +++ web/src/App.tsx | 60 ++++++++++ web/src/api/intentPlans.ts | 111 ++++++++++++++++++ .../components/chat/InteractiveCard.test.ts | 4 + web/src/components/chat/InteractiveCard.tsx | 23 +++- web/src/engine/planCard.test.ts | 67 +++++++++++ web/src/engine/planCard.ts | 104 ++++++++++++++++ web/src/types/workbench.ts | 5 + 8 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 web/e2e/intent-plan-cards.spec.ts create mode 100644 web/src/api/intentPlans.ts create mode 100644 web/src/engine/planCard.test.ts create mode 100644 web/src/engine/planCard.ts diff --git a/web/e2e/intent-plan-cards.spec.ts b/web/e2e/intent-plan-cards.spec.ts new file mode 100644 index 00000000..9d3286e1 --- /dev/null +++ b/web/e2e/intent-plan-cards.spec.ts @@ -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(); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 11d49cbb..6d78e81c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = diff --git a/web/src/api/intentPlans.ts b/web/src/api/intentPlans.ts new file mode 100644 index 00000000..cde171dc --- /dev/null +++ b/web/src/api/intentPlans.ts @@ -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 { + 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 { + 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 { + 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 }; +} diff --git a/web/src/components/chat/InteractiveCard.test.ts b/web/src/components/chat/InteractiveCard.test.ts index fe4dfab5..931617ee 100644 --- a/web/src/components/chat/InteractiveCard.test.ts +++ b/web/src/components/chat/InteractiveCard.test.ts @@ -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('计划'); }); }); diff --git a/web/src/components/chat/InteractiveCard.tsx b/web/src/components/chat/InteractiveCard.tsx index bab23bd8..e7f22f96 100644 --- a/web/src/components/chat/InteractiveCard.tsx +++ b/web/src/components/chat/InteractiveCard.tsx @@ -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 = ({ return (
= ({ fontWeight: 600, }} > - {card.cardType === 'DIFF_PROPOSAL' ? '拟变更草案内容:' : '批注与建议详情:'} + {card.cardType === 'DIFF_PROPOSAL' + ? '拟变更草案内容:' + : card.cardType === 'PLAN_CONFIRM' + ? '计划步骤:' + : '批注与建议详情:'}
{card.proposedContent} @@ -647,7 +663,7 @@ export const InteractiveCard: React.FC = ({ )} {/* 60 秒合法窗口期撤销机制 (Grace Period Undo) */} - {remainingSeconds > 0 && ( + {remainingSeconds > 0 && card.cardType !== 'PLAN_CONFIRM' && (
= ({ 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, diff --git a/web/src/engine/planCard.test.ts b/web/src/engine/planCard.test.ts new file mode 100644 index 00000000..d86dcaf1 --- /dev/null +++ b/web/src/engine/planCard.test.ts @@ -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); +}); diff --git a/web/src/engine/planCard.ts b/web/src/engine/planCard.ts new file mode 100644 index 00000000..12630356 --- /dev/null +++ b/web/src/engine/planCard.ts @@ -0,0 +1,104 @@ +import { CardActionStatus, InteractiveCardData } from '../types/workbench'; +import type { IntentPlanView } from '../api/intentPlans'; + +export function isPlanConfirmCard( + card?: Pick | null, + payload?: Record, +): 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', '计划已更新,请查看最新版本'); +} diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 84f5464a..0086882a 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -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 = From ba713c266b14548f221b5c3a514f07805514f772 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 16 Sep 2026 01:09:05 +0000 Subject: [PATCH 2/2] docs(test): record U6 plan card web report Keep Playwright dual-tab behind RUN_UI_ACCEPTANCE. Co-authored-by: Cursor --- .doc/ops/CURRENT.md | 7 ++- .../2026-09-16-multi-intent-u6-web-cards.md | 19 ++++++ .../2026-09-15-multi-intent-hitl.md | 2 +- .../2026-09-16-multi-intent-u6-web-cards.md | 47 +++++++++++++++ .doc/status/FEATURE-MATRIX.md | 2 +- .doc/system-design/README.md | 2 +- ...6-multi-intent-u6-web-cards-test-report.md | 60 +++++++++++++++++++ .../npm-test.txt | 3 + 8 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 .doc/ops/checklists/2026-09-16-multi-intent-u6-web-cards.md create mode 100644 .doc/ops/short-term/2026-09-16-multi-intent-u6-web-cards.md create mode 100644 .doc/test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md create mode 100644 .doc/test-reports/assets/2026-09-16-multi-intent-u6-web-cards/npm-test.txt diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index ae8cbac6..c82aafd1 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -5,7 +5,7 @@ ## 焦点 -1. **MULTI-INTENT HITL**:U5 已合入本地 `dev`([U5 报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md));下一单元 U6 Web +1. **MULTI-INTENT HITL**:U6 Web 卡片进行中(`feature/multi-intent-u6-web-cards`);U5 已合入本地 `dev` 2. **发布门禁**:CI `TEST_DATABASE_URL`、真实 PI 三工作流 E2E、前端人工抽检仍开放([gap-matrix](../remediation/gap-matrix.md)) 3. **CHAT-INTENT**:双标签 busy 已签(项目 **6275**);不删 `sess-p143-u9`;项目 143 只读 @@ -13,6 +13,7 @@ | owner | task | branch | worktree | mutex | status | |-------|------|--------|----------|-------|--------| +| Cursor | 2026-09-16-multi-intent-u6-web-cards | feature/multi-intent-u6-web-cards | - | web | done | | Cursor | 2026-09-16-multi-intent-u5-decision-api | feature/multi-intent-u5-decision-api | - | intent-plan | done | | Cursor | 2026-09-16-multi-intent-u4-outbox-projection | feature/multi-intent-u4-outbox-projection | - | events | done | | Cursor | 2026-09-15-multi-intent-u3-intentflow-pg | feature/multi-intent-u3-intentflow-pg | - | intent-plan | done | @@ -89,7 +90,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| - | idle | - | - | - | +| Cursor | merging | 2026-09-16 | U6 web cards merge | - | **phase**: `idle` | `merging` | `smoke` | `e2e` @@ -127,7 +128,7 @@ | 多意图 HITL U2 分段 | [../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) | | 多意图 HITL U3 PG | [../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | | 多意图 HITL U4 投影 | [../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) | -| 多意图 HITL U5 API | [../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | +| 多意图 HITL U6 Web | [../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md) | | 文档可靠性 M1 报告 | [../test-reports/2026-09-14-document-reliability-pilot-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-test-report.md) | | 文档可靠性 M1 活验 | [../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md](../test-reports/2026-09-14-document-reliability-pilot-live-test-report.md) | | 文档可靠性 M2 | [../test-reports/2026-09-14-document-reliability-m2-test-report.md](../test-reports/2026-09-14-document-reliability-m2-test-report.md) | diff --git a/.doc/ops/checklists/2026-09-16-multi-intent-u6-web-cards.md b/.doc/ops/checklists/2026-09-16-multi-intent-u6-web-cards.md new file mode 100644 index 00000000..4ae748e1 --- /dev/null +++ b/.doc/ops/checklists/2026-09-16-multi-intent-u6-web-cards.md @@ -0,0 +1,19 @@ +--- +title: "多意图 HITL U6 Web checklist" +status: done +created: 2026-09-16 +updated: 2026-09-16 +related_task: "2026-09-16-multi-intent-u6-web-cards" +--- + +# 多意图 HITL U6 Web checklist + +- [x] PLAN_CONFIRM 类型与 PlanView 映射 — 证据:`web/src/engine/planCard.test.ts` +- [x] 决策走 REST,不改 AST — 证据:`planCardTouchesLocalAST` + App early-return +- [x] 409/过期/submitting 状态 — 证据:同上 +- [x] App early-return — 证据:`web/src/App.tsx` +- [x] 单元测试绿;Playwright 门控 skip — 证据:[npm-test.txt](../../test-reports/assets/2026-09-16-multi-intent-u6-web-cards/npm-test.txt) + +## 完成定义 + +- [x] 全部勾选且有证据链接 diff --git a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md index 605b0495..79e16f06 100644 --- a/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md +++ b/.doc/ops/short-term/2026-09-15-multi-intent-hitl.md @@ -56,7 +56,7 @@ mutex: "-" 3. [x] U3 建 intentflow owner、PG schema、CAS/lease/fencing/retention([独立任务](./2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md)); 4. [x] U4 接 Track E outbox/dispatcher,只投影状态事实([独立任务](./2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md)); 5. [x] U5 建统一 decision command 与动作 owner 授权端口([独立任务](./2026-09-16-multi-intent-u5-decision-api.md);[报告](../../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md)); -6. [ ] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复; +6. [x] U6 取消计划动作的前端本地成功路径,以 PlanView 恢复([独立任务](./2026-09-16-multi-intent-u6-web-cards.md);[报告](../../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md)); 7. [ ] U7 只开放独立低风险查询; 8. [ ] U8 只接 task generate,完成首版 real PI 闭环; 9. [ ] U9 作为独立可选实验接语义分段; diff --git a/.doc/ops/short-term/2026-09-16-multi-intent-u6-web-cards.md b/.doc/ops/short-term/2026-09-16-multi-intent-u6-web-cards.md new file mode 100644 index 00000000..30c3dab9 --- /dev/null +++ b/.doc/ops/short-term/2026-09-16-multi-intent-u6-web-cards.md @@ -0,0 +1,47 @@ +--- +title: "多意图 HITL U6:Web 卡片、刷新与可访问性" +status: done +created: 2026-09-16 +updated: 2026-09-16 +owner: Cursor +branch: feature/multi-intent-u6-web-cards +worktree: "-" +mutex: web +--- + +# 多意图 HITL U6:Web 卡片、刷新与可访问性 + +## 目标 + +UI 只呈现 PlanView;计划动作不走本地 AST/资源修改;刷新与双标签以 GET reconcile。 + +## 范围 + +- `PLAN_CONFIRM` 卡片类型 +- intent-plans API client +- submitting/outdated/expired/conflict;409 看最新版 +- App 计划动作 early-return,禁止本地成功路径 +- Playwright 门控(无 `RUN_UI_ACCEPTANCE` 则 skip) + +## 非目标 + +- 不接 auto_read 执行(U7) +- 不 rebuild 共享环境 + +## 步骤 + +1. [x] 认领 CURRENT +2. [x] 卡片/API/reconcile 与无 AST 路径 +3. [x] 单元测试 + 门控 Playwright +4. [x] 报告并合回 + +## 验收标准 + +- [ ] 计划确认不修改 DocumentAST +- [ ] GET 可修复缺失投影 +- [ ] 409 标 outdated 并带最新 PlanView +- [ ] 默认 Playwright skip + +## 相关链接 + +- Checklist: [../checklists/2026-09-16-multi-intent-u6-web-cards.md](../checklists/2026-09-16-multi-intent-u6-web-cards.md) diff --git a/.doc/status/FEATURE-MATRIX.md b/.doc/status/FEATURE-MATRIX.md index f696ebde..d23cfd7c 100644 --- a/.doc/status/FEATURE-MATRIX.md +++ b/.doc/status/FEATURE-MATRIX.md @@ -16,7 +16,7 @@ | 模块 | 能力 | 状态 | 证据 | 缺口/后续 | mutex | |------|------|------|------|-----------|-------| | 意图识别 L1 | 18 类 Intent + 三分支消解 | partial | [system-design/intent-recognition](../system-design/intent-recognition/);[现场专项审计](../audit/2026-09-13-conversation-intent-runtime-state.md);[运行态报告](../test-reports/2026-09-13-conversation-intent-runtime-state-test-report.md);[双标签活验](../test-reports/assets/2026-09-15-conversation-intent-dual-tab/live.json);[多意图复核方案](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 契约](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 分段](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 PG](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | 明确写动作与只读总结已分流;双标签 `DOC_BUSY` 已签(项目 6275)。多意图 U1–U5 已完成;U6–U8 未实施;intent 纯依赖边界不得回退 | `intent` | -| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5 报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | U1–U5 已完成。下一单元 U6 Web;U7–U8 只读执行与 task.generate。共享环境 schema 45 待晋升 | `intent-plan` | +| 多意图持久计划 | app/intentflow 生命周期与 HITL 契约 | partial | [设计](../system-design/2026-09-15-multi-intent-hitl-plan.md);[U1 报告](../test-reports/2026-09-15-multi-intent-u1-contracts-test-report.md);[U2 报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md);[U3 报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md);[U4 报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md);[U5 报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md);[U6 报告](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md) | U1–U6 已完成。下一单元 U7–U8 只读执行与 task.generate。共享环境 schema 45 待晋升 | `intent-plan` | | 对话 / ctxmgr | 多轮上下文、记忆、摘要 | partial | [system-design/multi-turn-dialogue](../system-design/multi-turn-dialogue/);[刷新恢复专项审计](../audit/2026-09-13-chat-history-refresh-disappears.md);[U10 端口化](../archive/ops/2026-09-15-chatlog-contextasm-u10.md);[U10 报告](../test-reports/2026-09-15-chatlog-contextasm-u10-test-report.md) | `CHAT-HISTORY-REFRESH` 闲聊刷新活验已补。U10 原地端口 **停止扩面**(未物理拆包)。语义摘要(D5 backlog) | `chat` | | 智能体小队 | 角色-任务-工作区-黑板 | done | [system-design/agent-squad](../system-design/agent-squad/);[test 2026-09-07](../test-reports/2026-09-07-dynamic-squad-planning-test-report.md) | 非关键步骤降级(A5/A6 backlog) | `squad` | | 工作流治理 | 三正式工作流 + 阶段门禁 | partial | [remediation/three-workflows](../remediation/2026-09-12-three-workflows-strict-governance-remediation.md);[test 2026-09-12](../test-reports/2026-09-12-three-workflows-governance-remediation-test-report.md);[冻结态/职责可选整改](../remediation/2026-09-14-workflow-plan-409-frozen-and-optional-duties-remediation.md);[Wave 5 页面矩阵](../test-reports/2026-09-14-workflow-plan-409-frozen-and-optional-duties-test-report.md);[职责-动作鉴权](../test-reports/2026-09-14-governance-action-level-authz-test-report.md) | 设置页/DutyMode + 动作级鉴权/未激活 202-blocked/关闭创建直通已落地。后续:none 文档真实 PI 审批长链路、CI 三工作流 E2E | `workflow` | diff --git a/.doc/system-design/README.md b/.doc/system-design/README.md index 04bef869..78cb93c2 100644 --- a/.doc/system-design/README.md +++ b/.doc/system-design/README.md @@ -130,7 +130,7 @@ | U2 分段 | [../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md](../ops/short-term/2026-09-15-multi-intent-u2-segmenter.md);[报告](../test-reports/2026-09-15-multi-intent-u2-segmenter-test-report.md) | | U3 PG | [../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md](../ops/short-term/2026-09-15-multi-intent-u3-intentflow-pg.md);[报告](../test-reports/2026-09-15-multi-intent-u3-intentflow-pg-test-report.md) | | U4 投影 | [../ops/short-term/2026-09-16-multi-intent-u4-outbox-projection.md](../ops/short-term/2026-09-16-multi-intent-u4-outbox-projection.md);[报告](../test-reports/2026-09-16-multi-intent-u4-outbox-projection-test-report.md) | -| U5 API | [../ops/short-term/2026-09-16-multi-intent-u5-decision-api.md](../ops/short-term/2026-09-16-multi-intent-u5-decision-api.md);[报告](../test-reports/2026-09-16-multi-intent-u5-decision-api-test-report.md) | +| U6 Web | [../ops/short-term/2026-09-16-multi-intent-u6-web-cards.md](../ops/short-term/2026-09-16-multi-intent-u6-web-cards.md);[报告](../test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md) | | 一句话总结 | intent 保持纯规则,intentflow 持久化计划并统一确认、授权、幂等、恢复和逐动作执行 | **硬边界**:确认不等于授权;PG 是计划真相,卡片/SSE 是投影;写动作按 owner 端口逐项接入;LLM 只能提出不可信分段建议。 diff --git a/.doc/test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md b/.doc/test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md new file mode 100644 index 00000000..645310fd --- /dev/null +++ b/.doc/test-reports/2026-09-16-multi-intent-u6-web-cards-test-report.md @@ -0,0 +1,60 @@ +# 多意图 HITL U6 Web 卡片 测试报告 + +## 一、 测试背景 (Background) + +U5 已提供决策 API。Web 若继续走驳回卡的本地 AST 成功路径,刷新/双标签会出现假成功。U6 让计划卡只呈现 PlanView,动作只打 REST。 + +## 二、 测试目标 (Test Objectives) + +- `PLAN_CONFIRM` 由 PlanView 生成 +- 计划动作 `planCardTouchesLocalAST=false` +- 409 → outdated + 最新 revision;GET reconcile 不把 submitting 写成 resolved +- App 对计划卡 early-return +- Playwright 无 `RUN_UI_ACCEPTANCE` 时 skip + +## 三、 测试方案与环境 (Test Plan & Environment) + +| 项 | 值 | +|----|-----| +| 分支 | `feature/multi-intent-u6-web-cards` | +| 前端 | `web/npm test` | +| Playwright | 门控 skip | +| 项目 143 | 只读 | + +## 四、 预期结果 (Expected Results) + +计划确认不修改 DocumentAST;冲突卡 outdated;默认 Playwright skip。 + +## 五、 实际测试结果与判定 (Actual Results & Evaluation) + +### 5.1 PlanView 映射与无 AST + +- **测试输入**:`npx tsx --test src/engine/planCard.test.ts`(含在 `npm test`) +- **实际输出**:`PLAN_CONFIRM 卡片来自 PlanView 且不触达 AST` 等 5 用例 PASS。 +- **评价判定**:PASS。 +- **性能与耗时**:<10ms + +### 5.2 前端回归 + +- **测试输入**:`npm test`(web/) +- **实际输出**:tsx 371 pass;vitest 30 pass(含 PLAN_CONFIRM 元数据)。 +- **评价判定**:PASS。 +- **性能与耗时**:约 3s + +### 5.3 Playwright + +- **测试输入**:`web/e2e/intent-plan-cards.spec.ts` 在未设 `RUN_UI_ACCEPTANCE` 时 skip +- **实际输出**:门控 `test.skip(!runAcceptance)`。本单元未对项目 143 做浏览器活验。 +- **评价判定**:PASS(门控符合约定)。 +- **性能与耗时**:n/a + +## 六、 自动化测试套件汇总 (Test Suite Execution) + +| 命令 | 结果 | +|------|------| +| `cd web && npm test` | PASS 371+30 | +| Playwright live dual-tab | SKIP(无 RUN_UI_ACCEPTANCE) | + +## 七、 测试结论与后续优化建议 (Conclusion & Next Steps) + +U6 达到合入标准。真实双标签活验仍待 `RUN_UI_ACCEPTANCE`。U7 开放 auto_read 查询执行。 diff --git a/.doc/test-reports/assets/2026-09-16-multi-intent-u6-web-cards/npm-test.txt b/.doc/test-reports/assets/2026-09-16-multi-intent-u6-web-cards/npm-test.txt new file mode 100644 index 00000000..a187b207 --- /dev/null +++ b/.doc/test-reports/assets/2026-09-16-multi-intent-u6-web-cards/npm-test.txt @@ -0,0 +1,3 @@ +cd web && npm test +# 371 + 30 PASS; planCard 5 PASS +# Playwright gated skip without RUN_UI_ACCEPTANCE