From 2cfeb9776364e11ddc507725cf193978021edbb8 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Wed, 10 Jun 2026 07:09:04 +0800 Subject: [PATCH] feat(page-ai): align ACP session runtime --- ...x-official-desktop-session-alignment-v1.md | 736 ++++++++++++++ ...hermes-web-control-surface-alignment-v1.md | 570 +++++++++++ .../browser/sidebar-page-ai-render-runtime.js | 1 + .../browser/sidebar-page-ai-runtime.js | 46 + .../sidebar-page-ai-session-runtime.js | 37 +- .../mnote-web/src/acp_session_manager.rs | 220 +++++ .../mnote-web/src/routes/hermes_client.rs | 925 +++++++++++++++++- rust/crates/mnote-web/src/routes/mod.rs | 4 + rust/crates/mnote-web/src/ssr/styles.rs | 15 +- ...-ai-reasonix-live-session-context-smoke.js | 218 +++++ ...ai-terminal-status-reconciliation-smoke.js | 187 ++++ ...ask560-page-ai-hermes-load-replay-smoke.js | 304 ++++++ ...task561-page-ai-session-dashboard-smoke.js | 162 +++ 13 files changed, 3377 insertions(+), 48 deletions(-) create mode 100644 design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md create mode 100644 design/07-ai/process/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md create mode 100644 scripts/task558-page-ai-reasonix-live-session-context-smoke.js create mode 100644 scripts/task559-page-ai-terminal-status-reconciliation-smoke.js create mode 100644 scripts/task560-page-ai-hermes-load-replay-smoke.js create mode 100644 scripts/task561-page-ai-session-dashboard-smoke.js diff --git a/design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md b/design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md new file mode 100644 index 00000000..d9069cdb --- /dev/null +++ b/design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md @@ -0,0 +1,736 @@ +# 7-58 Page AI Reasonix / Hermes 官方会话与响应对齐 v1 + +> 创建时间:2026-06-09 +> +> 当前状态:`PROCESS` +> +> Owner:07-ai / Page AI / Reasonix ACP / Hermes ACP / mnote-web runtime +> +> 参考代码: +> - Reasonix 官方 `main-v2/desktop`:https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2/desktop +> - Hermes 官方仓库:`NousResearch/hermes-agent`:https://github.com/NousResearch/hermes-agent +> - 本地参考副本:`reference-code/DeepSeek-Reasonix-main` +> - MNote 当前链路:`rust/crates/mnote-web/src/routes/hermes_client.rs`、`rust/crates/mnote-web/src/acp_session_manager.rs`、`rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js`、`rust/crates/mnote-web/src/ssr/styles.rs` +> +> 上位依据: +> - `design/07-ai/process/7-18-local-first-agent-file-editing-control-plane-v1.md` +> - `design/07-ai/done/7-40-page-ai-context-envelope-and-run-receipt-v1.md` +> - `design/10-review/reference/13-hermes-vscode-plugin-gap-review-v1.md` +> - `design/10-review/done/10-current-mnote-ai-runtime-review-v1.md` + +## 1. 第一结论 + +Page AI 里 Reasonix 不能继续按“每个 run 都可 `session/load` 恢复旧 ACP session”的模式设计。 + +Reasonix 官方 `main-v2/desktop` 的 ACP 模型是: + +- `initialize.agentCapabilities.loadSession=false`。 +- `session/new` 创建一个内存 `Session`,其中持有 `CacheFirstLoop`、toolset、MCP clients、Eventizer 和 aborter。 +- `session/prompt` 必须命中同一个内存 `Session`,然后调用 `session.loop.step(text)` 继续对话。 +- 上下文主要在 Reasonix 本地 loop / session 对象里延续,而不是靠 MNote 每轮重发完整聊天历史。 + +因此 MNote 对 Reasonix 的正确集成不是“保存 acpSessionId,下轮再 load”,而是:**同一个 MNote Page AI session 在同一个 Rust Web 进程生命周期内必须复用同一个活的 Reasonix ACP session manager;如果底层 Reasonix session 已丢失,必须显式进入 cold resume/fallback,不得假装仍是原生 Reasonix 上下文。** + +这也解释当前现象:用户第二轮只回“可以”时,MNote payload 没有带历史 messages;它本来就依赖 Reasonix 本地 session 记住上一轮。但实际后端每轮都 fallback 出新的 Reasonix ACP session,Reasonix 当然只能把“可以”当新会话开头。 + +## 2. 当前实测问题 + +2026-06-09 现场证据:同一个 MNote session `mnote_current_page-ai-mq6q24d5` 连续三轮 run,实际 Reasonix ACP session 发生了三次变化: + +| 轮次 | 用户输入 | 前端发送的 acpSessionId | 后端实际 session.info.updated acpSessionId | +| --- | --- | --- | --- | +| 1 | 看看你现在能不能看到有3个mcp | 空 | `19dbc6c6-b61e-45d1-8a28-52d0e79a02e3` | +| 2 | 可以 | `19dbc6c6-b61e-45d1-8a28-52d0e79a02e3` | `daa898ff-e985-49da-bbb9-ba3072f130c7` | +| 3 | 帮我检查一下 LightRAG 服务的启用状态或者帮它启动起来 | `daa898ff-e985-49da-bbb9-ba3072f130c7` | `ed358871-5176-4f3c-869c-91578cefa2b3` | + +并且第二轮 run payload 中没有 `messages` 字段,只有当前 `message="可以"`、`pageContext`、`contextRefs`、`acpSessionId` 等。这证明当前 Page AI 并没有 MNote 侧 transcript 注入兜底。 + +同一轮后端还出现大量: + +```text +WARN ACP SSE lagged: N events dropped +``` + +这说明 ACP event 转发路径有背压丢事件。当前事件路径会在 `broadcast` 接收后先持久化 SQLite / local journal,再写入浏览器 SSE;当 Reasonix 高频输出 `thought.delta` / `message.delta` 时,256 buffer 很容易落后。终止事件或状态同步被拖慢/丢失后,前端 assistant bubble 的 `streaming=true` 不能及时清掉,CSS 就继续显示 `AI · 输出中`。 + +当前 SQLite `ai_runtime_runs.status` 仍停在 `acp_pending`,但同 run 的 `ai_runtime_events` 已有 `run.completed`。这会继续污染 active-run 判断、恢复逻辑和前端状态。 + +## 3. Reasonix 官方模式摘录 + +### 3.1 ACP 能力 + +Reasonix 官方 `src/cli/commands/acp.ts` 在 `initialize` 中返回: + +```ts +agentCapabilities: { + loadSession: false, + promptCapabilities: { image: false, audio: false, embeddedContext: true }, + mcpCapabilities: { http: false, sse: false }, +} +``` + +含义:MNote 不能把 `session/load` 当成 Reasonix 的正常恢复入口。对于 Reasonix,`acpSessionId` 只在当前 ACP 进程内有效。 + +### 3.2 session/new + +Reasonix `session/new` 做的事不是只分配一个 id,而是完整构造一个 session: + +- resolve workspace root。 +- build code toolset。 +- load MCP servers,并把 MCP tools bridge 进 tool registry。 +- 构造 `ImmutablePrefix`。 +- 创建 `CacheFirstLoop`。 +- 创建 `Eventizer`。 +- 保存到内存 `sessions` map。 + +这就是 Reasonix 的上下文承载点。 + +### 3.3 session/prompt + +Reasonix `session/prompt` 通过 `params.sessionId` 找内存 session,然后: + +```ts +for await (const ev of session.loop.step(text)) { + for (const kev of session.eventizer.consume(ev, session.ctx)) { + dispatchKernelEvent(server, session.id, kev); + } +} +``` + +因此多轮对话是否连续,关键是 MNote 是否一直把后续 prompt 发给同一个 live Reasonix session。 + +### 3.4 Desktop UI 终止语义 + +Reasonix 官方 desktop controller 在 turn 结束时发送类似 `$turn_complete` 的终止事件,并在 finally 中清理 running 状态、刷新 sessions/balance。MNote Page AI 当前只靠 `run.completed` SSE 和前端 `streaming=false` 清理 UI,这条链路在 SSE lag/drop 下不够稳。 + +## 4. 目标架构 + +### 4.1 双层 session 模型 + +MNote 必须区分两层 session: + +| 层 | 作用 | 是否持久 | 对 Reasonix 的含义 | +| --- | --- | --- | --- | +| MNote Page AI session | UI 历史、SQLite/local JSONL、用户可见会话、run audit | 持久 | 可用于恢复 UI 和构造 fallback context | +| Reasonix ACP session | Reasonix 内存 `CacheFirstLoop`、MCP toolset、模型上下文 | 进程内 live | 不能假设可 load,丢失后就是 cold | + +新增/收口一个运行时绑定概念: + +```text +PageAiAcpLiveBinding + mnoteSessionId + acpRuntime = reasonix + acpSessionId + workspaceRoot / cwd + rootUri / allowedRootsHash + profile / model + managerHandle + status: idle | running | queued | cold | closed + lastRunId + lastEventSeq + createdAt / lastPromptAt +``` + +`managerHandle` 只在 Rust Web 进程内存中存在;SQLite 只保存 `acpSessionId` 和 metadata,用于 UI 展示、审计和检测 stale,而不是承诺可恢复 Reasonix 原生上下文。 + +### 4.2 Reasonix run 启动规则 + +对于 `acpRuntime=reasonix`: + +1. 先按 `mnoteSessionId + acpRuntime` 查 live binding。 +2. 若 binding 存在且 status 非 closed,直接复用同一个 `AcpSessionManager` 和 `acpSessionId` 调 `session/prompt`。 +3. 若 binding 不存在: + - 不调用或不依赖 `session/load`。 + - 调 `session/new` 创建新 Reasonix session。 + - 将新 `acpSessionId` 写回 MNote session metadata。 + - 若该 MNote session 已有历史 transcript,标记为 `cold_resume`,走 fallback context 注入。 +4. 当前 run 完成后,不关闭 manager,不从 live binding 中移除;只把 binding 状态改回 idle。 +5. 用户显式关闭会话、切换 workspace/root/model/profile、登出、进程退出或资源压力回收时,才关闭 binding。 + +这和当前 `run_prompt_with_mnote_context(...).await` 后直接 `mgr_clone.close().await`、`ACP_ACTIVE_RUNS.remove(...)` 的模式相反。对于 Reasonix,turn 完成不等于 Reasonix session 生命周期结束。 + +### 4.3 Hermes 与 Reasonix 分歧 + +不要把 Hermes VSCode 的 `session/load` 结论机械套到 Reasonix: + +- Hermes adapter 如果声明支持 `loadSession`,MNote 应继续优先 `session/load`。 +- Reasonix 官方明确 `loadSession=false`,MNote 应使用 live binding + cold resume fallback。 +- UI 上要能显示当前会话是否是 `native-live`、`cold-resumed`、`new-session`,避免用户误以为 Reasonix 原生上下文仍在。 + +### 4.4 Hermes Desktop / Web UI 对照 + +Hermes 参考实现反而证明:Hermes 与 Reasonix 的上下文工程必须分开设计,只能共享外层 MNote envelope / audit / UI projection。 + +Hermes 官方公开文档与本地参考包需要分层理解: + +- ACP adapter 的 `session/load` / `session/new` / `session/prompt` 属于 adapter 会话合同;host 不能只把旧 `sessionId` 写回内存后直接 prompt。 +- 官方 user guide 明确提醒:ACP session 列表、load、resume、fork 的范围以当前运行的 ACP server process 为准。也就是说,MNote 不能把 Hermes `session/load` 永久等同为跨进程必然成功。 +- Hermes VSCode 插件的 `SessionManager.ensureSession()` 说明了 host 侧正确动作:已有 `storedSessionId` 时必须先调用 ACP `session/load`,让 adapter 注册会话;`session/load` 返回 `null` 或失败后才能 `session/new`。插件注释还指出,跳过 `session/load` 会形成 phantom session。 +- Hermes Web / dashboard 路径体现的是 server / DB 级会话资产:`state.db` 中有 sessions、messages、conversation chain、search/detail/export 这类持久查询能力。这是 MNote 做会话管理和 history dashboard 的参考,但不能直接推导为 ACP adapter 任意时刻都能无损 replay。 +- 本地历史包 `hermes-web-ui-0.5.18` 的 Socket.IO server 体现了 Web/API chat 路径:`resumeSession()` 先找 live `sessionMap`,找不到再从 DB `loadSessionStateFromDb()` 读取 messages、compression snapshot 和 token 统计。这条路径不是 ACP `session/load`,应放在 Track W。 + +因此 Hermes 的正确集成不是“无条件相信 session id 可恢复”,而是:**按 runtime capability 探测 -> 有 stored `acpSessionId` 就先 `session/load` -> load 成功才认为底层 adapter 连续 -> load 失败进入 Hermes cold/new;若 adapter replay 了 history,MNote 必须消费、去重、排序。** + +Reasonix 官方 desktop/ACP 与这两者都不同:Reasonix ACP 声明 `loadSession=false`,上下文在当前进程内的 `CacheFirstLoop` 上。MNote 如果每轮关闭 `AcpSessionManager`,就等于每轮丢掉 Reasonix 原生上下文。 + +因此 MNote 后续应冻结三套 runtime policy: + +| Runtime | 上下文主承载 | 恢复策略 | MNote 责任 | +| --- | --- | --- | --- | +| Hermes ACP | adapter session;若 adapter 支持并成功 `session/load`,才视为底层连续 | stored ACP session id -> `session/load` -> 按 load 结果和 replay evidence 决定连续性 -> `session/prompt` | 保存/加载 acpSessionId;消费可能出现的 replay;处理 load 失败 fallback | +| Hermes Web/API Chat | Hermes server transcript + DB state | DB messages / compression snapshot -> resume socket/run | 管理 messages、summary、run status、queue | +| Reasonix ACP | live `CacheFirstLoop` in ACP process | live binding 复用;丢失后 cold resume packet | 维持 live binding,不假装 session/load 可用 | + +共享部分只包括: + +- MNote Page AI session / run id / trace id。 +- `agentRunEnvelope`、target package、allowed roots、capability policy。 +- SQLite/local JSONL audit。 +- 前端 projection:assistant final、thought、tool、plan、permission、citation、run status。 + +不共享的部分是:底层 agent session 生命周期、上下文恢复机制、是否重发 transcript、是否允许 `session/load`。 + +## 5. Context 工程设计 + +### 5.1 native-live 路径 + +当 Reasonix live binding 存在时: + +- MNote 每轮 prompt 只发送当前用户输入、当前页面/selection/target package、allowed roots、MNote capability envelope。 +- 不重发完整 transcript,避免破坏 Reasonix 自己的 prefix/cache/memory 设计。 +- MNote 仍持久化 user/assistant/tool events,用于 UI 历史、审计、fallback 和 reload。 + +### 5.2 cold resume fallback + +当 MNote Page AI session 有历史,但 Reasonix live binding 丢失时: + +- 创建新的 Reasonix ACP session。 +- 将 MNote 最近 N 轮 transcript、上一次 summary、当前 page context、allowed roots 组成一个明确的 `MNote cold resume context packet`。 +- 作为 embedded/context prompt 的前置内容发给 Reasonix。 +- 前端显示轻量状态:`Reasonix 原生会话已重建,已用 MNote 历史摘要恢复上下文`。 +- 该状态进入 run audit,不能静默伪装成原生连续会话。 + +fallback packet 必须控制大小: + +- 优先 summary。 +- 再补最近 3-5 轮 user/assistant final。 +- tool raw output 只放 tool name、result summary、changed files、citations,不放大段 stdout。 +- thought/reasoning 默认不进入 fallback。 + +### 5.3 用户短回复消歧 + +对“可以”“继续”“按刚才说的”“同意”这类短回复: + +- native-live:完全交给 Reasonix 原生上下文。 +- cold-resumed:fallback packet 必须包含上一轮 user intent 和 assistant proposal。 +- new-session:前端或后端应提示“当前 Reasonix 原生上下文不可用,短回复缺少指代对象”,不要让模型编造。 + +## 6. Response / UI 状态设计 + +### 6.1 事件分类 + +Reasonix event 到 MNote UI 的分类应对齐官方 desktop 的 projection 思路: + +| Reasonix / ACP 事件 | MNote 展示 | +| --- | --- | +| message/model delta | 当前 assistant streaming bubble | +| model final / run completed | 关闭 streaming bubble,写入 final | +| thought delta | 折叠的“思考过程”,不混入 final answer | +| tool intent/result | tool card,带 status、locations、citations、audit id | +| plan/todo | plan/status panel,不当作 assistant 正文 | +| turn complete | 解除 busy / 输出中 / active run snapshot | + +### 6.2 终止事件不可丢 + +`run.completed` / `run.failed` / `run.aborted` / synthetic `turn.complete` 属于不可丢事件。 + +即使中间 `thought.delta` 或 `message.delta` 被限流/合并,终止事件也必须送达浏览器并持久化。前端 `输出中` 只能由 active run status 和 streaming flag 共同决定: + +- 收到 terminal event:强制 `streaming=false`。 +- SSE 断开但后端 run 已 terminal:`/active-run` 或 `/runs/{id}/events` reconciliation 必须合成 terminal event。 +- SQLite run status terminal 时,active-run 不得再返回该 run。 + +### 6.3 状态持久化 + +当前 `ai_runtime_events` 已记录 `run.completed`,但 `ai_runtime_runs.status` 仍停在 `acp_pending`,这是 UI 卡 “输出中” 和 active-run 误判的根因之一。 + +后续必须保证: + +- `register_runtime_from_create_run_response` 初始化 run 为 `running` 或 `queued`,不要长期停在 `acp_pending`。 +- `persist_acp_runtime_event(run.completed)` 同步更新 SQLite `ai_runtime_runs.status=completed`。 +- `persist_acp_runtime_event(run.failed)` 同步更新 `failed`。 +- `persist_acp_runtime_event(run.aborted)` 同步更新 `aborted`。 +- 内存 registry 与 SQLite status 不一致时,以 terminal event journal 为 reconciliation truth,补写 run status。 + +## 7. SSE / 背压设计 + +当前 `broadcast` consumer 同时承担“持久化事件”和“转发浏览器 SSE”,导致高频 Reasonix 输出时 `ACP SSE lagged`。 + +目标拆分: + +```text +Reasonix ACP event + -> in-memory run event bus + -> browser SSE fanout, low latency, terminal priority + -> persistence worker, async/batched, can lag but不能阻塞 SSE + -> run status reconciler, terminal immediate +``` + +规则: + +- `thought.delta` 可合并、降采样或只持久化部分摘要。 +- `message.delta` 可按时间片或 token chunk 合并,保证顺序。 +- tool events、permission events、session.info、plan、terminal events 不降采样。 +- terminal event 使用独立 high-priority path;即使普通 delta buffer 满,也不能 drop。 +- persistence worker 失败只产生 audit warning,不应阻塞浏览器终止状态。 + +## 8. 后续执行总图 + +本设计后续不应拆成“先随手修 Reasonix,再顺手修 Hermes”。正确顺序是先冻结共享合同,再分别推进 Reasonix 和 Hermes 的 runtime policy,最后统一 UI projection 与 smoke。 + +```text +Phase 0: Runtime policy / event store / status reconciliation 共享底座 + -> Track R: Reasonix ACP live binding + cold resume + -> Track H: Hermes ACP session/load + replay 消费 + -> Track W: Hermes Web/API chat transcript resume 兼容 + -> Track U: Page AI UI / history / debug / smoke 矩阵 +``` + +### 8.1 共享底座:Runtime policy + +先在 MNote 侧抽象出明确的 runtime policy,而不是在 `hermes_client.rs` 里靠 `if acpRuntime == "reasonix"` 散落判断。 + +建议合同: + +```text +AgentRuntimePolicy + runtime: hermes | reasonix | chat_only | api_chat + contextCarrier: + - adapter_loadable_session + - adapter_live_loop + - server_transcript + supportsSessionLoad: bool + supportsHistoryReplay: bool + supportsForkListResume: bool + promptContinuity: + - native_live_required + - load_then_prompt + - transcript_rebuild + terminalSemantics: + - run.completed + - run.failed + - run.aborted + - synthetic.turn_complete +``` + +MNote Page AI 每次 run 必须先解析 policy,再决定: + +- 是否允许 `session/load`。 +- 是否应复用 live manager。 +- 是否需要从 SQLite/local JSONL 构造 cold resume packet。 +- 是否要接收 adapter replay history。 +- terminal event 如何更新 SQLite run status 和前端 active-run snapshot。 + +### 8.2 共享底座:Session 身份 + +统一命名并避免当前概念混用: + +| 字段 | 含义 | 生命周期 | +| --- | --- | --- | +| `mnoteSessionId` | MNote Page AI 会话 id,UI/history/audit 主键 | 持久 | +| `mnoteRunId` | MNote 一次 run id,events/status/audit 主键 | 持久 | +| `acpSessionId` | adapter 返回的底层 session id | runtime 相关 | +| `runtimeBindingId` | MNote 内存 runtime binding id | 进程内 | +| `providerConversationId` | ChatOnly 网页 provider 远端会话 id | provider 相关 | + +SQLite `ai_runtime_runs` 必须保存这些字段的快照,但只有 `mnoteSessionId` / `mnoteRunId` 是 MNote 自己的长期真相。`acpSessionId` 对 Hermes 是可 load 引用,对 Reasonix 只是 live binding 标识,不代表可恢复。 + +### 8.3 共享底座:Event store / terminal reconciliation + +先修这个底座,否则 Reasonix 和 Hermes 都会继续被 `输出中`、active-run stale、历史恢复误判影响。 + +- [ ] browser SSE 转发与 persistence 解耦:浏览器低延迟 fanout,不在同一 consumer 里同步写 SQLite。 +- [ ] terminal event 高优先级:`run.completed` / `run.failed` / `run.aborted` / synthetic `turn.complete` 不可被 delta 洪峰挤掉。 +- [ ] `persist_acp_runtime_event` 或独立 reconciler 在 terminal event 到达时同步更新 `ai_runtime_runs.status`。 +- [ ] `/api/page-ai/sessions/{id}/active-run` 不得返回已有 terminal event 的 run。 +- [ ] `/api/page-ai/runs/{id}/events` 能在 run status terminal 但缺 terminal event 时合成 reconciliation event。 +- [ ] 前端收到 terminal / reconciliation terminal 后强制清理 `streaming=true`、`pageAiBusy`、active run snapshot。 + +### 8.4 共享底座:Response projection + +所有 runtime 的可见输出投影统一,但事件来源不统一: + +- assistant final:只来自 message/model delta + final output。 +- thought:只进折叠思考过程,默认不展开,不混入 final。 +- tool:独立 tool card,保留 status、toolCallId、locations、citations、auditId。 +- plan/todo:独立 plan/status panel,不当作 assistant 正文。 +- permission:独立权限卡,用户决策必须回写 adapter。 +- citation:优先 tool 返回的 `citationMarkdown` / `citationUrl`,不让模型手拼。 +- busy/输出中:只由 active run + streaming bubble 共同决定,terminal 后立即清除。 + +## 9. Track R:Reasonix ACP + +### R0:官方模式冻结 + +- [ ] fake Reasonix ACP fixture:`initialize.loadSession=false`、`session/new` 创建内存 loop、`session/prompt` 必须命中同一 session。 +- [ ] 在测试中证明:如果 MNote 每轮新建 session,短回复“可以”无法关联上一轮;如果复用 live binding,可以关联。 +- [ ] 在 runtime payload / SQLite detail 中暴露 `reasonixSessionMode: native_live | cold_resumed | new_session`。 + +### R1:Live binding pool + +- [ ] `AcpRuntimeManager` / `hermes_client` 增加按 `mnoteSessionId + acpRuntime` 复用的 Reasonix live binding pool。 +- [ ] Reasonix run 完成后不关闭 manager,不移除 binding,只从 running 改 idle。 +- [ ] 同一 binding 内 run 串行执行;第二条输入进入 binding queue,不另开 session。 +- [ ] Stop/abort 只取消当前 prompt,不默认销毁 binding。 +- [ ] 切 workspace/root/model/profile/allowedRootsHash 时关闭旧 binding,并把下一轮标记为 cold/new。 + +### R2:Cold resume fallback + +- [ ] 从 SQLite/local JSONL 构造 bounded transcript summary。 +- [ ] cold resume 首轮 prompt 注入 `MNote cold resume context packet`。 +- [ ] packet 只包含 summary、最近 3-5 轮 user/assistant final、tool summary、changedFiles、citations,不包含 thought 大段文本。 +- [ ] UI 显示 cold resume 状态,不误导为 Reasonix 原生会话。 +- [ ] `new_session` 且用户只发短回复时提示缺上下文,不让模型编造。 + +### R3:Reasonix 工具与 MCP 能力 + +- [ ] 确认 `mnoteCapabilities` / runtime manifest 进入 Reasonix 模型可见 prompt,而不只是 Rust payload。 +- [ ] 继续保持 Reasonix 原生文件读写为 local-first 普通 Markdown 主路径;MNote 不扩写一套本地文件编辑工具。 +- [ ] MCP 状态作为 Reasonix 工具可见事实进入 tool/status card,而不是让模型只凭自然语言猜测。 +- [ ] LightRAG / MNote bridge MCP 失败时返回结构化 tool failed,不污染 assistant final。 + +## 10. Track H:Hermes ACP + +### H0:官方 load 合同冻结 + +- [ ] fake Hermes ACP fixture:`initialize.loadSession=true`,支持 `session/load`;另准备一个会 replay history `session/update` 的变体。 +- [ ] MNote 启动 Hermes run 时读取 stored `acpSessionId`,先 `session/load`,再 `session/prompt`。 +- [ ] `session/load` 成功时,MNote 认为 adapter 当前已注册该 session;如果 adapter replay 出 user/assistant/thought/tool/plan,前端/后端必须消费并去重,不能覆盖乱序。 +- [ ] `session/load` 返回 `null` 或失败时,标记 `hermesSessionMode=cold_resumed | new_session`,不假装原生连续。 + +### H1:Hermes session store 对齐 + +Hermes Web / dashboard 和部分本地服务有 `state.db` 级 session store。MNote 不应复制 Hermes DB,也不能把 Web DB 语义直接套到 ACP adapter;但要保存映射与审计: + +- [ ] SQLite `ai_runtime_runs/session metadata` 保存 `acpSessionId`、Hermes profile、cwd/rootUri、lastLoadedAt、lastReplaySeq。 +- [ ] MNote session detail 能展示底层 Hermes session id 和 load/replay 状态。 +- [ ] 删除 MNote Page AI session 时,默认只删除 MNote history/audit;是否调用 Hermes delete/remove 需要单独确认,不默认删除 Hermes 外部 session store。 +- [ ] 恢复历史会话时,区分“只恢复 MNote UI 历史”和“恢复 Hermes 底层 ACP session”。 + +### H2:Hermes replay 消费 + +部分 Hermes adapter 或 Web/API resume 路径可能 replay 历史。MNote 当前已有自己的 UI history,因此只要收到 replay,就必须做去重和排序: + +- [ ] replay event 带 `source=adapter_replay`,不能当作新的当前 run 输出。 +- [ ] replay user/assistant/tool 与 MNote 已有消息按 content/run/time 去重。 +- [ ] replay tool start/completed 能恢复 tool card 状态,但不触发新的写入刷新。 +- [ ] replay plan/todo 恢复 plan panel,但不混入 assistant final。 +- [ ] replay 完成后再允许当前 prompt streaming bubble 开始。 + +### H3:Hermes 功能面 + +- [ ] 支持 Hermes ACP `session/list` / `session/resume` / `session/fork` 的能力探测,先展示为 debug/高级入口,不默认替代 MNote session list。 +- [ ] 权限请求沿 Hermes ACP decision loop 回传,不做前端假 allow。 +- [ ] tool `locations[]` 保留并能打开 MNote resource tab。 +- [ ] image prompt capability 只在 MNote target/attachment 明确支持时开启,不把图片 silently 变成文本。 + +## 11. Track W:Hermes Web/API Chat + +Hermes 官方 Web UI 是 dashboard,不是 MNote Page AI 的直接聊天实现。但它提供两个有用原则:session 是可查询/搜索/导出的持久资产;server 端负责 auth、session token 和 API 管理。 + +### W0:ChatOnly / API Chat 边界 + +- [ ] ChatOnly / API Chat 继续走 provider conversation 或 MNote server transcript,不复用 ACP session/load 设计。 +- [ ] providerConversationId 只属于网页 provider,不与 `acpSessionId` 混用。 +- [ ] API Chat 不写网页 provider conversation binding。 +- [ ] MNote UI history 和 provider 远端历史删除语义分开。 + +### W1:Session dashboard 能力 + +参考 Hermes Web UI 的 sessions dashboard,MNote Page AI 可补以下管理能力: + +- [ ] session list 展示 runtime、profile、message count、tool count、active/cold/new 状态。 +- [ ] session detail 展示 user/assistant/tool/plan/citation 的投影,而不是 raw event dump。 +- [ ] session search 支持按用户问题、assistant final、tool name、citation file 搜索。 +- [ ] export session JSON/Markdown 包含 MNote session id、runs、events、runtime binding summary。 +- [ ] rename/delete/bulk delete 明确只作用 MNote session,外部 provider/Hermes DB 删除需单独入口。 + +## 12. Track U:UI 与浏览器验证 + +### U0:可见状态 + +- [ ] Page AI header/debug surface 显示 runtime、profile、MNote session id、acpSessionId、sessionMode、active run status。 +- [ ] `native_live`、`cold_resumed`、`new_session` 用小状态标签表示,不占用 assistant 正文。 +- [ ] `输出中` 只在 run active 且 assistant bubble streaming 时显示;terminal 后必须消失。 + +### U1:Smoke 脚本 + +- [ ] `task558-page-ai-reasonix-live-session-context-smoke.js`:两轮 Reasonix,同一 acpSessionId,第二轮“可以”能引用上一轮。 +- [ ] `task559-page-ai-terminal-status-reconciliation-smoke.js`:高频 thought/message delta 后 terminal 不丢,SQLite status terminal,UI 无输出中。 +- [ ] `task560-page-ai-hermes-load-replay-smoke.js`:Hermes stored acpSessionId -> session/load;覆盖无 replay 与有 replay 两种变体 -> 当前 prompt。 +- [ ] `task561-page-ai-session-dashboard-smoke.js`:session list/detail/search/export/rename/delete 的最小可见验证。 + +### U2:回归矩阵 + +- [ ] Reasonix live binding 不破坏 Hermes `session/load`。 +- [ ] Hermes replay 不污染 Reasonix live transcript。 +- [ ] ChatOnly provider conversation binding 不受 ACP binding pool 影响。 +- [ ] local-folder allowed roots / readonly guard / agent audit 对所有 runtime 生效。 +- [ ] tool write receipt 仍通过 watcher/event bus 刷新当前文档,不重复全量 sidebar refresh。 + +## 13. 建议执行顺序 + +### 13.1 第一批:先修共性底座 + +1. Event store / terminal reconciliation。 +2. Runtime policy 表和 session 身份字段梳理。 +3. 前端 `输出中` / active-run 清理。 + +这批完成后,Reasonix 和 Hermes 后续调试不会继续被 stale status 干扰。 + +### 13.2 第二批:Reasonix 原生连续会话 + +1. Reasonix fake ACP fixture。 +2. Reasonix live binding pool。 +3. Reasonix 两轮“可以” browser smoke。 +4. Cold resume fallback。 + +这批完成后,解决当前用户可见的上下文断裂。 + +### 13.3 第三批:Hermes 官方 load/replay + +1. Hermes fake ACP fixture。 +2. `session/load` replay 消费与去重。 +3. Hermes session metadata/debug。 +4. Hermes load/replay browser smoke。 + +这批完成后,Hermes 与 Reasonix 的上下文机制真正分开。 + +### 13.4 第四批:会话管理与其它功能 + +1. Page AI session dashboard:list/detail/search/export/rename/delete。 +2. Tool locations/citations/open action 统一。 +3. permission decision loop 复核。 +4. model/profile/skills/MCP 状态面板对齐。 + +## 14. 验收矩阵 + +### 14.1 单元 / 集成测试 + +- [ ] fake Reasonix ACP:`loadSession=false`,同一 MNote session 连续两轮只调用一次 `session/new`,第二轮仍用同一 `acpSessionId`。 +- [ ] fake Reasonix ACP:第二轮输入“可以”,adapter 可读到上一轮 loop 历史,回答能引用上一轮提议。 +- [ ] fake Hermes ACP:`loadSession=true`,`session/load` 成功后可 prompt;replay 变体中 MNote 能消费且去重。 +- [ ] manager 丢失后 cold resume:后端创建新 ACP session,并注入 bounded MNote context packet。 +- [ ] SQLite status reconciliation:有 `run.completed` event 后,`ai_runtime_runs.status` 必为 `completed`。 +- [ ] 高频 delta:3000 条 `thought.delta` + terminal,不丢 terminal,前端最终无 `data-page-ai-streaming="true"`。 + +### 14.2 浏览器 smoke + +- [ ] Reasonix:打开 Page AI,发送“我下一条只说可以,你要根据这条消息继续检查 LightRAG 服务”。 +- [ ] Reasonix:第二轮发送“可以”。 +- [ ] Reasonix:捕获 `/api/hermes/client/runs` 和 `session.info.updated`,两轮实际 `acpSessionId` 相同。 +- [ ] Reasonix:第二轮可见回答能理解“可以”指上一轮提议,不要求用户重新说明任务。 +- [ ] Hermes:恢复已有 MNote Page AI session 后,先调用 `session/load`;若收到 replay,去重后再开始当前 prompt。 +- [ ] 通用:回答完成后页面不显示 `AI · 输出中`。 +- [ ] 通用:SQLite `ai_runtime_runs` 对所有 terminal run 都进入 terminal status。 + +### 14.3 回归边界 + +- [ ] Hermes runtime 仍可走 `session/load`,不被 Reasonix special case 破坏。 +- [ ] Reasonix runtime 不调用或不依赖 `session/load`。 +- [ ] ChatOnly provider conversation binding 不受 Reasonix live binding / Hermes load replay 影响。 +- [ ] local-folder allowed roots / readonly guard / agent audit 仍按现有 Page AI envelope 生效。 +- [ ] Stop/abort 能取消当前 prompt,不误删可复用 session;需要销毁 session 时必须有显式 close/reset 语义。 + +## 15. 不做事项 + +- 不让 MNote 为 Reasonix 重写一套长期 agent memory。 +- 不把完整 transcript 每轮强塞给 native-live Reasonix,避免破坏 Reasonix 官方 prefix/cache 设计。 +- 不把 `mnote.doc.*` 工具扩成 local-first 普通 Markdown 主路径。 +- 不把 Reasonix 官方 desktop 的 React/Wails UI 结构照搬到 MNote;只对齐 session、event、busy/turn-complete 和 response projection 语义。 +- 不继续把 `session/load` 失败当成普通 fallback 成功;对 Reasonix 这是上下文断裂事件,必须可见、可审计。 + +## 16. 当前建议优先级 + +优先做 `13.1 第一批:共性底座`,再做 `13.2 Reasonix 原生连续会话`。 + +原因: + +- 共性底座直接修复 `ACP SSE lagged`、SQLite status stale、active-run stale 和 `输出中` 卡住;不先修它,Reasonix/Hermes 的会话验证会继续被假状态干扰。 +- Reasonix live binding 直接修复“回复可以不理解上一轮”的根因。 +- Hermes `session/load` / replay 应在底座稳定后推进,否则 replay 历史和当前 streaming 输出容易继续混在同一条 UI/event 路径里。 +- cold resume 是必要兜底,但不能替代 native-live;如果先做 transcript 注入,容易把 Reasonix 官方 session 模型继续绕坏。 + +## 17. 文件级实施映射 + +本节用于把上面的架构拆到真实 owner,避免后续实现继续在单个 route 中临时判断。 + +### 17.1 后端 owner + +| 文件 | 当前职责 | 后续改动边界 | +| --- | --- | --- | +| `rust/crates/mnote-web/src/routes/hermes_client.rs` | Page AI run/create/stream、ACP active run、SQLite runtime event 持久化、active-run API | 第一批只改 terminal reconciliation、active-run 过滤、runtime policy 分派入口;不要把 Reasonix/Hermes 细节继续散落到所有分支 | +| `rust/crates/mnote-web/src/acp_session_manager.rs` | ACP `session/new`、`session/load`、`session/prompt`、event mapping | 增加 capability-aware `ensure_session_with_policy` 或等价接口;Reasonix policy 下不得尝试 `session/load` | +| `rust/crates/mnote-web/src/acp_runtime.rs` | runtime config、process switch、active client 生命周期 | 当前只有一个 active runtime process;若支持多 live binding,需要明确是否仍共享同一 process,还是按 runtime/profile/workspace 拆 manager pool | +| `rust/crates/mnote-web/src/acp_bridge.rs` | ACP event -> SSE event projection | 补 terminal priority、thought/message delta 限流策略、tool/permission/plan 分类,避免 UI 和持久化各自猜事件语义 | +| `rust/crates/mnote-web/src/routes/mod.rs` | Page AI route 注册 | 只在新增 debug/session dashboard API 时扩展,不放业务逻辑 | + +### 17.2 前端 owner + +| 文件 | 当前职责 | 后续改动边界 | +| --- | --- | --- | +| `rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js` | Page AI 输入、run streaming、event 消费、active run snapshot | terminal event 后强制清理 streaming/busy;显示 sessionMode/debug;不要把 thought/tool/plan 拼进 final | +| `rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js` | session list/history/active-run 恢复 | active-run API 返回 stale 时要忽略 terminal run;后续接 session dashboard/detail/search/export | +| `rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js` | 消息和 tool card 渲染 | `native_live/cold_resumed/new_session` 用状态标签,不占 assistant 正文;输出中只从 streaming attr 得出 | +| `rust/crates/mnote-web/src/ssr/styles.rs` | `AI · 输出中` 等可见样式 | 只保留样式,不承担状态判断;状态必须来自 JS runtime 和后端 terminal truth | + +### 17.3 测试 / fixture owner + +| 文件或建议文件 | 目的 | +| --- | --- | +| `rust/crates/mnote-web/src/acp_session_manager.rs` tests | fake Reasonix/Hermes ACP 的 `session/load` / `session/new` / `session/prompt` 单元合同 | +| `scripts/task558-page-ai-reasonix-live-session-context-smoke.js` | 浏览器两轮 Reasonix,上下文连续、同一底层 session、短回复可解析 | +| `scripts/task559-page-ai-terminal-status-reconciliation-smoke.js` | 高频 delta 后 terminal 不丢、SQLite terminal、UI 无输出中 | +| `scripts/task560-page-ai-hermes-load-replay-smoke.js` | Hermes stored acpSessionId -> load;覆盖无 replay / 有 replay 两种变体 -> 当前 prompt | +| `scripts/task561-page-ai-session-dashboard-smoke.js` | session list/detail/search/export/rename/delete 可见闭环 | + +## 18. Runtime 状态机与不变量 + +### 18.1 Run 状态机 + +```text +created + -> queued + -> running + -> tool_calling + -> running + -> completed | failed | aborted + +terminal = completed | failed | aborted +``` + +不变量: + +- `ai_runtime_events` 中出现 terminal event 后,`ai_runtime_runs.status` 必须在同一事务或 reconciliation pass 中进入 terminal。 +- `/active-run` 只能返回非 terminal run;如果 SQLite status 非 terminal 但 event journal 已 terminal,API 必须先补状态再返回空。 +- 前端收到 terminal 后必须清除对应 assistant bubble 的 `streaming=true`,即使最后一段 message delta 没到。 +- `acp_pending` 只能是创建到 SSE 建立前的短暂状态,不得作为完成后残留状态。 + +### 18.2 Reasonix binding 状态机 + +## 19. 完成记录(2026-06-10) + +本稿已按 `13.1` 到 `13.4` 的顺序完成可执行闭环,代码与验证证据如下: + +- 第一批共性底座:已实现 runtime policy、session 身份字段、terminal reconciliation、active-run 终态过滤、`/runs/{id}/events` synthetic terminal、前端 `streaming` / `输出中` 强制清理。验证:`page_ai_session_active_run*`、`acp_runtime_event_persistence_reconciles_terminal_status`、`task559-page-ai-terminal-status-reconciliation-smoke.js`。 +- 第二批 Reasonix:已实现 `loadSession=false` fake fixture、live binding pool、Reasonix run 后不关闭 manager、同一 MNote session 二轮复用同一 `acpSessionId`、cold resume packet。验证:`test_reasonix_live_session_keeps_short_reply_context`、`task558-page-ai-reasonix-live-session-context-smoke.js`、`page-ai-browser-verify` Reasonix clean reply。 +- 第三批 Hermes:已保留 Hermes `session/load` 路径,显式 runtime config 不再被 `MNOTE_WEB_HERMES_BIN` 覆盖;已捕获 `session/load` 期间 adapter replay,标记 `source=adapter_replay` 并在当前 streaming bubble 中去重/忽略。验证:`test_ensure_session_loads_stored`、`test_hermes_load_replay_events_before_prompt`、`task560-page-ai-hermes-load-replay-smoke.js` 覆盖 no replay / replay。 +- 第四批 session dashboard 与状态面板:既有 history UI 的 list/detail/search/rename/delete 已复核;本轮新增 export API 与 UI 操作,修复 history action 点击遮挡;runtime/profile/session/acpSessionId/status 继续由 Page AI header/debug surface 和 session row meta 展示。验证:`hermes_client_acp_session_export_uses_sqlite_store`、`task561-page-ai-session-dashboard-smoke.js`。 +- ChatOnly / API Chat 边界:ACP binding pool 与 Hermes load/replay 未复用 provider conversation binding。验证:`cargo test -p mnote-web api_chat`、`cargo test -p mnote-web chatonly_doubao_session_delete`。 + +逐项归档说明: + +- `session/list` / `session/resume` / `session/fork` 的 Hermes ACP 高级入口保持为 debug/future 能力探测,不替代 MNote session list;本轮完成的是 stored `acpSessionId -> session/load -> prompt` 的官方必需路径。 +- 外部 Hermes DB / provider 远端删除未自动执行;删除 MNote Page AI session 仍只默认删除/标记 MNote history 与 provider binding,外部 store 删除需要单独确认。 +- permission decision loop、tool locations/citations、allowed roots、readonly guard、tool write receipt 属于既有 Page AI envelope/tool projection 路径,本轮只复核不改写;后续若专项扩展需另开设计,不继续堆入 7-58。 +- 流式 apply / review session、Reasonix 长期 memory、每轮完整 transcript 注入明确不做,保持 `7-18` local-first agent file editing 主线。 + +状态:`DONE`。本文件可移入 `design/07-ai/done/`。 + +```text +missing + -> new_session + -> native_live.idle + -> native_live.running + -> native_live.idle + -> cold_required | closed +``` + +不变量: + +- 同一 `mnoteSessionId + runtime + profile + workspaceKey + allowedRootsHash` 同时最多一个 running prompt。 +- prompt 完成只释放 run,不销毁 Reasonix ACP session manager。 +- abort 只取消当前 prompt;reset/close 才销毁 binding。 +- workspace/root/model/profile/allowedRootsHash 变化必须关闭旧 binding,并把下一轮标记为 `cold_resumed` 或 `new_session`。 + +### 18.3 Hermes ACP 状态机 + +```text +missing + -> has_stored_acp_session_id + -> load_attempted + -> adapter_loaded | load_failed + -> prompt_running + -> completed | failed | aborted +``` + +不变量: + +- 有 stored `acpSessionId` 时必须先 `session/load`,不能跳过 load 直接 prompt。 +- `session/load` 成功只表示 adapter 当前承认该 session;是否 replay history 由事件流证据确认。 +- replay event 必须标记 `source=adapter_replay` 或等价来源,不能当作当前 run 的新输出。 +- load 失败不是普通成功路径,应进入 `hermesSessionMode=cold_resumed | new_session` 并写入 audit。 + +### 18.4 Hermes Web/API Chat 状态机 + +```text +providerConversationId | serverSessionId + -> load server transcript / compression snapshot + -> append current user message + -> stream provider response + -> flush messages/status +``` + +不变量: + +- Web/API Chat 不复用 ACP `session/load`。 +- providerConversationId 不得写入 `acpSessionId` 字段。 +- 删除 MNote UI session 不默认删除 provider 或 Hermes 官方 DB 中的会话资产。 + +## 19. 风险登记 + +| 风险 | 影响 | 控制方式 | +| --- | --- | --- | +| 把 Reasonix 做成 transcript replay | 破坏 Reasonix `CacheFirstLoop` / prefix / cache 语义,短期看似恢复,长期上下文和成本异常 | native-live 优先;cold resume 必须显式标记且 bounded | +| 把 Hermes `session/load` 写成必然成功 | 进程重启、adapter 清理或 profile 变化后产生 phantom continuity | capability + load result + replay evidence 三重判断 | +| terminal event 与 delta 共用可丢 buffer | UI 卡 `输出中`、active-run stale、历史恢复错判 | terminal high-priority path + status reconciler | +| replay history 与 MNote history 重复 | 用户看到重复消息、tool card 重复执行感 | replay source 标记、content/run/time 去重、replay 完成后再开当前 bubble | +| 多 runtime 共用一个 active process manager | 切 profile/runtime 时杀掉别的 live session | 明确 process pool 设计;Reasonix binding 与 Hermes load 不共用生命周期假设 | +| 删除语义混淆 | 删除 MNote session 时误删 Hermes DB 或 provider 远端历史 | UI 和 API 分开 MNote-only delete 与 external delete | +| 代码中已有未完成 scaffold | 后续实现误以为 live binding 已完成,或产生重复 static / 不可编译 | 第一批实现前先跑 `cargo test -p mnote-web`,清点当前未提交改动归属,不回滚用户改动 | + +## 20. 第一批落地切片 + +第一批只处理共享底座,不碰 Reasonix live binding 的完整生命周期,也不做 Hermes replay UI。目标是让 run 状态可信。 + +### 20.1 后端切片 A:terminal reconciliation + +- 输入:任意 runtime 的 `run.completed` / `run.failed` / `run.aborted` event。 +- 输出:`ai_runtime_runs.status` 同步进入 terminal,`status_reason` 写入 terminal event 名称或 error code。 +- 改动点:`persist_acp_runtime_event`、`update_runtime_by_run_id`、`/active-run` 查询路径。 +- 验收:构造 `ai_runtime_events` 已 terminal 但 `ai_runtime_runs.status=acp_pending` 的 fixture,active-run 返回空并补写 status。 + +### 20.2 后端切片 B:SSE terminal priority + +- 输入:高频 `thought.delta` / `message.delta` + terminal。 +- 输出:普通 delta 可合并或丢摘要,terminal 必达浏览器和 event store。 +- 改动点:ACP event fanout/persistence worker;如果短期不能拆 worker,至少让 terminal 事件绕过可 lag 的普通 receiver。 +- 验收:3000 条 delta 后仍收到 terminal,日志不再出现 terminal 丢失导致的 stale run。 + +### 20.3 前端切片 C:输出中清理 + +- 输入:terminal SSE、reconciliation terminal、active-run 空结果。 +- 输出:对应 assistant bubble `streaming=false`,document root 移除 active-run snapshot/busy 标记。 +- 改动点:`sidebar-page-ai-runtime.js`、`sidebar-page-ai-session-runtime.js`。 +- 验收:回答完成后 DOM 不存在 `data-page-ai-streaming="true"`,`.wolai-page-ai-message-role::after` 不再显示 `AI · 输出中`。 + +### 20.4 文档归档条件 + +本设计留在 `process/`,直到至少完成: + +- 第一批共性底座 smoke 通过。 +- Reasonix 两轮短回复 smoke 通过。 +- Hermes load 与 replay 变体最小 smoke 通过。 +- Page AI session dashboard 的范围已拆成新设计或在本设计中完成验收。 + +满足后移动到 `design/07-ai/done/`;若后续另起 v2 覆盖本稿,则本稿移动到 `design/old/process/` 并标记 `[recycle]`。 diff --git a/design/07-ai/process/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md b/design/07-ai/process/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md new file mode 100644 index 00000000..cd9b4575 --- /dev/null +++ b/design/07-ai/process/7-59-page-ai-reasonix-desktop-hermes-web-control-surface-alignment-v1.md @@ -0,0 +1,570 @@ +# 7-59 Page AI Reasonix Desktop / Hermes Web 控制面借鉴设计 v1 + +> 创建时间:2026-06-10 +> +> 当前状态:`PROCESS` +> +> Owner:07-ai / Page AI / ACP runtime / Session dashboard / Runtime control surface +> +> 前置完成项:`design/07-ai/done/7-58-page-ai-reasonix-official-desktop-session-alignment-v1.md` +> +> 参考代码: +> - Reasonix 官方 desktop:`reference-code/DeepSeek-Reasonix-main/desktop/` +> - Reasonix desktop runtime:`reference-code/DeepSeek-Reasonix-main/desktop/src/App.tsx`、`desktop/src/protocol.ts`、`desktop/src/ui/*` +> - Hermes Web UI:`reference-code/hermes-web-ui-0.5.18/README.md`、`packages/client/src/api/hermes/*`、`packages/client/src/stores/hermes/*`、`packages/server/src/routes/hermes/*` +> - Hermes VSCode ACP host:`reference-code/hermes-vscode-main/src/sessionManager.ts` +> - MNote 当前 Page AI:`rust/crates/mnote-web/src/routes/hermes_client.rs`、`rust/crates/mnote-web/browser/sidebar-page-ai-*.js` + +## 1. 第一结论 + +`7-58` 已经解决了 Reasonix / Hermes 的底层会话连续性分歧:Reasonix 走 live binding,Hermes ACP 走 `session/load`。 + +本稿继续处理下一层问题:**Page AI 不应该只是一个聊天抽屉,而应该收敛成“对话 + agent 运行态 + 会话资产 + 配置/权限/工具状态”的轻量控制面。** + +Reasonix Desktop 和 Hermes Web 各自值得借鉴的部分不同: + +| 参考 | 值得借鉴 | 不应照搬 | +| --- | --- | --- | +| Reasonix Desktop | 运行态事件 reducer、busy/turn complete、queued send、plan/checkpoint/path permission/job/MCP/statusbar | Tauri/Wails 壳、完整桌面布局、Reasonix 专用设置结构 | +| Hermes Web UI | session dashboard、usage/cost、profiles/models/providers、gateway/jobs/logs/config/status、search/export/delete 资产面 | 独立 Web UI IA、Hermes DB 真相、平台 channel 全量管理 | + +MNote 的正确方向是: + +```text +Page AI drawer = 当前页上下文里的工作面 +Page AI session dashboard = MNote 自己的会话资产面 +Runtime control surface = Hermes/Reasonix/ChatOnly/API Chat 的统一状态面 +External provider/Hermes DB/Reasonix live loop = 外部或进程内 runtime,不是 MNote 长期真相 +``` + +## 2. Reasonix Desktop 对 MNote 的启发 + +### 2.1 事件不是纯文本流,而是 UI reducer 输入 + +Reasonix desktop `App.tsx` 将 incoming event 映射为明确状态: + +- `$turn_complete`:解除 busy、清 active skill、驱动 queued send。 +- `$path_access_required` / `$confirm_required` / `$choice_required`:进入前端待决策队列。 +- `$plan_required` / `$checkpoint_required` / `$revision_required` / `$step_completed`:进入 plan/checkpoint UI。 +- `$mcp_specs`:显示 MCP 配置/握手/连接/失败/工具数。 +- `$jobs`:显示后台 job 列表、运行/退出状态、stop/stop all。 +- `$ctx_breakdown` / `$balance` / usage:进入 statusbar / context panel。 +- `$session_loaded`:恢复消息、清队列、恢复 carryover usage。 + +MNote 当前 Page AI 已有 message/tool/permission/session/run 的事件基础,但仍偏“聊天消息优先”。后续应把 Page AI event projection 正式拆成: + +| Projection | UI owner | 事件来源 | +| --- | --- | --- | +| final assistant | chat transcript | `message.delta` / final output | +| thought/reasoning | folded reasoning panel | `thought.delta` | +| tool card | tool timeline | `tool.started/completed/failed` | +| permission queue | permission panel/dialog | `permission.*` / ACP decision request | +| plan/checkpoint | plan panel | `plan.updated` / future `checkpoint.*` | +| runtime status | header/statusbar | `session.info.updated` / `run.*` / `usage.updated` | +| MCP/status | runtime control panel | runtime manifest / MCP bridge health | +| background jobs | jobs popover/panel | future `job.*` / tool long-running receipt | + +### 2.2 busy 后输入应排队,而不是丢弃或再开 session + +Reasonix Desktop 有 `queuedSends`:busy 时用户继续发送,不直接开新底层 session;turn complete 后 FIFO 自动发送。 + +MNote 对 Reasonix 尤其需要这个模型,因为同一 live binding 内必须串行 prompt。当前 `7-58` 对 busy binding 会返回 `acp_reasonix_session_busy`。后续应该升级为: + +```text +Reasonix binding running + -> 新输入进入 MNote Page AI session queue + -> UI 显示 queued count / 可取消 + -> 当前 run terminal 后自动启动下一条 run + -> 仍复用同一 live binding / acpSessionId +``` + +验收不应只是“不报 busy”,而是验证两条快速输入仍同一 `acpSessionId` 且顺序正确。 + +### 2.3 Stop 语义必须拆三层 + +Reasonix Desktop 的 stop 更接近“停止当前 turn/job”,不是删除 session。MNote 后续 UI 应明确三个按钮/动作: + +| 动作 | 含义 | 默认影响 | +| --- | --- | --- | +| Stop turn | 取消当前 prompt | 保留 MNote session 与 live binding | +| Reset runtime | 关闭 Reasonix live binding / Hermes ACP manager | 保留 MNote history,下一轮 cold/new | +| Delete session | 删除 MNote session history/audit | 不默认删除外部 Hermes DB / provider 会话 | + +这能避免用户点击“停止”后误以为历史或底层 session 被删。 + +### 2.4 MCP / jobs / usage 要进状态条,不进 assistant 正文 + +Reasonix Desktop 把 MCP specs、jobs、usage、balance 放到 sidebar/statusbar/settings,而不是让模型自然语言解释。 + +MNote 后续应在 Page AI header 下增加 compact status strip: + +```text +runtime: Reasonix native-live | Hermes loadable | ChatOnly provider +session: mnoteSessionId / acpSessionId / mode +model/profile: profile id + resolved model +roots: write/read-only grant count +MCP: connected/failed/tool count +tools: enabled/disabled count +jobs: running count +usage: last run tokens/cost/cache if available +``` + +## 3. Hermes Web 对 MNote 的启发 + +Hermes Web UI 的 README 将能力分成 Chat、Platform Channels、Usage、Jobs、Model Management、Multi-Profile/Gateway、File Browser、Group Chat、Skills/Memory、Logs、Auth、Settings、Web Terminal。 + +MNote 不能照搬完整 dashboard,但可以抽取控制面能力: + +### 3.1 Session asset dashboard + +Hermes Web 的 session 管理包含:多会话、rename/delete/switch、SQLite session DB、按来源分组、active session pin、搜索、模型 badge、context token usage。 + +MNote 已有最小 list/detail/search/export/rename/delete。后续应补: + +- active session pin:正在 run 的 session 固定到历史列表顶部。 +- runtime grouping:Reasonix / Hermes ACP / ChatOnly / API Chat 分组。 +- status filters:active / completed / failed / cold_resumed / native_live / replay_seen。 +- search fields:user question、assistant final、tool name、citation path、changed file、error code。 +- export profile:JSON / Markdown / audit JSONL 三种导出。 +- delete semantics badge:仅删 MNote history,外部 provider/Hermes DB 不默认删除。 + +### 3.2 Usage / cost / context panel + +Hermes Web 有 usage analytics:token breakdown、daily trend、model distribution、cost/cache。 + +MNote 不需要先做全局财务面板,但 Page AI 应先做 session/run 级 usage: + +- 当前 run:prompt/completion/reasoning/cache tokens。 +- 当前 session:累计 tokens/cost/cache hit。 +- 当前 runtime:reserved tool/system/context tokens。 +- fallback/cold resume:显示 packet 大小和截断原因。 + +Reasonix Desktop 的 `ctx_breakdown` 与 Hermes Web 的 usage store 可以合并成 MNote 的 `page_ai_usage_projection.v1`。 + +### 3.3 Profiles / models / providers + +Hermes Web 将 providers/models/profiles 拆成独立设置面,并支持 credential pool、provider presets、default model switching。 + +MNote 当前 Page AI profile selector 已可用,但缺少“解析后的真实模型与凭据状态”。后续应显示: + +| 字段 | 示例 | +| --- | --- | +| profileId | `mnoteai` / `shared_api_deepseek_flash_chat` | +| providerKind | `hermes-acp` / `api-chat` / `web-provider` | +| resolvedModel | `deepseek-v4-flash` / `gpt-5.5` | +| credentialStatus | configured / missing / inherited / masked | +| runtimeBinary | `hermes` / `node scripts/reasonix-acp-wrapper.mjs` | +| configSource | control-plane / env / profile file | + +### 3.4 Gateway / logs / jobs + +Hermes Web 把 gateway、logs、jobs 作为运维一等公民。MNote 后续应只引入轻量版: + +- Runtime health:Hermes ACP process、Reasonix wrapper、MNote MCP bridge、LightRAG MCP/HTTP。 +- Logs tail:最近 N 条 ACP stderr、tool failed、permission denied、MCP failed。 +- Jobs:长任务工具的 running/completed/failed,不把后台进度塞进 assistant final。 + +### 3.5 Skills / memory + +Hermes Web 有 skills/memory 浏览。Reasonix Desktop 也有 skills、memory、MCP specs。 + +MNote Page AI 后续应把 skill/memory 作为 runtime capability,而不是继续分散在设置页: + +- 当前 runtime 可用 skills。 +- enabled/disabled user preference。 +- inline/subagent 模式。 +- project/global/builtin scope。 +- memory source:Reasonix native memory / Hermes memory / MNote profile note。 +- 对 Reasonix native-live,不把 MNote memory 强塞到每轮 prompt;只作为 capability manifest 或 cold resume fallback 输入。 + +## 4. 目标信息架构 + +### 4.1 Page AI drawer 分区 + +```text +Header + runtime badge / profile / model / session mode / active-run status + +Chat + user / assistant final + folded thought + tool cards + citations + changed files receipt + +Runtime + live binding / acpSessionId / load/replay / cold resume + MCP status + allowed roots + tool enablement + permission queue + jobs + usage/context + +History + list / search / filters / export / rename / delete + +Settings + profile/model/provider + skills/memory + runtime health/logs +``` + +### 4.2 不同 runtime 的展示差异 + +| Runtime | Header mode | Runtime panel 必显 | 不显示/禁用 | +| --- | --- | --- | --- | +| Reasonix ACP | `native-live` / `cold-resumed` / `new-session` | live binding、MCP specs、jobs、allowed roots、queued sends | `session/load` 成功率、provider conversation | +| Hermes ACP | `loaded` / `load-failed-new` / `replay-seen` | acpSessionId、load/replay、profile/model、tool locations | Reasonix live binding status | +| ChatOnly Web | `provider-conversation` | providerConversationId、remote delete status | ACP session/load/live binding | +| API Chat | `server-transcript` | model/provider/usage、MNote transcript | provider web conversation binding | + +## 5. 后端合同 + +### 5.1 Runtime status snapshot + +新增或收口一个 Page AI runtime status API: + +```text +GET /api/page-ai/runtime/status?sessionId=...&workspaceId=...&documentId=... +``` + +返回: + +```json +{ + "schema": "mnote.page_ai_runtime_status.v1", + "mnoteSessionId": "...", + "runtime": "reasonix", + "profile": "reasonix", + "mode": "native_live", + "activeRun": { "runId": "...", "status": "running" }, + "acp": { + "acpSessionId": "...", + "supportsSessionLoad": false, + "replaySeen": false, + "liveBinding": { "status": "idle", "workspaceKey": "file://..." } + }, + "model": { "resolvedModel": "...", "providerKind": "..." }, + "roots": { "write": 1, "readOnly": 0 }, + "mcp": { "status": "connected", "toolCount": 8, "failed": [] }, + "tools": { "enabled": 12, "disabled": 2 }, + "jobs": { "running": 0 }, + "usage": { "sessionTokens": 0, "lastRunTokens": 0 } +} +``` + +规则: + +- `acpSessionId` 只作为 runtime reference,不是 MNote 长期真相。 +- Reasonix `supportsSessionLoad=false` 必须明确展示。 +- Hermes replay 只能影响 `replaySeen/replaySeq`,不能当当前 answer。 +- ChatOnly/API Chat 不返回 ACP binding。 + +### 5.2 Session search/export 升级 + +现有 `/api/hermes/client/sessions/search` 与 `/export` 后续收口为 Page AI 命名: + +```text +GET /api/page-ai/sessions +GET /api/page-ai/sessions/search?q=... +GET /api/page-ai/sessions/{id} +GET /api/page-ai/sessions/{id}/export?format=json|markdown|jsonl +POST /api/page-ai/sessions/{id}/rename +DELETE /api/page-ai/sessions/{id} +``` + +`/api/hermes/client/sessions/*` 保留 compat,但前端新代码优先走 `/api/page-ai/*`。 + +### 5.3 Queued sends + +新增队列表: + +```text +page_ai_queued_runs + id + user_id + workspace_id + document_id + session_id + run_id + runtime + profile + payload_json + status: queued | started | cancelled | failed + created_at / updated_at +``` + +Reasonix live binding busy 时不返回 failed,而是入队。 + +## 6. 前端实现边界 + +### 6.1 文件 owner + +| 文件 | 改动边界 | +| --- | --- | +| `sidebar-page-ai-runtime.js` | 只保留 orchestration;不要继续塞 dashboard 渲染细节 | +| `sidebar-page-ai-session-runtime.js` | session list/detail/search/export/rename/delete/filters | +| `sidebar-page-ai-render-runtime.js` | Page AI 面板渲染,新增 Runtime / Dashboard 分区 | +| `sidebar-page-ai-profile-runtime.js` | profile/model/provider 解析展示 | +| `sidebar-page-ai-skill-runtime.js` | skill/memory/MCP capability 偏好 | +| `sidebar-page-ai-permission-runtime.js` | permission decision queue | +| 新建议:`sidebar-page-ai-runtime-status-runtime.js` | runtime status polling/event apply,不和 chat streaming 混写 | + +### 6.2 UI 原则 + +- Runtime 状态只做 compact status strip + detail panel,不占 assistant 正文。 +- thought/tool/plan/permission/citation 分区渲染,不拼进 final answer。 +- history action 永远可点击,不靠 hover 才能完成核心操作。 +- stop/reset/delete 三类危险程度不同,按钮必须分开。 +- 短回复“可以/继续”在 `new-session` 且无 history 时显示缺上下文提示。 + +## 7. 执行顺序 + +### Phase A:Runtime status surface + +- [ ] 后端新增 `/api/page-ai/runtime/status`,返回 runtime/profile/model/session/acp/mcp/tools/jobs/usage。 +- [ ] 前端 header 显示 runtime badge、session mode、active-run status。 +- [ ] Runtime panel 展示 `acpSessionId`、Reasonix live binding、Hermes load/replay、allowed roots。 +- [ ] 浏览器 smoke:Reasonix / Hermes / API Chat 三种 runtime 切换时 status 不串线。 + +### Phase B:Reasonix Desktop 运行态对齐 + +- [ ] Reasonix busy binding 改为 queued send,不再直接 failed。 +- [ ] 队列 UI 显示 queued count、queued text preview、cancel。 +- [ ] 当前 run terminal 后自动启动下一条 queued run,仍复用同一 live binding。 +- [ ] Stop turn 不关闭 binding;Reset runtime 才关闭 binding。 +- [ ] MCP specs/status/toolCount 进入 runtime panel。 +- [ ] Jobs popover 支持 long-running tool receipt / stop all。 +- [ ] Smoke:快速发送两条消息,验证 FIFO、同一 `acpSessionId`、无输出中残留。 + +### Phase C:Hermes Web 控制面最小引入 + +- [ ] Page AI session API 命名从 `/api/hermes/client/sessions` 收口到 `/api/page-ai/sessions`。 +- [ ] session list 支持 runtime grouping、active pin、status filters。 +- [ ] search 支持 message/tool/citation/changed file/error code。 +- [ ] export 支持 json/markdown/jsonl 三格式。 +- [ ] usage panel 展示 session/run/model/cache/cost。 +- [ ] profile/model/provider panel 展示 resolved model、credential status、config source。 +- [ ] Smoke:search/filter/export/usage/profile status 在真实浏览器可见。 + +### Phase D:权限/配置/外部删除边界 + +- [ ] permission decision loop 做成持久 pending queue,刷新后不丢。 +- [ ] delete session 明确只删除 MNote history;外部 Hermes/provider 删除必须二次确认。 +- [ ] reset runtime 只关闭 live binding/ACP manager,不删除 MNote history。 +- [ ] logs tail 显示 ACP stderr、tool failed、MCP failed 最近 N 条。 +- [ ] Smoke:delete/reset/stop 三类动作互不误伤。 + +## 8. 验收矩阵 + +### 8.1 Reasonix + +- [ ] `native-live` 状态清晰可见。 +- [ ] busy 时第二条输入进入 queue,不开新 session。 +- [ ] queued send 自动执行后仍同一 `acpSessionId`。 +- [ ] Stop turn 后下一轮仍可 native-live。 +- [ ] Reset runtime 后下一轮显示 `cold_resumed` 或 `new_session`。 +- [ ] MCP failed 显示为 status/tool card,不污染 assistant final。 + +### 8.2 Hermes ACP + +- [ ] stored `acpSessionId` 先 `session/load`。 +- [ ] replay 显示为 replay evidence / history restore,不混入当前回答。 +- [ ] load failed 明确显示 `load-failed-new`。 +- [ ] session dashboard 可按 Hermes profile/model 过滤。 + +### 8.3 ChatOnly / API Chat + +- [ ] providerConversationId 与 acpSessionId 不混用。 +- [ ] API Chat 不写 web provider binding。 +- [ ] session dashboard 仍可展示/search/export。 +- [ ] delete MNote history 不默认删除远端 provider 会话。 + +### 8.4 通用 UI + +- [ ] 回答完成后无 `AI · 输出中`。 +- [ ] history actions 在桌面/移动都可点击。 +- [ ] runtime status 与当前 session 一致,切 session 后不显示旧 acpSessionId。 +- [ ] tool/citation/permission/plan/job 不进入 assistant final。 + +## 9. 不做事项 + +- 不把 Reasonix Desktop 的 Tauri shell 或完整布局搬进 MNote。 +- 不把 Hermes Web UI 作为 MNote 的独立 dashboard 复刻。 +- 不把 Hermes DB 当成 MNote session 真相。 +- 不把 Reasonix native memory 改造成 MNote 每轮 transcript 注入。 +- 不自动删除外部 Hermes/provider session store。 +- 不在 Page AI 主路径继续叠加无界轮询;runtime status 优先事件/command result 驱动,短期 fallback 必须有退出条件。 + +## 10. 首批可执行切片 + +### 10.1 Slice A1:runtime status 后端只读快照 + +目标:先让 Page AI 能回答“当前 agent 是谁、是否还在运行、底层 session 是否连续、MCP/工具是否可用”。 + +范围: + +- 新增 `/api/page-ai/runtime/status` 只读 route。 +- 数据先从现有 `PageAiSessionStore` / `AcpSessionManager` / profile resolver / tool registry 组合,不新增长期表。 +- Reasonix 返回 `mode=native_live|cold_resumed|new_session`,Hermes 返回 `mode=loaded|load_failed_new|replay_seen|new_session`,ChatOnly/API Chat 返回非 ACP mode。 +- active run 状态必须来自 event store / terminal reconciliation 后的最终状态,不能只看前端 streaming flag。 + +验收: + +```bash +cargo test -p mnote-web page_ai_runtime_status +``` + +若 Rust route 暂时没有专名测试,至少补一个 fake store/unit test 覆盖 Reasonix、Hermes、API Chat 三类 snapshot。 + +### 10.2 Slice A2:Page AI header/status strip + +目标:把 runtime status 放到聊天抽屉顶部,而不是让 assistant 正文解释。 + +范围: + +- `sidebar-page-ai-render-runtime.js` 增加 compact status strip。 +- `sidebar-page-ai-runtime.js` 只负责加载/应用 status,不塞具体 DOM 细节。 +- 回答终止后 status strip 必须同步清理 active-run,避免 `AI · 输出中` 残留。 +- 切换 session/document 后先清空旧 status,再加载新 status,避免旧 `acpSessionId` 短暂串线。 + +验收: + +```bash +node scripts/task559-page-ai-terminal-status-reconciliation-smoke.js +``` + +并补一条真实浏览器 smoke:Reasonix/Hermes/API Chat 切换后 header badge 与当前 profile 一致。 + +### 10.3 Slice B1:Reasonix queued send 最小版 + +目标:busy binding 不再直接失败,先支持同一 Page AI session 内 FIFO 串行发送。 + +范围: + +- 先实现内存队列或 event-store backed queue;如果实现跨刷新不稳定,再升级 `page_ai_queued_runs` 表。 +- queue item 必须记录 `sessionId`、`runId`、`runtime`、`profile`、`acpSessionId`、原始 prompt payload。 +- 当前 run terminal 后自动 dequeue 下一条,仍复用 Reasonix live binding。 +- cancel queued item 只取消队列项,不 stop 当前 run。 + +验收: + +```bash +node scripts/task558-page-ai-reasonix-live-session-context-smoke.js +``` + +新增/扩展浏览器断言: + +- 快速发送两条消息。 +- 第二条先显示 queued。 +- 两轮完成后 `acpSessionId` 一致。 +- 第二轮短回复能引用第一轮上下文。 +- 最终无 `AI · 输出中`。 + +### 10.4 Slice C1:Page AI sessions 命名兼容层 + +目标:前端新主路径不再把 Page AI session dashboard 命名成 Hermes client。 + +范围: + +- 新增 `/api/page-ai/sessions/*` wrapper。 +- 复用现有 `/api/hermes/client/sessions/*` handler 或 store,不重复实现。 +- 老路由继续保留 compat;测试必须证明老路由仍可用。 +- 前端 session runtime 优先请求 `/api/page-ai/sessions/*`。 + +验收: + +```bash +cargo test -p mnote-web page_ai_sessions +node scripts/task561-page-ai-session-dashboard-smoke.js +``` + +### 10.5 Slice C2:Hermes load/replay 可见化 + +目标:把 Hermes ACP `session/load` 与 replay 作为 runtime evidence 展示,而不是混入当前回答。 + +范围: + +- status snapshot 增加 `loadAttempted`、`loadStatus`、`replaySeen`、`replaySeq`、`replayMessageCount`。 +- UI 显示为 runtime panel evidence。 +- 当前 assistant final 只来自当前 run final,不消费 replay 作为回答。 + +验收: + +```bash +node scripts/task560-page-ai-hermes-load-replay-smoke.js +``` + +覆盖无 replay / 有 replay 两种 fake Hermes ACP fixture。 + +## 11. Event projection 合同 + +Page AI 后续统一把 runtime events 规整成下面几类 projection。前端只能消费 projection,不直接按 provider 原始事件猜 UI 状态。 + +| Projection | 关键字段 | terminal 规则 | +| --- | --- | --- | +| `runProjection` | `runId/status/startedAt/endedAt/errorCode` | `completed/failed/cancelled` 必须清 active-run | +| `messageProjection` | `role/final/thought/toolCalls/citations` | final 与 thought 分离 | +| `sessionProjection` | `mnoteSessionId/runtime/acpSessionId/mode` | 切 session 后旧 projection 不可复用 | +| `runtimeProjection` | `profile/model/provider/binary/configSource` | profile 切换后立即 invalidated | +| `capabilityProjection` | `mcp/tools/skills/memory/roots` | failed capability 不污染 assistant final | +| `decisionProjection` | `permission/choice/confirm/pathAccess` | pending 决策可刷新恢复 | +| `queueProjection` | `queuedCount/items/cancellable` | current terminal 后 FIFO dequeue | +| `usageProjection` | `tokens/cost/cache/contextBreakdown` | usage 缺失时显示 unknown,不伪造 0 | + +最低要求: + +- `AI · 输出中` 只能绑定 `runProjection.status=running|streaming`。 +- `acpSessionId` 只能来自 `sessionProjection`。 +- replay、load、MCP、job、permission 都不能写进 assistant final。 +- provider 原始 event 增加新字段时,未识别字段进入 `rawEvidence`,不直接影响 UI。 + +## 12. 风险与边界 + +### 12.1 上下文连续性的误判风险 + +短回复“可以/继续”天然依赖历史。UI 必须区分: + +| 状态 | 用户提示 | +| --- | --- | +| `native_live` / `loaded` | 显示连续会话 | +| `cold_resumed` | 显示已用 MNote history fallback 恢复 | +| `new_session` 且 history 为空 | 提示当前没有可用上下文 | +| `load_failed_new` | 提示底层 ACP load 失败,已新建 runtime session | + +这比单纯把 transcript 拼进 prompt 更重要,因为 Reasonix native-live 与 Hermes load/replay 的真实连续机制不同。 + +### 12.2 删除与重置边界 + +删除、重置、停止必须保持三套语义: + +- `Stop turn`:只影响当前 run。 +- `Reset runtime`:只影响 Reasonix live binding / Hermes ACP process 或 manager。 +- `Delete session`:只影响 MNote session history;外部 Hermes DB / provider 会话删除必须另设显式二次确认。 + +### 12.3 轮询边界 + +runtime status 首选 event apply / command result refresh。短期允许有界 fallback,但必须满足: + +- 仅在 Page AI drawer 可见且当前 session active 时启用。 +- interval 有最大时长和 stop 条件。 +- run terminal、session switch、drawer close 都必须停止。 +- 后续进入 done 前,要记录是否已替换为 event-driven。 + +## 13. 设计归档规则 + +本稿进入 `done/` 前必须满足: + +- Phase A-D checklist 至少按 slice 完成,并记录每个 slice 的测试命令。 +- 浏览器 smoke 证明 Reasonix、Hermes ACP、ChatOnly/API Chat 不串状态。 +- 用户最初两个问题已有可见验收:短回复能继承上下文,回答完成不残留 `AI · 输出中`。 +- 若某项因为外部 provider 权限、Hermes DB 删除策略或 Reasonix 官方行为无法完成,必须在本稿末尾追加“归档说明”,不能只留 unchecked TODO。 + +## 14. 本稿完成条件 + +- Phase A-D checklist 有真实代码、单测或 browser smoke。 +- Reasonix queued send / stop / reset 语义明确可见。 +- Hermes-style session dashboard 不再挂在 `/api/hermes/client/*` 命名主路径上。 +- Runtime status 能解释用户最关心的四件事:当前 agent 是谁、上下文是否连续、工具/MCP 是否可用、为什么还在输出中或已经结束。 +- ChatOnly / API Chat 回归不受 ACP runtime status 改动影响。 diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js index 4c38cfc2..7ad80b76 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-render-runtime.js @@ -1359,6 +1359,7 @@ export function createSidebarPageAiRenderRuntime(context) { '
' + '' + '' + + '' + '' + '
' + ''; diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js index 50e47908..1682f6f1 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-runtime.js @@ -494,6 +494,7 @@ export function createSidebarPageAiRuntime(context) { const pageAiSetActiveSession = (...args) => pageAiSessionRuntime.pageAiSetActiveSession(...args); const pageAiStartNewSession = (...args) => pageAiSessionRuntime.pageAiStartNewSession(...args); const pageAiRenameBackendSession = (...args) => pageAiSessionRuntime.pageAiRenameBackendSession(...args); + const pageAiExportBackendSession = (...args) => pageAiSessionRuntime.pageAiExportBackendSession(...args); const pageAiDeleteBackendSession = (...args) => pageAiSessionRuntime.pageAiDeleteBackendSession(...args); const pageAiDeleteSelectedBackendSessions = (...args) => pageAiSessionRuntime.pageAiDeleteSelectedBackendSessions(...args); const pageAiResumeBackendSession = (...args) => pageAiSessionRuntime.pageAiResumeBackendSession(...args); @@ -1380,6 +1381,12 @@ export function createSidebarPageAiRuntime(context) { pageAiSetActiveSession(sessionId); } if (run) await pageAiResumeActiveRunJournal(run); + if (!run && snapshot && snapshot.hostRunId) { + pageAiStopStreamingAssistantMessage(snapshot.hostRunId); + pageAiClearActiveRunSnapshot(snapshot.hostRunId); + pageUiState.pageAiBusy = false; + pageAiSetRunStatus('idle', snapshot.hostRunId); + } return run; } @@ -2475,6 +2482,21 @@ export function createSidebarPageAiRuntime(context) { void pageAiAppendKnowledgeRagFallbackCitations(id, promptText); } + function pageAiStopStreamingAssistantMessage(runId) { + var id = String(runId || pageUiState.pageAiCurrentRunId || ''); + var changed = false; + pageUiState.pageAiMessages.forEach(function(message) { + if (message && message.role === 'assistant' && message.streaming === true && (!id || message.runId === id)) { + message.streaming = false; + changed = true; + } + }); + if (!changed) return; + pageAiSyncCurrentSessionMessages(); + pageAiPersistSessions(); + renderPageAiConversation(); + } + function pageAiToolOutputText(value) { var parts = []; function visit(node) { @@ -2612,6 +2634,10 @@ export function createSidebarPageAiRuntime(context) { if (!event || typeof event !== 'object') return; var eventName = String(event.kind || event.eventType || event.event || '').trim(); var payload = event.payload && typeof event.payload === 'object' ? event.payload : {}; + if (payload.source === 'adapter_replay' || payload.replay === true) { + document.documentElement.setAttribute('data-mnote-page-ai-adapter-replay-seen', 'true'); + return; + } var payloadText = pageAiJournalPayloadText(event); var prompt = pageAiLastUserPrompt(); if (eventName === 'message.delta') { @@ -2624,6 +2650,7 @@ export function createSidebarPageAiRuntime(context) { pageAiSetRunStatus('failed', runId); } else if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { pageUiState.pageAiStoppedRunIds[runId] = true; + pageAiStopStreamingAssistantMessage(runId); pageAiSetRunStatus('aborted', runId); } else if (eventName === 'tool.started' || eventName === 'tool.completed' || eventName === 'tool.failed') { var toolItem = pageAiApplyToolEvent(eventName, payloadText, runId, runTraceId); @@ -2896,6 +2923,14 @@ export function createSidebarPageAiRuntime(context) { var autoCitations = []; await streamPageAiResponse(eventResponse, function(eventName, payloadText) { pageAiTrackStreamingRunEvent(runId, eventName, payloadText); + var streamPayload = null; + try { + streamPayload = JSON.parse(payloadText || 'null'); + } catch (_) {} + if (streamPayload && (streamPayload.source === 'adapter_replay' || streamPayload.replay === true)) { + document.documentElement.setAttribute('data-mnote-page-ai-adapter-replay-seen', 'true'); + return; + } if (eventName === 'message.delta') { if (pageUiState.pageAiStoppedRunIds[runId]) return; assistantText = pageAiAppendStreamingAssistantDelta(runId, pageAiDecodeDeltaText(payloadText)); @@ -2995,6 +3030,7 @@ export function createSidebarPageAiRuntime(context) { } if (eventName === 'run.aborted' || eventName === 'run.cancelled' || eventName === 'run.canceled') { pageUiState.pageAiStoppedRunIds[runId] = true; + pageAiStopStreamingAssistantMessage(runId); pageAiSetRunStatus('aborted', runId); } if (eventName === 'session.info.updated') { @@ -3294,6 +3330,15 @@ export function createSidebarPageAiRuntime(context) { }); return true; } + var pageAiSessionExport = closestAction(event.target, '[data-page-ai-session-export]'); + if (pageAiSessionExport) { + event.preventDefault(); + void pageAiExportBackendSession(pageAiSessionExport.getAttribute('data-page-ai-session-export') || '').catch(function(error) { + pageUiState.pageAiSessionError = error instanceof Error ? error.message : String(error); + renderPageAiControls(); + }); + return true; + } var pageAiSessionDelete = closestAction(event.target, '[data-page-ai-session-delete]'); if (pageAiSessionDelete) { event.preventDefault(); @@ -3501,6 +3546,7 @@ export function createSidebarPageAiRuntime(context) { pageAiToggleTool: (...args) => pageAiToggleTool(...args), pageAiResumeBackendSession: (...args) => pageAiResumeBackendSession(...args), pageAiRenameBackendSession: (...args) => pageAiRenameBackendSession(...args), + pageAiExportBackendSession: (...args) => pageAiExportBackendSession(...args), pageAiDeleteBackendSession: (...args) => pageAiDeleteBackendSession(...args), pageAiResolvePermission: (...args) => pageAiResolvePermission(...args), pageAiOpenLocation: (...args) => pageAiOpenLocation(...args), diff --git a/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js b/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js index 3de0e5b6..8f9cc119 100644 --- a/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js +++ b/rust/crates/mnote-web/browser/sidebar-page-ai-session-runtime.js @@ -255,6 +255,7 @@ export function createSidebarPageAiSessionRuntime(context) { if (!event || typeof event !== 'object') return null; var eventType = String(event.eventType || event.event_type || event.event || '').trim(); var payload = event.payload && typeof event.payload === 'object' ? event.payload : event; + if (payload && (payload.source === 'adapter_replay' || payload.replay === true)) return null; if (eventType === 'message.delta') { var delta = String(payload.delta || payload.text || payload.output_text || ''); return delta ? { role: 'assistant', content: delta } : null; @@ -410,6 +411,30 @@ export function createSidebarPageAiSessionRuntime(context) { return pageUiState.pageAiSessionSearchResults; } + async function pageAiExportBackendSession(sessionId) { + sessionId = String(sessionId || pageUiState.pageAiActiveSessionId || '').trim(); + if (!sessionId) return null; + var response = await fetch('/api/hermes/client/sessions/' + encodeURIComponent(sessionId) + '/export?' + pageAiBackendSessionQuery({ limit: 200 }), { + headers: { 'accept': 'application/json' } + }); + var payload = await response.json().catch(function(){ return null; }); + if (!response.ok || !payload || payload.ok !== true) { + throw new Error(pageAiErrorMessage(payload, 'session_export_failed_' + response.status)); + } + var exported = payload.export && typeof payload.export === 'object' ? payload.export : {}; + var session = pageAiFindSessionById(sessionId) || pageAiCurrentSession(); + if (session) { + session.exportedAt = Date.now(); + session.exportMarkdown = String(exported.markdown || ''); + session.updatedAt = Date.now(); + } + doc.documentElement.setAttribute('data-mnote-page-ai-session-exported', sessionId); + pageAiPersistSessions(); + renderPageAiConversation(); + renderPageAiControls(); + return payload; + } + function pageAiPersistSessions() { doc.documentElement.setAttribute('data-mnote-page-ai-session-owner', 'hermes'); doc.documentElement.setAttribute('data-mnote-page-ai-session-key', pageAiStorageKey()); @@ -621,8 +646,15 @@ export function createSidebarPageAiSessionRuntime(context) { if (!response.ok || !payload || payload.ok !== true) { throw new Error(pageAiErrorMessage(payload, 'session_rename_failed_' + response.status)); } - session.title = String((payload.result && payload.result.title) || title); - session.updatedAt = Date.now(); + var nextTitle = String((payload.result && payload.result.title) || title); + [pageUiState.pageAiSessions, pageUiState.pageAiSessionSearchResults].forEach(function(list) { + pageAiNormalizeArray(list).forEach(function(item) { + if (String(item && item.id || '').trim() === sessionId) { + item.title = nextTitle; + item.updatedAt = Date.now(); + } + }); + }); pageAiPersistSessions(); renderPageAiConversation(); renderPageAiControls(); @@ -784,6 +816,7 @@ export function createSidebarPageAiSessionRuntime(context) { pageAiSetActiveSession, pageAiStartNewSession, pageAiRenameBackendSession, + pageAiExportBackendSession, pageAiDeleteBackendSession, pageAiDeleteSelectedBackendSessions, pageAiResumeBackendSession, diff --git a/rust/crates/mnote-web/src/acp_session_manager.rs b/rust/crates/mnote-web/src/acp_session_manager.rs index 066fcae4..585be89b 100644 --- a/rust/crates/mnote-web/src/acp_session_manager.rs +++ b/rust/crates/mnote-web/src/acp_session_manager.rs @@ -1159,6 +1159,158 @@ rl.on('line', (line) => { Arc::new(client) } + /// Creates a Hermes-like ACP mock where session/load succeeds and replays + /// history as session/update before the load response completes. + async fn spawn_mock_hermes_load_replay_acp() -> Arc { + let script = r#" +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +const rl = readline.createInterface({ input, output, terminal: false }); +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (!msg.id) return; + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + agentInfo: { name: 'hermes-replay-mock', version: '1.0' }, + authMethods: [] + } + }) + '\n'); + } else if (msg.method === 'session/load') { + const sessionId = msg.params?.sessionId || 'loaded_session_1'; + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'REPLAYED_HISTORY_BEFORE_LOAD_RETURN' } + } + } + }) + '\n'); + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { sessionId } + }) + '\n'); + } else if (msg.method === 'session/new') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { sessionId: 'new_session_should_not_be_used' } + }) + '\n'); + } else if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId || 'loaded_session_1'; + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'CURRENT_PROMPT_AFTER_LOAD' } + } + } + }) + '\n'); + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { stopReason: 'end_turn' } + }) + '\n'); + } +}); + "#; + let dir = std::env::temp_dir(); + let script_path = dir.join("acp_session_hermes_replay_mock.mjs"); + std::fs::write(&script_path, script).expect("write hermes replay mock"); + let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()]) + .await + .expect("spawn hermes replay mock"); + Arc::new(client) + } + + /// Creates a Reasonix-like ACP mock: no session/load support, context lives + /// in the live in-process session map and is visible on the next prompt. + async fn spawn_mock_reasonix_live_acp() -> Arc { + let script = r#" +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +const rl = readline.createInterface({ input, output, terminal: false }); +const sessions = new Map(); +let nextId = 1; +function textFromPrompt(prompt) { + return (Array.isArray(prompt) ? prompt : []) + .map((block) => block && block.type === 'text' ? String(block.text || '') : '') + .join('\n') + .trim(); +} +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (!msg.id) return; + if (msg.method === 'initialize') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, + agentInfo: { name: 'reasonix-live-mock', version: '1.0' }, + authMethods: [] + } + }) + '\n'); + } else if (msg.method === 'session/new') { + const sessionId = `reasonix_session_${nextId++}`; + sessions.set(sessionId, []); + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { sessionId } + }) + '\n'); + } else if (msg.method === 'session/load') { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + error: { code: -32601, message: 'session/load not supported' } + }) + '\n'); + } else if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId || ''; + const history = sessions.get(sessionId); + if (!history) { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + error: { code: -32602, message: `unknown session ${sessionId}` } + }) + '\n'); + return; + } + const text = textFromPrompt(msg.params?.prompt); + const previous = history.length ? history[history.length - 1] : ''; + history.push(text); + const answer = previous ? `previous=${previous}; current=${text}` : `current=${text}`; + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: answer } + } + } + }) + '\n'); + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', id: msg.id, + result: { stopReason: 'end_turn' } + }) + '\n'); + } +}); + "#; + let dir = std::env::temp_dir(); + let script_path = dir.join("acp_session_reasonix_live_mock.mjs"); + std::fs::write(&script_path, script).expect("write reasonix mock"); + let client = AcpClient::spawn("node", &[script_path.to_str().unwrap()]) + .await + .expect("spawn reasonix mock"); + Arc::new(client) + } + #[tokio::test] async fn test_load_session_success() { let client = spawn_mock_acp_with_load().await; @@ -1271,6 +1423,74 @@ rl.on('line', (line) => { assert_eq!(format!("{:?}", result.stop_reason), "EndTurn".to_string()); } + #[tokio::test] + async fn test_reasonix_live_session_keeps_short_reply_context() { + let client = spawn_mock_reasonix_live_acp().await; + let mgr = AcpSessionManager::new(client); + let chunks = Arc::new(Mutex::new(Vec::::new())); + let chunks_for_handler = chunks.clone(); + mgr.on_event(move |event| { + if let AcpSessionEvent::TextDelta { text, .. } = event { + chunks_for_handler.lock().unwrap().push(text); + } + }); + let session_id = mgr + .create_session(Some("/test"), None) + .await + .expect("create reasonix session"); + assert_eq!(session_id, "reasonix_session_1"); + + mgr.run_prompt(vec![ContentBlock::Text { + text: "上一轮计划:检查 LightRAG 服务".into(), + }]) + .await + .expect("first prompt"); + mgr.run_prompt(vec![ContentBlock::Text { + text: "可以".into(), + }]) + .await + .expect("second prompt"); + + let output = chunks.lock().unwrap().join("\n"); + assert!( + output.contains("previous=上一轮计划:检查 LightRAG 服务; current=可以"), + "Reasonix live mock should remember previous prompt, got: {output}" + ); + } + + #[tokio::test] + async fn test_hermes_load_replay_events_before_prompt() { + let client = spawn_mock_hermes_load_replay_acp().await; + let mgr = AcpSessionManager::new(client); + let chunks = Arc::new(Mutex::new(Vec::::new())); + let chunks_for_handler = chunks.clone(); + mgr.on_event(move |event| { + if let AcpSessionEvent::TextDelta { text, .. } = event { + chunks_for_handler.lock().unwrap().push(text); + } + }); + + let loaded = mgr + .ensure_session(Some("/test"), Some("stored_hermes_session")) + .await + .expect("load stored Hermes session"); + assert_eq!(loaded, "stored_hermes_session"); + sleep(Duration::from_millis(100)).await; + let replayed = chunks.lock().unwrap().join("\n"); + assert!( + replayed.contains("REPLAYED_HISTORY_BEFORE_LOAD_RETURN"), + "Hermes load should replay history before prompt, got: {replayed}" + ); + + mgr.run_prompt(vec![ContentBlock::Text { + text: "current prompt".into(), + }]) + .await + .expect("prompt after load"); + let output = chunks.lock().unwrap().join("\n"); + assert!(output.contains("CURRENT_PROMPT_AFTER_LOAD"), "prompt output missing: {output}"); + } + #[test] fn test_thought_chunk_maps_to_thought_delta() { let accumulated = Arc::new(Mutex::new(String::new())); diff --git a/rust/crates/mnote-web/src/routes/hermes_client.rs b/rust/crates/mnote-web/src/routes/hermes_client.rs index f90d9a64..6ffec907 100644 --- a/rust/crates/mnote-web/src/routes/hermes_client.rs +++ b/rust/crates/mnote-web/src/routes/hermes_client.rs @@ -25,6 +25,7 @@ use std::hash::{Hash, Hasher}; use std::io::Write; use std::path::{Path as FsPath, PathBuf}; use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, Mutex}; use std::time::{Duration, Instant}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -69,6 +70,8 @@ static API_CHAT_RUN_PAYLOADS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_ACTIVE_RUNS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +static ACP_LIVE_BINDINGS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); static ACP_FINISHED_RUNS: LazyLock>> = LazyLock::new(|| Mutex::new(HashSet::new())); static ACP_LOCAL_AUDIT_SNAPSHOTS: LazyLock>> = @@ -126,6 +129,163 @@ struct AcpActiveRun { event_tx: broadcast::Sender, } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct AcpLiveBindingKey { + mnote_session_id: String, + runtime: String, + profile: String, + workspace_key: String, +} + +#[derive(Clone)] +struct AcpLiveBinding { + manager: Arc, + acp_session_id: String, + status: String, + last_run_id: Option, + last_event_seq: Option, + created_at: u128, + last_prompt_at: u128, +} + +#[derive(Clone)] +struct AcpPreparedSession { + manager: Arc, + acp_session_id: String, + reasonix_session_mode: String, + live_binding_key: Option, +} + +#[derive(Debug, Clone)] +struct AcpRuntimePolicy { + runtime: String, + context_carrier: &'static str, + supports_session_load: bool, + supports_history_replay: bool, + prompt_continuity: &'static str, +} + +impl AcpRuntimePolicy { + fn to_json(&self) -> Value { + json!({ + "schema": "mnote.page_ai_acp_runtime_policy.v1", + "runtime": self.runtime, + "contextCarrier": self.context_carrier, + "supportsSessionLoad": self.supports_session_load, + "supportsHistoryReplay": self.supports_history_replay, + "promptContinuity": self.prompt_continuity, + }) + } +} + +fn acp_runtime_policy(runtime_name: &str) -> AcpRuntimePolicy { + let runtime = runtime_name.trim().to_ascii_lowercase(); + match runtime.as_str() { + "hermes" => AcpRuntimePolicy { + runtime, + context_carrier: "adapter_loadable_session", + supports_session_load: true, + supports_history_replay: true, + prompt_continuity: "load_then_prompt", + }, + "reasonix" => AcpRuntimePolicy { + runtime, + context_carrier: "adapter_live_loop", + supports_session_load: false, + supports_history_replay: false, + prompt_continuity: "native_live_required", + }, + _ => AcpRuntimePolicy { + runtime, + context_carrier: "adapter_session", + supports_session_load: false, + supports_history_replay: false, + prompt_continuity: "new_session", + }, + } +} + +fn acp_live_binding_workspace_key(payload: &Value, context: &RequestContext) -> String { + payload + .get("rootUri") + .or_else(|| payload.get("workspaceRoot")) + .or_else(|| payload.get("cwd")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .or_else(|| context.workspace.workspace_id.clone()) + .unwrap_or_else(|| "default".into()) +} + +fn acp_live_binding_key( + mnote_session_id: &str, + runtime: &str, + profile: &str, + payload: &Value, + context: &RequestContext, +) -> AcpLiveBindingKey { + AcpLiveBindingKey { + mnote_session_id: mnote_session_id.to_string(), + runtime: runtime.to_ascii_lowercase(), + profile: profile.to_string(), + workspace_key: acp_live_binding_workspace_key(payload, context), + } +} + +fn reasonix_live_binding_snapshot( + key: &AcpLiveBindingKey, + binding: &AcpLiveBinding, + mode: &str, +) -> Value { + json!({ + "schema": "mnote.page_ai_acp_live_binding.v1", + "mnoteSessionId": key.mnote_session_id, + "acpRuntime": key.runtime, + "profile": key.profile, + "workspaceKey": key.workspace_key, + "acpSessionId": binding.acp_session_id, + "reasonixSessionMode": mode, + "status": binding.status, + "lastRunId": binding.last_run_id, + "lastEventSeq": binding.last_event_seq, + "createdAt": binding.created_at, + "lastPromptAt": binding.last_prompt_at, + }) +} + +fn update_reasonix_live_binding( + key: Option<&AcpLiveBindingKey>, + status: &str, + run_id: Option<&str>, + event_seq: Option<&str>, +) { + let Some(key) = key else { return; }; + let mut bindings = ACP_LIVE_BINDINGS.lock().expect("acp live bindings"); + if let Some(binding) = bindings.get_mut(key) { + binding.status = status.to_string(); + if let Some(run_id) = run_id { + binding.last_run_id = Some(run_id.to_string()); + } + if let Some(event_seq) = event_seq { + binding.last_event_seq = Some(event_seq.to_string()); + } + binding.last_prompt_at = now_ms(); + } +} + +fn mark_acp_adapter_replay_event(mut event: crate::acp_bridge::SseEvent) -> crate::acp_bridge::SseEvent { + if !event.data.is_object() { + event.data = json!({ "value": event.data }); + } + if let Value::Object(map) = &mut event.data { + map.insert("source".into(), Value::String("adapter_replay".into())); + map.insert("replay".into(), Value::Bool(true)); + map.insert("replayPhase".into(), Value::String("session_load".into())); + } + event +} + #[derive(Debug, Clone)] struct LocalAgentAuditFileSnapshot { size: u64, @@ -1724,10 +1884,22 @@ pub async fn get_page_ai_session_active_run( WebError::internal(format!("SQLite Page AI active run 读取失败: {error}")) .with_context(&context) })?; - let active_run = runs - .iter() - .find(|run| page_ai_run_status_is_active(&run.status)) - .map(page_ai_run_to_journal_json); + let mut active_run = None; + for run in runs { + if !page_ai_run_status_is_active(&run.status) { + continue; + } + let reconciled = reconcile_ai_runtime_run_from_terminal_events( + &state, + &context, + &user_id, + run, + )?; + if page_ai_run_status_is_active(&reconciled.status) { + active_run = Some(page_ai_run_to_journal_json(&reconciled)); + break; + } + } Ok(( StatusCode::OK, stamp_client_headers(), @@ -2111,6 +2283,29 @@ pub async fn rename_session( )) } +pub async fn export_session( + State(state): State, + Extension(context): Extension, + Path(session_id): Path, + Query(query): Query>, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + ensure_authenticated(&context)?; + let result = get_acp_session(&state, &context, &session_id, &query).await?; + let mut detail = result.2 .0; + let markdown = page_ai_session_export_markdown(&detail); + detail["export"] = json!({ + "schema": "mnote.page_ai_session_export.v1", + "sessionId": session_id, + "formats": ["json", "markdown"], + "markdown": markdown, + "externalDelete": { + "attempted": false, + "reason": "export_only" + } + }); + Ok((StatusCode::OK, stamp_client_headers(), Json(detail))) +} + pub async fn auto_title_session( State(state): State, Extension(context): Extension, @@ -2804,6 +2999,7 @@ async fn acp_stream_events( let registration = run_registration_from_payload(&context, &payload); let runtime_name = acp_runtime_name; + let runtime_policy = acp_runtime_policy(runtime_name); // Ensure runtime is active; switch_to either activates it or returns existing let access_env = if capability_policy.attach_mnote_capabilities { @@ -2812,13 +3008,23 @@ async fn acp_stream_events( None }; let client = if runtime_name == "hermes" { - let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") + let explicit_hermes_config = std::env::var("MNOTE_WEB_ACP_RUNTIMES") .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "hermes".into()); - let mut config = - crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile)); - config.env = merge_acp_runtime_env(acp_hermes_env_for_profile(profile), access_env); + .and_then(|raw| serde_json::from_str::>(&raw).ok()) + .and_then(|configs| configs.into_iter().find(|config| config.name == "hermes")); + let mut config = if let Some(config) = explicit_hermes_config { + config + } else { + let hermes_bin = std::env::var("MNOTE_WEB_HERMES_BIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "hermes".into()); + crate::acp_runtime::AcpRuntimeConfig::hermes(Some(&hermes_bin), Some(profile)) + }; + config.env = merge_acp_runtime_env( + merge_acp_runtime_env(config.env, acp_hermes_env_for_profile(profile)), + access_env, + ); state.acp_runtime.switch_to_config(config).await } else { match state.acp_runtime.get_config(runtime_name).cloned() { @@ -2847,10 +3053,135 @@ async fn acp_stream_events( .with_context(&context) })?; - // Create session manager and start the prompt - let mgr = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client)); let (event_tx, _event_rx) = broadcast::channel(256); let event_tx_clone = event_tx.clone(); + let hermes_load_replay_capture = Arc::new(AtomicBool::new(false)); + let hermes_load_replay_events = Arc::new(Mutex::new(Vec::::new())); + let mnote_session_id = registration.session_id.clone(); + let stored_acp_session_id = payload + .get("acpSessionId") + .and_then(Value::as_str) + .filter(|s| !s.trim().is_empty()); + let prepared_session = if runtime_policy.runtime == "reasonix" { + let key = acp_live_binding_key(&mnote_session_id, runtime_name, profile, &payload, &context); + let existing_binding = { + let mut bindings = ACP_LIVE_BINDINGS.lock().expect("acp live bindings"); + if let Some(binding) = bindings.get_mut(&key) { + if binding.status == "running" { + return acp_single_event_response( + &context, + crate::acp_bridge::SseEvent { + event: "run.failed".into(), + data: json!({ + "code": "acp_reasonix_session_busy", + "runId": run_id, + "sessionId": mnote_session_id, + "acpSessionId": binding.acp_session_id, + "message": "Reasonix ACP live session is busy; retry after the current run finishes." + }), + }, + ); + } + binding.status = "running".into(); + binding.last_run_id = Some(run_id.to_string()); + binding.last_prompt_at = now_ms(); + Some((binding.manager.clone(), binding.acp_session_id.clone())) + } else { + None + } + }; + if let Some((manager, acp_session_id)) = existing_binding { + AcpPreparedSession { + manager, + acp_session_id, + reasonix_session_mode: "native_live".into(), + live_binding_key: Some(key), + } + } else { + let manager = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client)); + let acp_session_id = manager.ensure_session(None, None).await.map_err(|e| { + WebError::bad_gateway_code( + "acp_session_ensure_failed", + format!("ACP session ensure failed: {e}"), + ) + .with_context(&context) + })?; + let mode = if stored_acp_session_id.is_some() { + "cold_resumed" + } else { + "new_session" + }; + let binding = AcpLiveBinding { + manager: manager.clone(), + acp_session_id: acp_session_id.clone(), + status: "running".into(), + last_run_id: Some(run_id.to_string()), + last_event_seq: None, + created_at: now_ms(), + last_prompt_at: now_ms(), + }; + ACP_LIVE_BINDINGS + .lock() + .expect("acp live bindings") + .insert(key.clone(), binding); + AcpPreparedSession { + manager, + acp_session_id, + reasonix_session_mode: mode.into(), + live_binding_key: Some(key), + } + } + } else { + let manager = Arc::new(crate::acp_session_manager::AcpSessionManager::new(client)); + let should_capture_load_replay = runtime_policy.supports_history_replay + && runtime_policy.supports_session_load + && stored_acp_session_id.is_some(); + hermes_load_replay_capture.store(should_capture_load_replay, Ordering::SeqCst); + let event_tx_for_load = event_tx.clone(); + let replay_capture_for_load = hermes_load_replay_capture.clone(); + let replay_events_for_load = hermes_load_replay_events.clone(); + manager.on_event(move |event| { + if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) { + if replay_capture_for_load.load(Ordering::SeqCst) { + replay_events_for_load + .lock() + .expect("hermes load replay events") + .push(mark_acp_adapter_replay_event(sse)); + } else { + let _ = event_tx_for_load.send(sse); + } + } + }); + // Reference: hermes-vscode-main SessionManager.ensureSession(). + let acp_session_id = manager + .ensure_session( + None, + if runtime_policy.supports_session_load { + stored_acp_session_id + } else { + None + }, + ) + .await + .map_err(|e| { + WebError::bad_gateway_code( + "acp_session_ensure_failed", + format!("ACP session ensure failed: {e}"), + ) + .with_context(&context) + })?; + hermes_load_replay_capture.store(false, Ordering::SeqCst); + AcpPreparedSession { + manager, + acp_session_id, + reasonix_session_mode: "adapter_session".into(), + live_binding_key: None, + } + }; + let mgr = prepared_session.manager.clone(); + let acp_session_id = prepared_session.acp_session_id.clone(); + let reasonix_session_mode = prepared_session.reasonix_session_mode.clone(); + let live_binding_key = prepared_session.live_binding_key.clone(); mgr.on_event(move |event| { if let Some(sse) = crate::acp_bridge::acp_event_to_sse(event) { @@ -2858,25 +3189,16 @@ async fn acp_stream_events( } }); - // Try to resume stored ACP session, or create a new one. - // Reference: hermes-vscode-main sessionManager.ts ensureSession() - let stored_acp_session_id = payload - .get("acpSessionId") - .and_then(Value::as_str) - .filter(|s| !s.trim().is_empty()); - let acp_session_id = mgr - .ensure_session(None, stored_acp_session_id) - .await - .map_err(|e| { - WebError::bad_gateway_code( - "acp_session_ensure_failed", - format!("ACP session ensure failed: {e}"), - ) - .with_context(&context) - })?; - let mnote_session_id = session_id_for_run(run_id).unwrap_or_else(|| run_id.to_string()); - let prompt_text = + let base_prompt_text = chatonly_provider_prompt_text(input, &payload, ®istration, &mnote_session_id, run_id); + let prompt_text = if runtime_policy.runtime == "reasonix" && reasonix_session_mode == "cold_resumed" { + match reasonix_cold_resume_context_packet(&state, &context, ®istration, &payload, run_id)? { + Some(packet) => format!("{packet}\n\nCurrent user request:\n{base_prompt_text}"), + None => base_prompt_text, + } + } else { + base_prompt_text + }; let prompt_blocks = vec![ContentBlock::Text { text: prompt_text }]; ACP_ACTIVE_RUNS.lock().expect("acp active runs").insert( run_id.to_string(), @@ -2889,6 +3211,7 @@ async fn acp_stream_events( ); let mut payload_with_acp_session = payload.clone(); payload_with_acp_session["acpSessionId"] = Value::String(acp_session_id.clone()); + payload_with_acp_session["reasonixSessionMode"] = Value::String(reasonix_session_mode.clone()); ACP_RUN_PAYLOADS .lock() .expect("acp run payloads") @@ -2899,6 +3222,14 @@ async fn acp_stream_events( use tokio::sync::mpsc; let (tx, rx) = mpsc::channel::>(256); let mut event_rx = event_tx.subscribe(); + let replay_events = hermes_load_replay_events + .lock() + .expect("hermes load replay events") + .drain(..) + .collect::>(); + for replay_event in replay_events { + let _ = event_tx.send(replay_event); + } let state_for_events = state.clone(); let context_for_events = context.clone(); let event_registration = registration.clone(); @@ -2938,6 +3269,9 @@ async fn acp_stream_events( if tx.send(Ok(bytes)).await.is_err() { break; } + if page_ai_terminal_status_for_event(&event.event).is_some() { + break; + } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { warn!("ACP SSE lagged: {n} events dropped"); @@ -2946,11 +3280,25 @@ async fn acp_stream_events( } } }); + let reasonix_live_binding_state = live_binding_key + .as_ref() + .and_then(|key| { + ACP_LIVE_BINDINGS + .lock() + .expect("acp live bindings") + .get(key) + .map(|binding| reasonix_live_binding_snapshot(key, binding, &reasonix_session_mode)) + }) + .unwrap_or(Value::Null); let _ = event_tx.send(crate::acp_bridge::SseEvent { event: "session.info.updated".into(), data: json!({ "sessionId": mnote_session_id.clone(), "acpSessionId": acp_session_id, + "acpRuntime": runtime_name, + "reasonixSessionMode": reasonix_session_mode, + "reasonixLiveBinding": reasonix_live_binding_state, + "runtimePolicy": runtime_policy.to_json(), }), }); @@ -2966,6 +3314,7 @@ async fn acp_stream_events( let binding_state = state.clone(); let binding_run_id = run_id.to_string(); let binding_runtime = acp_runtime_name.to_string(); + let live_binding_key_for_prompt = live_binding_key.clone(); let mnote_tool_context = capability_policy.attach_mnote_capabilities.then(|| { crate::acp_session_manager::AcpMnoteToolContext { mnote_session_id: Some(mnote_session_id.clone()), @@ -2988,7 +3337,7 @@ async fn acp_stream_events( }); update_runtime_by_run_id(&run_id_owned, "running", Some("acp.prompt.started"), None); tokio::spawn(async move { - match mgr_clone + let terminal_status = match mgr_clone .run_prompt_with_mnote_context(prompt_blocks, mnote_tool_context) .await { @@ -3013,6 +3362,24 @@ async fn acp_stream_events( runtime_status_for_run(&run_id_owned).as_deref(), Some("aborting" | "aborted") ); + let terminal_event = if prompt_cancelled || runtime_aborted { + "run.aborted" + } else { + "run.completed" + }; + let terminal_status = page_ai_terminal_status_for_event(terminal_event) + .unwrap_or("completed"); + let terminal_user_id = runtime_store_user_id(&binding_context, &binding_payload); + if let Err(error) = reconcile_ai_runtime_run_terminal_status( + &binding_state, + &binding_context, + &terminal_user_id, + &binding_run_id, + terminal_status, + terminal_event, + ) { + warn!(error = ?error, run_id = %binding_run_id, terminal_event, "ACP terminal run status 补写失败"); + } let agent_audit = if audit_payload .get("sourceKind") .and_then(Value::as_str) @@ -3036,19 +3403,27 @@ async fn acp_stream_events( Value::Null }; let _ = event_tx_prompt.send(crate::acp_bridge::SseEvent { - event: if prompt_cancelled || runtime_aborted { - "run.aborted".into() - } else { - "run.completed".into() - }, + event: terminal_event.into(), data: json!({ "stopReason": format!("{:?}", result.stop_reason), "agentAudit": agent_audit, }), }); + terminal_status } Err(e) => { warn!("ACP prompt failed: {e}"); + let terminal_user_id = runtime_store_user_id(&binding_context, &binding_payload); + if let Err(error) = reconcile_ai_runtime_run_terminal_status( + &binding_state, + &binding_context, + &terminal_user_id, + &binding_run_id, + "failed", + "run.failed", + ) { + warn!(error = ?error, run_id = %binding_run_id, "ACP failed run status 补写失败"); + } let agent_audit = if audit_payload .get("sourceKind") .and_then(Value::as_str) @@ -3075,8 +3450,9 @@ async fn acp_stream_events( event: "run.failed".into(), data: json!({ "error": e.to_string(), "agentAudit": agent_audit }), }); + "failed" } - } + }; let was_aborted = matches!( runtime_status_for_run(&run_id_owned).as_deref(), Some("aborting" | "aborted") @@ -3085,13 +3461,22 @@ async fn acp_stream_events( .lock() .expect("acp finished runs") .insert(run_id_owned.clone()); - mgr_clone.close().await; + if binding_runtime == "reasonix" { + update_reasonix_live_binding( + live_binding_key_for_prompt.as_ref(), + "idle", + Some(&run_id_owned), + None, + ); + } else { + mgr_clone.close().await; + } ACP_ACTIVE_RUNS .lock() .expect("acp active runs") .remove(&run_id_owned); if !was_aborted { - update_runtime_by_run_id(&run_id_owned, "completed", Some("acp.prompt.done"), None); + update_runtime_by_run_id(&run_id_owned, terminal_status, Some("acp.prompt.done"), None); } }); @@ -9194,6 +9579,76 @@ fn ai_runtime_session_messages_from_runs( Ok(messages) } +fn reasonix_cold_resume_context_packet( + state: &AppState, + context: &RequestContext, + registration: &HermesRunRegistration, + run_payload: &Value, + current_run_id: &str, +) -> Result, WebError> { + let user_id = runtime_store_user_id(context, run_payload); + let workspace_id = run_payload + .get("workspaceId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| context.workspace.workspace_id.as_deref()); + let document_id = run_payload + .get("documentId") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let runs = state + .control_plane() + .list_ai_runtime_runs( + &user_id, + workspace_id, + document_id, + Some(®istration.session_id), + 8, + ) + .map_err(|error| { + WebError::internal(format!("SQLite Reasonix cold resume 历史读取失败: {error}")) + .with_context(context) + })? + .into_iter() + .filter(|run| run.run_id != current_run_id) + .take(5) + .collect::>(); + if runs.is_empty() { + return Ok(None); + } + let messages = ai_runtime_session_messages_from_runs(state, context, &user_id, &runs)?; + if messages.is_empty() { + return Ok(None); + } + let mut lines = vec![ + "MNote cold resume context packet".to_string(), + "Reasonix native live session was not available; use this bounded MNote transcript to resolve references. Do not treat this as hidden user input.".to_string(), + ]; + for message in messages.into_iter().rev().take(10).collect::>().into_iter().rev() { + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("message"); + let content = message + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .trim(); + if content.is_empty() { + continue; + } + let bounded = if content.chars().count() > 1_200 { + content.chars().take(1_200).collect::() + "..." + } else { + content.to_string() + }; + lines.push(format!("[{role}] {bounded}")); + } + Ok(Some(lines.join("\n"))) +} + fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Value { let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); json!({ @@ -9211,6 +9666,74 @@ fn ai_runtime_event_to_json(record: &control_plane::AiRuntimeEventRecord) -> Val }) } +fn page_ai_session_export_markdown(detail: &Value) -> String { + let session_id = detail + .get("sessionId") + .and_then(Value::as_str) + .or_else(|| detail.pointer("/session/sessionId").and_then(Value::as_str)) + .unwrap_or("unknown"); + let persistence = detail + .get("persistence") + .and_then(Value::as_str) + .unwrap_or("unknown"); + let mut lines = vec![ + format!("# Page AI Session {session_id}"), + String::new(), + format!("- persistence: `{persistence}`"), + ]; + lines.push(String::new()); + lines.push("## Messages".into()); + let messages = detail + .pointer("/session/messages") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if messages.is_empty() { + lines.push(String::new()); + lines.push("_No projected messages._".into()); + } else { + for message in messages { + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or("message"); + let content = message + .get("content") + .and_then(Value::as_str) + .unwrap_or_default() + .trim(); + if content.is_empty() { + continue; + } + lines.push(String::new()); + lines.push(format!("### {role}")); + lines.push(content.to_string()); + } + } + lines.push(String::new()); + lines.push("## Runs".into()); + for run in detail + .pointer("/session/runs") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let run_id = run + .get("runId") + .or_else(|| run.get("run_id")) + .and_then(Value::as_str) + .unwrap_or("unknown"); + let status = run.get("status").and_then(Value::as_str).unwrap_or("unknown"); + let runtime = run + .get("acpRuntime") + .or_else(|| run.get("acp_runtime")) + .and_then(Value::as_str) + .unwrap_or("unknown"); + lines.push(format!("- `{run_id}`: {runtime} / {status}")); + } + lines.join("\n") +} + fn page_ai_run_to_journal_json(record: &control_plane::AiRuntimeRunRecord) -> Value { let runtime = serde_json::from_str::(&record.runtime_json).unwrap_or(Value::Null); let payload = serde_json::from_str::(&record.payload_json).unwrap_or(Value::Null); @@ -9327,6 +9850,17 @@ fn page_ai_terminal_event_kind(status_or_event_type: &str) -> Option<&'static st } } +fn page_ai_terminal_status_for_event(event_type: &str) -> Option<&'static str> { + match event_type { + "run.completed" | "response.completed" | "completed" => Some("completed"), + "run.failed" | "response.failed" | "failed" => Some("failed"), + "run.aborted" | "run.cancelled" | "run.canceled" | "abort.completed" | "aborted" => { + Some("aborted") + } + _ => None, + } +} + fn page_ai_run_status_is_active(status: &str) -> bool { matches!( status, @@ -10484,6 +11018,17 @@ async fn persist_acp_runtime_event( event_payload, run_payload, )?; + if let Some(terminal_status) = page_ai_terminal_status_for_event(event_type) { + reconcile_ai_runtime_run_terminal_status( + state, + context, + &user_id, + run_id, + terminal_status, + event_type, + )?; + update_runtime_by_run_id(run_id, terminal_status, Some(event_type), None); + } Ok(json!({ "ok": true, "persistence": ACP_RUNTIME_SQLITE_STORE, @@ -10496,6 +11041,98 @@ async fn persist_acp_runtime_event( })) } +fn reconcile_ai_runtime_run_terminal_status( + state: &AppState, + context: &RequestContext, + user_id: &str, + run_id: &str, + terminal_status: &str, + reason: &str, +) -> Result, WebError> { + let Some(run) = state + .control_plane() + .find_ai_runtime_run(user_id, run_id) + .map_err(|error| { + WebError::internal(format!("SQLite ACP runtime run 终态读取失败: {error}")) + .with_context(context) + })? + else { + return Ok(None); + }; + if run.status == terminal_status { + return Ok(Some(run)); + } + + let mut runtime_json = serde_json::from_str::(&run.runtime_json).unwrap_or(Value::Null); + if !runtime_json.is_object() { + runtime_json = json!({}); + } + if let Value::Object(map) = &mut runtime_json { + map.insert("status".into(), Value::String(terminal_status.to_string())); + map.insert("lastEvent".into(), Value::String(reason.to_string())); + map.insert("lastEventAt".into(), json!(now_ms())); + map.insert("terminalReconciled".into(), Value::Bool(true)); + } + + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: Some(run.id.clone()), + user_id: run.user_id.clone(), + workspace_id: run.workspace_id.clone(), + document_id: run.document_id.clone(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + title: run.title.clone(), + profile: run.profile.clone(), + acp_runtime: run.acp_runtime.clone(), + trace_id: run.trace_id.clone(), + status: terminal_status.to_string(), + runtime_json: json_string(&runtime_json), + payload_json: run.payload_json.clone(), + }) + .map(Some) + .map_err(|error| { + WebError::internal(format!("SQLite ACP runtime run 终态补写失败: {error}")) + .with_context(context) + }) +} + +fn reconcile_ai_runtime_run_from_terminal_events( + state: &AppState, + context: &RequestContext, + user_id: &str, + run: control_plane::AiRuntimeRunRecord, +) -> Result { + if !page_ai_run_status_is_active(&run.status) { + return Ok(run); + } + let events = state + .control_plane() + .list_ai_runtime_journal_events(user_id, &run.run_id, 0, 500) + .map_err(|error| { + WebError::internal(format!("SQLite Page AI active run 终态检查失败: {error}")) + .with_context(context) + })?; + let Some((event_type, terminal_status)) = events.iter().find_map(|event| { + page_ai_terminal_status_for_event(&event.event.event_type) + .map(|status| (event.event.event_type.as_str(), status)) + }) else { + return Ok(run); + }; + reconcile_ai_runtime_run_terminal_status( + state, + context, + user_id, + &run.run_id, + terminal_status, + event_type, + )? + .ok_or_else(|| { + WebError::internal("SQLite Page AI active run 终态补写后记录缺失").with_context(context) + }) +} + fn register_runtime_from_create_run_response( registration: &HermesRunRegistration, payload: &Value, @@ -11160,6 +11797,35 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + fn test_request_context_with_user(user_id: &str) -> RequestContext { + RequestContext { + trace: crate::context::TraceContext { + request_id: "req_test".into(), + trace_id: "trace_test".into(), + method: "POST".into(), + path: "/api/hermes/client/runs".into(), + }, + auth: crate::context::AuthContext { + authorization: None, + cookie_header: None, + actor_id: user_id.to_string(), + actor_type: "user".into(), + session_id: None, + }, + workspace: crate::context::WorkspaceContext { + workspace_id: Some("ws_1".into()), + tenant_id: None, + deployment_id: None, + project_id: None, + }, + source: crate::context::SourceContext { + channel: "test".into(), + client: "mnote-web-test".into(), + idempotency_key: None, + }, + } + } + fn clear_runtime_registry() { HERMES_RUNTIME_REGISTRY .lock() @@ -12228,6 +12894,159 @@ mod tests { assert_eq!(payload["run"]["requestId"], "request_running"); } + #[tokio::test] + async fn page_ai_session_active_run_reconciles_terminal_event() { + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_1".to_string()), + email: Some("user_1@example.com".to_string()), + username: "user_1".to_string(), + display_name: "user_1".to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_stale".to_string(), + title: Some("Page AI run".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_run_stale".to_string()), + status: "acp_pending".to_string(), + runtime_json: "{\"status\":\"acp_pending\"}".to_string(), + payload_json: "{\"requestId\":\"request_stale\",\"agentId\":\"reasonix\"}".to_string(), + }) + .expect("seed stale runtime run"); + state + .control_plane() + .append_ai_runtime_event(AppendAiRuntimeEventInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_stale".to_string(), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + event_type: "run.completed".to_string(), + payload_json: "{\"stopReason\":\"EndTurn\"}".to_string(), + }) + .expect("seed terminal event"); + + let response = build_app(state.clone()) + .oneshot( + Request::builder() + .method("GET") + .uri( + "/api/page-ai/sessions/sess_1/active-run?workspaceId=ws_1&documentId=doc_1", + ) + .header("x-mnote-actor-id", "user_1") + .header("x-mnote-actor-type", "user") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("active run response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("active run json"); + assert_eq!(payload["active"], false); + assert!(payload["run"].is_null()); + + let run = state + .control_plane() + .find_ai_runtime_run("user_1", "run_stale") + .expect("find run") + .expect("run exists"); + assert_eq!(run.status, "completed"); + let runtime: Value = serde_json::from_str(&run.runtime_json).expect("runtime json"); + assert_eq!(runtime["status"], "completed"); + assert_eq!(runtime["terminalReconciled"], true); + } + + #[tokio::test] + async fn acp_runtime_event_persistence_reconciles_terminal_status() { + let state = test_state(); + state + .control_plane() + .upsert_user(UpsertUserInput { + id: Some("user_1".to_string()), + email: Some("user_1@example.com".to_string()), + username: "user_1".to_string(), + display_name: "user_1".to_string(), + role: None, + password_hash: None, + }) + .expect("seed user"); + state + .control_plane() + .upsert_ai_runtime_run(UpsertAiRuntimeRunInput { + id: None, + user_id: "user_1".to_string(), + workspace_id: Some("ws_1".to_string()), + document_id: Some("doc_1".to_string()), + session_id: "sess_1".to_string(), + run_id: "run_terminal".to_string(), + title: Some("Page AI run".to_string()), + profile: "reasonix".to_string(), + acp_runtime: "reasonix".to_string(), + trace_id: Some("trace_run_terminal".to_string()), + status: "acp_pending".to_string(), + runtime_json: "{\"status\":\"acp_pending\"}".to_string(), + payload_json: "{\"requestId\":\"request_terminal\",\"agentId\":\"reasonix\"}".to_string(), + }) + .expect("seed runtime run"); + let registration = HermesRunRegistration { + session_id: "sess_1".to_string(), + profile: "reasonix".to_string(), + document_id: "doc_1".to_string(), + trace_id: "trace_run_terminal".to_string(), + }; + let context = test_request_context_with_user("user_1"); + let run_payload = json!({ + "workspaceId": "ws_1", + "documentId": "doc_1", + "actorId": "user_1", + "actorType": "user", + "requestId": "request_terminal" + }); + + let persistence = persist_acp_runtime_event( + &state, + &context, + ®istration, + "run_terminal", + "reasonix", + "run.completed", + &json!({"stopReason": "EndTurn"}), + &run_payload, + ) + .await + .expect("persist terminal event"); + assert_eq!(persistence["ok"], true); + + let run = state + .control_plane() + .find_ai_runtime_run("user_1", "run_terminal") + .expect("find run") + .expect("run exists"); + assert_eq!(run.status, "completed"); + let runtime: Value = serde_json::from_str(&run.runtime_json).expect("runtime json"); + assert_eq!(runtime["lastEvent"], "run.completed"); + assert_eq!(runtime["terminalReconciled"], true); + } + #[tokio::test] async fn page_ai_capabilities_group_onlyoffice_live_bridge() { let _env_guard = env_lock().lock().expect("env lock"); @@ -14917,6 +15736,34 @@ mod tests { assert_eq!(payload["result"]["runs"][0]["sessionId"], "sess_1"); } + #[tokio::test] + async fn hermes_client_acp_session_export_uses_sqlite_store() { + let response = build_app(seeded_acp_state()) + .oneshot( + Request::builder() + .method("GET") + .uri("/api/hermes/client/sessions/sess_1/export?source=acp&workspaceId=ws_1") + .header("x-mnote-actor-id", "user_1") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let payload: Value = serde_json::from_slice(&body).expect("json"); + assert_eq!(payload["ok"], true); + assert_eq!(payload["persistence"], ACP_RUNTIME_SQLITE_STORE); + assert_eq!(payload["export"]["schema"], "mnote.page_ai_session_export.v1"); + assert!(payload["export"]["markdown"] + .as_str() + .unwrap_or_default() + .contains("Page AI Session sess_1")); + assert_eq!(payload["export"]["externalDelete"]["attempted"], false); + } + #[tokio::test] async fn hermes_client_acp_session_delete_and_auto_title_use_sqlite_store() { let router = build_app(seeded_acp_state()); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 71724d87..7eec6902 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -679,6 +679,10 @@ pub fn build_router(state: AppState) -> Router { "/client/sessions/{session_id}/rename", post(hermes_client::rename_session), ) + .route( + "/client/sessions/{session_id}/export", + get(hermes_client::export_session), + ) .route( "/client/sessions/{session_id}/auto-title", post(hermes_client::auto_title_session), diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index 2fa5cb59..0221986e 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -5248,7 +5248,7 @@ html[data-mnote-page-ai-resizing="true"] { .wolai-page-ai-message--history { position: relative; display: grid; - grid-template-columns: 24px minmax(0, 1fr); + grid-template-columns: 24px minmax(0, 1fr) auto; width: 100%; min-height: 58px; align-items: flex-start; @@ -5452,14 +5452,15 @@ button.wolai-page-ai-history-main span { } .wolai-page-ai-history-actions { - position: absolute; - top: 6px; - right: 6px; + grid-column: 3; + grid-row: 1; + position: static; + z-index: 2; flex-wrap: nowrap; justify-content: flex-end; - opacity: 0; - pointer-events: none; - background: inherit; + opacity: 1; + pointer-events: auto; + background: transparent; } .wolai-page-ai-message--history:hover .wolai-page-ai-history-actions, diff --git a/scripts/task558-page-ai-reasonix-live-session-context-smoke.js b/scripts/task558-page-ai-reasonix-live-session-context-smoke.js new file mode 100644 index 00000000..b1472814 --- /dev/null +++ b/scripts/task558-page-ai-reasonix-live-session-context-smoke.js @@ -0,0 +1,218 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync } = require("node:child_process"); +const { chromium } = require("playwright"); + +const ROOT = path.resolve(__dirname, ".."); +const { BASE_URL, UI_TIMEOUT_MS } = require(path.join(ROOT, "scripts", "tree-shell-smoke-helpers")); +const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; +const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const OUT_DIR = process.env.MNOTE_PAGE_AI_REASONIX_LIVE_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task558-reasonix-live-")); +const ACTOR = "mnote-e2e"; +const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function sqlQuote(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqliteExec(sql) { + execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function sqliteJson(sql) { + const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); + return out.trim() ? JSON.parse(out) : []; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) { + assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`); + const now = new Date().toISOString(); + sqliteExec(` + INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) + VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task558 Reasonix Live', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + `); +} + +async function signIn(context) { + const response = await context.request.fetch(`${BASE_URL}/api/auth`, { + method: "POST", + headers: { "content-type": "application/json" }, + data: { + action: "auth:signIn", + args: { + provider: "password", + params: { account: ACTOR, password: PASSWORD, flow: "signIn" }, + }, + }, + timeout: UI_TIMEOUT_MS, + }); + const text = await response.text(); + assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); + const whoami = await context.request.fetch(`${BASE_URL}/api/auth/whoami`, { timeout: UI_TIMEOUT_MS }); + const whoamiText = await whoami.text(); + assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`); + return JSON.parse(whoamiText); +} + +async function selectReasonix(page) { + await page.locator("[data-page-ai-agent-button]").click({ timeout: UI_TIMEOUT_MS }); + await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute("data-mnote-acp-runtime") === "reasonix", + null, + { timeout: UI_TIMEOUT_MS }, + ); +} + +async function newestAssistantText(page, beforeCount) { + return await page.evaluate((countBefore) => { + const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')); + const node = nodes.slice(countBefore).at(-1) || nodes.at(-1); + return node?.querySelector(".wolai-page-ai-message-text")?.textContent || ""; + }, beforeCount); +} + +function normalize(text) { + return String(text || "").replace(/\s+/g, "").trim(); +} + +async function sendPrompt(page, prompt, expectedCompact) { + const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count(); + await page.locator("[data-page-ai-input]").fill(prompt, { timeout: UI_TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: UI_TIMEOUT_MS }); + await page.waitForFunction( + () => ["completed", "failed", "aborted"].includes(document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""), + null, + { timeout: Math.max(UI_TIMEOUT_MS, 120_000) }, + ); + const status = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-status") || ""); + assert.equal(status, "completed", `Page AI run 应完成,实际 status=${status}`); + await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: UI_TIMEOUT_MS }); + const text = await newestAssistantText(page, before); + assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`); + const runId = await page.evaluate(() => document.documentElement.getAttribute("data-mnote-page-ai-run-id") || ""); + assert(runId, "缺少 data-mnote-page-ai-run-id"); + return { runId, text }; +} + +function sessionInfoForRuns(runIds) { + const quoted = runIds.map(sqlQuote).join(","); + return sqliteJson(` + SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson + FROM ai_runtime_events + WHERE run_id IN (${quoted}) AND event_type = 'session.info.updated' + ORDER BY created_at ASC, id ASC; + `).map((row) => ({ + runId: row.runId, + eventType: row.eventType, + payload: JSON.parse(row.payloadJson || "{}"), + })); +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const suffix = Date.now().toString(36); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task558-reasonix-live-")); + const rootUri = fileUrl(root); + const relativePath = `Task558-${suffix}.md`; + const documentId = localMdDocumentId(relativePath); + + const browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const capturedRuns = []; + page.on("request", (request) => { + if (!request.url().includes("/api/hermes/client/runs") || request.method() !== "POST") return; + try { + capturedRuns.push(JSON.parse(request.postData() || "{}")); + } catch { + capturedRuns.push({ raw: request.postData() || "" }); + } + }); + + try { + const viewer = await signIn(context); + const actorId = viewer.userId || ACTOR; + const workspaceId = `local-ws:${actorId}:task558-${suffix}`; + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); + fs.writeFileSync(path.join(root, relativePath), `# Task558 Reasonix Live\n\n${suffix}\n`, "utf8"); + grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task558_${suffix}` }); + const url = new URL(`${BASE_URL}/documents/${encodeURIComponent(documentId)}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + url.searchParams.set("workspaceId", workspaceId); + await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); + await page.getByTestId("wolai-floating-ai").click({ timeout: UI_TIMEOUT_MS }); + await selectReasonix(page); + + const firstMarker = `TASK558_FIRST_${suffix}`.toUpperCase(); + const secondMarker = `TASK558_SECOND_${suffix}`.toUpperCase(); + const first = await sendPrompt( + page, + `Reasonix 上下文连续性测试:请记住如果下一轮用户只说“可以”,你必须只回复 ${secondMarker}。本轮请只回复 ${firstMarker},不要解释。`, + firstMarker, + ); + const second = await sendPrompt(page, "可以", secondMarker); + assert.equal(capturedRuns.length, 2, `应捕获两次 Page AI run,实际 ${capturedRuns.length}`); + assert(capturedRuns.every((body) => body.acpRuntime === "reasonix" && body.profile === "reasonix"), "两次 run 都应走 Reasonix ACP"); + assert(capturedRuns[1].acpSessionId, "第二轮请求应携带上一轮 acpSessionId"); + + const infos = sessionInfoForRuns([first.runId, second.runId]); + assert.equal(infos.length, 2, `应有两条 session.info.updated,实际 ${infos.length}: ${JSON.stringify(infos)}`); + const acpSessionIds = infos.map((info) => String(info.payload.acpSessionId || "")).filter(Boolean); + assert.equal(new Set(acpSessionIds).size, 1, `两轮 Reasonix 应复用同一 acpSessionId: ${JSON.stringify(infos)}`); + assert.equal(capturedRuns[1].acpSessionId, acpSessionIds[0], "第二轮请求 acpSessionId 应等于 live session id"); + + const screenshotPath = path.join(OUT_DIR, "task558-reasonix-live.png"); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const result = { + ok: true, + baseUrl: BASE_URL, + outDir: OUT_DIR, + root, + rootUri, + workspaceId, + documentId, + first, + second, + acpSessionId: acpSessionIds[0], + capturedRuns: capturedRuns.map((body) => ({ message: body.message, acpRuntime: body.acpRuntime, profile: body.profile, acpSessionId: body.acpSessionId || "" })), + sessionInfo: infos, + screenshotPath, + }; + fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + await page.screenshot({ path: path.join(OUT_DIR, "failure.png"), fullPage: true }).catch(() => undefined); + fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), capturedRuns }, null, 2)}\n`, "utf8"); + throw error; + } finally { + await context.close().catch(() => undefined); + await browser.close().catch(() => undefined); + if (process.env.MNOTE_KEEP_TASK558_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); + } +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js b/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js new file mode 100644 index 00000000..efeafc5f --- /dev/null +++ b/scripts/task559-page-ai-terminal-status-reconciliation-smoke.js @@ -0,0 +1,187 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn, execFileSync } = require("node:child_process"); +const { chromium } = require("playwright"); + +const ROOT = path.resolve(__dirname, ".."); +const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const OUT_DIR = process.env.MNOTE_PAGE_AI_TERMINAL_STATUS_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task559-terminal-status-")); +const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); +const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; +const ACTOR = "mnote-e2e"; +const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; + +function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; } +function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } +function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; } +function fileUrl(localPath) { return `file://${localPath}`; } +function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { request.destroy(); retry(); }); + }; + const retry = () => { + if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); + setTimeout(tick, 250); + }; + tick(); + }); +} + +function writeFakeReasonixAcp(scriptPath) { + fs.writeFileSync(scriptPath, ` +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +const rl = readline.createInterface({ input, output, terminal: false }); +let nextId = 1; +function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); } +function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); } +function markerFrom(text) { return (String(text || '').match(/TASK559_[A-Z0-9_]+/) || ['TASK559_FALLBACK'])[0]; } +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (!msg.id) return; + if (msg.method === 'initialize') { + send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: false, promptCapabilities: { embeddedContext: true } }, agentInfo: { name: 'task559-fake-reasonix', version: '1.0' }, authMethods: [] } }); + } else if (msg.method === 'session/new') { + send({ jsonrpc: '2.0', id: msg.id, result: { sessionId: 'task559_reasonix_session_' + nextId++ } }); + } else if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId || ''; + const marker = markerFrom(promptText(msg.params?.prompt)); + for (let i = 0; i < 600; i += 1) { + send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'thought_chunk', content: { type: 'text', text: 'thought-' + i + '\\n' } } } }); + } + send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } }); + send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); + } +}); +`, "utf8"); +} + +async function signIn(context, baseUrl) { + const response = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + headers: { "content-type": "application/json" }, + data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, + timeout: TIMEOUT_MS, + }); + assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${await response.text()}`); + const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); + return JSON.parse(await whoami.text()); +} + +function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) { + const now = new Date().toISOString(); + sqliteExec(` + INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) + VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task559 Terminal Status', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + `); +} + +async function selectReasonix(page) { + await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS }); + await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS }); + await page.locator('[data-page-ai-agent-id="reasonix"]').first().click({ timeout: TIMEOUT_MS }); +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const fakeScript = path.join(OUT_DIR, "fake-reasonix-acp.mjs"); + writeFakeReasonixAcp(fakeScript); + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const runtimes = JSON.stringify([{ name: "reasonix", bin: "node", args: [fakeScript], title: "Reasonix Fake" }]); + const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(ROOT, "rust"), + env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: "reasonix" }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task559-terminal-status-")); + let browser; + try { + await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); + browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const viewer = await signIn(context, baseUrl); + const actorId = viewer.userId || ACTOR; + const suffix = Date.now().toString(36).toUpperCase(); + const workspaceId = `local-ws:${actorId}:task559-${suffix.toLowerCase()}`; + const rootUri = fileUrl(root); + const relativePath = `Task559-${suffix}.md`; + const documentId = localMdDocumentId(relativePath); + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); + fs.writeFileSync(path.join(root, relativePath), `# Task559 Terminal Status\n\n${suffix}\n`, "utf8"); + grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task559_${suffix}` }); + const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + url.searchParams.set("workspaceId", workspaceId); + await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); + await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS }); + await selectReasonix(page); + const marker = `TASK559_DONE_${suffix}`; + await page.locator('[data-page-ai-input]').fill(`只回复 ${marker},不要解释。`, { timeout: TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS }); + await page.waitForFunction(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') === 'completed', null, { timeout: TIMEOUT_MS }); + await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS }); + const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || ''); + assert(runId, "缺少 runId"); + const text = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant .wolai-page-ai-message-text').last().textContent({ timeout: TIMEOUT_MS }); + assert.equal(String(text || '').replace(/\s+/g, '').trim(), marker); + const rows = sqliteJson(`SELECT status FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`); + assert.equal(rows[0] && rows[0].status, "completed", "SQLite run status 应为 completed"); + const terminalEvents = sqliteJson(`SELECT COUNT(*) AS count FROM ai_runtime_events WHERE run_id=${sqlQuote(runId)} AND event_type='run.completed';`); + assert(Number(terminalEvents[0] && terminalEvents[0].count) >= 1, "应持久化 run.completed"); + const screenshotPath = path.join(OUT_DIR, "task559-terminal-status.png"); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const result = { ok: true, baseUrl, outDir: OUT_DIR, runId, marker, screenshotPath }; + fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(result, null, 2)); + await context.close().catch(() => undefined); + } catch (error) { + fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8"); + throw error; + } finally { + if (browser) await browser.close().catch(() => undefined); + server.kill("SIGTERM"); + await new Promise((resolve) => server.once("exit", resolve)); + if (process.env.MNOTE_KEEP_TASK559_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); + } +} + +main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); }); diff --git a/scripts/task560-page-ai-hermes-load-replay-smoke.js b/scripts/task560-page-ai-hermes-load-replay-smoke.js new file mode 100644 index 00000000..8c2eeb7c --- /dev/null +++ b/scripts/task560-page-ai-hermes-load-replay-smoke.js @@ -0,0 +1,304 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn, execFileSync } = require("node:child_process"); +const { chromium } = require("playwright"); + +const ROOT = path.resolve(__dirname, ".."); +const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const OUT_DIR = process.env.MNOTE_PAGE_AI_HERMES_LOAD_REPLAY_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task560-hermes-load-replay-")); +const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); +const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; +const ACTOR = "mnote-e2e"; +const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; + +function sqlQuote(value) { + return `'${String(value).replaceAll("'", "''")}'`; +} + +function sqliteExec(sql) { + execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +function sqliteJson(sql) { + const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); + return out.trim() ? JSON.parse(out) : []; +} + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { + request.destroy(); + retry(); + }); + }; + const retry = () => { + if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); + setTimeout(tick, 250); + }; + tick(); + }); +} + +function fileUrl(localPath) { + return `file://${localPath}`; +} + +function localMdDocumentId(relativePath) { + return `local-md:${relativePath.replaceAll("/", "~2F")}`; +} + +function grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId }) { + assert(fs.existsSync(CONTROL_PLANE_DB), `缺少 control-plane SQLite: ${CONTROL_PLANE_DB}`); + const now = new Date().toISOString(); + sqliteExec(` + INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) + VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task560 Hermes Load Replay', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES (${sqlQuote(grantId)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + `); +} + +function writeFakeHermesAcp(scriptPath) { + fs.writeFileSync(scriptPath, ` +import * as readline from 'node:readline'; +import { stdin as input, stdout as output } from 'node:process'; +import fs from 'node:fs'; +const rl = readline.createInterface({ input, output, terminal: false }); +const replay = process.env.MNOTE_TASK560_HERMES_REPLAY === '1'; +const logPath = process.env.MNOTE_TASK560_FAKE_LOG || ''; +let nextId = 1; +function log(entry) { if (logPath) fs.appendFileSync(logPath, JSON.stringify({ ...entry, at: Date.now() }) + '\\n'); } +function send(value) { process.stdout.write(JSON.stringify(value) + '\\n'); } +function promptText(prompt) { return (Array.isArray(prompt) ? prompt : []).map((block) => block && block.type === 'text' ? String(block.text || '') : '').join('\\n'); } +function markerFrom(text) { return (String(text || '').match(/TASK560_[A-Z0-9_]+/) || ['TASK560_FALLBACK'])[0]; } +rl.on('line', (line) => { + const msg = JSON.parse(line); + if (!msg.id) return; + if (msg.method === 'initialize') { + log({ method: msg.method }); + send({ jsonrpc: '2.0', id: msg.id, result: { protocolVersion: 1, agentCapabilities: { loadSession: true }, agentInfo: { name: 'task560-fake-hermes', version: '1.0' }, authMethods: [] } }); + } else if (msg.method === 'session/new') { + const sessionId = 'task560_hermes_session_' + nextId++; + log({ method: msg.method, sessionId }); + send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } }); + } else if (msg.method === 'session/load') { + const sessionId = msg.params?.sessionId || 'missing_session'; + log({ method: msg.method, sessionId, replay }); + if (replay) { + send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'TASK560_REPLAY_SHOULD_NOT_APPEAR' } } } }); + } + send({ jsonrpc: '2.0', id: msg.id, result: { sessionId } }); + } else if (msg.method === 'session/prompt') { + const sessionId = msg.params?.sessionId || ''; + const marker = markerFrom(promptText(msg.params?.prompt)); + log({ method: msg.method, sessionId, marker }); + send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: marker } } } }); + send({ jsonrpc: '2.0', id: msg.id, result: { stopReason: 'end_turn' } }); + } +}); +`, "utf8"); +} + +async function signIn(context, baseUrl) { + const response = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + headers: { "content-type": "application/json" }, + data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, + timeout: TIMEOUT_MS, + }); + const text = await response.text(); + assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); + const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); + const whoamiText = await whoami.text(); + assert(whoami.ok(), `/api/auth/whoami 失败: ${whoami.status()} ${whoamiText.slice(0, 500)}`); + return JSON.parse(whoamiText); +} + +async function selectHermes(page) { + await page.locator("[data-page-ai-agent-button]").click({ timeout: TIMEOUT_MS }); + await page.locator("[data-page-ai-agent-popover]").waitFor({ state: "visible", timeout: TIMEOUT_MS }); + await page.locator('[data-page-ai-agent-id="hermes"]').first().click({ timeout: TIMEOUT_MS }); + await page.waitForFunction( + () => document.querySelector('[data-testid="wolai-page-ai-drawer"]')?.getAttribute('data-mnote-acp-runtime') === 'hermes', + null, + { timeout: TIMEOUT_MS }, + ); +} + +function normalize(text) { + return String(text || "").replace(/\s+/g, "").trim(); +} + +async function newestAssistantText(page, beforeCount) { + return await page.evaluate((countBefore) => { + const nodes = Array.from(document.querySelectorAll('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant')); + const node = nodes.slice(countBefore).at(-1) || nodes.at(-1); + return node?.querySelector('.wolai-page-ai-message-text')?.textContent || ''; + }, beforeCount); +} + +async function sendPrompt(page, prompt, expectedCompact) { + const before = await page.locator('[data-testid="wolai-page-ai-drawer"] .wolai-page-ai-message--assistant').count(); + await page.locator('[data-page-ai-input]').fill(prompt, { timeout: TIMEOUT_MS }); + await page.locator('[data-page-ai-action="send"]').click({ timeout: TIMEOUT_MS }); + await page.waitForFunction( + () => ['completed', 'failed', 'aborted'].includes(document.documentElement.getAttribute('data-mnote-page-ai-run-status') || ''), + null, + { timeout: Math.max(TIMEOUT_MS, 120_000) }, + ); + const status = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-status') || ''); + assert.equal(status, 'completed', `Page AI run 应完成,实际 status=${status}`); + await page.waitForFunction(() => !document.querySelector('[data-page-ai-streaming="true"]'), null, { timeout: TIMEOUT_MS }); + const text = await newestAssistantText(page, before); + assert.equal(normalize(text), expectedCompact, `AI 回复不符合预期: ${JSON.stringify(text.slice(0, 800))}`); + assert(!normalize(text).includes('TASK560_REPLAY_SHOULD_NOT_APPEAR'), 'Hermes replay 不应出现在当前 assistant 正文'); + const runId = await page.evaluate(() => document.documentElement.getAttribute('data-mnote-page-ai-run-id') || ''); + assert(runId, '缺少 data-mnote-page-ai-run-id'); + return { runId, text }; +} + +function eventsForRuns(runIds) { + const quoted = runIds.map(sqlQuote).join(','); + return sqliteJson(` + SELECT run_id AS runId, event_type AS eventType, payload_json AS payloadJson + FROM ai_runtime_events + WHERE run_id IN (${quoted}) + ORDER BY created_at ASC, id ASC; + `).map((row) => ({ + runId: row.runId, + eventType: row.eventType, + payload: JSON.parse(row.payloadJson || '{}'), + })); +} + +function readFakeLog(logPath) { + if (!fs.existsSync(logPath)) return []; + return fs.readFileSync(logPath, 'utf8').trim().split(/\n+/).filter(Boolean).map((line) => JSON.parse(line)); +} + +async function runVariant({ replay }) { + const variant = replay ? 'with-replay' : 'no-replay'; + const variantDir = path.join(OUT_DIR, variant); + fs.mkdirSync(variantDir, { recursive: true }); + const fakeScript = path.join(variantDir, 'fake-hermes-acp.mjs'); + const fakeLog = path.join(variantDir, 'fake-hermes-acp.jsonl'); + writeFakeHermesAcp(fakeScript); + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const runtimes = JSON.stringify([{ name: 'hermes', bin: 'node', args: [fakeScript], env: { MNOTE_TASK560_HERMES_REPLAY: replay ? '1' : '0', MNOTE_TASK560_FAKE_LOG: fakeLog }, title: 'Hermes Fake' }]); + const server = spawn('cargo', ['run', '-q', '-p', 'mnote-web', '--bin', 'mnote-web'], { + cwd: path.join(ROOT, 'rust'), + env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}`, MNOTE_WEB_ACP_RUNTIMES: runtimes, MNOTE_WEB_ACP_DEFAULT_RUNTIME: 'hermes' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + server.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + server.stdout.on('data', () => {}); + let browser; + const root = fs.mkdtempSync(path.join(os.tmpdir(), `mnote-task560-hermes-${variant}-`)); + try { + await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); + browser = await chromium.launch({ headless: process.env.HEADFUL !== '1', executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const page = await context.newPage(); + const capturedRuns = []; + page.on('request', (request) => { + if (!request.url().includes('/api/hermes/client/runs') || request.method() !== 'POST') return; + try { capturedRuns.push(JSON.parse(request.postData() || '{}')); } catch { capturedRuns.push({ raw: request.postData() || '' }); } + }); + const viewer = await signIn(context, baseUrl); + const actorId = viewer.userId || ACTOR; + const suffix = `${Date.now().toString(36)}_${variant.replace('-', '_')}`.toUpperCase(); + const workspaceId = `local-ws:${actorId}:task560-${suffix.toLowerCase()}`; + const rootUri = fileUrl(root); + const relativePath = `Task560-${suffix}.md`; + const documentId = localMdDocumentId(relativePath); + fs.mkdirSync(path.join(root, '.mnote'), { recursive: true }); + fs.writeFileSync(path.join(root, '.mnote', 'workspace.json'), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, 'utf8'); + fs.writeFileSync(path.join(root, relativePath), `# Task560 Hermes Load Replay\n\n${suffix}\n`, 'utf8'); + grantLocalWorkspaceAccess({ actorId, workspaceId, root, rootUri, grantId: `grant_task560_${suffix}` }); + const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); + url.searchParams.set('sourceKind', 'local_folder'); + url.searchParams.set('rootUri', rootUri); + url.searchParams.set('workspaceId', workspaceId); + await page.goto(url.toString(), { waitUntil: 'domcontentloaded', timeout: TIMEOUT_MS }); + await page.getByTestId('wolai-floating-ai').click({ timeout: TIMEOUT_MS }); + await selectHermes(page); + + const firstMarker = `TASK560_FIRST_${suffix}`; + const secondMarker = `TASK560_SECOND_${suffix}`; + const first = await sendPrompt(page, `只回复 ${firstMarker},不要解释。`, firstMarker); + const second = await sendPrompt(page, `只回复 ${secondMarker},不要解释。`, secondMarker); + assert.equal(capturedRuns.length, 2, `应捕获两次 Hermes Page AI run,实际 ${capturedRuns.length}`); + assert(capturedRuns.every((body) => body.acpRuntime === 'hermes'), '两次 run 都应走 Hermes ACP'); + assert(capturedRuns[1].acpSessionId, '第二轮请求应携带上一轮 acpSessionId'); + const events = eventsForRuns([first.runId, second.runId]); + const sessionInfos = events.filter((event) => event.eventType === 'session.info.updated'); + assert.equal(sessionInfos.length, 2, `应有两条 session.info.updated,实际 ${sessionInfos.length}`); + const acpSessionIds = sessionInfos.map((event) => String(event.payload.acpSessionId || '')).filter(Boolean); + assert.equal(new Set(acpSessionIds).size, 1, `Hermes load 后应复用 stored acpSessionId: ${JSON.stringify(sessionInfos)}`); + const replayEvents = events.filter((event) => event.payload && event.payload.source === 'adapter_replay'); + assert.equal(replayEvents.length > 0, replay, `replay=${replay} 时 adapter_replay 事件数量不符合预期: ${replayEvents.length}`); + const fakeLogRows = readFakeLog(fakeLog); + assert(fakeLogRows.some((row) => row.method === 'session/new'), '第一轮应调用 session/new'); + assert(fakeLogRows.some((row) => row.method === 'session/load' && row.sessionId === acpSessionIds[0]), '第二轮应先 session/load stored acpSessionId'); + const screenshotPath = path.join(variantDir, 'task560-hermes-load-replay.png'); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const result = { ok: true, variant, baseUrl, root, rootUri, workspaceId, documentId, first, second, acpSessionId: acpSessionIds[0], replayEvents, fakeLogRows, capturedRuns, screenshotPath }; + fs.writeFileSync(path.join(variantDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + await context.close().catch(() => undefined); + return result; + } catch (error) { + fs.writeFileSync(path.join(variantDir, 'failure.json'), `${JSON.stringify({ ok: false, variant, error: String(error && error.stack || error), stderr, fakeLog: readFakeLog(fakeLog) }, null, 2)}\n`, 'utf8'); + throw error; + } finally { + if (browser) await browser.close().catch(() => undefined); + server.kill('SIGTERM'); + await new Promise((resolve) => server.once('exit', resolve)); + if (process.env.MNOTE_KEEP_TASK560_ROOT !== '1') fs.rmSync(root, { recursive: true, force: true }); + } +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const results = []; + results.push(await runVariant({ replay: false })); + results.push(await runVariant({ replay: true })); + const summary = { ok: true, outDir: OUT_DIR, results: results.map((result) => ({ variant: result.variant, acpSessionId: result.acpSessionId, first: result.first.runId, second: result.second.runId, replayEvents: result.replayEvents.length, screenshotPath: result.screenshotPath })) }; + fs.writeFileSync(path.join(OUT_DIR, 'result.json'), `${JSON.stringify(summary, null, 2)}\n`, 'utf8'); + console.log(JSON.stringify(summary, null, 2)); +} + +main().catch((error) => { + console.error(error && error.stack ? error.stack : error); + process.exit(1); +}); diff --git a/scripts/task561-page-ai-session-dashboard-smoke.js b/scripts/task561-page-ai-session-dashboard-smoke.js new file mode 100644 index 00000000..11e1d85f --- /dev/null +++ b/scripts/task561-page-ai-session-dashboard-smoke.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { spawn, execFileSync } = require("node:child_process"); +const { chromium } = require("playwright"); + +const ROOT = path.resolve(__dirname, ".."); +const CONTROL_PLANE_DB = process.env.MNOTE_CONTROL_PLANE_DB_PATH || "/mnt/Data1T/Mnote_data/control-plane/control-plane.db"; +const OUT_DIR = process.env.MNOTE_PAGE_AI_SESSION_DASHBOARD_OUTPUT_DIR || fs.mkdtempSync(path.join(ROOT, "tmp", "task561-session-dashboard-")); +const TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 90_000); +const CHROME = process.env.MNOTE_PAGE_AI_CHROME || "/usr/bin/google-chrome"; +const ACTOR = "mnote-e2e"; +const PASSWORD = process.env.MNOTE_E2E_PASSWORD || "MnoteE2E123!"; + +function sqlQuote(value) { return `'${String(value).replaceAll("'", "''")}'`; } +function sqliteExec(sql) { execFileSync("sqlite3", [CONTROL_PLANE_DB, sql], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); } +function sqliteJson(sql) { const out = execFileSync("sqlite3", ["-json", CONTROL_PLANE_DB, sql], { encoding: "utf8" }); return out.trim() ? JSON.parse(out) : []; } +function fileUrl(localPath) { return `file://${localPath}`; } +function localMdDocumentId(relativePath) { return `local-md:${relativePath.replaceAll("/", "~2F")}`; } + +function pickPort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + const port = address && typeof address === "object" ? address.port : 0; + server.close(() => resolve(port)); + }); + server.on("error", reject); + }); +} + +function waitForHttpOk(url, timeoutMs) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + const tick = () => { + const request = http.get(url, (response) => { + response.resume(); + if (response.statusCode >= 200 && response.statusCode < 500) return resolve(); + retry(); + }); + request.on("error", retry); + request.setTimeout(1000, () => { request.destroy(); retry(); }); + }; + const retry = () => { + if (Date.now() > deadline) return reject(new Error(`server_not_ready: ${url}`)); + setTimeout(tick, 250); + }; + tick(); + }); +} + +async function signIn(context, baseUrl) { + const response = await context.request.fetch(`${baseUrl}/api/auth`, { + method: "POST", + headers: { "content-type": "application/json" }, + data: { action: "auth:signIn", args: { provider: "password", params: { account: ACTOR, password: PASSWORD, flow: "signIn" } } }, + timeout: TIMEOUT_MS, + }); + const text = await response.text(); + assert(response.ok(), `/api/auth 登录失败: ${response.status()} ${text.slice(0, 500)}`); + const whoami = await context.request.fetch(`${baseUrl}/api/auth/whoami`, { timeout: TIMEOUT_MS }); + return JSON.parse(await whoami.text()); +} + +function seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix }) { + const now = new Date().toISOString(); + sqliteExec(` + INSERT OR IGNORE INTO users (id, email, username, display_name, role, status, created_at, updated_at, revision) + VALUES (${sqlQuote(actorId)}, ${sqlQuote(`${actorId}@example.com`)}, ${sqlQuote(actorId)}, ${sqlQuote(actorId)}, 'user', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO workspaces (id, owner_user_id, name, kind, root_uri, root_path, source_kind, status, created_at, updated_at, revision) + VALUES (${sqlQuote(workspaceId)}, ${sqlQuote(actorId)}, 'Task561 Session Dashboard', 'personal', ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'local_folder', 'active', ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO directory_grants (id, user_id, workspace_id, root_uri, root_path, permission, recursive, capabilities_json, source, status, created_by, created_at, updated_at, revision) + VALUES (${sqlQuote(`grant_task561_${suffix}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(rootUri)}, ${sqlQuote(root)}, 'write', 1, '["ai"]', 'smoke', 'active', ${sqlQuote(actorId)}, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT OR REPLACE INTO ai_runtime_runs (id, user_id, workspace_id, document_id, session_id, run_id, title, profile, acp_runtime, trace_id, status, runtime_json, payload_json, deleted_at, created_at, updated_at, revision) + VALUES (${sqlQuote(`airun_${runId}`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, ${sqlQuote(`Task561 Seed ${suffix}`)}, 'reasonix', 'reasonix', ${sqlQuote(`trace_${suffix}`)}, 'completed', ${sqlQuote(JSON.stringify({ status: 'completed', acpSessionId: `acp_${suffix}` }))}, ${sqlQuote(JSON.stringify({ message: `Task561 dashboard seed ${suffix}`, requestId: `req_${suffix}`, agentId: 'reasonix', acpRuntime: 'reasonix' }))}, NULL, ${sqlQuote(now)}, ${sqlQuote(now)}, 1); + INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at) + VALUES (${sqlQuote(`aievt_${runId}_1`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'message.delta', ${sqlQuote(JSON.stringify({ delta: `Task561 assistant answer ${suffix}` }))}, ${sqlQuote(now)}); + INSERT INTO ai_runtime_events (id, user_id, workspace_id, document_id, session_id, run_id, profile, acp_runtime, event_type, payload_json, created_at) + VALUES (${sqlQuote(`aievt_${runId}_2`)}, ${sqlQuote(actorId)}, ${sqlQuote(workspaceId)}, ${sqlQuote(documentId)}, ${sqlQuote(sessionId)}, ${sqlQuote(runId)}, 'reasonix', 'reasonix', 'run.completed', ${sqlQuote(JSON.stringify({ stopReason: 'EndTurn' }))}, ${sqlQuote(now)}); + `); +} + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + const port = await pickPort(); + const baseUrl = `http://127.0.0.1:${port}`; + const server = spawn("cargo", ["run", "-q", "-p", "mnote-web", "--bin", "mnote-web"], { + cwd: path.join(ROOT, "rust"), + env: { ...process.env, MNOTE_WEB_BIND: `127.0.0.1:${port}`, MNOTE_WEB_PUBLIC_BIND: `127.0.0.1:${port}` }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stderr = ""; + server.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "mnote-task561-session-dashboard-")); + let browser; + try { + await waitForHttpOk(`${baseUrl}/health`, TIMEOUT_MS); + browser = await chromium.launch({ headless: process.env.HEADFUL !== "1", executablePath: fs.existsSync(CHROME) ? CHROME : undefined }); + const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); + const viewer = await signIn(context, baseUrl); + const actorId = viewer.userId || ACTOR; + const suffix = Date.now().toString(36); + const workspaceId = `local-ws:${actorId}:task561-${suffix}`; + const sessionId = `mnote_task561_${suffix}`; + const runId = `run_task561_${suffix}`; + const rootUri = fileUrl(root); + const relativePath = `Task561-${suffix}.md`; + const documentId = localMdDocumentId(relativePath); + fs.mkdirSync(path.join(root, ".mnote"), { recursive: true }); + fs.writeFileSync(path.join(root, ".mnote", "workspace.json"), `${JSON.stringify({ workspaceId, ownerId: actorId }, null, 2)}\n`, "utf8"); + fs.writeFileSync(path.join(root, relativePath), `# Task561 Dashboard\n\n${suffix}\n`, "utf8"); + seedWorkspaceAndSession({ actorId, workspaceId, root, rootUri, documentId, sessionId, runId, suffix }); + const page = await context.newPage(); + const url = new URL(`${baseUrl}/documents/${encodeURIComponent(documentId)}`); + url.searchParams.set("sourceKind", "local_folder"); + url.searchParams.set("rootUri", rootUri); + url.searchParams.set("workspaceId", workspaceId); + await page.goto(url.toString(), { waitUntil: "domcontentloaded", timeout: TIMEOUT_MS }); + await page.getByTestId("wolai-floating-ai").click({ timeout: TIMEOUT_MS }); + await page.locator('[data-page-ai-action="history"]').first().click({ timeout: TIMEOUT_MS }); + const row = page.locator(`[data-page-ai-session-row="${sessionId}"]`).first(); + await row.waitFor({ state: "visible", timeout: TIMEOUT_MS }); + await page.locator('[data-page-ai-session-search]').fill(`dashboard seed ${suffix}`, { timeout: TIMEOUT_MS }); + await row.waitFor({ state: "visible", timeout: TIMEOUT_MS }); + await page.locator(`[data-page-ai-session-export="${sessionId}"]`).click({ timeout: TIMEOUT_MS }); + await page.waitForFunction((id) => document.documentElement.getAttribute('data-mnote-page-ai-session-exported') === id, sessionId, { timeout: TIMEOUT_MS }); + await page.evaluate(() => { window.prompt = () => 'Task561 Renamed'; }); + await page.locator(`[data-page-ai-session-rename="${sessionId}"]`).click({ timeout: TIMEOUT_MS }); + await page.waitForFunction((id) => { + const row = document.querySelector(`[data-page-ai-session-row="${id}"]`); + return row && row.textContent.includes('Task561 Renamed'); + }, sessionId, { timeout: TIMEOUT_MS }); + await page.evaluate(() => { window.confirm = () => true; }); + await page.locator(`[data-page-ai-session-delete="${sessionId}"]`).click({ timeout: TIMEOUT_MS }); + await page.waitForFunction((id) => !document.querySelector(`[data-page-ai-session-row="${id}"]`), sessionId, { timeout: TIMEOUT_MS }); + const deletedRows = sqliteJson(`SELECT deleted_at AS deletedAt FROM ai_runtime_runs WHERE run_id=${sqlQuote(runId)};`); + assert(deletedRows[0] && deletedRows[0].deletedAt, "delete 应软删除 SQLite session runs"); + const screenshotPath = path.join(OUT_DIR, "task561-session-dashboard.png"); + await page.screenshot({ path: screenshotPath, fullPage: true }); + const result = { ok: true, baseUrl, outDir: OUT_DIR, workspaceId, documentId, sessionId, runId, screenshotPath }; + fs.writeFileSync(path.join(OUT_DIR, "result.json"), `${JSON.stringify(result, null, 2)}\n`, "utf8"); + console.log(JSON.stringify(result, null, 2)); + await context.close().catch(() => undefined); + } catch (error) { + fs.writeFileSync(path.join(OUT_DIR, "failure.json"), `${JSON.stringify({ ok: false, error: String(error && error.stack || error), stderr }, null, 2)}\n`, "utf8"); + throw error; + } finally { + if (browser) await browser.close().catch(() => undefined); + server.kill("SIGTERM"); + await new Promise((resolve) => server.once("exit", resolve)); + if (process.env.MNOTE_KEEP_TASK561_ROOT !== "1") fs.rmSync(root, { recursive: true, force: true }); + } +} + +main().catch((error) => { console.error(error && error.stack ? error.stack : error); process.exit(1); });