merge: feature/multi-intent-u6-web-cards
This commit is contained in:
+4
-3
@@ -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) |
|
||||
|
||||
@@ -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] 全部勾选且有证据链接
|
||||
@@ -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 作为独立可选实验接语义分段;
|
||||
|
||||
@@ -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)
|
||||
@@ -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` |
|
||||
|
||||
@@ -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 只能提出不可信分段建议。
|
||||
|
||||
@@ -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 查询执行。
|
||||
@@ -0,0 +1,3 @@
|
||||
cd web && npm test
|
||||
# 371 + 30 PASS; planCard 5 PASS
|
||||
# Playwright gated skip without RUN_UI_ACCEPTANCE
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 =
|
||||
|
||||
@@ -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('计划');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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', '计划已更新,请查看最新版本');
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user