diff --git a/docs/exec-plans/agentui-implementation-progress.md b/docs/exec-plans/agentui-implementation-progress.md index 91e90d228..5b04d5c98 100644 --- a/docs/exec-plans/agentui-implementation-progress.md +++ b/docs/exec-plans/agentui-implementation-progress.md @@ -728,3 +728,45 @@ npx eslint "src/components/agent/chat/components/MessageList.tsx" "src/component ``` 结果:性能指标汇总单测通过,ESLint touched files 继续通过。 + +### 2026-04-30:P1 第十四刀,旧会话首帧延后 Markdown hydrate + +采集事实: + +- DevBridge `3030` 仍未监听,真实 Playwright 多旧会话切换暂不可测。 +- 当前机器已有 `tsc --noEmit` 与多条 `rustc` 高 CPU 进程,继续启动 Tauri / GUI smoke 会污染用户反馈的鼠标 loading 与 CPU 飙高采样。 +- 第十三刀已跳过旧会话首帧的 `contentParts` 细节扫描,但短历史 assistant 正文仍会进入 `StreamingRenderer -> MarkdownRenderer -> ReactMarkdown`,即使 `renderMode=light` 也会同步解析多条历史 Markdown。 +- 旧会话首帧的产品目标是“先看到消息文本与布局”,Markdown 标题、列表、代码高亮、表格等可等 idle 后恢复。 + +已完成: + +- `MessageList.tsx`: + - 增加 `MESSAGE_LIST_STRUCTURED_HISTORY_CONTENT_RE` 与 `hasStructuredHistoricalContentHint`,避免把 A2UI / write_file / document 这类结构化协议用纯文本提前露出。 + - 增加 `HistoricalMarkdownHydrationPreview`,旧会话首帧用 `whitespace-pre-wrap` 纯文本直接展示 assistant 正文,不挂载 `StreamingRenderer` / `ReactMarkdown`。 + - 增加 `historicalMarkdownDeferredCount` 指标,记录旧会话首帧被延后的 Markdown hydrate 数量。 + - 当 historical timeline idle-ready 后,自动恢复原有 `StreamingRenderer` / light Markdown 渲染。 +- `MessageList.test.tsx`:更新旧会话首帧回归,断言首帧出现 `message-list-historical-markdown-preview`、不挂载 `streaming-renderer`,idle 后移除 preview 并恢复 renderer。 +- `agentUiPerformanceMetrics.ts` / `.test.ts`:summary 增加 `historicalMarkdownDeferredMax`,方便 Playwright summary 直接读取 Markdown hydrate 延后命中量。 + +已验证: + +```bash +npm exec -- vitest run "src/components/agent/chat/components/MessageList.test.tsx" -t "旧会话首帧应延后历史助手 contentParts 与 Markdown 细节扫描|已分页旧会话首帧应只把尾部相关 turns 的 threadItems 纳入计算|旧会话消息较少但执行过程很多时也应延后构建 timeline|旧会话里的长助手回复应先展示纯文本预览" +npm exec -- vitest run "src/lib/agentUiPerformanceMetrics.test.ts" +npx eslint "src/components/agent/chat/components/MessageList.tsx" "src/components/agent/chat/components/MessageList.test.tsx" "src/lib/agentUiPerformanceMetrics.ts" "src/lib/agentUiPerformanceMetrics.test.ts" --max-warnings 0 +npm run bridge:health -- --timeout-ms 5000 +``` + +结果: + +- MessageList 定向回归:通过,`4` 个测试通过。 +- 性能指标汇总单测:通过,`2` 个测试通过。 +- ESLint touched files:通过。 +- DevBridge 健康检查:失败,`http://127.0.0.1:3030/health` 未监听。 +- TypeScript:本轮未追加全量 typecheck;本机已有其它 `tsc --noEmit` 高 CPU 进程在运行,避免重复启动。 + +下一步: + +1. DevBridge 恢复后复测真实旧会话,读取 `historicalMarkdownDeferredMax`、`historicalContentPartsDeferredMax`、`threadItemsScanDeferredCount`、`clickToMessageListPaintMs`、`longTaskMaxMs`。 +2. 若首帧仍慢,下一刀优先看 `visibleMessages.filter` / `buildMessageTurnGroups` 是否需要基于 sessionId 做更强 memo 或窗口化。 +3. 若首帧已快但 idle 后出现卡顿,把 Markdown hydrate / timeline hydrate 拆成分批 idle,而不是一次性恢复完整历史。 diff --git a/src-tauri/src/services/runtime_replay_case_service.rs b/src-tauri/src/services/runtime_replay_case_service.rs index 5a769d044..67d9066ea 100644 --- a/src-tauri/src/services/runtime_replay_case_service.rs +++ b/src-tauri/src/services/runtime_replay_case_service.rs @@ -688,6 +688,22 @@ fn build_success_criteria( "未命名合同" ) )); + let execution_profile_keys = + modality_contract_execution_profile_keys(modality_runtime_contracts); + if !execution_profile_keys.is_empty() { + criteria.push(format!( + "回放必须保留 execution profile 决策输入:{}。", + format_text_list(&execution_profile_keys, "未记录 execution profile") + )); + } + let executor_adapter_keys = + modality_contract_executor_adapter_keys(modality_runtime_contracts); + if !executor_adapter_keys.is_empty() { + criteria.push(format!( + "回放必须保留 executor adapter 绑定:{},不能退回自由工具选择或旧 CLI 旁路。", + format_text_list(&executor_adapter_keys, "未记录 executor adapter") + )); + } } if modality_contract_has_browser_control(modality_runtime_contracts) { criteria.push( @@ -914,6 +930,21 @@ fn build_modality_contract_checks(modality_runtime_contracts: &Value) -> Vec Vec Vec { + let mut values = + collect_unique_snapshot_strings(modality_runtime_contracts, "executionProfileKey"); + collect_unique_snapshot_strings_into( + modality_runtime_contracts, + "execution_profile_key", + &mut values, + ); + collect_unique_string_array_at_pointer( + modality_runtime_contracts, + "/snapshotIndex/executionProfileKeys", + &mut values, + ); + collect_unique_string_array_at_pointer( + modality_runtime_contracts, + "/snapshot_index/execution_profile_keys", + &mut values, + ); + values +} + +fn modality_contract_executor_adapter_keys(modality_runtime_contracts: &Value) -> Vec { + let mut values = + collect_unique_snapshot_strings(modality_runtime_contracts, "executorAdapterKey"); + collect_unique_snapshot_strings_into( + modality_runtime_contracts, + "executor_adapter_key", + &mut values, + ); + collect_unique_string_array_at_pointer( + modality_runtime_contracts, + "/snapshotIndex/executorAdapterKeys", + &mut values, + ); + collect_unique_string_array_at_pointer( + modality_runtime_contracts, + "/snapshot_index/executor_adapter_keys", + &mut values, + ); + values +} + fn modality_contract_has_browser_control(modality_runtime_contracts: &Value) -> bool { modality_contract_snapshots(modality_runtime_contracts) .iter() @@ -1724,16 +1803,37 @@ fn collect_unique_snapshot_strings( field_name: &str, ) -> Vec { let mut values = Vec::new(); + collect_unique_snapshot_strings_into(modality_runtime_contracts, field_name, &mut values); + values +} + +fn collect_unique_snapshot_strings_into( + modality_runtime_contracts: &Value, + field_name: &str, + values: &mut Vec, +) { for snapshot in modality_contract_snapshots(modality_runtime_contracts) { if let Some(value) = snapshot .get(field_name) .and_then(Value::as_str) .and_then(|value| normalize_optional_text(Some(value.to_string()))) { - push_unique_owned_tag(&mut values, value); + push_unique_owned_tag(values, value); + } + } +} + +fn collect_unique_string_array_at_pointer(value: &Value, pointer: &str, values: &mut Vec) { + if let Some(items) = value.pointer(pointer).and_then(Value::as_array) { + for item in items { + if let Some(value) = item + .as_str() + .and_then(|value| normalize_optional_text(Some(value.to_string()))) + { + push_unique_owned_tag(values, value); + } } } - values } fn format_text_list(values: &[String], fallback: &str) -> String { @@ -3253,6 +3353,12 @@ mod tests { "executor_binding": { "executor_kind": "skill", "binding_key": "research" + }, + "execution_profile": { + "profile_key": "web_research_profile" + }, + "executor_adapter": { + "adapter_key": "skill:research" } }, "entry_source": "at_report_command" @@ -3296,12 +3402,42 @@ mod tests { .and_then(Value::as_str), Some("executor_invoked") ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/executionProfileKey") + .and_then(Value::as_str), + Some("web_research_profile") + ); + assert_eq!( + input + .pointer("/runtimeContext/modalityRuntimeContracts/snapshots/0/executorAdapterKey") + .and_then(Value::as_str), + Some("skill:research") + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/executionProfileKeys/0", + ) + .and_then(Value::as_str), + Some("web_research_profile") + ); + assert_eq!( + input + .pointer( + "/runtimeContext/modalityRuntimeContracts/snapshotIndex/executorAdapterKeys/0", + ) + .and_then(Value::as_str), + Some("skill:research") + ); let suite_tags = input .pointer("/classification/suiteTags") .and_then(Value::as_array) .expect("suite tags"); for expected_tag in [ "modality-web_research", + "execution-profile", + "executor-adapter", "web-research", "research-skill", "web-research-trace", @@ -3317,12 +3453,16 @@ mod tests { assert!(expected.contains("Skill(report_generate)")); assert!(expected.contains("search_query")); assert!(expected.contains("lime_site_*")); + assert!(expected.contains("web_research_profile")); + assert!(expected.contains("skill:research")); assert!(expected.contains("model_memory_only_answer")); assert!(expected.contains("local_file_search_before_research_skill")); assert!(expected.contains("\"requiresHumanReview\": false")); let grader = fs::read_to_string(grader_path).expect("grader"); assert!(grader.contains("多模态运行合同检查")); + assert!(grader.contains("web_research_profile")); + assert!(grader.contains("skill:research")); assert!(grader.contains("Skill(research)")); assert!(grader.contains("Skill(report_generate)")); assert!(grader.contains("model_memory_only_answer")); @@ -3337,6 +3477,18 @@ mod tests { .and_then(Value::as_str), Some("web_research_skill_trace.modality_runtime_contract") ); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshotIndex/executionProfileKeys/0") + .and_then(Value::as_str), + Some("web_research_profile") + ); + assert_eq!( + links + .pointer("/modalityRuntimeContracts/snapshotIndex/executorAdapterKeys/0") + .and_then(Value::as_str), + Some("skill:research") + ); } #[test]